RetroAchievements Compliance Review
Verify the built-in emulator meets RetroAchievements hardcore requirements before release. This skill is release-gating: a FAIL here blocks the release until resolved or explicitly waived by the user.
When to invoke: before any release touching RA/achievement code, the built-in emulator, LibretroActivity, LaunchMode, save cache/sync logic, or hotkeys; and when debugging hardcore mode or auditing save isolation.
Every grep in this file was run against the tree and confirmed non-empty at the time of writing (except the one marked EXPECT EMPTY). If a grep comes back wrong, the code moved: re-anchor by tracing the named symbol, do not "fix the code toward the skill."
References are by SYMBOL, never by line number - the greps are the durable anchors, offsets are not.
RA Hardcore Requirements (behavioral contract)
From the official docs (https://docs.retroachievements.org/developer-docs/hardcore-mode.html):
| Feature | Hardcore | Argosy enforcement |
|---|---|---|
| Save states (save + load) | BLOCKED | HotkeyDispatcher, InGameMenu, auto-save/auto-restore gates |
| Cheats | BLOCKED | CheatSessionManager, InGameMenu cheats row |
| Rewind | BLOCKED | HotkeyDispatcher + rewind buffer never allocated |
| Fast forward | Allowed | Not gated on hardcore |
| SRAM (battery) saves | Allowed | Normal gameplay; see casualSaveInHardcore exception |
1. Preconditions: what "hardcore" means here
Effective hardcore = requested hardcore AND secure saves enabled. Secure Saves is the user preference that lets Argosy own the save lifecycle; without it, hardcore save integrity cannot be guaranteed, so hardcore is stripped at FOUR independent layers:
- Pre-launch strip - the
val launchMode = when {block inGameLaunchDelegate.kt(greptakeUnless { it.isHardcore }): when!prefs.secureSaves, any hardcore override mode is discarded. - Intent-arrival downgrade -
LibretroActivity.parseIntentExtras: readssecureSavesonce, and if disabled downgradesRESUME_HARDCORE -> RESUME,NEW_HARDCORE -> NEW_CASUAL, then deriveshardcoreMode = launchMode.isHardcore. This catches any caller that bypassed the delegate. - switchToHardcore gate -
LibretroActivity(grepswitchToHardcore): a resume that lands on a hardcore-tagged save only flips the session to hardcore whensecureSavesEnabledis still true. - Offer gate -
PlayOptionsDelegate:hardcoreAvailable = hasRASupport && isRALoggedIn && secureSaves; hardcore rows are never shown without all three.
grep -n "secureSaves\|isActiveSaveHardcore\|shouldDefaultToHardcore" app/src/main/kotlin/com/nendo/argosy/ui/screens/common/GameLaunchDelegate.kt
grep -n "secureSavesEnabled\|launchMode.isHardcore\|switchToHardcore" app/src/main/kotlin/com/nendo/argosy/libretro/LibretroActivity.kt
grep -n "hardcoreAvailable\|showResumeHardcore\|shouldShowModeSelection" app/src/main/kotlin/com/nendo/argosy/ui/screens/gamedetail/delegates/PlayOptionsDelegate.kt
LaunchMode
libretro/LaunchMode.kt: RESUME, NEW_CASUAL, NEW_HARDCORE,
RESUME_HARDCORE; isHardcore = NEW_HARDCORE || RESUME_HARDCORE. Unknown
intent strings parse to RESUME.
The 3-way mode preference
builtin_default_to_hardcore_mode (DataStore key BUILTIN_DEFAULT_TO_HARDCORE_MODE
in BuiltinEmulatorPreferencesRepository.kt) holds "ask" | "casual" | "hardcore". Consumers:
- RASettingsSection - "Play Mode Preference" cycle row (Ask / Default to Casual / Default to Hardcore); rendered disabled with subtitle "Requires Secure Saves" when secure saves is off.
- GameLaunchDelegate.shouldDefaultToHardcore - quick-launch resume
defaults to
RESUME_HARDCOREonly when token == "hardcore" AND built-in emulator ANDgame.achievementCount > 0AND RA logged in. - PlayOptionsDelegate.shouldShowModeSelection - the fresh-game mode-selection modal appears only when token == "ask" (plus built-in, has achievements, RA logged in, secure saves, and no existing saves).
- PlayOptionsDelegate - token == "hardcore" pre-focuses ResumeHardcore / NewHardcore in the modal.
- SyncSettingsDelegate.toggleSecureSaves - disabling secure saves while the pref is not "casual" and RA is logged in requires an extra confirm (the user is warned they are giving up hardcore).
grep -n "BUILTIN_DEFAULT_TO_HARDCORE_MODE" app/src/main/kotlin/com/nendo/argosy/data/preferences/BuiltinEmulatorPreferencesRepository.kt
grep -n "tokenOptions" app/src/main/kotlin/com/nendo/argosy/ui/screens/settings/sections/RASettingsSection.kt
2. The six gating sites
2.1 HotkeyDispatcher - state/rewind hotkeys
Invariant: QUICK_SAVE, QUICK_LOAD, and REWIND actions are no-ops with a toast
in hardcore. File: libretro/HotkeyDispatcher.kt - the HotkeyAction.QUICK_SAVE,
QUICK_LOAD and REWIND branches of dispatch, gated by the injected
isHardcoreMode lambda.
NAMING TRAP: LibretroActivity does not construct HotkeyDispatcher. It
constructs LibretroHotkeyDispatcher (libretro/LibretroHotkeyDispatcher.kt),
a wrapper that owns fast-forward/rewind runtime state and delegates every action
to an inner HotkeyDispatcher. It forwards isHardcoreMode verbatim and adds
NO hardcore logic of its own - grepping the activity for HotkeyDispatcher(
lands on the wrapper, not the gates.
grep -n "isHardcoreMode()" app/src/main/kotlin/com/nendo/argosy/libretro/HotkeyDispatcher.kt
grep -n "disabled in Hardcore mode" app/src/main/kotlin/com/nendo/argosy/libretro/HotkeyDispatcher.kt
Expect three isHardcoreMode() gates and toasts "Save states disabled in
Hardcore mode" (x2) and "Rewind disabled in Hardcore mode".
Belt-and-suspenders: LibretroActivity never allocates the rewind buffer in
hardcore (grep rewindEnabled && !hardcoreMode), and checkStateSupport()
forces statesSupported = false when hardcoreMode. The auto-save-state and
auto-restore paths each independently bail on hardcoreMode.
2.2 InGameMenu - state rows hidden
Invariant: Quick Save / Quick Load / Manage States rows do not exist in
hardcore. File: libretro/ui/InGameMenu.kt:
val showStates = !isHardcoreMode && statesSupported && !isInNetplaySession.
A "HARDCORE" badge renders in the menu header.
grep -n "showStates" app/src/main/kotlin/com/nendo/argosy/libretro/ui/InGameMenu.kt
2.3 CheatSessionManager - cheats never applied
Invariant: no cheat reaches the core in hardcore. File:
libretro/CheatSessionManager.kt - applyAllEnabledCheats(hardcoreMode)
returns immediately when true; loadCheats and selectVariant route through
it. Every LibretroActivity caller passes the live flag.
UI side: the Cheats menu row is gated in LibretroActivity -
cheatsAvailable = !hardcoreMode && PlatformWeightRegistry.supportsCheats(platformSlug).
grep -n "if (hardcoreMode) return" app/src/main/kotlin/com/nendo/argosy/libretro/CheatSessionManager.kt
grep -n "cheatsAvailable = !hardcoreMode" app/src/main/kotlin/com/nendo/argosy/libretro/LibretroActivity.kt
2.4 SaveStateManager - SRAM restore per launch mode
File: libretro/SaveStateManager.kt. restoreSaveForLaunchMode is the single
entry; its KDoc states the two non-obvious rules (activeSaveApplied wins;
RESUME_HARDCORE SRAM fallback). It takes ONLY a LaunchMode - there is no
timestamp parameter anywhere in this path.
- Short-circuit, before the mode branch: when the active row has
activeSaveApplied == true, the on-disk.srmis read and returned as-is. An explicit save-management restore is never second-guessed. - NEW_HARDCORE / NEW_CASUAL: fresh start. Existing .srm is backed up via
saveCacheManager.cacheAsRollbackBEFORE deletion, then the .srm and all state slots are deleted. A fresh hardcore run must never destroy the only copy of a prior save. - RESUME_HARDCORE: loads the latest hardcore save
(
getLatestHardcoreSave); validates the trailer withisValidHardcoreSaveand logs a warning if missing. No hardcore save -> falls back torestoreResumeSavewithcasualSaveInHardcore = true(see exceptions). - RESUME (
restoreResumeSave): target precedence is the active row fromactiveSaveRepository.getActiveRow, elsegetMostRecentSave. If the target is hardcore-tagged,switchToHardcore = trueONLY whenisValidHardcoreSavepasses; an invalid trailer loads the save as casual.
grep -n "restoreSaveForLaunchMode\|casualSaveInHardcore\|isValidHardcoreSave\|switchToHardcore" app/src/main/kotlin/com/nendo/argosy/libretro/SaveStateManager.kt
grep -n "cacheAsRollback" app/src/main/kotlin/com/nendo/argosy/libretro/SaveStateManager.kt
2.5 GameLaunchDelegate - pre-launch mode resolution
File: ui/screens/common/GameLaunchDelegate.kt, the val launchMode = when {
block. Resolution order:
!prefs.secureSaves-> strip hardcore from any override.- Hardcore sync conflict resolved as KEEP_HARDCORE ->
RESUME_HARDCORE. - Explicit
overrideLaunchMode(from PlayOptionsModal) wins. isActiveSaveHardcore(gameId)(active channel's most recent save hasisHardcore) ->RESUME_HARDCORE(hardcore ratchet: a hardcore save resumes hardcore by default).shouldDefaultToHardcore->RESUME_HARDCORE.- else null (plain resume).
Preference flags are read once at the top of the launch pass and passed down; mirror that if touching this path.
2.6 PlayOptionsDelegate - what the user is offered
File: ui/screens/gamedetail/delegates/PlayOptionsDelegate.kt.
hardcoreAvailable=hasRASupport && isRALoggedIn && secureSaves.showResumeHardcore: offered whenever hardcore is available and ANY resumable save exists (casual or hardcore) - because of the SRAM fallback, continuing a casual save in hardcore is legal.visibleActionsis the single source of truth for row order: Resume / ResumeNoSync / ResumeHardcore / NewCasual / NewHardcore.confirmPlayOptionSelection: NewHardcore refused while offline (hardcore unlocks need a live session).shouldShowModeSelection: fresh-game Casual-vs-Hardcore modal only when built-in + has achievements + RA logged in + secure saves + pref == "ask" + zero existing saves.
3. Isolation model: isHardcore flag + trailer
Hardcore saves are isolated by a column plus an integrity trailer, not by slot name:
data/local/entity/SaveCacheEntity.kt-val isHardcore: Boolean.SLOT_HARDCOREis@Deprecated. DO NOT resurrect slot-name isolation; anything keying on the "HARDCORE" slot string is a regression.- Session save caching carries the flag end-to-end:
LibretroActivitystarts the play session withhardcoreMode;data/emulator/PlaySessionTracker.ktpassesisHardcoreintosaveCacheManager.cacheCurrentSave(hardcore sessions also forcechannelName = null- hardcore saves live outside named channels, seeSaveCacheManager.resolveDefaultChannel). - Trailer write:
SaveCacheManager.cacheCurrentSaveappends the trailer to the cached copy whenisHardcore, viaSaveArchiver.appendHardcoreTrailer:{"h":true,"v":1}+ LE length + magic, appended to the file. - Trailer read/strip:
SaveArchiver.readHardcoreTrailer/hasHardcoreTrailer;readBytesWithoutTrailerstrips it before bytes are handed to the core or written to a target path (callers inSaveCacheManager). - Validation:
SaveCacheManager.isValidHardcoreSave=entity.isHardcoreAND trailer present on the cached file. An isHardcore-tagged save whose trailer is missing (modified externally) is demoted to casual on resume: inSaveStateManager.restoreResumeSave,switchToHardcoreis set only inside theif (isValid)branch, and the else branch logs "RESUME: Hardcore save missing trailer, loading as casual". The demotion is live; the session does not get hardcore credit from a tampered save.
grep -n "SLOT_HARDCORE\|isHardcore" app/src/main/kotlin/com/nendo/argosy/data/local/entity/SaveCacheEntity.kt
grep -n "appendHardcoreTrailer\|readHardcoreTrailer\|hasHardcoreTrailer" app/src/main/kotlin/com/nendo/argosy/data/sync/SaveArchiver.kt
grep -n "appendHardcoreTrailer\|isValidHardcoreSave\|getLatestHardcoreSave" app/src/main/kotlin/com/nendo/argosy/data/repository/SaveCacheManager.kt
4. Justified exceptions
Each exception is deliberate. Verify the boundary holds; do not "fix" the exception itself.
4.1 Casual SRAM may continue in a hardcore session
- Rule: hardcore sessions load hardcore saves.
- Exception:
RESUME_HARDCOREwith no hardcore save falls back to the active (casual) SRAM, flaggedcasualSaveInHardcoreinSaveStateManager.restoreSaveForLaunchMode; the UI surfaces "Continuing casual save in hardcore" (grep that string inLibretroActivity.kt). - Why: RA forbids save STATES in hardcore, not SRAM battery-save
continuity - stated in the
restoreSaveForLaunchModeKDoc and mirrored inPlayOptionsState.showResumeHardcore. - Boundary: any save-STATE load in hardcore remains absolutely blocked (sections 2.1, 2.2, and the auto-restore gates). If SRAM fallback ever starts touching state slots, that is a violation.
4.2 Speedrun mode runs alongside hardcore
- Rule: nothing extra manipulates game state in hardcore.
- Exception: the speedrun timer/splits overlay is available regardless of mode.
- Why: it only OBSERVES - reset events and hotkey-driven splits
(
LibretroActivity.initializeHotkeyDispatcherwiresonGameResetand the fiveonSpeedrun*callbacks toSpeedrunTimerEngine); it never touches save states, SRAM, rewind, or core memory. - Boundary:
libretro/speedrun/must stay hardcore-agnostic. Verify:
grep -rn "hardcore" app/src/main/kotlin/com/nendo/argosy/libretro/speedrun/
EXPECT EMPTY. Any speedrun feature that starts touching save states, rewind, or core memory re-clears this whole skill first.
4.3 Hardcore award failure falls back to casual award
- Rule: hardcore unlocks are submitted with the hardcore flag.
- Exception (designed behavior): if the hardcore award errors, the
achievement is re-submitted as casual and recorded locally as a casual
unlock (
RetroAchievementsSessionManager, theRAAwardResult.Errorbranch of the award handling). - Why: hardcore awards cannot be queued offline (they need a live heartbeat); losing the unlock entirely would be worse than a softcore credit.
- Enforcement site for "cannot be queued": in
RetroAchievementsRepository.awardAchievement, EVERYqueueAchievementcall sits in theelsebranch of anif (forHardcoreMode)- HTTP failure, empty body, error body, and the exception handler each returnRAAwardResult.Errorfor hardcore and only queue for casual. Adding a queue path that is not behind that branch would let a hardcore unlock be submitted after the session ended, which RA does not permit. - Boundary: the fallback only ever DOWNGRADES (
earnedHardcore = false); nothing may promote a casual unlock to hardcore after the fact.
5. Adjacent gates
- Netplay guests force NEW_CASUAL:
LibretroActivity.parseIntentExtras- a join intent overrides launchMode toNEW_CASUAL; guests never earn hardcore on a host's snapshot. Netplay also blocks state operations inHotkeyDispatcherand hides state rows inInGameMenu(!isInNetplaySessionin showStates) - but the netplay gates are NOT uniform:QUICK_SAVEis blocked only for NON-HOSTS (isNetplayInSession() && getNetplayRole() != NetplayMenuRole.Host); the host CAN quick-save mid-netplay.QUICK_LOADandRESET_GAMEare blocked for everyone in session. The hardcore gate is unconditional in all three cases and sits after the netplay check, so hardcore compliance does not depend on the role.
- RA session carries the flag, not the heartbeat:
RetroAchievementsSessionManagerstarts the session withhardcoreMode(raRepository.startSession(gameRaId, hardcoreMode), sent ashardcore = 0/1); awards sendforHardcoreMode; the periodicsendHeartbeatcarries no mode. Unlocks are stored split (markUnlockedHardcorevsmarkUnlocked) and social/LED surfaces receiveisHardcore. - Unlock validation hash (upstream-exact, do not "simplify"):
RetroAchievementsRepository.generateValidationcomputesmd5(achievementId + username + hardcoreFlag)where hardcoreFlag is the literal"1"or"0", and sends it with every award. The input order, the string form of the flag, and MD5 itself are RA Connect's contract - a refactor that reorders the concatenation or switches digest produces awards the server silently rejects. handleAuthFailureis a DELIBERATE no-op: the body inRetroAchievementsRepositoryis empty with a long comment explaining why - the previous implementation cleared stored credentials on any error string containing "invalid"/"expired"/"credentials", which wiped tokens on non-auth errors like "Invalid game ID". Do not "implement" it; if auth handling is genuinely needed, it needs a precise signal, not a substring match.
grep -n "startSession\|sendHeartbeat" app/src/main/kotlin/com/nendo/argosy/libretro/RetroAchievementsSessionManager.kt
grep -n "launchMode = LaunchMode.NEW_CASUAL" app/src/main/kotlin/com/nendo/argosy/libretro/LibretroActivity.kt
6. UPSTREAM MANDATE
Hardcore semantics, memory addressing, and achievement logic are verified against upstream sources, never inferred:
- rcheevos is vendored in-tree at
libretrodroid/src/main/cpp/rcheevos/, but only PART of it is compiled.CMakeLists.txtbuildssrc/rcheevos/*.c(alloc, condition, condset, consoleinfo, format, lboard, memref, operand, richpresence, runtime, runtime_progress, trigger, value) plusrcheevos_stubs.c.rc_client.candsrc/rapi/are NOT in the build. So rcheevos is the authority for condition evaluation, memory peek semantics and runtime progress. - The uncompiled
src/rapi/IS still the reference for Connect wire shapes. Before writing or changing anyRAApiendpoint orRAModelsresponse class, read the matchingrc_api_*.crequest builder and response parser, and its fixtures intest/rapi/, and match field names, casing and nullability to what they show. Uncompiled means it does not run in our build, not that it is not authoritative. Inferring a shape instead of reading it is how thegameidhash lookup shipped parsing JSON as a bare number and silently resolved every game to "no match" for five months (36a95c82, fixed in #330); the RomMraIdfallback hid it the entire time. - ALL RA server traffic is Kotlin:
RAApi(Retrofit) driven byRetroAchievementsRepository. Hardcore flags, validation hashes, session start and awards are ours to get right; there is no native client to defer to. - RetroAchievements docs (docs.retroachievements.org) are the authority for policy: what hardcore must block, award semantics, session rules.
Any compliance question this codebase cannot answer goes to those sources. Any NEW hardcore-adjacent feature (new overlay, new input path, new save mechanism, new core capability) verifies RA's actual rules against the docs and rcheevos source BEFORE shipping - "it seems allowed" is not a determination.
7. Manual verification checklist
Run when hardcore logic changed. Log tag anchors:
[Startup] gameId=..., core=..., hardcore=... (LibretroActivity) and
RetroAchievementsSessionManager session lines.
Hardcore entry
- With RA logged in + Secure Saves ON, launch via "New Hardcore" in the play options modal.
- Verify logs show
hardcore=trueand the RA session starts withhardcore=1. - Open the in-game menu: HARDCORE badge visible.
Preconditions
- Turn Secure Saves OFF (note the confirm warning when the mode pref is not casual). Hardcore rows disappear from the play options modal; the settings Play Mode row renders disabled.
- Launching a game whose last save was hardcore lands in casual
(intent downgrade); verify
hardcore=falsein logs. - Set the mode pref to "Ask": a fresh built-in RA game with zero saves shows the Casual/Hardcore selection; a game with existing saves does not.
- While offline, "New Hardcore" refuses to confirm.
Blocking in-session (hardcore)
- In-game menu shows no Quick Save / Quick Load / Manage States rows.
- Quick-save hotkey -> toast "Save states disabled in Hardcore mode"; quick-load likewise; rewind hotkey -> "Rewind disabled in Hardcore mode".
- No Cheats row; cheats previously enabled for the game are not applied.
- Exit the game: no auto-save state written; relaunch does not auto-restore a state.
Save isolation
- Create a casual save (in-game SRAM save), exit.
- "New Hardcore" for the same game: verify a rollback backup is logged before the fresh start and prior state slots are gone.
- Play, save in-game, exit. Verify the cached save logs
[HARDCORE]and the hardcore save is not listed under a named channel. - Plain "Resume" now enters hardcore (ratchet via valid trailer).
- Corrupt/strip the trailer on the cached hardcore file (test env only): Resume demotes to casual with the trailer warning in logs.
- "Resume Hardcore" on a game with only casual saves shows "Continuing casual save in hardcore".
Award fallback
- With the network dropped mid-session, unlock an achievement in hardcore: verify the casual fallback path logs and the local unlock is casual, not hardcore.
Integration with release process
If RA/emulator/save-cache code changed since the last release:
- Run every grep in sections 1-5; each must match (section 4.2's must be empty).
- Walk the manual checklist for the touched areas.
- Any behavioral change to hardcore semantics gets checked against the upstream mandate (section 6) and documented in release notes.
References
- https://docs.retroachievements.org/developer-docs/hardcore-mode.html
- https://docs.retroachievements.org/guidelines/users/code-of-conduct.html
- Vendored rcheevos:
libretrodroid/src/main/cpp/rcheevos/