Skip to content

Commit c6dc57d

Browse files
authored
Built-in libretro saves & states: flat layout, honor explicit restores, surface silent failures (#266)
* saves: flush built-in SRAM to disk before caching on quit The in-game quit handler cached the session save (cacheCurrentSessionForQuit reads the .srm off disk) before saveSram() flushed the live SRAM, so the cache captured the pre-session copy and a later resume could restore that stale save over the fresh one. Flush SRAM first, then cache. * states: flatten built-in save-state layout; channels are cache-only Built-in libretro wrote live save-states into per-channel subdirs ({statesDir}/{channel}/rom.state.X) while every cache/restore path that writes the live disk wrote flat and, for built-in, used StatePathRegistry's unexpanded '{filesDir}/libretro/states' base -- reconciled by a flat->default/ migration. SRAM and external emulators are already flat. Unify on flat, channels only in the cache: - LibretroStateSlots: single source of truth for slot<->filename. - LibretroStatePathResolver: flat live-state base dir with the real filesDir and the same override precedence GameLauncher uses (fixes the unexpanded {filesDir} bug for built-in). - SaveStateManager: flat live paths; migration inverted to lift legacy channel states up to flat. - StateCacheManager / RestoreCachedStatesUseCase / RestoreStateUseCase route through the resolver; deleteAutoStateFromDisk -> deleteAutoResumeStatesFromDisk now clears both auto and resume so an explicitly-restored save loads instead of being overridden by a stale auto-resume state. Also: RESUME_HARDCORE falls back to the active SRAM save instead of starting fresh when no hardcore-tagged save exists (RetroAchievements allows SRAM in hardcore). No DB schema change (disk-layout migration only). * states: resolve GameLauncher's built-in state dir via LibretroStatePathResolver GameLauncher computed the live state dir inline (platformOverride?.statePath ?: customStatePath), mirroring the resolver instead of using it. Route it through LibretroStatePathResolver.liveStateBaseDir so the launch path and the cache/restore path share one source of truth. LibretroActivity still layers variant isolation on top of this base. No behavior change (the resolver replicates the prior precedence, with an added blank-path guard). * settings: mark save-state toggles as casual-only in built-in settings Save states are disabled in hardcore (checkStateSupport bails on hardcoreMode), so 'Save State on Exit', 'Restore State on Launch', and 'Hardware-Core Save States' have no effect in a hardcore session. Add an 'Applies to casual games only' subtitle to each so that's clear in the UI. * gamedetail: offer Continue-in-Hardcore when only a casual save exists RESUME_HARDCORE now falls back to loading the active SRAM, so a game with only casual saves can be continued in hardcore. But the Play Options modal only showed the hardcore continue row when a hardcore-tagged save existed, so hardcore could never resume such a game (only New Game offered hardcore). Show the row whenever hardcore is available (RA logged in) and there is any resumable save; the condition is centralized on PlayOptionsState.showResumeHardcore so the delegate index math and the modal rendering stay in sync. * settings: add global "Default to Hardcore" launch preference New built-in setting (default off), 'When launching a game, default to hardcore instead of casual'. When on and hardcore is available, the Play Options modal pre-focuses the Continue-in-Hardcore row so A launches hardcore without reaching for the secondary action. Wired through BuiltinEmulatorSettings + the prefs repo + the LibretroSettingDef built-in settings UI (accessor/section/routers), read in PlayOptionsDelegate.showPlayOptions to choose the default focus. * saves: refresh save panel in place after restore instead of closing Restoring a save (activateSlot / restoreSave) set isVisible=false, so the panel closed and never showed the restore taking effect -- and a server restore left activeSaveCacheId null with nothing marked active, so the panel kept showing the local save as current. Keep the panel open and call refreshEntries() on success; the active marker falls back to the recorded timestamp, so server (cloud) restores now show as the active point too. * emulator: surface core-fallback + SRAM-load failures, register vba_next Three related fixes so save/core problems stop being silent: - resolveBuiltinCoreId warned to log and silently substituted mGBA when the configured core wasn't a valid built-in core. Now it shows a 'Failed to load X, using Y instead' notification. - The built-in registry only listed mgba/vbam for GBA, so a vba_next selection was rejected and fell back to mGBA (making an incompatible 64KB save). Register vba_next so it downloads from the buildbot and matches vba_next cloud saves exactly. - unserializeSRAM returns false on a size mismatch (e.g. a 128KB save into a 64KB core) but the result was ignored, so the game booted a fresh save silently. GLRetroView now records it and LibretroActivity shows 'Failed to load save, using new save instead' in-game. * saves: honor an explicit save restore over the default resume pick A save-management restore writes the chosen save to the live .srm and sets activeSaveApplied, but restoreSaveForLaunchMode ignored that and re-derived the SRAM from the cache -- RESUME_HARDCORE via getLatestHardcoreSave, RESUME via getByTimestamp. So a Continue / Continue-Hardcore launch right after a restore silently loaded a different save (e.g. the latest hardcore autosave) and clobbered the user's choice, with no error since the SRAM itself loaded fine. When activeSaveApplied is set (cleared on session end) load the on-disk .srm as-is for RESUME/RESUME_HARDCORE. Consistent with ConflictAutoResolver already treating activeSaveApplied as keep-local. * cleanup: dedup play-options ordering, reuse resolver inputs + core name helper Post-review simplifications (no behavior change): - PlayOptionsDelegate: derive row count, confirm mapping and default focus from one visibleActions() list instead of three parallel index computations; drop the redundant defaultFocusIndex helper. - PlayOptionsModal: the hardcore-continue row's (hasSaves || hasHardcoreSave) is always true inside the CONTINUE section -> gate on showHardcoreOptions only. - LibretroStatePathResolver: add a (platformStatePath, customStatePath) overload so GameLauncher reuses the game/platform/prefs it already loaded instead of 2 extra Room reads + a DataStore collect per launch. - SaveStateManager.restoreSaveForLaunchMode: read the game row once and thread it into restoreResumeSave instead of fetching it twice on RESUME. - LibretroCoreRegistry.displayNameFor(): shared coreId->name fallback, reused by GameLauncher and CoreCrashController. * saves: recognize server-only cloud saves in Start Game + delete For a freshly synced RomM game the save lives only on the server (no local cache), and two paths only looked at the local cache: Start Game (showPlayOptions + shouldShowModeSelection) showed New Game with no Continue -- and New Game overwrites the cloud save on launch -- while deleting a save slot removed only the local cache, reporting success as the server copy re-synced back. Start Game now derives its options from GetUnifiedSavesUseCase (cache + server, offline-safe via checkSavesForGame returning empty), and confirmDeleteChannel now deletes the server copies too, matching confirmDeleteLegacyChannel. * saves: apply autosave autocleanup when the channel is the string "autosave" autocleanup (server-side pruning to the newest 10) was gated on channelName == null, but the autosave channel is also represented as the literal string "autosave" -- a restore sets it via targetChannel = entry.channelName ?: AUTOSAVE_SLOT_NAME. In that string form autocleanup was skipped, so the autosave slot's server-side history grew unbounded after a restore. Recognize both forms (matching SaveSyncApiClient's own autosave normalization). * gamedetail: apply Default to Hardcore on the direct-launch + fresh paths Default to Hardcore only set the play-options modal's focus, so the direct launch (playGame -> gameLaunchDelegate.launchGame with forResume=true) and the fresh-game mode picker ignored it and started/resumed casual -- which is what a game with a save actually hits. playGame now passes overrideLaunchMode=RESUME_HARDCORE for a built-in game when the setting is on and RetroAchievements is signed in; showFreshGameModeSelection pre-focuses New Hardcore. New shouldDefaultToHardcoreResume() centralizes the decision (setting + RA login). Verified on-device that the pref itself was persisted true, so this was purely the launch path not consulting it. * launch: centralize Default to Hardcore in the shared launch path The default-to-hardcore decision only lived in GameDetailViewModel.playGame, so launching from the home screen, library, dual-screen, or secondary home (which all call GameLaunchDelegate.launchGame directly) still resumed casual. Move it into GameLaunchDelegate's launch-mode when() alongside isActiveSaveHardcore: a built-in game defaults to RESUME_HARDCORE when the setting is on and RetroAchievements is signed in. Every launch entry point now honors it. Revert the per-call-site check in playGame and drop the now-unused PlayOptionsDelegate.shouldDefaultToHardcoreResume; explicit modal choices (overrideLaunchMode) still take precedence. * cleanup: dedup channel-delete, autosave-channel check, and modal-open Post-review /simplify pass on the server-save + default-to-hardcore changes: extract SaveChannelSavesDelegate.deleteChannelEverywhere (was copy-pasted between confirmDeleteChannel and confirmDeleteLegacyChannel); add SaveSyncApiClient.isAutosaveChannel() for the null-or-"autosave" check, used by SaveUploader's autocleanup gate and computeUploadFileName; extract PlayOptionsDelegate.openModal(state, preferred) shared by showPlayOptions and showFreshGameModeSelection; and gate shouldShowModeSelection on the cheap raRepository.isLoggedIn() before the networked getUnifiedSaves. * review: address maintainer feedback on the save/state PR - cores: restrict vba_next to platforms = setOf("gba"). VBA Next only loads .gba, so listing gb/gbc made it a dead-end selectable core and let a stale vba_next selection on a GB/GBC game pass resolveBuiltinCoreId validation, suppressing the fallback toast. - launch: gate Default to Hardcore on game.achievementCount > 0. hardcoreMode disables save states, rewind, and cheats regardless of whether an RA session resolves, so forcing it on an achievement-less game only removes features, and its hardcore-tagged save would ratchet future resumes into hardcore even after the setting is turned off. - gamedetail: skip the Start Game server fetch when offline. GetUnifiedSavesUseCase gains includeServer; showPlayOptions and shouldShowModeSelection pass NetworkUtils.isOnline so an offline launch no longer waits out checkSavesForGame's 30s connect timeout. The blocking fetch is kept when online. - saves: correct the slot-delete dialog copy to "from the server and local storage. This cannot be undone.", matching the delete now removing the server copy. - libretro: surface the casual-into-hardcore fallback. RestoreResult gains casualSaveInHardcore, set when RESUME_HARDCORE falls back to a non-hardcore save, and LibretroActivity shows "Continuing casual save in hardcore". - test: cover the activeSaveApplied early-return in restoreSaveForLaunchMode (the on-disk .srm must win over the cache's hardcore pick). - cleanup: remove inline narration comments per project standard; move the genuinely-novel rationale into docblocks.
1 parent 39a604d commit c6dc57d

34 files changed

Lines changed: 618 additions & 178 deletions

app/src/main/kotlin/com/nendo/argosy/core/emulator/LibretroSettingDef.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,21 +144,31 @@ sealed class LibretroSettingDef(
144144
key = "autoSaveState",
145145
section = "saving",
146146
title = "Save State on Exit",
147+
subtitle = "Applies to casual games only",
147148
type = SettingType.Switch
148149
)
149150

150151
data object AutoRestoreState : LibretroSettingDef(
151152
key = "autoRestoreState",
152153
section = "saving",
153154
title = "Restore State on Launch",
155+
subtitle = "Applies to casual games only",
154156
type = SettingType.Switch
155157
)
156158

157159
data object HwCoreSaveStates : LibretroSettingDef(
158160
key = "hwCoreSaveStates",
159161
section = "saving",
160162
title = "Hardware-Core Save States",
161-
subtitle = "Experimental; cores like Dolphin may black-screen or fail to resume",
163+
subtitle = "Experimental; cores like Dolphin may black-screen or fail to resume. Applies to casual games only",
164+
type = SettingType.Switch
165+
)
166+
167+
data object DefaultToHardcore : LibretroSettingDef(
168+
key = "defaultToHardcore",
169+
section = "saving",
170+
title = "Default to Hardcore",
171+
subtitle = "When launching a game, default to hardcore instead of casual",
162172
type = SettingType.Switch
163173
)
164174

@@ -183,7 +193,8 @@ sealed class LibretroSettingDef(
183193
AudioVolume,
184194
AutoSaveState,
185195
AutoRestoreState,
186-
HwCoreSaveStates
196+
HwCoreSaveStates,
197+
DefaultToHardcore
187198
)
188199

189200
val SECTIONS: Map<String, String> = mapOf(

app/src/main/kotlin/com/nendo/argosy/data/emulator/GameLauncher.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ class GameLauncher @Inject constructor(
8787
private val coreSystemDataManager: CoreSystemDataManager,
8888
private val gameFileDao: GameFileDao,
8989
private val emulatorSaveConfigRepository: com.nendo.argosy.data.repository.EmulatorSaveConfigRepository,
90-
private val saveHandlerRegistry: com.nendo.argosy.data.sync.platform.PlatformSaveHandlerRegistry
90+
private val saveHandlerRegistry: com.nendo.argosy.data.sync.platform.PlatformSaveHandlerRegistry,
91+
private val libretroStatePathResolver: LibretroStatePathResolver,
92+
private val notificationManager: com.nendo.argosy.core.notification.NotificationManager
9193
) {
9294
private val shellAmAvailable: Boolean by lazy {
9395
try {
@@ -521,7 +523,9 @@ class GameLauncher @Inject constructor(
521523
val builtinBesideRom = emulatorSaveConfigRepository.getByEmulator("builtin")?.savesBesideRom == true
522524
val effectiveSavePath = if (builtinBesideRom) romFile.parent
523525
else platformLibretroOverride?.savePath ?: builtinSettings.customSavePath
524-
val effectiveStatePath = platformLibretroOverride?.statePath ?: builtinSettings.customStatePath
526+
val effectiveStatePath = libretroStatePathResolver
527+
.liveStateBaseDir(platformLibretroOverride?.statePath, builtinSettings.customStatePath)
528+
.absolutePath
525529
return Intent(context, LibretroActivity::class.java).apply {
526530
putExtra(LibretroActivity.EXTRA_ROM_PATH, romFile.absolutePath)
527531
putExtra(LibretroActivity.EXTRA_VARIANT_FILE_ID, variantFileId ?: -1L)
@@ -535,7 +539,7 @@ class GameLauncher @Inject constructor(
535539
putExtra(LibretroActivity.EXTRA_CORE_VAR_KEYS, coreVariables.map { it.key }.toTypedArray())
536540
putExtra(LibretroActivity.EXTRA_CORE_VAR_VALUES, coreVariables.map { it.value }.toTypedArray())
537541
effectiveSavePath?.let { putExtra(LibretroActivity.EXTRA_SAVES_DIR, it) }
538-
effectiveStatePath?.let { putExtra(LibretroActivity.EXTRA_STATES_DIR, it) }
542+
putExtra(LibretroActivity.EXTRA_STATES_DIR, effectiveStatePath)
539543
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
540544
}
541545
}
@@ -1061,11 +1065,13 @@ class GameLauncher @Inject constructor(
10611065
private suspend fun resolveBuiltinCoreId(game: GameEntity): String? {
10621066
val validCoreIds = com.nendo.argosy.libretro.LibretroCoreRegistry
10631067
.getCoresForPlatform(game.platformSlug).map { it.coreId }.toSet()
1068+
var rejectedCore: String? = null
10641069

10651070
fun accept(coreId: String?, source: String): String? {
10661071
if (coreId.isNullOrBlank()) return null
10671072
if (coreId !in validCoreIds) {
10681073
Logger.warn(TAG, "[BuiltIn] ignoring unknown core '$coreId' from $source for ${game.platformSlug}")
1074+
if (rejectedCore == null) rejectedCore = coreId
10691075
return null
10701076
}
10711077
Logger.debug(TAG, "[BuiltIn] core selection: $source -> $coreId")
@@ -1079,6 +1085,16 @@ class GameLauncher @Inject constructor(
10791085
val default = com.nendo.argosy.libretro.LibretroCoreRegistry
10801086
.getDefaultCoreForPlatform(game.platformSlug)?.coreId
10811087
Logger.debug(TAG, "[BuiltIn] core selection: registry default -> $default")
1088+
1089+
val rejected = rejectedCore
1090+
if (rejected != null && default != null) {
1091+
val registry = com.nendo.argosy.libretro.LibretroCoreRegistry
1092+
notificationManager.show(
1093+
title = "Failed to load ${registry.displayNameFor(rejected)}, " +
1094+
"using ${registry.displayNameFor(default)} instead",
1095+
type = com.nendo.argosy.core.notification.NotificationType.WARNING
1096+
)
1097+
}
10821098
return default
10831099
}
10841100

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.nendo.argosy.data.emulator
2+
3+
import android.content.Context
4+
import com.nendo.argosy.data.local.dao.GameDao
5+
import com.nendo.argosy.data.local.dao.PlatformLibretroSettingsDao
6+
import com.nendo.argosy.data.preferences.UserPreferencesRepository
7+
import com.nendo.argosy.libretro.LibretroStateSlots
8+
import com.nendo.argosy.util.AppPaths
9+
import dagger.hilt.android.qualifiers.ApplicationContext
10+
import kotlinx.coroutines.flow.first
11+
import java.io.File
12+
import javax.inject.Inject
13+
import javax.inject.Singleton
14+
15+
/**
16+
* Single source of truth for the built-in libretro core's FLAT live save-state directory.
17+
*
18+
* Live states are flat ({base}/rom.state.X), mirroring SRAM and external emulators; channels
19+
* live only in the cache. The base dir mirrors the precedence [GameLauncher] uses when it builds
20+
* the launch intent: per-platform override -> global custom path -> default
21+
* ({filesDir}/libretro/states). Routing the cache/restore side through here keeps it in step with
22+
* the live engine and fixes the old bug where the cache side used StatePathRegistry's unexpanded
23+
* "{filesDir}/libretro/states" token for built-in.
24+
*
25+
* Keyed by gameId because the cache/restore callers have a gameId but not always the numeric
26+
* platformId the per-platform override is keyed on. Variant isolation ({base}/variants/{id}) is
27+
* applied by LibretroActivity at launch and is not modelled here (cache/restore is non-variant).
28+
*/
29+
@Singleton
30+
class LibretroStatePathResolver @Inject constructor(
31+
@ApplicationContext private val context: Context,
32+
private val gameDao: GameDao,
33+
private val platformLibretroSettingsDao: PlatformLibretroSettingsDao,
34+
private val userPreferencesRepository: UserPreferencesRepository,
35+
) {
36+
suspend fun liveStateBaseDir(gameId: Long): File {
37+
val platformId = gameDao.getById(gameId)?.platformId
38+
val platformOverride = platformId?.let { platformLibretroSettingsDao.getByPlatformId(it)?.statePath }
39+
val custom = userPreferencesRepository.getBuiltinEmulatorSettings().first().customStatePath
40+
return liveStateBaseDir(platformOverride, custom)
41+
}
42+
43+
/**
44+
* Overload for callers ([GameLauncher]) that already hold the resolved per-platform override and
45+
* custom paths -- avoids the extra game/platform/prefs lookups the gameId overload performs.
46+
*/
47+
fun liveStateBaseDir(platformStatePath: String?, customStatePath: String?): File {
48+
val base = platformStatePath?.takeIf { it.isNotBlank() }
49+
?: customStatePath?.takeIf { it.isNotBlank() }
50+
?: AppPaths.libretroStatesDir(context.filesDir).absolutePath
51+
return File(base)
52+
}
53+
54+
fun liveStateFile(baseDir: File, romBaseName: String, slotNumber: Int): File =
55+
File(baseDir, LibretroStateSlots.fileName(romBaseName, slotNumber))
56+
}

app/src/main/kotlin/com/nendo/argosy/data/preferences/BuiltinEmulatorPreferencesRepository.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class BuiltinEmulatorPreferencesRepository @Inject constructor(
4848
val BUILTIN_AUTO_RESTORE_STATE = booleanPreferencesKey("builtin_auto_restore_state")
4949
val BUILTIN_AUTO_RESTORE_STATE_MODE = stringPreferencesKey("builtin_auto_restore_state_mode")
5050
val BUILTIN_HW_CORE_SAVE_STATES = booleanPreferencesKey("builtin_hw_core_save_states")
51+
val BUILTIN_DEFAULT_TO_HARDCORE = booleanPreferencesKey("builtin_default_to_hardcore")
5152
val BUILTIN_CUSTOM_SAVE_PATH = stringPreferencesKey("builtin_custom_save_path")
5253
val BUILTIN_CUSTOM_STATE_PATH = stringPreferencesKey("builtin_custom_state_path")
5354
val BUILTIN_MIGRATION_V1 = booleanPreferencesKey("builtin_migration_v2")
@@ -101,6 +102,7 @@ class BuiltinEmulatorPreferencesRepository @Inject constructor(
101102
?: (prefs[Keys.BUILTIN_AUTO_RESTORE_STATE_MODE] != "off"),
102103
autoRestoreStateMode = prefs[Keys.BUILTIN_AUTO_RESTORE_STATE_MODE] ?: "restore",
103104
hwCoreSaveStatesEnabled = prefs[Keys.BUILTIN_HW_CORE_SAVE_STATES] ?: false,
105+
defaultToHardcore = prefs[Keys.BUILTIN_DEFAULT_TO_HARDCORE] ?: false,
104106
customSavePath = prefs[Keys.BUILTIN_CUSTOM_SAVE_PATH],
105107
customStatePath = prefs[Keys.BUILTIN_CUSTOM_STATE_PATH],
106108
architectureOverride = prefs[Keys.BUILTIN_ARCHITECTURE_OVERRIDE],
@@ -277,6 +279,10 @@ class BuiltinEmulatorPreferencesRepository @Inject constructor(
277279
dataStore.edit { it[Keys.BUILTIN_HW_CORE_SAVE_STATES] = enabled }
278280
}
279281

282+
suspend fun setBuiltinDefaultToHardcore(enabled: Boolean) {
283+
dataStore.edit { it[Keys.BUILTIN_DEFAULT_TO_HARDCORE] = enabled }
284+
}
285+
280286
suspend fun setBuiltinCustomSavePath(path: String?) {
281287
dataStore.edit {
282288
if (path != null) it[Keys.BUILTIN_CUSTOM_SAVE_PATH] = path

app/src/main/kotlin/com/nendo/argosy/data/preferences/UserPreferencesRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ data class BuiltinEmulatorSettings(
421421
val autoRestoreState: Boolean = true,
422422
val autoRestoreStateMode: String = "restore",
423423
val hwCoreSaveStatesEnabled: Boolean = false,
424+
val defaultToHardcore: Boolean = false,
424425
val customSavePath: String? = null,
425426
val customStatePath: String? = null,
426427
val architectureOverride: String? = null,

app/src/main/kotlin/com/nendo/argosy/data/repository/LibretroSettingsRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ class LibretroSettingsRepository @Inject constructor(
125125
suspend fun setBuiltinHwCoreSaveStates(enabled: Boolean) =
126126
builtinPrefs.setBuiltinHwCoreSaveStates(enabled)
127127

128+
suspend fun setBuiltinDefaultToHardcore(enabled: Boolean) =
129+
builtinPrefs.setBuiltinDefaultToHardcore(enabled)
130+
128131
suspend fun setBuiltinCustomSavePath(path: String?) =
129132
builtinPrefs.setBuiltinCustomSavePath(path)
130133

app/src/main/kotlin/com/nendo/argosy/data/repository/SaveSyncApiClient.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,11 +463,17 @@ class SaveSyncApiClient @Inject constructor(
463463
internal const val MIN_VALID_SAVE_SIZE_BYTES = 100L
464464
internal const val AUTOCLEANUP_LIMIT = 10
465465

466+
/**
467+
* The autosave channel is stored as either null or the literal "autosave" (a restore sets
468+
* the string form); recognize both so callers do not each re-inline the check.
469+
*/
470+
fun isAutosaveChannel(channelName: String?): Boolean =
471+
channelName == null || channelName.equals(AUTOSAVE_SLOT_NAME, ignoreCase = true)
472+
466473
fun computeUploadFileName(localSavePath: String?, channelName: String?, romBaseName: String?): String {
467474
val baseName = when {
468-
channelName == null || channelName.equals(AUTOSAVE_SLOT_NAME, ignoreCase = true) ->
469-
romBaseName ?: DEFAULT_SAVE_NAME
470-
else -> channelName
475+
isAutosaveChannel(channelName) -> romBaseName ?: DEFAULT_SAVE_NAME
476+
else -> channelName ?: DEFAULT_SAVE_NAME
471477
}
472478
val ext = localSavePath?.let { java.io.File(it) }?.let { file ->
473479
when {

app/src/main/kotlin/com/nendo/argosy/data/repository/SaveUploader.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ class SaveUploader @Inject constructor(
313313
val filePart = MultipartBody.Part.createFormData("saveFile", uploadFileName, requestBody)
314314

315315
val slotForUpload = channelName ?: SaveSyncApiClient.AUTOSAVE_SLOT_NAME
316-
val isAutosaveSlot = channelName == null
316+
val isAutosaveSlot = SaveSyncApiClient.isAutosaveChannel(channelName)
317317
val autocleanupEnabled = isAutosaveSlot
318318
val autocleanupLimit = if (isAutosaveSlot) SaveSyncApiClient.AUTOCLEANUP_LIMIT else null
319319
val response = if (deviceId != null) {

app/src/main/kotlin/com/nendo/argosy/data/repository/StateCacheManager.kt

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class StateCacheManager @Inject constructor(
6262
private val coreVersionExtractor: CoreVersionExtractor,
6363
private val retroArchConfigParser: RetroArchConfigParser,
6464
private val retroArchPathResolver: com.nendo.argosy.data.emulator.RetroArchPathResolver,
65+
private val libretroStatePathResolver: com.nendo.argosy.data.emulator.LibretroStatePathResolver,
6566
private val saveSyncApiClient: SaveSyncApiClient,
6667
private val payloadCodec: com.nendo.argosy.data.sync.SyncPayloadCodec
6768
) {
@@ -134,13 +135,7 @@ class StateCacheManager @Inject constructor(
134135
retroArchPathResolver.resolveStateDirectories(req)
135136
}
136137
userStateOverride != null -> listOf(userStateOverride)
137-
emulatorId == "builtin" -> {
138-
val baseDir = AppPaths.libretroStatesDir(context.filesDir)
139-
val channelDirs = baseDir.listFiles { f -> f.isDirectory }
140-
?.map { it.absolutePath }
141-
?: listOf(File(baseDir, "default").absolutePath)
142-
channelDirs
143-
}
138+
emulatorId == "builtin" -> listOf(libretroStatePathResolver.liveStateBaseDir(gameId).absolutePath)
144139
else -> StatePathRegistry.resolvePath(config, platformId)
145140
}
146141
Log.d(TAG, "Searching ${statePaths.size} paths: $statePaths")
@@ -487,7 +482,12 @@ class StateCacheManager @Inject constructor(
487482
emulatorId: String? = null,
488483
coreName: String? = null,
489484
romPath: String? = null,
485+
gameId: Long? = null,
490486
): String? {
487+
if (emulatorId == "builtin" && gameId != null) {
488+
val baseDir = libretroStatePathResolver.liveStateBaseDir(gameId)
489+
return libretroStatePathResolver.liveStateFile(baseDir, romBaseName, slotNumber).absolutePath
490+
}
491491
val paths = if (emulatorId != null &&
492492
com.nendo.argosy.data.emulator.RetroArchPathResolver.isRetroArch(emulatorId)
493493
) {
@@ -560,16 +560,17 @@ class StateCacheManager @Inject constructor(
560560
suspend fun getStatesForChannelAndCore(gameId: Long, channelName: String?, coreId: String?): List<StateCacheEntity> =
561561
stateCacheDao.getByChannelAndCore(gameId, channelName, coreId)
562562

563-
suspend fun deleteAutoStateFromDisk(
563+
suspend fun deleteAutoResumeStatesFromDisk(
564564
emulatorId: String,
565565
romPath: String,
566566
platformSlug: String,
567567
emulatorPackage: String?,
568-
coreId: String?
568+
coreId: String?,
569+
gameId: Long? = null,
569570
): Boolean = withContext(Dispatchers.IO) {
570571
val config = StatePathRegistry.getConfig(emulatorId)
571572
if (config == null) {
572-
Log.d(TAG, "deleteAutoStateFromDisk: No state config for emulator: $emulatorId")
573+
Log.d(TAG, "deleteAutoResumeStatesFromDisk: No state config for emulator: $emulatorId")
573574
return@withContext false
574575
}
575576

@@ -590,26 +591,33 @@ class StateCacheManager @Inject constructor(
590591
retroArchPathResolver.resolveStateDirectories(req)
591592
}
592593
userStateOverride != null -> listOf(userStateOverride)
594+
emulatorId == "builtin" && gameId != null ->
595+
listOf(libretroStatePathResolver.liveStateBaseDir(gameId).absolutePath)
593596
else -> StatePathRegistry.resolvePath(config, platformSlug)
594597
}
595598

596-
val autoFileName = config.slotPattern.buildFileName(romBaseName, -1)
599+
val fileNames = listOf(
600+
config.slotPattern.buildFileName(romBaseName, -1),
601+
com.nendo.argosy.libretro.LibretroStateSlots.fileName(
602+
romBaseName, com.nendo.argosy.libretro.LibretroStateSlots.RESUME_SLOT
603+
),
604+
)
597605

606+
var deletedAny = false
598607
for (path in statePaths) {
599608
val stateDir = File(path)
600609
if (!stateDir.exists()) continue
601-
602-
val autoStateFile = File(stateDir, autoFileName)
603-
if (autoStateFile.exists()) {
604-
autoStateFile.delete()
605-
File("${autoStateFile.absolutePath}.png").takeIf { it.exists() }?.delete()
606-
Log.d(TAG, "Deleted auto-state from disk: ${autoStateFile.absolutePath}")
607-
return@withContext true
610+
for (name in fileNames) {
611+
val file = File(stateDir, name)
612+
if (file.exists() && file.delete()) {
613+
File("${file.absolutePath}.png").takeIf { it.exists() }?.delete()
614+
Log.d(TAG, "Deleted live state: ${file.absolutePath}")
615+
deletedAny = true
616+
}
608617
}
609618
}
610-
611-
Log.d(TAG, "deleteAutoStateFromDisk: No auto-state found for $romBaseName")
612-
false
619+
if (!deletedAny) Log.d(TAG, "deleteAutoResumeStatesFromDisk: nothing to delete for $romBaseName")
620+
deletedAny
613621
}
614622

615623
suspend fun clearAllCache() = withContext(Dispatchers.IO) {

app/src/main/kotlin/com/nendo/argosy/domain/usecase/save/GetUnifiedSavesUseCase.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@ class GetUnifiedSavesUseCase @Inject constructor(
2323
) {
2424
suspend operator fun invoke(
2525
gameId: Long,
26-
expandHistory: Boolean = false
26+
expandHistory: Boolean = false,
27+
includeServer: Boolean = true
2728
): List<UnifiedSaveEntry> {
2829
saveCacheManager.dedupeIdenticalCaches(gameId)
2930
val localCaches = saveCacheManager.getCachesForGameOnce(gameId)
3031
val game = gameDao.getById(gameId)
3132
val rommId = game?.rommId
3233
val romBaseName = game?.localPath?.let { File(it).nameWithoutExtension }
3334

34-
val serverSaves = if (rommId != null) {
35+
val serverSaves = if (rommId != null && includeServer) {
3536
saveSyncRepository.checkSavesForGame(gameId, rommId)
3637
} else {
3738
emptyList()

0 commit comments

Comments
 (0)