From 49c2f3eb616fe6c350cca2fa13f4de01e5e3fb17 Mon Sep 17 00:00:00 2001 From: Clint Beacock Date: Tue, 12 May 2026 17:13:16 -0400 Subject: [PATCH 01/19] Offline RetroAchievements Support (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix badge download cap at 256 preventing large achievement sets from fully downloading * Add offline achievements development plan * Update dev working file * WIP: Initial working-ish commit * WIP: Sync generally working, but still some issues with a potentially stale ledger * Fix order-dependent SYNC_ACK matching and production hardening for offline RA ledgerGetPendingUnlocks and ledgerCompact used naive matching that cancelled ALL UNLOCKs with a matching SYNC_ACK achievement ID regardless of order, causing re-unlocks after a sync to be silently dropped. Both now use order-aware matching: a SYNC_ACK only cancels the earliest preceding unmatched UNLOCK, preserving later re-unlocks as pending. Also includes Phase 6 production hardening: - Ledger compaction after successful sync (removes acked pairs) - Resilient chain validation (no longer truncates at first break) - Diagnostic logging removed, verbose logs downgraded to DEBUG - API token sanitized in HTTP error logs * Add connectivity state machine for offline-first RA startup (Phase 7) Replace blocking WiFi wait with async background probe. When a cached login exists, start in offline mode immediately and probe the RA server every 30s in the background. On success, seamlessly transition to online mode with deferred notification, sync, and hardcore re-enable on the main thread. Fall back to current blocking behavior only when no cache exists. Wire up DISCONNECTED/RECONNECTED events to flip offline state, manage the probe lifecycle, and always track pending unlocks regardless of online/offline mode. * Fix offline-to-online transition reliability and reduce notification noise Process deferred connectivity flags (online/offline notifications, sync, hardcore re-enable, login retry) during gameplay via periodic 500ms check in RA_doFrame(), instead of only when the menu is opened in RA_idle(). Prevent false RECONNECTED events by returning retryable errors for non-cacheable requests (awardachievement, ping) in offline mode instead of synthetic success responses. Suppress transient offline/connected notification spam at startup when WiFi connects quickly. Add sync retry on failure via connectivity probe re-trigger. * Clean up RA integration: reduce log noise, remove dead variables, add safety comment Downgrade per-response queue log from INFO to DEBUG and rcheevos internal logging from INFO to WARN. Remove unused variables (ra_probe_thread, ra_sync_needs_retry). Strip redundant "Warning:" prefix from WARN-level messages. Add comment explaining why the direct callback in the offline non-cacheable path is thread-safe. * Fix notification thread safety and system indicator width on Brick Add progress_mutex to protect progress_state, which is written by background threads (sync engine, badge downloads) and read by the main thread during rendering. Writers lock around all updates; the render path snapshots progress_state under lock then renders from the local copy to keep critical sections sub-microsecond. Fix system indicator pill width using hardcoded 10 (internal pill padding) instead of the PADDING macro, matching the formula in GFX_blitHardwareIndicator. On Brick where PADDING=5, the old formula allocated a surface too narrow, clipping the pill on the right edge. * Remove planning doc * Fix RA offline thread safety: add ledger mutex, reorder shutdown, validate sync responses * Remove RA offline/connected status notifications Suppress the 4 notifications that alert users to RetroAchievement connectivity state changes (offline mode, reconnected, connected). These transitions are handled transparently with automatic syncing, so surfacing them adds noise without actionable information. * Fix offline achievement sync not updating count or list until second restart Remove ra_offline_mode guard from startsession patching to avoid race with connectivity probe. Re-apply pending offline unlocks after hardcore re-enable (both deferred and reconnect paths) since softcore-only bits get cleared. Add deferred sync-apply so the sync thread's confirmed unlocks update rcheevos state without requiring a restart. Augment game-load notification count with pending offline unlocks. * Refactor ra_offline.c: extract SHA-256, fix hardcore filter, deduplicate ledger logic - Extract SHA-256 to common/sha256.c + sha256.h for reuse - Fix hardcore filter asymmetry: reject hardcore unlocks at write time and filter during compaction to prevent immortal unsynced records - Extract ledger_record_init() helper to deduplicate ledgerWrite* boilerplate - Fix thread-safety: replace static buffer in get_request_type with caller-provided buffer - Replace prev_hash[0] scratch flag with dedicated bool* cancelled array - Remove redundant double ledger read in patchStartsessionResponse - Consolidate 9 identical passthrough blocks into goto passthrough - Extract shared ledger_read_pending_records() to unify SYNC_ACK cancellation logic between ledgerCompact and ledgerGetPendingUnlocks * Fix race: defer offline sync until game is loaded The probe thread's online transition triggered sync before rcheevos finished loading the game. The sync thread would compact the ledger before startsession patching or ra_reapply_pending_unlocks could use it, causing the just-synced achievement to appear locked until the next restart. Defer both sync start and sync_apply processing until ra_game_loaded is true. * Fix online unlocks showing as offline-pending after mid-game sync When an achievement was unlocked while online, the event handler added it to the pending cache (write-ahead for crash safety) but the HTTP callback only wrote a SYNC_ACK to the ledger — it never removed the entry from the in-memory pending cache. The UI kept showing the [O] offline indicator for server-confirmed unlocks. Remove the pending cache entry when awardachievement succeeds. * Add RA offline sync engine and Settings sync button - Add common/md5.c+h: standalone MD5 with nui_ prefix to avoid LTO symbol collision with rcheevos' internal rhash/md5.c - Add common/ra_sync.c+h: shared offline sync engine used by both settings and minarch; supports game_id filtering, configurable delays, MD5-signed award requests, SYNC_ACK ledger writes, and startsession cache invalidation after successful sync - Add "Sync Offline Unlocks" button to settings with B-button cancel, progress overlay, and RA_SYNC_CONFIG_INTERACTIVE timing - Refactor minarch ra_integration.c to use shared sync engine instead of inline rc_api award calls; per-game sync on game load - Fix overlay text ghosting in settings showOverlay force-draw path - Fix stale startsession cache causing synced achievements to show as locked on first offline-first game launch after a settings sync * Fix stale startsession cache after online achievement unlock sync * Show pending offline unlock count in Settings sync button description * fix: patch startsession cache in-place instead of deleting it on online award When an achievement is confirmed online (either during gameplay or via settings sync), the cached startsession file is now surgically updated to include the new unlock rather than being deleted entirely. This prevents the next offline-first load from seeing 0/X unlocks and re-triggering all past achievements as new offline unlocks. * perf(ra): fix main-thread stalls on achievement unlock - Move ledger fsync off main thread via async write queue - Pre-decode badge surfaces on HTTP worker thread at download time - Replace O(N) response queue shift loop with O(1) ring buffer - Fix use-after-free in progress indicator icon * Replace [O] text tag with wifi-off icon for offline achievement indicators * feat(ra): replace text indicators with sprite icons in achievement UI Replace [M] mute and [O] offline text tags with ASSET_VOLUME_MUTE and ASSET_WIFI_OFF sprite icons in achievement list rows and the detail page. Mute icon is src_rect-cropped to 12px to match text height. List X button hint now toggles MUTE/UNMUTE based on selected item. * Fix sync notification showing total pending count instead of game-filtered count When launching a game with offline achievements pending across multiple games, the initial "Syncing N offline achievements..." notification incorrectly showed the total count from all games rather than only the count for the loaded game. The completion notification was correct since RA_Sync_syncAll filters by game_id internally. * WIP: fix offline achievement timestamp bugs and add diagnostic logging Three interrelated bugs in the offline→online achievement sync path: 1. waiting_for_reset never cleared: rc_client_set_hardcore_enabled() sets waiting_for_reset=1, but rc_client_reset() was never called to acknowledge it, permanently blocking do_frame achievement processing after offline→online transitions with hardcore re-enable. 2. On-device unlock timestamps overwritten: the deferred sync-apply code replaced the original ledger timestamp with time(NULL), causing the on-device display to show the sync time instead of the actual unlock time after sync completed. 3. Startsession patching only injected into softcore "Unlocks" array, not "HardcoreUnlocks". When hardcore was re-enabled, rcheevos saw pending achievements as not-yet-unlocked-in-hardcore, reset them to ACTIVE, and could re-award them via its own server call without &o=, causing the RA server to record the sync time as the unlock time. Also adds diagnostic logging: per-achievement startsession injection details, rc_client_reset confirmation, redacted POST body (to verify &o= is present), and raw server response (to distinguish "Success" from "User already has"). * Show notification when game is not recognized by RetroAchievements When a ROM hash is unknown to RetroAchievements, users previously had no indication of the issue until they opened the achievements menu. Now a toast notification is shown immediately ("No achievements found for this game"), and the achievements menu provides guidance about checking retroachievements.org for compatibility patches or supported ROM versions. Also removes erroneous ra_start_offline_sync(0) calls from the game load failure paths, which were triggering sync-all behavior for unrelated games instead of being scoped to the current game. * fix(ra): prevent timestamp loss in offline→online achievement sync Reported: offline achievement unlock times being replaced by sync time on the RA server. Root cause: race between our sync engine (which sends &o=) and rcheevos' own retry path (which doesn't include &o=, causing server to use current time). Fix 1: In ra_server_call(), intercept awardachievement requests and return synthetic success if the achievement is in our pending ledger. This lets our sync engine handle the submission with the correct timestamp. Fix 2: In ra_http_callback(), query ledger for original timestamp BEFORE removing from cache, use it when patching startsession cache. Previously time(NULL) was always used, corrupting on-device display. Also adds build version to startup log for easier testing. * fix(ra): block rcheevos retry path to prevent duplicate awardachievement submissions When achievements are unlocked offline, rcheevos' internal retry mechanism races with our sync engine, sending awardachievement with incorrect CLOCK_MONOTONIC-based timestamps that can overwrite correct wall-clock timestamps on the RA server. - Gate awardachievement in ra_server_call() to return synthetic success when unlock is pending or sync is in progress - Fix startsession cache patch to skip update when ledger entry is already compacted, preventing time(NULL) fallback corruption - Add tagged diagnostic logging ([AWARD_GATE], [AWARD_HTTP], etc.) - Bump build version to 3 * fix(ra): retry game load on connectivity restore when offline cache misses When starting offline with a game never previously played online, game data requests (gameid, achievementsets, patch) have no cached response. Previously these got a synthetic {"Success":true} which rcheevos couldn't parse, permanently failing the game load with no retry mechanism. - Only synthesize responses for simple types (login2, startsession) - Return retryable error for game data types on cache miss - Add game_load_retry deferred flag to retry load on connectivity restore - Preserve pending load info until game load succeeds (not on attempt) - Clear retry state on game unload to prevent stale retries * Fix offline achievement unlock times for renamed RA accounts The RA server validates the sync hash using its internal `username` field, which may differ from `display_name` after an account rename. Our sync engine was using the locally-configured display name, causing a hash mismatch that made the server silently drop the `&o=` (seconds_since_unlock) offset parameter — recording unlock times as the sync time instead of the actual unlock time. Extract the internal username from the `AvatarUrl` field in the login response (which is built from the server's `username`, not `display_name`), persist it in config as `raServerUsername`, and use it for sync hash computation with fallback to the local username for first-boot compatibility. * fix: resolve offline achievement timestamp bug caused by RA username mismatch The RA server silently ignores the backdate offset (&o=) when the MD5 hash is computed with the wrong username. The login API returns display_name (which users can change) rather than the internal username used for hash validation. Extract the server's internal username from the AvatarUrl field (/UserPic/USERNAME.png) at every login path and persist it in config for the sync engine. Also fixes an off-by-one in config.c that corrupted raServerUsername on every load/save cycle (strncmp length 16 vs correct 17), and adds a missing rc_client_reset() call in the RECONNECTED handler. * Small text tweak * refactor: harden and restructure offline RetroAchievements subsystem Five-wave refactoring of the offline RA code (~1500 net lines across 12 files): Wave 1 — Bug fixes: - Join probe/sync threads on shutdown instead of detaching (BF-1) - Fix JSON success-field parsing for awardachievement responses (BF-2) - URL-encode username/token in sync HTTP requests (BF-3) - Fix fwrite short-write checks in cache persistence (BF-4) - Block with timeout when offline queue is full instead of dropping (BF-5) Wave 2/3 — Deduplication: - Extract shared helpers to new header-only ra_util.h (JSON parsing, URL param extraction, recursive mkdir, login POST, interruptible sleep) - Add filtered query variants to ra_offline API to eliminate caller-side loops over the full pending-unlock set - Consolidate duplicate cache-corruption handling Wave 4 — Concurrency hardening: - Dedicated ra_probe_mutex for probe thread lifecycle (CH-1) - New ra_cache_mutex serializing read-modify-write on startsession cache updates (CH-2) - Mark cross-thread flag volatile (CH-4, CH-3 skipped: gnu99 has no _Atomic) - Lock notification progress-indicator reads (CH-5) Wave 5 — State machine refactoring: - SM-1: State enums (RAConnState, RALoginState, RAGameState, RASyncState) with derivation functions replacing scattered compound flag checks - SM-2: Mutex-protected event queue (ra_fsm.c/h) so background threads post events instead of writing shared flags - SM-3: FSM becomes authoritative — RADeferredState struct and its mutex removed; ra_process_deferred_flags() rewritten as event-driven dispatch - SM-4: ra_logged_in/ra_game_loaded booleans replaced with authoritative ra_login_state/ra_game_state enums; every transition is an explicit assignment; fixes stale-state bug on online game-load failure (T7) * fix(ra): detect WiFi drops mid-game and sync offline achievements on reconnect Two complementary mechanisms to reliably detect connectivity changes: 1. Lightweight WiFi polling (Phase 3 in ra_process_deferred_flags): Checks wpa_cli association state every 5 seconds — no network traffic. Detects WiFi loss (walk out of range, router reboot, interface toggle) within seconds and switches to offline mode, starting the connectivity probe for automatic sync on return. 2. AWARD_GATE fallback: When an awardachievement HTTP request fails and rcheevos retries into a pending-cache hit, the gate now recognizes this as evidence of connectivity loss and transitions to offline mode. Catches edge cases where WiFi appears associated but the route to the internet is broken. Previously, mid-game WiFi loss was only detected after rcheevos exhausted its internal retry backoff and fired RC_CLIENT_EVENT_DISCONNECTED, which could take over a minute — or never fire at all if the AWARD_GATE's synthetic success satisfied rcheevos first. Offline achievements would be recorded in the ledger but never synced. * refactor(ra): replace volatile bools with SDL_AtomicInt, rename ra_fsm, harden offline subsystem - Fix use-after-free in startsession cache patching (read pre_patch_len before the call that may realloc the buffer) - Add missing init guard to RA_Offline_refreshPendingCache - Replace all cross-thread volatile bool flags with SDL_atomic_t in ra_offline.c (3 flags, ~27 sites), ra_integration.c (3 flags, ~22 sites), ra_sync.c (cancel parameter), and settings.cpp (cancel flag) - Update ra_interruptible_sleep and RA_Sync_syncAll signatures to accept SDL_atomic_t* instead of volatile bool* - Rename ra_fsm to ra_event_queue (files, RA_FSM_* symbols to RA_EVQ_*, internal statics fsm_* to evq_*, log tags, includes, both makefiles) - Namespace sha256 public symbols with nui_ prefix (SHA256_CTX → NUI_SHA256_CTX, sha256_init → nui_sha256_init, etc.) and add extern "C" guard to sha256.h - Fix include guard style (__RA_OFFLINE_H__ → RA_OFFLINE_H, same for ra_sync.h) - Downgrade per-item sync logging from INFO to DEBUG, remove timezone delta diagnostic logging from api.c and both platform.c files - Seed srand() only once in RA_Sync_syncAll via static guard - Document lock ordering, ledger hash chain, and two-tier connectivity probe architecture * fix(ra): clear pending cache after sync-apply, fix notification TOCTOU race * fix(ra): clear stale raServerUsername when AvatarUrl is unavailable On login success, the internal/server username is extracted from the AvatarUrl field so offline unlock signatures use a name the server still recognizes. If AvatarUrl is missing or unparseable, the prior code silently left any previously-cached value in place — so a stale name from an earlier login would keep being used even after a rename. Make CFG_setRAServerUsernameFromAvatarUrl return bool, and at every login/probe success site clear raServerUsername when extraction fails. This lets the existing ra_sync.c fallback select the user-entered username (CFG_getRAUsername) — unlock timestamps may be wrong for renamed accounts, but that's better than signing with a stale name. * refactor(ra): pin raServerUsername to settings-auth, drop background writes raServerUsername signs offline achievement unlocks. It was being re- extracted from AvatarUrl on every successful rc_client background login, every connectivity-probe success, and lazily on first sync via sync_resolve_server_username(). If the RA server ever tightened its rules around renamed accounts, these paths would keep overwriting the stored value with a stale internal name and unlock signing would keep failing with no user-visible recovery. Restrict writes to RA_authenticateSync in ra_auth.c, which is the only place the user consciously enters credentials (settings "Authenticate" menu). Remove the extraction calls from the ra_integration.c login callback and probe, delete sync_resolve_server_username in ra_sync.c, and simplify the sync-site lookup to CFG_getRAServerUsername() with a direct fallback to CFG_getRAUsername() when empty. raServerUsername now reflects the last time the user explicitly authenticated. If unlock signing breaks after a rename, re-running "Authenticate" in settings is the single, obvious recovery path. * fix(ra): match JSON-escaped "\/UserPic\/" in AvatarUrl parser RA API responses escape forward slashes in JSON string values, so the AvatarUrl field arrives as "http:\/\/...\/UserPic\/name.png". The parser was only looking for the unescaped "/UserPic/" marker, which worked when the string came pre-decoded from rcheevos (user->avatar_url) but silently failed on raw API response bodies — including the settings "Authenticate" path, which is now the sole writer of raServerUsername. The result was an empty raServerUsername after every settings auth. Try the unescaped marker first, then fall back to the escaped form. * core: clean up comment in parse_login_response * chore: clean up MINARCH_BUILD_VERSION debugging * refactor(ra): extract shared logging macros and replace custom crypto Add ra_log.h with a parameterized RA_LOG_PREFIX macro and migrate all four RA source files to it, eliminating duplicate per-file LOG_* wrapper definitions that could silently diverge. Replace hand-rolled md5.c/sha256.c with OpenSSL's EVP API; remove both files and add -lcrypto to LDFLAGS for all RA-enabled platforms. * Add AGENTS.md and CLAUDE.md to gitignore * fix(build): bundle libcrypto for all RA-enabled handheld platforms * fix: ensure libchdr/libcrypto are readable before CI system copy CI fails with 'Permission denied' when copying libcrypto.so.1.1 from the build output. Some sub-builds produce files with restrictive permissions; add chmod a+r before the copy to normalize permissions for all platforms. * Revert "fix: ensure libchdr/libcrypto are readable before CI system copy" This reverts commit 26c8c8a68b143f23e0a9a359b88c6e2f5f428a51. * fix(build): chmod libcrypto readable inside docker build The toolchain's libcrypto.so.1.1 has restrictive perms; cp -L preserves them, leaving the file root-owned and unreadable to the host CI user during the subsequent `system` bundling step. Run chmod a+r inside the docker build (where we are root and the mode change actually sticks) rather than on the host, which was the failure mode of the previously-reverted 26c8c8a6. --- .gitignore | 3 + makefile | 5 +- workspace/all/common/config.c | 50 + workspace/all/common/config.h | 10 + workspace/all/common/notification.c | 384 ++++-- workspace/all/common/notification.h | 6 +- workspace/all/common/ra_auth.c | 125 +- workspace/all/common/ra_badges.c | 106 +- workspace/all/common/ra_event_queue.c | 120 ++ workspace/all/common/ra_event_queue.h | 136 ++ workspace/all/common/ra_log.h | 20 + workspace/all/common/ra_offline.c | 1579 +++++++++++++++++++++ workspace/all/common/ra_offline.h | 315 +++++ workspace/all/common/ra_sync.c | 396 ++++++ workspace/all/common/ra_sync.h | 103 ++ workspace/all/common/ra_util.h | 257 ++++ workspace/all/minarch/makefile | 10 +- workspace/all/minarch/minarch.c | 132 +- workspace/all/minarch/ra_integration.c | 1750 ++++++++++++++++++++++-- workspace/all/minarch/ra_integration.h | 7 + workspace/all/settings/makefile | 10 +- workspace/all/settings/menu.cpp | 4 + workspace/all/settings/settings.cpp | 119 ++ 23 files changed, 5169 insertions(+), 478 deletions(-) create mode 100644 workspace/all/common/ra_event_queue.c create mode 100644 workspace/all/common/ra_event_queue.h create mode 100644 workspace/all/common/ra_log.h create mode 100644 workspace/all/common/ra_offline.c create mode 100644 workspace/all/common/ra_offline.h create mode 100644 workspace/all/common/ra_sync.c create mode 100644 workspace/all/common/ra_sync.h create mode 100644 workspace/all/common/ra_util.h diff --git a/.gitignore b/.gitignore index 8058204ba..5571e6b78 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ audiomon.elf workspace/hash.txt workspace/readmes + +AGENTS.md +CLAUDE.md diff --git a/makefile b/makefile index ac5139426..fed3da9f6 100644 --- a/makefile +++ b/makefile @@ -110,6 +110,8 @@ ifeq ($(PLATFORM), tg5040) # Limbo fix cp ./workspace/$(PLATFORM)/poweroff_next/build/$(PLATFORM)/poweroff_next.elf ./build/SYSTEM/$(PLATFORM)/bin/poweroff_next endif +endif +ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355)) # Audio resampling cp ./workspace/all/minarch/build/$(PLATFORM)/libsamplerate.* ./build/SYSTEM/$(PLATFORM)/lib/ @@ -119,8 +121,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 + # libchdr and libcrypto for RetroAchievements cp ./workspace/all/minarch/build/$(PLATFORM)/libchdr.so.* ./build/SYSTEM/$(PLATFORM)/lib/ + cp ./workspace/all/minarch/build/$(PLATFORM)/libcrypto.so.* ./build/SYSTEM/$(PLATFORM)/lib/ ifeq ($(PLATFORM), tg5040) # liblz4 for Rewind support diff --git a/workspace/all/common/config.c b/workspace/all/common/config.c index 90cc1fb27..37279d99a 100644 --- a/workspace/all/common/config.c +++ b/workspace/all/common/config.c @@ -86,6 +86,7 @@ void CFG_defaults(NextUISettings *cfg) .raPassword = CFG_DEFAULT_RA_PASSWORD, .raHardcoreMode = CFG_DEFAULT_RA_HARDCOREMODE, .raToken = CFG_DEFAULT_RA_TOKEN, + .raServerUsername = CFG_DEFAULT_RA_SERVER_USERNAME, .raAuthenticated = CFG_DEFAULT_RA_AUTHENTICATED, .raShowNotifications = CFG_DEFAULT_RA_SHOW_NOTIFICATIONS, .raNotificationDuration = CFG_DEFAULT_RA_NOTIFICATION_DURATION, @@ -373,6 +374,13 @@ void CFG_init(FontLoad_callback_t cb, ColorSet_callback_t ccb) CFG_setRAToken(value); continue; } + if (strncmp(line, "raServerUsername=", 17) == 0) + { + char *value = line + 17; + value[strcspn(value, "\n")] = 0; + CFG_setRAServerUsername(value); + continue; + } if (sscanf(line, "raAuthenticated=%i", &temp_value) == 1) { CFG_setRAAuthenticated((bool)temp_value); @@ -963,6 +971,47 @@ void CFG_setRAToken(const char* token) CFG_sync(); } +const char* CFG_getRAServerUsername(void) +{ + return settings.raServerUsername; +} + +void CFG_setRAServerUsername(const char* username) +{ + if (username) { + strncpy(settings.raServerUsername, username, sizeof(settings.raServerUsername) - 1); + settings.raServerUsername[sizeof(settings.raServerUsername) - 1] = '\0'; + } else { + settings.raServerUsername[0] = '\0'; + } + CFG_sync(); +} + +bool CFG_setRAServerUsernameFromAvatarUrl(const char* str) +{ + if (!str) return false; + /* Accept both unescaped "/UserPic/" (rcheevos-decoded strings) and + * JSON-escaped "\/UserPic\/" (raw RA API response bodies). */ + const char* marker = strstr(str, "/UserPic/"); + size_t marker_skip = 9; /* strlen("/UserPic/") */ + if (!marker) { + marker = strstr(str, "\\/UserPic\\/"); + marker_skip = 11; /* strlen("\\/UserPic\\/") */ + if (!marker) return false; + } + marker += marker_skip; + /* Name ends at ".png" (or its escaped form, though ".png" has no slash). */ + const char* dot = strstr(marker, ".png"); + if (!dot || dot <= marker) return false; + size_t len = (size_t)(dot - marker); + if (len == 0 || len >= sizeof(settings.raServerUsername)) return false; + char username[64]; + memcpy(username, marker, len); + username[len] = '\0'; + CFG_setRAServerUsername(username); + return true; +} + bool CFG_getRAAuthenticated(void) { return settings.raAuthenticated; @@ -1255,6 +1304,7 @@ void CFG_sync(void) fprintf(file, "raPassword=%s\n", settings.raPassword); fprintf(file, "raHardcoreMode=%i\n", settings.raHardcoreMode); fprintf(file, "raToken=%s\n", settings.raToken); + fprintf(file, "raServerUsername=%s\n", settings.raServerUsername); fprintf(file, "raAuthenticated=%i\n", settings.raAuthenticated); fprintf(file, "raShowNotifications=%i\n", settings.raShowNotifications); fprintf(file, "raNotificationDuration=%i\n", settings.raNotificationDuration); diff --git a/workspace/all/common/config.h b/workspace/all/common/config.h index 3d5bbdf7c..3a3a4eaa5 100644 --- a/workspace/all/common/config.h +++ b/workspace/all/common/config.h @@ -156,6 +156,7 @@ typedef struct char raPassword[128]; bool raHardcoreMode; char raToken[64]; // API token (stored after successful auth) + char raServerUsername[64]; // Server's internal username (from avatar URL, used for sync hash) bool raAuthenticated; // Whether we have a valid token bool raShowNotifications; // Show achievement unlock notifications int raNotificationDuration; // Duration for achievement notifications (1-5 seconds) @@ -229,6 +230,7 @@ typedef struct #define CFG_DEFAULT_RA_PASSWORD "" #define CFG_DEFAULT_RA_HARDCOREMODE false #define CFG_DEFAULT_RA_TOKEN "" +#define CFG_DEFAULT_RA_SERVER_USERNAME "" #define CFG_DEFAULT_RA_AUTHENTICATED false #define CFG_DEFAULT_RA_SHOW_NOTIFICATIONS true #define CFG_DEFAULT_RA_NOTIFICATION_DURATION 3 @@ -379,6 +381,14 @@ bool CFG_getRAHardcoreMode(void); void CFG_setRAHardcoreMode(bool enable); const char* CFG_getRAToken(void); void CFG_setRAToken(const char* token); +const char* CFG_getRAServerUsername(void); +void CFG_setRAServerUsername(const char* username); +// Extract the RA server's internal username from any string containing +// "/UserPic/USERNAME.png" (e.g. an avatar URL or raw JSON) and persist +// it via CFG_setRAServerUsername(). Returns true if a username was +// extracted and stored; returns false (and leaves any existing stored +// value untouched) if the pattern is missing or malformed. +bool CFG_setRAServerUsernameFromAvatarUrl(const char* str); bool CFG_getRAAuthenticated(void); void CFG_setRAAuthenticated(bool authenticated); bool CFG_getRAShowNotifications(void); diff --git a/workspace/all/common/notification.c b/workspace/all/common/notification.c index 446cccc1c..f8d454f4a 100644 --- a/workspace/all/common/notification.c +++ b/workspace/all/common/notification.c @@ -56,6 +56,11 @@ static int last_system_indicator_type = SYSTEM_INDICATOR_NONE; /////////////////////////////// // Progress indicator state +// +// Thread safety: progress_state is written by background threads +// (sync engine in ra_integration.c, badge download callbacks in +// ra_badges.c) and read by the main thread during rendering. +// All access is protected by progress_mutex. /////////////////////////////// #define PROGRESS_TITLE_MAX 48 @@ -72,6 +77,7 @@ typedef struct { } ProgressIndicatorState; static ProgressIndicatorState progress_state = {0}; +static SDL_mutex* progress_mutex = NULL; /////////////////////////////// // Rounded rectangle drawing @@ -160,6 +166,11 @@ void Notification_init(void) { screen_width = FIXED_WIDTH; screen_height = FIXED_HEIGHT; + // Create progress indicator mutex (background threads write progress_state) + if (!progress_mutex) { + progress_mutex = SDL_CreateMutex(); + } + render_dirty = 1; last_notification_count = 0; initialized = 1; @@ -171,7 +182,7 @@ void Notification_push(NotificationType type, const char* message, SDL_Surface* } // Check if notifications are enabled for this type - if (type == NOTIFICATION_ACHIEVEMENT && !CFG_getRAShowNotifications()) { + if ((type == NOTIFICATION_ACHIEVEMENT || type == NOTIFICATION_OFFLINE_ACHIEVEMENT) && !CFG_getRAShowNotifications()) { return; } @@ -189,7 +200,7 @@ void Notification_push(NotificationType type, const char* message, SDL_Surface* n->start_time = SDL_GetTicks(); // Use RA-specific duration for achievement notifications - if (type == NOTIFICATION_ACHIEVEMENT) { + if (type == NOTIFICATION_ACHIEVEMENT || type == NOTIFICATION_OFFLINE_ACHIEVEMENT) { n->duration_ms = CFG_getRANotificationDuration() * 1000; } else { n->duration_ms = CFG_getNotifyDuration() * 1000; @@ -212,15 +223,21 @@ void Notification_update(uint32_t now) { } } - // 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; - } - } + // Update progress indicator timeout (skip if persistent). + // Read-check-write must be atomic to avoid a TOCTOU race: a background + // thread can call showProgressIndicator() between our read and write, + // resetting start_time. If we then write active=0 based on the stale + // snapshot, the new notification is silently killed. + SDL_LockMutex(progress_mutex); + 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; + } + } + SDL_UnlockMutex(progress_mutex); // Check each notification for expiration for (int i = 0; i < notification_count; i++) { @@ -241,8 +258,11 @@ void Notification_update(uint32_t now) { } // Render system indicator (top-right) +// Width formula must match GFX_blitHardwareIndicator in api.c: +// SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + 4) +// The 10 is internal pill content padding (not the screen-edge PADDING macro). static void render_system_indicator(void) { - int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + PADDING + SYS_INDICATOR_EXTRA_PAD); + int indicator_width = SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + SYS_INDICATOR_EXTRA_PAD); int indicator_height = SCALE1(PILL_SIZE); int indicator_x = screen_width - SCALE1(PADDING) - indicator_width; int indicator_y = SCALE1(PADDING); @@ -269,67 +289,68 @@ static void render_system_indicator(void) { } // 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); +// Takes a snapshot of progress state to avoid holding the mutex during rendering. +static void render_progress_indicator(const ProgressIndicatorState* snap) { + 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 (snap->progress[0] != '\0') { + snprintf(progress_text, sizeof(progress_text), "%s: %s", + snap->title, snap->progress); + } else { + snprintf(progress_text, sizeof(progress_text), "%s", snap->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 (snap->icon) { + icon_h = text_h; + icon_w = (snap->icon->w * icon_h) / snap->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 (snap->icon && icon_w > 0 && icon_h > 0) { + SDL_Rect icon_dst = {content_x, notif_padding_y, icon_w, icon_h}; + SDL_SetSurfaceBlendMode(snap->icon, SDL_BLENDMODE_BLEND); + SDL_BlitScaled(snap->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 @@ -344,7 +365,13 @@ static void render_notification_pill(Notification* n, int x, int y, SDL_Color te icon_total_w = icon_w + notif_icon_gap; } - int pill_w = icon_total_w + text_w + (notif_padding_x * 2); + // Wifi-off indicator for offline achievement notifications + int wifi_icon_w = 0; + if (n->type == NOTIFICATION_OFFLINE_ACHIEVEMENT) { + wifi_icon_w = SCALE1(12) + notif_icon_gap; // icon natural size + gap + } + + int pill_w = icon_total_w + wifi_icon_w + text_w + (notif_padding_x * 2); int pill_h = text_h + (notif_padding_y * 2); int corner_radius = pill_h / 2; @@ -366,6 +393,15 @@ static void render_notification_pill(Notification* n, int x, int y, SDL_Color te content_x += icon_total_w; } + // Blit wifi-off icon between badge and text for offline achievements + if (n->type == NOTIFICATION_OFFLINE_ACHIEVEMENT) { + int wifi_size = SCALE1(12); + int wifi_y = notif_padding_y + (text_h - wifi_size) / 2; + GFX_blitAssetColor(ASSET_WIFI_OFF, NULL, notif_surface, + &(SDL_Rect){content_x, wifi_y}, THEME_COLOR1_255); + content_x += wifi_size + notif_icon_gap; + } + SDL_Surface* text_surf = TTF_RenderUTF8_Blended(font.tiny, n->message, text_color); if (text_surf) { SDL_SetSurfaceBlendMode(text_surf, SDL_BLENDMODE_BLEND); @@ -419,37 +455,47 @@ void Notification_renderToLayer(int layer) { 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; + int has_notifications = notification_count > 0; + int has_system_indicator = system_indicator_type != SYSTEM_INDICATOR_NONE; + + // Snapshot progress state under lock — sub-microsecond critical section. + // Rendering uses the snapshot so we never hold the lock during SDL calls. + ProgressIndicatorState progress_snap; + SDL_LockMutex(progress_mutex); + progress_snap = progress_state; + SDL_UnlockMutex(progress_mutex); + + int has_progress_indicator = progress_snap.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; + SDL_LockMutex(progress_mutex); + progress_state.dirty = 0; + SDL_UnlockMutex(progress_mutex); + 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_snap.dirty; if (!notifications_changed && !indicator_changed && !progress_changed) { return; @@ -472,9 +518,9 @@ void Notification_renderToLayer(int layer) { if (has_system_indicator) { render_system_indicator(); } - if (has_progress_indicator) { - render_progress_indicator(); - } + if (has_progress_indicator) { + render_progress_indicator(&progress_snap); + } if (has_notifications) { render_notification_stack(); } @@ -482,11 +528,16 @@ void Notification_renderToLayer(int layer) { // 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; + render_dirty = 0; + last_notification_count = notification_count; + system_indicator_dirty = 0; + last_system_indicator_type = system_indicator_type; + + // Clear dirty flag under lock — a background thread may have set it again + // since our snapshot, in which case we'll re-render next frame. + SDL_LockMutex(progress_mutex); + progress_state.dirty = 0; + SDL_UnlockMutex(progress_mutex); } bool Notification_isActive(void) { @@ -494,12 +545,19 @@ bool Notification_isActive(void) { } void Notification_clear(void) { - notification_count = 0; - progress_state.active = 0; - progress_state.icon = NULL; - render_dirty = 1; - progress_state.dirty = 1; - PLAT_clearNotificationSurface(); + notification_count = 0; + + SDL_LockMutex(progress_mutex); + progress_state.active = 0; + if (progress_state.icon) { + SDL_FreeSurface(progress_state.icon); + progress_state.icon = NULL; + } + progress_state.dirty = 1; + SDL_UnlockMutex(progress_mutex); + + render_dirty = 1; + PLAT_clearNotificationSurface(); if (gl_notification_surface) { SDL_FreeSurface(gl_notification_surface); gl_notification_surface = NULL; @@ -509,8 +567,12 @@ void Notification_clear(void) { void Notification_quit(void) { Notification_clear(); system_indicator_type = SYSTEM_INDICATOR_NONE; - progress_state.active = 0; initialized = 0; + + if (progress_mutex) { + SDL_DestroyMutex(progress_mutex); + progress_mutex = NULL; + } } /////////////////////////////// @@ -535,7 +597,8 @@ 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); + // Must match GFX_blitHardwareIndicator width formula (10 = internal pill padding) + return SCALE1(PILL_SIZE + SETTINGS_WIDTH + 10 + SYS_INDICATOR_EXTRA_PAD); } /////////////////////////////// @@ -543,42 +606,63 @@ int Notification_getSystemIndicatorWidth(void) { /////////////////////////////// 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; + if (!initialized) return; + + // Check if RA notifications are enabled + if (!CFG_getRAShowNotifications()) return; + + // Called from background threads (sync engine, badge downloads) + SDL_LockMutex(progress_mutex); + + // 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'; + + // Duplicate the icon so progress_state owns its copy and the badge cache + // can free its surfaces (e.g. on game unload) without causing a use-after-free. + SDL_Surface* prev_icon = progress_state.icon; + progress_state.icon = icon ? SDL_DuplicateSurface(icon) : NULL; + if (prev_icon) SDL_FreeSurface(prev_icon); + + // Activate and reset timer + progress_state.active = 1; + progress_state.start_time = SDL_GetTicks(); + progress_state.dirty = 1; + + SDL_UnlockMutex(progress_mutex); } 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; - } + if (!initialized) return; + + // Called from background threads (sync engine, badge downloads) + SDL_LockMutex(progress_mutex); + if (progress_state.active) { + progress_state.active = 0; + progress_state.persistent = 0; + if (progress_state.icon) { + SDL_FreeSurface(progress_state.icon); + progress_state.icon = NULL; + } + progress_state.dirty = 1; + } + SDL_UnlockMutex(progress_mutex); } void Notification_setProgressIndicatorPersistent(bool persistent) { - progress_state.persistent = persistent ? 1 : 0; + // Called from background threads + SDL_LockMutex(progress_mutex); + progress_state.persistent = persistent ? 1 : 0; + SDL_UnlockMutex(progress_mutex); } bool Notification_hasProgressIndicator(void) { - return initialized && progress_state.active; + if (!initialized) return false; + SDL_LockMutex(progress_mutex); + bool active = progress_state.active; + SDL_UnlockMutex(progress_mutex); + return active; } diff --git a/workspace/all/common/notification.h b/workspace/all/common/notification.h index 54a3bade0..eeb3608fc 100644 --- a/workspace/all/common/notification.h +++ b/workspace/all/common/notification.h @@ -21,7 +21,8 @@ typedef enum { NOTIFICATION_SAVE_STATE, NOTIFICATION_LOAD_STATE, NOTIFICATION_SETTING, // volume/brightness/colortemp adjustments - NOTIFICATION_ACHIEVEMENT, // RetroAchievements unlocks + NOTIFICATION_ACHIEVEMENT, // RetroAchievements unlocks + NOTIFICATION_OFFLINE_ACHIEVEMENT, // Offline RA unlocks (shows wifi-off icon) } NotificationType; typedef enum { @@ -131,7 +132,8 @@ int Notification_getSystemIndicatorWidth(void); * 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. + * @param icon Optional badge icon (can be NULL). A copy is made internally; + * caller retains ownership of the passed surface. */ void Notification_showProgressIndicator(const char* title, const char* progress, SDL_Surface* icon); diff --git a/workspace/all/common/ra_auth.c b/workspace/all/common/ra_auth.c index 862f86ba4..1f3587aa5 100644 --- a/workspace/all/common/ra_auth.c +++ b/workspace/all/common/ra_auth.c @@ -1,5 +1,7 @@ #include "ra_auth.h" +#include "ra_util.h" #include "http.h" +#include "config.h" #include "defines.h" #include @@ -9,73 +11,44 @@ // 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"); + int success = ra_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)); + ra_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)); + ra_find_json_string(json, "User", response->display_name, sizeof(response->display_name)); + + // Extract internal (server) username from AvatarUrl. + // The RA server builds AvatarUrl from the internal username field + // (e.g. "/UserPic/MyOriginalUserName.png"), which may differ from the + // display_name if the user has renamed their account. + // Unfortunately, there is no current other way with the RA api to get the orginal + // username which was used, and if an offline achievement was sent with an updated + // name, the server will reject the unlock time, and use the current time instead. + { + char avatar_url[256] = {0}; + bool have_server_username = false; + if (ra_find_json_string(json, "AvatarUrl", avatar_url, sizeof(avatar_url))) { + have_server_username = CFG_setRAServerUsernameFromAvatarUrl(avatar_url); + } + if (!have_server_username) { + // AvatarUrl missing or malformed — clear any stale value from a + // prior login so offline sync falls back to CFG_getRAUsername(). + // This may produce incorrect unlock timestamps for renamed + // accounts, but it's better than signing unlocks with a stale + // internal username the server no longer recognizes. + CFG_setRAServerUsername(""); + } + } if (strlen(response->token) == 0) { // Token missing in success response - shouldn't happen but handle it @@ -87,7 +60,7 @@ static void parse_login_response(const char* json, RA_AuthResponse* response) { response->result = RA_AUTH_ERROR_INVALID; // Try to extract error message - if (!find_json_string(json, "Error", response->error_message, + if (!ra_find_json_string(json, "Error", response->error_message, sizeof(response->error_message))) { strncpy(response->error_message, "Invalid credentials", sizeof(response->error_message) - 1); @@ -153,29 +126,17 @@ void RA_authenticate(const char* username, const char* password, 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); + // Build POST data: r=login&u=username&p=password + char post_data[512]; + if (!ra_build_login_post_password(username, password, post_data, sizeof(post_data))) { RA_AuthResponse response = {0}; response.result = RA_AUTH_ERROR_UNKNOWN; - strncpy(response.error_message, "Memory allocation failed", + strncpy(response.error_message, "Failed to build login request", 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) { @@ -206,27 +167,15 @@ RA_AuthResult RA_authenticateSync(const char* username, const char* password, 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); + // Build POST data + char post_data[512]; + if (!ra_build_login_post_password(username, password, post_data, sizeof(post_data))) { response->result = RA_AUTH_ERROR_UNKNOWN; - strncpy(response->error_message, "Memory allocation failed", + strncpy(response->error_message, "Failed to build login request", 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); diff --git a/workspace/all/common/ra_badges.c b/workspace/all/common/ra_badges.c index a9325141b..24039645a 100644 --- a/workspace/all/common/ra_badges.c +++ b/workspace/all/common/ra_badges.c @@ -1,4 +1,8 @@ +#define RA_LOG_PREFIX "RA_BADGES" +#include "ra_log.h" + #include "ra_badges.h" +#include "ra_util.h" #include "http.h" #include "defines.h" #include "api.h" @@ -12,12 +16,6 @@ #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 *****************************************************************************/ @@ -87,7 +85,7 @@ static BadgeCacheEntry* find_or_create_entry(const char* badge_name, bool locked // Create new entry if space available if (badge_cache_count >= MAX_CACHED_BADGES) { - BADGE_LOG_WARN("Cache full, cannot add badge %s\n", badge_name); + RA_LOG_WARN("Cache full, cannot add badge %s\n", badge_name); return NULL; } @@ -102,14 +100,7 @@ static BadgeCacheEntry* find_or_create_entry(const char* badge_name, bool locked // 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); + ra_mkdirs(RA_BADGE_CACHE_DIR); } // Check if cache file exists @@ -122,7 +113,7 @@ static bool cache_file_exists(const char* path) { static bool save_to_cache(const char* path, const char* data, size_t size) { FILE* f = fopen(path, "wb"); if (!f) { - BADGE_LOG_ERROR("Failed to open cache file for writing: %s\n", path); + RA_LOG_ERROR("Failed to open cache file for writing: %s\n", path); return false; } @@ -130,7 +121,7 @@ static bool save_to_cache(const char* path, const char* data, size_t size) { fclose(f); if (written != size) { - BADGE_LOG_ERROR("Failed to write cache file: %s\n", path); + RA_LOG_ERROR("Failed to write cache file: %s\n", path); unlink(path); return false; } @@ -142,7 +133,7 @@ static bool save_to_cache(const char* path, const char* data, size_t size) { static SDL_Surface* load_from_cache(const char* path) { SDL_Surface* surface = IMG_Load(path); if (!surface) { - BADGE_LOG_WARN("Failed to load badge image: %s - %s\n", path, IMG_GetError()); + RA_LOG_WARN("Failed to load badge image: %s - %s\n", path, IMG_GetError()); return NULL; } return surface; @@ -196,7 +187,7 @@ 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_CACHED_BADGES) { - BADGE_LOG_WARN("Download queue full, dropping badge %s\n", badge_name); + RA_LOG_WARN("Download queue full, dropping badge %s\n", badge_name); return; } @@ -271,21 +262,30 @@ static void badge_download_callback(HTTP_Response* response, void* 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", + RA_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", + RA_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 + // Decode the image and pre-scale it here on the worker thread so the main + // thread never has to do IMG_Load or scale_surface at achievement-trigger time. + SDL_Surface* surface = NULL; + SDL_Surface* surface_scaled = NULL; + if (success) { + surface = load_from_cache(ctx->cache_path); + if (surface) { + surface_scaled = scale_surface(surface, RA_BADGE_NOTIFY_SIZE); + } + } + + // Hold mutex only long enough to update the cache entry state + surfaces if (badge_mutex) SDL_LockMutex(badge_mutex); download_queue.active--; @@ -296,8 +296,18 @@ static void badge_download_callback(HTTP_Response* response, void* userdata) { BadgeCacheEntry* entry = find_or_create_entry(ctx->badge_name, ctx->locked); if (entry) { - // Mark as cached (on disk) - surfaces will be loaded lazily entry->state = success ? RA_BADGE_STATE_CACHED : RA_BADGE_STATE_FAILED; + if (surface) { + // Free any previously-loaded surfaces (shouldn't happen, but be safe) + if (entry->surface) SDL_FreeSurface(entry->surface); + if (entry->surface_scaled) SDL_FreeSurface(entry->surface_scaled); + entry->surface = surface; + entry->surface_scaled = surface_scaled; + } + } else { + // Entry couldn't be created — discard decoded surfaces + if (surface) SDL_FreeSurface(surface); + if (surface_scaled) SDL_FreeSurface(surface_scaled); } // Start next queued download(s) @@ -445,16 +455,31 @@ SDL_Surface* RA_Badges_get(const char* badge_name, bool locked) { if (entry) { if (entry->state == RA_BADGE_STATE_CACHED) { - // Lazy load from disk if not in memory if (!entry->surface) { + // Release mutex during disk I/O so we don't stall other threads. + // Re-check entry->surface after re-acquiring in case another thread + // raced us and loaded it first. 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); + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + SDL_Surface* surface = load_from_cache(cache_path); + SDL_Surface* surface_scaled = surface + ? scale_surface(surface, RA_BADGE_NOTIFY_SIZE) : NULL; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + // Re-find entry: cache may have been modified while mutex was dropped + entry = find_or_create_entry(badge_name, locked); + if (entry && !entry->surface) { + entry->surface = surface; + entry->surface_scaled = surface_scaled; + } else { + // Lost the race — discard what we loaded + if (surface) SDL_FreeSurface(surface); + if (surface_scaled) SDL_FreeSurface(surface_scaled); } } - result = entry->surface; + result = entry ? entry->surface : NULL; } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { // Trigger download start_download(badge_name, locked); @@ -476,16 +501,27 @@ SDL_Surface* RA_Badges_getNotificationSize(const char* badge_name, bool locked) if (entry) { if (entry->state == RA_BADGE_STATE_CACHED) { - // Lazy load from disk if not in memory if (!entry->surface_scaled) { + // Release mutex during disk I/O so we don't stall other threads. 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); + if (badge_mutex) SDL_UnlockMutex(badge_mutex); + + SDL_Surface* surface = load_from_cache(cache_path); + SDL_Surface* surface_scaled = surface + ? scale_surface(surface, RA_BADGE_NOTIFY_SIZE) : NULL; + + if (badge_mutex) SDL_LockMutex(badge_mutex); + entry = find_or_create_entry(badge_name, locked); + if (entry && !entry->surface_scaled) { + entry->surface = surface; + entry->surface_scaled = surface_scaled; + } else { + if (surface) SDL_FreeSurface(surface); + if (surface_scaled) SDL_FreeSurface(surface_scaled); } } - result = entry->surface_scaled; + result = entry ? entry->surface_scaled : NULL; } else if (entry->state == RA_BADGE_STATE_UNKNOWN) { // Trigger download start_download(badge_name, locked); diff --git a/workspace/all/common/ra_event_queue.c b/workspace/all/common/ra_event_queue.c new file mode 100644 index 000000000..7a612ef13 --- /dev/null +++ b/workspace/all/common/ra_event_queue.c @@ -0,0 +1,120 @@ +/** + * ra_event_queue.c — Thread-safe event queue implementation. + * + * A mutex-protected circular buffer. Background threads push events, + * the main thread drains them. The queue is intentionally over-sized + * (32 slots) relative to the actual event rate (~4 events per probe/sync + * cycle) so overflow should never happen in practice. + */ + +#include "ra_event_queue.h" + +#include +#include +#include + +/* Logging — LOG_info/LOG_warn/LOG_debug are macros defined in api.h that + * expand to LOG_note(). We don't include api.h here (too many transitive + * deps) so declare LOG_note and define the macros we need locally. + * Values must stay in sync with the LOG_* enum in api.h. */ +enum { LOG_DEBUG = 0, LOG_INFO = 1, LOG_WARN = 2 }; +void LOG_note(int level, const char* fmt, ...); +#define LOG_debug(fmt, ...) LOG_note(LOG_DEBUG, fmt, ##__VA_ARGS__) +#define LOG_info(fmt, ...) LOG_note(LOG_INFO, fmt, ##__VA_ARGS__) +#define LOG_warn(fmt, ...) LOG_note(LOG_WARN, fmt, ##__VA_ARGS__) + +/***************************************************************************** + * Internal state + *****************************************************************************/ + +static SDL_mutex* evq_mutex = NULL; +static RAEvent evq_queue[RA_EVQ_QUEUE_CAPACITY]; +static uint32_t evq_head = 0; /* next slot to read (main thread) */ +static uint32_t evq_tail = 0; /* next slot to write (any thread) */ +static uint32_t evq_count = 0; /* current number of queued events */ + +/***************************************************************************** + * Diagnostic: event type -> string + *****************************************************************************/ + +static const char* ra_event_type_str(RAEventType t) { + switch (t) { + case RA_EV_PROBE_ONLINE: return "PROBE_ONLINE"; + case RA_EV_PROBE_STOPPED: return "PROBE_STOPPED"; + case RA_EV_SYNC_DONE: return "SYNC_DONE"; + case RA_EV_SYNC_FAILED: return "SYNC_FAILED"; + default: return "EV_?"; + } +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +void RA_EVQ_init(void) { + if (evq_mutex) return; /* already initialized */ + evq_mutex = SDL_CreateMutex(); + evq_head = 0; + evq_tail = 0; + evq_count = 0; + memset(evq_queue, 0, sizeof(evq_queue)); +} + +void RA_EVQ_quit(void) { + if (!evq_mutex) return; + /* Drain under lock to be tidy, then destroy */ + SDL_LockMutex(evq_mutex); + evq_head = 0; + evq_tail = 0; + evq_count = 0; + SDL_UnlockMutex(evq_mutex); + + SDL_DestroyMutex(evq_mutex); + evq_mutex = NULL; +} + +void RA_EVQ_post(const RAEvent* event) { + if (!evq_mutex || !event) return; + + SDL_LockMutex(evq_mutex); + + if (evq_count >= RA_EVQ_QUEUE_CAPACITY) { + LOG_warn("[RA_EVQ] Event queue full, dropping %s\n", + ra_event_type_str(event->type)); + SDL_UnlockMutex(evq_mutex); + return; + } + + evq_queue[evq_tail] = *event; + evq_tail = (evq_tail + 1) % RA_EVQ_QUEUE_CAPACITY; + evq_count++; + + LOG_debug("[RA_EVQ] Posted %s (queue depth %u)\n", + ra_event_type_str(event->type), evq_count); + + SDL_UnlockMutex(evq_mutex); +} + +uint32_t RA_EVQ_drain(RAEvent* out_buf, uint32_t capacity) { + if (!evq_mutex || !out_buf || capacity == 0) return 0; + + uint32_t n = 0; + SDL_LockMutex(evq_mutex); + + while (evq_count > 0 && n < capacity) { + out_buf[n] = evq_queue[evq_head]; + evq_head = (evq_head + 1) % RA_EVQ_QUEUE_CAPACITY; + evq_count--; + n++; + } + + SDL_UnlockMutex(evq_mutex); + return n; +} + +void RA_EVQ_post_signal(RAEventType type) { + RAEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = type; + RA_EVQ_post(&ev); +} diff --git a/workspace/all/common/ra_event_queue.h b/workspace/all/common/ra_event_queue.h new file mode 100644 index 000000000..1e43a71fe --- /dev/null +++ b/workspace/all/common/ra_event_queue.h @@ -0,0 +1,136 @@ +/** + * ra_event_queue.h — Thread-safe event queue for RetroAchievements background threads. + * + * Background threads (connectivity probe, sync engine) post events into a + * mutex-protected circular buffer. The main thread drains the queue during + * its periodic processing loop (ra_process_deferred_flags) and applies the + * state transitions described by each event. + * + * Lifecycle: + * RA_EVQ_init() — call once at startup (creates mutex + queue) + * RA_EVQ_post() — call from any thread (enqueues event under lock) + * RA_EVQ_drain() — call from main thread (dequeues all pending events) + * RA_EVQ_quit() — call once at shutdown (drains queue, destroys mutex) + */ + +#ifndef RA_EVENT_QUEUE_H +#define RA_EVENT_QUEUE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/***************************************************************************** + * Event types + * + * Each event represents a state transition that a background thread needs + * the main thread to apply. + *****************************************************************************/ + +typedef enum { + /* ---- Connectivity probe thread ---- */ + + /** Probe login succeeded — device is back online. + * Corresponds to: offline_notification=false (cancel), online_notification=true, + * sync=true, and optionally hardcore_enable=true. */ + RA_EV_PROBE_ONLINE, + + /** Probe thread exiting without achieving connectivity. + * Corresponds to: ra_probe_running=false (on early-exit or abort). */ + RA_EV_PROBE_STOPPED, + + /* ---- Sync thread ---- */ + + /** Sync completed with results ready for main thread. + * Payload: sync_ids[], sync_timestamps[], sync_count. + * Corresponds to: sync_apply=true + sync_ids/timestamps/count. */ + RA_EV_SYNC_DONE, + + /** Sync failed — device going back to offline mode. + * Corresponds to: offline_notification=true, hardcore_disable=true, + * user_saw_offline=true, and probe restart. */ + RA_EV_SYNC_FAILED, + + RA_EV_COUNT /* sentinel — not a real event */ +} RAEventType; + +/***************************************************************************** + * Event payload + * + * Events that carry data use a union. Most events are "signal-only" (no + * payload beyond the type). RA_EV_SYNC_DONE carries the synced achievement + * IDs and timestamps. RA_EV_PROBE_ONLINE carries the hardcore_enable flag + * to indicate whether hardcore re-enable was requested. + *****************************************************************************/ + +#define RA_EVQ_MAX_SYNC_IDS 256 + +typedef struct { + RAEventType type; + union { + /** RA_EV_PROBE_ONLINE payload */ + struct { + bool hardcore_enable; /* true if probe set hardcore_enable */ + bool offline_notif_cancel; /* true if pending offline notif was cancelled */ + } probe_online; + + /** RA_EV_SYNC_DONE payload */ + struct { + uint32_t ids[RA_EVQ_MAX_SYNC_IDS]; + uint32_t timestamps[RA_EVQ_MAX_SYNC_IDS]; + uint32_t count; + } sync_done; + } data; +} RAEvent; + +/***************************************************************************** + * Queue API + *****************************************************************************/ + +/** Maximum events that can be queued before the oldest is overwritten. */ +#define RA_EVQ_QUEUE_CAPACITY 32 + +/** + * Initialize the event queue (create mutex, zero state). + * Safe to call multiple times; subsequent calls are no-ops. + */ +void RA_EVQ_init(void); + +/** + * Shut down the event queue (drain pending events, destroy mutex). + * Safe to call if never initialized. + */ +void RA_EVQ_quit(void); + +/** + * Post an event from any thread. Thread-safe (locks internal mutex). + * If the queue is full, the event is logged and dropped (should never + * happen in practice — the queue is sized generously for the actual + * event rate of ~4 events per probe/sync cycle). + */ +void RA_EVQ_post(const RAEvent* event); + +/** + * Drain all pending events into @out_buf (up to @capacity entries). + * Returns the number of events dequeued. Main thread only. + * + * @param out_buf Caller-provided array to receive events. + * @param capacity Size of out_buf. + * @return Number of events written to out_buf (0 if empty). + */ +uint32_t RA_EVQ_drain(RAEvent* out_buf, uint32_t capacity); + +/** + * Convenience: post a signal-only event (no payload). + * Equivalent to constructing an RAEvent with the given type and zeroed data. + */ +void RA_EVQ_post_signal(RAEventType type); + +#ifdef __cplusplus +} +#endif + +#endif /* RA_EVENT_QUEUE_H */ diff --git a/workspace/all/common/ra_log.h b/workspace/all/common/ra_log.h new file mode 100644 index 000000000..dd297c76e --- /dev/null +++ b/workspace/all/common/ra_log.h @@ -0,0 +1,20 @@ +/* ra_log.h — Parameterized RetroAchievements logging macros. + * + * Define RA_LOG_PREFIX to a string literal before including this header. + * + * Example: + * #define RA_LOG_PREFIX "RA_OFFLINE" + * #include "ra_log.h" + * + * RA_LOG_PREFIX is NOT undef'd here: the macros reference it at expansion + * time (not definition time), so it must remain defined throughout the TU. + */ + +#ifndef RA_LOG_PREFIX +#error "RA_LOG_PREFIX must be defined before including ra_log.h" +#endif + +#define RA_LOG_DEBUG(...) LOG_debug("[" RA_LOG_PREFIX "] " __VA_ARGS__) +#define RA_LOG_INFO(...) LOG_info("[" RA_LOG_PREFIX "] " __VA_ARGS__) +#define RA_LOG_WARN(...) LOG_warn("[" RA_LOG_PREFIX "] " __VA_ARGS__) +#define RA_LOG_ERROR(...) LOG_error("[" RA_LOG_PREFIX "] " __VA_ARGS__) diff --git a/workspace/all/common/ra_offline.c b/workspace/all/common/ra_offline.c new file mode 100644 index 000000000..2d9374ea7 --- /dev/null +++ b/workspace/all/common/ra_offline.c @@ -0,0 +1,1579 @@ +#define RA_LOG_PREFIX "RA_OFFLINE" +#include "ra_log.h" + +#include "ra_offline.h" +#include "ra_util.h" +#include "defines.h" +#include "api.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NUI_SHA256_DIGEST_SIZE 32 + +/***************************************************************************** + * Static state + *****************************************************************************/ + +static SDL_atomic_t ra_offline_initialized = {0}; +static SDL_atomic_t ra_offline_mode = {0}; +static SDL_atomic_t ra_sync_in_progress = {0}; + +/* Lock ordering (always acquire in this order to avoid deadlock): + * 1. ra_ledger_mutex — protects hash-chain, pending cache, write queue + * 2. wq->mutex — protects the ledger write queue internals + * 3. ra_cache_mutex — protects cache file read-modify-write + * ra_ledger_mutex and ra_cache_mutex are never held simultaneously. */ + +/* Mutex protecting hash-chain state, pending cache, and the write queue. + * Guards: ledger_append (hash chain + enqueue), ledgerCompact, + * ledgerGetPendingUnlocks, and all pending-cache functions. */ +static SDL_mutex* ra_ledger_mutex = NULL; + +/* Mutex serializing cache file read-modify-write operations. + * Guards: patchStartsessionCacheWithUnlock (called from HTTP worker threads + * and the sync thread — concurrent patches on the same game file would + * otherwise lose updates). */ +static SDL_mutex* ra_cache_mutex = NULL; + +/* Base data directory (set during init) */ +static char ra_data_dir[512] = {0}; +static char ra_cache_dir[512] = {0}; +static char ra_ledger_path[512] = {0}; + +/* Running hash chain state: SHA-256 of the last ledger record written */ +static uint8_t ra_ledger_last_hash[NUI_SHA256_DIGEST_SIZE]; +static bool ra_ledger_has_records = false; + +/* Cached pending offline achievement IDs for quick lookup */ +#define RA_MAX_PENDING_CACHE 512 +static uint32_t ra_pending_ids[RA_MAX_PENDING_CACHE]; +static uint32_t ra_pending_count = 0; + +/***************************************************************************** + * Async ledger write queue + * + * Ledger writes (fopen/fwrite/fsync/fclose) are handed off to a background + * thread so that the main game loop is never stalled by SD-card I/O. + * + * The hash chain (prev_hash / record_hash computation) is still finalized + * synchronously under ra_ledger_mutex before the record is enqueued — this + * preserves strict ordering without blocking on disk. + *****************************************************************************/ + +#define RA_LEDGER_WRITE_QUEUE_SIZE 32 /* more than enough for any burst */ + +typedef struct { + RA_LedgerRecord records[RA_LEDGER_WRITE_QUEUE_SIZE]; + int head; /* next slot to read (writer thread) */ + int tail; /* next slot to write (producers) */ + int count; + SDL_mutex* mutex; + SDL_cond* cond_nonempty; /* signalled when a record is enqueued */ + SDL_cond* cond_empty; /* signalled when queue drains to zero */ + SDL_Thread* thread; + volatile bool running; +} LedgerWriteQueue; + +static LedgerWriteQueue ra_ledger_wq = {0}; + +/** + * Get the request type from URL or post_data. + * Returns pointer to static string or NULL. + */ +static const char* get_request_type(const char* url, const char* post_data, + char* buf, size_t buf_size) { + /* Try URL first (GET requests have ?r=... in URL) */ + if (url && ra_extract_param(url, "r", buf, buf_size)) { + return buf; + } + /* Try POST data */ + if (post_data && ra_extract_param(post_data, "r", buf, buf_size)) { + return buf; + } + return NULL; +} + +/** + * Get the game hash from post_data (used for cache key). + */ +static bool get_game_hash_param(const char* url, const char* post_data, char* buf, size_t buf_size) { + /* rcheevos sends game hash as 'm' parameter in some requests, or 'h' */ + if (post_data) { + if (ra_extract_param(post_data, "m", buf, buf_size)) return true; + if (ra_extract_param(post_data, "h", buf, buf_size)) return true; + } + if (url) { + if (ra_extract_param(url, "m", buf, buf_size)) return true; + if (ra_extract_param(url, "h", buf, buf_size)) return true; + } + return false; +} + +/***************************************************************************** + * Response cache implementation + * + * Cache file format: + * [uint32_t body_len] [body_bytes...] [32-byte SHA-256 of body] + * + * We cache: + * - login2 responses -> cache/login.bin + * - patch responses -> cache/.bin + *****************************************************************************/ + +static void cache_file_path(const char* request_type, const char* url, + const char* post_data, char* buf, size_t buf_size) { + if (strcmp(request_type, "login2") == 0) { + snprintf(buf, buf_size, "%s/login.bin", ra_cache_dir); + } else if (strcmp(request_type, "achievementsets") == 0 || + strcmp(request_type, "gameid") == 0 || + strcmp(request_type, "patch") == 0 || + strcmp(request_type, "startsession") == 0) { + char hash[64] = {0}; + if (get_game_hash_param(url, post_data, hash, sizeof(hash))) { + snprintf(buf, buf_size, "%s/%s_%s.bin", ra_cache_dir, request_type, hash); + } else { + /* Fallback: use request type only */ + snprintf(buf, buf_size, "%s/%s.bin", ra_cache_dir, request_type); + } + } else { + buf[0] = '\0'; + } +} + +static bool is_cacheable_request(const char* request_type) { + return request_type && + (strcmp(request_type, "login2") == 0 || + strcmp(request_type, "achievementsets") == 0 || + strcmp(request_type, "gameid") == 0 || + strcmp(request_type, "patch") == 0 || + strcmp(request_type, "startsession") == 0); +} + +/***************************************************************************** + * Cache file I/O helpers + * + * Cache file format: [uint32_t body_len][body bytes][SHA-256 digest] + * + * The SHA-256 is over the body bytes only and serves as an integrity check + * against SD-card corruption or partial writes. + *****************************************************************************/ + +/** + * Read a cache file and verify its SHA-256 digest. + * + * @param path Absolute path to the cache file. + * @param out_body Output: heap-allocated, NUL-terminated body. Caller must free(). + * @param out_len Output: body length in bytes (excluding NUL terminator). + * @return true on success, false on miss / corrupt / I/O error. + * On failure, *out_body is not modified. + */ +static bool cache_read_file(const char* path, char** out_body, size_t* out_len) { + FILE* f = fopen(path, "rb"); + if (!f) return false; + + uint32_t len32; + if (fread(&len32, sizeof(len32), 1, f) != 1 || len32 > 8 * 1024 * 1024) { + RA_LOG_WARN("Cache file corrupt or too large: %s\n", path); + fclose(f); + return false; + } + + char* body = (char*)malloc(len32 + 1); + if (!body) { + RA_LOG_ERROR("Failed to allocate %u bytes for cache read: %s\n", len32, path); + fclose(f); + return false; + } + if (fread(body, 1, len32, f) != len32) { + RA_LOG_WARN("Cache file truncated: %s\n", path); + free(body); + fclose(f); + return false; + } + body[len32] = '\0'; + + uint8_t stored_digest[NUI_SHA256_DIGEST_SIZE]; + if (fread(stored_digest, NUI_SHA256_DIGEST_SIZE, 1, f) != 1) { + RA_LOG_WARN("Cache file missing digest: %s\n", path); + free(body); + fclose(f); + return false; + } + fclose(f); + + uint8_t computed_digest[NUI_SHA256_DIGEST_SIZE]; + EVP_Digest(body, len32, computed_digest, NULL, EVP_sha256(), NULL); + if (memcmp(stored_digest, computed_digest, NUI_SHA256_DIGEST_SIZE) != 0) { + RA_LOG_WARN("Cache file SHA-256 mismatch (corrupt): %s\n", path); + free(body); + unlink(path); + return false; + } + + *out_body = body; + *out_len = (size_t)len32; + return true; +} + +/** + * Write a cache file with length prefix and SHA-256 digest. + * + * Writes atomically with fflush+fsync. On partial-write failure the file is + * unlinked. The caller retains ownership of @p body. + * + * @param path Absolute path to the cache file. + * @param body Body bytes to write. + * @param len Length of body in bytes. + * @return true on success, false on I/O error. + */ +static bool cache_write_file(const char* path, const char* body, size_t len) { + uint8_t digest[NUI_SHA256_DIGEST_SIZE]; + EVP_Digest(body, len, digest, NULL, EVP_sha256(), NULL); + + FILE* f = fopen(path, "wb"); + if (!f) { + RA_LOG_ERROR("Failed to open cache file for writing: %s (%s)\n", + path, strerror(errno)); + return false; + } + + uint32_t len32 = (uint32_t)len; + size_t written = 0; + written += fwrite(&len32, sizeof(len32), 1, f); + written += (fwrite(body, 1, len, f) == len) ? 1 : 0; + written += fwrite(digest, NUI_SHA256_DIGEST_SIZE, 1, f); + + if (written < 3) { + RA_LOG_ERROR("Failed to write cache file: %s\n", path); + fclose(f); + unlink(path); + return false; + } + + fflush(f); + fsync(fileno(f)); + fclose(f); + return true; +} + +void RA_Offline_cacheResponse(const char* url, const char* post_data, + const char* response_body, size_t response_len) { + if (!SDL_AtomicGet(&ra_offline_initialized) || !response_body || response_len == 0) return; + + char type_buf[32]; + const char* req_type = get_request_type(url, post_data, type_buf, sizeof(type_buf)); + if (!is_cacheable_request(req_type)) return; + + char path[512]; + cache_file_path(req_type, url, post_data, path, sizeof(path)); + if (path[0] == '\0') return; + + if (cache_write_file(path, response_body, response_len)) { + RA_LOG_DEBUG("Cached %s response (%zu bytes) to %s\n", + req_type, response_len, path); + } +} + +/** + * Patch a cached startsession JSON response with offline ledger unlocks. + * + * The startsession response contains "Unlocks":[...] and "HardcoreUnlocks":[...] + * arrays that tell rcheevos which achievements are already unlocked. When we have + * offline ledger entries for achievements unlocked after the cache was written, + * we inject them into BOTH arrays so rcheevos shows them as unlocked in both + * softcore and hardcore modes. This prevents rcheevos from re-triggering and + * re-awarding them through its own server call (which would lack the &o= + * seconds_since parameter, causing the RA server to record the sync time + * instead of the original unlock time). + * + * @param body The cached JSON body (will be freed and replaced) + * @param body_len Length of body + * @param game_hash Game hash to filter ledger entries by + * @param out_body Output: patched body (caller must free) + * @param out_len Output: patched body length + * @return true if patching succeeded (or no patching needed), false on error + */ + +/** + * Helper: inject pending unlocks into a single JSON array within the body. + * + * Finds the array identified by @p array_key (e.g. "\"Unlocks\""), checks for + * duplicates, and splices new entries just before the closing ']'. + * + * @param body Current body buffer (NOT freed — caller manages memory) + * @param body_len Length of body + * @param array_key JSON key string to locate, e.g. "\"Unlocks\"" + * @param pending Array of pending unlocks (already filtered to this game) + * @param pending_count Number of entries in pending array + * @param game_hash Game hash to filter by + * @param out_body Output: new body (malloc'd) or NULL if no injection needed + * @param out_len Output: new body length + * @return number of entries injected (0 means no change, out_body is NULL) + */ +static uint32_t inject_unlocks_into_array(const char* body, size_t body_len, + const char* array_key, + const RA_PendingUnlock* pending, + uint32_t pending_count, + const char* game_hash, + char** out_body, size_t* out_len) { + *out_body = NULL; + *out_len = 0; + + const char* arr_key_pos = strstr(body, array_key); + if (!arr_key_pos) { + return 0; + } + + /* Find the '[' after the key */ + const char* arr_start = strchr(arr_key_pos + strlen(array_key), '['); + if (!arr_start) + return 0; + + /* Find the matching ']' */ + const char* arr_end = strchr(arr_start, ']'); + if (!arr_end) + return 0; + + /* Check which pending achievement IDs need injection (not already present) */ + bool* need_inject = (bool*)calloc(pending_count, sizeof(bool)); + if (!need_inject) + return 0; + + uint32_t inject_count = 0; + for (uint32_t i = 0; i < pending_count; i++) { + /* When game_hash is provided, only inject entries for that game. + * When game_hash is NULL, accept all entries (used by single-entry + * callers like patchStartsessionCacheWithUnlock). */ + if (game_hash && strcmp(pending[i].game_hash, game_hash) != 0) + continue; + + char id_pattern[32]; + snprintf(id_pattern, sizeof(id_pattern), "\"ID\":%u", pending[i].achievement_id); + bool found = false; + for (const char* p = arr_start; p < arr_end; p++) { + if (strncmp(p, id_pattern, strlen(id_pattern)) == 0) { + found = true; + break; + } + } + if (!found) { + need_inject[i] = true; + inject_count++; + } + } + + if (inject_count == 0) { + free(need_inject); + return 0; + } + + /* Log each achievement being injected */ + for (uint32_t i = 0; i < pending_count; i++) { + if (!need_inject[i]) continue; + RA_LOG_INFO("inject_unlocks: %s ach=%u timestamp=%u\n", + array_key, pending[i].achievement_id, + pending[i].timestamp); + } + + /* Build the injection string */ + size_t inject_buf_size = inject_count * 48 + 1; + char* inject_buf = (char*)malloc(inject_buf_size); + if (!inject_buf) { + free(need_inject); + return 0; + } + + size_t inject_len = 0; + bool existing_entries = (arr_end - arr_start > 1); + for (uint32_t i = 0; i < pending_count; i++) { + if (!need_inject[i]) continue; + int written; + if (existing_entries || inject_len > 0) { + written = snprintf(inject_buf + inject_len, inject_buf_size - inject_len, + ",{\"ID\":%u,\"When\":%u}", + pending[i].achievement_id, pending[i].timestamp); + } else { + written = snprintf(inject_buf + inject_len, inject_buf_size - inject_len, + "{\"ID\":%u,\"When\":%u}", + pending[i].achievement_id, pending[i].timestamp); + } + if (written > 0) inject_len += (size_t)written; + } + free(need_inject); + + /* Build new body: prefix + inject + suffix */ + size_t prefix_len = (size_t)(arr_end - body); + size_t suffix_len = body_len - prefix_len; + size_t new_len = prefix_len + inject_len + suffix_len; + char* new_body = (char*)malloc(new_len + 1); + if (!new_body) { + free(inject_buf); + return 0; + } + + memcpy(new_body, body, prefix_len); + memcpy(new_body + prefix_len, inject_buf, inject_len); + memcpy(new_body + prefix_len + inject_len, arr_end, suffix_len); + new_body[new_len] = '\0'; + free(inject_buf); + + *out_body = new_body; + *out_len = new_len; + return inject_count; +} + +static bool patch_startsession_with_ledger(char* body, size_t body_len, + const char* game_hash, + char** out_body, size_t* out_len) { + /* Get pending unlocks from ledger, pre-filtered by game hash */ + RA_PendingUnlock* pending = NULL; + uint32_t pending_count = 0; + if (!RA_Offline_ledgerGetPendingByGameHash(game_hash, &pending, &pending_count)) + goto passthrough; + + if (pending_count == 0 || !pending) + goto passthrough; + + /* + * Inject into both "Unlocks" (softcore) and "HardcoreUnlocks" arrays. + * This ensures rcheevos sees pending achievements as already unlocked + * in both modes, preventing it from re-awarding them via its own + * server call (which would lack &o= and record the wrong timestamp). + */ + char* current_body = body; + size_t current_len = body_len; + bool body_replaced = false; + uint32_t total_injected = 0; + + /* Pass 1: Inject into "Unlocks" (softcore) */ + { + char* new_body = NULL; + size_t new_len = 0; + uint32_t injected = inject_unlocks_into_array( + current_body, current_len, "\"Unlocks\"", + pending, pending_count, game_hash, + &new_body, &new_len); + if (injected > 0 && new_body) { + RA_LOG_DEBUG("Patching startsession: injected %u ledger unlocks " + "into Unlocks array\n", injected); + if (body_replaced) { + free(current_body); + } + current_body = new_body; + current_len = new_len; + body_replaced = true; + total_injected += injected; + } + } + + /* Pass 2: Inject into "HardcoreUnlocks" */ + { + char* new_body = NULL; + size_t new_len = 0; + uint32_t injected = inject_unlocks_into_array( + current_body, current_len, "\"HardcoreUnlocks\"", + pending, pending_count, game_hash, + &new_body, &new_len); + if (injected > 0 && new_body) { + RA_LOG_DEBUG("Patching startsession: injected %u ledger unlocks " + "into HardcoreUnlocks array\n", injected); + if (body_replaced) { + free(current_body); + } + current_body = new_body; + current_len = new_len; + body_replaced = true; + total_injected += injected; + } + } + + free(pending); + + if (total_injected > 0 && body_replaced) { + RA_LOG_DEBUG("Patched startsession: %zu -> %zu bytes\n", + body_len, current_len); + free(body); + *out_body = current_body; + *out_len = current_len; + return true; + } + + /* No injections needed — fall through to passthrough */ + *out_body = body; + *out_len = body_len; + return true; + +passthrough: + free(pending); + *out_body = body; + *out_len = body_len; + return true; +} + +bool RA_Offline_patchStartsessionResponse(char* body, size_t body_len, + const char* game_hash, + char** out_body, size_t* out_len) { + if (!SDL_AtomicGet(&ra_offline_initialized) || !body || body_len == 0) return false; + + /* Delegate to the internal patching function, which reads the ledger + * once internally. No need to pre-check — the function is a no-op + * when there are no matching pending unlocks. */ + char* orig_body = body; + if (!patch_startsession_with_ledger(body, body_len, game_hash, out_body, out_len)) { + return false; + } + + /* patch_startsession_with_ledger returns the original body unchanged + * when no patching is needed. Check if it actually changed. */ + return (*out_body != orig_body); +} + +bool RA_Offline_getCachedResponse(const char* url, const char* post_data, + char** out_body, size_t* out_len) { + if (!SDL_AtomicGet(&ra_offline_initialized) || !out_body || !out_len) return false; + + char type_buf[32]; + const char* req_type = get_request_type(url, post_data, type_buf, sizeof(type_buf)); + if (!req_type) return false; + + char path[512]; + cache_file_path(req_type, url, post_data, path, sizeof(path)); + if (path[0] == '\0') return false; + + if (!cache_read_file(path, out_body, out_len)) { + RA_LOG_DEBUG("Cache miss for %s: %s\n", req_type, path); + return false; + } + + RA_LOG_DEBUG("Cache hit for %s (%zu bytes) from %s\n", + req_type, *out_len, path); + + /* For startsession responses served from cache, patch in any pending ledger unlocks + * so rcheevos shows offline-earned achievements as unlocked on restart. + * Note: we intentionally do NOT gate on ra_offline_mode here — by the time this + * function runs, the connectivity probe may have already flipped the flag to false + * on its thread, but we still need to patch the cached response. The underlying + * patch_startsession_with_ledger() safely no-ops when there are no pending unlocks. */ + if (strcmp(req_type, "startsession") == 0) { + char game_hash[64] = {0}; + get_game_hash_param(url, post_data, game_hash, sizeof(game_hash)); + + size_t pre_patch_len = *out_len; + if (!patch_startsession_with_ledger(*out_body, *out_len, game_hash, + out_body, out_len)) { + RA_LOG_WARN("Failed to patch startsession with ledger data\n"); + } else if (*out_len != pre_patch_len) { + RA_LOG_INFO("Patched offline startsession (%zu -> %zu bytes)\n", + pre_patch_len, *out_len); + } + } + + return true; +} + +/***************************************************************************** + * Ledger implementation + *****************************************************************************/ + +/** + * Compute the SHA-256 of a complete ledger record (for chain linking). + * This hashes the entire record including its record_hash field. + */ +static void ledger_hash_record(const RA_LedgerRecord* rec, uint8_t out[NUI_SHA256_DIGEST_SIZE]) { + EVP_Digest(rec, sizeof(RA_LedgerRecord), out, NULL, EVP_sha256(), NULL); +} + +/** + * Compute the record_hash field (hashes everything except record_hash itself). + */ +static void ledger_compute_record_hash(RA_LedgerRecord* rec) { + EVP_Digest(rec, RA_LEDGER_RECORD_HASHABLE_SIZE, rec->record_hash, NULL, EVP_sha256(), NULL); +} + +/** + * Validate the ledger file and rebuild chain state. + * Returns the number of valid records, and sets ra_ledger_last_hash. + */ +static uint32_t ledger_validate_and_load(void) { + FILE* f = fopen(ra_ledger_path, "rb"); + if (!f) { + RA_LOG_DEBUG("No ledger file found (will create on first write)\n"); + memset(ra_ledger_last_hash, 0, NUI_SHA256_DIGEST_SIZE); + ra_ledger_has_records = false; + return 0; + } + + uint32_t valid_count = 0; + uint32_t total_read = 0; + bool chain_broken = false; + uint8_t prev_hash[NUI_SHA256_DIGEST_SIZE]; + memset(prev_hash, 0, NUI_SHA256_DIGEST_SIZE); + + RA_LedgerRecord rec; + while (fread(&rec, sizeof(rec), 1, f) == 1) { + total_read++; + + /* Verify prev_hash chain */ + if (memcmp(rec.prev_hash, prev_hash, NUI_SHA256_DIGEST_SIZE) != 0) { + RA_LOG_WARN("Ledger chain broken at record %u (skipping)\n", total_read - 1); + chain_broken = true; + /* Reset chain expectation to this record's actual prev_hash + * so we can continue validating subsequent records */ + } + + /* Verify record_hash (self-integrity, independent of chain) */ + uint8_t expected_hash[NUI_SHA256_DIGEST_SIZE]; + EVP_Digest(&rec, RA_LEDGER_RECORD_HASHABLE_SIZE, expected_hash, NULL, EVP_sha256(), NULL); + if (memcmp(rec.record_hash, expected_hash, NUI_SHA256_DIGEST_SIZE) != 0) { + RA_LOG_WARN("Ledger record_hash invalid at record %u (skipping)\n", total_read - 1); + chain_broken = true; + /* Skip this record but continue reading — don't update prev_hash + * so the next record's chain link will also break, but we'll + * still read its data for pending-unlock purposes */ + continue; + } + + /* Record has valid self-hash — update chain state */ + ledger_hash_record(&rec, prev_hash); + valid_count++; + } + + fclose(f); + + if (chain_broken) { + /* Chain was broken but we recovered what we could. Compact to fix. + * Don't truncate — that loses valid records after the break point. + * Instead, trigger a compaction on next sync to rebuild the chain. */ + RA_LOG_WARN("Ledger has chain integrity issues (%u/%u records valid). " + "Will be repaired on next compaction.\n", valid_count, total_read); + } + + memcpy(ra_ledger_last_hash, prev_hash, NUI_SHA256_DIGEST_SIZE); + ra_ledger_has_records = (valid_count > 0); + + RA_LOG_DEBUG("Ledger loaded: %u valid records\n", valid_count); + return valid_count; +} + +/** + * Background thread: drains the ledger write queue, writing each record to + * disk with fsync so data survives a crash. All blocking I/O happens here, + * never on the main thread. + */ +static int ledger_writer_thread(void* userdata) { + (void)userdata; + LedgerWriteQueue* wq = &ra_ledger_wq; + + while (1) { + SDL_LockMutex(wq->mutex); + + /* Wait until there is work to do or we're asked to stop */ + while (wq->count == 0 && wq->running) { + SDL_CondWait(wq->cond_nonempty, wq->mutex); + } + + if (wq->count == 0 && !wq->running) { + /* Shutdown signal and queue is empty — exit cleanly */ + SDL_UnlockMutex(wq->mutex); + break; + } + + RA_LedgerRecord rec = wq->records[wq->head]; + wq->head = (wq->head + 1) % RA_LEDGER_WRITE_QUEUE_SIZE; + wq->count--; + + SDL_UnlockMutex(wq->mutex); + + /* Write to disk — blocking I/O, safe on background thread */ + FILE* f = fopen(ra_ledger_path, "ab"); + if (!f) { + RA_LOG_ERROR("Ledger writer: failed to open ledger: %s\n", + strerror(errno)); + } else { + if (fwrite(&rec, sizeof(RA_LedgerRecord), 1, f) != 1) { + RA_LOG_ERROR("Ledger writer: fwrite failed: %s\n", + strerror(errno)); + } + fflush(f); + fsync(fileno(f)); + fclose(f); + } + + /* Signal any thread waiting for a free slot or for the queue to drain */ + SDL_LockMutex(wq->mutex); + SDL_CondSignal(wq->cond_empty); + SDL_UnlockMutex(wq->mutex); + } + + return 0; +} + +/** + * Start the ledger background writer thread. + */ +static void ledger_writer_start(void) { + LedgerWriteQueue* wq = &ra_ledger_wq; + memset(wq, 0, sizeof(*wq)); + wq->mutex = SDL_CreateMutex(); + wq->cond_nonempty = SDL_CreateCond(); + wq->cond_empty = SDL_CreateCond(); + wq->running = true; + wq->thread = SDL_CreateThread(ledger_writer_thread, "ra_ledger_writer", NULL); + if (!wq->thread) { + RA_LOG_ERROR("Failed to create ledger writer thread: %s\n", SDL_GetError()); + } +} + +/** + * Stop the ledger background writer thread, flushing any queued records first. + */ +static void ledger_writer_stop(void) { + LedgerWriteQueue* wq = &ra_ledger_wq; + if (!wq->thread) return; + + /* Wait for queue to drain, then signal shutdown */ + SDL_LockMutex(wq->mutex); + while (wq->count > 0) { + SDL_CondWait(wq->cond_empty, wq->mutex); + } + wq->running = false; + SDL_CondSignal(wq->cond_nonempty); /* wake thread so it can exit */ + SDL_UnlockMutex(wq->mutex); + + SDL_WaitThread(wq->thread, NULL); + wq->thread = NULL; + + SDL_DestroyCond(wq->cond_empty); + SDL_DestroyCond(wq->cond_nonempty); + SDL_DestroyMutex(wq->mutex); + wq->mutex = NULL; + wq->cond_empty = NULL; + wq->cond_nonempty = NULL; +} + +/** + * Append a record to the ledger. + * + * The hash chain is finalized synchronously under ra_ledger_mutex (preserving + * strict ordering), then the completed record is handed off to the background + * writer thread. The main thread never blocks on disk I/O. + */ +static bool ledger_append(RA_LedgerRecord* rec) { + SDL_LockMutex(ra_ledger_mutex); + + /* Finalize hash chain — must be done in order, under the mutex */ + memcpy(rec->prev_hash, ra_ledger_last_hash, NUI_SHA256_DIGEST_SIZE); + ledger_compute_record_hash(rec); + + /* Update in-memory chain state immediately so the next enqueue sees it */ + ledger_hash_record(rec, ra_ledger_last_hash); + ra_ledger_has_records = true; + + /* Enqueue for async disk write. If the queue is full, wait up to 5s + * for the writer thread to drain a slot rather than silently dropping + * an achievement unlock record. */ + LedgerWriteQueue* wq = &ra_ledger_wq; + bool enqueued = false; + + SDL_LockMutex(wq->mutex); + if (wq->count >= RA_LEDGER_WRITE_QUEUE_SIZE) { + RA_LOG_WARN("Ledger write queue full — waiting for drain\n"); + SDL_CondWaitTimeout(wq->cond_empty, wq->mutex, 5000); + } + if (wq->count < RA_LEDGER_WRITE_QUEUE_SIZE) { + wq->records[wq->tail] = *rec; + wq->tail = (wq->tail + 1) % RA_LEDGER_WRITE_QUEUE_SIZE; + wq->count++; + SDL_CondSignal(wq->cond_nonempty); + enqueued = true; + } else { + RA_LOG_ERROR("Ledger write queue still full after 5s — record dropped!\n"); + } + SDL_UnlockMutex(wq->mutex); + + SDL_UnlockMutex(ra_ledger_mutex); + return enqueued; +} + +static void ledger_record_init(RA_LedgerRecord* rec, uint8_t type, + uint32_t game_id, uint32_t achievement_id, + uint8_t hardcore, const char* game_hash) { + memset(rec, 0, sizeof(*rec)); + rec->type = type; + rec->timestamp = (uint32_t)time(NULL); + rec->game_id = game_id; + rec->achievement_id = achievement_id; + rec->hardcore = hardcore; + if (game_hash) { + strncpy(rec->game_hash, game_hash, sizeof(rec->game_hash) - 1); + } +} + +void RA_Offline_ledgerWriteSessionStart(uint32_t game_id, const char* game_hash, + uint8_t hardcore) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + RA_LedgerRecord rec; + ledger_record_init(&rec, RA_LEDGER_SESSION_START, game_id, 0, hardcore, game_hash); + + if (ledger_append(&rec)) { + RA_LOG_DEBUG("Ledger: SESSION_START game=%u hash=%s\n", game_id, + game_hash ? game_hash : "(null)"); + } +} + +void RA_Offline_ledgerWriteUnlock(uint32_t game_id, uint32_t achievement_id, + const char* game_hash, uint8_t hardcore) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + /* Hardcore unlocks are never written to the offline ledger — they cannot + * be synced retroactively and would persist forever after compaction. */ + if (hardcore) { + RA_LOG_DEBUG("Ledger: skipping hardcore UNLOCK achievement=%u game=%u\n", + achievement_id, game_id); + return; + } + + RA_LedgerRecord rec; + ledger_record_init(&rec, RA_LEDGER_ACHIEVEMENT_UNLOCK, game_id, achievement_id, 0, game_hash); + + if (ledger_append(&rec)) { + RA_LOG_INFO("Ledger: UNLOCK achievement=%u game=%u timestamp=%u\n", + achievement_id, game_id, rec.timestamp); + } +} + +void RA_Offline_ledgerWriteSessionEnd(uint32_t game_id, const char* game_hash) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + RA_LedgerRecord rec; + ledger_record_init(&rec, RA_LEDGER_SESSION_END, game_id, 0, 0, game_hash); + + if (ledger_append(&rec)) { + RA_LOG_DEBUG("Ledger: SESSION_END game=%u\n", game_id); + } +} + +void RA_Offline_ledgerWriteSyncAck(uint32_t achievement_id, uint32_t game_id) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + RA_LedgerRecord rec; + ledger_record_init(&rec, RA_LEDGER_SYNC_ACK, game_id, achievement_id, 0, NULL); + + if (ledger_append(&rec)) { + RA_LOG_DEBUG("Ledger: SYNC_ACK achievement=%u game=%u\n", + achievement_id, game_id); + } +} + +/* + * Read the ledger and return only the pending (un-cancelled) softcore UNLOCK records. + * Implements order-aware SYNC_ACK matching: each SYNC_ACK cancels the earliest + * preceding unmatched UNLOCK with the same achievement ID. + * + * Caller MUST hold ra_ledger_mutex. + * + * On success: returns true, *out_records and *out_count are set. + * Caller must free(*out_records) when done. + * *out_count == 0 is a valid success (no pending records). + * On failure: returns false (allocation error). + */ +static bool ledger_read_pending_records(RA_LedgerRecord** out_records, + uint32_t* out_count) { + *out_records = NULL; + *out_count = 0; + + FILE* f = fopen(ra_ledger_path, "rb"); + if (!f) return true; /* No ledger = no pending records (not an error) */ + + fseek(f, 0, SEEK_END); + long file_size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (file_size <= 0 || (file_size % sizeof(RA_LedgerRecord)) != 0) { + fclose(f); + return true; + } + + uint32_t total_records = (uint32_t)(file_size / (long)sizeof(RA_LedgerRecord)); + RA_LedgerRecord* records = (RA_LedgerRecord*)malloc(total_records * sizeof(RA_LedgerRecord)); + if (!records) { + fclose(f); + return false; + } + + uint32_t read_count = (uint32_t)fread(records, sizeof(RA_LedgerRecord), total_records, f); + fclose(f); + + if (read_count < total_records) { + total_records = read_count; + } + + if (total_records == 0) { + free(records); + return true; + } + + /* Order-aware SYNC_ACK matching using a separate boolean array */ + bool* cancelled = (bool*)calloc(total_records, sizeof(bool)); + if (!cancelled) { + free(records); + return false; + } + + for (uint32_t i = 0; i < total_records; i++) { + if (records[i].type == RA_LEDGER_SYNC_ACK) { + for (uint32_t j = 0; j < i; j++) { + if (records[j].type == RA_LEDGER_ACHIEVEMENT_UNLOCK && + records[j].achievement_id == records[i].achievement_id && + !cancelled[j]) { + cancelled[j] = true; + break; /* only cancel ONE unlock per SYNC_ACK */ + } + } + } + } + + /* Collect pending softcore UNLOCK records */ + RA_LedgerRecord* pending = (RA_LedgerRecord*)malloc(total_records * sizeof(RA_LedgerRecord)); + if (!pending) { + free(cancelled); + free(records); + return false; + } + + uint32_t pending_count = 0; + for (uint32_t i = 0; i < total_records; i++) { + if (records[i].type == RA_LEDGER_ACHIEVEMENT_UNLOCK && + records[i].hardcore == 0 && + !cancelled[i]) { + pending[pending_count++] = records[i]; + } + } + + free(cancelled); + free(records); + + if (pending_count == 0) { + free(pending); + return true; + } + + *out_records = pending; + *out_count = pending_count; + return true; +} + +void RA_Offline_ledgerCompact(void) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + SDL_LockMutex(ra_ledger_mutex); + + /* Read pending records using the shared matching logic */ + RA_LedgerRecord* kept = NULL; + uint32_t kept_count = 0; + if (!ledger_read_pending_records(&kept, &kept_count)) { + RA_LOG_ERROR("Compact: failed to read pending records\n"); + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + + if (kept_count == 0) { + /* Everything was acked (or no ledger) — delete the ledger */ + free(kept); + if (unlink(ra_ledger_path) == 0) { + RA_LOG_INFO("Compact: ledger fully synced, deleted\n"); + } + /* If unlink fails, the file may not exist — that's fine */ + memset(ra_ledger_last_hash, 0, NUI_SHA256_DIGEST_SIZE); + ra_ledger_has_records = false; + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + + /* Rewrite the ledger with only the kept records, rebuilding the hash chain */ + RA_LOG_INFO("Compact: keeping %u pending records\n", kept_count); + + /* Write to a temp file, then rename for atomicity */ + char tmp_path[512]; + snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", ra_ledger_path); + + FILE* out = fopen(tmp_path, "wb"); + if (!out) { + RA_LOG_ERROR("Compact: failed to create temp file: %s\n", strerror(errno)); + free(kept); + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + + /* Rebuild hash chain from scratch */ + uint8_t prev_hash[NUI_SHA256_DIGEST_SIZE]; + memset(prev_hash, 0, NUI_SHA256_DIGEST_SIZE); + + for (uint32_t i = 0; i < kept_count; i++) { + /* Update chain links */ + memcpy(kept[i].prev_hash, prev_hash, NUI_SHA256_DIGEST_SIZE); + ledger_compute_record_hash(&kept[i]); + + if (fwrite(&kept[i], sizeof(RA_LedgerRecord), 1, out) != 1) { + RA_LOG_ERROR("Compact: write failed at record %u\n", i); + fclose(out); + unlink(tmp_path); + free(kept); + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + + /* Advance chain */ + ledger_hash_record(&kept[i], prev_hash); + } + + fflush(out); + fsync(fileno(out)); + fclose(out); + + /* Atomic replace */ + if (rename(tmp_path, ra_ledger_path) != 0) { + RA_LOG_ERROR("Compact: rename failed: %s\n", strerror(errno)); + unlink(tmp_path); + free(kept); + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + + /* Update in-memory chain state */ + memcpy(ra_ledger_last_hash, prev_hash, NUI_SHA256_DIGEST_SIZE); + ra_ledger_has_records = true; + + RA_LOG_INFO("Compact: rewrote ledger with %u records\n", kept_count); + free(kept); + SDL_UnlockMutex(ra_ledger_mutex); +} + +bool RA_Offline_ledgerGetPendingUnlocks(RA_PendingUnlock** out_unlocks, + uint32_t* out_count) { + if (!SDL_AtomicGet(&ra_offline_initialized) || !out_unlocks || !out_count) return false; + + SDL_LockMutex(ra_ledger_mutex); + + *out_unlocks = NULL; + *out_count = 0; + + /* Use the shared matching logic to get pending records */ + RA_LedgerRecord* records = NULL; + uint32_t record_count = 0; + if (!ledger_read_pending_records(&records, &record_count)) { + SDL_UnlockMutex(ra_ledger_mutex); + return false; + } + + if (record_count == 0) { + SDL_UnlockMutex(ra_ledger_mutex); + return true; + } + + /* Convert RA_LedgerRecord to RA_PendingUnlock */ + RA_PendingUnlock* unlocks = (RA_PendingUnlock*)malloc(record_count * sizeof(RA_PendingUnlock)); + if (!unlocks) { + free(records); + SDL_UnlockMutex(ra_ledger_mutex); + return false; + } + + for (uint32_t i = 0; i < record_count; i++) { + unlocks[i].achievement_id = records[i].achievement_id; + unlocks[i].game_id = records[i].game_id; + unlocks[i].timestamp = records[i].timestamp; + unlocks[i].hardcore = 0; + memcpy(unlocks[i].game_hash, records[i].game_hash, sizeof(records[i].game_hash)); + } + free(records); + + *out_unlocks = unlocks; + *out_count = record_count; + RA_LOG_DEBUG("Ledger: %u pending softcore unlocks\n", record_count); + SDL_UnlockMutex(ra_ledger_mutex); + return true; +} + +bool RA_Offline_ledgerGetPendingCount(uint32_t* out_count) { + if (!out_count) return false; + *out_count = 0; + if (!SDL_AtomicGet(&ra_offline_initialized)) return false; + + SDL_LockMutex(ra_ledger_mutex); + RA_LedgerRecord* records = NULL; + uint32_t record_count = 0; + if (!ledger_read_pending_records(&records, &record_count)) { + SDL_UnlockMutex(ra_ledger_mutex); + return false; + } + free(records); + SDL_UnlockMutex(ra_ledger_mutex); + + *out_count = record_count; + return true; +} + +bool RA_Offline_ledgerGetPendingByGameHash(const char* game_hash, + RA_PendingUnlock** out_unlocks, + uint32_t* out_count) { + if (!game_hash || !out_unlocks || !out_count) return false; + + RA_PendingUnlock* all = NULL; + uint32_t all_count = 0; + if (!RA_Offline_ledgerGetPendingUnlocks(&all, &all_count)) + return false; + + if (all_count == 0) { + *out_unlocks = NULL; + *out_count = 0; + return true; + } + + /* Count matches */ + uint32_t match_count = 0; + for (uint32_t i = 0; i < all_count; i++) { + if (strcmp(all[i].game_hash, game_hash) == 0) + match_count++; + } + + if (match_count == 0) { + free(all); + *out_unlocks = NULL; + *out_count = 0; + return true; + } + + RA_PendingUnlock* filtered = (RA_PendingUnlock*)malloc( + match_count * sizeof(RA_PendingUnlock)); + if (!filtered) { + free(all); + return false; + } + + uint32_t j = 0; + for (uint32_t i = 0; i < all_count; i++) { + if (strcmp(all[i].game_hash, game_hash) == 0) + filtered[j++] = all[i]; + } + free(all); + + *out_unlocks = filtered; + *out_count = match_count; + return true; +} + +bool RA_Offline_ledgerGetPendingByGameId(uint32_t game_id, + RA_PendingUnlock** out_unlocks, + uint32_t* out_count) { + if (!out_unlocks || !out_count) return false; + + /* game_id == 0 means "all games" — just delegate to unfiltered API */ + if (game_id == 0) + return RA_Offline_ledgerGetPendingUnlocks(out_unlocks, out_count); + + RA_PendingUnlock* all = NULL; + uint32_t all_count = 0; + if (!RA_Offline_ledgerGetPendingUnlocks(&all, &all_count)) + return false; + + if (all_count == 0) { + *out_unlocks = NULL; + *out_count = 0; + return true; + } + + uint32_t match_count = 0; + for (uint32_t i = 0; i < all_count; i++) { + if (all[i].game_id == game_id) + match_count++; + } + + if (match_count == 0) { + free(all); + *out_unlocks = NULL; + *out_count = 0; + return true; + } + + RA_PendingUnlock* filtered = (RA_PendingUnlock*)malloc( + match_count * sizeof(RA_PendingUnlock)); + if (!filtered) { + free(all); + return false; + } + + uint32_t j = 0; + for (uint32_t i = 0; i < all_count; i++) { + if (all[i].game_id == game_id) + filtered[j++] = all[i]; + } + free(all); + + *out_unlocks = filtered; + *out_count = match_count; + return true; +} + +bool RA_Offline_ledgerFindPendingUnlock(uint32_t achievement_id, + RA_PendingUnlock* out) { + if (!out) return false; + + RA_PendingUnlock* all = NULL; + uint32_t all_count = 0; + if (!RA_Offline_ledgerGetPendingUnlocks(&all, &all_count)) + return false; + + for (uint32_t i = 0; i < all_count; i++) { + if (all[i].achievement_id == achievement_id) { + *out = all[i]; + free(all); + return true; + } + } + + free(all); + return false; +} + +/***************************************************************************** + * Offline mode state + *****************************************************************************/ + +bool RA_Offline_isOffline(void) { + return SDL_AtomicGet(&ra_offline_mode) != 0; +} + +void RA_Offline_setOffline(bool offline) { + if ((SDL_AtomicGet(&ra_offline_mode) != 0) != offline) { + SDL_AtomicSet(&ra_offline_mode, offline ? 1 : 0); + RA_LOG_INFO("Offline mode: %s\n", offline ? "ON" : "OFF"); + } +} + +/***************************************************************************** + * Sync engine state + *****************************************************************************/ + +bool RA_Offline_isSyncing(void) { + return SDL_AtomicGet(&ra_sync_in_progress) != 0; +} + +void RA_Offline_setSyncing(bool syncing) { + SDL_AtomicSet(&ra_sync_in_progress, syncing ? 1 : 0); +} + +/***************************************************************************** + * Initialization / shutdown + *****************************************************************************/ + +void RA_Offline_init(const char* data_dir) { + /* Must be called from the main thread before any background threads + * are started — ledger_validate_and_load() runs before the mutex + * exists, which is safe only because no concurrent access is possible. */ + if (SDL_AtomicGet(&ra_offline_initialized)) return; + + /* Store base paths */ + snprintf(ra_data_dir, sizeof(ra_data_dir), "%s%s", data_dir, RA_OFFLINE_DIR); + snprintf(ra_cache_dir, sizeof(ra_cache_dir), "%s%s", data_dir, RA_CACHE_DIR); + snprintf(ra_ledger_path, sizeof(ra_ledger_path), "%s%s", data_dir, RA_LEDGER_FILE); + + /* Create directories */ + if (ra_mkdirs(ra_cache_dir) != 0) { + RA_LOG_ERROR("Failed to create cache directory: %s (%s)\n", + ra_cache_dir, strerror(errno)); + /* Continue anyway - caching will fail gracefully */ + } + + /* Validate and load ledger */ + ledger_validate_and_load(); + + /* Create ledger mutex for thread-safe access */ + ra_ledger_mutex = SDL_CreateMutex(); + if (!ra_ledger_mutex) { + RA_LOG_ERROR("Failed to create ledger mutex: %s\n", SDL_GetError()); + } + + /* Create cache mutex for serializing cache file read-modify-write */ + ra_cache_mutex = SDL_CreateMutex(); + if (!ra_cache_mutex) { + RA_LOG_ERROR("Failed to create cache mutex: %s\n", SDL_GetError()); + } + + /* Start the background ledger writer thread */ + ledger_writer_start(); + + SDL_AtomicSet(&ra_offline_initialized, 1); + RA_LOG_INFO("Initialized (cache: %s, ledger: %s)\n", ra_cache_dir, ra_ledger_path); +} + +void RA_Offline_shutdown(void) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + /* Stop the background writer first — flushes any queued records to disk */ + ledger_writer_stop(); + + SDL_AtomicSet(&ra_offline_initialized, 0); + SDL_AtomicSet(&ra_offline_mode, 0); + SDL_AtomicSet(&ra_sync_in_progress, 0); + ra_ledger_has_records = false; + ra_pending_count = 0; + memset(ra_ledger_last_hash, 0, NUI_SHA256_DIGEST_SIZE); + + if (ra_ledger_mutex) { + SDL_DestroyMutex(ra_ledger_mutex); + ra_ledger_mutex = NULL; + } + + if (ra_cache_mutex) { + SDL_DestroyMutex(ra_cache_mutex); + ra_cache_mutex = NULL; + } + + RA_LOG_INFO("Shut down\n"); +} + +/***************************************************************************** + * Pending offline unlock cache (for UI queries) + *****************************************************************************/ + +void RA_Offline_refreshPendingCache(void) { + if (!SDL_AtomicGet(&ra_offline_initialized)) return; + + /* Read pending unlocks (ledgerGetPendingUnlocks locks internally) */ + RA_PendingUnlock* unlocks = NULL; + uint32_t count = 0; + if (!RA_Offline_ledgerGetPendingUnlocks(&unlocks, &count) || count == 0) { + SDL_LockMutex(ra_ledger_mutex); + ra_pending_count = 0; + SDL_UnlockMutex(ra_ledger_mutex); + free(unlocks); + return; + } + + SDL_LockMutex(ra_ledger_mutex); + uint32_t to_cache = count < RA_MAX_PENDING_CACHE ? count : RA_MAX_PENDING_CACHE; + if (count > RA_MAX_PENDING_CACHE) { + RA_LOG_WARN("Pending cache truncated: %u entries, max %u\n", + count, RA_MAX_PENDING_CACHE); + } + for (uint32_t i = 0; i < to_cache; i++) { + ra_pending_ids[i] = unlocks[i].achievement_id; + } + ra_pending_count = to_cache; + SDL_UnlockMutex(ra_ledger_mutex); + free(unlocks); + + RA_LOG_DEBUG("Pending cache refreshed: %u entries\n", to_cache); +} + +bool RA_Offline_isUnlockPending(uint32_t achievement_id) { + SDL_LockMutex(ra_ledger_mutex); + bool found = false; + for (uint32_t i = 0; i < ra_pending_count; i++) { + if (ra_pending_ids[i] == achievement_id) { + found = true; + break; + } + } + SDL_UnlockMutex(ra_ledger_mutex); + return found; +} + +void RA_Offline_addPendingCacheEntry(uint32_t achievement_id) { + SDL_LockMutex(ra_ledger_mutex); + /* Check for duplicates */ + for (uint32_t i = 0; i < ra_pending_count; i++) { + if (ra_pending_ids[i] == achievement_id) { + SDL_UnlockMutex(ra_ledger_mutex); + return; + } + } + if (ra_pending_count < RA_MAX_PENDING_CACHE) { + ra_pending_ids[ra_pending_count++] = achievement_id; + } else { + RA_LOG_WARN("Pending cache full (%u entries), achievement %u not cached for UI\n", + RA_MAX_PENDING_CACHE, achievement_id); + } + SDL_UnlockMutex(ra_ledger_mutex); +} + +void RA_Offline_removePendingCacheEntry(uint32_t achievement_id) { + SDL_LockMutex(ra_ledger_mutex); + for (uint32_t i = 0; i < ra_pending_count; i++) { + if (ra_pending_ids[i] == achievement_id) { + /* Shift remaining elements down */ + for (uint32_t j = i; j < ra_pending_count - 1; j++) { + ra_pending_ids[j] = ra_pending_ids[j + 1]; + } + ra_pending_count--; + break; + } + } + SDL_UnlockMutex(ra_ledger_mutex); +} + +void RA_Offline_clearPendingCache(void) { + SDL_LockMutex(ra_ledger_mutex); + ra_pending_count = 0; + SDL_UnlockMutex(ra_ledger_mutex); +} + +void RA_Offline_patchStartsessionCacheWithUnlock(const char* game_hash, + uint32_t achievement_id, + uint32_t timestamp) { + if (!SDL_AtomicGet(&ra_offline_initialized) || !game_hash || game_hash[0] == '\0') return; + + /* Serialize the entire read-modify-write to prevent lost updates when + * HTTP worker threads and the sync thread patch the same game file. */ + SDL_LockMutex(ra_cache_mutex); + + /* Build cache file path: /startsession_.bin */ + char path[512]; + snprintf(path, sizeof(path), "%s/startsession_%s.bin", ra_cache_dir, game_hash); + + /* Read existing cache file */ + char* body = NULL; + size_t body_len = 0; + if (!cache_read_file(path, &body, &body_len)) { + /* No cached startsession for this game, or corrupt — nothing to patch */ + goto done; + } + + /* Find the "Unlocks" array */ + const char* unlocks_key = "\"Unlocks\""; + char* unlocks_pos = strstr(body, unlocks_key); + if (!unlocks_pos) { + free(body); + goto done; + } + + char* arr_start = strchr(unlocks_pos + strlen(unlocks_key), '['); + if (!arr_start) { + free(body); + goto done; + } + + char* arr_end = strchr(arr_start, ']'); + if (!arr_end) { + free(body); + goto done; + } + + /* Check if this achievement ID is already in the Unlocks array */ + char id_pattern[32]; + snprintf(id_pattern, sizeof(id_pattern), "\"ID\":%u", achievement_id); + bool already_in_softcore = false; + for (char* p = arr_start; p < arr_end; p++) { + if (strncmp(p, id_pattern, strlen(id_pattern)) == 0) { + already_in_softcore = true; + break; + } + } + + /* Also check HardcoreUnlocks array */ + bool already_in_hardcore = false; + const char* hc_key = "\"HardcoreUnlocks\""; + char* hc_pos = strstr(body, hc_key); + if (hc_pos) { + char* hc_arr_start = strchr(hc_pos + strlen(hc_key), '['); + if (hc_arr_start) { + char* hc_arr_end = strchr(hc_arr_start, ']'); + if (hc_arr_end) { + for (char* p = hc_arr_start; p < hc_arr_end; p++) { + if (strncmp(p, id_pattern, strlen(id_pattern)) == 0) { + already_in_hardcore = true; + break; + } + } + } + } + } + + if (already_in_softcore && already_in_hardcore) { + /* Already present in both arrays — no-op */ + free(body); + goto done; + } + + /* + * Use inject_unlocks_into_array for each array that needs patching. + * We create a temporary single-element pending array to reuse the helper. + */ + RA_PendingUnlock single; + single.achievement_id = achievement_id; + single.timestamp = timestamp; + single.game_id = 0; + single.hardcore = 0; + /* game_hash filter: use empty string so the filter is bypassed + * (inject_unlocks_into_array skips entries where game_hash doesn't match, + * but if we pass NULL as game_hash it accepts all entries). */ + single.game_hash[0] = '\0'; + + char* current_body = body; + size_t current_len = body_len; + bool body_replaced = false; + + /* Inject into "Unlocks" if not already present */ + if (!already_in_softcore) { + char* new_body = NULL; + size_t new_len = 0; + uint32_t injected = inject_unlocks_into_array( + current_body, current_len, "\"Unlocks\"", + &single, 1, NULL, + &new_body, &new_len); + if (injected > 0 && new_body) { + if (body_replaced) free(current_body); + current_body = new_body; + current_len = new_len; + body_replaced = true; + } + } + + /* Inject into "HardcoreUnlocks" if not already present */ + if (!already_in_hardcore) { + char* new_body = NULL; + size_t new_len = 0; + uint32_t injected = inject_unlocks_into_array( + current_body, current_len, "\"HardcoreUnlocks\"", + &single, 1, NULL, + &new_body, &new_len); + if (injected > 0 && new_body) { + if (body_replaced) free(current_body); + current_body = new_body; + current_len = new_len; + body_replaced = true; + } + } + + if (!body_replaced) { + /* Nothing was injected (shouldn't happen given the checks above) */ + free(body); + goto done; + } + + /* Free the original body (if not already freed via body_replaced logic) */ + if (body_replaced && current_body != body) { + free(body); + } + + /* Rewrite cache file with updated body and new SHA-256 */ + if (cache_write_file(path, current_body, current_len)) { + RA_LOG_INFO("patchCache: injected achievement %u into %s\n", + achievement_id, path); + } + free(current_body); + +done: + SDL_UnlockMutex(ra_cache_mutex); +} + diff --git a/workspace/all/common/ra_offline.h b/workspace/all/common/ra_offline.h new file mode 100644 index 000000000..ba0c333be --- /dev/null +++ b/workspace/all/common/ra_offline.h @@ -0,0 +1,315 @@ +#ifndef RA_OFFLINE_H +#define RA_OFFLINE_H + +#include +#include +#include + +/** + * Offline RetroAchievements support for NextUI. + * + * Provides: + * - Response cache: Caches rcheevos server responses so games can load + * with achievements active when offline. + * - Unlock ledger: Append-only binary log with hash chain integrity for + * persisting achievement unlocks across app restarts and power cycles. + * The ledger is the single source of truth for offline unlocks until the + * sync engine confirms them with the RA server. During gameplay, UNLOCK + * records are appended; when connectivity is restored, the sync engine + * reads pending records, submits them, writes SYNC_ACK records on + * success, and finally compacts the file to remove confirmed records. + * - Sync engine: Submits pending offline unlocks to the server with + * realistic timing when connectivity is restored. + */ + +/* Cache directory under SHARED_USERDATA_PATH */ +#define RA_OFFLINE_DIR "/.ra/offline" +#define RA_CACHE_DIR "/.ra/offline/cache" +#define RA_LEDGER_FILE "/.ra/offline/ledger.bin" + +/***************************************************************************** + * Ledger record types + *****************************************************************************/ + +typedef enum { + RA_LEDGER_SESSION_START = 0x01, + RA_LEDGER_ACHIEVEMENT_UNLOCK = 0x02, + RA_LEDGER_SESSION_END = 0x03, + RA_LEDGER_SYNC_ACK = 0x04 +} RA_LedgerRecordType; + +/** + * On-disk ledger record (packed, fixed size). + * + * Hash chain: each record's record_hash = SHA-256(bytes 0 .. prev_hash end), + * covering RA_LEDGER_RECORD_HASHABLE_SIZE bytes. prev_hash = SHA-256 of + * the *entire* previous record (all fields including its record_hash), + * or zeros for the first record in the chain. + */ +#pragma pack(push, 1) +typedef struct { + uint8_t type; /* RA_LedgerRecordType */ + uint32_t timestamp; /* Unix epoch seconds */ + uint32_t game_id; /* RA game ID */ + uint32_t achievement_id; /* 0 for session records */ + uint8_t hardcore; /* 0=softcore, 1=hardcore */ + char game_hash[33]; /* MD5 hex string + NUL (char, not uint8_t, + * because rcheevos provides it as text) */ + uint8_t prev_hash[32]; /* SHA-256 of entire previous record (zeros for first) */ + uint8_t record_hash[32]; /* SHA-256 of bytes [0, RA_LEDGER_RECORD_HASHABLE_SIZE) */ +} RA_LedgerRecord; +#pragma pack(pop) + +/* Bytes hashed to produce record_hash: everything up to (not including) record_hash */ +#define RA_LEDGER_RECORD_HASHABLE_SIZE offsetof(RA_LedgerRecord, record_hash) + +/* Pending unlock info returned by ledger query */ +typedef struct { + uint32_t achievement_id; + uint32_t game_id; + uint32_t timestamp; /* Unix epoch of original unlock */ + char game_hash[33]; + uint8_t hardcore; +} RA_PendingUnlock; + +/***************************************************************************** + * Initialization / shutdown + *****************************************************************************/ + +/** + * Initialize the offline subsystem. + * Creates directories, loads ledger state. + * @param data_dir Base userdata path (SHARED_USERDATA_PATH) + */ +void RA_Offline_init(const char* data_dir); + +/** + * Shut down the offline subsystem. + * Flushes any pending state. + */ +void RA_Offline_shutdown(void); + +/***************************************************************************** + * Offline mode state + *****************************************************************************/ + +/** + * Check if we are currently in offline mode. + * @return true if offline (no network connectivity) + */ +bool RA_Offline_isOffline(void); + +/** + * Set the offline mode state. + * Called during init based on WiFi state. + */ +void RA_Offline_setOffline(bool offline); + +/***************************************************************************** + * Response cache + *****************************************************************************/ + +/** + * Cache a server response to disk (write-through). + * Determines cache key from URL/post_data parameters. + * Only caches login and game data (patch) responses. + * + * @param url Request URL + * @param post_data POST body (may be NULL) + * @param response_body Response body + * @param response_len Response body length + */ +void RA_Offline_cacheResponse(const char* url, const char* post_data, + const char* response_body, size_t response_len); + +/** + * Retrieve a cached response from disk. + * + * @param url Request URL + * @param post_data POST body (may be NULL) + * @param out_body Output: allocated response body (caller must free) + * @param out_len Output: response body length + * @return true if cache hit, false if miss or corrupt + */ +bool RA_Offline_getCachedResponse(const char* url, const char* post_data, + char** out_body, size_t* out_len); + +/** + * Patch a startsession response body with pending ledger unlocks. + * Injects unsynced UNLOCK records into the "Unlocks" JSON array so + * rcheevos shows offline-earned achievements as unlocked. + * + * Works for both online and offline responses. The input body is freed + * on success and replaced with a new allocation. + * + * @param body Mutable response body (will be freed and replaced on success) + * @param body_len Length of body + * @param game_hash Game hash to filter ledger entries + * @param out_body Output: patched body (caller must free) + * @param out_len Output: patched body length + * @return true if patching occurred (out_body/out_len updated), false if + * no patching was needed (out_body/out_len unchanged, body NOT freed) + */ +bool RA_Offline_patchStartsessionResponse(char* body, size_t body_len, + const char* game_hash, + char** out_body, size_t* out_len); + +/***************************************************************************** + * Unlock ledger + *****************************************************************************/ + +/** + * Write a SESSION_START record to the ledger. + */ +void RA_Offline_ledgerWriteSessionStart(uint32_t game_id, const char* game_hash, + uint8_t hardcore); + +/** + * Write an ACHIEVEMENT_UNLOCK record to the ledger. + */ +void RA_Offline_ledgerWriteUnlock(uint32_t game_id, uint32_t achievement_id, + const char* game_hash, uint8_t hardcore); + +/** + * Write a SESSION_END record to the ledger. + */ +void RA_Offline_ledgerWriteSessionEnd(uint32_t game_id, const char* game_hash); + +/** + * Write a SYNC_ACK record to the ledger, marking an unlock as server-confirmed. + */ +void RA_Offline_ledgerWriteSyncAck(uint32_t achievement_id, uint32_t game_id); + +/** + * Get all pending (unsynced) softcore unlocks from the ledger. + * + * @param out_unlocks Output: allocated array (caller must free) + * @param out_count Output: number of entries + * @return true on success + */ +bool RA_Offline_ledgerGetPendingUnlocks(RA_PendingUnlock** out_unlocks, + uint32_t* out_count); + +/** + * Get the count of pending (unsynced) softcore unlocks without allocating. + * + * @param out_count Output: number of pending unlocks + * @return true on success + */ +bool RA_Offline_ledgerGetPendingCount(uint32_t* out_count); + +/** + * Get pending unlocks filtered by game hash. + * + * @param game_hash Game hash to filter by + * @param out_unlocks Output: allocated array (caller must free); NULL if count is 0 + * @param out_count Output: number of matching entries + * @return true on success + */ +bool RA_Offline_ledgerGetPendingByGameHash(const char* game_hash, + RA_PendingUnlock** out_unlocks, + uint32_t* out_count); + +/** + * Get pending unlocks filtered by game ID. + * + * @param game_id RA game ID to filter by (0 returns all) + * @param out_unlocks Output: allocated array (caller must free); NULL if count is 0 + * @param out_count Output: number of matching entries + * @return true on success + */ +bool RA_Offline_ledgerGetPendingByGameId(uint32_t game_id, + RA_PendingUnlock** out_unlocks, + uint32_t* out_count); + +/** + * Look up a single pending unlock by achievement ID. + * + * @param achievement_id Achievement ID to search for + * @param out Output: matching entry (only written on success) + * @return true if found, false if not found or error + */ +bool RA_Offline_ledgerFindPendingUnlock(uint32_t achievement_id, + RA_PendingUnlock* out); + +/***************************************************************************** + * Sync engine + *****************************************************************************/ + +/** + * Check if a sync is currently in progress. + */ +bool RA_Offline_isSyncing(void); + +/** + * Set the sync-in-progress flag. + * Called by the sync engine in ra_integration.c. + */ +void RA_Offline_setSyncing(bool syncing); + +/** + * Compact the ledger file after a successful sync. + * + * Removes all UNLOCK records that have matching SYNC_ACK records, and + * removes the SYNC_ACK records themselves. Keeps only truly pending + * UNLOCK records. All other record types (SESSION_START, SESSION_END, + * SYNC_ACK) are dropped. If nothing is pending, deletes the ledger + * file entirely. + * + * Rebuilds the hash chain from scratch on the compacted records. + * Thread-safety: call only from the sync thread after sync completes. + */ +void RA_Offline_ledgerCompact(void); + +/***************************************************************************** + * Pending offline unlock cache (for UI queries) + *****************************************************************************/ + +/** + * Refresh the in-memory cache of pending offline achievement IDs. + * Call after game load or after ledger writes to keep the cache current. + */ +void RA_Offline_refreshPendingCache(void); + +/** + * Check if an achievement has a pending offline unlock (not yet synced). + * Uses the in-memory cache for fast per-row lookups in the UI. + * @return true if the achievement was unlocked offline and not yet synced + */ +bool RA_Offline_isUnlockPending(uint32_t achievement_id); + +/** + * Add an achievement ID to the pending cache (after a new offline unlock). + * Avoids needing to re-read the full ledger. + */ +void RA_Offline_addPendingCacheEntry(uint32_t achievement_id); + +/** + * Remove an achievement ID from the pending cache (after server confirms unlock). + */ +void RA_Offline_removePendingCacheEntry(uint32_t achievement_id); + +/** + * Clear the pending cache (after successful sync). + */ +void RA_Offline_clearPendingCache(void); + +/** + * Patch a cached startsession file to inject a newly-confirmed unlock. + * + * Reads the cache file for the given game_hash, injects the achievement + * into the "Unlocks" JSON array if not already present, and rewrites the + * file with an updated SHA-256 digest. + * + * No-op if the cache file is absent, corrupt, or already contains the + * achievement. Thread-safe: read-modify-write serialized by ra_cache_mutex. + * + * @param game_hash Game hash (used to locate startsession_.bin) + * @param achievement_id Achievement ID to inject + * @param timestamp Unix timestamp for the "When" field + */ +void RA_Offline_patchStartsessionCacheWithUnlock(const char* game_hash, + uint32_t achievement_id, + uint32_t timestamp); + +#endif /* RA_OFFLINE_H */ diff --git a/workspace/all/common/ra_sync.c b/workspace/all/common/ra_sync.c new file mode 100644 index 000000000..1665f5af2 --- /dev/null +++ b/workspace/all/common/ra_sync.c @@ -0,0 +1,396 @@ +/* + * Standalone RetroAchievements offline sync engine. + * + * Submits pending offline achievement unlocks to the RA server + * without requiring the rcheevos library. Uses MD5 for the award + * request signature and the existing HTTP/ledger infrastructure. + * + * Shared by both settings (batch sync all games) and minarch + * (per-game sync on game load). + */ + +#define RA_LOG_PREFIX "RA_SYNC" +#include "ra_log.h" + +#include "ra_sync.h" +#include "ra_offline.h" +#define RA_UTIL_NEED_SDL +#include "ra_util.h" +#include "http.h" +#include "config.h" +#include "defines.h" +#include "api.h" + +#include +#include +#include +#include +#include +#include + +#define NUI_MD5_DIGEST_SIZE 16 + +#define RA_API_URL "https://retroachievements.org/dorequest.php" + +/* Default config used when NULL is passed */ +static const RA_SyncConfig sync_default_config = RA_SYNC_CONFIG_INTERACTIVE; + +/***************************************************************************** + * Internal helpers + *****************************************************************************/ + +static bool ra_sync_initialized = false; + +static void sync_ensure_init(void) { + if (!ra_sync_initialized) { + RA_Offline_init(SHARED_USERDATA_PATH); + ra_sync_initialized = true; + } +} + +/** + * Generate a random delay in [min_ms, max_ms]. + */ +static uint32_t sync_random_delay(uint32_t min_ms, uint32_t max_ms) { + if (min_ms >= max_ms) return min_ms; + return min_ms + (rand() % (max_ms - min_ms + 1)); +} + +/** + * Compute the MD5 signature for an award achievement request. + * + * The signature is: MD5(achievement_id_str + username + hardcore_str) + * If seconds_since > 0, appends: + achievement_id_str + seconds_since_str + * + * Result is a 32-char lowercase hex string + null terminator. + */ +static void sync_compute_signature(uint32_t achievement_id, + const char* username, + uint8_t hardcore, + uint32_t seconds_since, + char sig_out[33]) { + char id_str[16]; + char hc_str[2]; + char sec_str[16]; + + snprintf(id_str, sizeof(id_str), "%u", achievement_id); + snprintf(hc_str, sizeof(hc_str), "%u", hardcore ? 1 : 0); + + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + EVP_DigestInit_ex(ctx, EVP_md5(), NULL); + EVP_DigestUpdate(ctx, id_str, strlen(id_str)); + EVP_DigestUpdate(ctx, username, strlen(username)); + EVP_DigestUpdate(ctx, hc_str, strlen(hc_str)); + + if (seconds_since > 0) { + snprintf(sec_str, sizeof(sec_str), "%u", seconds_since); + EVP_DigestUpdate(ctx, id_str, strlen(id_str)); + EVP_DigestUpdate(ctx, sec_str, strlen(sec_str)); + } + + uint8_t digest[NUI_MD5_DIGEST_SIZE]; + EVP_DigestFinal_ex(ctx, digest, NULL); + EVP_MD_CTX_free(ctx); + + for (int i = 0; i < NUI_MD5_DIGEST_SIZE; i++) { + sprintf(sig_out + i * 2, "%02x", digest[i]); + } + sig_out[32] = '\0'; +} + +/** + * Build the POST body for an award achievement request. + * Returns allocated string (caller must free), or NULL on error. + */ +static char* sync_build_post_data(const char* username, + const char* token, + uint32_t achievement_id, + uint8_t hardcore, + const char* game_hash, + uint32_t seconds_since) { + char sig[33]; + sync_compute_signature(achievement_id, username, hardcore, seconds_since, sig); + + char* enc_username = HTTP_urlEncode(username); + char* enc_token = HTTP_urlEncode(token); + if (!enc_username || !enc_token) { + free(enc_username); + free(enc_token); + return NULL; + } + + /* r=awardachievement&u=&t=&a=&h=<0|1>&v=[&m=][&o=] */ + size_t buf_size = 512 + strlen(enc_username) + strlen(enc_token); + char* post_data = (char*)malloc(buf_size); + if (!post_data) { + free(enc_username); + free(enc_token); + return NULL; + } + + int written = snprintf(post_data, buf_size, + "r=awardachievement&u=%s&t=%s&a=%u&h=%u&v=%s", + enc_username, enc_token, achievement_id, + hardcore ? 1 : 0, sig); + + if (game_hash && game_hash[0] != '\0') { + written += snprintf(post_data + written, buf_size - written, + "&m=%s", game_hash); + } + if (seconds_since > 0) { + written += snprintf(post_data + written, buf_size - written, + "&o=%u", seconds_since); + } + + free(enc_username); + free(enc_token); + return post_data; +} + +/** + * Parse the RA server response to determine success/failure. + * + * Success: response contains "Success":true (with or without space after colon) + * Already unlocked: response contains "User already has" — treated as success + * + * Returns: 1 = success, 0 = server rejection (skip), -1 = network/parse error + */ +static int sync_parse_response(HTTP_Response* resp) { + if (!resp || resp->http_status != 200 || !resp->data || resp->size == 0) { + return -1; + } + + /* Check for success: "Success":true (with or without space) */ + if (ra_find_json_bool(resp->data, "Success") == 1) { + return 1; + } + + /* Check for "already has" — treat as success */ + if (strstr(resp->data, "User already has") != NULL) { + return 1; + } + + /* Server returned 200 but achievement was rejected */ + return 0; +} + +/***************************************************************************** + * Public API + *****************************************************************************/ + +bool RA_Sync_hasPendingUnlocks(uint32_t* out_count) { + sync_ensure_init(); + + uint32_t count = 0; + if (!RA_Offline_ledgerGetPendingCount(&count)) { + if (out_count) *out_count = 0; + return false; + } + + if (out_count) *out_count = count; + return count > 0; +} + +RA_SyncResult RA_Sync_syncAll(uint32_t game_id, + const RA_SyncConfig* config, + SDL_atomic_t* cancel, + RA_SyncProgressCallback progress_cb, + void* userdata) { + RA_SyncResult result = {0, 0, 0, 0}; + + sync_ensure_init(); + + /* Use default config if none provided */ + if (!config) { + config = &sync_default_config; + } + + /* Seed RNG once for random delays (avoid reseeding global PRNG on every call) */ + static bool rng_seeded = false; + if (!rng_seeded) { + srand((unsigned)time(NULL)); + rng_seeded = true; + } + + /* Get credentials. + * For the username used in hash validation, prefer the server (internal) + * username captured from the AvatarUrl during the user's last settings- + * initiated authentication. This may differ from the locally-configured + * username if the user has renamed their account. If it's empty, fall + * back to CFG_getRAUsername() — unlock timestamps may be rejected for + * renamed accounts, but the user can refresh by re-authenticating in + * settings. */ + const char* server_username = CFG_getRAServerUsername(); + const char* username = (server_username && strlen(server_username) > 0) + ? server_username + : CFG_getRAUsername(); + const char* token = CFG_getRAToken(); + + RA_LOG_DEBUG("Using username '%s' for sync hash computation " + "(server_username='%s', config_username='%s')\n", + username, + server_username ? server_username : "(null)", + CFG_getRAUsername() ? CFG_getRAUsername() : "(null)"); + + if (!username || !token || strlen(username) == 0 || strlen(token) == 0) { + return result; + } + + /* Read pending unlocks (filtered by game_id; 0 = all) */ + RA_PendingUnlock* unlocks = NULL; + uint32_t count = 0; + + if (!RA_Offline_ledgerGetPendingByGameId(game_id, &unlocks, &count) || count == 0) { + free(unlocks); + return result; + } + + result.total = count; + + RA_LOG_INFO("Starting sync: %u pending unlocks, game_id=%u, time_now=%lld\n", + count, game_id, (long long)time(NULL)); + + /* Process each pending unlock */ + for (uint32_t i = 0; i < count; i++) { + /* Check cancel before starting each submission */ + if (cancel && SDL_AtomicGet(cancel)) { + break; + } + + RA_PendingUnlock* unlock = &unlocks[i]; + + /* Skip hardcore (should already be filtered, but be safe) */ + if (unlock->hardcore) { + result.skipped++; + if (progress_cb) { + progress_cb(i + 1, count, false, userdata); + } + continue; + } + + /* Compute seconds since unlock */ + time_t now = time(NULL); + uint32_t seconds_since = 0; + if ((time_t)unlock->timestamp < now) { + seconds_since = (uint32_t)(now - (time_t)unlock->timestamp); + } + + /* Log timing details at debug level for diagnosing clock drift. */ + RA_LOG_DEBUG("ach=%u: ledger_timestamp=%u time_now=%lld seconds_since=%u\n", + unlock->achievement_id, unlock->timestamp, + (long long)now, seconds_since); + + /* Build request */ + char* post_data = sync_build_post_data(username, token, + unlock->achievement_id, + 0, /* softcore */ + unlock->game_hash, + seconds_since); + if (!post_data) { + RA_LOG_ERROR("ach=%u: failed to build POST data\n", unlock->achievement_id); + result.skipped++; + if (progress_cb) { + progress_cb(i + 1, count, false, userdata); + } + continue; + } + + /* Log the POST body at debug level (token redacted). */ + { + const char* t_start = strstr(post_data, "&t="); + if (t_start) { + const char* t_end = strchr(t_start + 3, '&'); + RA_LOG_DEBUG("ach=%u: POST %.*s&t=***%s\n", + unlock->achievement_id, + (int)(t_start - post_data), post_data, + t_end ? t_end : ""); + } else { + RA_LOG_DEBUG("ach=%u: POST %s\n", + unlock->achievement_id, post_data); + } + } + + /* Submit to server */ + HTTP_Response* http_resp = HTTP_post(RA_API_URL, post_data, NULL); + free(post_data); + + /* Log raw server response at debug level */ + if (http_resp && http_resp->data && http_resp->size > 0) { + int log_len = (int)http_resp->size; + if (log_len > 512) log_len = 512; + RA_LOG_DEBUG("ach=%u: server response (status=%d): %.*s\n", + unlock->achievement_id, + http_resp->http_status, + log_len, http_resp->data); + } else if (!http_resp) { + RA_LOG_WARN("ach=%u: no HTTP response (network failure?)\n", + unlock->achievement_id); + } + + int parse_result = sync_parse_response(http_resp); + if (http_resp) HTTP_freeResponse(http_resp); + + if (parse_result == 1) { + /* Success — write SYNC_ACK to ledger */ + RA_LOG_DEBUG("ach=%u: server accepted (seconds_since=%u)\n", + unlock->achievement_id, seconds_since); + RA_Offline_ledgerWriteSyncAck(unlock->achievement_id, unlock->game_id); + /* Patch cached startsession to include this unlock so the next + offline-first launch doesn't re-trigger it */ + RA_Offline_patchStartsessionCacheWithUnlock( + unlock->game_hash, unlock->achievement_id, unlock->timestamp); + result.synced++; + } else if (parse_result == 0) { + /* Server rejected — skip and continue */ + RA_LOG_WARN("ach=%u: server rejected\n", unlock->achievement_id); + result.skipped++; + } else { + /* Network error — stop sync, remaining unlocks stay pending */ + RA_LOG_ERROR("ach=%u: network error, stopping sync\n", unlock->achievement_id); + result.failed++; + if (progress_cb) { + progress_cb(i + 1, count, false, userdata); + } + break; + } + + if (progress_cb) { + progress_cb(i + 1, count, parse_result == 1, userdata); + } + + /* Delay before next submission (skip after last item or if cancelled) */ + if (i + 1 < count) { + uint32_t delay; + if ((i + 1) % config->delay_long_every == 0) { + delay = sync_random_delay(config->delay_long_min_ms, + config->delay_long_max_ms); + } else { + delay = sync_random_delay(config->delay_min_ms, + config->delay_max_ms); + } + if (ra_interruptible_sleep(delay, cancel)) { + break; /* Cancelled during delay */ + } + } + } + + free(unlocks); + + /* Post-sync ledger maintenance */ + if (result.failed == 0 && (!cancel || !SDL_AtomicGet(cancel))) { + /* Full sync completed — compact ledger to remove synced records */ + if (result.synced > 0) { + RA_Offline_clearPendingCache(); + RA_Offline_ledgerCompact(); + } + } else { + /* Partial sync (failure or cancel) — refresh cache to reflect + what's still pending. Synced records have SYNC_ACK written + and will be cleaned up on next full sync or compaction. */ + if (result.synced > 0) { + RA_Offline_refreshPendingCache(); + } + } + + return result; +} diff --git a/workspace/all/common/ra_sync.h b/workspace/all/common/ra_sync.h new file mode 100644 index 000000000..4d7dd7569 --- /dev/null +++ b/workspace/all/common/ra_sync.h @@ -0,0 +1,103 @@ +#ifndef RA_SYNC_H +#define RA_SYNC_H + +#include +#include +#include + +/** + * Standalone RetroAchievements offline sync engine. + * + * Submits pending offline achievement unlocks to the RA server + * without requiring the rcheevos library. Shared by both the + * settings app (batch sync all games) and minarch (per-game sync + * on game load). + * + * Uses the existing ra_offline ledger and HTTP infrastructure. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Result of a sync operation. + */ +typedef struct { + uint32_t synced; /* Successfully synced to server */ + uint32_t skipped; /* Skipped (server rejected, e.g. invalid achievement) */ + uint32_t failed; /* Failed (network error, stops sync) */ + uint32_t total; /* Total pending at start (after filtering) */ +} RA_SyncResult; + +/** + * Configuration for sync timing. + * + * Controls the delays between submissions. Use shorter delays when the + * user is explicitly watching progress (settings), longer delays when + * sync runs in the background during gameplay (minarch). + */ +typedef struct { + uint32_t delay_min_ms; /* Minimum delay between submissions */ + uint32_t delay_max_ms; /* Maximum delay between submissions */ + uint32_t delay_long_min_ms; /* Minimum long delay (every Nth submission) */ + uint32_t delay_long_max_ms; /* Maximum long delay (every Nth submission) */ + uint32_t delay_long_every; /* Apply long delay every N submissions */ +} RA_SyncConfig; + +/* Preset configs for common use cases */ +#define RA_SYNC_CONFIG_INTERACTIVE \ + { 500, 1500, 2000, 3000, 5 } /* User is watching (settings) */ +#define RA_SYNC_CONFIG_BACKGROUND \ + { 2000, 5000, 5000, 10000, 5 } /* Background during gameplay (minarch) */ + +/** + * Progress callback invoked after each submission attempt. + * Called from the sync thread (the calling thread). + * + * @param current 1-based index of the unlock just processed + * @param total Total number of unlocks being processed + * @param success true if this unlock was synced successfully + * @param userdata Opaque pointer passed to RA_Sync_syncAll + */ +typedef void (*RA_SyncProgressCallback)(uint32_t current, uint32_t total, + bool success, void* userdata); + +/** + * Check if there are pending offline unlocks without performing any sync. + * + * @param out_count Output: number of pending unlocks (may be NULL) + * @return true if there are pending unlocks + */ +bool RA_Sync_hasPendingUnlocks(uint32_t* out_count); + +/** + * Sync pending offline unlocks to the RA server. + * + * Blocks the calling thread. Processes one unlock at a time with + * randomized delays between submissions to avoid rate limiting. + * + * The cancel flag is polled between each submission. Setting it to + * true will stop the sync after the current submission completes. + * Already-synced unlocks are preserved (SYNC_ACK written to ledger). + * + * @param game_id Filter to a specific game (0 = sync all games). + * @param config Timing configuration. If NULL, uses interactive defaults. + * @param cancel Pointer to a cancel flag. Set to true to abort sync. + * May be NULL if cancellation is not needed. + * @param progress_cb Optional callback for progress updates. + * May be NULL. + * @param userdata User data passed to progress_cb. + * @return RA_SyncResult with counts of synced/skipped/failed. + */ +RA_SyncResult RA_Sync_syncAll(uint32_t game_id, + const RA_SyncConfig* config, + SDL_atomic_t* cancel, + RA_SyncProgressCallback progress_cb, + void* userdata); + +#ifdef __cplusplus +} +#endif + +#endif /* RA_SYNC_H */ diff --git a/workspace/all/common/ra_util.h b/workspace/all/common/ra_util.h new file mode 100644 index 000000000..68beec9a6 --- /dev/null +++ b/workspace/all/common/ra_util.h @@ -0,0 +1,257 @@ +/** + * ra_util.h — Shared inline utilities for the RetroAchievements subsystem. + * + * Header-only: every function is `static inline` so each translation unit + * gets its own copy without needing a .c file or makefile changes. + * + * Contents: + * JSON parsing — ra_find_json_bool(), ra_find_json_string() + * URL parameters — ra_extract_param() + * Directory I/O — ra_mkdirs() + * Login POST — ra_build_login_post() + * Sleep — ra_interruptible_sleep() + */ + +#ifndef RA_UTIL_H +#define RA_UTIL_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "http.h" + +/* SDL is only needed for the interruptible sleep helper. + * Guard the include so pure-C callers that don't need sleep can still + * include this header without pulling in SDL. */ +#ifdef RA_UTIL_NEED_SDL +#include +#endif + +/***************************************************************************** + * JSON parsing helpers (DU-3) + * + * Minimal ad-hoc JSON helpers for RA API responses. Handles both + * compact ("key":value) and spaced ("key": value) formatting. + *****************************************************************************/ + +/** + * Search for a boolean value in JSON by key name. + * Returns 1 for true, 0 for false, -1 if not found. + */ +static inline int ra_find_json_bool(const char* json, const char* key) { + if (!json || !key) return -1; + + char search_true[128]; + char search_false[128]; + + /* Try compact format first: "key":true / "key":false */ + 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: "key": true / "key": false */ + 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; +} + +/** + * Extract a quoted string value from JSON by key name. + * Writes into out (at most out_size-1 chars + NUL). + * Returns out on success, NULL if the key is not found. + */ +static inline const char* ra_find_json_string(const char* json, const char* key, + char* out, size_t out_size) { + if (!json || !key || !out || out_size == 0) return NULL; + + char search[128]; + + /* Try compact: "key":"value" */ + snprintf(search, sizeof(search), "\"%s\":\"", key); + const char* start = strstr(json, search); + if (!start) { + /* Try spaced: "key": "value" */ + 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 = (size_t)(end - start); + if (len >= out_size) len = out_size - 1; + + memcpy(out, start, len); + out[len] = '\0'; + + return out; +} + +/***************************************************************************** + * URL parameter extraction (DU-5) + * + * Extract key=value pairs from URL query strings or POST bodies. + *****************************************************************************/ + +/** + * Extract the value of parameter @key from a URL query string or POST body. + * Writes into buf (at most buf_size-1 chars + NUL). + * Returns true if the parameter was found. + */ +static inline bool ra_extract_param(const char* str, const char* key, + char* buf, size_t buf_size) { + if (!str || !key || !buf || buf_size == 0) return false; + + size_t key_len = strlen(key); + const char* p = str; + + while ((p = strstr(p, key)) != NULL) { + /* Ensure this is a parameter start (beginning of string, after ? or &) */ + if (p != str) { + char before = *(p - 1); + if (before != '?' && before != '&') { + p += key_len; + continue; + } + } + /* Check for '=' immediately after key */ + if (p[key_len] == '=') { + const char* val = p + key_len + 1; + const char* end = val; + while (*end && *end != '&' && *end != '#' && *end != ' ') end++; + size_t vlen = (size_t)(end - val); + if (vlen >= buf_size) vlen = buf_size - 1; + memcpy(buf, val, vlen); + buf[vlen] = '\0'; + return true; + } + p += key_len; + } + return false; +} + +/***************************************************************************** + * Recursive directory creation (DU-7) + *****************************************************************************/ + +/** + * Recursively create directories for the given path (like `mkdir -p`). + * Returns 0 on success, -1 on failure. + */ +static inline int ra_mkdirs(const char* path) { + char tmp[512]; + char* p; + size_t len; + + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len > 0 && tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, 0755) != 0 && errno != EEXIST) { + return -1; + } + *p = '/'; + } + } + return mkdir(tmp, 0755) != 0 && errno != EEXIST ? -1 : 0; +} + +/***************************************************************************** + * Login POST builder (DU-6) + * + * Builds a URL-encoded "r=login&u=...&t=..." or "r=login&u=...&p=..." body. + * The caller supplies a buffer; the function returns true on success. + *****************************************************************************/ + +/** + * Build a token-based login POST body with proper URL encoding. + * Writes "r=login&u=&t=" into @buf. + * Returns true on success, false on encoding failure. + */ +static inline bool ra_build_login_post_token(const char* username, + const char* token, + char* buf, size_t buf_size) { + if (!username || !token || !buf || buf_size == 0) return false; + + char* enc_user = HTTP_urlEncode(username); + char* enc_token = HTTP_urlEncode(token); + if (!enc_user || !enc_token) { + free(enc_user); + free(enc_token); + return false; + } + + snprintf(buf, buf_size, "r=login&u=%s&t=%s", enc_user, enc_token); + free(enc_user); + free(enc_token); + return true; +} + +/** + * Build a password-based login POST body with proper URL encoding. + * Writes "r=login&u=&p=" into @buf. + * Returns true on success, false on encoding failure. + */ +static inline bool ra_build_login_post_password(const char* username, + const char* password, + char* buf, size_t buf_size) { + if (!username || !password || !buf || buf_size == 0) return false; + + char* enc_user = HTTP_urlEncode(username); + char* enc_pass = HTTP_urlEncode(password); + if (!enc_user || !enc_pass) { + free(enc_user); + free(enc_pass); + return false; + } + + snprintf(buf, buf_size, "r=login&u=%s&p=%s", enc_user, enc_pass); + free(enc_user); + free(enc_pass); + return true; +} + +/***************************************************************************** + * Interruptible sleep (DU-4) + * + * Requires RA_UTIL_NEED_SDL to be defined before including this header. + *****************************************************************************/ + +#ifdef RA_UTIL_NEED_SDL +/** + * Sleep for @ms milliseconds, waking every 100ms to check @cancel_flag. + * Returns true if cancelled (flag became non-zero), false if sleep completed. + * @cancel_flag may be NULL (sleep runs to completion). + */ +static inline bool ra_interruptible_sleep(uint32_t ms, SDL_atomic_t* cancel_flag) { + uint32_t elapsed = 0; + while (elapsed < ms) { + if (cancel_flag && SDL_AtomicGet(cancel_flag)) return true; + uint32_t chunk = (ms - elapsed > 100) ? 100 : (ms - elapsed); + SDL_Delay(chunk); + elapsed += chunk; + } + return (cancel_flag && SDL_AtomicGet(cancel_flag)); +} +#endif /* RA_UTIL_NEED_SDL */ + +#endif /* RA_UTIL_H */ diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index cb389dd9e..0ecec422a 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -27,7 +27,7 @@ SOURCE = $(TARGET).c ../common/scaler.c ../common/utils.c ../common/config.c ../ # RA support ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355 desktop)) # RA source files -SOURCE += ../common/http.c ../common/ra_badges.c ra_integration.c chd_reader.c +SOURCE += ../common/http.c ../common/ra_badges.c ../common/ra_offline.c ../common/ra_sync.c ../common/ra_event_queue.c ra_integration.c chd_reader.c endif CC = $(CROSS_COMPILE)gcc @@ -64,7 +64,7 @@ endif # RA support: rcheevos and libchdr linking ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355 desktop)) -LDFLAGS += -lrcheevos -lchdr +LDFLAGS += -lrcheevos -lchdr -lcrypto CFLAGS += -DRC_CLIENT_SUPPORTS_HASH -DHAS_CHEEVOS -DHAS_CHDR ifeq ($(PLATFORM), desktop) # Desktop needs rpath for local shared library lookup @@ -95,14 +95,14 @@ ifeq ($(PLATFORM), desktop) all: clean libretro-common rcheevos libchdr $(PREFIX_LOCAL)/include/msettings.h mkdir -p build/$(PLATFORM) $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) -else ifneq (,$(filter $(PLATFORM),tg5040 tg5050)) +else ifneq (,$(filter $(PLATFORM),tg5040 tg5050 my355)) # 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/libcrypto.so.* build/$(PLATFORM) + chmod a+r build/$(PLATFORM)/libcrypto.so.* ifeq ($(PLATFORM), my355) - cp -L $(PREFIX)/lib/libcrypto.so.3 build/$(PLATFORM) cp -L $(PREFIX)/lib/libsqlite3.so build/$(PLATFORM) cp -L $(PREFIX)/lib/libffi.so.7 build/$(PLATFORM) cp -L $(PREFIX)/lib/libtinyalsa.so.2 build/$(PLATFORM) diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index fd0402a19..ef7225333 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -7174,6 +7174,7 @@ static int OptionAchievements_showDetail(MenuList* list, int i) { if (dirty) { bool is_muted = RA_isAchievementMuted(ach->id); + bool is_offline_pending = RA_isAchievementOfflinePending(ach->id); GFX_clear(screen); @@ -7240,6 +7241,23 @@ static int OptionAchievements_showDetail(MenuList* list, int i) { SDL_FreeSurface(progress_text); } + // Offline pending indicator + if (is_offline_pending) { + SDL_Surface* offline_text = TTF_RenderUTF8_Blended(font.tiny, "Unlocked offline - pending sync", COLOR_LIGHT_TEXT); + int wifi_size = SCALE1(12); + int total_w = wifi_size + SCALE1(4) + offline_text->w; + int icon_x = center_x - total_w / 2; + int text_x = icon_x + wifi_size + SCALE1(4); + int wifi_y = content_y + (offline_text->h - wifi_size) / 2; + GFX_blitAssetColor(ASSET_WIFI_OFF, NULL, screen, + &(SDL_Rect){icon_x, wifi_y}, 0xCCCCCC); + SDL_BlitSurface(offline_text, NULL, screen, &(SDL_Rect){ + text_x, content_y + }); + content_y += offline_text->h + SCALE1(2); + SDL_FreeSurface(offline_text); + } + // Unlock rate/rarity (smaller font, gray) if (ach->rarity > 0) { char rarity_buf[32]; @@ -7278,9 +7296,18 @@ static int OptionAchievements_showDetail(MenuList* list, int i) { // 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_Surface* mute_text = TTF_RenderUTF8_Blended(font.tiny, "Muted - progress notifications silenced", COLOR_LIGHT_TEXT); + int mute_icon_w = SCALE1(10); + int mute_icon_h = SCALE1(12); + int total_w = mute_icon_w + SCALE1(4) + mute_text->w; + int icon_x = center_x - total_w / 2; + int text_x = icon_x + mute_icon_w + SCALE1(4); + int icon_y = content_y + SCALE1(4) + (mute_text->h - mute_icon_h) / 2; + SDL_Rect mute_src = {0, SCALE1(4), SCALE1(10), SCALE1(12)}; + GFX_blitAssetColor(ASSET_VOLUME_MUTE, &mute_src, screen, + &(SDL_Rect){icon_x, icon_y}, 0xCCCCCC); SDL_BlitSurface(mute_text, NULL, screen, &(SDL_Rect){ - center_x - mute_text->w / 2, content_y + SCALE1(4) + text_x, content_y + SCALE1(4) }); SDL_FreeSurface(mute_text); } @@ -7302,7 +7329,7 @@ static int OptionAchievements_showDetail(MenuList* list, int i) { static int OptionAchievements_openMenu(MenuList* list, int i) { if (!RA_isGameLoaded()) { - Menu_message("No game loaded for achievements", (char*[]){"B","BACK", NULL}); + Menu_message("No achievements found for this game.\n\nThis ROM may need a compatibility patch\nor may not be a supported version.\n\nVisit retroachievements.org to check\nsupported game files.", (char*[]){"B","BACK", NULL}); return MENU_CALLBACK_NOP; } @@ -7310,7 +7337,7 @@ static int OptionAchievements_openMenu(MenuList* list, int i) { RA_getAchievementSummary(&unlocked, &total); if (total == 0) { - Menu_message("No achievements available for this game", (char*[]){"B","BACK", NULL}); + Menu_message("No achievements available for this game.\n\nThis game may not have achievements yet.\n\nVisit retroachievements.org for details.", (char*[]){"B","BACK", NULL}); return MENU_CALLBACK_NOP; } @@ -7532,6 +7559,7 @@ static int OptionAchievements_openMenu(MenuList* list, int i) { 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_offline_pending = RA_isAchievementOfflinePending(ach->id); bool is_selected = (row == selected_row); SDL_Color text_color = COLOR_WHITE; @@ -7552,16 +7580,19 @@ static int OptionAchievements_openMenu(MenuList* list, int i) { 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 + // Calculate width needed for: badge + spacing + [mute icon] + [wifi icon] + title + 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 + mute_width = SCALE1(10) + SCALE1(4); // volume-mute icon + gap + } + int offline_width = 0; + if (is_offline_pending) { + offline_width = SCALE1(12) + SCALE1(4); // wifi-off icon + gap } - int pill_width = opt_pad + badge_display_size + SCALE1(6) + title_width + mute_width + opt_pad; + int pill_width = opt_pad + badge_display_size + SCALE1(6) + mute_width + offline_width + title_width + opt_pad; GFX_blitPillDark(ASSET_BUTTON, screen, &(SDL_Rect){ ox, oy + SCALE1(row * BUTTON_SIZE), pill_width, row_height @@ -7580,25 +7611,36 @@ static int OptionAchievements_openMenu(MenuList* list, int i) { SDL_BlitScaled(badge, &badge_src, screen, &badge_dst); } + int text_x = ox + opt_pad + badge_display_size + SCALE1(6); + + // Mute icon prefix (volume-mute icon, cropped to match text height) + if (is_muted) { + int mute_icon_h = SCALE1(12); + int mute_y = oy + SCALE1(row * BUTTON_SIZE) + (row_height - mute_icon_h) / 2; + SDL_Rect mute_src = {0, SCALE1(4), SCALE1(10), SCALE1(12)}; + GFX_blitAssetColor(ASSET_VOLUME_MUTE, &mute_src, screen, + &(SDL_Rect){text_x, mute_y}, THEME_COLOR5_255); + text_x += mute_width; + } + + // Offline pending icon prefix (wifi-off icon before title) + if (is_offline_pending) { + int wifi_size = SCALE1(12); + int wifi_y = oy + SCALE1(row * BUTTON_SIZE) + (row_height - wifi_size) / 2; + GFX_blitAssetColor(ASSET_WIFI_OFF, NULL, screen, + &(SDL_Rect){text_x, wifi_y}, THEME_COLOR5_255); + text_x += offline_width; + } + // 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), + text_x, 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 + // Unselected row - just badge + title + indicators, no pills int badge_display_size = SCALE1(BUTTON_SIZE - 4); // Badge icon @@ -7613,30 +7655,40 @@ static int OptionAchievements_openMenu(MenuList* list, int i) { SDL_BlitScaled(badge, &badge_src, screen, &badge_dst); } - // Title text (theme color for unselected) + int text_x = ox + opt_pad + badge_display_size + SCALE1(6); + + // Mute icon prefix (volume-mute icon, cropped to match text height) + if (is_muted) { + int mute_icon_h = SCALE1(12); + int mute_y = oy + SCALE1(row * BUTTON_SIZE) + (row_height - mute_icon_h) / 2; + SDL_Rect mute_src = {0, SCALE1(4), SCALE1(10), SCALE1(12)}; + GFX_blitAssetColor(ASSET_VOLUME_MUTE, &mute_src, screen, + &(SDL_Rect){text_x, mute_y}, RGB_WHITE); + text_x += SCALE1(10) + SCALE1(4); + } + + // Offline pending icon prefix (wifi-off icon before title) + if (is_offline_pending) { + int wifi_size = SCALE1(12); + int wifi_y = oy + SCALE1(row * BUTTON_SIZE) + (row_height - wifi_size) / 2; + GFX_blitAssetColor(ASSET_WIFI_OFF, NULL, screen, + &(SDL_Rect){text_x, wifi_y}, RGB_WHITE); + text_x += wifi_size + SCALE1(4); + } + + // Title text (white 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), + text_x, 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}; + // Button hints at bottom with dynamic Y and X button text + int selected_muted = (filtered_count > 0) ? RA_isAchievementMuted(filtered[selected]->id) : 0; + char* hints[] = {"Y", ach_filter_locked_only ? "SHOW ALL" : "SHOW LOCKED", "X", selected_muted ? "UNMUTE" : "MUTE", NULL}; GFX_blitButtonGroup(hints, 0, screen, 1); GFX_flip(screen); @@ -8961,8 +9013,6 @@ void onAudioSinkChanged(int device, int watch_event) } int main(int argc , char* argv[]) { - LOG_info("MinArch\n"); - //static char asoundpath[MAX_PATH]; //sprintf(asoundpath, "%s/.asoundrc", getenv("HOME")); //LOG_info("minarch: need asoundrc at %s\n", asoundpath); @@ -9186,14 +9236,16 @@ int main(int argc , char* argv[]) { PLAT_clearTurbo(); Menu_quit(); - Notification_quit(); QuitSettings(); finish: - // Unload game and shutdown RetroAchievements before Core_quit + // Unload game and shutdown RetroAchievements before Notification_quit — + // RA background threads (sync, badge downloads) may call notification + // APIs, so the notification mutex should outlive all RA threads. RA_unloadGame(); RA_quit(); + Notification_quit(); Game_close(); Rewind_free(); diff --git a/workspace/all/minarch/ra_integration.c b/workspace/all/minarch/ra_integration.c index 3f6a77291..ac983ac3f 100644 --- a/workspace/all/minarch/ra_integration.c +++ b/workspace/all/minarch/ra_integration.c @@ -1,3 +1,6 @@ +#define RA_LOG_PREFIX "RA" +#include "ra_log.h" + #include "ra_integration.h" #include "ra_consoles.h" #include "chd_reader.h" @@ -5,9 +8,15 @@ #include "http.h" #include "notification.h" #include "ra_badges.h" +#include "ra_offline.h" +#include "ra_sync.h" #include "defines.h" #include "api.h" +#define RA_UTIL_NEED_SDL +#include "ra_util.h" +#include "ra_event_queue.h" + #include #include #include @@ -19,20 +28,86 @@ #include #include #include +#include + +/***************************************************************************** + * State machine enums (SM-1 / SM-4) + * + * The codebase has 4 state machines. For connectivity and sync the enums + * are derived (computed from underlying flags on each query). For login + * and game-load the enum variable IS the authoritative state — every + * transition is an explicit assignment. Background threads communicate + * exclusively through the event queue (ra_event_queue.h); the main thread + * drains the queue in ra_process_deferred_flags() and updates the state + * variables / deferred-action bools. + * + * Connectivity detection is two-tier: + * + * 1. Lightweight WiFi poll (every RA_WIFI_POLL_INTERVAL_MS, ~5 s): + * Calls PLAT_wifiConnected() to detect link-layer drops and + * restores without any network I/O. Runs on the main thread + * inside ra_process_deferred_flags(), Phase 3. + * + * 2. Full HTTP probe (every RA_PROBE_INTERVAL_MS, ~30 s, only while + * offline): A background thread (ra_connectivity_probe_func) POSTs + * to the RA login endpoint to verify end-to-end server reachability. + * On success it caches the login response for offline use, flips + * to online, and posts RA_EV_PROBE_ONLINE for the main thread. + * + * Typical connectivity restore flow: + * WiFi poll detects link up → start probe → probe POSTs login → success + * → RA_EV_PROBE_ONLINE → main thread triggers sync → sync submits + * pending ledger unlocks → RA_EV_SYNC_DONE → main thread applies + * results to rcheevos state. + *****************************************************************************/ -// 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__) +/* Connectivity: is the device online or offline (with/without probe)? */ +typedef enum { + CONN_ONLINE, /* !isOffline && !probe_running */ + CONN_OFFLINE_NO_PROBE, /* isOffline && !probe_running */ + CONN_OFFLINE_PROBING, /* isOffline && probe_running && !abort */ + CONN_OFFLINE_PROBE_STOPPING /* isOffline && probe_running && abort */ +} RAConnState; + +/* Login: lifecycle of the rc_client login attempt (authoritative). */ +typedef enum { + LOGIN_IDLE, /* no credentials or not yet attempted */ + LOGIN_IN_PROGRESS, /* attempt in flight (async rc_client call) */ + LOGIN_RETRY_WAITING, /* failed, timer running before next retry */ + LOGIN_LOGGED_IN, /* authenticated */ + LOGIN_FAILED /* all retries exhausted */ +} RALoginState; + +/* Game load: lifecycle of a game load through rcheevos (authoritative). */ +typedef enum { + GAME_NONE, /* no game */ + GAME_PENDING_LOGIN, /* waiting for login to complete */ + GAME_LOADING, /* rc_client_begin_identify_and_load_game sent */ + GAME_LOADED, /* game active, achievements available */ + GAME_LOAD_RETRY_PENDING /* load failed offline, retry on connectivity */ +} RAGameState; + +/* Sync: lifecycle of the offline-unlock sync engine (derived). */ +typedef enum { + SYNC_IDLE, /* nothing happening */ + SYNC_DEFERRED, /* waiting for game load before starting */ + SYNC_RUNNING, /* sync thread active */ + SYNC_ABORTING, /* abort requested, waiting for thread exit */ + SYNC_APPLY_PENDING /* results ready for main thread */ +} RASyncState; /***************************************************************************** * Static state *****************************************************************************/ static rc_client_t* ra_client = NULL; -static bool ra_game_loaded = false; -static bool ra_logged_in = false; + +// Authoritative sub-FSM state (SM-4). These replace the old boolean flags +// (ra_game_loaded, ra_logged_in) — every transition is an explicit enum +// assignment visible in one place. Forward-declared; enum definitions are +// in the "State machine enums" section below. +static RALoginState ra_login_state = LOGIN_IDLE; +static RAGameState ra_game_state = GAME_NONE; // Current game hash (for mute file path) static char ra_game_hash[64] = {0}; @@ -63,7 +138,7 @@ typedef struct { uint8_t* rom_data; size_t rom_size; char emu_tag[16]; - bool active; + bool active; // data-validity flag (struct holds valid ROM info) } RAPendingLoad; static RAPendingLoad ra_pending_load = {0}; @@ -73,7 +148,6 @@ static RAPendingLoad ra_pending_load = {0}; 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; @@ -83,6 +157,41 @@ static RALoginRetry ra_login_retry = {0}; #define RA_WIFI_WAIT_MAX_MS 3000 // 3 seconds max blocking wait #define RA_WIFI_WAIT_POLL_MS 500 // Check every 500ms +// Connectivity probe config +#define RA_PROBE_INTERVAL_MS 30000 // 30 seconds between probe attempts +#define RA_PROBE_SLEEP_CHUNK_MS 200 // Sleep granularity for abort responsiveness + +// Lightweight WiFi state polling (detects drops without hitting RA server) +#define RA_WIFI_POLL_INTERVAL_MS 5000 // Check wpa_cli status every 5 seconds + +// Connectivity probe state (background thread polls RA login endpoint) +static SDL_atomic_t ra_probe_abort = {0}; +static SDL_atomic_t ra_probe_running = {0}; +static SDL_Thread* ra_probe_thread = NULL; + +// Offline sync state (background thread replays pending unlocks) +static SDL_Thread* ra_sync_thread = NULL; +static SDL_atomic_t ra_sync_abort = {0}; +static uint32_t ra_sync_game_id = 0; + +// Deferred state: simple main-thread-only flags. Background threads +// communicate exclusively through the event queue (ra_event_queue.h). +// These flags are read and written only on the main thread — no mutex needed. +// Note: game_load_retry is now encoded as ra_game_state == GAME_LOAD_RETRY_PENDING. +static bool ra_deferred_offline_notification = false; /* show "Offline" toast when Notification_init is ready */ +static bool ra_deferred_sync_pending = false; /* sync should start when game is loaded */ +static bool ra_user_saw_offline = false; /* user has seen the "Offline" notification */ +static bool ra_sync_apply_pending = false; /* sync results waiting to be applied */ +static uint32_t ra_sync_apply_ids[RA_EVQ_MAX_SYNC_IDS]; +static uint32_t ra_sync_apply_timestamps[RA_EVQ_MAX_SYNC_IDS]; +static uint32_t ra_sync_apply_count = 0; + +// Probe lifecycle mutex — protects ra_probe_running check-and-set. +// Separate from the event queue mutex. Held only briefly for the +// atomic read-modify-write in ra_start/stop_connectivity_probe and +// at probe thread exit. Must NOT be held across SDL_WaitThread. +static SDL_mutex* ra_probe_mutex = NULL; + /***************************************************************************** * Thread-safe response queue * @@ -102,6 +211,8 @@ typedef struct { #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 int ra_response_queue_head = 0; /* next slot to read (main thread) */ +static int ra_response_queue_tail = 0; /* next slot to write (worker threads) */ static SDL_mutex* ra_queue_mutex = NULL; // Forward declarations for queue functions @@ -112,16 +223,28 @@ static bool ra_queue_push(const char* body, size_t body_length, int http_status, static bool ra_queue_pop(RA_QueuedResponse* out); static void ra_process_queued_responses(void); +// Forward declarations for deferred state lifecycle +static void ra_deferred_init(void); +static void ra_deferred_quit(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_reset_login_retry(void); static void ra_start_login(void); +static void ra_start_offline_sync(uint32_t game_id); 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); +static void ra_start_connectivity_probe(void); +static void ra_stop_connectivity_probe(void); + +// Extracted helpers to reduce duplication +static bool ra_should_hide_achievement(const rc_client_achievement_t* ach); +static uint32_t ra_reapply_pending_unlocks(rc_client_t* client, const char* game_hash); +static void ra_show_game_summary(rc_client_t* client, const rc_client_game_t* game); /***************************************************************************** * CHD (compressed hunks of data) reader support for disc images @@ -270,19 +393,108 @@ static uint32_t ra_get_retry_delay_ms(int attempt) { } /***************************************************************************** - * Helper: Reset login retry state + * Helper: Reset login retry context (timer/counter data) *****************************************************************************/ -static void ra_reset_login_state(void) { +static void ra_reset_login_retry(void) { ra_login_retry.count = 0; - ra_login_retry.pending = false; ra_login_retry.next_time = 0; ra_login_retry.notified_connecting = false; } +/***************************************************************************** + * State accessor functions + * + * ra_get_conn_state() and ra_get_sync_state() are derived — they compute + * the current state from underlying flags on each call. + * + * ra_get_login_state() and ra_get_game_state() return the authoritative + * state variable directly (SM-4). + * + * Note: ra_get_conn_state() reads ra_probe_running which is protected by + * ra_probe_mutex, but the accessor is called from the main thread where + * a momentary stale read is harmless (the flags are re-checked under the + * lock when an actual transition is made). + *****************************************************************************/ + +static RAConnState ra_get_conn_state(void) { + if (!RA_Offline_isOffline()) + return CONN_ONLINE; + if (!SDL_AtomicGet(&ra_probe_running)) + return CONN_OFFLINE_NO_PROBE; + if (SDL_AtomicGet(&ra_probe_abort)) + return CONN_OFFLINE_PROBE_STOPPING; + return CONN_OFFLINE_PROBING; +} + +static RALoginState ra_get_login_state(void) { + return ra_login_state; +} + +static RAGameState ra_get_game_state(void) { + return ra_game_state; +} + +static RASyncState ra_get_sync_state(void) { + if (RA_Offline_isSyncing()) { + if (SDL_AtomicGet(&ra_sync_abort)) + return SYNC_ABORTING; + return SYNC_RUNNING; + } + /* sync results waiting for main thread to apply */ + if (ra_sync_apply_pending) + return SYNC_APPLY_PENDING; + /* sync trigger deferred — waiting for game load */ + if (ra_deferred_sync_pending) + return SYNC_DEFERRED; + return SYNC_IDLE; +} + +/* Diagnostic: state → string for logging */ +static const char* ra_conn_state_str(RAConnState s) { + switch (s) { + case CONN_ONLINE: return "CONN_ONLINE"; + case CONN_OFFLINE_NO_PROBE: return "CONN_OFFLINE_NO_PROBE"; + case CONN_OFFLINE_PROBING: return "CONN_OFFLINE_PROBING"; + case CONN_OFFLINE_PROBE_STOPPING:return "CONN_OFFLINE_PROBE_STOPPING"; + } + return "CONN_?"; +} +static const char* ra_login_state_str(RALoginState s) { + switch (s) { + case LOGIN_IDLE: return "LOGIN_IDLE"; + case LOGIN_IN_PROGRESS: return "LOGIN_IN_PROGRESS"; + case LOGIN_RETRY_WAITING: return "LOGIN_RETRY_WAITING"; + case LOGIN_LOGGED_IN: return "LOGIN_LOGGED_IN"; + case LOGIN_FAILED: return "LOGIN_FAILED"; + } + return "LOGIN_?"; +} +static const char* ra_game_state_str(RAGameState s) { + switch (s) { + case GAME_NONE: return "GAME_NONE"; + case GAME_PENDING_LOGIN: return "GAME_PENDING_LOGIN"; + case GAME_LOADING: return "GAME_LOADING"; + case GAME_LOADED: return "GAME_LOADED"; + case GAME_LOAD_RETRY_PENDING: return "GAME_LOAD_RETRY_PENDING"; + } + return "GAME_?"; +} +static const char* ra_sync_state_str(RASyncState s) { + switch (s) { + case SYNC_IDLE: return "SYNC_IDLE"; + case SYNC_DEFERRED: return "SYNC_DEFERRED"; + case SYNC_RUNNING: return "SYNC_RUNNING"; + case SYNC_ABORTING: return "SYNC_ABORTING"; + case SYNC_APPLY_PENDING: return "SYNC_APPLY_PENDING"; + } + return "SYNC_?"; +} + /***************************************************************************** * Helper: Start a login attempt *****************************************************************************/ static void ra_start_login(void) { + ra_login_state = LOGIN_IN_PROGRESS; 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, @@ -302,6 +514,8 @@ static void ra_queue_init(void) { ra_queue_mutex = SDL_CreateMutex(); } ra_response_queue_count = 0; + ra_response_queue_head = 0; + ra_response_queue_tail = 0; memset(ra_response_queue, 0, sizeof(ra_response_queue)); } @@ -309,11 +523,12 @@ 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; + while (ra_response_queue_count > 0) { + free(ra_response_queue[ra_response_queue_head].body); + ra_response_queue[ra_response_queue_head].body = NULL; + ra_response_queue_head = (ra_response_queue_head + 1) % RA_RESPONSE_QUEUE_SIZE; + ra_response_queue_count--; } - ra_response_queue_count = 0; SDL_UnlockMutex(ra_queue_mutex); SDL_DestroyMutex(ra_queue_mutex); @@ -332,7 +547,7 @@ static bool ra_queue_push(const char* body, size_t body_length, int http_status, SDL_LockMutex(ra_queue_mutex); if (ra_response_queue_count < RA_RESPONSE_QUEUE_SIZE) { - RA_QueuedResponse* resp = &ra_response_queue[ra_response_queue_count]; + RA_QueuedResponse* resp = &ra_response_queue[ra_response_queue_tail]; // Copy the body data (caller will free original) if (body && body_length > 0) { @@ -342,6 +557,7 @@ static bool ra_queue_push(const char* body, size_t body_length, int http_status, resp->body[body_length] = '\0'; resp->body_length = body_length; } else { + resp->body = NULL; resp->body_length = 0; } } else { @@ -353,10 +569,11 @@ static bool ra_queue_push(const char* body, size_t body_length, int http_status, resp->callback = callback; resp->callback_data = callback_data; + ra_response_queue_tail = (ra_response_queue_tail + 1) % RA_RESPONSE_QUEUE_SIZE; ra_response_queue_count++; success = true; } else { - RA_LOG_WARN("Warning: Response queue full, dropping response\n"); + RA_LOG_WARN("Response queue full, dropping response\n"); } SDL_UnlockMutex(ra_queue_mutex); @@ -373,18 +590,9 @@ static bool ra_queue_pop(RA_QueuedResponse* out) { 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]; - } + *out = ra_response_queue[ra_response_queue_head]; + ra_response_queue_head = (ra_response_queue_head + 1) % RA_RESPONSE_QUEUE_SIZE; ra_response_queue_count--; - - // Clear the last slot - memset(&ra_response_queue[ra_response_queue_count], 0, sizeof(RA_QueuedResponse)); - has_item = true; } @@ -395,8 +603,12 @@ static bool ra_queue_pop(RA_QueuedResponse* out) { // Called from main thread in RA_idle() - process all queued responses static void ra_process_queued_responses(void) { RA_QueuedResponse resp; + int processed = 0; while (ra_queue_pop(&resp)) { + processed++; + RA_LOG_DEBUG("Processing queued response #%d: http_status=%d, body_len=%zu\n", + processed, resp.http_status_code, resp.body_length); // Build the server response structure rc_api_server_response_t server_response; memset(&server_response, 0, sizeof(server_response)); @@ -415,6 +627,34 @@ static void ra_process_queued_responses(void) { } } +/***************************************************************************** + * Deferred state lifecycle + * + * ra_deferred_init / ra_deferred_quit manage the probe mutex and reset + * main-thread-only flags. Called from RA_init / RA_quit alongside the + * response-queue lifecycle. + *****************************************************************************/ + +static void ra_deferred_init(void) { + if (!ra_probe_mutex) { + ra_probe_mutex = SDL_CreateMutex(); + } + // Zero all main-thread-only flags (be explicit in case RA_init is + // called more than once in the process lifetime). + ra_deferred_offline_notification = false; + ra_deferred_sync_pending = false; + ra_user_saw_offline = false; + ra_sync_apply_pending = false; + ra_sync_apply_count = 0; +} + +static void ra_deferred_quit(void) { + if (ra_probe_mutex) { + SDL_DestroyMutex(ra_probe_mutex); + ra_probe_mutex = NULL; + } +} + /***************************************************************************** * Helper: Muted achievements file path *****************************************************************************/ @@ -427,10 +667,8 @@ static void ra_get_mute_file_path(char* path, size_t path_size) { *****************************************************************************/ static void ra_ensure_mute_dir(void) { char dir_path[512]; - snprintf(dir_path, sizeof(dir_path), SHARED_USERDATA_PATH "/.ra"); - mkdir(dir_path, 0755); snprintf(dir_path, sizeof(dir_path), SHARED_USERDATA_PATH "/.ra/muted"); - mkdir(dir_path, 0755); + ra_mkdirs(dir_path); } /***************************************************************************** @@ -567,6 +805,120 @@ static uint32_t ra_read_memory(uint32_t address, uint8_t* buffer, uint32_t num_b return num_bytes; } +/***************************************************************************** + * Helpers: achievement filtering + *****************************************************************************/ + +/** + * Returns true if this achievement should be hidden from the user. + * Currently hides the "Unknown Emulator" warning (ID 101000001) when + * hardcore mode is disabled. + */ +static bool ra_should_hide_achievement(const rc_client_achievement_t* ach) { + return !CFG_getRAHardcoreMode() && ach->id == 101000001; +} + +/** + * Re-apply pending offline unlock state to rcheevos' internal achievement data. + * + * When hardcore mode is re-enabled after an offline session, rcheevos clears + * achievements that only have the softcore unlock bit (which is all offline + * unlocks, since offline forces softcore). This function reads the pending + * unlock ledger, finds achievements matching the given game hash, and sets + * both the softcore and hardcore unlock bits so they remain visible as unlocked. + * + * Also useful at game-load time: if startsession patching missed some pending + * unlocks (e.g., due to a race with the connectivity probe), this ensures they + * are marked unlocked in rcheevos immediately. + * + * Returns the number of achievements whose state was changed. + */ +static uint32_t ra_reapply_pending_unlocks(rc_client_t* client, const char* game_hash) { + if (!client || !game_hash || !game_hash[0]) return 0; + + RA_PendingUnlock* pending = NULL; + uint32_t pending_count = 0; + if (!RA_Offline_ledgerGetPendingByGameHash(game_hash, &pending, &pending_count) || + pending_count == 0 || !pending) { + return 0; + } + + uint32_t fixed = 0; + for (uint32_t i = 0; i < pending_count; i++) { + const rc_client_achievement_t* ach = + rc_client_get_achievement_info(client, pending[i].achievement_id); + if (!ach) continue; + + // Only fix achievements that rcheevos thinks are still locked + if (ach->state == RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE) { + // Cast away const — rc_client_get_achievement_info returns a const + // pointer to the internal struct, but we need to update unlock state. + // This is safe: the data is mutable and we're on the main thread. + rc_client_achievement_t* m = (rc_client_achievement_t*)ach; + m->unlocked |= RC_CLIENT_ACHIEVEMENT_UNLOCKED_BOTH; + m->unlock_time = (time_t)pending[i].timestamp; + m->state = RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED; + fixed++; + } + } + + free(pending); + return fixed; +} + +/** + * Compute the game achievement summary (unlocked/total), augmented with + * pending offline unlocks and filtered to hide suppressed achievements, + * then push the result as a notification. + */ +static void ra_show_game_summary(rc_client_t* client, const rc_client_game_t* game) { + 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; + + // Re-apply any pending offline unlocks that rcheevos may not know + // about yet (e.g., startsession patching was skipped due to timing, + // or hardcore re-enable cleared softcore-only unlock bits). + // This both fixes rcheevos' internal state and returns the count + // so we can augment the notification. + uint32_t extra = ra_reapply_pending_unlocks(client, game->hash); + display_unlocked += extra; + if (display_unlocked > display_total) { + display_unlocked = display_total; + } + if (extra > 0) { + RA_LOG_INFO("Notification: added %u pending offline unlocks to count\n", extra); + } + + // Hide filtered achievements (e.g., "Unknown Emulator" in non-hardcore mode) + // from the summary counts. + // Note: We intentionally show "Unsupported Game Version" so users know to find a supported ROM. + { + 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) { + 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 (ra_should_hide_achievement(ach)) { + if (display_total > 0) display_total--; + if (ach->unlocked && display_unlocked > 0) display_unlocked--; + } + } + } + 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); +} + /***************************************************************************** * Callback: Server call (HTTP) * @@ -581,6 +933,9 @@ static uint32_t ra_read_memory(uint32_t address, uint8_t* buffer, uint32_t num_b typedef struct { rc_client_server_callback_t callback; void* callback_data; + char* url; /* Owned copy for write-through caching */ + char* post_data; /* Owned copy for write-through caching (may be NULL) */ + char game_hash[33]; /* Snapshot of ra_game_hash at request time (thread-safe) */ } RA_ServerCallData; static void ra_http_callback(HTTP_Response* response, void* userdata) { @@ -589,16 +944,116 @@ static void ra_http_callback(HTTP_Response* response, void* userdata) { // Extract response info before freeing const char* body = NULL; size_t body_length = 0; - int http_status = RC_API_SERVER_RESPONSE_CLIENT_ERROR; + // Default to RETRYABLE error so rcheevos will retry on network failures + // (connection refused, timeout, DNS failure, etc.) + int http_status = RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR; if (response && response->data && !response->error) { body = response->data; body_length = response->size; http_status = response->http_status; + + // Write-through cache: store successful responses for offline use + if (http_status == 200 && body_length > 0) { + RA_Offline_cacheResponse(data->url, data->post_data, body, body_length); + + // SYNC_ACK: when an online awardachievement succeeds, mark it in the ledger + // so the sync engine won't re-submit it after a crash. + // Verify the response body indicates success — a 200 with "Success":false + // (e.g., rate limit, invalid achievement) must NOT generate a SYNC_ACK + // or the unlock would be silently dropped from the ledger. + if (data->post_data && strstr(data->post_data, "r=awardachievement") && + ra_find_json_bool(body, "Success") == 1) { + char a_buf[16] = {0}; + if (ra_extract_param(data->post_data, "a", a_buf, sizeof(a_buf))) { + uint32_t ach_id = (uint32_t)strtoul(a_buf, NULL, 10); + if (ach_id > 0) { + RA_LOG_INFO("[AWARD_HTTP] awardachievement SUCCESS for ach=%u\n", ach_id); + + // Look up ledger timestamp BEFORE removing from cache. + // If the ledger still has this entry, use its timestamp. + // If the ledger was already compacted (sync engine finished), + // skip the cache patch entirely — the sync engine already + // patched the cache with the correct timestamp. + uint32_t unlock_timestamp = 0; // 0 = "not found" + bool found_in_ledger = false; + { + RA_PendingUnlock entry; + if (RA_Offline_ledgerFindPendingUnlock(ach_id, &entry)) { + unlock_timestamp = entry.timestamp; + found_in_ledger = true; + RA_LOG_DEBUG("[AWARD_HTTP] ach=%u: found in ledger, " + "timestamp=%u\n", ach_id, unlock_timestamp); + } + } + + RA_Offline_ledgerWriteSyncAck(ach_id, 0); + RA_Offline_removePendingCacheEntry(ach_id); + + if (found_in_ledger && unlock_timestamp > 0) { + // Ledger entry found — patch the startsession cache + // with the correct original unlock timestamp. + RA_LOG_DEBUG("[AWARD_HTTP] ach=%u: patching startsession cache " + "with ledger timestamp=%u\n", ach_id, unlock_timestamp); + RA_Offline_patchStartsessionCacheWithUnlock( + data->game_hash, ach_id, unlock_timestamp); + } else { + // Ledger already compacted (sync engine handled it) OR + // this was a genuinely online unlock (no ledger entry). + // Either way, skip cache patching here — the sync engine + // already patched with the correct timestamp, and using + // time(NULL) here would corrupt it. + RA_LOG_DEBUG("[AWARD_HTTP] ach=%u: NOT patching cache " + "(found_in_ledger=%d) — sync engine already " + "handled or genuinely online unlock\n", + ach_id, found_in_ledger); + } + } + } + } + } } else { - // Error case + // Error case — network failure, timeout, DNS error, etc. + // Extract request type only (don't log full post_data — it may contain API tokens) + char err_rt_buf[32] = {0}; + const char* err_rtype = ra_extract_param(data->post_data, "r", err_rt_buf, sizeof(err_rt_buf)) + ? err_rt_buf : "unknown"; if (response && response->error) { - RA_LOG_ERROR("HTTP error: %s\n", response->error); + RA_LOG_ERROR("HTTP error for r=%s: %s\n", err_rtype, response->error); + } else { + RA_LOG_ERROR("HTTP error for r=%s: no response\n", err_rtype); + } + } + + // For online startsession responses, patch in any pending ledger unlocks + // so rcheevos shows offline-earned achievements as unlocked even before + // the sync engine submits them to the server. + char* patched_body = NULL; + size_t patched_length = 0; + if (body && body_length > 0 && http_status == 200 && + data->post_data && strstr(data->post_data, "r=startsession")) { + // Make a mutable copy for patching + patched_body = (char*)malloc(body_length + 1); + if (patched_body) { + memcpy(patched_body, body, body_length); + patched_body[body_length] = '\0'; + patched_length = body_length; + + // Extract game hash from request params + char game_hash[64] = {0}; + ra_extract_param(data->post_data, "m", game_hash, sizeof(game_hash)); + + if (game_hash[0] && RA_Offline_patchStartsessionResponse( + patched_body, patched_length, game_hash, + &patched_body, &patched_length)) { + RA_LOG_INFO("Patched online startsession with pending ledger unlocks\n"); + body = patched_body; + body_length = patched_length; + } else { + // No patching needed or failed — use original + free(patched_body); + patched_body = NULL; + } } } @@ -606,13 +1061,18 @@ static void ra_http_callback(HTTP_Response* response, void* userdata) { // The queue makes a copy of the body, so we can free the response after if (!ra_queue_push(body, body_length, http_status, data->callback, data->callback_data)) { // Queue failed (full or not initialized) - log but don't crash - RA_LOG_WARN("Warning: Failed to queue HTTP response\n"); + RA_LOG_WARN("Failed to queue HTTP response\n"); } // Cleanup - safe to free now since queue copied the data + if (patched_body) { + free(patched_body); + } if (response) { HTTP_freeResponse(response); } + free(data->url); + free(data->post_data); free(data); } @@ -621,6 +1081,146 @@ static void ra_server_call(const rc_api_request_t* request, void* callback_data, rc_client_t* client) { (void)client; // unused + // Offline mode: serve cached responses or return retryable errors + if (ra_get_conn_state() != CONN_ONLINE) { + // Extract request type for logging and decision-making + char rt_buf[32] = {0}; + const char* req_type = ra_extract_param(request->post_data, "r", rt_buf, sizeof(rt_buf)) + ? rt_buf : NULL; + + // Try cache first — this handles login, gameid, achievementsets, startsession, patch + char* cached_body = NULL; + size_t cached_len = 0; + + if (RA_Offline_getCachedResponse(request->url, request->post_data, + &cached_body, &cached_len)) { + // Cache hit - deliver cached response via queue + RA_LOG_DEBUG("Offline cache hit: %s (%zu bytes)\n", + req_type ? req_type : "unknown", cached_len); + if (!ra_queue_push(cached_body, cached_len, 200, callback, callback_data)) { + RA_LOG_WARN("Failed to queue cached response\n"); + } + free(cached_body); + return; + } + + // Cache miss — behavior depends on request type. + // + // "Synthesizable" types (login2, startsession): their response format + // is simple enough that {"Success":true} is a valid stub. Synthesize + // a minimal success so rcheevos can proceed. + // + // "Non-synthesizable" cacheable types (gameid, achievementsets, patch): + // these responses contain structured data (GameId, achievement lists, + // memory maps, etc.) that cannot be faked. A synthetic {"Success":true} + // causes rcheevos to fail with RC_MISSING_VALUE. Return a retryable + // error instead — the game load will fail, and we'll retry when + // connectivity is restored via the game_load_retry deferred flag. + // + // Non-cacheable types (awardachievement, ping, submitlbentry, etc.): + // return a retryable error so rcheevos keeps them in its retry queue. + bool is_synthesizable = false; + if (req_type) { + is_synthesizable = (strcmp(req_type, "login2") == 0 || + strcmp(req_type, "startsession") == 0); + } + + if (is_synthesizable) { + // Simple response format — safe to synthesize + RA_LOG_DEBUG("Offline cache miss (synthesizable): %s — returning synthetic success\n", + req_type); + static const char* empty_success = "{\"Success\":true}"; + if (!ra_queue_push(empty_success, strlen(empty_success), 200, callback, callback_data)) { + RA_LOG_WARN("Failed to queue synthetic offline response\n"); + } + } else { + // Non-synthesizable type — return retryable error. + // This covers both game-data requests (gameid, achievementsets, patch) + // whose responses can't be faked, and non-cacheable requests + // (awardachievement, ping, etc.) that need real server interaction. + RA_LOG_DEBUG("Offline cache miss (non-synthesizable): %s — returning retryable error\n", + req_type ? req_type : "unknown"); + + // Extra logging for awardachievement in offline mode + if (req_type && strcmp(req_type, "awardachievement") == 0) { + char offline_a_buf[16] = {0}; + if (ra_extract_param(request->post_data, "a", offline_a_buf, sizeof(offline_a_buf))) { + RA_LOG_INFO("[AWARD_GATE] OFFLINE retryable for ach=%s — " + "rcheevos will retry when online\n", offline_a_buf); + } + } + + rc_api_server_response_t error_response; + memset(&error_response, 0, sizeof(error_response)); + error_response.http_status_code = RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR; + // Direct callback is safe here: ra_server_call runs on the main thread, + // and rcheevos has no internal threading — all server_call invocations + // are synchronous on the calling thread. + callback(&error_response, callback_data); + } + return; + } + + // Online mode: make real HTTP request + + // Check if this is an awardachievement request that should be blocked. + // When the sync engine is handling offline unlocks, rcheevos may also retry + // its own awardachievement calls (queued during offline mode). These use + // CLOCK_MONOTONIC for &o= which doesn't advance during device sleep, + // producing wrong timestamps. Block rcheevos' attempts when: + // 1. The achievement is still in the pending cache (sync hasn't started it yet), OR + // 2. The sync engine is actively running (covers the window after pending cache + // entry was consumed but before sync finishes and compacts the ledger) + // In both cases, return a synthetic success so rcheevos clears its retry state. + char rt_buf[32] = {0}; + const char* req_type = ra_extract_param(request->post_data, "r", rt_buf, sizeof(rt_buf)) + ? rt_buf : NULL; + if (req_type && strcmp(req_type, "awardachievement") == 0) { + char a_buf[16] = {0}; + if (ra_extract_param(request->post_data, "a", a_buf, sizeof(a_buf))) { + uint32_t ach_id = (uint32_t)strtoul(a_buf, NULL, 10); + bool is_pending = (ach_id > 0) && RA_Offline_isUnlockPending(ach_id); + RASyncState sync = ra_get_sync_state(); + + RA_LOG_DEBUG("[AWARD_GATE] ra_server_call: awardachievement ach=%u " + "is_pending=%d sync=%s\n", + ach_id, is_pending, ra_sync_state_str(sync)); + + if (ach_id > 0 && (is_pending || sync == SYNC_RUNNING || sync == SYNC_ABORTING)) { + RA_LOG_INFO("[AWARD_GATE] BLOCKED rcheevos award for ach=%u " + "(pending=%d, sync=%s) — sync engine handles submission\n", + ach_id, is_pending, ra_sync_state_str(sync)); + // Do NOT remove pending cache entry here — that's the sync engine's job. + // Removing it prematurely would open a window where a subsequent + // rcheevos retry slips through the is_pending check. + static const char* synthetic_success = "{\"Success\":true}"; + if (!ra_queue_push(synthetic_success, strlen(synthetic_success), 200, callback, callback_data)) { + RA_LOG_WARN("[AWARD_GATE] Failed to queue synthetic success for blocked ach=%u\n", ach_id); + } + + // If we blocked because the achievement is pending (not because + // sync is running), and we still think we're online, then a real + // HTTP request just failed while the system didn't know WiFi was + // down. Transition to offline and start the connectivity probe + // so that sync fires automatically when WiFi returns. + if (is_pending && !RA_Offline_isOffline()) { + RA_LOG_INFO("[AWARD_GATE] HTTP failure detected while " + "apparently online — switching to offline mode " + "and starting connectivity probe\n"); + RA_Offline_setOffline(true); + ra_start_connectivity_probe(); + } + return; + } + + // Not blocked — this is either a genuinely online unlock or a retry + // after sync completed. Let it through to the real HTTP path. + RA_LOG_DEBUG("[AWARD_GATE] ALLOWED rcheevos award for ach=%u " + "(pending=%d, sync=%s) — sending to server\n", + ach_id, is_pending, ra_sync_state_str(sync)); + } + } + // Allocate data structure to pass through to callback RA_ServerCallData* data = (RA_ServerCallData*)malloc(sizeof(RA_ServerCallData)); if (!data) { @@ -634,6 +1234,11 @@ static void ra_server_call(const rc_api_request_t* request, data->callback = callback; data->callback_data = callback_data; + data->url = request->url ? strdup(request->url) : NULL; + data->post_data = request->post_data ? strdup(request->post_data) : NULL; + /* Snapshot game hash on main thread for safe use in worker callback */ + strncpy(data->game_hash, ra_game_hash, sizeof(data->game_hash) - 1); + data->game_hash[sizeof(data->game_hash) - 1] = '\0'; // Make async HTTP request if (request->post_data && strlen(request->post_data) > 0) { @@ -666,18 +1271,36 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client 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"); + if (ra_should_hide_achievement(event->achievement)) { + RA_LOG_DEBUG("Skipping hidden achievement notification\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); + Notification_push(ra_get_conn_state() != CONN_ONLINE ? NOTIFICATION_OFFLINE_ACHIEVEMENT : NOTIFICATION_ACHIEVEMENT, + message, badge_icon); + RA_LOG_INFO("[AWARD_TRIGGER] Achievement unlocked: %s (id=%u, points=%d, " + "offline=%d, syncing=%d, time_now=%lld)\n", + event->achievement->title, event->achievement->id, + event->achievement->points, + RA_Offline_isOffline(), RA_Offline_isSyncing(), + (long long)time(NULL)); + + // Write-ahead log: persist unlock to ledger (survives app crash) + { + const rc_client_game_t* game = rc_client_get_game_info(client); + if (game) { + RA_Offline_ledgerWriteUnlock(game->id, event->achievement->id, + game->hash, + rc_client_get_hardcore_enabled(client) ? 1 : 0); + // Update pending cache so UI shows offline indicator immediately + // (always add — if disconnect occurs before server confirms, the + // indicator is already in place; sync engine clears it on success) + RA_Offline_addPendingCacheEntry(event->achievement->id); + } + } break; case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW: @@ -766,13 +1389,44 @@ static void ra_event_handler(const rc_client_event_t* event, rc_client_t* client break; case RC_CLIENT_EVENT_DISCONNECTED: - RA_LOG_WARN("Disconnected - unlocks pending\n"); - Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Offline mode", NULL); + RA_LOG_WARN("[CONNECTIVITY] DISCONNECTED — switching to offline mode " + "(time_now=%lld)\n", (long long)time(NULL)); + RA_Offline_setOffline(true); + // Force softcore when offline + rc_client_set_hardcore_enabled(client, 0); + ra_user_saw_offline = true; + // Start probe to detect when connectivity returns + ra_start_connectivity_probe(); break; case RC_CLIENT_EVENT_RECONNECTED: - RA_LOG_INFO("Reconnected - pending unlocks submitted\n"); - Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Reconnected", NULL); + RA_LOG_INFO("[CONNECTIVITY] RECONNECTED — switching to online mode " + "(time_now=%lld)\n", (long long)time(NULL)); + // Stop probe (redundant — rcheevos already confirmed connectivity) + ra_stop_connectivity_probe(); + RA_Offline_setOffline(false); + // Re-enable hardcore if configured + if (CFG_getRAHardcoreMode()) { + rc_client_set_hardcore_enabled(client, 1); + RA_LOG_INFO("Hardcore re-enabled after reconnection\n"); + // rc_client_set_hardcore_enabled() sets waiting_for_reset=1 which + // blocks rc_client_do_frame() from processing any achievements. + // We must call rc_client_reset() to acknowledge the reset and + // clear the flag. + rc_client_reset(client); + RA_LOG_DEBUG("rc_client_reset complete (cleared waiting_for_reset)\n"); + uint32_t fixed = ra_reapply_pending_unlocks(client, ra_game_hash); + if (fixed > 0) { + RA_LOG_INFO("Re-applied %u offline unlock(s) after " + "reconnect hardcore re-enable\n", fixed); + } + } + ra_user_saw_offline = false; + // Network is back — try syncing current game's ledger entries + { + const rc_client_game_t* g = rc_client_get_game_info(client); + ra_start_offline_sync(g ? g->id : 0); + } break; default: @@ -789,36 +1443,43 @@ static void ra_login_callback(int result, const char* error_message, rc_client_t* client, void* userdata) { (void)userdata; + RA_LOG_INFO("ra_login_callback: result=%d, error=%s\n", result, + error_message ? error_message : "(none)"); + if (result == RC_OK) { - // Success - reset retry state - ra_reset_login_state(); - ra_logged_in = true; + // Success — transition to LOGGED_IN + ra_reset_login_retry(); + ra_login_state = LOGIN_LOGGED_IN; + RA_LOG_DEBUG("[SM] Login: → %s\n", ra_login_state_str(ra_get_login_state())); 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) { + + // Trigger deferred game load if one is pending + if (ra_game_state == GAME_PENDING_LOGIN) { RA_LOG_DEBUG("Processing deferred game load: %s\n", ra_pending_load.rom_path); + ra_game_state = GAME_LOADING; 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(); + // Don't clear pending game yet — cleared on successful load in + // ra_game_loaded_callback. If the load fails (e.g., offline cache + // miss), the pending info is preserved for retry. } } else { - // Failure - attempt retry or give up - ra_logged_in = false; + // Failure — schedule retry or give up RA_LOG_ERROR("Login failed: %s\n", error_message ? error_message : "unknown error"); if (ra_login_retry.count < RA_LOGIN_MAX_RETRIES) { - // Schedule retry + // Schedule retry — transition to RETRY_WAITING 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_login_state = LOGIN_RETRY_WAITING; - RA_LOG_DEBUG("Scheduling retry %d/%d in %ums\n", + RA_LOG_DEBUG("[SM] Login: → %s (retry %d/%d in %ums)\n", + ra_login_state_str(ra_get_login_state()), ra_login_retry.count, RA_LOGIN_MAX_RETRIES, delay); // Show "Connecting..." notification on first retry only @@ -828,12 +1489,16 @@ static void ra_login_callback(int result, const char* error_message, "Connecting to RetroAchievements...", NULL); } } else { - // All retries exhausted + // All retries exhausted — transition to FAILED + ra_login_state = LOGIN_FAILED; RA_LOG_ERROR("All login retries exhausted\n"); Notification_push(NOTIFICATION_ACHIEVEMENT, "RetroAchievements: Connection failed", NULL); - ra_reset_login_state(); - ra_clear_pending_game(); + ra_reset_login_retry(); + if (ra_game_state == GAME_PENDING_LOGIN) { + ra_game_state = GAME_NONE; + ra_clear_pending_game(); + } } } } @@ -888,6 +1553,384 @@ static void ra_prefetch_badges(rc_client_t* client) { RA_LOG_DEBUG("Prefetching %d achievement badges\n", idx); } +/***************************************************************************** + * Offline sync engine + * + * When online, replays pending offline achievement unlocks to the server. + * Runs on a background thread with realistic timing between submissions + * to avoid looking automated. + * + * State variables (ra_sync_thread, ra_sync_abort, ra_sync_game_id) are + * declared in the "Static state" section at the top of this file so that + * ra_get_sync_state() can reference them. + *****************************************************************************/ + + +/** + * Background thread function: replay pending offline unlocks using + * the shared sync engine (ra_sync.c). + */ +static int ra_sync_thread_func(void* data) { + (void)data; + + RA_LOG_INFO("[SYNC_THREAD] Starting sync thread (game_id=%u, time_now=%lld)\n", + ra_sync_game_id, (long long)time(NULL)); + + // Snapshot achievement IDs that are pending BEFORE sync, so we can + // tell the main thread which ones were synced (after sync, the ledger + // may have been compacted and the records are gone). + // Snapshot pending unlocks filtered to the sync game (or all if game_id=0). + // This serves two purposes: (a) display_count for the notification, and + // (b) a pre-sync snapshot for post-sync diff to identify which achievements + // were synced. + RA_PendingUnlock* pre_unlocks = NULL; + uint32_t pre_count = 0; + RA_Offline_ledgerGetPendingByGameId(ra_sync_game_id, &pre_unlocks, &pre_count); + + RA_LOG_INFO("[SYNC_THREAD] Pre-sync snapshot: %u pending unlocks (game_id=%u)\n", + pre_count, ra_sync_game_id); + for (uint32_t i = 0; i < pre_count; i++) { + RA_LOG_INFO("[SYNC_THREAD] pending[%u]: ach=%u game=%u timestamp=%u hash=%.8s\n", + i, pre_unlocks[i].achievement_id, pre_unlocks[i].game_id, + pre_unlocks[i].timestamp, pre_unlocks[i].game_hash); + } + + uint32_t display_count = pre_count; + + // Show sync progress in top-left (same area as badge download notifications) + if (display_count > 0) { + char msg[NOTIFICATION_MAX_MESSAGE]; + snprintf(msg, sizeof(msg), "Syncing %u offline achievement%s...", + display_count, display_count == 1 ? "" : "s"); + Notification_setProgressIndicatorPersistent(true); + Notification_showProgressIndicator(msg, "", NULL); + } + + // Run the shared sync engine with background timing + RA_SyncConfig config = RA_SYNC_CONFIG_BACKGROUND; + RA_SyncResult result = RA_Sync_syncAll(ra_sync_game_id, &config, + &ra_sync_abort, NULL, NULL); + + RA_LOG_INFO("[SYNC_THREAD] Sync complete: %u synced, %u skipped, %u failed (of %u) " + "(time_now=%lld)\n", + result.synced, result.skipped, result.failed, result.total, + (long long)time(NULL)); + + // Post synced achievement IDs to the FSM event queue for the main thread. + // We know that RA_Sync_syncAll processes unlocks in order from the + // pending list, and synced + skipped + failed <= total. The first + // (synced + skipped) entries in our pre-snapshot were processed. + // We collect the IDs that match the game filter and were successfully + // synced so the main thread can update rcheevos unlock bits. + if (result.synced > 0 && pre_unlocks && pre_count > 0) { + RAEvent sync_ev; + memset(&sync_ev, 0, sizeof(sync_ev)); + sync_ev.type = RA_EV_SYNC_DONE; + + uint32_t idx = 0; + uint32_t processed = 0; + for (uint32_t i = 0; i < pre_count && idx < RA_EVQ_MAX_SYNC_IDS; i++) { + // Skip entries that don't match the game filter + if (ra_sync_game_id != 0 && pre_unlocks[i].game_id != ra_sync_game_id) + continue; + // Skip hardcore (not synced) + if (pre_unlocks[i].hardcore) + continue; + // The sync processes in order: synced entries come first, + // then skipped, then failed (which stops the loop). + // We can't distinguish synced vs skipped by position alone, + // but we know exactly result.synced were successful. + // Since the sync thread writes SYNC_ACK for each success, + // just store all IDs up to result.synced count. + processed++; + if (processed <= result.synced) { + sync_ev.data.sync_done.ids[idx] = pre_unlocks[i].achievement_id; + sync_ev.data.sync_done.timestamps[idx] = pre_unlocks[i].timestamp; + idx++; + } + } + sync_ev.data.sync_done.count = idx; + RA_EVQ_post(&sync_ev); + } + + free(pre_unlocks); + + // Show completion in top-left progress area, then auto-hide + { + char msg[NOTIFICATION_MAX_MESSAGE]; + if (result.failed > 0) { + snprintf(msg, sizeof(msg), "Sync incomplete: %u synced, will retry later", + result.synced); + } else if (result.synced > 0) { + snprintf(msg, sizeof(msg), "Synced %u offline achievement%s", + result.synced, result.synced == 1 ? "" : "s"); + } else if (result.skipped > 0) { + snprintf(msg, sizeof(msg), "Sync: %u achievement%s skipped", + result.skipped, result.skipped == 1 ? "" : "s"); + } + if (result.synced > 0 || result.failed > 0 || result.skipped > 0) { + // Show first (resets start_time), THEN clear persistent. + // Reversed order risks a TOCTOU race: the main thread's + // Notification_update could see persistent=false with the old + // start_time and expire the indicator before we set new content. + Notification_showProgressIndicator(msg, "", NULL); + Notification_setProgressIndicatorPersistent(false); + } else { + Notification_hideProgressIndicator(); + } + } + + // If sync failed, go back to offline mode and restart the probe + // so connectivity can be re-verified and sync retried + if (result.failed > 0) { + RA_Offline_setOffline(true); + RA_EVQ_post_signal(RA_EV_SYNC_FAILED); + ra_start_connectivity_probe(); + } + + RA_Offline_setSyncing(false); + RA_LOG_INFO("[SYNC_THREAD] Sync thread exiting, isSyncing=false (time_now=%lld)\n", + (long long)time(NULL)); + return 0; +} + +/** + * Start offline sync if we're online and have pending unlocks. + * Called after successful online game load or connectivity restoration. + * + * @param game_id Game to sync (0 = sync all games). + */ +static void ra_start_offline_sync(uint32_t game_id) { + RAConnState conn = ra_get_conn_state(); + RASyncState sync = ra_get_sync_state(); + RA_LOG_INFO("[SYNC_START] ra_start_offline_sync called: game_id=%u, " + "conn=%s, sync=%s, time_now=%lld\n", + game_id, ra_conn_state_str(conn), ra_sync_state_str(sync), + (long long)time(NULL)); + + if (conn != CONN_ONLINE) { + RA_LOG_DEBUG("Sync: skipped (%s)\n", ra_conn_state_str(conn)); + return; + } + if (sync == SYNC_RUNNING || sync == SYNC_ABORTING) { + RA_LOG_DEBUG("Sync: skipped (%s)\n", ra_sync_state_str(sync)); + return; + } + + // Quick check: any pending unlocks? + uint32_t count = 0; + if (!RA_Sync_hasPendingUnlocks(&count) || count == 0) { + RA_LOG_DEBUG("Sync: no pending unlocks found in ledger\n"); + return; + } + + // Start sync on background thread + RA_LOG_INFO("Starting offline sync (%u pending unlocks, game_id=%u)\n", + count, game_id); + SDL_AtomicSet(&ra_sync_abort, 0); + ra_sync_game_id = game_id; + RA_Offline_setSyncing(true); + + /* If a previous sync thread exited but we still hold the handle, + * join it to release SDL resources before creating a new one. */ + if (ra_sync_thread) { + SDL_WaitThread(ra_sync_thread, NULL); + ra_sync_thread = NULL; + } + + ra_sync_thread = SDL_CreateThread(ra_sync_thread_func, "ra_sync", NULL); + if (!ra_sync_thread) { + RA_LOG_ERROR("Failed to create sync thread\n"); + RA_Offline_setSyncing(false); + } +} + + +/***************************************************************************** + * Connectivity probe + * + * When starting in offline mode, a background thread periodically attempts + * a login request to the RA server. On success, it flips offline mode off + * and sets deferred flags so the main thread can push notifications, start + * sync, and re-enable hardcore. The probe uses HTTP_post() synchronously + * (background thread) and rc_api_init_login_request() to build the + * request — it does NOT call rc_client_begin_login (which rejects + * concurrent login attempts and accesses non-thread-safe client state). + *****************************************************************************/ + +/** + * Background thread function: periodically probe RA login endpoint. + * On success, cache the response, flip to online, set deferred flags, exit. + * On failure, sleep RA_PROBE_INTERVAL_MS and retry. + * No retry limit — probes until success or ra_probe_abort. + */ +static int ra_connectivity_probe_func(void* data) { + (void)data; + + RA_LOG_INFO("Connectivity probe started\n"); + + const char* username = CFG_getRAUsername(); + const char* token = CFG_getRAToken(); + + if (!username || !token || strlen(username) == 0 || strlen(token) == 0) { + RA_LOG_ERROR("Probe: no credentials available, exiting\n"); + RA_EVQ_post_signal(RA_EV_PROBE_STOPPED); + SDL_LockMutex(ra_probe_mutex); + SDL_AtomicSet(&ra_probe_running, 0); + SDL_UnlockMutex(ra_probe_mutex); + return 1; + } + + bool probe_succeeded = false; + while (!SDL_AtomicGet(&ra_probe_abort)) { + // Build login request via rcheevos API + rc_api_login_request_t login_params; + memset(&login_params, 0, sizeof(login_params)); + login_params.username = username; + login_params.api_token = token; + + rc_api_request_t request; + memset(&request, 0, sizeof(request)); + + int rc = rc_api_init_login_request(&request, &login_params); + if (rc != RC_OK) { + RA_LOG_ERROR("Probe: failed to build login request (rc=%d)\n", rc); + rc_api_destroy_request(&request); + RA_EVQ_post_signal(RA_EV_PROBE_STOPPED); + SDL_LockMutex(ra_probe_mutex); + SDL_AtomicSet(&ra_probe_running, 0); + SDL_UnlockMutex(ra_probe_mutex); + return 1; + } + + RA_LOG_DEBUG("Probe: attempting login...\n"); + + // Synchronous HTTP POST (background thread, blocking OK) + HTTP_Response* http_resp = HTTP_post(request.url, request.post_data, + request.content_type); + + bool success = false; + if (http_resp && http_resp->http_status == 200 && + http_resp->data && http_resp->size > 0) { + // Check for "Success":true in response (handles optional space) + if (ra_find_json_bool(http_resp->data, "Success") == 1) { + // Cache the login response (write-through) + RA_Offline_cacheResponse(request.url, request.post_data, + http_resp->data, http_resp->size); + + // Flip to online mode + RA_Offline_setOffline(false); + + RA_LOG_INFO("[SM] Conn: → CONN_ONLINE (probe success, time_now=%lld)\n", + (long long)time(NULL)); + + // Post event for main thread. The event payload carries + // whether hardcore should be re-enabled and whether the + // offline notification was still pending (so the main + // thread can decide to suppress both offline + online toasts). + { + bool hc_enable = CFG_getRAHardcoreMode(); + RAEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = RA_EV_PROBE_ONLINE; + ev.data.probe_online.hardcore_enable = hc_enable; + /* The main thread checks ra_deferred_offline_notification + * directly — the probe can't safely read a main-thread-only + * flag, so we always set offline_notif_cancel = false here. + * The main thread determines if the cancel applies. */ + ev.data.probe_online.offline_notif_cancel = false; + RA_EVQ_post(&ev); + } + + success = true; + probe_succeeded = true; + RA_LOG_INFO("Probe: login successful, transitioning to online mode\n"); + } else { + RA_LOG_WARN("Probe: server returned 200 but Success!=true\n"); + } + } else { + RA_LOG_DEBUG("Probe: login failed (status=%d)\n", + http_resp ? http_resp->http_status : -1); + } + + if (http_resp) HTTP_freeResponse(http_resp); + rc_api_destroy_request(&request); + + if (success || SDL_AtomicGet(&ra_probe_abort)) break; + + ra_interruptible_sleep(RA_PROBE_INTERVAL_MS, &ra_probe_abort); + } + + RA_LOG_INFO("Connectivity probe exiting\n"); + // Post stopped event if we didn't already post PROBE_ONLINE + if (!probe_succeeded) { + RA_EVQ_post_signal(RA_EV_PROBE_STOPPED); + } + SDL_LockMutex(ra_probe_mutex); + SDL_AtomicSet(&ra_probe_running, 0); + SDL_UnlockMutex(ra_probe_mutex); + return 0; +} + +/** + * Start the connectivity probe thread (if not already running). + * + * Called from both the main thread and the sync background thread, so + * the check-and-set of ra_probe_running is protected by ra_probe_mutex + * to prevent a TOCTOU race that could launch duplicate probe threads. + */ +static void ra_start_connectivity_probe(void) { + /* Atomically check-and-set probe state under ra_probe_mutex. + * The lock must NOT be held across SDL_WaitThread/SDL_CreateThread + * because the probe thread itself locks ra_probe_mutex at exit. */ + SDL_LockMutex(ra_probe_mutex); + if (SDL_AtomicGet(&ra_probe_running)) { + SDL_UnlockMutex(ra_probe_mutex); + RA_LOG_DEBUG("Probe: already running, not starting another\n"); + return; + } + SDL_AtomicSet(&ra_probe_abort, 0); + SDL_AtomicSet(&ra_probe_running, 1); + SDL_UnlockMutex(ra_probe_mutex); + + /* If a previous probe thread exited but we still hold the handle, join it + * to release SDL resources before creating a new one. */ + if (ra_probe_thread) { + SDL_WaitThread(ra_probe_thread, NULL); + ra_probe_thread = NULL; + } + + ra_probe_thread = SDL_CreateThread(ra_connectivity_probe_func, "ra_probe", NULL); + if (!ra_probe_thread) { + RA_LOG_ERROR("Failed to create connectivity probe thread\n"); + SDL_LockMutex(ra_probe_mutex); + SDL_AtomicSet(&ra_probe_running, 0); + SDL_UnlockMutex(ra_probe_mutex); + return; + } + + RA_LOG_INFO("Connectivity probe thread launched\n"); +} + +/** + * Stop the connectivity probe thread (if running). + * Joins the thread to ensure it has fully exited before returning. + * May block up to HTTP_TIMEOUT_SECS (30s) while an in-flight request completes. + */ +static void ra_stop_connectivity_probe(void) { + if (!ra_probe_thread) return; + + RA_LOG_DEBUG("Stopping connectivity probe...\n"); + SDL_AtomicSet(&ra_probe_abort, 1); + + SDL_WaitThread(ra_probe_thread, NULL); + ra_probe_thread = NULL; + SDL_LockMutex(ra_probe_mutex); + SDL_AtomicSet(&ra_probe_running, 0); + SDL_UnlockMutex(ra_probe_mutex); + RA_LOG_DEBUG("Connectivity probe thread joined\n"); +} + /***************************************************************************** * Callback: Game load callback *****************************************************************************/ @@ -896,9 +1939,17 @@ static void ra_game_loaded_callback(int result, const char* error_message, rc_client_t* client, void* userdata) { (void)userdata; + RA_LOG_INFO("ra_game_loaded_callback: result=%d, error=%s\n", result, + error_message ? error_message : "(none)"); + if (result == RC_OK) { const rc_client_game_t* game = rc_client_get_game_info(client); - ra_game_loaded = true; + ra_game_state = GAME_LOADED; + RA_LOG_DEBUG("[SM] Game: → %s\n", ra_game_state_str(ra_get_game_state())); + + // Game loaded successfully — clear pending load info (no longer needed + // for retry). Must happen before any early returns below. + ra_clear_pending_game(); if (game && game->id != 0) { RA_LOG_INFO("Game loaded: %s (ID: %u)\n", game->title, game->id); @@ -915,51 +1966,44 @@ static void ra_game_loaded_callback(int result, const char* error_message, // Load muted achievements for this game ra_load_muted_achievements(); + // Refresh pending offline unlock cache (for UI display) + RA_Offline_refreshPendingCache(); + // 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); + // Show achievement summary (includes offline unlock augmentation + // and filtered achievement hiding) + ra_show_game_summary(client, game); - uint32_t display_unlocked = summary.num_unlocked_achievements; - uint32_t display_total = summary.num_core_achievements; + // Ledger: record session start + RA_Offline_ledgerWriteSessionStart(game->id, game->hash, + rc_client_get_hardcore_enabled(client) ? 1 : 0); - // 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); + // Sync pending offline unlocks for this game (runs in background) + ra_start_offline_sync(game->id); } else { RA_LOG_WARN("Game not recognized by RetroAchievements\n"); + Notification_push(NOTIFICATION_ACHIEVEMENT, + "No achievements found for this game", NULL); } } else { - ra_game_loaded = false; RA_LOG_ERROR("Game load failed: %s\n", error_message ? error_message : "unknown error"); + + // If we're offline, the failure is likely a cache miss for game data + // (gameid/achievementsets/patch). Schedule a retry when connectivity + // is restored — don't show "no achievements" since it's transient. + if (ra_get_conn_state() != CONN_ONLINE) { + ra_game_state = GAME_LOAD_RETRY_PENDING; + RA_LOG_INFO("Game load failed while offline — will retry on connectivity restore\n"); + } else { + // Genuine failure while online — clear pending load data (T7 fix) + ra_game_state = GAME_NONE; + ra_clear_pending_game(); + Notification_push(NOTIFICATION_ACHIEVEMENT, + "No achievements found for this game", NULL); + } } } @@ -968,43 +2012,97 @@ static void ra_game_loaded_callback(int result, const char* error_message, *****************************************************************************/ void RA_init(void) { + RA_LOG_INFO("RA_init() called, RAEnable=%d\n", CFG_getRAEnable()); if (!CFG_getRAEnable()) { - RA_LOG_DEBUG("RetroAchievements disabled in settings\n"); + RA_LOG_INFO("RetroAchievements disabled in settings, returning early\n"); return; } if (ra_client) { - RA_LOG_DEBUG("Already initialized\n"); + RA_LOG_INFO("Already initialized, returning early\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; - } + // Initialize the event queue (before any threads can post events) + RA_EVQ_init(); - // 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); + // Initialize offline subsystem (always, regardless of WiFi state) + RA_Offline_init(SHARED_USERDATA_PATH); + + // Determine startup mode: offline-first (with probe) vs online vs pure offline + bool wifi_enabled = PLAT_wifiEnabled(); + bool start_offline = false; + bool launch_probe = false; + + RA_LOG_INFO("WiFi check: enabled=%d, connected=%d\n", + wifi_enabled, wifi_enabled ? PLAT_wifiConnected() : 0); + + if (wifi_enabled) { + // WiFi is on — check if we have a cached login for offline-first startup + char* cached_login = NULL; + size_t cached_len = 0; + // Build a probe-style login request to get the correct cache key + bool has_cached_login = false; + if (CFG_getRAAuthenticated() && strlen(CFG_getRAToken()) > 0) { + rc_api_login_request_t login_params; + memset(&login_params, 0, sizeof(login_params)); + login_params.username = CFG_getRAUsername(); + login_params.api_token = CFG_getRAToken(); + + rc_api_request_t request; + memset(&request, 0, sizeof(request)); + if (rc_api_init_login_request(&request, &login_params) == RC_OK) { + has_cached_login = RA_Offline_getCachedResponse( + request.url, request.post_data, &cached_login, &cached_len); + if (has_cached_login) { + free(cached_login); + } + rc_api_destroy_request(&request); + } } - 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; + if (has_cached_login) { + // Cache exists — start offline immediately, probe in background + RA_LOG_INFO("Cached login found — offline-first startup with background probe\n"); + start_offline = true; + launch_probe = true; + } else { + // No cache — must go online for login (current behavior with blocking wait) + RA_LOG_INFO("No cached login — online startup with WiFi wait\n"); + 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_DEBUG("WiFi connected after %ums\n", SDL_GetTicks() - start); + } else { + RA_LOG_WARN("WiFi did not connect within %dms - cannot start online, no cache available\n", RA_WIFI_WAIT_MAX_MS); + start_offline = true; // pure offline, no probe (no cache to serve from) + } + } + // If WiFi connected and no cache, start_offline stays false → online path } - RA_LOG_DEBUG("WiFi connected after %ums\n", SDL_GetTicks() - start); + } else { + // WiFi radio off — pure offline, no probe + RA_LOG_INFO("WiFi disabled — pure offline mode\n"); + start_offline = true; } - RA_LOG_INFO("Initializing...\n"); + RA_Offline_setOffline(start_offline); + + // Initialize the deferred state (must be before setting any flags) + ra_deferred_init(); + + if (start_offline) { + RA_LOG_INFO("Initializing in offline mode (softcore only)...\n"); + // Defer notification — Notification_init() hasn't been called yet at this point + ra_deferred_offline_notification = true; + } else { + RA_LOG_INFO("Initializing in online mode...\n"); + } // Initialize the response queue (must be before any HTTP requests) ra_queue_init(); @@ -1017,7 +2115,7 @@ void RA_init(void) { } // Configure logging - rc_client_enable_logging(ra_client, RC_CLIENT_LOG_LEVEL_INFO, ra_log_message); + rc_client_enable_logging(ra_client, RC_CLIENT_LOG_LEVEL_WARN, ra_log_message); // Set event handler rc_client_set_event_handler(ra_client, ra_event_handler); @@ -1040,26 +2138,66 @@ void RA_init(void) { } // Configure hardcore mode from settings - rc_client_set_hardcore_enabled(ra_client, CFG_getRAHardcoreMode() ? 1 : 0); + // Force softcore when offline + if (ra_get_conn_state() != CONN_ONLINE) { + rc_client_set_hardcore_enabled(ra_client, 0); + } else { + rc_client_set_hardcore_enabled(ra_client, CFG_getRAHardcoreMode() ? 1 : 0); + } - // Reset login state before attempting - ra_reset_login_state(); + // Reset login/game state before attempting + ra_login_state = LOGIN_IDLE; + ra_game_state = GAME_NONE; + ra_reset_login_retry(); // Attempt login with stored token + RA_LOG_INFO("Credentials check: authenticated=%d, token_len=%zu, username=%s\n", + CFG_getRAAuthenticated(), strlen(CFG_getRAToken()), CFG_getRAUsername()); if (CFG_getRAAuthenticated() && strlen(CFG_getRAToken()) > 0) { - RA_LOG_INFO("Logging in with stored token...\n"); + RA_LOG_INFO("Logging in with stored token (conn=%s)...\n", + ra_conn_state_str(ra_get_conn_state())); ra_start_login(); + + // Launch connectivity probe if we started offline with a cached login + if (launch_probe) { + ra_start_connectivity_probe(); + } } else { RA_LOG_WARN("No stored token - user needs to authenticate in settings\n"); } + RA_LOG_INFO("RA_init() complete — conn=%s login=%s game=%s sync=%s\n", + ra_conn_state_str(ra_get_conn_state()), + ra_login_state_str(ra_get_login_state()), + ra_game_state_str(ra_get_game_state()), + ra_sync_state_str(ra_get_sync_state())); } void RA_quit(void) { + // Abort connectivity probe (first pass — may be restarted by sync thread) + ra_stop_connectivity_probe(); + + // Abort any running sync — join the thread to avoid use-after-free + if (ra_sync_thread) { + RA_LOG_INFO("Joining sync thread for shutdown\n"); + SDL_AtomicSet(&ra_sync_abort, 1); + SDL_WaitThread(ra_sync_thread, NULL); + ra_sync_thread = NULL; + RA_LOG_DEBUG("Sync thread joined\n"); + } + + // Stop probe again in case the sync thread restarted it on failure + ra_stop_connectivity_probe(); + + // Shut down the event queue (all threads are joined, drain any leftovers) + RA_EVQ_quit(); + // Clear any pending game data ra_clear_pending_game(); - // Reset login retry state - ra_reset_login_state(); + // Reset login/game state + ra_login_state = LOGIN_IDLE; + ra_game_state = GAME_NONE; + ra_reset_login_retry(); // Clean up badge cache RA_Badges_quit(); @@ -1089,8 +2227,11 @@ void RA_quit(void) { // Clean up the response queue (after rc_client is destroyed) ra_queue_quit(); - ra_game_loaded = false; - ra_logged_in = false; + // Clean up deferred state mutex + ra_deferred_quit(); + + // Shut down offline subsystem (after everything else) + RA_Offline_shutdown(); } void RA_setMemoryAccessors(RA_GetMemoryFunc get_data, RA_GetMemorySizeFunc get_size) { @@ -1164,7 +2305,7 @@ void RA_initMemoryRegions(uint32_t console_id) { 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); + RA_LOG_WARN("Failed to initialize memory regions for console %u\n", console_id); } } @@ -1241,12 +2382,15 @@ static void ra_do_load_game(const char* rom_path, const uint8_t* rom_data, size_ } void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, const char* emu_tag) { + RA_LOG_INFO("RA_loadGame: client=%p, RAEnable=%d, login=%s, path=%s\n", + (void*)ra_client, CFG_getRAEnable(), + ra_login_state_str(ra_get_login_state()), rom_path); if (!ra_client || !CFG_getRAEnable()) { return; } // If not logged in yet, store the game info for deferred loading - if (!ra_logged_in) { + if (ra_get_login_state() != LOGIN_LOGGED_IN) { RA_LOG_DEBUG("Login in progress - deferring game load for: %s\n", rom_path); // Clear any previous pending game @@ -1273,10 +2417,12 @@ void RA_loadGame(const char* rom_path, const uint8_t* rom_data, size_t rom_size, } ra_pending_load.active = true; + ra_game_state = GAME_PENDING_LOGIN; return; } // Already logged in - load immediately + ra_game_state = GAME_LOADING; ra_do_load_game(rom_path, rom_data, rom_size, emu_tag); } @@ -1285,9 +2431,27 @@ void RA_unloadGame(void) { return; } - if (ra_game_loaded) { + if (ra_game_state == GAME_LOADED) { RA_LOG_INFO("Unloading game\n"); + // Abort connectivity probe and sync before unloading + ra_stop_connectivity_probe(); + + if (ra_get_sync_state() == SYNC_RUNNING) { + RA_LOG_INFO("Aborting offline sync for game unload\n"); + SDL_AtomicSet(&ra_sync_abort, 1); + // Give sync thread time to notice the abort + for (int i = 0; i < 10 && ra_get_sync_state() != SYNC_IDLE; i++) { + SDL_Delay(50); + } + } + + // Write SESSION_END to ledger before cleanup invalidates game info + const rc_client_game_t* game = rc_client_get_game_info(ra_client); + if (game) { + RA_Offline_ledgerWriteSessionEnd(game->id, game->hash); + } + // Save any pending muted achievements ra_save_muted_achievements(); ra_clear_muted_achievements(); @@ -1308,7 +2472,277 @@ void RA_unloadGame(void) { // freed in RA_quit() or overwritten in RA_setMemoryMap(). rc_client_unload_game(ra_client); - ra_game_loaded = false; + } + + // Clear any pending game data and reset game state. + // Handles all game states: PENDING_LOGIN, LOADING, LOAD_RETRY_PENDING, etc. + ra_clear_pending_game(); + ra_game_state = GAME_NONE; +} + +/** + * Named action: handle probe-online event. + * + * The connectivity probe confirmed we're back online. This triggers: + * - Cancel pending offline notification if user hasn't seen it yet + * - Otherwise mark the online transition for UI + * - Re-enable hardcore mode if configured + * - Trigger sync of pending offline unlocks + */ +static void action_probe_online(const RAEvent* ev) { + RA_LOG_INFO("[SM] action_probe_online: hardcore=%d\n", + ev->data.probe_online.hardcore_enable); + + // If the deferred offline notification hasn't been shown yet, cancel it. + // The user never saw "Offline" so showing "Connected" is meaningless noise. + if (ra_deferred_offline_notification) { + ra_deferred_offline_notification = false; + RA_LOG_INFO("Probe: cancelled pending offline notification " + "(connectivity arrived in time)\n"); + } else { + ra_user_saw_offline = false; + } + + // Re-enable hardcore if configured + if (ev->data.probe_online.hardcore_enable) { + if (ra_client && CFG_getRAHardcoreMode()) { + rc_client_set_hardcore_enabled(ra_client, 1); + RA_LOG_INFO("Hardcore re-enabled after online transition\n"); + rc_client_reset(ra_client); + RA_LOG_DEBUG("rc_client_reset complete (cleared waiting_for_reset)\n"); + uint32_t fixed = ra_reapply_pending_unlocks(ra_client, ra_game_hash); + if (fixed > 0) { + RA_LOG_INFO("Re-applied %u offline unlock(s) after " + "hardcore re-enable\n", fixed); + } + } + } + + // Trigger sync — deferred until game is loaded + ra_deferred_sync_pending = true; +} + +/** + * Named action: handle sync-done event. + * + * The sync thread successfully synced achievements with the server. + * Store the results for application to rcheevos state. + */ +static void action_sync_done(const RAEvent* ev) { + RA_LOG_INFO("[SM] action_sync_done: count=%u\n", ev->data.sync_done.count); + + ra_sync_apply_pending = true; + ra_sync_apply_count = ev->data.sync_done.count; + if (ev->data.sync_done.count > 0) { + memcpy(ra_sync_apply_ids, ev->data.sync_done.ids, + ev->data.sync_done.count * sizeof(uint32_t)); + memcpy(ra_sync_apply_timestamps, ev->data.sync_done.timestamps, + ev->data.sync_done.count * sizeof(uint32_t)); + } +} + +/** + * Named action: handle sync-failed event. + * + * The sync thread encountered failures. Device goes back to offline mode: + * - Show offline notification + * - Disable hardcore + * - Mark user as having seen offline state + */ +static void action_sync_failed(const RAEvent* ev) { + (void)ev; + RA_LOG_WARN("[SM] action_sync_failed: reverting to offline mode\n"); + + ra_user_saw_offline = true; + ra_deferred_offline_notification = true; + + if (ra_client) { + rc_client_set_hardcore_enabled(ra_client, 0); + RA_LOG_INFO("Hardcore disabled after sync failure (offline mode)\n"); + } +} + +/** + * Central dispatcher: process FSM events and deferred main-thread state. + * + * Called periodically from RA_doFrame() (~every 500ms) and from RA_idle(). + * This is the single point where background-thread signals are converted + * into main-thread state transitions. + * + * Phase 1: Drain the event queue populated by background threads (probe, + * sync) and dispatch each event to its action_*() handler. + * Phase 2: Check main-thread-only deferred flags (offline notification, + * sync pending, sync apply, login retry, game load retry) and + * act on any that are set. + * Phase 3: Lightweight WiFi connectivity poll — calls PLAT_wifiConnected() + * to detect link-layer drops/restores without network I/O. + */ +static void ra_process_deferred_flags(void) { + // --- Phase 1: Drain event queue and dispatch to action functions --- + { + RAEvent ev_buf[RA_EVQ_QUEUE_CAPACITY]; + uint32_t ev_count = RA_EVQ_drain(ev_buf, RA_EVQ_QUEUE_CAPACITY); + + for (uint32_t i = 0; i < ev_count; i++) { + const RAEvent* ev = &ev_buf[i]; + switch (ev->type) { + case RA_EV_PROBE_ONLINE: + action_probe_online(ev); + break; + case RA_EV_PROBE_STOPPED: + RA_LOG_DEBUG("[SM] Probe stopped (abort or failure)\n"); + break; + case RA_EV_SYNC_DONE: + action_sync_done(ev); + break; + case RA_EV_SYNC_FAILED: + action_sync_failed(ev); + break; + default: + RA_LOG_WARN("[SM] Unknown event type %d\n", ev->type); + break; + } + } + } + + // --- Phase 2: Process main-thread-only deferred state --- + + // Deferred offline notification (set at RA_init or by sync failure) + if (ra_deferred_offline_notification) { + ra_user_saw_offline = true; + ra_deferred_offline_notification = false; + } + + // Deferred sync trigger (set by probe-online action) + if (ra_deferred_sync_pending) { + RAGameState gs = ra_get_game_state(); + RA_LOG_INFO("[DEFERRED] sync_pending=true, game=%s, time_now=%lld\n", + ra_game_state_str(gs), (long long)time(NULL)); + // Don't start sync until the game is loaded — the sync thread will + // compact the ledger when done, which removes pending records needed + // by startsession patching and ra_reapply_pending_unlocks. If we sync + // before the game loads, those records are gone before rcheevos can + // use them, causing the just-synced achievement to appear locked. + if (gs == GAME_LOADED) { + ra_deferred_sync_pending = false; + const rc_client_game_t* g = rc_client_get_game_info(ra_client); + ra_start_offline_sync(g ? g->id : 0); + } + // else: stays pending until game loads + } + + // Apply synced achievement unlock state to rcheevos. + // The sync thread confirmed these achievements with the RA server; now we + // update rcheevos' internal unlock bits so the achievement list and summary + // reflect the correct state without needing to restart the game. + if (ra_sync_apply_pending) { + // If the game isn't loaded yet, rcheevos doesn't have achievement data + // so we can't update unlock bits. Keep pending until game loads. + if (ra_get_game_state() == GAME_LOADED && ra_client) { + ra_sync_apply_pending = false; + uint32_t applied = 0; + uint8_t mode = rc_client_get_hardcore_enabled(ra_client) + ? RC_CLIENT_ACHIEVEMENT_UNLOCKED_BOTH + : RC_CLIENT_ACHIEVEMENT_UNLOCKED_SOFTCORE; + + for (uint32_t i = 0; i < ra_sync_apply_count; i++) { + const rc_client_achievement_t* ach = + rc_client_get_achievement_info(ra_client, ra_sync_apply_ids[i]); + if (ach && !(ach->unlocked & mode)) { + // Cast away const — rc_client_get_achievement_info returns a const + // pointer to the internal struct, but we need to update unlock state. + // This is safe: the data is mutable and we're on the main thread. + rc_client_achievement_t* mutable_ach = (rc_client_achievement_t*)ach; + mutable_ach->unlocked |= mode; + time_t old_unlock_time = mutable_ach->unlock_time; + mutable_ach->unlock_time = (time_t)ra_sync_apply_timestamps[i]; + RA_LOG_INFO("Deferred sync-apply: ach %u old_unlock_time=%lld " + "new_unlock_time(ledger)=%lld\n", + ra_sync_apply_ids[i], (long long)old_unlock_time, + (long long)mutable_ach->unlock_time); + if (mutable_ach->state == RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE) { + mutable_ach->state = RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED; + } + applied++; + } + } + + if (applied > 0) { + RA_LOG_INFO("Applied %u synced achievement unlocks to rcheevos state\n", + applied); + } + + // Clear pending cache entries for ALL synced achievements (not just + // the ones we applied above — some may already have been unlocked by + // rcheevos' own retry, but the cache entry still needs removing so + // the UI drops the offline icon and the AWARD_GATE stops blocking). + for (uint32_t i = 0; i < ra_sync_apply_count; i++) { + RA_Offline_removePendingCacheEntry(ra_sync_apply_ids[i]); + } + } + // else: stays pending until game loads + } + + // Check for pending login retry timer + if (ra_client && ra_login_state == LOGIN_RETRY_WAITING && + SDL_GetTicks() >= ra_login_retry.next_time) { + ra_start_login(); /* sets ra_login_state = LOGIN_IN_PROGRESS */ + } + + // Retry game load after connectivity is restored. + // The game load may have failed due to an offline cache miss for game data + // requests (gameid, achievementsets, patch). Now that we're online, retry + // with the preserved pending load info. + if (ra_game_state == GAME_LOAD_RETRY_PENDING) { + RAConnState conn = ra_get_conn_state(); + if (conn == CONN_ONLINE) { + if (ra_pending_load.active && ra_client) { + ra_game_state = GAME_LOADING; + RA_LOG_INFO("Retrying game load after connectivity restored: %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() is called in ra_game_loaded_callback on success. + // If it fails again (unlikely now that we're online), the callback + // won't set GAME_LOAD_RETRY_PENDING again (only set when offline). + } else { + ra_game_state = GAME_NONE; + RA_LOG_WARN("GAME_LOAD_RETRY_PENDING but no pending load or no client\n"); + } + } + // else: stays pending until online + } + + // --- Phase 3: Lightweight WiFi connectivity polling --- + // Check wpa_cli status every RA_WIFI_POLL_INTERVAL_MS to detect WiFi + // drops/restores without hitting the RA server. This catches the case + // where WiFi disappears mid-game before any HTTP request fails (e.g., + // router reboot, wifi_init.sh toggle, moving out of range). + { + static uint32_t last_wifi_poll = 0; + uint32_t now = SDL_GetTicks(); + if (ra_client && now - last_wifi_poll >= RA_WIFI_POLL_INTERVAL_MS) { + last_wifi_poll = now; + bool wifi_up = PLAT_wifiConnected(); + + if (!wifi_up && !RA_Offline_isOffline()) { + // WiFi dropped while we thought we were online + RA_LOG_WARN("[WIFI_POLL] WiFi connection lost — " + "switching to offline mode\n"); + RA_Offline_setOffline(true); + ra_user_saw_offline = true; + ra_start_connectivity_probe(); + } else if (wifi_up && RA_Offline_isOffline() && + !SDL_AtomicGet(&ra_probe_running)) { + // WiFi is back but no probe is running (edge case: + // probe was never started, or exited without success). + // Kick off a probe to verify real connectivity and + // trigger sync. + RA_LOG_INFO("[WIFI_POLL] WiFi restored while offline, " + "no probe running — starting probe\n"); + ra_start_connectivity_probe(); + } + } } } @@ -1317,12 +2751,29 @@ void RA_doFrame(void) { // This ensures game load completes and achievements are active ra_process_queued_responses(); - if (ra_client && ra_game_loaded) { + if (ra_client && ra_game_state == GAME_LOADED) { rc_client_do_frame(ra_client); } + + // Periodically process deferred state transitions (~every 500ms) + // This ensures connectivity probe results, login retries, and sync + // triggers are handled during gameplay without waiting for menu open. + // Cost: one SDL_GetTicks() + one integer comparison per frame. + { + static uint32_t last_deferred_check = 0; + uint32_t now = SDL_GetTicks(); + if (now - last_deferred_check >= 500) { + last_deferred_check = now; + ra_process_deferred_flags(); + } + } } void RA_idle(void) { + // Process any deferred flags (also done periodically in RA_doFrame, + // but running here too ensures prompt handling when menu is open) + ra_process_deferred_flags(); + // 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 @@ -1332,12 +2783,6 @@ void RA_idle(void) { 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() @@ -1346,22 +2791,22 @@ void RA_idle(void) { } bool RA_isGameLoaded(void) { - return ra_game_loaded; + return ra_game_state == GAME_LOADED; } bool RA_isHardcoreModeActive(void) { - if (!ra_client || !ra_game_loaded) { + if (!ra_client || ra_game_state != GAME_LOADED) { return false; } return rc_client_get_hardcore_enabled(ra_client) != 0; } bool RA_isLoggedIn(void) { - return ra_logged_in; + return ra_login_state == LOGIN_LOGGED_IN; } const char* RA_getUserDisplayName(void) { - if (!ra_client || !ra_logged_in) { + if (!ra_client || ra_login_state != LOGIN_LOGGED_IN) { return NULL; } const rc_client_user_t* user = rc_client_get_user_info(ra_client); @@ -1369,7 +2814,7 @@ const char* RA_getUserDisplayName(void) { } const char* RA_getGameTitle(void) { - if (!ra_client || !ra_game_loaded) { + if (!ra_client || ra_game_state != GAME_LOADED) { return NULL; } const rc_client_game_t* game = rc_client_get_game_info(ra_client); @@ -1377,7 +2822,7 @@ const char* RA_getGameTitle(void) { } void RA_getAchievementSummary(uint32_t* unlocked, uint32_t* total) { - if (!ra_client || !ra_game_loaded) { + if (!ra_client || ra_game_state != GAME_LOADED) { if (unlocked) *unlocked = 0; if (total) *total = 0; return; @@ -1393,13 +2838,10 @@ void RA_getAchievementSummary(uint32_t* unlocked, uint32_t* total) { 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) { + if (ra_should_hide_achievement(ach)) { continue; } total_count++; @@ -1416,7 +2858,7 @@ void RA_getAchievementSummary(uint32_t* unlocked, uint32_t* total) { } const void* RA_createAchievementList(int category, int grouping) { - if (!ra_client || !ra_game_loaded) { + if (!ra_client || ra_game_state != GAME_LOADED) { return NULL; } return rc_client_create_achievement_list(ra_client, category, grouping); @@ -1429,7 +2871,7 @@ void RA_destroyAchievementList(const void* list) { } const char* RA_getGameHash(void) { - if (!ra_game_loaded || ra_game_hash[0] == '\0') { + if (ra_game_state != GAME_LOADED || ra_game_hash[0] == '\0') { return NULL; } return ra_game_hash; @@ -1482,3 +2924,7 @@ void RA_setAchievementMuted(uint32_t achievement_id, bool muted) { } } } + +bool RA_isAchievementOfflinePending(uint32_t achievement_id) { + return RA_Offline_isUnlockPending(achievement_id); +} diff --git a/workspace/all/minarch/ra_integration.h b/workspace/all/minarch/ra_integration.h index 9353a5c97..45f5bc2ce 100644 --- a/workspace/all/minarch/ra_integration.h +++ b/workspace/all/minarch/ra_integration.h @@ -129,6 +129,13 @@ bool RA_toggleAchievementMute(uint32_t achievement_id); */ void RA_setAchievementMuted(uint32_t achievement_id, bool muted); +/** + * Check if an achievement has a pending offline unlock (not yet synced to server). + * @param achievement_id The achievement ID to check + * @return true if the achievement was unlocked offline and not yet synced + */ +bool RA_isAchievementOfflinePending(uint32_t achievement_id); + /** * Typedef for memory read function pointer. * This allows minarch to provide its memory access function. diff --git a/workspace/all/settings/makefile b/workspace/all/settings/makefile index 9b0927114..da1aae07b 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 ../common/http.c ../common/ra_auth.c ../../$(PLATFORM)/platform/platform.c +SOURCE = -c ../common/utils.c ../common/api.c ../common/config.c ../common/scaler.c ../common/http.c ../common/ra_auth.c ../common/ra_offline.c ../common/ra_sync.c ../common/ra_event_queue.c ../../$(PLATFORM)/platform/platform.c CXXSOURCE = $(TARGET).cpp menu.cpp colorpickermenu.cpp wifimenu.cpp btmenu.cpp keyboardprompt.cpp -CXXSOURCE += build/$(PLATFORM)/utils.o build/$(PLATFORM)/api.o build/$(PLATFORM)/config.o build/$(PLATFORM)/scaler.o build/$(PLATFORM)/http.o build/$(PLATFORM)/ra_auth.o build/$(PLATFORM)/platform.o +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)/ra_offline.o build/$(PLATFORM)/ra_sync.o build/$(PLATFORM)/ra_event_queue.o build/$(PLATFORM)/platform.o CC = $(CROSS_COMPILE)gcc CXX = $(CROSS_COMPILE)g++ @@ -31,7 +31,7 @@ CFLAGS += $(OPT) CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" CXXFLAGS += $(CFLAGS) -std=c++17 CFLAGS += -fpermissive -LDFLAGS += -lmsettings +LDFLAGS += -lmsettings -lcrypto ifeq ($(PLATFORM), tg5040) # drop as soon as we use btagent on all platforms CXXFLAGS += -DHAS_BTAGENT @@ -50,10 +50,10 @@ 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 http.o ra_auth.o platform.o build/$(PLATFORM) + mv utils.o api.o config.o scaler.o http.o ra_auth.o ra_offline.o ra_sync.o ra_event_queue.o platform.o build/$(PLATFORM) $(CXX) $(CXXSOURCE) -o $(PRODUCT) $(CXXFLAGS) $(LDFLAGS) -lstdc++ clean: rm -f $(PRODUCT) $(PREFIX_LOCAL)/include/msettings.h: - cd ../../$(PLATFORM)/libmsettings && make \ No newline at end of file + cd ../../$(PLATFORM)/libmsettings && make diff --git a/workspace/all/settings/menu.cpp b/workspace/all/settings/menu.cpp index 70c85b2ff..ab5915b03 100644 --- a/workspace/all/settings/menu.cpp +++ b/workspace/all/settings/menu.cpp @@ -912,6 +912,10 @@ void MenuList::showOverlay(const std::string& message, OverlayDismissMode dismis // We want to force a draw right now since usually we are about to block WriteLock w(overlayLock); if (overlaySurface) { + // Clear the surface first to prevent text ghosting from previous + // overlay frames (the semi-transparent shadow doesn't fully obscure + // old content, causing overlap when updated repeatedly in a loop) + SDL_FillRect(overlaySurface, NULL, SDL_MapRGB(overlaySurface->format, 0, 0, 0)); drawOverlayLocal(overlaySurface); GFX_flip(overlaySurface); } diff --git a/workspace/all/settings/settings.cpp b/workspace/all/settings/settings.cpp index 1ad298a73..bd8bb52c5 100644 --- a/workspace/all/settings/settings.cpp +++ b/workspace/all/settings/settings.cpp @@ -6,6 +6,7 @@ extern "C" #include "api.h" #include "utils.h" #include "ra_auth.h" +#include "ra_sync.h" } #include @@ -13,6 +14,9 @@ extern "C" #include #include #include +#include +#include +#include #include "wifimenu.hpp" #include "btmenu.hpp" #include "keyboardprompt.hpp" @@ -776,6 +780,121 @@ int main(int argc, char *argv[]) []() -> 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, "Sync Offline Unlocks", + []() -> std::string { + uint32_t count = 0; + RA_Sync_hasPendingUnlocks(&count); + if (count > 0) { + char buf[64]; + snprintf(buf, sizeof(buf), "%u pending \u2014 send to RA server", count); + return std::string(buf); + } + return std::string("No pending unlocks"); + }(), + [](AbstractMenuItem &item) -> InputReactionHint { + // Check authentication + if (!CFG_getRAAuthenticated() || strlen(CFG_getRAToken()) == 0) { + item.setDesc("Not authenticated"); + return NoOp; + } + + // Check for pending unlocks + uint32_t pending = 0; + if (!RA_Sync_hasPendingUnlocks(&pending) || pending == 0) { + item.setDesc("No pending unlocks"); + return NoOp; + } + + // Show initial overlay with cancel hint + char msg[128]; + snprintf(msg, sizeof(msg), "Syncing %u achievement%s...\n\n(0/%u)", + pending, pending == 1 ? "" : "s", pending); + MenuList::showOverlay(msg, OverlayDismissMode::None); + + // Shared state between sync thread and main thread + SDL_atomic_t cancel; + SDL_AtomicSet(&cancel, 0); + std::atomic done{false}; + std::mutex progress_mutex; + std::string progress_msg; + std::atomic progress_dirty{false}; + RA_SyncResult sync_result = {0, 0, 0, 0}; + + // Progress callback updates shared message string + struct ProgressCtx { + std::mutex* mutex; + std::atomic* dirty; + std::string* msg; + uint32_t total; + }; + ProgressCtx pctx = {&progress_mutex, &progress_dirty, &progress_msg, pending}; + + // Launch sync on background thread (game_id=0 for all games, NULL config for interactive defaults) + std::thread sync_thread([&]() { + sync_result = RA_Sync_syncAll(0, NULL, &cancel, + [](uint32_t current, uint32_t total, bool success, void* userdata) { + auto* ctx = static_cast(userdata); + char buf[128]; + snprintf(buf, sizeof(buf), "Syncing achievements...\n\n(%u/%u)", + current, ctx->total); + { + std::lock_guard lock(*ctx->mutex); + *ctx->msg = buf; + } + ctx->dirty->store(true); + }, &pctx); + done.store(true); + }); + + // Main thread: poll for B-button cancel and update overlay + while (!done.load()) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_B)) { + SDL_AtomicSet(&cancel, 1); + MenuList::showOverlay("Cancelling sync...", OverlayDismissMode::None); + } + + // Update overlay if progress changed + if (progress_dirty.exchange(false)) { + std::string current_msg; + { + std::lock_guard lock(progress_mutex); + current_msg = progress_msg; + } + if (!SDL_AtomicGet(&cancel)) { + MenuList::showOverlay(current_msg, OverlayDismissMode::None); + } + } + + GFX_sync(); + } + + sync_thread.join(); + MenuList::hideOverlay(); + + // Update button description with result + if (SDL_AtomicGet(&cancel) && sync_result.synced == 0) { + item.setDesc("Sync cancelled"); + } else if (SDL_AtomicGet(&cancel) && sync_result.synced > 0) { + snprintf(msg, sizeof(msg), "Cancelled: %u of %u synced", + sync_result.synced, sync_result.total); + item.setDesc(msg); + } else if (sync_result.failed > 0) { + snprintf(msg, sizeof(msg), "Incomplete: %u synced, retry later", + sync_result.synced); + item.setDesc(msg); + } else if (sync_result.synced > 0) { + snprintf(msg, sizeof(msg), "Synced %u achievement%s", + sync_result.synced, sync_result.synced == 1 ? "" : "s"); + item.setDesc(msg); + } else { + item.setDesc("No pending unlocks"); + } + + return NoOp; + }}, new MenuItem{ListItemType::Button, "Reset to defaults", "Resets all options in this menu to their default values.", ResetCurrentMenu}, }); From e0b0013cad1d3675e38a5688c34bba088cbd794d Mon Sep 17 00:00:00 2001 From: frysee Date: Tue, 12 May 2026 23:36:41 +0200 Subject: [PATCH 02/19] squash pick DrFlarp/shaders_origtexture (generic_video.c only) (#720) --- workspace/all/common/generic_video.c | 494 +++++++++++++++------------ 1 file changed, 270 insertions(+), 224 deletions(-) diff --git a/workspace/all/common/generic_video.c b/workspace/all/common/generic_video.c index 5f6b8570b..00cb0cbf8 100644 --- a/workspace/all/common/generic_video.c +++ b/workspace/all/common/generic_video.c @@ -32,7 +32,6 @@ #define NEXTUI_TSAN 1 #endif -static int finalScaleFilter=GL_LINEAR; static int reloadShaderTextures = 1; static int shaderResetRequested = 0; @@ -49,42 +48,74 @@ static SDL_BlendMode getPremultipliedBlendMode(void) { // shader stuff -typedef struct Shader { - int srcw; - int srch; - int texw; - int texh; - int filter; +typedef struct ShaderProgram { GLuint shader_p; - int scale; - int srctype; - int scaletype; char *filename; - GLuint texture; - int updated; + + // Cached from glGetUniformLocation() GLint u_FrameDirection; GLint u_FrameCount; GLint u_OutputSize; GLint u_TextureSize; GLint u_InputSize; - GLint OrigInputSize; - GLint texLocation; - GLint texelSizeLocation; + GLint u_OrigTextureSize; + GLint u_OrigInputSize; + GLint u_Texture; + GLint u_OrigTexture; + GLint u_texelSize; + ShaderParam *pragmas; // Dynamic array of parsed pragma parameters int num_pragmas; // Count of valid pragma parameters +} ShaderProgram; + +ShaderProgram s_shader_default = {0}; +ShaderProgram s_shader_overlay = {0}; +ShaderProgram s_noshader = {0}; + +typedef struct ShaderPass { + ShaderProgram * program; + int filter; + int alpha; + GLuint target_texture; + int target_updated; + int scale; + int srctype; + int scaletype; + int srcw; + int srch; + int texw; + int texh; +} ShaderPass; -} Shader; +ShaderProgram shader_programs[MAXSHADERS]; +ShaderPass shaders[MAXSHADERS]; -GLuint g_shader_default = 0; -GLuint g_shader_overlay = 0; -GLuint g_noshader = 0; +// memcpy these in initShaders() +const ShaderProgram blank_shader_program = { + .shader_p = 0, .filename = "stock.glsl" +}; +const ShaderPass blank_shader_pass = { .program = NULL, + .alpha = 0, .target_texture = 0, .target_updated = 1 +}; + +ShaderPass s_pass_finalscale = { .program = &s_shader_default, + .filter = GL_NEAREST, + .alpha = 0, .target_texture = 0, .target_updated = 1 +}; -Shader* shaders[MAXSHADERS] = { - &(Shader){ .shader_p = 0, .scale = 1, .filter = GL_LINEAR, .scaletype = 1, .srctype = 0, .filename ="stock.glsl", .texture = 0, .updated = 1 }, - &(Shader){ .shader_p = 0, .scale = 1, .filter = GL_LINEAR, .scaletype = 1, .srctype = 0, .filename ="stock.glsl", .texture = 0, .updated = 1 }, - &(Shader){ .shader_p = 0, .scale = 1, .filter = GL_LINEAR, .scaletype = 1, .srctype = 0, .filename ="stock.glsl", .texture = 0, .updated = 1 }, +ShaderPass s_pass_effect = { .program = &s_shader_overlay, + .alpha = 1, .target_texture = 0, .target_updated = 1 }; +ShaderPass s_pass_overlay = { .program = &s_shader_overlay, + .alpha = 1, .target_texture = 0, .target_updated = 1 +}; + +ShaderPass s_pass_notif = { .program = &s_shader_overlay, + .alpha = 1, .target_texture = 0, .target_updated = 1 +}; + + static int nrofshaders = 0; // choose between 1 and 3 pipelines, > pipelines = more cpu usage, but more shader options and shader upscaling stuff /////////////////////////////// @@ -282,9 +313,7 @@ char* load_shader_source(const char* filename) { return source; } -GLuint load_shader_from_file(GLenum type, const char* filename, const char* path) { - char filepath[256]; - snprintf(filepath, sizeof(filepath), "%s/%s", path, filename); +GLuint load_shader_from_file(GLenum type, const char* filepath) { char* source = load_shader_source(filepath); if (!source) return 0; @@ -444,29 +473,101 @@ GLuint load_shader_from_file(GLenum type, const char* filename, const char* path return shader; } +#define MAX_SHADER_PRAGMAS 32 +void loadShaderPragmas(ShaderProgram *shader, const char *shaderSource) { + shader->pragmas = calloc(MAX_SHADER_PRAGMAS, sizeof(ShaderParam)); + if (!shader->pragmas) { + fprintf(stderr, "Out of memory allocating pragmas for %s\n", shader->filename); + return; + } + shader->num_pragmas = extractPragmaParameters(shaderSource, shader->pragmas, MAX_SHADER_PRAGMAS); +} + +ShaderParam* PLAT_getShaderPragmas(int i) { + return shaders[i].program->pragmas; +} + +void init_shader_program(ShaderProgram * shader, const char * path, const char * filename) { + char filepath[512]; + snprintf(filepath, sizeof(filepath), "%s/%s", path, filename); + + const char *shaderSource = load_shader_source(filepath); + loadShaderPragmas(shader,shaderSource); + + GLuint vertex_shader1 = load_shader_from_file(GL_VERTEX_SHADER, filepath); + GLuint fragment_shader1 = load_shader_from_file(GL_FRAGMENT_SHADER, filepath); + + // Link the shader program + if (shader->shader_p != 0) { + LOG_info("Deleting previous shader %i\n",shader->shader_p); + glDeleteProgram(shader->shader_p); + } + shader->shader_p = link_program(vertex_shader1, fragment_shader1, filename); + + + if (shader->shader_p == 0) { + LOG_info("Shader linking failed for %s\n", filename); + } + + GLint success = 0; + glGetProgramiv(shader->shader_p, GL_LINK_STATUS, &success); + if (!success) { + char infoLog[512]; + glGetProgramInfoLog(shader->shader_p, 512, NULL, infoLog); + LOG_info("Shader Program Linking Failed: %s\n", infoLog); + } else { + LOG_info("Shader Program Linking Success %s shader ID is %i\n", filename,shader->shader_p); + + // Populate uniforms and pragma uniforms + shader->u_FrameDirection = glGetUniformLocation( shader->shader_p, "FrameDirection"); + shader->u_FrameCount = glGetUniformLocation( shader->shader_p, "FrameCount"); + shader->u_OutputSize = glGetUniformLocation( shader->shader_p, "OutputSize"); + shader->u_TextureSize = glGetUniformLocation( shader->shader_p, "TextureSize"); + shader->u_InputSize = glGetUniformLocation( shader->shader_p, "InputSize"); + shader->u_OrigTextureSize = glGetUniformLocation( shader->shader_p, "OrigTextureSize"); + shader->u_OrigInputSize = glGetUniformLocation( shader->shader_p, "OrigInputSize"); + shader->u_Texture = glGetUniformLocation(shader->shader_p, "Texture"); + shader->u_OrigTexture = glGetUniformLocation(shader->shader_p, "OrigTexture"); + shader->u_texelSize = glGetUniformLocation(shader->shader_p, "texelSize"); + for (int i = 0; i < shader->num_pragmas; ++i) { + shader->pragmas[i].uniformLocation = glGetUniformLocation(shader->shader_p, shader->pragmas[i].name); + shader->pragmas[i].value = shader->pragmas[i].def; + + LOG_info("Param: %s = %f (min: %f, max: %f, step: %f)\n", + shader->pragmas[i].name, + shader->pragmas[i].def, + shader->pragmas[i].min, + shader->pragmas[i].max, + shader->pragmas[i].step); + } + + } + shader->filename = strdup(filename); + +} + void PLAT_initShaders() { SDL_GL_MakeCurrent(vid.window, vid.gl_context); glViewport(0, 0, device_width, device_height); - GLuint vertex; - GLuint fragment; + // Init user shaders + for (int i = 0; i < MAXSHADERS; i++) { + memcpy(&shader_programs[i], &blank_shader_program, sizeof(ShaderProgram)); + memcpy(&shaders[i], &blank_shader_pass, sizeof(ShaderPass)); + shaders[i].program = &shader_programs[i]; + } - // Final display shader (simple texture blit) - vertex = load_shader_from_file(GL_VERTEX_SHADER, "default.glsl",SYSSHADERS_FOLDER); - fragment = load_shader_from_file(GL_FRAGMENT_SHADER, "default.glsl",SYSSHADERS_FOLDER); - g_shader_default = link_program(vertex, fragment,"default.glsl"); + // Init .system shaders + // Final display shader (simple texture blit) + init_shader_program(&s_shader_default, SYSSHADERS_FOLDER, "default.glsl"); // Overlay shader, for png overlays and static line/grid overlays - vertex = load_shader_from_file(GL_VERTEX_SHADER, "overlay.glsl",SYSSHADERS_FOLDER); - fragment = load_shader_from_file(GL_FRAGMENT_SHADER, "overlay.glsl",SYSSHADERS_FOLDER); - g_shader_overlay = link_program(vertex, fragment,"overlay.glsl"); + init_shader_program(&s_shader_overlay, SYSSHADERS_FOLDER, "overlay.glsl"); // Stand-In if a shader is supposed to be applied, but wasnt compiled properly (shaper_p == NULL) - vertex = load_shader_from_file(GL_VERTEX_SHADER, "noshader.glsl",SYSSHADERS_FOLDER); - fragment = load_shader_from_file(GL_FRAGMENT_SHADER, "noshader.glsl",SYSSHADERS_FOLDER); - g_noshader = link_program(vertex, fragment,"noshader.glsl"); - - LOG_info("default shaders loaded, %i\n\n",g_shader_default); + init_shader_program(&s_noshader, SYSSHADERS_FOLDER, "noshader.glsl"); + + LOG_info("default shaders loaded, %i\n\n", s_shader_default.shader_p); } void PLAT_initNotificationTexture(void) { @@ -618,96 +719,34 @@ void PLAT_setClearColor(uint32_t color) { vid.clear_color = color; } -#define MAX_SHADER_PRAGMAS 32 -void loadShaderPragmas(Shader *shader, const char *shaderSource) { - shader->pragmas = calloc(MAX_SHADER_PRAGMAS, sizeof(ShaderParam)); - if (!shader->pragmas) { - fprintf(stderr, "Out of memory allocating pragmas for %s\n", shader->filename); - return; - } - shader->num_pragmas = extractPragmaParameters(shaderSource, shader->pragmas, MAX_SHADER_PRAGMAS); -} - -ShaderParam* PLAT_getShaderPragmas(int i) { - return shaders[i]->pragmas; -} - void PLAT_updateShader(int i, const char *filename, int *scale, int *filter, int *scaletype, int *srctype) { if (i < 0 || i >= MAXSHADERS) { return; } - Shader* shader = shaders[i]; + ShaderPass* shader_pass = &shaders[i]; if (filename != NULL) { SDL_GL_MakeCurrent(vid.window, vid.gl_context); LOG_info("loading shader \n"); - char filepath[512]; - snprintf(filepath, sizeof(filepath), SHADERS_FOLDER "/glsl/%s",filename); - const char *shaderSource = load_shader_source(filepath); - loadShaderPragmas(shader,shaderSource); - - GLuint vertex_shader1 = load_shader_from_file(GL_VERTEX_SHADER, filename,SHADERS_FOLDER "/glsl"); - GLuint fragment_shader1 = load_shader_from_file(GL_FRAGMENT_SHADER, filename,SHADERS_FOLDER "/glsl"); - - // Link the shader program - if (shader->shader_p != 0) { - LOG_info("Deleting previous shader %i\n",shader->shader_p); - glDeleteProgram(shader->shader_p); - } - shader->shader_p = link_program(vertex_shader1, fragment_shader1,filename); - - shader->u_FrameDirection = glGetUniformLocation( shader->shader_p, "FrameDirection"); - shader->u_FrameCount = glGetUniformLocation( shader->shader_p, "FrameCount"); - shader->u_OutputSize = glGetUniformLocation( shader->shader_p, "OutputSize"); - shader->u_TextureSize = glGetUniformLocation( shader->shader_p, "TextureSize"); - shader->u_InputSize = glGetUniformLocation( shader->shader_p, "InputSize"); - shader->OrigInputSize = glGetUniformLocation( shader->shader_p, "OrigInputSize"); - shader->texLocation = glGetUniformLocation(shader->shader_p, "Texture"); - shader->texelSizeLocation = glGetUniformLocation(shader->shader_p, "texelSize"); - for (int i = 0; i < shader->num_pragmas; ++i) { - shader->pragmas[i].uniformLocation = glGetUniformLocation(shader->shader_p, shader->pragmas[i].name); - shader->pragmas[i].value = shader->pragmas[i].def; - - LOG_info("Param: %s = %f (min: %f, max: %f, step: %f)\n", - shader->pragmas[i].name, - shader->pragmas[i].def, - shader->pragmas[i].min, - shader->pragmas[i].max, - shader->pragmas[i].step); - } - - if (shader->shader_p == 0) { - LOG_info("Shader linking failed for %s\n", filename); - } - - GLint success = 0; - glGetProgramiv(shader->shader_p, GL_LINK_STATUS, &success); - if (!success) { - char infoLog[512]; - glGetProgramInfoLog(shader->shader_p, 512, NULL, infoLog); - LOG_info("Shader Program Linking Failed: %s\n", infoLog); - } else { - LOG_info("Shader Program Linking Success %s shader ID is %i\n", filename,shader->shader_p); - } - shader->filename = strdup(filename); - } + init_shader_program(shader_pass->program, SHADERS_FOLDER "/glsl", filename); + } if (scale != NULL) { - shader->scale = *scale +1; + shader_pass->scale = *scale +1; reloadShaderTextures = 1; } if (scaletype != NULL) { - shader->scaletype = *scaletype; + shader_pass->scaletype = *scaletype; } if (srctype != NULL) { - shader->srctype = *srctype; + shader_pass->srctype = *srctype; } if (filter != NULL) { - shader->filter = (*filter == 1) ? GL_LINEAR : GL_NEAREST; + shader_pass->filter = (*filter == 1) ? GL_LINEAR : GL_NEAREST; reloadShaderTextures = 1; } - shader->updated = 1; + shader_pass->target_updated = 1; } @@ -849,10 +888,10 @@ SDL_Surface* PLAT_resizeVideo(int w, int h, int p) { void PLAT_setSharpness(int sharpness) { if(sharpness==1) { - finalScaleFilter=GL_LINEAR; - } + s_pass_finalscale.filter = GL_LINEAR; + } else { - finalScaleFilter = GL_NEAREST; + s_pass_finalscale.filter = GL_NEAREST; } reloadShaderTextures = 1; } @@ -1663,8 +1702,14 @@ void PLAT_flip(SDL_Surface* IGNORED, int ignored) { } static int frame_count = 0; -void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_texture, - int x, int y, int dst_width, int dst_height, Shader* shader, int alpha, int filter) { +static GLuint orig_texture = 0; +static int orig_w = 0; +static int orig_h = 0; +static int origtex_w = 0; +static int origtex_h = 0; +void runShaderPass(ShaderPass * shader_pass, GLuint src_texture, + GLuint * target_texture, int next_filter, + int x, int y, int dst_width, int dst_height) { static GLuint static_VAO = 0, static_VBO = 0; static GLuint last_program = 0; @@ -1676,6 +1721,16 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex static int logged_bad_size = 0; GLenum pre_err; + if (!shader_pass) return; + + ShaderProgram * shader_program = shader_pass->program; + + if (!shader_program || !shader_program->shader_p) { + shader_program = &s_noshader; + } + + const GLuint shader_program_handle = shader_program->shader_p; + while ((pre_err = glGetError()) != GL_NO_ERROR) { (void)pre_err; } @@ -1705,12 +1760,12 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex last_bound_texture = 0; } - texelSize[0] = 1.0f / shader->texw; - texelSize[1] = 1.0f / shader->texh; + texelSize[0] = 1.0f / shader_pass->texw; + texelSize[1] = 1.0f / shader_pass->texh; - if (shader_program != last_program) - glUseProgram(shader_program); + if (shader_program_handle != last_program) + glUseProgram(shader_program_handle); if (static_VAO == 0) { glGenVertexArrays(1, &static_VAO); @@ -1729,29 +1784,30 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); } - if (shader_program != last_program) { - GLint posAttrib = glGetAttribLocation(shader_program, "VertexCoord"); + if (shader_program_handle != last_program) { + GLint posAttrib = glGetAttribLocation(shader_program_handle, "VertexCoord"); if (posAttrib >= 0) { glVertexAttribPointer(posAttrib, 4, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0); glEnableVertexAttribArray(posAttrib); } - GLint texAttrib = glGetAttribLocation(shader_program, "TexCoord"); + GLint texAttrib = glGetAttribLocation(shader_program_handle, "TexCoord"); if (texAttrib >= 0) { glVertexAttribPointer(texAttrib, 4, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(4 * sizeof(float))); glEnableVertexAttribArray(texAttrib); } - if (shader->u_FrameDirection >= 0) glUniform1i(shader->u_FrameDirection, 1); - if (shader->u_FrameCount >= 0) glUniform1i(shader->u_FrameCount, frame_count); - if (shader->u_OutputSize >= 0) glUniform2f(shader->u_OutputSize, dst_width, dst_height); - if (shader->u_TextureSize >= 0) glUniform2f(shader->u_TextureSize, shader->texw, shader->texh); - if (shader->OrigInputSize >= 0) glUniform2f(shader->OrigInputSize, shader->srcw, shader->srch); - if (shader->u_InputSize >= 0) glUniform2f(shader->u_InputSize, shader->srcw, shader->srch); - for (int i = 0; i < shader->num_pragmas; ++i) { - glUniform1f(shader->pragmas[i].uniformLocation, shader->pragmas[i].value); + if (shader_program->u_FrameDirection >= 0) glUniform1i(shader_program->u_FrameDirection, 1); + if (shader_program->u_FrameCount >= 0) glUniform1i(shader_program->u_FrameCount, frame_count); + if (shader_program->u_OutputSize >= 0) glUniform2f(shader_program->u_OutputSize, dst_width, dst_height); + if (shader_program->u_TextureSize >= 0) glUniform2f(shader_program->u_TextureSize, shader_pass->texw, shader_pass->texh); + if (shader_program->u_InputSize >= 0) glUniform2f(shader_program->u_InputSize, shader_pass->srcw, shader_pass->srch); + if (shader_program->u_OrigTextureSize >= 0) glUniform2f(shader_program->u_OrigTextureSize, origtex_w, origtex_h); + if (shader_program->u_OrigInputSize >= 0) glUniform2f(shader_program->u_OrigInputSize, orig_w, orig_h); + for (int i = 0; i < shader_program->num_pragmas; ++i) { + glUniform1f(shader_program->pragmas[i].uniformLocation, shader_program->pragmas[i].value); } - GLint u_MVP = glGetUniformLocation(shader_program, "MVPMatrix"); + GLint u_MVP = glGetUniformLocation(shader_program_handle, "MVPMatrix"); if (u_MVP >= 0) { float identity[16] = { 1,0,0,0, @@ -1766,9 +1822,9 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex if (target_texture) { if (*target_texture != 0 && !glIsTexture(*target_texture)) { *target_texture = 0; - shader->updated = 1; + shader_pass->target_updated = 1; } - if (*target_texture==0 || shader->updated || reloadShaderTextures) { + if (*target_texture==0 || shader_pass->target_updated || reloadShaderTextures) { // if(target_texture) { // glDeleteTextures(1,target_texture); @@ -1777,12 +1833,12 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex glGenTextures(1, target_texture); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, *target_texture); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, next_filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, next_filter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, dst_width, dst_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); - shader->updated = 0; + shader_pass->target_updated = 0; } if (fbo == 0) { glGenFramebuffers(1, &fbo); @@ -1808,7 +1864,7 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex glBindFramebuffer(GL_FRAMEBUFFER, 0); } - if(alpha==1) { + if(shader_pass->alpha==1) { glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); } else { @@ -1822,16 +1878,22 @@ void runShaderPass(GLuint src_texture, GLuint shader_program, GLuint* target_tex } glViewport(x, y, dst_width, dst_height); + if (shader_program->u_Texture >= 0) glUniform1i(shader_program->u_Texture, 0); - if (shader->texLocation >= 0) glUniform1i(shader->texLocation, 0); - - if (shader->texelSizeLocation >= 0) { - glUniform2fv(shader->texelSizeLocation, 1, texelSize); + if (shader_program->u_OrigTexture >= 0) { + glUniform1i(shader_program->u_OrigTexture, 1); + glActiveTexture(GL_TEXTURE0+1); + glBindTexture(GL_TEXTURE_2D, orig_texture); + glActiveTexture(GL_TEXTURE0); + } + + if (shader_program->u_texelSize >= 0) { + glUniform2fv(shader_program->u_texelSize, 1, texelSize); last_texelSize[0] = texelSize[0]; last_texelSize[1] = texelSize[1]; } glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - last_program = shader_program; + last_program = shader_program_handle; } typedef struct { @@ -1967,7 +2029,7 @@ void PLAT_GL_Swap() { static int last_w = 0, last_h = 0; if (shaderResetRequested) { - if (src_texture) { glDeleteTextures(1, &src_texture); src_texture = 0; } + if (orig_texture) { glDeleteTextures(1, &orig_texture); orig_texture = 0; } src_w_last = src_h_last = 0; last_w = last_h = 0; if (effect_tex) { @@ -2012,6 +2074,8 @@ void PLAT_GL_Swap() { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, loaded_effect->w, loaded_effect->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, loaded_effect->pixels); effect_w = loaded_effect->w; effect_h = loaded_effect->h; + s_pass_effect.srcw = s_pass_effect.texw = effect_w; + s_pass_effect.srch = s_pass_effect.texh = effect_h; } else { if (effect_tex) { glDeleteTextures(1, &effect_tex); @@ -2040,6 +2104,8 @@ void PLAT_GL_Swap() { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, loaded_overlay->w, loaded_overlay->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, loaded_overlay->pixels); overlay_w = loaded_overlay->w; overlay_h = loaded_overlay->h; + s_pass_overlay.srcw = s_pass_overlay.texw = overlay_w; + s_pass_overlay.srch = s_pass_overlay.texh = overlay_h; } else { if (overlay_tex) { @@ -2052,25 +2118,29 @@ void PLAT_GL_Swap() { pthread_mutex_unlock(&video_prep_mutex); } - if (!src_texture || reloadShaderTextures) { - // if (src_texture) { - // glDeleteTextures(1, &src_texture); - // src_texture = 0; + if (!orig_texture || reloadShaderTextures) { + // if (orig_texture) { + // glDeleteTextures(1, &orig_texture); + // orig_texture = 0; // } - if (src_texture==0) - glGenTextures(1, &src_texture); - glBindTexture(GL_TEXTURE_2D, src_texture); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, nrofshaders > 0 ? shaders[0]->filter : finalScaleFilter); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, nrofshaders > 0 ? shaders[0]->filter : finalScaleFilter); + if (orig_texture==0) + glGenTextures(1, &orig_texture); + glBindTexture(GL_TEXTURE_2D, orig_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, nrofshaders > 0 ? shaders[0].filter : s_pass_finalscale.filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, nrofshaders > 0 ? shaders[0].filter : s_pass_finalscale.filter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); } - glBindTexture(GL_TEXTURE_2D, src_texture); + glBindTexture(GL_TEXTURE_2D, orig_texture); if (vid.blit->src_w != src_w_last || vid.blit->src_h != src_h_last || reloadShaderTextures) { glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, vid.blit->src_w, vid.blit->src_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, vid.blit->src); src_w_last = vid.blit->src_w; src_h_last = vid.blit->src_h; + orig_w = vid.blit->src_w; + orig_h = vid.blit->src_h; + origtex_w = vid.blit->src_w; + origtex_h = vid.blit->src_h; } else { glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, vid.blit->src_w, vid.blit->src_h, GL_RGBA, GL_UNSIGNED_BYTE, vid.blit->src); } @@ -2081,10 +2151,10 @@ void PLAT_GL_Swap() { for (int i = 0; i < nrofshaders; i++) { int src_w = last_w; int src_h = last_h; - int dst_w = src_w * shaders[i]->scale; - int dst_h = src_h * shaders[i]->scale; + int dst_w = src_w * shaders[i].scale; + int dst_h = src_h * shaders[i].scale; - if (shaders[i]->scale == 9) { + if (shaders[i].scale == 9) { dst_w = dst_rect.w; dst_h = dst_rect.h; } @@ -2094,10 +2164,10 @@ void PLAT_GL_Swap() { int real_input_w = (i == 0) ? vid.blit->src_w : last_w; int real_input_h = (i == 0) ? vid.blit->src_h : last_h; - shaders[i]->srcw = shaders[i]->srctype == 0 ? vid.blit->src_w : shaders[i]->srctype == 2 ? dst_rect.w : real_input_w; - shaders[i]->srch = shaders[i]->srctype == 0 ? vid.blit->src_h : shaders[i]->srctype == 2 ? dst_rect.h : real_input_h; - shaders[i]->texw = shaders[i]->scaletype == 0 ? vid.blit->src_w : shaders[i]->scaletype == 2 ? dst_rect.w : real_input_w; - shaders[i]->texh = shaders[i]->scaletype == 0 ? vid.blit->src_h : shaders[i]->scaletype == 2 ? dst_rect.h : real_input_h; + shaders[i].srcw = shaders[i].srctype == 0 ? vid.blit->src_w : shaders[i].srctype == 2 ? dst_rect.w : real_input_w; + shaders[i].srch = shaders[i].srctype == 0 ? vid.blit->src_h : shaders[i].srctype == 2 ? dst_rect.h : real_input_h; + shaders[i].texw = shaders[i].scaletype == 0 ? vid.blit->src_w : shaders[i].scaletype == 2 ? dst_rect.w : real_input_w; + shaders[i].texh = shaders[i].scaletype == 0 ? vid.blit->src_h : shaders[i].scaletype == 2 ? dst_rect.h : real_input_h; } } @@ -2105,10 +2175,10 @@ void PLAT_GL_Swap() { static int shaderinfoscreen = 0; if (shaderinfocount > 600 && shaderinfoscreen == i) { currentshaderpass = i + 1; - currentshadertexw = shaders[i]->texw; - currentshadertexh = shaders[i]->texh; - currentshadersrcw = shaders[i]->srcw; - currentshadersrch = shaders[i]->srch; + currentshadertexw = shaders[i].texw; + currentshadertexh = shaders[i].texh; + currentshadersrcw = shaders[i].srcw; + currentshadersrch = shaders[i].srch; currentshaderdstw = dst_w; currentshaderdsth = dst_h; shaderinfocount = 0; @@ -2118,28 +2188,12 @@ void PLAT_GL_Swap() { } shaderinfocount++; - if (shaders[i]->shader_p) { - //LOG_info("Shader Pass: Pipeline step %d/%d\n", i + 1, nrofshaders); - runShaderPass( - (i == 0) ? src_texture : shaders[i - 1]->texture, - shaders[i]->shader_p, - &shaders[i]->texture, - 0, 0, dst_w, dst_h, - shaders[i], - 0, - (i == nrofshaders - 1) ? finalScaleFilter : shaders[i + 1]->filter - ); - } else { - runShaderPass( - (i == 0) ? src_texture : shaders[i - 1]->texture, - g_noshader, - &shaders[i]->texture, - 0, 0, dst_w, dst_h, - shaders[i], - 0, - (i == nrofshaders - 1) ? finalScaleFilter : shaders[i + 1]->filter - ); - } + runShaderPass( + &shaders[i], + (i == 0) ? orig_texture : shaders[i - 1].target_texture, + &shaders[i].target_texture, + (i == nrofshaders - 1) ? s_pass_finalscale.filter : shaders[i+1].filter, + 0, 0, dst_w, dst_h); last_w = dst_w; last_h = dst_h; @@ -2147,65 +2201,57 @@ void PLAT_GL_Swap() { if (nrofshaders > 0) { //LOG_info("Shader Pass: Scale to screen (pipeline size: %d)\n", nrofshaders); - runShaderPass( - shaders[nrofshaders - 1]->texture, - g_shader_default, - NULL, - dst_rect.x, dst_rect.y, dst_rect.w, dst_rect.h, - &(Shader){.srcw = last_w, .srch = last_h, .texw = last_w, .texh = last_h}, - 0, GL_NONE - ); + s_pass_finalscale.srcw = s_pass_finalscale.texw = last_w; + s_pass_finalscale.srch = s_pass_finalscale.texh = last_h; + runShaderPass( + &s_pass_finalscale, + shaders[nrofshaders - 1].target_texture, + NULL, + GL_NONE, + dst_rect.x, dst_rect.y, dst_rect.w, dst_rect.h); } else { //LOG_info("Shader Pass: Scale to screen (pipeline size: %d)\n", nrofshaders); - runShaderPass(src_texture, - g_shader_default, + s_pass_finalscale.srcw = s_pass_finalscale.texw = orig_w; + s_pass_finalscale.srch = s_pass_finalscale.texh = orig_h; + runShaderPass( + &s_pass_finalscale, + orig_texture, NULL, - dst_rect.x, dst_rect.y, dst_rect.w, dst_rect.h, - &(Shader){.srcw = vid.blit->src_w, .srch = vid.blit->src_h, .texw = vid.blit->src_w, .texh = vid.blit->src_h}, - 0, GL_NONE); + GL_NONE, + dst_rect.x, dst_rect.y, dst_rect.w, dst_rect.h); } if (effect_tex) { //LOG_info("Shader Pass: Screen Effect\n"); runShaderPass( - effect_tex, - g_shader_overlay, - NULL, - dst_rect.x, dst_rect.y, effect_w, effect_h, - &(Shader){.srcw = effect_w, .srch = effect_h, .texw = effect_w, .texh = effect_h}, - 1, GL_NONE - ); + &s_pass_overlay, effect_tex, NULL, + GL_NONE, + dst_rect.x, dst_rect.y, effect_w, effect_h); } if (overlay_tex) { //LOG_info("Shader Pass: Overlay\n"); runShaderPass( - overlay_tex, - g_shader_overlay, - NULL, - 0, 0, device_width, device_height, - &(Shader){.srcw = vid.blit->src_w, .srch = vid.blit->src_h, .texw = overlay_w, .texh = overlay_h}, - 1, GL_NONE - ); + &s_pass_overlay, overlay_tex, NULL, + GL_NONE, + 0, 0, device_width, device_height); } // 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; + 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); + s_pass_notif.srcw = s_pass_notif.texw = notif.tex_w; + s_pass_notif.srch = s_pass_notif.texh = notif.tex_h; + 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 - ); + runShaderPass( + &s_pass_overlay, notif.tex, NULL, + GL_NONE, + notif.x, notif.y, notif.tex_w, notif.tex_h); } SDL_GL_SwapWindow(vid.window); From 18600063fe161d53e821d966f02162988fa960c4 Mon Sep 17 00:00:00 2001 From: Kevin Vranken <29517264+Helaas@users.noreply.github.com> Date: Wed, 13 May 2026 00:06:43 +0200 Subject: [PATCH 03/19] Pre- and Post App/ROM launch hooks (#690) * feat: implement hook system for pre and post command execution * feat: implement shared hook runner and integrate into launch and suspend scripts * feat: add documentation for NextUI hooks and usage guidelines * feat: add hooks path and directory creation in launch scripts --------- Co-authored-by: frysee --- HOOKS.md | 84 +++++++++++++++++++ skeleton/SYSTEM/desktop/bin/run_hooks.sh | 36 ++++++++ .../SYSTEM/desktop/paks/MinUI.pak/launch.sh | 26 ++++++ skeleton/SYSTEM/tg5040/bin/run_hooks.sh | 36 ++++++++ skeleton/SYSTEM/tg5040/bin/suspend | 7 ++ .../SYSTEM/tg5040/paks/MinUI.pak/launch.sh | 26 ++++++ skeleton/SYSTEM/tg5050/bin/run_hooks.sh | 36 ++++++++ skeleton/SYSTEM/tg5050/bin/suspend | 7 ++ .../SYSTEM/tg5050/paks/MinUI.pak/launch.sh | 26 ++++++ 9 files changed, 284 insertions(+) create mode 100644 HOOKS.md create mode 100755 skeleton/SYSTEM/desktop/bin/run_hooks.sh create mode 100755 skeleton/SYSTEM/tg5040/bin/run_hooks.sh create mode 100755 skeleton/SYSTEM/tg5050/bin/run_hooks.sh diff --git a/HOOKS.md b/HOOKS.md new file mode 100644 index 000000000..91dcff569 --- /dev/null +++ b/HOOKS.md @@ -0,0 +1,84 @@ +# About NextUI hooks + +## The idea + +Hooks are platform-specific, just like paks. The launcher reads them from: + +``` +$USERDATA_PATH/.hooks/ + pre-launch.d/ # scripts run before launch + post-launch.d/ # scripts run after launch exits +``` + +On device, `USERDATA_PATH` resolves to: + +``` +/mnt/SDCARD/.userdata/$PLATFORM +``` + +So the actual hook directories on device are: + +``` +/mnt/SDCARD/.userdata//.hooks/pre-launch.d/ +/mnt/SDCARD/.userdata//.hooks/post-launch.d/ + +``` + +Example installed hook path: + +``` +/mnt/SDCARD/.userdata/tg5040/.hooks/post-launch.d/shortcuts-resume.sh + +``` + +If these directories don't exist, nothing happens and there is no overhead. + +## Environment variables + +Hook scripts inherit all standard NextUI environment variables (`SDCARD_PATH`, `PLATFORM`, `USERDATA_PATH`, `SHARED_USERDATA_PATH`, etc.) plus these launch-specific ones: + +| Variable | Description | +|---|---| +| `HOOK_PHASE` | `pre` or `post` | +| `HOOK_TYPE` | `rom` or `pak` | +| `HOOK_CMD` | The raw launch command | +| `HOOK_EMU_PATH` | Path to the emulator or pak `launch.sh` | +| `HOOK_ROM_PATH` | Path to the ROM file (empty for pak launches) | +| `HOOK_LAST` | Contents of `/tmp/last.txt` (the last selected menu entry) | + +These can then be used by the underlying Pak to ingest information about the hook that just occurred. + +## Writing a hook script + +A hook script is any executable `.sh` file in one of the hook directories. Scripts run in alphabetical order. + +```sh +#!/bin/sh +# my-hook.sh — log every ROM launch + +[ "$HOOK_TYPE" = "rom" ] || exit 0 +echo "$(date): launched $HOOK_ROM_PATH" >> "$LOGS_PATH/launches.log" +``` + +## Rules + +- Each script runs in a subshell. A crash or non-zero exit will not affect the launcher or other hooks. +- Script output (stdout/stderr) is suppressed. If you need logging, write to your own log file. +- Pre-launch hooks cannot cancel the launch. They are for observation and setup only. +- Keep hooks fast. A slow hook delays the launch or the return to the menu. +- Unlike auto.sh, each pak should manage their own hook and use a descriptive filename to avoid collisions. + + +## Example: sync after ROM exit + +```sh +#!/bin/sh +# shortcuts-resume.sh — one-shot resume metadata sync after a ROM exits + +[ "$HOOK_TYPE" = "rom" ] || exit 0 + +SHORTCUTS_PAK="$SDCARD_PATH/Tools/$PLATFORM/Shortcuts.pak" +[ -x "$SHORTCUTS_PAK/shortcuts" ] || exit 0 + +"$SHORTCUTS_PAK/shortcuts" --resume-sync-hook >> "$LOGS_PATH/shortcuts-resume-sync.txt" 2>&1 +``` diff --git a/skeleton/SYSTEM/desktop/bin/run_hooks.sh b/skeleton/SYSTEM/desktop/bin/run_hooks.sh new file mode 100755 index 000000000..30b86ca60 --- /dev/null +++ b/skeleton/SYSTEM/desktop/bin/run_hooks.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# run_hooks.sh - shared hook runner for NextUI +# Usage: run_hooks.sh [--sync-only] +# +# dir-name: directory name under $USERDATA_PATH/.hooks/ (e.g. pre-launch.d, boot.d) +# --sync-only: force all scripts to run synchronously +# +# By default, scripts run in the background. Scripts ending in .sync.sh +# always run synchronously. All background scripts are waited on before exit. + +DIR_NAME="$1" +SYNC_ONLY="${2:-}" + +: "${SDCARD_PATH:=/var/tmp/nextui/sdcard}" +: "${PLATFORM:=desktop}" +: "${USERDATA_PATH:=$SDCARD_PATH/.userdata/$PLATFORM}" + +HOOK_DIR="$USERDATA_PATH/.hooks/$DIR_NAME" +[ -d "$HOOK_DIR" ] || exit 0 + +case "$DIR_NAME" in + pre-*) export HOOK_PHASE="pre" ;; + post-*) export HOOK_PHASE="post" ;; + boot*) export HOOK_PHASE="boot" ;; +esac +export HOOK_CATEGORY="$DIR_NAME" + +for script in "$HOOK_DIR"/*.sh; do + [ -f "$script" ] || continue + if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then + ( "$script" ) > /dev/null 2>&1 || true + else + ( "$script" ) > /dev/null 2>&1 & + fi +done +wait diff --git a/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh index 5c1f99ae0..9ba15c8d6 100755 --- a/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/desktop/paks/MinUI.pak/launch.sh @@ -18,6 +18,7 @@ export CORES_PATH="$SYSTEM_PATH/cores" export USERDATA_PATH="$SDCARD_PATH/.userdata/$PLATFORM" export SHARED_USERDATA_PATH="$SDCARD_PATH/.userdata/shared" export LOGS_PATH="$USERDATA_PATH/logs" +export HOOKS_PATH="$USERDATA_PATH/.hooks" export DATETIME_PATH="$SHARED_USERDATA_PATH/datetime.txt" mkdir -p "$BIOS_PATH" @@ -26,6 +27,7 @@ mkdir -p "$SAVES_PATH" mkdir -p "$CHEATS_PATH" mkdir -p "$USERDATA_PATH" mkdir -p "$LOGS_PATH" +mkdir -p "$HOOKS_PATH" mkdir -p "$SHARED_USERDATA_PATH/.minui" export IS_NEXT="yes" @@ -45,8 +47,29 @@ if [ -f "$AUTO_PATH" ]; then "$AUTO_PATH" fi +# Composable boot hooks (run after auto.sh for backward compatibility) +"$SYSTEM_PATH/bin/run_hooks.sh" boot.d + cd $(dirname "$0") +####################################### +# Hook system + +parse_hook_cmd() { + HOOK_CMD="$1" + HOOK_EMU_PATH=$(echo "$HOOK_CMD" | sed "s/^'\\([^']*\\)'.*/\\1/") + _remainder=$(echo "$HOOK_CMD" | sed "s/^'[^']*'//") + if echo "$_remainder" | grep -q "'"; then + HOOK_TYPE="rom" + HOOK_ROM_PATH=$(echo "$_remainder" | sed "s/.*'\\([^']*\\)'.*/\\1/") + else + HOOK_TYPE="pak" + HOOK_ROM_PATH="" + fi + [ -f /tmp/last.txt ] && HOOK_LAST=$(cat /tmp/last.txt) || HOOK_LAST="" + export HOOK_CMD HOOK_EMU_PATH HOOK_TYPE HOOK_ROM_PATH HOOK_LAST +} + ####################################### EXEC_PATH="/tmp/nextui_exec" @@ -57,7 +80,10 @@ touch "$EXEC_PATH" && sync if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` + parse_hook_cmd "$CMD" + "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d rm -f $NEXT_PATH fi #done diff --git a/skeleton/SYSTEM/tg5040/bin/run_hooks.sh b/skeleton/SYSTEM/tg5040/bin/run_hooks.sh new file mode 100755 index 000000000..96fbe4833 --- /dev/null +++ b/skeleton/SYSTEM/tg5040/bin/run_hooks.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# run_hooks.sh - shared hook runner for NextUI +# Usage: run_hooks.sh [--sync-only] +# +# dir-name: directory name under $USERDATA_PATH/.hooks/ (e.g. pre-launch.d, boot.d) +# --sync-only: force all scripts to run synchronously +# +# By default, scripts run in the background. Scripts ending in .sync.sh +# always run synchronously. All background scripts are waited on before exit. + +DIR_NAME="$1" +SYNC_ONLY="${2:-}" + +: "${SDCARD_PATH:=/mnt/SDCARD}" +: "${PLATFORM:=tg5040}" +: "${USERDATA_PATH:=$SDCARD_PATH/.userdata/$PLATFORM}" + +HOOK_DIR="$USERDATA_PATH/.hooks/$DIR_NAME" +[ -d "$HOOK_DIR" ] || exit 0 + +case "$DIR_NAME" in + pre-*) export HOOK_PHASE="pre" ;; + post-*) export HOOK_PHASE="post" ;; + boot*) export HOOK_PHASE="boot" ;; +esac +export HOOK_CATEGORY="$DIR_NAME" + +for script in "$HOOK_DIR"/*.sh; do + [ -f "$script" ] || continue + if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then + ( "$script" ) > /dev/null 2>&1 || true + else + ( "$script" ) > /dev/null 2>&1 & + fi +done +wait diff --git a/skeleton/SYSTEM/tg5040/bin/suspend b/skeleton/SYSTEM/tg5040/bin/suspend index 4a384d4c7..593159423 100644 --- a/skeleton/SYSTEM/tg5040/bin/suspend +++ b/skeleton/SYSTEM/tg5040/bin/suspend @@ -16,6 +16,10 @@ asound_state_dir=/tmp/asound-suspend before() { >&2 echo "Preparing for suspend..." + # Run pre-sleep hooks synchronously before services are stopped. + # Subshell ensures a crashing hook cannot block suspend. + ( "$SYSTEM_PATH/bin/run_hooks.sh" pre-sleep.d --sync-only ) >/dev/null 2>&1 || true + >&2 echo "Saving mixer state..." mkdir -p "$asound_state_dir" alsactl --file "$asound_state_dir/asound.state.pre" store || true @@ -36,6 +40,9 @@ before() { after() { >&2 echo "Resumed from suspend." + # Run post-resume hooks in background immediately after wake. + ( "$SYSTEM_PATH/bin/run_hooks.sh" post-resume.d ) >/dev/null 2>&1 & + if [ -n "$wifid_running" ]; then >&2 echo "Starting wpa_supplicant..." /etc/wifi/wifi_init.sh start || true diff --git a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh index 09528cf3a..65abb1f40 100755 --- a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh @@ -22,6 +22,7 @@ export CORES_PATH="$SYSTEM_PATH/cores" export USERDATA_PATH="$SDCARD_PATH/.userdata/$PLATFORM" export SHARED_USERDATA_PATH="$SDCARD_PATH/.userdata/shared" export LOGS_PATH="$USERDATA_PATH/logs" +export HOOKS_PATH="$USERDATA_PATH/.hooks" export DATETIME_PATH="$SHARED_USERDATA_PATH/datetime.txt" export HOME="$USERDATA_PATH" @@ -44,6 +45,7 @@ mkdir -p "$SAVES_PATH" mkdir -p "$CHEATS_PATH" mkdir -p "$USERDATA_PATH" mkdir -p "$LOGS_PATH" +mkdir -p "$HOOKS_PATH" mkdir -p "$SHARED_USERDATA_PATH/.minui" export TRIMUI_MODEL=`strings /usr/trimui/bin/MainUI | grep ^Trimui` @@ -151,8 +153,29 @@ if [ -f "$AUTO_PATH" ]; then "$AUTO_PATH" fi +# Composable boot hooks (run after auto.sh for backward compatibility) +"$SYSTEM_PATH/bin/run_hooks.sh" boot.d + cd $(dirname "$0") +####################################### +# Hook system + +parse_hook_cmd() { + HOOK_CMD="$1" + HOOK_EMU_PATH=$(echo "$HOOK_CMD" | sed "s/^'\\([^']*\\)'.*/\\1/") + _remainder=$(echo "$HOOK_CMD" | sed "s/^'[^']*'//") + if echo "$_remainder" | grep -q "'"; then + HOOK_TYPE="rom" + HOOK_ROM_PATH=$(echo "$_remainder" | sed "s/.*'\\([^']*\\)'.*/\\1/") + else + HOOK_TYPE="pak" + HOOK_ROM_PATH="" + fi + [ -f /tmp/last.txt ] && HOOK_LAST=$(cat /tmp/last.txt) || HOOK_LAST="" + export HOOK_CMD HOOK_EMU_PATH HOOK_TYPE HOOK_ROM_PATH HOOK_LAST +} + ####################################### # kill show2.elf if running @@ -167,7 +190,10 @@ while [ -f $EXEC_PATH ]; do if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` + parse_hook_cmd "$CMD" + "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d rm -f $NEXT_PATH echo $CPU_SPEED_PERF > $CPU_PATH fi diff --git a/skeleton/SYSTEM/tg5050/bin/run_hooks.sh b/skeleton/SYSTEM/tg5050/bin/run_hooks.sh new file mode 100755 index 000000000..7e6886546 --- /dev/null +++ b/skeleton/SYSTEM/tg5050/bin/run_hooks.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# run_hooks.sh - shared hook runner for NextUI +# Usage: run_hooks.sh [--sync-only] +# +# dir-name: directory name under $USERDATA_PATH/.hooks/ (e.g. pre-launch.d, boot.d) +# --sync-only: force all scripts to run synchronously +# +# By default, scripts run in the background. Scripts ending in .sync.sh +# always run synchronously. All background scripts are waited on before exit. + +DIR_NAME="$1" +SYNC_ONLY="${2:-}" + +: "${SDCARD_PATH:=/mnt/SDCARD}" +: "${PLATFORM:=tg5050}" +: "${USERDATA_PATH:=$SDCARD_PATH/.userdata/$PLATFORM}" + +HOOK_DIR="$USERDATA_PATH/.hooks/$DIR_NAME" +[ -d "$HOOK_DIR" ] || exit 0 + +case "$DIR_NAME" in + pre-*) export HOOK_PHASE="pre" ;; + post-*) export HOOK_PHASE="post" ;; + boot*) export HOOK_PHASE="boot" ;; +esac +export HOOK_CATEGORY="$DIR_NAME" + +for script in "$HOOK_DIR"/*.sh; do + [ -f "$script" ] || continue + if [ "$SYNC_ONLY" = "--sync-only" ] || echo "$script" | grep -q '\.sync\.sh$'; then + ( "$script" ) > /dev/null 2>&1 || true + else + ( "$script" ) > /dev/null 2>&1 & + fi +done +wait diff --git a/skeleton/SYSTEM/tg5050/bin/suspend b/skeleton/SYSTEM/tg5050/bin/suspend index 9062b84ef..090cfbad5 100644 --- a/skeleton/SYSTEM/tg5050/bin/suspend +++ b/skeleton/SYSTEM/tg5050/bin/suspend @@ -16,6 +16,10 @@ asound_state_dir=/tmp/asound-suspend before() { >&2 echo "Preparing for suspend..." + # Run pre-sleep hooks synchronously before services are stopped. + # Subshell ensures a crashing hook cannot block suspend. + ( "$SYSTEM_PATH/bin/run_hooks.sh" pre-sleep.d --sync-only ) >/dev/null 2>&1 || true + >&2 echo "Saving mixer state..." mkdir -p "$asound_state_dir" alsactl --file "$asound_state_dir/asound.state.pre" store || true @@ -36,6 +40,9 @@ before() { after() { >&2 echo "Resumed from suspend." + # Run post-resume hooks in background immediately after wake. + ( "$SYSTEM_PATH/bin/run_hooks.sh" post-resume.d ) >/dev/null 2>&1 & + if [ -n "$wifid_running" ]; then >&2 echo "Starting wpa_supplicant..." $SYSTEM_PATH/etc/wifi/wifi_init.sh start || true diff --git a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh index d73b92a2a..796d4fc14 100755 --- a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh @@ -22,6 +22,7 @@ export CORES_PATH="$SYSTEM_PATH/cores" export USERDATA_PATH="$SDCARD_PATH/.userdata/$PLATFORM" export SHARED_USERDATA_PATH="$SDCARD_PATH/.userdata/shared" export LOGS_PATH="$USERDATA_PATH/logs" +export HOOKS_PATH="$USERDATA_PATH/.hooks" export DATETIME_PATH="$SHARED_USERDATA_PATH/datetime.txt" export HOME="$USERDATA_PATH" @@ -44,6 +45,7 @@ mkdir -p "$SAVES_PATH" mkdir -p "$CHEATS_PATH" mkdir -p "$USERDATA_PATH" mkdir -p "$LOGS_PATH" +mkdir -p "$HOOKS_PATH" mkdir -p "$SHARED_USERDATA_PATH/.minui" export TRIMUI_MODEL=`strings /usr/trimui/bin/MainUI | grep ^Trimui` @@ -164,8 +166,29 @@ if [ -f "$AUTO_PATH" ]; then echo after auto.sh `cat /proc/uptime` >> /tmp/nextui_boottime fi +# Composable boot hooks (run after auto.sh for backward compatibility) +"$SYSTEM_PATH/bin/run_hooks.sh" boot.d + cd $(dirname "$0") +####################################### +# Hook system + +parse_hook_cmd() { + HOOK_CMD="$1" + HOOK_EMU_PATH=$(echo "$HOOK_CMD" | sed "s/^'\\([^']*\\)'.*/\\1/") + _remainder=$(echo "$HOOK_CMD" | sed "s/^'[^']*'//") + if echo "$_remainder" | grep -q "'"; then + HOOK_TYPE="rom" + HOOK_ROM_PATH=$(echo "$_remainder" | sed "s/.*'\\([^']*\\)'.*/\\1/") + else + HOOK_TYPE="pak" + HOOK_ROM_PATH="" + fi + [ -f /tmp/last.txt ] && HOOK_LAST=$(cat /tmp/last.txt) || HOOK_LAST="" + export HOOK_CMD HOOK_EMU_PATH HOOK_TYPE HOOK_ROM_PATH HOOK_LAST +} + ####################################### # kill show2.elf if running @@ -180,7 +203,10 @@ while [ -f $EXEC_PATH ]; do if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` + parse_hook_cmd "$CMD" + "$SYSTEM_PATH/bin/run_hooks.sh" pre-launch.d eval $CMD + "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d rm -f $NEXT_PATH echo $CPU_SPEED_PERF > $BIG_PATH fi From 02d3dc079a4792d5e82e11c8838deec6c969ec67 Mon Sep 17 00:00:00 2001 From: nborodikhin Date: Tue, 12 May 2026 17:59:10 -0500 Subject: [PATCH 04/19] fix: crash on out-of-bound access when loading cheats (#718) * fix: stack corruption on cheat loading * fix: missing semicolon in Cheat_getPaths * Apply suggestion from @frysee --------- Co-authored-by: frysee --- workspace/all/minarch/minarch.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index ef7225333..1a664f249 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -604,11 +604,16 @@ static int parse_cheats(struct Cheats *cheats, FILE *file) { } // return variations with/without extensions and other cruft -#define CHEAT_MAX_PATHS 16 +// note: CHEAT_MAX_PATHS must be large enough to contain one entry per extension supported by a core, plus ~5 more +// 48 is enough: retroarch cores with most extensions at the moment is VICE VIC-20 at 37 and rom-cleaner at 42 +#define CHEAT_MAX_PATHS 48 #define CHEAT_MAX_DISPLAY_PATHS 8 // the list of displayed paths will be a bit shorter, we cant render that much text #define CHEAT_MAX_LIST_LENGTH (CHEAT_MAX_DISPLAY_PATHS * MAX_PATH) static void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count) { + // reserve a few entries at the end, for sanitized name and glob patterns + const int sanitized_paths_count = 3; + // Generate possible paths, ordered by most likely to be used (pre v6.2.3 style first) sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, game.name); // /mnt/SDCARD/Cheats/GB/Super Example World..cht if(CFG_getUseExtractedFileName()) @@ -627,7 +632,8 @@ static void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count) { strcpy(exts, core.extensions); while ((ext = strtok(i ? NULL : exts, "|"))) { - if (*count >= CHEAT_MAX_PATHS - 1) { + // sanitized_paths_count slots are reserved for sanitized rom name, see below + if (*count >= CHEAT_MAX_PATHS - sanitized_paths_count) { LOG_info("Maximum cheat paths reached, stopping\n"); break; } @@ -660,6 +666,7 @@ static void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count) { // eg. Super Example World (USA).zip -> Super Example World // Super Example World (USA) [!].7z -> Super Example World // Super Example World (USA) (Rev 1).rar -> Super Example World + // Important: update `sanitized_paths_count` if adding more sanitized variations char rom_name[MAX_PATH]; getDisplayName(game.alt_name, rom_name); sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, rom_name); // /mnt/SDCARD/Cheats/GB/Super Example World.cht @@ -670,18 +677,20 @@ static void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count) { // Santitized alias, ignoring all extra cruft - including Cheat specifics like "(Game Breaker)" etc. // This is a wildcard that may match something unexpected, but also may find something when nothing else does. - getDisplayName(game.alt_name, rom_name); getAlias(game.path, rom_name); sprintf(paths[(*count)++], "%s/%s*.cht", core.cheats_dir, rom_name); // /mnt/SDCARD/Cheats/GB/Super Example World*.cht // Log all path candidates { - int i; - char list[CHEAT_MAX_LIST_LENGTH] = {0}; - for (i=0; i<*count; i++) { - strcat(list, paths[i]); - if (i < *count-1) strcat(list, ", "); + char *list = calloc(*count * (MAX_PATH + 2) + 1, 1); // path + separator for each entry + if (list != NULL) { + int i; + for (i=0; i<*count; i++) { + strcat(list, paths[i]); + if (i < *count-1) strcat(list, ", "); + } + //LOG_info("Cheat paths to check: %s\n", list); + free(list); } - LOG_info("Cheat paths to check: %s\n", list); } } @@ -6845,8 +6854,8 @@ static int OptionCheats_openMenu(MenuList* list, int i) { else { // update for (int j = 0; j < cheatcodes.count; j++) { - struct Cheat *cheat = &cheatcodes.cheats[i]; - MenuItem *item = &OptionCheats_menu.items[i]; + struct Cheat *cheat = &cheatcodes.cheats[j]; + MenuItem *item = &OptionCheats_menu.items[j]; // I guess that makes sense, nobody is changing these but us - what about state restore? if(!cheat->enabled) continue; From 7dd51d9823ddec64601450b95123010f86697ce1 Mon Sep 17 00:00:00 2001 From: Christophe Vanlancker Date: Wed, 13 May 2026 01:10:13 +0200 Subject: [PATCH 05/19] Feature/podman (#711) * build: support podman as container runtime fallback Auto-detect docker or podman at build time, preferring docker when both are present. Override with CONTAINER_RUNTIME=podman (or any other compatible runtime) on the make command line. Signed-off-by: Christophe Vanlancker * build: clean workspace when switching between docker and podman Docker runs as real root inside the container, so build artifacts and cloned repos (other/) end up root-owned on the host. Rootless podman can't write into those directories, causing permission errors mid-build. Keep track of the last-used runtime in workspace/.container_runtime. When a different runtime is requested, re-run the cleanup inside the previous container using 'find -depth -user root -delete', which strips out everything the old runtime created while leaving host-owned source files untouched. If the previous runtime is gone, print a manual cleanup command and bail out cleanly. Also fixes a missing clean target in btmanager/Makefile, which was caught when the cleanup path exercised it for the first time. Signed-off-by: Christophe Vanlancker --------- Signed-off-by: Christophe Vanlancker Co-authored-by: frysee --- .gitignore | 1 + makefile.toolchain | 44 +++++++++++++++++++++++------ workspace/tg5040/btmanager/Makefile | 3 ++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 5571e6b78..d5e49d6b2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ audiomon.elf workspace/hash.txt workspace/readmes +workspace/.container_runtime AGENTS.md CLAUDE.md diff --git a/makefile.toolchain b/makefile.toolchain index c1d508b50..e34ac3d61 100644 --- a/makefile.toolchain +++ b/makefile.toolchain @@ -1,6 +1,6 @@ # there is no reason to use this makefile manually -.PHONY: build clean +.PHONY: build clean _runtime_check ifeq (,$(PLATFORM)) $(error please specify PLATFORM, eg. make PLATFORM=trimui) @@ -13,8 +13,17 @@ GIT_IF_NECESSARY=toolchains/$(PLATFORM)-toolchain INIT_IF_NECESSARY=toolchains/$(PLATFORM)-toolchain/.build IMAGE_NAME=ghcr.io/loveretro/$(PLATFORM)-toolchain:latest +CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +ifeq (,$(CONTAINER_RUNTIME)) +$(error Neither docker nor podman found in PATH) +endif + +RUNTIME_MARKER := $(HOST_WORKSPACE)/.container_runtime +LAST_RUNTIME := $(shell cat $(RUNTIME_MARKER) 2>/dev/null) +CURRENT_RUNTIME := $(notdir $(CONTAINER_RUNTIME)) + all: $(INIT_IF_NECESSARY) - docker run -it --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) $(IMAGE_NAME) /bin/bash + $(CONTAINER_RUNTIME) run -it --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) $(IMAGE_NAME) /bin/bash $(INIT_IF_NECESSARY): $(GIT_IF_NECESSARY) cd toolchains/$(PLATFORM)-toolchain && make .build @@ -22,19 +31,36 @@ $(INIT_IF_NECESSARY): $(GIT_IF_NECESSARY) $(GIT_IF_NECESSARY): mkdir -p toolchains git clone https://github.com/LoveRetro/$(PLATFORM)-toolchain/ toolchains/$(PLATFORM)-toolchain - docker pull $(IMAGE_NAME) && touch toolchains/$(PLATFORM)-toolchain/.build + $(CONTAINER_RUNTIME) pull $(IMAGE_NAME) && touch toolchains/$(PLATFORM)-toolchain/.build clean: cd toolchains/$(PLATFORM)-toolchain && make clean -build: $(INIT_IF_NECESSARY) - docker run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make' +_runtime_check: + @if [ -n "$(LAST_RUNTIME)" ] && [ "$(LAST_RUNTIME)" != "$(CURRENT_RUNTIME)" ]; then \ + echo "Container runtime changed ($(LAST_RUNTIME) -> $(CURRENT_RUNTIME)), cleaning workspace build artifacts..."; \ + PREV=$$(command -v $(LAST_RUNTIME) 2>/dev/null); \ + if [ -n "$$PREV" ]; then \ + $$PREV run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) $(IMAGE_NAME) /bin/bash -c \ + 'find $(GUEST_WORKSPACE) -depth -user root -delete 2>/dev/null; true'; \ + else \ + echo "Error: previous runtime $(LAST_RUNTIME) not available to clean root-owned artifacts."; \ + echo "Run: sudo rm -rf $(HOST_WORKSPACE)/*/build $(HOST_WORKSPACE)/$(PLATFORM)/other"; \ + exit 1; \ + fi; \ + fi + +build: $(INIT_IF_NECESSARY) _runtime_check + $(CONTAINER_RUNTIME) run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make' + @echo "$(CURRENT_RUNTIME)" > $(RUNTIME_MARKER) -build-cores: $(INIT_IF_NECESSARY) - docker run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make cores' +build-cores: $(INIT_IF_NECESSARY) _runtime_check + $(CONTAINER_RUNTIME) run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make cores' + @echo "$(CURRENT_RUNTIME)" > $(RUNTIME_MARKER) -build-core: $(INIT_IF_NECESSARY) +build-core: $(INIT_IF_NECESSARY) _runtime_check ifndef CORE $(error CORE is not set) endif - docker run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) -e CORE=$(CORE) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make core' + $(CONTAINER_RUNTIME) run --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) -e COMPILE_CORES=$(COMPILE_CORES) -e CORE=$(CORE) $(IMAGE_NAME) /bin/bash -c '. ~/.bashrc && cd /root/workspace && make core' + @echo "$(CURRENT_RUNTIME)" > $(RUNTIME_MARKER) diff --git a/workspace/tg5040/btmanager/Makefile b/workspace/tg5040/btmanager/Makefile index 93d7153bf..fe89afbb3 100644 --- a/workspace/tg5040/btmanager/Makefile +++ b/workspace/tg5040/btmanager/Makefile @@ -54,3 +54,6 @@ bluez: cd build && python3 -m zipfile -c nextui.upgrade_bluez.pakz .update_bluez/ post_install.sh all: bluez + +clean: + rm -rf ./build From 8cc8110f762dc5c7f228d04345c8ee9f7f0be576 Mon Sep 17 00:00:00 2001 From: frysee Date: Thu, 14 May 2026 18:52:08 +0200 Subject: [PATCH 06/19] Clarified hooks --- HOOKS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/HOOKS.md b/HOOKS.md index 91dcff569..bd6583c83 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -6,8 +6,11 @@ Hooks are platform-specific, just like paks. The launcher reads them from: ``` $USERDATA_PATH/.hooks/ + boot.d/ # scripts run on boot pre-launch.d/ # scripts run before launch post-launch.d/ # scripts run after launch exits + pre-sleep.d/ # scripts run before device goes to sleep + post-resume.d/ # scripts run after device wakes from sleep ``` On device, `USERDATA_PATH` resolves to: From 6990d474cabf6c07d80e53140a2ce66760c3f39a Mon Sep 17 00:00:00 2001 From: Prashant Vaibhav Date: Thu, 14 May 2026 23:39:57 +0200 Subject: [PATCH 07/19] feat: Replace userspace CPU governor with kernel scaling governors (#695) * Replace userspace CPU governor with kernel scaling governors Remove all manual CPU frequency control via the userspace governor and scaling_setspeed. Instead, three shell scripts (auto_governor.sh, powersave_governor.sh, performance_governor.sh) configure the kernel governor and frequency range dynamically at runtime. - auto: ondemand, min to one step below max frequency - powersave: conservative, min to midpoint frequency - performance: schedutil, min to max frequency The minarch CPU Speed menu is reduced from four options (Powersave, Normal, Performance, Auto) to three (Auto, Performance, Powersave). Selecting a profile executes the corresponding script via an absolute path constructed from SYSTEM_PATH. The auto governor is always restored on minarch exit, including early-exit paths. The userspace PLAT_cpu_monitor frequency-scaling loop is removed from both tg5040 and tg5050 platform.c; the thread is retained for CPU usage measurement only. PLAT_setCPUSpeed and PLAT_setCustomCPUSpeed are now no-ops so existing callers (ledcontrol, bootlogo, etc.) are unaffected. * fix: address kernel-cpu-governor review feedback * Simplify the CPU Speed description in MinArch * Switch the governor scripts to the shared policy0 path * Remove the unrelated diff about rom_path Also stop running the CPU monitor when it is not needed by gating it behind the Debug HUD setting and removing the NextUI-side thread launch. Previously, nextui.c was launching this thread in teh back- ground and detaching from it. Now, it's launched conditionally in minarch.c only when the Debug HUD switch is turned on. The platform monitor loop now checks the shared enabled state so the sampling work shuts down when the HUD is off. Hope this squeezes a little more performance out of the cpu. * fix: update governor scripts to include frequency ranges in comments * fix: remaining references to CPU_PATH * fix: adapt scripts to tg5050, which has two clusters * chore: combined governor scripts * fix: bring back PLAT_setCPUSpeed so paks can communicate their cpu needs * chore: removed obsolete macro --------- Co-authored-by: frysee --- skeleton/SYSTEM/tg5040/bin/governor.sh | 58 +++++++ .../SYSTEM/tg5040/paks/MinUI.pak/launch.sh | 11 +- skeleton/SYSTEM/tg5050/bin/governor.sh | 72 +++++++++ .../SYSTEM/tg5050/paks/MinUI.pak/launch.sh | 24 +-- workspace/all/battery/battery.c | 2 +- workspace/all/bootlogo/bootlogo.c | 2 +- workspace/all/clock/clock.c | 2 +- workspace/all/common/api.c | 45 +++++- workspace/all/common/api.h | 16 +- workspace/all/gametime/gametime.c | 2 +- workspace/all/ledcontrol/ledcontrol.c | 2 +- workspace/all/minarch/minarch.c | 80 ++++------ workspace/all/minput/minput.c | 2 +- workspace/all/nextui/nextui.c | 6 +- workspace/all/settings/settings.cpp | 2 +- workspace/desktop/platform/platform.c | 1 - workspace/tg5040/platform/platform.c | 144 +++++------------ workspace/tg5050/platform/platform.c | 148 +++++------------- 18 files changed, 307 insertions(+), 312 deletions(-) create mode 100755 skeleton/SYSTEM/tg5040/bin/governor.sh create mode 100755 skeleton/SYSTEM/tg5050/bin/governor.sh diff --git a/skeleton/SYSTEM/tg5040/bin/governor.sh b/skeleton/SYSTEM/tg5040/bin/governor.sh new file mode 100755 index 000000000..1238e9608 --- /dev/null +++ b/skeleton/SYSTEM/tg5040/bin/governor.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# governor.sh - CPU governor controller for TG5040 +# Usage: governor.sh +# Modes: auto, performance, powersave + +MODE="$1" +[ -z "$MODE" ] && MODE="auto" + +set_policy() { + local policy_path="$1" + local governor="$2" + local max_type="$3" # "second_max", "max", or "mid" + + [ -f "$policy_path/scaling_available_frequencies" ] || return 0 + FREQS=$(cat "$policy_path/scaling_available_frequencies" | tr ' ' '\n' | grep -v '^$' | sort -n) + MIN_FREQ=$(echo "$FREQS" | head -1) + + case "$max_type" in + second_max) + MAX_FREQ=$(echo "$FREQS" | tail -2 | head -1) + ;; + max) + MAX_FREQ=$(echo "$FREQS" | tail -1) + ;; + mid) + COUNT=$(echo "$FREQS" | wc -l) + MID=$(( (COUNT + 1) / 2 )) + MAX_FREQ=$(echo "$FREQS" | sed -n "${MID}p") + ;; + *) + MAX_FREQ=$(echo "$FREQS" | tail -1) + ;; + esac + + echo "$governor" > "$policy_path/scaling_governor" 2>/dev/null || true + echo "$MIN_FREQ" > "$policy_path/scaling_min_freq" + echo "$MAX_FREQ" > "$policy_path/scaling_max_freq" +} + +case "$MODE" in + auto) + # ondemand governor, min freq to one step below max (408-1800 MHz on TG5040) + set_policy /sys/devices/system/cpu/cpufreq/policy0 "ondemand" "second_max" + ;; + performance) + # schedutil governor, min freq to max freq (408-2000 MHz on TG5040) + set_policy /sys/devices/system/cpu/cpufreq/policy0 "schedutil" "max" + ;; + powersave) + # conservative governor, min freq to midpoint max (408-1200 MHz on TG5040) + set_policy /sys/devices/system/cpu/cpufreq/policy0 "conservative" "mid" + ;; + *) + echo "governor.sh: unknown mode '$MODE'" >&2 + echo " Valid modes: auto, performance, powersave" >&2 + exit 1 + ;; +esac diff --git a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh index 65abb1f40..0aad231b8 100755 --- a/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5040/paks/MinUI.pak/launch.sh @@ -113,10 +113,7 @@ fi # start stock gpio input daemon trimui_inputd & -echo userspace > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor -CPU_PATH=/sys/devices/system/cpu/cpu0/cpufreq/scaling_setspeed -CPU_SPEED_PERF=2000000 -echo $CPU_SPEED_PERF > $CPU_PATH +sh "$SYSTEM_PATH/bin/governor.sh" "auto" keymon.elf & # &> $SDCARD_PATH/keymon.txt & batmon.elf & # &> $SDCARD_PATH/batmon.txt & @@ -186,7 +183,8 @@ NEXT_PATH="/tmp/next" touch "$EXEC_PATH" && sync while [ -f $EXEC_PATH ]; do nextui.elf &> $LOGS_PATH/nextui.txt - echo $CPU_SPEED_PERF > $CPU_PATH + # default launched paks to performance, they can change it themselves after launch if they want + sh "$SYSTEM_PATH/bin/governor.sh" "performance" if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` @@ -195,7 +193,8 @@ while [ -f $EXEC_PATH ]; do eval $CMD "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d rm -f $NEXT_PATH - echo $CPU_SPEED_PERF > $CPU_PATH + # reset to performance when exiting, UI will reset to auto if needed + sh "$SYSTEM_PATH/bin/governor.sh" "performance" fi if [ -f "/tmp/poweroff" ]; then diff --git a/skeleton/SYSTEM/tg5050/bin/governor.sh b/skeleton/SYSTEM/tg5050/bin/governor.sh new file mode 100755 index 000000000..5862c1987 --- /dev/null +++ b/skeleton/SYSTEM/tg5050/bin/governor.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# governor.sh - CPU governor controller for TG5050 +# Usage: governor.sh +# Modes: auto, performance, powersave + +MODE="$1" +[ -z "$MODE" ] && MODE="auto" + +set_policy() { + local policy_path="$1" + local governor="$2" + local max_type="$3" # "second_max", "max", or "mid" + + [ -f "$policy_path/scaling_available_frequencies" ] || return 0 + FREQS=$(cat "$policy_path/scaling_available_frequencies" | tr ' ' '\n' | grep -v '^$' | sort -n) + MIN_FREQ=$(echo "$FREQS" | head -1) + + case "$max_type" in + second_max) + MAX_FREQ=$(echo "$FREQS" | tail -2 | head -1) + ;; + max) + MAX_FREQ=$(echo "$FREQS" | tail -1) + ;; + mid) + COUNT=$(echo "$FREQS" | wc -l) + MID=$(( (COUNT + 1) / 2 )) + MAX_FREQ=$(echo "$FREQS" | sed -n "${MID}p") + ;; + *) + MAX_FREQ=$(echo "$FREQS" | tail -1) + ;; + esac + + echo "$governor" > "$policy_path/scaling_governor" 2>/dev/null || true + echo "$MIN_FREQ" > "$policy_path/scaling_min_freq" + echo "$MAX_FREQ" > "$policy_path/scaling_max_freq" +} + +apply_mode() { + local mode="$1" + local policy="$2" + + case "$mode" in + auto) + # ondemand governor, min freq to one step below max + # policy0 - little Cores (408-1320 MHz on TG5050) + # policy4 - BIG Cores (408-2088 MHz on TG5050) + set_policy "$policy" "ondemand" "second_max" + ;; + performance) + # schedutil governor, min freq to max freq + # policy0 - little Cores (408-1416 MHz on TG5050) + # policy4 - BIG Cores (408-2160 MHz on TG5050) + set_policy "$policy" "schedutil" "max" + ;; + powersave) + # conservative governor, min freq to midpoint max + # policy0 - little Cores (408-1032 MHz on TG5050) + # policy4 - BIG Cores (408-1488 MHz on TG5050) + set_policy "$policy" "conservative" "mid" + ;; + *) + echo "governor.sh: unknown mode '$mode'" >&2 + echo " Valid modes: auto, performance, powersave" >&2 + return 1 + ;; + esac +} + +apply_mode "$MODE" /sys/devices/system/cpu/cpufreq/policy0 +apply_mode "$MODE" /sys/devices/system/cpu/cpufreq/policy4 diff --git a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh index 796d4fc14..ec5a9af56 100755 --- a/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh +++ b/skeleton/SYSTEM/tg5050/paks/MinUI.pak/launch.sh @@ -100,21 +100,7 @@ echo 0 > /sys/class/led_anim/max_scale # start gpio input daemon trimui_inputd & -echo userspace > /sys/devices/system/cpu/cpu4/cpufreq/scaling_governor -echo 408000 > /sys/devices/system/cpu/cpu4/cpufreq/scaling_min_freq -echo 2160000 > /sys/devices/system/cpu/cpu4/cpufreq/scaling_max_freq - -BIG_PATH=/sys/devices/system/cpu/cpu4/cpufreq/scaling_setspeed -CPU_SPEED_PERF=2160000 -echo $CPU_SPEED_PERF > $BIG_PATH - -echo schedutil > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor -echo 408000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_min_freq -echo 2000000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq - -#LITTLE_PATH=/sys/devices/system/cpu/cpu0/cpufreq/scaling_setspeed -#CPU_SPEED_PERF_LITTLE=2000000 -#echo $CPU_SPEED_PERF_LITTLE > $LITTLE_PATH +sh "$SYSTEM_PATH/bin/governor.sh" "auto" echo performance > /sys/devices/platform/soc@3000000/1800000.gpu/devfreq/1800000.gpu/governor @@ -198,8 +184,9 @@ EXEC_PATH="/tmp/nextui_exec" NEXT_PATH="/tmp/next" touch "$EXEC_PATH" && sync while [ -f $EXEC_PATH ]; do - nextui.elf &> $LOGS_PATH/nextui.txt - echo $CPU_SPEED_PERF > $BIG_PATH + nextui.elf &> $LOGS_PATH/nextui.txt + # default launched paks to performance, they can change it themselves after launch if they want + sh "$SYSTEM_PATH/bin/governor.sh" "performance" if [ -f $NEXT_PATH ]; then CMD=`cat $NEXT_PATH` @@ -208,7 +195,8 @@ while [ -f $EXEC_PATH ]; do eval $CMD "$SYSTEM_PATH/bin/run_hooks.sh" post-launch.d rm -f $NEXT_PATH - echo $CPU_SPEED_PERF > $BIG_PATH + # reset to performance when exiting, UI will reset to auto if needed + sh "$SYSTEM_PATH/bin/governor.sh" "performance" fi if [ -f "/tmp/poweroff" ]; then diff --git a/workspace/all/battery/battery.c b/workspace/all/battery/battery.c index 5c58ab956..68fae04d5 100644 --- a/workspace/all/battery/battery.c +++ b/workspace/all/battery/battery.c @@ -637,7 +637,7 @@ int main(int argc, char *argv[]) { InitSettings(); - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); device_model = PLAT_getModel(); screen = GFX_init(MODE_MAIN); diff --git a/workspace/all/bootlogo/bootlogo.c b/workspace/all/bootlogo/bootlogo.c index 0f153b0e4..d1201dc50 100644 --- a/workspace/all/bootlogo/bootlogo.c +++ b/workspace/all/bootlogo/bootlogo.c @@ -89,7 +89,7 @@ int main(int argc, char *argv[]) { InitSettings(); - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); screen = GFX_init(MODE_MENU); PAD_init(); diff --git a/workspace/all/clock/clock.c b/workspace/all/clock/clock.c index 65534c336..3734f6329 100644 --- a/workspace/all/clock/clock.c +++ b/workspace/all/clock/clock.c @@ -20,7 +20,7 @@ enum { }; int main(int argc , char* argv[]) { - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); SDL_Surface* screen = GFX_init(MODE_MENU); PAD_init(); diff --git a/workspace/all/common/api.c b/workspace/all/common/api.c index af9fef268..91661f39c 100644 --- a/workspace/all/common/api.c +++ b/workspace/all/common/api.c @@ -215,6 +215,49 @@ int currentshadertexh = 0; int should_rotate = 0; +static pthread_mutex_t perf_cpu_monitor_mutex = PTHREAD_MUTEX_INITIALIZER; +static int perf_cpu_monitor_enabled = 0; +static int perf_cpu_monitor_running = 0; + +void Perf_setCPUMonitorEnabled(int enabled) +{ + pthread_mutex_lock(&perf_cpu_monitor_mutex); + perf_cpu_monitor_enabled = enabled; + pthread_mutex_unlock(&perf_cpu_monitor_mutex); +} + +int Perf_isCPUMonitorEnabled(void) +{ + int enabled; + + pthread_mutex_lock(&perf_cpu_monitor_mutex); + enabled = perf_cpu_monitor_enabled; + pthread_mutex_unlock(&perf_cpu_monitor_mutex); + + return enabled; +} + +int Perf_tryBeginCPUMonitor(void) +{ + int should_run = 0; + + pthread_mutex_lock(&perf_cpu_monitor_mutex); + if (perf_cpu_monitor_enabled && !perf_cpu_monitor_running) { + perf_cpu_monitor_running = 1; + should_run = 1; + } + pthread_mutex_unlock(&perf_cpu_monitor_mutex); + + return should_run; +} + +void Perf_endCPUMonitor(void) +{ + pthread_mutex_lock(&perf_cpu_monitor_mutex); + perf_cpu_monitor_running = 0; + pthread_mutex_unlock(&perf_cpu_monitor_mutex); +} + FALLBACK_IMPLEMENTATION void PLAT_pinToCores(int core_type) { // no-op @@ -4393,4 +4436,4 @@ FALLBACK_IMPLEMENTATION void PLAT_bluetoothStreamBegin(int buffersize) {} FALLBACK_IMPLEMENTATION void PLAT_bluetoothStreamEnd() {} FALLBACK_IMPLEMENTATION void PLAT_bluetoothStreamQuit() {} FALLBACK_IMPLEMENTATION int PLAT_bluetoothVolume() { return 100; } -FALLBACK_IMPLEMENTATION void PLAT_bluetoothSetVolume(int vol) {} \ No newline at end of file +FALLBACK_IMPLEMENTATION void PLAT_bluetoothSetVolume(int vol) {} diff --git a/workspace/all/common/api.h b/workspace/all/common/api.h index 7018de0eb..da8e14534 100644 --- a/workspace/all/common/api.h +++ b/workspace/all/common/api.h @@ -113,8 +113,6 @@ extern int currentshaderdsth; extern int currentshadertexw; extern int currentshadertexh; extern int should_rotate; -extern volatile int useAutoCpu; - enum { ASSET_WHITE_PILL, ASSET_BLACK_PILL, @@ -577,10 +575,10 @@ void LEDS_setProfile(int profile); // enum LightProfile void LEDS_updateLeds(bool indicator_only); enum { - CPU_SPEED_MENU, - CPU_SPEED_POWERSAVE, - CPU_SPEED_NORMAL, - CPU_SPEED_PERFORMANCE, + CPU_SPEED_AUTO = 0, + CPU_SPEED_PERFORMANCE = 1, + CPU_SPEED_POWERSAVE = 2, + CPU_SPEED_MENU = CPU_SPEED_AUTO, // legacy }; #define PWR_setCPUSpeed PLAT_setCPUSpeed @@ -685,9 +683,13 @@ int PLAT_supportsDeepSleep(void); int PLAT_deepSleep(void); void PLAT_powerOff(int reboot); +void Perf_setCPUMonitorEnabled(int enabled); +int Perf_isCPUMonitorEnabled(void); +int Perf_tryBeginCPUMonitor(void); +void Perf_endCPUMonitor(void); + void *PLAT_cpu_monitor(void *arg); void PLAT_setCPUSpeed(int speed); // enum -void PLAT_setCustomCPUSpeed(int speed); // note: this affects the calling thread and every thread spawned from it (after) void PLAT_pinToCores(int core_type); // CPU_CORE_EFFICIENCY or CPU_CORE_PERFORMANCE void PLAT_setRumble(int strength); diff --git a/workspace/all/gametime/gametime.c b/workspace/all/gametime/gametime.c index 4d1b907a0..49f23c4ca 100644 --- a/workspace/all/gametime/gametime.c +++ b/workspace/all/gametime/gametime.c @@ -333,7 +333,7 @@ int main(int argc, char *argv[]) { InitSettings(); - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); screen = GFX_init(MODE_MAIN); PAD_init(); diff --git a/workspace/all/ledcontrol/ledcontrol.c b/workspace/all/ledcontrol/ledcontrol.c index b8344ba98..0452703f1 100644 --- a/workspace/all/ledcontrol/ledcontrol.c +++ b/workspace/all/ledcontrol/ledcontrol.c @@ -217,7 +217,7 @@ int main(int argc, char *argv[]) int is_brick = exactMatch("brick", device); InitSettings(); - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); if (is_brick) { const char *brick_names[] = {"F1 key", "F2 key", "Top bar", "L&R triggers"}; diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 1a664f249..6d6daff65 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -97,7 +97,7 @@ static int rewind_cfg_granularity = MINARCH_DEFAULT_REWIND_GRANULARITY; static int rewind_cfg_audio = MINARCH_DEFAULT_REWIND_AUDIO; static int rewind_cfg_compress = 1; static int rewind_cfg_lz4_acceleration = MINARCH_DEFAULT_REWIND_LZ4_ACCELERATION; -static int overclock = 3; // auto +static int overclock = 0; // auto static int has_custom_controllers = 0; static int gamepad_type = 0; // index in gamepad_labels/gamepad_values @@ -2499,10 +2499,9 @@ static char* button_labels[] = { NULL, }; static char* overclock_labels[] = { - "Powersave", - "Normal", - "Performance", "Auto", + "Performance", + "Powersave", NULL, }; @@ -2649,10 +2648,10 @@ static struct Config { [FE_OPT_OVERCLOCK] = { .key = "minarch_cpu_speed", .name = "CPU Speed", - .desc = "Over- or underclock the CPU to prioritize\npure performance or power savings.", - .default_value = 3, - .value = 3, - .count = 4, + .desc = "Choose how the CPU scales.\nAuto is recommended for most users.", + .default_value = 0, + .value = 0, + .count = 3, .values = overclock_labels, .labels = overclock_labels, }, @@ -2995,34 +2994,24 @@ static int Config_getValue(char* cfg, const char* key, char* out_value, int* loc return 1; } +static void updateCPUMonitor(void) { + Perf_setCPUMonitorEnabled(show_debug); + if (!show_debug) return; - - + pthread_t cpucheckthread; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + if (pthread_create(&cpucheckthread, &attr, PLAT_cpu_monitor, NULL) != 0) { + LOG_info("WARNING: failed to start CPU monitor thread\n"); + Perf_setCPUMonitorEnabled(0); + } + pthread_attr_destroy(&attr); +} static void setOverclock(int i) { - overclock = i; - switch (i) { - case 0: { - useAutoCpu = 0; - PWR_setCPUSpeed(CPU_SPEED_POWERSAVE); - break; - } - case 1: { - useAutoCpu = 0; - PWR_setCPUSpeed(CPU_SPEED_NORMAL); - break; - } - case 2: { - useAutoCpu = 0; - PWR_setCPUSpeed(CPU_SPEED_PERFORMANCE); - break; - } - case 3: { - PWR_setCPUSpeed(CPU_SPEED_NORMAL); - useAutoCpu = 1; - break; - } - } + overclock = i; + PWR_setCPUSpeed(i); } static void Config_syncFrontend(char* key, int value) { int i = -1; @@ -3089,7 +3078,9 @@ static void Config_syncFrontend(char* key, int value) { i = FE_OPT_OVERCLOCK; } else if (exactMatch(key,config.frontend.options[FE_OPT_DEBUG].key)) { - show_debug = value; + int prev_show_debug = show_debug; + show_debug = value; + if (prev_show_debug != show_debug) updateCPUMonitor(); i = FE_OPT_DEBUG; } else if (exactMatch(key,config.frontend.options[FE_OPT_MAXFF].key)) { @@ -6284,8 +6275,6 @@ void Menu_beforeSleep() { RTC_write(); State_autosave(); putFile(AUTO_RESUME_PATH, game.path + strlen(SDCARD_PATH)); - - PWR_setCPUSpeed(CPU_SPEED_MENU); } void Menu_afterSleep() { unlink(AUTO_RESUME_PATH); @@ -8569,7 +8558,6 @@ static void Menu_loop(void) { SRAM_write(); RTC_write(); if (!HAS_POWER_BUTTON) PWR_enableSleep(); - PWR_setCPUSpeed(CPU_SPEED_MENU); // set Hz directly GFX_setEffect(EFFECT_NONE); @@ -9030,23 +9018,16 @@ int main(int argc , char* argv[]) { //else // LOG_info("asoundrc does not exist at %s\n", asoundpath); - pthread_t cpucheckthread; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - pthread_create(&cpucheckthread, &attr, PLAT_cpu_monitor, NULL); - pthread_attr_destroy(&attr); + if(argc < 2) + return EXIT_FAILURE; - setOverclock(2); // start up in performance mode, faster init + setOverclock(1); // start up in performance mode, faster init PWR_pinToCores(CPU_CORE_PERFORMANCE); // thread affinity - + char core_path[MAX_PATH]; - char rom_path[MAX_PATH]; + char rom_path[MAX_PATH]; char tag_name[MAX_PATH]; - if(argc < 2) - return EXIT_FAILURE; - strcpy(core_path, argv[1]); strcpy(rom_path, argv[2]); getEmuName(rom_path, tag_name); @@ -9248,6 +9229,7 @@ int main(int argc , char* argv[]) { QuitSettings(); finish: + Perf_setCPUMonitorEnabled(0); // Unload game and shutdown RetroAchievements before Notification_quit — // RA background threads (sync, badge downloads) may call notification diff --git a/workspace/all/minput/minput.c b/workspace/all/minput/minput.c index 1ec1797c7..8c06fef5b 100644 --- a/workspace/all/minput/minput.c +++ b/workspace/all/minput/minput.c @@ -42,7 +42,7 @@ static void blitButton(char* label, SDL_Surface* dst, int pressed, int x, int y, } int main(int argc , char* argv[]) { - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); SDL_Surface* screen = GFX_init(MODE_MAIN); PAD_init(); diff --git a/workspace/all/nextui/nextui.c b/workspace/all/nextui/nextui.c index 7e02ca094..4754bed78 100644 --- a/workspace/all/nextui/nextui.c +++ b/workspace/all/nextui/nextui.c @@ -2259,6 +2259,7 @@ int main (int argc, char *argv[]) { system("gametimectl.elf stop_all"); GFX_setVsync(VSYNC_STRICT); + PWR_setCPUSpeed(CPU_SPEED_AUTO); PAD_reset(); GFX_clearLayers(LAYER_ALL); @@ -2269,11 +2270,6 @@ int main (int argc, char *argv[]) { int had_bt = PLAT_btIsConnected(); int had_sink = GetAudioSink(); - pthread_t cpucheckthread = 0; - if (pthread_create(&cpucheckthread, NULL, PLAT_cpu_monitor, NULL) == 0) { - pthread_detach(cpucheckthread); - } - int selected_row = top->selected - top->start; float targetY; float previousY; diff --git a/workspace/all/settings/settings.cpp b/workspace/all/settings/settings.cpp index bd8bb52c5..18592caff 100644 --- a/workspace/all/settings/settings.cpp +++ b/workspace/all/settings/settings.cpp @@ -285,7 +285,7 @@ int main(int argc, char *argv[]) LOG_info("This is stock OS version %s\n", version); InitSettings(); - PWR_setCPUSpeed(CPU_SPEED_MENU); + PWR_setCPUSpeed(CPU_SPEED_AUTO); Context ctx = {0}; ctx.dirty = 1; diff --git a/workspace/desktop/platform/platform.c b/workspace/desktop/platform/platform.c index bdf3f65d5..ab9fe52c0 100644 --- a/workspace/desktop/platform/platform.c +++ b/workspace/desktop/platform/platform.c @@ -62,7 +62,6 @@ void PLAT_powerOff(int reboot) { /////////////////////////////// -volatile int useAutoCpu = 0; void PLAT_setCPUSpeed(int speed) { // buh } diff --git a/workspace/tg5040/platform/platform.c b/workspace/tg5040/platform/platform.c index 07b915b55..6c975993f 100644 --- a/workspace/tg5040/platform/platform.c +++ b/workspace/tg5040/platform/platform.c @@ -243,140 +243,70 @@ static pthread_mutex_t currentcpuinfo; // a roling average for the display values of about 2 frames, otherwise they are unreadable jumping too fast up and down and stuff to read #define ROLLING_WINDOW 120 -volatile int useAutoCpu = 1; void *PLAT_cpu_monitor(void *arg) { - struct timespec start_time, curr_time; - clock_gettime(CLOCK_MONOTONIC_RAW, &start_time); - - long clock_ticks_per_sec = sysconf(_SC_CLK_TCK); + if (!Perf_tryBeginCPUMonitor()) return NULL; double prev_real_time = get_time_sec(); double prev_cpu_time = get_process_cpu_time_sec(); - // big Cortex-A53 CPUx4 - 408Mhz to 2160Mhz - // 408000 600000 816000 1008000 1200000 1416000 1608000 1800000 2000000 - const int cpu_frequencies[] = {408,600,816,1008,1200,1416,1608,1800,2000}; - const int num_freqs = sizeof(cpu_frequencies) / sizeof(cpu_frequencies[0]); - int current_index = 1; - double cpu_usage_history[ROLLING_WINDOW] = {0}; - double cpu_speed_history[ROLLING_WINDOW] = {0}; int history_index = 0; - int history_count = 0; - - while (true) { + int history_count = 0; - double curr_real_time = get_time_sec(); - double curr_cpu_time = get_process_cpu_time_sec(); + while (Perf_isCPUMonitorEnabled()) { + double curr_real_time = get_time_sec(); + double curr_cpu_time = get_process_cpu_time_sec(); - double elapsed_real_time = curr_real_time - prev_real_time; - double elapsed_cpu_time = curr_cpu_time - prev_cpu_time; + double elapsed_real_time = curr_real_time - prev_real_time; + double elapsed_cpu_time = curr_cpu_time - prev_cpu_time; - if (useAutoCpu) { - double cpu_usage = 0; - - if (elapsed_real_time > 0) { - cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; - } + if (elapsed_real_time > 0) { + double cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; pthread_mutex_lock(¤tcpuinfo); - // the goal here is is to keep cpu usage between 75% and 85% at the lowest possible speed so device stays cool and battery usage is at a minimum - // if usage falls out of this range it will either scale a step down or up - // but if usage hits above 95% we need that max boost and we instant scale up to 2000mhz as long as needed - // all this happens very fast like 60 times per second, so i'm applying roling averages to display values, so debug screen is readable and gives a good estimate on whats happening cpu wise - // the roling averages are purely for displaying, the actual scaling is happening realtime each run. - if (cpu_usage > 95) { - current_index = num_freqs - 1; // Instant power needed, cpu is above 95% Jump directly to max boost 2000MHz - } - else if (cpu_usage > 85 && current_index < num_freqs - 1) { // otherwise try to keep between 75 and 85 at lowest clock speed - current_index++; - } - else if (cpu_usage < 75 && current_index > 0) { - current_index--; - } - - PLAT_setCustomCPUSpeed(cpu_frequencies[current_index] * 1000); - cpu_usage_history[history_index] = cpu_usage; - cpu_speed_history[history_index] = cpu_frequencies[current_index]; - history_index = (history_index + 1) % ROLLING_WINDOW; - if (history_count < ROLLING_WINDOW) { - history_count++; - } - - double sum_cpu_usage = 0, sum_cpu_speed = 0; - for (int i = 0; i < history_count; i++) { - sum_cpu_usage += cpu_usage_history[i]; - sum_cpu_speed += cpu_speed_history[i]; - } + if (history_count < ROLLING_WINDOW) history_count++; + double sum_cpu_usage = 0; + for (int i = 0; i < history_count; i++) sum_cpu_usage += cpu_usage_history[i]; perf.cpu_usage = sum_cpu_usage / history_count; - //perf.cpu_speed = sum_cpu_speed / history_count; pthread_mutex_unlock(¤tcpuinfo); - - prev_real_time = curr_real_time; - prev_cpu_time = curr_cpu_time; - // 20ms really seems lowest i can go, anything lower it becomes innacurate, maybe one day I will find another even more granual way to calculate usage accurately and lower this shit to 1ms haha, altough anything lower than 10ms causes cpu usage in itself so yeah - // Anyways screw it 20ms is pretty much on a frame by frame basis anyways, so will anything lower really make a difference specially if that introduces cpu usage by itself? - // Who knows, maybe some CPU engineer will find my comment here one day and can explain, maybe this is looking for the limits of C and needs Assambler or whatever to call CPU instructions directly to go further, but all I know is PUSH and MOV, how did the orignal Roller Coaster Tycoon developer wrote a whole game like this anyways? Its insane.. - usleep(20000); - - } else { - // Just measure CPU usage without changing frequency - - if (elapsed_real_time > 0) { - double cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; - - pthread_mutex_lock(¤tcpuinfo); - - cpu_usage_history[history_index] = cpu_usage; - - history_index = (history_index + 1) % ROLLING_WINDOW; - if (history_count < ROLLING_WINDOW) { - history_count++; - } - - double sum_cpu_usage = 0; - for (int i = 0; i < history_count; i++) { - sum_cpu_usage += cpu_usage_history[i]; - } - - perf.cpu_usage = sum_cpu_usage / history_count; - - pthread_mutex_unlock(¤tcpuinfo); - } - - prev_real_time = curr_real_time; - prev_cpu_time = curr_cpu_time; - usleep(100000); } - } -} - -#define GOVERNOR_PATH "/sys/devices/system/cpu/cpu0/cpufreq/scaling_setspeed" -void PLAT_setCustomCPUSpeed(int speed) { - FILE *fp = fopen(GOVERNOR_PATH, "w"); - if (fp == NULL) { - perror("Failed to open scaling_setspeed"); - return; + prev_real_time = curr_real_time; + prev_cpu_time = curr_cpu_time; + usleep(100000); } - fprintf(fp, "%d\n", speed); - fclose(fp); + Perf_endCPUMonitor(); + return NULL; } + void PLAT_setCPUSpeed(int speed) { - int freq = 0; + const char* mode; switch (speed) { - case CPU_SPEED_MENU: freq = 600000; perf.cpu_speed = 600; break; - case CPU_SPEED_POWERSAVE: freq = 1200000; perf.cpu_speed = 1200; break; - case CPU_SPEED_NORMAL: freq = 1608000; perf.cpu_speed = 1600; break; - case CPU_SPEED_PERFORMANCE: freq = 2000000; perf.cpu_speed = 2000; break; + case CPU_SPEED_AUTO: mode = "auto"; break; + case CPU_SPEED_PERFORMANCE: mode = "performance"; break; + case CPU_SPEED_POWERSAVE: mode = "powersave"; break; + default: return; + } + + const char* system_path = getenv("SYSTEM_PATH"); + if (!system_path) { + LOG_info("WARNING: SYSTEM_PATH not set, cannot run governor script\n"); + return; + } + char cmd[512]; + int n = snprintf(cmd, sizeof(cmd), "sh \"%s/bin/governor.sh\" \"%s\"", system_path, mode); + if (n < 0 || n >= (int)sizeof(cmd)) { + LOG_info("WARNING: SYSTEM_PATH too long for governor script path\n"); + return; } - putInt(GOVERNOR_PATH, freq); + int ret = system(cmd); + if (ret != 0) LOG_info("WARNING: governor script exited with status %d for mode '%s'\n", ret, mode); } #define MAX_STRENGTH 0xFFFF diff --git a/workspace/tg5050/platform/platform.c b/workspace/tg5050/platform/platform.c index cef0e80d0..c20ebdc7c 100644 --- a/workspace/tg5050/platform/platform.c +++ b/workspace/tg5050/platform/platform.c @@ -288,144 +288,70 @@ void PLAT_pinToCores(int core_type) LOG_error("Failed to pin: Are all cores sleeping?\n"); } -volatile int useAutoCpu = 1; void *PLAT_cpu_monitor(void *arg) { - struct timespec start_time, curr_time; - clock_gettime(CLOCK_MONOTONIC_RAW, &start_time); - - long clock_ticks_per_sec = sysconf(_SC_CLK_TCK); + if (!Perf_tryBeginCPUMonitor()) return NULL; double prev_real_time = get_time_sec(); double prev_cpu_time = get_process_cpu_time_sec(); - // big Cortex-A55 CPU4 - 408Mhz to 2160Mhz - // 408000 672000 840000 1008000 1200000 1344000 1488000 1584000 1680000 1800000 1992000 2088000 2160000 - const int big_cpu_frequencies[] = {408,672,840,1008,1200,1344,1488,1584,1680,1800,1992,2088,2160}; - const int big_num_freqs = sizeof(big_cpu_frequencies) / sizeof(big_cpu_frequencies[0]); - int big_index = 1; // 672Mhz start - // little Cortex-A55 CPU0 - 408Mhz to 1416Mhz - // 408000 672000 792000 936000 1032000 1128000 1224000 1320000 1416000 - const int little_cpu_frequencies[] = {408,672,792,936,1032,1128,1224,1320,1416}; - const int little_num_freqs = sizeof(little_cpu_frequencies) / sizeof(little_cpu_frequencies[0]); - int little_index = 1; // 672Mhz start - double cpu_usage_history[ROLLING_WINDOW] = {0}; - double cpu_speed_history[ROLLING_WINDOW] = {0}; int history_index = 0; - int history_count = 0; - - while (true) { + int history_count = 0; - double curr_real_time = get_time_sec(); - double curr_cpu_time = get_process_cpu_time_sec(); + while (Perf_isCPUMonitorEnabled()) { + double curr_real_time = get_time_sec(); + double curr_cpu_time = get_process_cpu_time_sec(); - double elapsed_real_time = curr_real_time - prev_real_time; - double elapsed_cpu_time = curr_cpu_time - prev_cpu_time; + double elapsed_real_time = curr_real_time - prev_real_time; + double elapsed_cpu_time = curr_cpu_time - prev_cpu_time; - if (useAutoCpu) { - double cpu_usage = 0; - - if (elapsed_real_time > 0) { - cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; - } + if (elapsed_real_time > 0) { + double cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; pthread_mutex_lock(¤tcpuinfo); - // the goal here is is to keep cpu usage between 75% and 85% at the lowest possible speed so device stays cool and battery usage is at a minimum - // if usage falls out of this range it will either scale a step down or up - // but if usage hits above 95% we need that max boost and we instant scale up to 2000mhz as long as needed - // all this happens very fast like 60 times per second, so i'm applying roling averages to display values, so debug screen is readable and gives a good estimate on whats happening cpu wise - // the roling averages are purely for displaying, the actual scaling is happening realtime each run. - if (cpu_usage > 95) { - big_index = big_num_freqs - 1; // Instant power needed, cpu is above 95% Jump directly to max boost 2000MHz - } - else if (cpu_usage > 85 && big_index < big_num_freqs - 1) { // otherwise try to keep between 75 and 85 at lowest clock speed - big_index++; - } - else if (cpu_usage < 75 && big_index > 0) { - big_index--; - } - - PLAT_setCustomCPUSpeed(big_cpu_frequencies[big_index] * 1000); - cpu_usage_history[history_index] = cpu_usage; - cpu_speed_history[history_index] = big_cpu_frequencies[big_index]; - history_index = (history_index + 1) % ROLLING_WINDOW; - if (history_count < ROLLING_WINDOW) { - history_count++; - } - - double sum_cpu_usage = 0, sum_cpu_speed = 0; - for (int i = 0; i < history_count; i++) { - sum_cpu_usage += cpu_usage_history[i]; - sum_cpu_speed += cpu_speed_history[i]; - } + if (history_count < ROLLING_WINDOW) history_count++; + double sum_cpu_usage = 0; + for (int i = 0; i < history_count; i++) sum_cpu_usage += cpu_usage_history[i]; perf.cpu_usage = sum_cpu_usage / history_count; - //perf.cpu_speed = sum_cpu_speed / history_count; pthread_mutex_unlock(¤tcpuinfo); - - prev_real_time = curr_real_time; - prev_cpu_time = curr_cpu_time; - // 20ms really seems lowest i can go, anything lower it becomes innacurate, maybe one day I will find another even more granual way to calculate usage accurately and lower this shit to 1ms haha, altough anything lower than 10ms causes cpu usage in itself so yeah - // Anyways screw it 20ms is pretty much on a frame by frame basis anyways, so will anything lower really make a difference specially if that introduces cpu usage by itself? - // Who knows, maybe some CPU engineer will find my comment here one day and can explain, maybe this is looking for the limits of C and needs Assambler or whatever to call CPU instructions directly to go further, but all I know is PUSH and MOV, how did the orignal Roller Coaster Tycoon developer wrote a whole game like this anyways? Its insane.. - usleep(20000); - } else { - // Just measure CPU usage without changing frequency - - if (elapsed_real_time > 0) { - double cpu_usage = (elapsed_cpu_time / elapsed_real_time) * 100.0; - - pthread_mutex_lock(¤tcpuinfo); - - cpu_usage_history[history_index] = cpu_usage; - - history_index = (history_index + 1) % ROLLING_WINDOW; - if (history_count < ROLLING_WINDOW) { - history_count++; - } - - double sum_cpu_usage = 0; - for (int i = 0; i < history_count; i++) { - sum_cpu_usage += cpu_usage_history[i]; - } - - perf.cpu_usage = sum_cpu_usage / history_count; - - pthread_mutex_unlock(¤tcpuinfo); - } - - prev_real_time = curr_real_time; - prev_cpu_time = curr_cpu_time; - usleep(100000); } - } -} - -#define GOVERNOR_PATH "/sys/devices/system/cpu/cpu4/cpufreq/scaling_setspeed" -void PLAT_setCustomCPUSpeed(int speed) { - FILE *fp = fopen(GOVERNOR_PATH, "w"); - if (fp == NULL) { - perror("Failed to open scaling_setspeed"); - return; + prev_real_time = curr_real_time; + prev_cpu_time = curr_cpu_time; + usleep(100000); } - fprintf(fp, "%d\n", speed); - fclose(fp); + Perf_endCPUMonitor(); + return NULL; } + void PLAT_setCPUSpeed(int speed) { - int freq = 0; + const char* mode; switch (speed) { - case CPU_SPEED_MENU: freq = 672000; perf.cpu_speed = 672; break; - case CPU_SPEED_POWERSAVE: freq = 1200000; perf.cpu_speed = 1200; break; - case CPU_SPEED_NORMAL: freq = 1680000; perf.cpu_speed = 1680; break; - case CPU_SPEED_PERFORMANCE: freq = 2160000; perf.cpu_speed = 2160; break; + case CPU_SPEED_AUTO: mode = "auto"; break; + case CPU_SPEED_PERFORMANCE: mode = "performance"; break; + case CPU_SPEED_POWERSAVE: mode = "powersave"; break; + default: return; + } + + const char* system_path = getenv("SYSTEM_PATH"); + if (!system_path) { + LOG_info("WARNING: SYSTEM_PATH not set, cannot run governor script\n"); + return; + } + char cmd[512]; + int n = snprintf(cmd, sizeof(cmd), "sh \"%s/bin/governor.sh\" \"%s\"", system_path, mode); + if (n < 0 || n >= (int)sizeof(cmd)) { + LOG_info("WARNING: SYSTEM_PATH too long for governor script path\n"); + return; } - putInt(GOVERNOR_PATH, freq); + int ret = system(cmd); + if (ret != 0) LOG_info("WARNING: governor script exited with status %d for mode '%s'\n", ret, mode); } #define MAX_STRENGTH 0xFFFF From afb3783de5b017f0f02862a23024bb44ecc4cc75 Mon Sep 17 00:00:00 2001 From: Prashant Vaibhav Date: Sun, 17 May 2026 17:23:12 +0200 Subject: [PATCH 08/19] fix: slowdowns on Auto cpu speed (#727) --- skeleton/SYSTEM/tg5040/bin/governor.sh | 8 ++++---- skeleton/SYSTEM/tg5050/bin/governor.sh | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skeleton/SYSTEM/tg5040/bin/governor.sh b/skeleton/SYSTEM/tg5040/bin/governor.sh index 1238e9608..b0552094e 100755 --- a/skeleton/SYSTEM/tg5040/bin/governor.sh +++ b/skeleton/SYSTEM/tg5040/bin/governor.sh @@ -39,12 +39,12 @@ set_policy() { case "$MODE" in auto) - # ondemand governor, min freq to one step below max (408-1800 MHz on TG5040) - set_policy /sys/devices/system/cpu/cpufreq/policy0 "ondemand" "second_max" + # schedutil governor, min freq to one step below max (408-1800 MHz on TG5040) + set_policy /sys/devices/system/cpu/cpufreq/policy0 "schedutil" "second_max" ;; performance) - # schedutil governor, min freq to max freq (408-2000 MHz on TG5040) - set_policy /sys/devices/system/cpu/cpufreq/policy0 "schedutil" "max" + # performance governor, max freq (2000 MHz on TG5040) + set_policy /sys/devices/system/cpu/cpufreq/policy0 "performance" "max" ;; powersave) # conservative governor, min freq to midpoint max (408-1200 MHz on TG5040) diff --git a/skeleton/SYSTEM/tg5050/bin/governor.sh b/skeleton/SYSTEM/tg5050/bin/governor.sh index 5862c1987..b77c5c1a3 100755 --- a/skeleton/SYSTEM/tg5050/bin/governor.sh +++ b/skeleton/SYSTEM/tg5050/bin/governor.sh @@ -43,16 +43,16 @@ apply_mode() { case "$mode" in auto) - # ondemand governor, min freq to one step below max + # schedutil governor, min freq to one step below max # policy0 - little Cores (408-1320 MHz on TG5050) # policy4 - BIG Cores (408-2088 MHz on TG5050) - set_policy "$policy" "ondemand" "second_max" + set_policy "$policy" "schedutil" "second_max" ;; performance) - # schedutil governor, min freq to max freq - # policy0 - little Cores (408-1416 MHz on TG5050) - # policy4 - BIG Cores (408-2160 MHz on TG5050) - set_policy "$policy" "schedutil" "max" + # performance governor, max freq + # policy0 - little Cores (1416 MHz on TG5050) + # policy4 - BIG Cores (2160 MHz on TG5050) + set_policy "$policy" "performance" "max" ;; powersave) # conservative governor, min freq to midpoint max From 2a9c0d4de688271af8e823cc35a31625ef167d99 Mon Sep 17 00:00:00 2001 From: Christophe Vanlancker Date: Mon, 18 May 2026 22:03:11 +0200 Subject: [PATCH 09/19] refactor: break minarch.c monolith into focused modules (#721) --- .gitignore | 1 + workspace/all/minarch/ma_audio.c | 57 + workspace/all/minarch/ma_audio.h | 8 + workspace/all/minarch/ma_cheats.c | 349 + workspace/all/minarch/ma_cheats.h | 29 + workspace/all/minarch/ma_config.c | 1729 ++++ workspace/all/minarch/ma_config.h | 19 + workspace/all/minarch/ma_core.c | 171 + workspace/all/minarch/ma_core.h | 12 + workspace/all/minarch/ma_environment.c | 411 + workspace/all/minarch/ma_environment.h | 3 + workspace/all/minarch/ma_frontend_opts.c | 728 ++ workspace/all/minarch/ma_frontend_opts.h | 72 + workspace/all/minarch/ma_game.c | 272 + workspace/all/minarch/ma_game.h | 6 + workspace/all/minarch/ma_input.c | 296 + workspace/all/minarch/ma_input.h | 11 + workspace/all/minarch/ma_internal.h | 328 + workspace/all/minarch/ma_menu.c | 2082 +++++ workspace/all/minarch/ma_menu.h | 22 + workspace/all/minarch/ma_options.c | 327 + workspace/all/minarch/ma_options.h | 20 + workspace/all/minarch/ma_rewind.c | 741 ++ workspace/all/minarch/ma_rewind.h | 100 + workspace/all/minarch/ma_runframe.c | 105 + workspace/all/minarch/ma_runframe.h | 4 + workspace/all/minarch/ma_saves.c | 386 + workspace/all/minarch/ma_saves.h | 13 + workspace/all/minarch/ma_video.c | 996 +++ workspace/all/minarch/ma_video.h | 12 + workspace/all/minarch/makefile | 5 +- workspace/all/minarch/minarch.c | 9094 +--------------------- 32 files changed, 9417 insertions(+), 8992 deletions(-) create mode 100644 workspace/all/minarch/ma_audio.c create mode 100644 workspace/all/minarch/ma_audio.h create mode 100644 workspace/all/minarch/ma_cheats.c create mode 100644 workspace/all/minarch/ma_cheats.h create mode 100644 workspace/all/minarch/ma_config.c create mode 100644 workspace/all/minarch/ma_config.h create mode 100644 workspace/all/minarch/ma_core.c create mode 100644 workspace/all/minarch/ma_core.h create mode 100644 workspace/all/minarch/ma_environment.c create mode 100644 workspace/all/minarch/ma_environment.h create mode 100644 workspace/all/minarch/ma_frontend_opts.c create mode 100644 workspace/all/minarch/ma_frontend_opts.h create mode 100644 workspace/all/minarch/ma_game.c create mode 100644 workspace/all/minarch/ma_game.h create mode 100644 workspace/all/minarch/ma_input.c create mode 100644 workspace/all/minarch/ma_input.h create mode 100644 workspace/all/minarch/ma_internal.h create mode 100644 workspace/all/minarch/ma_menu.c create mode 100644 workspace/all/minarch/ma_menu.h create mode 100644 workspace/all/minarch/ma_options.c create mode 100644 workspace/all/minarch/ma_options.h create mode 100644 workspace/all/minarch/ma_rewind.c create mode 100644 workspace/all/minarch/ma_rewind.h create mode 100644 workspace/all/minarch/ma_runframe.c create mode 100644 workspace/all/minarch/ma_runframe.h create mode 100644 workspace/all/minarch/ma_saves.c create mode 100644 workspace/all/minarch/ma_saves.h create mode 100644 workspace/all/minarch/ma_video.c create mode 100644 workspace/all/minarch/ma_video.h diff --git a/.gitignore b/.gitignore index d5e49d6b2..3136a5393 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ workspace/.container_runtime AGENTS.md CLAUDE.md +.worktrees/ diff --git a/workspace/all/minarch/ma_audio.c b/workspace/all/minarch/ma_audio.c new file mode 100644 index 000000000..a8f66e765 --- /dev/null +++ b/workspace/all/minarch/ma_audio.c @@ -0,0 +1,57 @@ +#include +#include +#include +#include "ma_internal.h" +#include "ma_audio.h" + +static bool resetAudio = false; + +void Audio_onSinkChanged(int device, int watch_event) { + switch (watch_event) { + case DIRWATCH_CREATE: LOG_info("callback reason: DIRWATCH_CREATE\n"); break; + case DIRWATCH_DELETE: LOG_info("callback reason: DIRWATCH_DELETE\n"); break; + case FILEWATCH_MODIFY: LOG_info("callback reason: FILEWATCH_MODIFY\n"); break; + case FILEWATCH_DELETE: LOG_info("callback reason: FILEWATCH_DELETE\n"); break; + case FILEWATCH_CLOSE_WRITE:LOG_info("callback reason: FILEWATCH_CLOSE_WRITE\n");break; + } + + resetAudio = true; + + // FIXME: This shouldnt be necessary, alsa should just read .asoundrc for the changed default device. + if (device == AUDIO_SINK_BLUETOOTH) + SDL_setenv("AUDIODEV", "bluealsa", 1); + else + SDL_setenv("AUDIODEV", "default", 1); +} + +void Audio_checkAndResetIfNeeded(void) { + if (!resetAudio) return; + resetAudio = false; + LOG_info("Resetting audio device config! (new state: %s)\n", SDL_getenv("AUDIODEV")); + SND_resetAudio(core.sample_rate, core.fps); +} + +void audio_sample_callback(int16_t left, int16_t right) { + if (rewinding && !rewind_ctx.audio) return; + if (!fast_forward || ff_audio) { + if (use_core_fps || fast_forward) { + SND_batchSamples_fixed_rate(&(const SND_Frame){left,right}, 1); + } + else { + SND_batchSamples(&(const SND_Frame){left,right}, 1); + } + } +} + +size_t audio_sample_batch_callback(const int16_t *data, size_t frames) { + if (rewinding && !rewind_ctx.audio) return frames; + if (!fast_forward || ff_audio) { + if (use_core_fps || fast_forward) { + return SND_batchSamples_fixed_rate((const SND_Frame*)data, frames); + } + else { + return SND_batchSamples((const SND_Frame*)data, frames); + } + } + else return frames; +} diff --git a/workspace/all/minarch/ma_audio.h b/workspace/all/minarch/ma_audio.h new file mode 100644 index 000000000..74ec98213 --- /dev/null +++ b/workspace/all/minarch/ma_audio.h @@ -0,0 +1,8 @@ +#pragma once + +#include "libretro.h" + +void audio_sample_callback(int16_t left, int16_t right); +size_t audio_sample_batch_callback(const int16_t *data, size_t frames); +void Audio_onSinkChanged(int device, int watch_event); +void Audio_checkAndResetIfNeeded(void); diff --git a/workspace/all/minarch/ma_cheats.c b/workspace/all/minarch/ma_cheats.c new file mode 100644 index 000000000..289a2a303 --- /dev/null +++ b/workspace/all/minarch/ma_cheats.c @@ -0,0 +1,349 @@ +#include "ma_internal.h" +#include "ma_cheats.h" + +#include +#include +#include + +// based on picoarch/cheat.c + +struct Cheats cheatcodes; + +#define CHEAT_MAX_DESC_LEN 27 +#define CHEAT_MAX_LINE_LEN 52 +#define CHEAT_MAX_LINES 3 + +static size_t parse_count(FILE *file) { + size_t count = 0; + fscanf(file, " cheats = %lu\n", (unsigned long *)&count); + return count; +} + +static const char *find_val(const char *start) { + start--; + while(!isspace(*++start)) + ; + + while(isspace(*++start)) + ; + + if (*start != '=') + return NULL; + + while(isspace(*++start)) + ; + + return start; +} + +static int parse_bool(const char *ptr, int *out) { + if (!strncasecmp(ptr, "true", 4)) { + *out = 1; + } else if (!strncasecmp(ptr, "false", 5)) { + *out = 0; + } else { + return -1; + } + return 0; +} + +static int parse_string(const char *ptr, char *buf, size_t len) { + int index = 0; + size_t input_len = strlen(ptr); + + buf[0] = '\0'; + + if (*ptr++ != '"') + return -1; + + while (*ptr != '\0' && *ptr != '"' && index < len - 1) { + if (*ptr == '\\' && index < input_len - 1) { + ptr++; + buf[index++] = *ptr++; + } else if (*ptr == '&' && !strncmp(ptr, """, 6)) { + buf[index++] = '"'; + ptr += 6; + } else { + buf[index++] = *ptr++; + } + } + + if (*ptr != '"') { + buf[0] = '\0'; + return -1; + } + + buf[index] = '\0'; + return 0; +} + +static int parse_cheats(struct Cheats *cheats, FILE *file) { + int ret = -1; + char line[512]; + char buf[512]; + const char *ptr; + + do { + if (!fgets(line, sizeof(line), file)) { + ret = 0; + break; + } + + if (line[strlen(line) - 1] != '\n' && !feof(file)) { + LOG_warn("Cheat line too long\n"); + continue; + } + + if ((ptr = strstr(line, "cheat"))) { + int index = -1; + struct Cheat *cheat; + size_t len; + sscanf(ptr, "cheat%d", &index); + + if (index >= cheats->count) + continue; + cheat = &cheats->cheats[index]; + + if (strstr(ptr, "_desc")) { + ptr = find_val(ptr); + if (!ptr || parse_string(ptr, buf, sizeof(buf))) { + LOG_warn("Couldn't parse cheat %d description\n", index); + continue; + } + + len = strlen(buf); + if (len == 0) + continue; + + cheat->name = calloc(len+1, sizeof(char)); + if (!cheat->name) + goto finish; + + strncpy((char *)cheat->name, buf, len); + truncateString((char *)cheat->name, CHEAT_MAX_DESC_LEN); + + if (len >= CHEAT_MAX_DESC_LEN) { + cheat->info = calloc(len+1, sizeof(char)); + if (!cheat->info) + goto finish; + + strncpy((char *)cheat->info, buf, len); + wrapString((char *)cheat->info, CHEAT_MAX_LINE_LEN, CHEAT_MAX_LINES); + } + } else if (strstr(ptr, "_code")) { + ptr = find_val(ptr); + if (!ptr || parse_string(ptr, buf, sizeof(buf))) { + LOG_warn("Couldn't parse cheat %d code\n", index); + continue; + } + + len = strlen(buf); + if (len == 0) + continue; + + cheat->code = calloc(len+1, sizeof(char)); + if (!cheat->code) + goto finish; + + strncpy((char *)cheat->code, buf, len); + } else if (strstr(ptr, "_enable")) { + ptr = find_val(ptr); + if (!ptr || parse_bool(ptr, &cheat->enabled)) { + LOG_warn("Couldn't parse cheat %d enabled\n", index); + continue; + } + } + } + } while(1); + +finish: + return ret; +} + +// return variations with/without extensions and other cruft +// note: CHEAT_MAX_PATHS must be large enough to contain one entry per extension supported by a core, plus ~5 more +// 48 is enough: retroarch cores with most extensions at the moment is VICE VIC-20 at 37 and rom-cleaner at 42 +void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count) { + // reserve a few entries at the end, for sanitized name and glob patterns + const int sanitized_paths_count = 3; + + // Generate possible paths, ordered by most likely to be used (pre v6.2.3 style first) + sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, game.name); // /mnt/SDCARD/Cheats/GB/Super Example World..cht + if(CFG_getUseExtractedFileName()) + sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, game.alt_name); // /mnt/SDCARD/Cheats/GB/Super Example World (USA)..cht + + // game.alt_name, but with all extension-like stuff removed (apart from .cht) + // eg. Super Example World (USA).zip -> Super Example World (USA).cht + { + int i = 0; + char* ext; + char exts[128]; + if (core.extensions == NULL || strlen(core.extensions) >= sizeof(exts)) { + LOG_info("Invalid or too long core.extensions\n"); + return; + } + + strcpy(exts, core.extensions); + while ((ext = strtok(i ? NULL : exts, "|"))) { + // sanitized_paths_count slots are reserved for sanitized rom name, see below + if (*count >= CHEAT_MAX_PATHS - sanitized_paths_count) { + LOG_info("Maximum cheat paths reached, stopping\n"); + break; + } + + char rom_name[MAX_PATH]; + if (strlen(game.alt_name) >= MAX_PATH) { + LOG_info("game.alt_name too long, skipping\n"); + i++; + continue; + } + + strcpy(rom_name, game.alt_name); + char* tmp = strrchr(rom_name, '.'); + if (tmp != NULL && strlen(tmp) > 2 && strlen(tmp) <= 5) { + tmp[0] = '\0'; + + // Add length check before sprintf to prevent buffer overflow + int needed_len = strlen(core.cheats_dir) + strlen(rom_name) + strlen(ext) + 10; // +10 for "/", ".", ".cht", etc. + if (needed_len < MAX_PATH) { + sprintf(paths[(*count)++], "%s/%s.%s.cht", core.cheats_dir, rom_name, ext); + } else { + LOG_info("Path too long, skipping: %s/%s.%s.cht\n", core.cheats_dir, rom_name, ext); + } + } + i++; + } + } + + // Sanitized: remove extension and other cruft + // eg. Super Example World (USA).zip -> Super Example World + // Super Example World (USA) [!].7z -> Super Example World + // Super Example World (USA) (Rev 1).rar -> Super Example World + // Important: update `sanitized_paths_count` if adding more sanitized variations + char rom_name[MAX_PATH]; + getDisplayName(game.alt_name, rom_name); + sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, rom_name); // /mnt/SDCARD/Cheats/GB/Super Example World.cht + // Respect map.txt: use alias if available + // eg. 1941.zip -> 1941: Counter Attack + if(getAlias(game.path, rom_name)) + sprintf(paths[(*count)++], "%s/%s.cht", core.cheats_dir, rom_name); // /mnt/SDCARD/Cheats/GB/Super Example World.cht + + // Santitized alias, ignoring all extra cruft - including Cheat specifics like "(Game Breaker)" etc. + // This is a wildcard that may match something unexpected, but also may find something when nothing else does. + getAlias(game.path, rom_name); + sprintf(paths[(*count)++], "%s/%s*.cht", core.cheats_dir, rom_name); // /mnt/SDCARD/Cheats/GB/Super Example World*.cht + // Log all path candidates + { + char *list = calloc(*count * (MAX_PATH + 2) + 1, 1); // path + separator for each entry + if (list != NULL) { + int i; + for (i=0; i<*count; i++) { + strcat(list, paths[i]); + if (i < *count-1) strcat(list, ", "); + } + //LOG_info("Cheat paths to check: %s\n", list); + free(list); + } + } +} + +void Cheats_free() { + size_t i; + for (i = 0; i < cheatcodes.count; i++) { + struct Cheat *cheat = &cheatcodes.cheats[i]; + if (cheat) { + free((char *)cheat->name); + free((char *)cheat->info); + free((char *)cheat->code); + } + } + free(cheatcodes.cheats); + cheatcodes.count = 0; +} + +bool Cheats_load() { + int success = 0; + struct Cheats *cheats = &cheatcodes; + FILE *file = NULL; + size_t i; + + // we get our paths frrom Cheat_getPaths, some might be wildcards + char paths[CHEAT_MAX_PATHS][MAX_PATH]; + int path_count = 0; + Cheat_getPaths(paths, &path_count); + char filename[MAX_PATH] = {0}; + for (i=0; i 0) { + for (size_t gi = 0; gi < glob_results.gl_pathc; ++gi) { + if (!suffixMatch(".cht", glob_results.gl_pathv[gi])) continue; + strcpy(filename, glob_results.gl_pathv[gi]); + if (exists(filename)) { + LOG_info("Found potential cheat file: %s\n", filename); + break; + } + filename[0] = '\0'; + } + } + globfree(&glob_results); + if (filename[0] == '\0') continue; // no match + } else { + strcpy(filename, paths[i]); + if (!exists(filename)) { + filename[0] = '\0'; + continue; + } + } + break; // found a valid file + } + if (filename[0] == '\0') { + LOG_info("No cheat file found\n"); + goto finish; + } + + LOG_info("Loading cheats from %s\n", filename); + + file = fopen(filename, "r"); + if (!file) { + LOG_error("Couldn't open cheat file: %s\n", filename); + goto finish; + } + + cheatcodes.count = parse_count(file); + if (cheatcodes.count <= 0) { + LOG_error("Couldn't read cheat count\n"); + goto finish; + } + + cheatcodes.cheats = calloc(cheatcodes.count, sizeof(struct Cheat)); + if (!cheatcodes.cheats) { + LOG_error("Couldn't allocate memory for cheats\n"); + goto finish; + } + + if (parse_cheats(&cheatcodes, file)) { + LOG_error("Error reading cheat %d\n", i); + goto finish; + } + + LOG_info("Found %i cheats for the current game.\n", cheatcodes.count); + + success = 1; +finish: + if (!success) { + Cheats_free(); + } + + if (file) + fclose(file); +} diff --git a/workspace/all/minarch/ma_cheats.h b/workspace/all/minarch/ma_cheats.h new file mode 100644 index 000000000..7e865a63f --- /dev/null +++ b/workspace/all/minarch/ma_cheats.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include "defines.h" + +#define CHEAT_MAX_PATHS 48 +#define CHEAT_MAX_DISPLAY_PATHS 8 +#define CHEAT_MAX_LIST_LENGTH (CHEAT_MAX_DISPLAY_PATHS * MAX_PATH) + +struct Cheat { + const char *name; + const char *info; + int enabled; + const char *code; +}; + +struct Cheats { + int enabled; + size_t count; + struct Cheat *cheats; +}; + +extern struct Cheats cheatcodes; + +void Cheat_getPaths(char paths[CHEAT_MAX_PATHS][MAX_PATH], int* count); +void Cheats_free(void); +bool Cheats_load(void); diff --git a/workspace/all/minarch/ma_config.c b/workspace/all/minarch/ma_config.c new file mode 100644 index 000000000..9d060677b --- /dev/null +++ b/workspace/all/minarch/ma_config.c @@ -0,0 +1,1729 @@ +#include +#include +#include +#include +#include + +#include "ma_internal.h" +#include "ma_options.h" +#include "ma_config.h" + +static ButtonMapping button_label_mapping[] = { // used to lookup the retro_id and local btn_id from button name + {"NONE", -1, BTN_ID_NONE}, + {"UP", RETRO_DEVICE_ID_JOYPAD_UP, BTN_ID_DPAD_UP}, + {"DOWN", RETRO_DEVICE_ID_JOYPAD_DOWN, BTN_ID_DPAD_DOWN}, + {"LEFT", RETRO_DEVICE_ID_JOYPAD_LEFT, BTN_ID_DPAD_LEFT}, + {"RIGHT", RETRO_DEVICE_ID_JOYPAD_RIGHT, BTN_ID_DPAD_RIGHT}, + {"A", RETRO_DEVICE_ID_JOYPAD_A, BTN_ID_A}, + {"B", RETRO_DEVICE_ID_JOYPAD_B, BTN_ID_B}, + {"X", RETRO_DEVICE_ID_JOYPAD_X, BTN_ID_X}, + {"Y", RETRO_DEVICE_ID_JOYPAD_Y, BTN_ID_Y}, + {"START", RETRO_DEVICE_ID_JOYPAD_START, BTN_ID_START}, + {"SELECT", RETRO_DEVICE_ID_JOYPAD_SELECT, BTN_ID_SELECT}, + {"L1", RETRO_DEVICE_ID_JOYPAD_L, BTN_ID_L1}, + {"R1", RETRO_DEVICE_ID_JOYPAD_R, BTN_ID_R1}, + {"L2", RETRO_DEVICE_ID_JOYPAD_L2, BTN_ID_L2}, + {"R2", RETRO_DEVICE_ID_JOYPAD_R2, BTN_ID_R2}, + {"L3", RETRO_DEVICE_ID_JOYPAD_L3, BTN_ID_L3}, + {"R3", RETRO_DEVICE_ID_JOYPAD_R3, BTN_ID_R3}, + {NULL,0,0} +}; + +static int Config_getValue(char* cfg, const char* key, char* out_value, int* lock) { // gets value from string + char* tmp = cfg; + while ((tmp = strstr(tmp, key))) { + if (lock!=NULL && tmp>cfg && *(tmp-1)=='-') *lock = 1; // prefixed with a `-` means lock + tmp += strlen(key); + if (!strncmp(tmp, " = ", 3)) break; // matched + }; + if (!tmp) return 0; + tmp += 3; + + strncpy(out_value, tmp, 256); + out_value[256 - 1] = '\0'; + tmp = strchr(out_value, '\n'); + if (!tmp) tmp = strchr(out_value, '\r'); + if (tmp) *tmp = '\0'; + + // LOG_info("\t%s = %s (%s)\n", key, out_value, (lock && *lock) ? "hidden":"shown"); + return 1; +} + +void updateCPUMonitor(void) { + Perf_setCPUMonitorEnabled(show_debug); + if (!show_debug) return; + + pthread_t cpucheckthread; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + if (pthread_create(&cpucheckthread, &attr, PLAT_cpu_monitor, NULL) != 0) { + LOG_info("WARNING: failed to start CPU monitor thread\n"); + Perf_setCPUMonitorEnabled(0); + } + pthread_attr_destroy(&attr); +} + +void setOverclock(int i) { + overclock = i; + PWR_setCPUSpeed(i); +} +void Config_syncFrontend(char* key, int value) { + int i = -1; + if (exactMatch(key,config.frontend.options[FE_OPT_SCALING].key)) { + screen_scaling = value; + + renderer.dst_p = 0; + i = FE_OPT_SCALING; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_RESAMPLING].key)) { + resampling_quality = value; + SND_setQuality(resampling_quality); + i = FE_OPT_RESAMPLING; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_AMBIENT].key)) { + ambient_mode = value; + if(ambient_mode > 0) + LEDS_pushProfileOverride(LIGHT_PROFILE_AMBIENT); + else + LEDS_popProfileOverride(LIGHT_PROFILE_AMBIENT); + i = FE_OPT_AMBIENT; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_EFFECT].key)) { + screen_effect = value; + GFX_setEffect(value); + renderer.dst_p = 0; + i = FE_OPT_EFFECT; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_OVERLAY].key)) { + char** overlayList = config.frontend.options[FE_OPT_OVERLAY].values; + if (overlayList) { + + int count = 0; + while (overlayList && overlayList[count]) count++; + if (value >= 0 && value < count) { + LOG_info("minarch: updating overlay - %s\n", overlayList[value]); + GFX_setOverlay(overlayList[value], core.tag); + overlay = value; + renderer.dst_p = 0; + i = FE_OPT_OVERLAY; + } + } + } + else if (exactMatch(key,config.frontend.options[FE_OPT_SCREENX].key)) { + cfg_screenx = value; + GFX_setOffsetX(value); + i = FE_OPT_SCREENX; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_SCREENY].key)) { + cfg_screeny = value; + GFX_setOffsetY(value); + i = FE_OPT_SCREENY; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_SHARPNESS].key)) { + GFX_setSharpness(value); + i = FE_OPT_SHARPNESS; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_SYNC_REFERENCE].key)) { + sync_ref = value; + i = FE_OPT_SYNC_REFERENCE; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_OVERCLOCK].key)) { + overclock = value; + i = FE_OPT_OVERCLOCK; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_DEBUG].key)) { + int prev_show_debug = show_debug; + show_debug = value; + if (prev_show_debug != show_debug) updateCPUMonitor(); + i = FE_OPT_DEBUG; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_MAXFF].key)) { + max_ff_speed = value; + i = FE_OPT_MAXFF; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_FF_AUDIO].key)) { + ff_audio = value; + i = FE_OPT_FF_AUDIO; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_ENABLE].key)) { + i = FE_OPT_REWIND_ENABLE; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_BUFFER].key)) { + i = FE_OPT_REWIND_BUFFER; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_GRANULARITY].key)) { + i = FE_OPT_REWIND_GRANULARITY; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_AUDIO].key)) { + i = FE_OPT_REWIND_AUDIO; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_COMPRESSION].key)) { + i = FE_OPT_REWIND_COMPRESSION; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_COMPRESSION_ACCEL].key)) { + i = FE_OPT_REWIND_COMPRESSION_ACCEL; + } + if (i==-1) return; + Option* option = &config.frontend.options[i]; + option->value = value; + if (i==FE_OPT_REWIND_ENABLE || i==FE_OPT_REWIND_BUFFER || i==FE_OPT_REWIND_GRANULARITY || i==FE_OPT_REWIND_AUDIO || i==FE_OPT_REWIND_COMPRESSION || i==FE_OPT_REWIND_COMPRESSION_ACCEL) { + const char* sval = option->values && option->values[value] ? option->values[value] : "0"; + int parsed = 0; + if (i==FE_OPT_REWIND_ENABLE || i==FE_OPT_REWIND_AUDIO || i==FE_OPT_REWIND_COMPRESSION) { + // use option index (Off/On) + parsed = value; + } + else { + parsed = strtol(sval, NULL, 10); + } + switch (i) { + case FE_OPT_REWIND_ENABLE: rewind_cfg_enable = parsed; break; + case FE_OPT_REWIND_BUFFER: rewind_cfg_buffer_mb = parsed; break; + case FE_OPT_REWIND_GRANULARITY: rewind_cfg_granularity = parsed; break; + case FE_OPT_REWIND_AUDIO: rewind_cfg_audio = parsed; break; + case FE_OPT_REWIND_COMPRESSION: rewind_cfg_compress = parsed; break; + case FE_OPT_REWIND_COMPRESSION_ACCEL: rewind_cfg_lz4_acceleration = parsed; break; + } + // Only call Rewind_init if core is initialized; early config reads happen before + // the core is ready and will be followed by an explicit Rewind_init later + if (core.initialized) { + Rewind_init(core.serialize_size ? core.serialize_size() : 0); + } + if (i==FE_OPT_REWIND_ENABLE) { + // ensure runtime toggles don't linger when enabling/disabling feature + rewind_toggle = 0; + rewind_pressed = 0; + Rewind_sync_encode_state(); + rewinding = 0; + ff_paused_by_rewind_hold = 0; + } + } +} + +// ensure live gameplay immediately picks up scaler/effect changes triggered via shortcuts +static void apply_live_video_reset(void) { + // defer work to the video thread: mark scaler dirty (shader reset not needed here) + renderer.dst_p = 0; + // If shaders are disabled (0 passes), force a reset so the default pipeline rebuilds + if (config.shaders.options[SH_NROFSHADERS].value == 0) { + GFX_resetShaders(); + shader_reset_suppressed = 0; + } else { + shader_reset_suppressed = 1; // skip reset for >0 shader pipelines + } +} + +char** list_files_in_folder(const char* folderPath, int* fileCount, const char* defaultElement, const char* extensionFilter) { + DIR* dir = opendir(folderPath); + if (!dir) { + perror("opendir"); + return NULL; + } + + struct dirent* entry; + struct stat fileStat; + char** fileList = NULL; + *fileCount = 0; + + if(defaultElement) { + fileList = malloc(sizeof(char* ) * 2); + fileList[0] = strdup(defaultElement); + fileList[1] = NULL; + (*fileCount)++; + } + + while ((entry = readdir(dir)) != NULL) { + // skip all entries starting with ._ (hidden files on macOS) + if (entry->d_name[0] == '.' && entry->d_name[1] == '_') + continue; + // skip macOS .DS_Store files + if (strcmp(entry->d_name, ".DS_Store") == 0) + continue; + + char fullPath[1024]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", folderPath, entry->d_name); + + if (stat(fullPath, &fileStat) == 0 && S_ISREG(fileStat.st_mode)) { + if (extensionFilter) { + const char* ext = strrchr(entry->d_name, '.'); + if (!ext || strcmp(ext, extensionFilter) != 0) { + continue; + } + } + + char** temp = realloc(fileList, sizeof(char*) * (*fileCount + 1)); + if (!temp) { + perror("realloc"); + for (int i = 0; i < *fileCount; ++i) { + free(fileList[i]); + } + free(fileList); + closedir(dir); + return NULL; + } + fileList = temp; + fileList[*fileCount] = strdup(entry->d_name); + (*fileCount)++; + } + } + + closedir(dir); + + // Alphabetical sort + for (int i = 0; i < *fileCount - 1; ++i) { + for (int j = i + 1; j < *fileCount; ++j) { + if (strcmp(fileList[i], fileList[j]) > 0) { + char* temp = fileList[i]; + fileList[i] = fileList[j]; + fileList[j] = temp; + } + } + } + + // NUll terminate the list + char** temp = realloc(fileList, sizeof(char*) * (*fileCount + 1)); + if (!temp) { + perror("realloc"); + for (int i = 0; i < *fileCount; ++i) { + free(fileList[i]); + } + free(fileList); + return NULL; + } + fileList = temp; + fileList[*fileCount] = NULL; + + return fileList; +} + +// CONFIG_WRITE_ALL, CONFIG_WRITE_GAME defined in minarch_internal.h +static void Config_getPath(char* filename, int override) { + char device_tag[64] = {0}; + if (config.device_tag) sprintf(device_tag,"-%s",config.device_tag); + if (override) sprintf(filename, "%s/%s%s.cfg", core.config_dir, game.alt_name, device_tag); + else sprintf(filename, "%s/minarch%s.cfg", core.config_dir, device_tag); + LOG_info("Config_getPath %s\n", filename); +} +void Config_init(void) { + if (!config.default_cfg || config.initialized) return; + + LOG_info("Config_init\n"); + char* tmp = config.default_cfg; + char* tmp2; + char* key; + + char button_name[128]; + char button_id[128]; + int i = 0; + while ((tmp = strstr(tmp, "bind "))) { + tmp += 5; // tmp now points to the button name (plus the rest of the line) + key = tmp; + tmp = strstr(tmp, " = "); + if (!tmp) break; + + int len = tmp-key; + strncpy(button_name, key, len); + button_name[len] = '\0'; + + tmp += 3; + strncpy(button_id, tmp, 128); + tmp2 = strchr(button_id, '\n'); + if (!tmp2) tmp2 = strchr(button_id, '\r'); + if (tmp2) *tmp2 = '\0'; + + int retro_id = -1; + int local_id = -1; + + tmp2 = strrchr(button_id, ':'); + int remap = 0; + if (tmp2) { + for (int j=0; button_label_mapping[j].name; j++) { + ButtonMapping* button = &button_label_mapping[j]; + if (!strcmp(tmp2+1,button->name)) { + retro_id = button->retro; + break; + } + } + *tmp2 = '\0'; + } + for (int j=0; button_label_mapping[j].name; j++) { + ButtonMapping* button = &button_label_mapping[j]; + if (!strcmp(button_id,button->name)) { + local_id = button->local; + if (retro_id==-1) retro_id = button->retro; + break; + } + } + + tmp += strlen(button_id); // prepare to continue search + + LOG_info("\tbind %s (%s) %i:%i\n", button_name, button_id, local_id, retro_id); + + // TODO: test this without a final line return + tmp2 = calloc(strlen(button_name)+1, sizeof(char)); + strcpy(tmp2, button_name); + ButtonMapping* button = &core_button_mapping[i++]; + button->name = tmp2; + button->retro = retro_id; + button->local = local_id; + }; + + // populate shader presets + // TODO: None option? + int preset_filecount; + char** preset_filelist = list_files_in_folder(SHADERS_FOLDER, &preset_filecount, NULL, ".cfg"); + config.shaders.options[SH_SHADERS_PRESET].values = preset_filelist; + + // populate shader options + // TODO: None option? + // TODO: Why do we do this twice? (see OptionShaders_openMenu) + int filecount; + char** filelist = list_files_in_folder(SHADERS_FOLDER "/glsl", &filecount, NULL, NULL); + + config.shaders.options[SH_SHADER1].values = filelist; + config.shaders.options[SH_SHADER1].labels = filelist; + config.shaders.options[SH_SHADER1].count = filecount; + + config.shaders.options[SH_SHADER2].values = filelist; + config.shaders.options[SH_SHADER2].labels = filelist; + config.shaders.options[SH_SHADER2].count = filecount; + + config.shaders.options[SH_SHADER3].values = filelist; + config.shaders.options[SH_SHADER3].labels = filelist; + config.shaders.options[SH_SHADER3].count = filecount; + + char overlaypath[MAX_PATH]; + snprintf(overlaypath, sizeof(overlaypath), "%s/%s", OVERLAYS_FOLDER, core.tag); + char** overlaylist = list_files_in_folder(overlaypath, &filecount, "None", NULL); + + if (overlaylist) { + config.frontend.options[FE_OPT_OVERLAY].labels = overlaylist; + config.frontend.options[FE_OPT_OVERLAY].values = overlaylist; + config.frontend.options[FE_OPT_OVERLAY].count = filecount; + } + config.initialized = 1; +} +void Config_quit(void) { + if (!config.initialized) return; + for (int i=0; core_button_mapping[i].name; i++) { + free(core_button_mapping[i].name); + } +} +static void Config_readOptionsString(char* cfg) { + if (!cfg) return; + + LOG_info("Config_readOptions\n"); + char key[256]; + char value[256]; + for (int i=0; config.frontend.options[i].key; i++) { + Option* option = &config.frontend.options[i]; + if (!Config_getValue(cfg, option->key, value, &option->lock)) continue; + OptionList_setOptionValue(&config.frontend, option->key, value); + Config_syncFrontend(option->key, option->value); + } + + if (has_custom_controllers && Config_getValue(cfg,"minarch_gamepad_type",value,NULL)) { + gamepad_type = strtol(value, NULL, 0); + int device = strtol(gamepad_values[gamepad_type], NULL, 0); + core.set_controller_port_device(0, device); + } + for (int i=0; config.core.options[i].key; i++) { + Option* option = &config.core.options[i]; + // LOG_info("%s\n",option->key); + if (!Config_getValue(cfg, option->key, value, &option->lock)) continue; + OptionList_setOptionValue(&config.core, option->key, value); + } + for (int i=0; config.shaders.options[i].key; i++) { + Option* option = &config.shaders.options[i]; + if (!Config_getValue(cfg, option->key, value, &option->lock)) continue; + OptionList_setOptionValue(&config.shaders, option->key, value); + } + for (int y=0; y < config.shaders.options[SH_NROFSHADERS].value; y++) { + if(config.shaderpragmas[y].count > 0) { + for (int i=0; config.shaderpragmas[y].options[i].key; i++) { + Option* option = &config.shaderpragmas[y].options[i]; + if (!Config_getValue(cfg, option->key, value, &option->lock)) continue; + OptionList_setOptionValue(&config.shaderpragmas[y], option->key, value); + } + } + } +} +static void Config_readControlsString(char* cfg) { + if (!cfg) return; + + LOG_info("Config_readControlsString\n"); + + char key[256]; + char value[256]; + char* tmp; + for (int i=0; config.controls[i].name; i++) { + ButtonMapping* mapping = &config.controls[i]; + sprintf(key, "bind %s", mapping->name); + sprintf(value, "NONE"); + + if (!Config_getValue(cfg, key, value, NULL)) continue; + if ((tmp = strrchr(value, ':'))) *tmp = '\0'; // this is a binding artifact in default.cfg, ignore + + int id = -1; + for (int j=0; button_labels[j]; j++) { + if (!strcmp(button_labels[j],value)) { + id = j - 1; + break; + } + } + // LOG_info("\t%s (%i)\n", value, id); + + int mod = 0; + if (id>=LOCAL_BUTTON_COUNT) { + id -= LOCAL_BUTTON_COUNT; + mod = 1; + } + + mapping->local = id; + mapping->mod = mod; + } + + for (int i=0; config.shortcuts[i].name; i++) { + ButtonMapping* mapping = &config.shortcuts[i]; + sprintf(key, "bind %s", mapping->name); + sprintf(value, "NONE"); + + if (!Config_getValue(cfg, key, value, NULL)) continue; + + int id = -1; + for (int j=0; button_labels[j]; j++) { + if (!strcmp(button_labels[j],value)) { + id = j - 1; + break; + } + } + + int mod = 0; + if (id>=LOCAL_BUTTON_COUNT) { + id -= LOCAL_BUTTON_COUNT; + mod = 1; + } + // LOG_info("shortcut %s:%s (%i:%i)\n", key,value, id, mod); + + mapping->local = id; + mapping->mod = mod; + } +} +void Config_load(void) { + LOG_info("Config_load\n"); + + config.device_tag = getenv("DEVICE"); + LOG_info("config.device_tag %s\n", config.device_tag); + + // update for crop overscan support + Option* scaling_option = &config.frontend.options[FE_OPT_SCALING]; + scaling_option->desc = getScreenScalingDesc(); + scaling_option->count = getScreenScalingCount(); + if (!GFX_supportsOverscan()) { + scaling_labels[4] = NULL; + } + + char* system_path = SYSTEM_PATH "/system.cfg"; + + char device_system_path[MAX_PATH] = {0}; + if (config.device_tag) sprintf(device_system_path, SYSTEM_PATH "/system-%s.cfg", config.device_tag); + + if (config.device_tag && exists(device_system_path)) { + LOG_info("usng device_system_path: %s\n", device_system_path); + config.system_cfg = allocFile(device_system_path); + } + else if (exists(system_path)) config.system_cfg = allocFile(system_path); + else config.system_cfg = NULL; + + // LOG_info("config.system_cfg: %s\n", config.system_cfg); + + char default_path[MAX_PATH]; + getEmuPath((char *)core.tag, default_path); + char* tmp = strrchr(default_path, '/'); + strcpy(tmp,"/default.cfg"); + + char device_default_path[MAX_PATH] = {0}; + if (config.device_tag) { + getEmuPath((char *)core.tag, device_default_path); + tmp = strrchr(device_default_path, '/'); + char filename[64]; + sprintf(filename,"/default-%s.cfg", config.device_tag); + strcpy(tmp,filename); + } + + if (config.device_tag && exists(device_default_path)) { + LOG_info("usng device_default_path: %s\n", device_default_path); + config.default_cfg = allocFile(device_default_path); + } + else if (exists(default_path)) config.default_cfg = allocFile(default_path); + else config.default_cfg = NULL; + + // LOG_info("config.default_cfg: %s\n", config.default_cfg); + + char path[MAX_PATH]; + config.loaded = CONFIG_NONE; + int override = 0; + Config_getPath(path, CONFIG_WRITE_GAME); + if (exists(path)) override = 1; + if (!override) Config_getPath(path, CONFIG_WRITE_ALL); + + config.user_cfg = allocFile(path); + if (!config.user_cfg) return; + + LOG_info("using user config: %s\n", path); + + config.loaded = override ? CONFIG_GAME : CONFIG_CONSOLE; +} +void Config_free(void) { + if (config.system_cfg) free(config.system_cfg); + if (config.default_cfg) free(config.default_cfg); + if (config.user_cfg) free(config.user_cfg); +} +void Config_readOptions(void) { + Config_readOptionsString(config.system_cfg); + Config_readOptionsString(config.default_cfg); + Config_readOptionsString(config.user_cfg); +} +void Config_readControls(void) { + Config_readControlsString(config.default_cfg); + Config_readControlsString(config.user_cfg); +} +void Config_write(int override) { + char path[MAX_PATH]; + // sprintf(path, "%s/%s.cfg", core.config_dir, game.alt_name); + Config_getPath(path, CONFIG_WRITE_GAME); + + if (!override) { + if (config.loaded==CONFIG_GAME) unlink(path); + Config_getPath(path, CONFIG_WRITE_ALL); + } + config.loaded = override ? CONFIG_GAME : CONFIG_CONSOLE; + + FILE *file = fopen(path, "wb"); + if (!file) return; + + for (int i=0; config.frontend.options[i].key; i++) { + Option* option = &config.frontend.options[i]; + int count = 0; + while ( option->values && option->values[count]) count++; + if (option->value >= 0 && option->value < count) { + fprintf(file, "%s = %s\n", option->key, option->values[option->value]); + } + } + for (int i=0; config.core.options[i].key; i++) { + Option* option = &config.core.options[i]; + fprintf(file, "%s = %s\n", option->key, option->values[option->value]); + } + for (int i=0; config.shaders.options[i].key; i++) { + Option* option = &config.shaders.options[i]; + int count = 0; + while ( option->values && option->values[count]) count++; + if (option->value >= 0 && option->value < count) { + fprintf(file, "%s = %s\n", option->key, option->values[option->value]); + } + } + for (int y=0; y < config.shaders.options[SH_NROFSHADERS].value; y++) { + for (int i=0; config.shaderpragmas[y].options[i].key; i++) { + Option* option = &config.shaderpragmas[y].options[i]; + int count = 0; + while ( option->values && option->values[count]) count++; + if (option->value >= 0 && option->value < count) { + fprintf(file, "%s = %s\n", option->key, option->values[option->value]); + } + } + } + + if (has_custom_controllers) fprintf(file, "%s = %i\n", "minarch_gamepad_type", gamepad_type); + + for (int i=0; config.controls[i].name; i++) { + ButtonMapping* mapping = &config.controls[i]; + int j = mapping->local + 1; + if (mapping->mod) j += LOCAL_BUTTON_COUNT; + fprintf(file, "bind %s = %s\n", mapping->name, button_labels[j]); + } + for (int i=0; config.shortcuts[i].name; i++) { + ButtonMapping* mapping = &config.shortcuts[i]; + int j = mapping->local + 1; + if (mapping->mod) j += LOCAL_BUTTON_COUNT; + fprintf(file, "bind %s = %s\n", mapping->name, button_labels[j]); + } + + fclose(file); + sync(); +} +void Config_restore(void) { + char path[MAX_PATH]; + if (config.loaded==CONFIG_GAME) { + if (config.device_tag) sprintf(path, "%s/%s-%s.cfg", core.config_dir, game.alt_name, config.device_tag); + else sprintf(path, "%s/%s.cfg", core.config_dir, game.alt_name); + unlink(path); + LOG_info("deleted game config: %s\n", path); + } + else if (config.loaded==CONFIG_CONSOLE) { + if (config.device_tag) sprintf(path, "%s/minarch-%s.cfg", core.config_dir, config.device_tag); + else sprintf(path, "%s/minarch.cfg", core.config_dir); + unlink(path); + LOG_info("deleted console config: %s\n", path); + } + config.loaded = CONFIG_NONE; + + for (int i=0; config.frontend.options[i].key; i++) { + Option* option = &config.frontend.options[i]; + option->value = option->default_value; + Config_syncFrontend(option->key, option->value); + } + for (int i=0; config.core.options[i].key; i++) { + Option* option = &config.core.options[i]; + option->value = option->default_value; + } + for (int i=0; config.shaders.options[i].key; i++) { + Option* option = &config.shaders.options[i]; + option->value = option->default_value; + } + config.core.changed = 1; // let the core know + + if (has_custom_controllers) { + gamepad_type = 0; + core.set_controller_port_device(0, RETRO_DEVICE_JOYPAD); + } + + for (int i=0; config.controls[i].name; i++) { + ButtonMapping* mapping = &config.controls[i]; + mapping->local = mapping->default_; + mapping->mod = 0; + } + for (int i=0; config.shortcuts[i].name; i++) { + ButtonMapping* mapping = &config.shortcuts[i]; + mapping->local = BTN_ID_NONE; + mapping->mod = 0; + } + + Config_load(); + Config_readOptions(); + Config_readControls(); + Config_free(); + + renderer.dst_p = 0; +} + +void readShadersPreset(int i) { + char shaderspath[MAX_PATH] = {0}; + sprintf(shaderspath, SHADERS_FOLDER "/%s", config.shaders.options[SH_SHADERS_PRESET].values[i]); + LOG_info("read shaders preset %s\n",shaderspath); + if (exists(shaderspath)) { + config.shaders_preset = allocFile(shaderspath); + Config_readOptionsString(config.shaders_preset); + } + else config.shaders_preset = NULL; +} + +void loadShaderSettings(int i) { + int menucount = 0; + config.shaderpragmas[i].options = calloc(32 + 1, sizeof(Option)); + ShaderParam *params = PLAT_getShaderPragmas(i); + if(params == NULL) return; + for (int j = 0; j < 32; j++) { + + if (params[j].step == 0.0f) { + // Prevent division by zero; skip this parameter or set steps to 1 + continue; + } + + if (!params[j].name || strlen(params[j].name) == 0) { + // Skip invalid parameter names + continue; + } + config.shaderpragmas[i].options[menucount].key = params[j].name; + config.shaderpragmas[i].options[menucount].name = params[j].name; + config.shaderpragmas[i].options[menucount].desc = params[j].name; + config.shaderpragmas[i].options[menucount].default_value = params[j].def; + + int steps = (int)((params[j].max - params[j].min) / params[j].step) + 1; + config.shaderpragmas[i].options[menucount].values = malloc(sizeof(char *) * (steps + 1)); + config.shaderpragmas[i].options[menucount].labels = malloc(sizeof(char *) * (steps + 1)); + for (int s = 0; s < steps; s++) { + float val = params[j].min + s * params[j].step; + char *str = malloc(16); + snprintf(str, 16, "%.2f", val); + config.shaderpragmas[i].options[menucount].values[s] = str; + config.shaderpragmas[i].options[menucount].labels[s] = str; + if (fabs(params[j].value - val) < 0.001f) + config.shaderpragmas[i].options[menucount].value = s; + } + config.shaderpragmas[i].options[menucount].count = steps; + config.shaderpragmas[i].options[menucount].values[steps] = NULL; + config.shaderpragmas[i].options[menucount].labels[steps] = NULL; + menucount++; + + } + config.shaderpragmas[i].count = menucount; +} + +void Config_syncShaders(char* key, int value) { + int i = -1; + if (exactMatch(key,config.shaders.options[SH_SHADERS_PRESET].key)) { + readShadersPreset(value); + i = SH_SHADERS_PRESET; + } + else if (exactMatch(key,config.shaders.options[SH_NROFSHADERS].key)) { + GFX_setShaders(value); + i = SH_NROFSHADERS; + } + else if (exactMatch(key, config.shaders.options[SH_SHADER1].key)) { + char** shaderList = config.shaders.options[SH_SHADER1].values; + if (shaderList) { + LOG_info("minarch: updating shader 1 - %i\n",value); + int count = 0; + while (shaderList && shaderList[count]) count++; + if (value >= 0 && value < count) { + GFX_updateShader(0, shaderList[value], NULL, NULL,NULL,NULL); + i = SH_SHADER1; + } + } + loadShaderSettings(0); + } + else if (exactMatch(key,config.shaders.options[SH_SHADER1_FILTER].key)) { + GFX_updateShader(0,NULL,NULL,&value,NULL,NULL); + i = SH_SHADER1_FILTER; + } + else if (exactMatch(key,config.shaders.options[SH_SRCTYPE1].key)) { + GFX_updateShader(0,NULL,NULL,NULL,NULL,&value); + i = SH_SRCTYPE1; + } + if (exactMatch(key,config.shaders.options[SH_SCALETYPE1].key)) { + GFX_updateShader(0,NULL,NULL,NULL,&value,NULL); + i = SH_SCALETYPE1; + } + else if (exactMatch(key,config.shaders.options[SH_UPSCALE1].key)) { + GFX_updateShader(0,NULL,&value,NULL,NULL,NULL); + i = SH_UPSCALE1; + } + else if (exactMatch(key, config.shaders.options[SH_SHADER2].key)) { + char** shaderList = config.shaders.options[SH_SHADER2].values; + if (shaderList) { + LOG_info("minarch: updating shader 2 - %i\n",value); + int count = 0; + while (shaderList && shaderList[count]) count++; + if (value >= 0 && value < count) { + GFX_updateShader(1, shaderList[value], NULL, NULL,NULL,NULL); + i = SH_SHADER2; + } + } + loadShaderSettings(1); + } + else if (exactMatch(key,config.shaders.options[SH_SHADER2_FILTER].key)) { + GFX_updateShader(1,NULL,NULL,&value,NULL,NULL); + i = SH_SHADER2_FILTER; + } + else if (exactMatch(key,config.shaders.options[SH_SRCTYPE2].key)) { + GFX_updateShader(1,NULL,NULL,NULL,NULL,&value); + i = SH_SRCTYPE2; + } + else if (exactMatch(key,config.shaders.options[SH_SCALETYPE2].key)) { + GFX_updateShader(1,NULL,NULL,NULL,&value,NULL); + i = SH_SCALETYPE2; + } + else if (exactMatch(key,config.shaders.options[SH_UPSCALE2].key)) { + GFX_updateShader(1,NULL,&value,NULL,NULL,NULL); + i = SH_UPSCALE2; + } + else if (exactMatch(key, config.shaders.options[SH_SHADER3].key)) { + char** shaderList = config.shaders.options[SH_SHADER3].values; + if (shaderList) { + LOG_info("minarch: updating shader 3 - %i\n",value); + int count = 0; + while (shaderList && shaderList[count]) count++; + if (value >= 0 && value < count) { + GFX_updateShader(2, shaderList[value], NULL, NULL,NULL,NULL); + i = SH_SHADER3; + } + } + loadShaderSettings(2); + } + else if (exactMatch(key,config.shaders.options[SH_SHADER3_FILTER].key)) { + GFX_updateShader(2,NULL,NULL,&value,NULL,NULL); + i = SH_SHADER3_FILTER; + } + if (exactMatch(key,config.shaders.options[SH_SRCTYPE3].key)) { + GFX_updateShader(2,NULL,NULL,NULL,NULL,&value); + i = SH_SRCTYPE3; + } + else if (exactMatch(key,config.shaders.options[SH_SCALETYPE3].key)) { + GFX_updateShader(2,NULL,NULL,NULL,&value,NULL); + i = SH_SCALETYPE3; + } + else if (exactMatch(key,config.shaders.options[SH_UPSCALE3].key)) { + GFX_updateShader(2,NULL,&value,NULL,NULL,NULL); + i = SH_UPSCALE3; + } + + if (i==-1) return; + Option* option = &config.shaders.options[i]; + option->value = value; +} + +//////// + +void applyShaderSettings() { + for (int y=0; y < config.shaders.options[SH_NROFSHADERS].value; y++) { + ShaderParam *params = PLAT_getShaderPragmas(y); + if (params == NULL) { + break; + } + for (int i=0; i < config.shaderpragmas[y].count; i++) { + for (int j = 0; j < 32; j++) { + if (exactMatch(params[j].name, config.shaderpragmas[y].options[i].key)) { + params[j].value = strtof(config.shaderpragmas[y].options[i].values[config.shaderpragmas[y].options[i].value], NULL); + } + } + } + } +} +void initShaders() { + for (int i=0; config.shaders.options[i].key; i++) { + if(i!=SH_SHADERS_PRESET) { + Option* option = &config.shaders.options[i];; + Config_syncShaders(option->key, option->value); + } + } +} + +/* ----------------------------------------------------------------------- + Config data: label arrays, button mappings, and struct Config initializer. + Moved from minarch.c; these are the static data backing the config module. + ----------------------------------------------------------------------- */ + +char* onoff_labels[] = { + "Off", + "On", + NULL +}; +char* scaling_labels[] = { + "Native", + "Aspect", + "Aspect Screen", + "Fullscreen", + "Cropped", + NULL +}; +static char* resample_labels[] = { + "Low", + "Medium", + "High", + "Max", + NULL +}; +static char* rewind_enable_labels[] = { + "Off", + "On", + NULL +}; +static char* rewind_buffer_labels[] = { + "8", + "16", + "32", + "64", + "128", + "256", + NULL +}; +static char* rewind_granularity_values[] = { + "16", + "22", + "25", + "33", + "50", + "66", + "100", + "150", + "200", + "300", + "450", + "600", + NULL +}; +static char* rewind_granularity_labels[] = { + "16 ms (~60 fps)", + "22 ms (~45 fps)", + "25 ms (~40 fps)", + "33 ms (~30 fps)", + "50 ms (~20 fps)", + "66 ms (~15 fps)", + "100 ms (~10 fps)", + "150 ms (~7 fps)", + "200 ms (~5 fps)", + "300 ms", + "450 ms", + "600 ms", + NULL +}; +static char* rewind_compression_accel_values[] = { + "1", + "2", + "4", + "8", + "12", + NULL +}; +static char* rewind_compression_accel_labels[] = { + "1 (best ratio)", + "2 (default)", + "4 (fast)", + "8 (faster)", + "12 (fastest)", + NULL +}; +static char* ambient_labels[] = { + "Off", + "All", + "Top", + "FN", + "LR", + "Top/LR", + NULL +}; + +static char* effect_labels[] = { + "None", + "Line", + "Grid", + NULL +}; +static char* overlay_labels[] = { + "None", + NULL +}; +// static char* sharpness_labels[] = { +// "Sharp", +// "Crisp", +// "Soft", +// NULL +// }; +static char* sharpness_labels[] = { + "NEAREST", + "LINEAR", + NULL +}; +char* sync_ref_labels[] = { + "Auto", + "Screen", + "Native", + NULL +}; +static char* max_ff_labels[] = { + "None", + "2x", + "3x", + "4x", + "5x", + "6x", + "7x", + "8x", + NULL, +}; +static char* offset_labels[] = { + "-64", + "-63", + "-62", + "-61", + "-60", + "-59", + "-58", + "-57", + "-56", + "-55", + "-54", + "-53", + "-52", + "-51", + "-50", + "-49", + "-48", + "-47", + "-46", + "-45", + "-44", + "-43", + "-42", + "-41", + "-40", + "-39", + "-38", + "-37", + "-36", + "-35", + "-34", + "-33", + "-32", + "-31", + "-30", + "-29", + "-28", + "-27", + "-26", + "-25", + "-24", + "-23", + "-22", + "-21", + "-20", + "-19", + "-18", + "-17", + "-16", + "-15", + "-14", + "-13", + "-12", + "-11", + "-10", + "-9", + "-8", + "-7", + "-6", + "-5", + "-4", + "-3", + "-2", + "-1", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "51", + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "60", + "61", + "62", + "63", + "64", + NULL, +}; +static char* nrofshaders_labels[] = { + "off", + "1", + "2", + "3", + NULL +}; +static char* shupscale_labels[] = { + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "screen", + NULL +}; +static char* shfilter_labels[] = { + "NEAREST", + "LINEAR", + NULL +}; +static char* shscaletype_labels[] = { + "source", + "relative", + NULL +}; + +// SYNC_SRC_AUTO, SYNC_SRC_SCREEN, SYNC_SRC_CORE defined in minarch_internal.h +// SH_EXTRASETTINGS..SH_NONE defined in minarch_internal.h + + +ButtonMapping default_button_mapping[] = { // used if pak.cfg doesn't exist or doesn't have bindings + {"Up", RETRO_DEVICE_ID_JOYPAD_UP, BTN_ID_DPAD_UP}, + {"Down", RETRO_DEVICE_ID_JOYPAD_DOWN, BTN_ID_DPAD_DOWN}, + {"Left", RETRO_DEVICE_ID_JOYPAD_LEFT, BTN_ID_DPAD_LEFT}, + {"Right", RETRO_DEVICE_ID_JOYPAD_RIGHT, BTN_ID_DPAD_RIGHT}, + {"A Button", RETRO_DEVICE_ID_JOYPAD_A, BTN_ID_A}, + {"B Button", RETRO_DEVICE_ID_JOYPAD_B, BTN_ID_B}, + {"X Button", RETRO_DEVICE_ID_JOYPAD_X, BTN_ID_X}, + {"Y Button", RETRO_DEVICE_ID_JOYPAD_Y, BTN_ID_Y}, + {"Start", RETRO_DEVICE_ID_JOYPAD_START, BTN_ID_START}, + {"Select", RETRO_DEVICE_ID_JOYPAD_SELECT, BTN_ID_SELECT}, + {"L1 Button", RETRO_DEVICE_ID_JOYPAD_L, BTN_ID_L1}, + {"R1 Button", RETRO_DEVICE_ID_JOYPAD_R, BTN_ID_R1}, + {"L2 Button", RETRO_DEVICE_ID_JOYPAD_L2, BTN_ID_L2}, + {"R2 Button", RETRO_DEVICE_ID_JOYPAD_R2, BTN_ID_R2}, + {"L3 Button", RETRO_DEVICE_ID_JOYPAD_L3, BTN_ID_L3}, + {"R3 Button", RETRO_DEVICE_ID_JOYPAD_R3, BTN_ID_R3}, + {NULL,0,0} +}; + +ButtonMapping core_button_mapping[RETRO_BUTTON_COUNT+1] = {0}; + +static const char* device_button_names[LOCAL_BUTTON_COUNT] = { + [BTN_ID_DPAD_UP] = "UP", + [BTN_ID_DPAD_DOWN] = "DOWN", + [BTN_ID_DPAD_LEFT] = "LEFT", + [BTN_ID_DPAD_RIGHT] = "RIGHT", + [BTN_ID_SELECT] = "SELECT", + [BTN_ID_START] = "START", + [BTN_ID_Y] = "Y", + [BTN_ID_X] = "X", + [BTN_ID_B] = "B", + [BTN_ID_A] = "A", + [BTN_ID_L1] = "L1", + [BTN_ID_R1] = "R1", + [BTN_ID_L2] = "L2", + [BTN_ID_R2] = "R2", + [BTN_ID_L3] = "L3", + [BTN_ID_R3] = "R3", +}; + + +// NOTE: these must be in BTN_ID_ order also off by 1 because of NONE (which is -1 in BTN_ID_ land) +char* button_labels[] = { + "NONE", // displayed by default + "UP", + "DOWN", + "LEFT", + "RIGHT", + "A", + "B", + "X", + "Y", + "START", + "SELECT", + "L1", + "R1", + "L2", + "R2", + "L3", + "R3", + "MENU+UP", + "MENU+DOWN", + "MENU+LEFT", + "MENU+RIGHT", + "MENU+A", + "MENU+B", + "MENU+X", + "MENU+Y", + "MENU+START", + "MENU+SELECT", + "MENU+L1", + "MENU+R1", + "MENU+L2", + "MENU+R2", + "MENU+L3", + "MENU+R3", + NULL, +}; +static char* overclock_labels[] = { + "Auto", + "Performance", + "Powersave", + NULL, +}; + +// TODO: this should be provided by the core +char* gamepad_labels[] = { + "Standard", + "DualShock", + NULL, +}; +char* gamepad_values[] = { + "1", + "517", + NULL, +}; + +// CONFIG_NONE, CONFIG_CONSOLE, CONFIG_GAME defined in minarch_internal.h + +char* getScreenScalingDesc(void) { + if (GFX_supportsOverscan()) { + return "Native uses integer scaling. Aspect uses core nreported aspect ratio.\nAspect screen uses screen aspect ratio\n Fullscreen has non-square\npixels. Cropped is integer scaled then cropped."; + } + else { + return "Native uses integer scaling.\nAspect uses core reported aspect ratio.\nAspect screen uses screen aspect ratio\nFullscreen has non-square pixels."; + } +} +int getScreenScalingCount(void) { + return GFX_supportsOverscan() ? 5 : 4; +} + + +struct Config config = { + .frontend = { // (OptionList) + .count = FE_OPT_COUNT, + .options = (Option[]){ + [FE_OPT_SCALING] = { + .key = "minarch_screen_scaling", + .name = "Screen Scaling", + .desc = NULL, // will call getScreenScalingDesc() + .default_value = 1, + .value = 1, + .count = 3, // will call getScreenScalingCount() + .values = scaling_labels, + .labels = scaling_labels, + }, + [FE_OPT_RESAMPLING] = { + .key = "minarch__resampling_quality", + .name = "Audio Resampling Quality", + .desc = "Resampling quality higher takes more CPU", + .default_value = 2, + .value = 2, + .count = 4, + .values = resample_labels, + .labels = resample_labels, + }, + [FE_OPT_AMBIENT] = { + .key = "minarch_ambient", + .name = "Ambient Mode", + .desc = "Makes your leds follow on screen colors", + .default_value = 0, + .value = 0, + .count = 6, + .values = ambient_labels, + .labels = ambient_labels, + }, + [FE_OPT_EFFECT] = { + .key = "minarch_screen_effect", + .name = "Screen Effect", + .desc = "Grid simulates an LCD grid.\nLine simulates CRT scanlines.\nEffects usually look best at native scaling.", + .default_value = 0, + .value = 0, + .count = 3, + .values = effect_labels, + .labels = effect_labels, + }, + [FE_OPT_OVERLAY] = { + .key = "minarch_overlay", + .name = "Overlay", + .desc = "Choose a custom overlay png from the Overlays folder", + .default_value = 0, + .value = 0, + .count = 1, + .values = overlay_labels, + .labels = overlay_labels, + }, + [FE_OPT_SCREENX] = { + .key = "minarch_screen_offsetx", + .name = "Offset screen X", + .desc = "Offset X pixels", + .default_value = 64, + .value = 64, + .count = 129, + .values = offset_labels, + .labels = offset_labels, + }, + [FE_OPT_SCREENY] = { + .key = "minarch_screen_offsety", + .name = "Offset screen Y", + .desc = "Offset Y pixels", + .default_value = 64, + .value = 64, + .count = 129, + .values = offset_labels, + .labels = offset_labels, + }, + [FE_OPT_SHARPNESS] = { + // .key = "minarch_screen_sharpness", + .key = "minarch_scale_filter", + .name = "Screen Sharpness", + .desc = "LINEAR smooths lines, but works better when final image is at higher resolution, so either core that outputs higher resolution or upscaling with shaders", + .default_value = 1, + .value = 1, + .count = 3, + .values = sharpness_labels, + .labels = sharpness_labels, + }, + [FE_OPT_SYNC_REFERENCE] = { + .key = "minarch_sync_reference", + .name = "Core Sync", + .desc = "Choose what should be used as a\nreference for the frame rate.\n\"Native\" uses the emulator frame rate,\n\"Screen\" uses the frame rate of the screen.", + .default_value = SYNC_SRC_AUTO, + .value = SYNC_SRC_AUTO, + .count = 3, + .values = sync_ref_labels, + .labels = sync_ref_labels, + }, + [FE_OPT_OVERCLOCK] = { + .key = "minarch_cpu_speed", + .name = "CPU Speed", + .desc = "Choose how the CPU scales.\nAuto is recommended for most users.", + .default_value = 0, + .value = 0, + .count = 3, + .values = overclock_labels, + .labels = overclock_labels, + }, + [FE_OPT_DEBUG] = { + .key = "minarch_debug_hud", + .name = "Debug HUD", + .desc = "Show frames per second, cpu load,\nresolution, and scaler information.", + .default_value = 0, + .value = 0, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, + [FE_OPT_MAXFF] = { + .key = "minarch_max_ff_speed", + .name = "Max FF Speed", + .desc = "Fast forward will not exceed the\nselected speed (but may be less\ndepending on game and emulator).", + .default_value = 3, // 4x + .value = 3, // 4x + .count = 8, + .values = max_ff_labels, + .labels = max_ff_labels, + }, + [FE_OPT_FF_AUDIO] = { + .key = "minarch__ff_audio", + .name = "Fast forward audio", + .desc = "Play or mute audio when fast forwarding.", + .default_value = 0, + .value = 0, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, + [FE_OPT_REWIND_ENABLE] = { + .key = "minarch_rewind_enable", + .name = "Rewind", + .desc = "Enable in-memory rewind buffer.\nMust set a shortcut to access rewind during gameplay.\nUses extra CPU and memory.", + .default_value = MINARCH_DEFAULT_REWIND_ENABLE ? 1 : 0, + .value = MINARCH_DEFAULT_REWIND_ENABLE ? 1 : 0, + .count = 2, + .values = rewind_enable_labels, + .labels = rewind_enable_labels, + }, + [FE_OPT_REWIND_BUFFER] = { + .key = "minarch_rewind_buffer_mb", + .name = "Rewind Buffer (MB)", + .desc = "Memory reserved for rewind snapshots.\nIncrease for longer rewind times.", + .default_value = 3, // 64MB + .value = 3, + .count = 6, + .values = rewind_buffer_labels, + .labels = rewind_buffer_labels, + }, + [FE_OPT_REWIND_GRANULARITY] = { + .key = "minarch_rewind_granularity", + .name = "Rewind Interval", + .desc = "Interval between rewind snapshots.\nShorter intervals improve smoothness during rewind,\nbut increase CPU and memory usage.", + .default_value = 0, // 16ms + .value = 0, + .count = 12, + .values = rewind_granularity_values, + .labels = rewind_granularity_labels, + }, + [FE_OPT_REWIND_COMPRESSION] = { + .key = "minarch_rewind_compression", + .name = "Rewind Compression", + .desc = "Compress rewind snapshots to save memory at the cost of CPU.", + .default_value = 1, + .value = 1, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, + [FE_OPT_REWIND_COMPRESSION_ACCEL] = { + .key = "minarch_rewind_compression_speed", + .name = "Rewind Compression Speed", + .desc = "LZ4 acceleration used for rewind snapshots.\nLower values compress more but use more CPU.", + .default_value = 1, // value 2 + .value = 1, + .count = 5, + .values = rewind_compression_accel_values, + .labels = rewind_compression_accel_labels, + }, + [FE_OPT_REWIND_AUDIO] = { + .key = "minarch_rewind_audio", + .name = "Rewind audio", + .desc = "Play or mute audio when rewinding.", + .default_value = MINARCH_DEFAULT_REWIND_AUDIO ? 1 : 0, + .value = MINARCH_DEFAULT_REWIND_AUDIO ? 1 : 0, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, + [FE_OPT_COUNT] = {NULL} + } + }, + .core = { // (OptionList) + .count = 0, + .options = (Option[]){ + {NULL}, + }, + }, + .shaders = { // (OptionList) + .count = 18, + .options = (Option[]){ + [SH_EXTRASETTINGS] = { + .key = "minarch_shaders_settings", + .name = "Optional Shaders Settings", + .desc = "If shaders have extra settings they will show up in this settings menu", + .default_value = 1, + .value = 1, + .count = 0, + .values = NULL, + .labels = NULL, + }, + [SH_SHADERS_PRESET] = { + .key = "minarch_shaders_preset", + .name = "Shader / Emulator Settings Preset", + .desc = "Load a premade shaders/emulators config.\nTo try out a preset, exit the game without saving settings!", + .default_value = 1, + .value = 1, + .count = 0, + .values = NULL, + .labels = NULL, + }, + [SH_NROFSHADERS] = { + .key = "minarch_nrofshaders", + .name = "Number of Shaders", + .desc = "Number of shaders 1 to 3", + .default_value = 0, + .value = 0, + .count = 4, + .values = nrofshaders_labels, + .labels = nrofshaders_labels, + }, + + [SH_SHADER1] = { + .key = "minarch_shader1", + .name = "Shader 1", + .desc = "Shader 1 program to run", + .default_value = 1, + .value = 1, + .count = 0, + .values = NULL, + .labels = NULL, + }, + [SH_SHADER1_FILTER] = { + .key = "minarch_shader1_filter", + .name = "Shader 1 Filter", + .desc = "Method of upscaling, NEAREST or LINEAR", + .default_value = 1, + .value = 1, + .count = 2, + .values = shfilter_labels, + .labels = shfilter_labels, + }, + [SH_SRCTYPE1] = { + .key = "minarch_shader1_srctype", + .name = "Shader 1 Source type", + .desc = "This will choose resolution source to scale from", + .default_value = 0, + .value = 0, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_SCALETYPE1] = { + .key = "minarch_shader1_scaletype", + .name = "Shader 1 Texture Type", + .desc = "This will choose resolution source to scale from", + .default_value = 1, + .value = 1, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_UPSCALE1] = { + .key = "minarch_shader1_upscale", + .name = "Shader 1 Scale", + .desc = "This will scale images x times,\nscreen scales to screens resolution (can hit performance)", + .default_value = 1, + .value = 1, + .count = 9, + .values = shupscale_labels, + .labels = shupscale_labels, + }, + [SH_SHADER2] = { + .key = "minarch_shader2", + .name = "Shader 2", + .desc = "Shader 2 program to run", + .default_value = 0, + .value = 0, + .count = 0, + .values = NULL, + .labels = NULL, + + }, + [SH_SHADER2_FILTER] = { + .key = "minarch_shader2_filter", + .name = "Shader 2 Filter", + .desc = "Method of upscaling, NEAREST or LINEAR", + .default_value = 0, + .value = 0, + .count = 2, + .values = shfilter_labels, + .labels = shfilter_labels, + }, + [SH_SRCTYPE2] = { + .key = "minarch_shader2_srctype", + .name = "Shader 2 Source type", + .desc = "This will choose resolution source to scale from", + .default_value = 0, + .value = 0, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_SCALETYPE2] = { + .key = "minarch_shader2_scaletype", + .name = "Shader 2 Texture Type", + .desc = "This will choose resolution source to scale from", + .default_value = 1, + .value = 1, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_UPSCALE2] = { + .key = "minarch_shader2_upscale", + .name = "Shader 2 Scale", + .desc = "This will scale images x times,\nscreen scales to screens resolution (can hit performance)", + .default_value = 0, + .value = 0, + .count = 9, + .values = shupscale_labels, + .labels = shupscale_labels, + }, + [SH_SHADER3] = { + .key = "minarch_shader3", + .name = "Shader 3", + .desc = "Shader 3 program to run", + .default_value = 2, + .value = 2, + .count = 0, + .values = NULL, + .labels = NULL, + + }, + [SH_SHADER3_FILTER] = { + .key = "minarch_shader3_filter", + .name = "Shader 3 Filter", + .desc = "Method of upscaling, NEAREST or LINEAR", + .default_value = 0, + .value = 0, + .count = 2, + .values = shfilter_labels, + .labels = shfilter_labels, + }, + [SH_SRCTYPE3] = { + .key = "minarch_shader3_srctype", + .name = "Shader 3 Source type", + .desc = "This will choose resolution source to scale from", + .default_value = 0, + .value = 0, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_SCALETYPE3] = { + .key = "minarch_shader3_scaletype", + .name = "Shader 3 Texture Type", + .desc = "This will choose resolution source to scale from", + .default_value = 1, + .value = 1, + .count = 2, + .values = shscaletype_labels, + .labels = shscaletype_labels, + }, + [SH_UPSCALE3] = { + .key = "minarch_shader3_upscale", + .name = "Shader 3 Scale", + .desc = "This will scale images x times,\nscreen scales to screens resolution (can hit performance)", + .default_value = 0, + .value = 0, + .count = 9, + .values = shupscale_labels, + .labels = shupscale_labels, + }, + {NULL} + }, + }, + .shaderpragmas = {{ + .count = 0, + .options = NULL, + }}, + .controls = default_button_mapping, + .shortcuts = (ButtonMapping[]){ + [SHORTCUT_SAVE_STATE] = {"Save State", -1, BTN_ID_NONE, 0}, + [SHORTCUT_LOAD_STATE] = {"Load State", -1, BTN_ID_NONE, 0}, + [SHORTCUT_RESET_GAME] = {"Reset Game", -1, BTN_ID_NONE, 0}, + [SHORTCUT_SAVE_QUIT] = {"Save & Quit", -1, BTN_ID_NONE, 0}, + [SHORTCUT_CYCLE_SCALE] = {"Cycle Scaling", -1, BTN_ID_NONE, 0}, + [SHORTCUT_CYCLE_EFFECT] = {"Cycle Effect", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_FF] = {"Toggle FF", -1, BTN_ID_NONE, 0}, + [SHORTCUT_HOLD_FF] = {"Hold FF", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_REWIND] = {"Toggle Rewind", -1, BTN_ID_NONE, 0}, + [SHORTCUT_HOLD_REWIND] = {"Hold Rewind", -1, BTN_ID_NONE, 0}, + [SHORTCUT_GAMESWITCHER] = {"Game Switcher", -1, BTN_ID_NONE, 0}, + [SHORTCUT_SCREENSHOT] = {"Screenshot", -1, BTN_ID_NONE, 0}, + // Trimui only + [SHORTCUT_TOGGLE_TURBO_A] = {"Toggle Turbo A", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_B] = {"Toggle Turbo B", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_X] = {"Toggle Turbo X", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_Y] = {"Toggle Turbo Y", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_L] = {"Toggle Turbo L", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_L2] = {"Toggle Turbo L2", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_R] = {"Toggle Turbo R", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_TURBO_R2] = {"Toggle Turbo R2", -1, BTN_ID_NONE, 0}, + // ----- + {NULL} + }, +}; + + diff --git a/workspace/all/minarch/ma_config.h b/workspace/all/minarch/ma_config.h new file mode 100644 index 000000000..85293afbd --- /dev/null +++ b/workspace/all/minarch/ma_config.h @@ -0,0 +1,19 @@ +#pragma once + +char* getScreenScalingDesc(void); +int getScreenScalingCount(void); +void setOverclock(int i); +void updateCPUMonitor(void); +void Config_syncFrontend(char* key, int value); +void Config_init(void); +void Config_quit(void); +void Config_load(void); +void Config_free(void); +void Config_readOptions(void); +void Config_readControls(void); +void Config_write(int override); +void Config_restore(void); +void Config_syncShaders(char* key, int value); +void applyShaderSettings(void); +void initShaders(void); +char** list_files_in_folder(const char* folderPath, int* fileCount, const char* defaultElement, const char* extensionFilter); diff --git a/workspace/all/minarch/ma_core.c b/workspace/all/minarch/ma_core.c new file mode 100644 index 000000000..771c8fb10 --- /dev/null +++ b/workspace/all/minarch/ma_core.c @@ -0,0 +1,171 @@ +#include +#include +#include + +#include "ma_internal.h" +#include "ma_saves.h" +#include "ma_video.h" +#include "ma_audio.h" +#include "ma_input.h" +#include "ma_cheats.h" +#include "ma_core.h" + + +void Core_getName(char* in_name, char* out_name) { + strcpy(out_name, basename(in_name)); + char* tmp = strrchr(out_name, '_'); + tmp[0] = '\0'; +} +void Core_open(const char* core_path, const char* tag_name) { + LOG_info("Core_open\n"); + core.handle = dlopen(core_path, RTLD_LAZY); + + if (!core.handle) LOG_error("%s\n", dlerror()); + + core.init = dlsym(core.handle, "retro_init"); + core.deinit = dlsym(core.handle, "retro_deinit"); + core.get_system_info = dlsym(core.handle, "retro_get_system_info"); + core.get_system_av_info = dlsym(core.handle, "retro_get_system_av_info"); + core.set_controller_port_device = dlsym(core.handle, "retro_set_controller_port_device"); + core.reset = dlsym(core.handle, "retro_reset"); + core.run = dlsym(core.handle, "retro_run"); + core.serialize_size = dlsym(core.handle, "retro_serialize_size"); + core.serialize = dlsym(core.handle, "retro_serialize"); + core.unserialize = dlsym(core.handle, "retro_unserialize"); + core.cheat_reset = dlsym(core.handle, "retro_cheat_reset"); + core.cheat_set = dlsym(core.handle, "retro_cheat_set"); + core.load_game = dlsym(core.handle, "retro_load_game"); + core.load_game_special = dlsym(core.handle, "retro_load_game_special"); + core.unload_game = dlsym(core.handle, "retro_unload_game"); + core.get_region = dlsym(core.handle, "retro_get_region"); + core.get_memory_data = dlsym(core.handle, "retro_get_memory_data"); + core.get_memory_size = dlsym(core.handle, "retro_get_memory_size"); + + void (*set_environment_callback)(retro_environment_t); + void (*set_video_refresh_callback)(retro_video_refresh_t); + void (*set_audio_sample_callback)(retro_audio_sample_t); + void (*set_audio_sample_batch_callback)(retro_audio_sample_batch_t); + void (*set_input_poll_callback)(retro_input_poll_t); + void (*set_input_state_callback)(retro_input_state_t); + + set_environment_callback = dlsym(core.handle, "retro_set_environment"); + set_video_refresh_callback = dlsym(core.handle, "retro_set_video_refresh"); + set_audio_sample_callback = dlsym(core.handle, "retro_set_audio_sample"); + set_audio_sample_batch_callback = dlsym(core.handle, "retro_set_audio_sample_batch"); + set_input_poll_callback = dlsym(core.handle, "retro_set_input_poll"); + set_input_state_callback = dlsym(core.handle, "retro_set_input_state"); + + struct retro_system_info info = {}; + core.get_system_info(&info); + + + LOG_info("Block Extract: %d\n", info.block_extract); + + Core_getName((char*)core_path, (char*)core.name); + sprintf((char*)core.version, "%s (%s)", info.library_name, info.library_version); + strcpy((char*)core.tag, tag_name); + strcpy((char*)core.extensions, info.valid_extensions); + + core.need_fullpath = info.need_fullpath; + + LOG_info("core: %s version: %s tag: %s (valid_extensions: %s need_fullpath: %i)\n", core.name, core.version, core.tag, info.valid_extensions, info.need_fullpath); + + sprintf((char*)core.config_dir, USERDATA_PATH "/%s-%s", core.tag, core.name); + sprintf((char*)core.states_dir, SHARED_USERDATA_PATH "/%s-%s", core.tag, core.name); + sprintf((char*)core.saves_dir, SDCARD_PATH "/Saves/%s", core.tag); + sprintf((char*)core.bios_dir, SDCARD_PATH "/Bios/%s", core.tag); + sprintf((char*)core.cheats_dir, SDCARD_PATH "/Cheats/%s", core.tag); + sprintf((char*)core.overlays_dir, SDCARD_PATH "/Overlays/%s", core.tag); + + char cmd[512]; + sprintf(cmd, "mkdir -p \"%s\"; mkdir -p \"%s\"", core.config_dir, core.states_dir); + system(cmd); + + set_environment_callback(environment_callback); + set_video_refresh_callback(video_refresh_callback); + set_audio_sample_callback(audio_sample_callback); + set_audio_sample_batch_callback(audio_sample_batch_callback); + set_input_poll_callback(input_poll_callback); + set_input_state_callback(input_state_callback); +} +void Core_init(void) { + LOG_info("Core_init\n"); + core.init(); + core.initialized = 1; +} + +void Core_applyCheats(struct Cheats *cheats) +{ + if (!cheats) + return; + + if (!core.cheat_reset || !core.cheat_set) + return; + + core.cheat_reset(); + for (int i = 0; i < cheats->count; i++) { + if (cheats->cheats[i].enabled) { + core.cheat_set(i, cheats->cheats[i].enabled, cheats->cheats[i].code); + } + } +} + +int Core_updateAVInfo(void) { + struct retro_system_av_info av_info = {}; + core.get_system_av_info(&av_info); + + double a = av_info.geometry.aspect_ratio; + if (a<=0) a = (double)av_info.geometry.base_width / av_info.geometry.base_height; + + int changed = (core.fps != av_info.timing.fps || core.sample_rate != av_info.timing.sample_rate || core.aspect_ratio != a); + + core.fps = av_info.timing.fps; + core.sample_rate = av_info.timing.sample_rate; + core.aspect_ratio = a; + + if (changed) LOG_info("aspect_ratio: %f (%ix%i) fps: %f\n", a, av_info.geometry.base_width,av_info.geometry.base_height, core.fps); + + return changed; +} + +void Core_load(void) { + LOG_info("Core_load\n"); + struct retro_game_info game_info; + game_info.path = game.tmp_path[0]?game.tmp_path:game.path; + game_info.data = game.data; + game_info.size = game.size; + LOG_info("game path: %s (%i)\n", game_info.path, game.size); + core.load_game(&game_info); + + if (Cheats_load()) + Core_applyCheats(&cheatcodes); + + SRAM_read(); + RTC_read(); + // NOTE: must be called after core.load_game! + core.set_controller_port_device(0, RETRO_DEVICE_JOYPAD); // set a default, may update after loading configs + Core_updateAVInfo(); +} +void Core_reset(void) { + core.reset(); + Rewind_on_state_change(); +} +void Core_unload(void) { + // Disabling this is a dumb hack for bluetooth, we should really be using + // bluealsa with --keep-alive=-1 - but SDL wont reconnect the stream on next start. + // Reenable as soon as we have a more recent SDL available, if ever. + //SND_quit(); +} +void Core_quit(void) { + if (core.initialized) { + SRAM_write(); + Cheats_free(); + RTC_write(); + core.unload_game(); + core.deinit(); + core.initialized = 0; + } +} +void Core_close(void) { + if (core.handle) dlclose(core.handle); +} diff --git a/workspace/all/minarch/ma_core.h b/workspace/all/minarch/ma_core.h new file mode 100644 index 000000000..482e15170 --- /dev/null +++ b/workspace/all/minarch/ma_core.h @@ -0,0 +1,12 @@ +#pragma once + +void Core_getName(char* in_name, char* out_name); +void Core_open(const char* core_path, const char* tag_name); +void Core_init(void); +void Core_applyCheats(struct Cheats *cheats); +int Core_updateAVInfo(void); +void Core_load(void); +void Core_reset(void); +void Core_unload(void); +void Core_quit(void); +void Core_close(void); diff --git a/workspace/all/minarch/ma_environment.c b/workspace/all/minarch/ma_environment.c new file mode 100644 index 000000000..3f4da0452 --- /dev/null +++ b/workspace/all/minarch/ma_environment.c @@ -0,0 +1,411 @@ +#include + +#include "ma_internal.h" +#include "ma_options.h" +#include "ma_input.h" +#include "ra_integration.h" +#include "ma_environment.h" + +static bool set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { + // TODO: handle other args? not sure I can + VIB_setStrength(strength); + return 1; +} + +bool environment_callback(unsigned cmd, void *data) { // copied from picoarch initially + // LOG_info("environment_callback: %i\n", cmd); + + switch(cmd) { + // case RETRO_ENVIRONMENT_SET_ROTATION: { /* 1 */ + // LOG_info("RETRO_ENVIRONMENT_SET_ROTATION %i\n", *(int *)data); // core requests frontend to handle rotation + // break; + // } + case RETRO_ENVIRONMENT_GET_OVERSCAN: { /* 2 */ + bool *out = (bool *)data; + if (out) + *out = true; + break; + } + case RETRO_ENVIRONMENT_GET_CAN_DUPE: { /* 3 */ + bool *out = (bool *)data; + if (out) + *out = true; + break; + } + case RETRO_ENVIRONMENT_SET_MESSAGE: { /* 6 */ + const struct retro_message *message = (const struct retro_message*)data; + if (message) LOG_info("%s\n", message->msg); + break; + } + case RETRO_ENVIRONMENT_SHUTDOWN: { /* 7 */ + LOG_info("Core requested shutdown\n"); + quit = 1; + break; + } + case RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL: { /* 8 */ + // puts("RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL"); + // TODO: used by fceumm at least + } + case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: { /* 9 */ + const char **out = (const char **)data; + if (out) { + *out = core.bios_dir; + } + break; + } + case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: { /* 10 */ + const enum retro_pixel_format *format = (const enum retro_pixel_format *)data; + LOG_info("Requested pixel format by core: %d\n", *format); // Log the requested format (raw integer value) + + // Check if the requested format is supported + if (*format == RETRO_PIXEL_FORMAT_XRGB8888) { + fmt = RETRO_PIXEL_FORMAT_XRGB8888; + LOG_info("Format supported: RETRO_PIXEL_FORMAT_XRGB8888\n"); + return true; // Indicate success + } else if (*format == RETRO_PIXEL_FORMAT_RGB565) { + fmt = RETRO_PIXEL_FORMAT_RGB565; + LOG_info("Format supported: RETRO_PIXEL_FORMAT_RGB565\n"); + return true; // Indicate success + } + // Log unsupported formats + LOG_info("Format not supported, defaulting to RGB565\n"); + fmt = RETRO_PIXEL_FORMAT_RGB565; + return false; // Indicate failure + } + case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS: { /* 11 */ + // puts("RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS\n"); + Input_init((const struct retro_input_descriptor *)data); + return false; + break; + } + case RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE: { /* 13 */ + const struct retro_disk_control_callback *var = + (const struct retro_disk_control_callback *)data; + + if (var) { + memset(&disk_control_ext, 0, sizeof(struct retro_disk_control_ext_callback)); + memcpy(&disk_control_ext, var, sizeof(struct retro_disk_control_callback)); + } + break; + } + + // TODO: this is called whether using variables or options + case RETRO_ENVIRONMENT_GET_VARIABLE: { /* 15 */ + // puts("RETRO_ENVIRONMENT_GET_VARIABLE "); + struct retro_variable *var = (struct retro_variable *)data; + if (var && var->key) { + var->value = OptionList_getOptionValue(&config.core, var->key); + // printf("\t%s = \"%s\"\n", var->key, var->value); + } + // fflush(stdout); + break; + } + // TODO: I think this is where the core reports its variables (the precursor to options) + // TODO: this is called if RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION sets out to 0 + // TODO: not used by anything yet + case RETRO_ENVIRONMENT_SET_VARIABLES: { /* 16 */ + // puts("RETRO_ENVIRONMENT_SET_VARIABLES"); + const struct retro_variable *vars = (const struct retro_variable *)data; + if (vars) { + OptionList_reset(); + OptionList_vars(vars); + } + break; + } + case RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME: { /* 18 */ + bool flag = *(bool*)data; + // LOG_info("%i: RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME: %i\n", cmd, flag); + break; + } + case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: { /* 17 */ + bool *out = (bool *)data; + if (out) { + *out = config.core.changed; + config.core.changed = 0; + } + break; + } + case RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK: { /* 21 */ + // LOG_info("%i: RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK\n", cmd); + break; + } + case RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK: { /* 22 */ + // LOG_info("%i: RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK\n", cmd); + break; + } + case RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE: { /* 23 */ + struct retro_rumble_interface *iface = (struct retro_rumble_interface*)data; + + // LOG_info("Setup rumble interface.\n"); + iface->set_rumble_state = set_rumble_state; + break; + } + case RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES: { + unsigned *out = (unsigned *)data; + if (out) + *out = (1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG); + break; + } + case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: { /* 27 */ + struct retro_log_callback *log_cb = (struct retro_log_callback *)data; + if (log_cb) + log_cb->log = (void (*)(enum retro_log_level, const char*, ...))LOG_note; // same difference + break; + } + case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: { /* 31 */ + const char **out = (const char **)data; + if (out) + *out = core.saves_dir; // save_dir; + break; + } + case RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO: { /* 32 */ + const struct retro_system_av_info *av = (const struct retro_system_av_info *)data; + if (av) { + double a = av->geometry.aspect_ratio; + if (a <= 0) a = (double)av->geometry.base_width / av->geometry.base_height; + + core.fps = av->timing.fps; + core.sample_rate = av->timing.sample_rate; + core.aspect_ratio = a; + renderer.dst_p = 0; + } + return true; + } + case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: { /* 35 */ + // LOG_info("RETRO_ENVIRONMENT_SET_CONTROLLER_INFO\n"); + const struct retro_controller_info *infos = (const struct retro_controller_info *)data; + if (infos) { + // TODO: store to gamepad_values/gamepad_labels for gamepad_device + const struct retro_controller_info *info = &infos[0]; + for (int i=0; inum_types; i++) { + const struct retro_controller_description *type = &info->types[i]; + if (exactMatch((char*)type->desc,"dualshock")) { // currently only enabled for PlayStation + has_custom_controllers = 1; + break; + } + // printf("\t%i: %s\n", type->id, type->desc); + } + } + fflush(stdout); + return false; // TODO: tmp + break; + } + 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_SET_GEOMETRY: { /* 37 */ + const struct retro_game_geometry *geom = (const struct retro_game_geometry *)data; + if (geom) { + double a = geom->aspect_ratio; + if (a <= 0) a = (double)geom->base_width / geom->base_height; + core.aspect_ratio = a; + renderer.dst_p = 0; + } + return true; + } + case RETRO_ENVIRONMENT_GET_LANGUAGE: { /* 39 */ + // puts("RETRO_ENVIRONMENT_GET_LANGUAGE"); + if (data) *(int *) data = RETRO_LANGUAGE_ENGLISH; + break; + } + case RETRO_ENVIRONMENT_GET_CURRENT_SOFTWARE_FRAMEBUFFER: { /* (40 | RETRO_ENVIRONMENT_EXPERIMENTAL) */ + // puts("RETRO_ENVIRONMENT_GET_CURRENT_SOFTWARE_FRAMEBUFFER"); + break; + } + + case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: { + // fixes fbneo save state graphics corruption + // puts("RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE"); + int *out_p = (int *)data; + if (out_p) { + int out = 0; + out |= RETRO_AV_ENABLE_VIDEO; + out |= RETRO_AV_ENABLE_AUDIO; + *out_p = out; + } + break; + } + + // RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS (42 | RETRO_ENVIRONMENT_EXPERIMENTAL) + // RETRO_ENVIRONMENT_GET_VFS_INTERFACE (45 | RETRO_ENVIRONMENT_EXPERIMENTAL) + // RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE (47 | RETRO_ENVIRONMENT_EXPERIMENTAL) + // RETRO_ENVIRONMENT_GET_INPUT_BITMASKS (51 | RETRO_ENVIRONMENT_EXPERIMENTAL) + case RETRO_ENVIRONMENT_GET_INPUT_BITMASKS: { /* 51 | RETRO_ENVIRONMENT_EXPERIMENTAL */ + bool *out = (bool *)data; + if (out) + *out = true; + break; + } + case RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION: { /* 52 */ + // puts("RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION"); + if (data) *(unsigned *)data = 2; + break; + } + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS: { /* 53 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS"); + if (data) { + OptionList_reset(); + OptionList_init((const struct retro_core_option_definition *)data); + Config_readOptions(); + } + break; + } + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL: { /* 54 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL"); + const struct retro_core_options_intl *options = (const struct retro_core_options_intl *)data; + if (options && options->us) { + OptionList_reset(); + OptionList_init(options->us); + Config_readOptions(); + } + break; + } + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY: { /* 55 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY"); + if (data) { + const struct retro_core_option_display *display = (const struct retro_core_option_display *)data; + LOG_info("Core asked for option key %s to be %s\n", display->key, display->visible ? "visible" : "invisible"); + OptionList_setOptionVisibility(&config.core, display->key, display->visible); + } + break; + } + case RETRO_ENVIRONMENT_GET_DISK_CONTROL_INTERFACE_VERSION: { /* 57 */ + unsigned *out = (unsigned *)data; + if (out) *out = 1; + break; + } + case RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE: { /* 58 */ + const struct retro_disk_control_ext_callback *var = + (const struct retro_disk_control_ext_callback *)data; + + if (var) { + memcpy(&disk_control_ext, var, sizeof(struct retro_disk_control_ext_callback)); + } + break; + } + // TODO: RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION 59 + // TODO: used by mgba, (but only during frameskip?) + // case RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK: { /* 62 */ + // LOG_info("RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK\n"); + // const struct retro_audio_buffer_status_callback *cb = (const struct retro_audio_buffer_status_callback *)data; + // if (cb) { + // LOG_info("has audo_buffer_status callback\n"); + // core.audio_buffer_status = cb->callback; + // } else { + // LOG_info("no audo_buffer_status callback\n"); + // core.audio_buffer_status = NULL; + // } + // break; + // } + // TODO: used by mgba, (but only during frameskip?) + // case RETRO_ENVIRONMENT_SET_MINIMUM_AUDIO_LATENCY: { /* 63 */ + // LOG_info("RETRO_ENVIRONMENT_SET_MINIMUM_AUDIO_LATENCY\n"); + // + // const unsigned *latency_ms = (const unsigned *)data; + // if (latency_ms) { + // unsigned frames = *latency_ms * core.fps / 1000; + // if (frames < 30) + // // audio_buffer_size_override = frames; + // LOG_info("audio_buffer_size_override = %i (unused?)\n", frames); + // else + // LOG_info("Audio buffer change out of range (%d), ignored\n", frames); + // } + // break; + // } + + // TODO: RETRO_ENVIRONMENT_SET_FASTFORWARDING_OVERRIDE 64 + case RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE: { /* 65 */ + // const struct retro_system_content_info_override* info = (const struct retro_system_content_info_override* )data; + // if (info) LOG_info("has overrides"); + break; + } + // RETRO_ENVIRONMENT_GET_GAME_INFO_EXT 66 + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2: { /* 67 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2"); + if (data) { + OptionList_reset(); + OptionList_v2_init((const struct retro_core_options_v2 *)data); + Config_readOptions(); + } + break; + } + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL: { /* 68 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL"); + if (data) { + const struct retro_core_options_v2_intl *intl = (const struct retro_core_options_v2_intl *)data; + if (intl && intl->us) { + OptionList_reset(); + OptionList_v2_init(intl->us); + Config_readOptions(); + } + } + break; + } + case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK: { /* 69 */ + // puts("RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK"); + if (data) { + struct retro_core_options_update_display_callback *update_display_cb = (struct retro_core_options_update_display_callback *) data; + core.update_visibility_callback = update_display_cb->callback; + } + else { + core.update_visibility_callback = NULL; + } + break; + } + // used by fceumm + // TODO: used by gambatte for L/R palette switching (seems like it needs to return true even if data is NULL to indicate support) + case RETRO_ENVIRONMENT_SET_VARIABLE: { + // puts("RETRO_ENVIRONMENT_SET_VARIABLE"); + const struct retro_variable *var = (const struct retro_variable *)data; + if (var && var->key) { + // printf("\t%s = %s\n", var->key, var->value); + OptionList_setOptionValue(&config.core, var->key, var->value); + break; + } + + int *out = (int *)data; + if (out) *out = 1; + + break; + } + + // unused + // case RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK: { + // puts("RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK"); fflush(stdout); + // break; + // } + // case RETRO_ENVIRONMENT_GET_THROTTLE_STATE: { + // puts("RETRO_ENVIRONMENT_GET_THROTTLE_STATE"); fflush(stdout); + // break; + // } + // case RETRO_ENVIRONMENT_GET_FASTFORWARDING: { + // puts("RETRO_ENVIRONMENT_GET_FASTFORWARDING"); fflush(stdout); + // break; + // }; + case RETRO_ENVIRONMENT_SET_HW_RENDER: + { + struct retro_hw_render_callback *cb = (struct retro_hw_render_callback*)data; + + // Log the requested context + LOG_info("Core requested GL context type: %d, version %d.%d\n", + cb->context_type, cb->version_major, cb->version_minor); + + // Fallback if version is 0.0 or other unexpected values + if (cb->context_type == 4 && cb->version_major == 0 && cb->version_minor == 0) { + LOG_info("Core requested invalid GL context type or version, defaulting to GLES 2.0\n"); + cb->context_type = RETRO_HW_CONTEXT_OPENGLES3; + cb->version_major = 3; + cb->version_minor = 0; + } + + return true; + } + default: + // LOG_debug("Unsupported environment cmd: %u\n", cmd); + return false; + } + return true; +} diff --git a/workspace/all/minarch/ma_environment.h b/workspace/all/minarch/ma_environment.h new file mode 100644 index 000000000..3483517ee --- /dev/null +++ b/workspace/all/minarch/ma_environment.h @@ -0,0 +1,3 @@ +#pragma once + +bool environment_callback(unsigned cmd, void *data); diff --git a/workspace/all/minarch/ma_frontend_opts.c b/workspace/all/minarch/ma_frontend_opts.c new file mode 100644 index 000000000..3c786b96c --- /dev/null +++ b/workspace/all/minarch/ma_frontend_opts.c @@ -0,0 +1,728 @@ +#include "ma_internal.h" +#include "ma_frontend_opts.h" +#include "ma_cheats.h" +#include "ra_integration.h" +#include "notification.h" + +#include +#include +#include +#include + +int Menu_messageWithFont(char* message, char** pairs, TTF_Font* f) { + GFX_setMode(MODE_MAIN); + int dirty = 1; + while (1) { + GFX_startFrame(); + PAD_poll(); + + if (PAD_justPressed(BTN_A) || PAD_justPressed(BTN_B)) break; + + PWR_update(&dirty, NULL, Menu_beforeSleep, Menu_afterSleep); + + + GFX_clear(screen); + GFX_blitMessage(f, message, screen, &(SDL_Rect){SCALE1(PADDING),SCALE1(PADDING),screen->w-SCALE1(2*PADDING),screen->h-SCALE1(PILL_SIZE+PADDING)}); + GFX_blitButtonGroup(pairs, 0, screen, 1); + GFX_flip(screen); + dirty = 0; + + + hdmimon(); + } + GFX_setMode(MODE_MENU); + return MENU_CALLBACK_NOP; // TODO: this should probably be an arg +} + +int Menu_message(char* message, char** pairs) { + return Menu_messageWithFont(message, pairs, font.medium); +} + +static int MenuList_freeItems(MenuList* list, int i) { + // TODO: what calls this? do menu's register for needing it? then call it on quit for each? + if (list->items) free(list->items); + return MENU_CALLBACK_NOP; +} + +static int OptionFrontend_optionChanged(MenuList* list, int i) { + MenuItem* item = &list->items[i]; + Config_syncFrontend(item->key, item->value); + return MENU_CALLBACK_NOP; +} +static MenuList OptionFrontend_menu = { + .type = MENU_VAR, + .on_change = OptionFrontend_optionChanged, + .items = NULL, +}; +int OptionFrontend_openMenu(MenuList* list, int i) { + if (OptionFrontend_menu.items==NULL) { + // TODO: where do I free this? I guess I don't :sweat_smile: + if (!config.frontend.enabled_count) { + int enabled_count = 0; + for (int i=0; ilock) continue; + config.frontend.enabled_options[j] = item; + j += 1; + } + } + OptionFrontend_menu.items = calloc(config.frontend.enabled_count+1, sizeof(MenuItem)); + for (int j=0; jkey = option->key; + item->name = option->name; + item->desc = option->desc; + item->value = option->value; + item->values = option->labels; + } + } + else { + // update values + for (int j=0; jvalue = option->value; + } + } + Menu_options(&OptionFrontend_menu); + return MENU_CALLBACK_NOP; +} + +static int OptionEmulator_optionChanged(MenuList* list, int i) { + MenuItem* item = &list->items[i]; + Option* option = OptionList_getOption(&config.core, item->key); + LOG_info("%s (%s) changed from `%s` (%s) to `%s` (%s)\n", item->name, item->key, + item->values[option->value], option->values[option->value], + item->values[item->value], option->values[item->value] + ); + OptionList_setOptionRawValue(&config.core, item->key, item->value); + return MENU_CALLBACK_NOP; +} + +static int OptionEmulator_optionDetail(MenuList* list, int i); +int OptionEmulator_openMenu(MenuList* list, int index); + +static MenuList OptionEmulator_menu = { + .type = MENU_FIXED, + .on_confirm = OptionEmulator_optionDetail, // TODO: this needs pagination to be truly useful + .on_change = OptionEmulator_optionChanged, + .items = NULL, +}; + +static int OptionEmulator_optionDetail(MenuList* list, int i) { + MenuItem* item = &list->items[i]; + + if (item->values == NULL) { + // This is a category item + // Display the corresponding submenu + list->category = item->key; + LOG_info("%s: displaying category %s\n", __FUNCTION__, item->key); + + int prev_enabled_count = config.core.enabled_count; + Option **prev_enabled = config.core.enabled_options; + MenuItem *prev_items = OptionEmulator_menu.items; + + OptionEmulator_openMenu(list, 0); + list->category = NULL; + + config.core.enabled_count = prev_enabled_count ; + config.core.enabled_options = prev_enabled ; + OptionEmulator_menu.items = prev_items; + + LOG_info("%s: back to root menu\n", __FUNCTION__); + } + else { + Option* option = OptionList_getOption(&config.core, item->key); + if (option->full) return Menu_messageWithFont(option->full, (char*[]){ "B","BACK", NULL }, font.medium); + else return MENU_CALLBACK_NOP; + } +} + +int OptionEmulator_openMenu(MenuList* list, int index) { + LOG_info("%s: limit to category %s\n", __FUNCTION__, list->category ? list->category : ""); + + if (list->category == NULL) { + if (core.update_visibility_callback) { + LOG_info("%s: calling update visibility callback\n", __FUNCTION__); + core.update_visibility_callback(); + } + } + + int enabled_count = 0; + config.core.enabled_options = calloc(config.core.count + 1, sizeof(Option*)); + for (int i=0; i