Skip to content

Commit 73c235d

Browse files
committed
feat: SDK cloud save bridge with Ludusavi + launch-time prompt
Adds per-container support for "Pattern B" SDK-cloud games (Dead Cells-style) that read saves from a subdirectory of their install directory rather than from <userdata>/<appid>/remote/. Replaces the hardcoded one-entry registry with the Ludusavi save-path manifest (~1944 Steam games matching Pattern B). Container setting: - New sdkCloudSaveSubdir field on Container/ContainerData, persisted through JSON and Compose Saver. Empty = bridge disabled. - "Cloud Save Bridge" section in GeneralTab (visible when Launch Steam Client is on) with text field + Use Recommended / Detect / Clear buttons, confirmation dialog on first activation, and validation rejecting path separators, .., and drive letters. Ludusavi integration (utils/LudusaviRegistry.kt): - Fetches manifest.yaml from mtkennerly/ludusavi-manifest via the existing OkHttp client. Streaming line-parser avoids the OOM that SnakeYAML's eager map load caused on the 5 MB file. - Filters to Steam-IDed entries with <base>/<subdir> save paths tagged "save" and applicable to Windows (or no OS filter). - Writes ~190 KB filtered JSON to filesDir/ludusavi_pattern_b.json, 7-day TTL, falls back to stale disk cache on fetch failure. - Primed in background at PluviaApp.onCreate so Use Recommended and the launch-time prompt are instant after the first session. Launch-time prompt: - preLaunchApp runs a Pattern B check (Ludusavi match AND no saveFilePatterns in PICS UFS) before cloud sync. Match fires an SDK_CLOUD_BRIDGE_SUGGESTION dialog with Enable / Skip / Don't ask again. Catches users who install with real-Steam default and never touch settings. - Don't-ask-again persists per-container as extraData.sdkCloudBridgePromptDismissed. Runtime mirror (utils/SteamUtils.kt): - sdkCloudGameSaveDir now reads the user-configured subdir only; no implicit fallback, to avoid REPLACE_EXISTING copies into a guessed dir that might be wrong. - detectSdkCloudSaveSubdir resolves paths via the container's own rootDir rather than the global xuser symlink so it works even when a different container is activated. Removed: - Bundled assets/sdk_cloud_save_bridge.json (single hardcoded entry); Ludusavi covers it plus ~1943 others. Cosmetic: replaced "Pluvia" with "GameNative" in comments/strings written on this branch. PluviaApp/PluviaTheme/PluviaPreferences class names and storage keys left alone.
1 parent cc29e52 commit 73c235d

15 files changed

Lines changed: 738 additions & 15 deletions

File tree

app/src/main/java/app/gamenative/PluviaApp.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import app.gamenative.service.DownloadService
1313
import app.gamenative.service.SteamService
1414
import app.gamenative.utils.ContainerMigrator
1515
import app.gamenative.utils.IntentLaunchManager
16+
import app.gamenative.utils.LudusaviRegistry
1617
import app.gamenative.utils.PlayIntegrity
1718
import java.io.File
1819
import javax.inject.Inject
@@ -87,6 +88,15 @@ class PluviaApp : SplitCompatApplication() {
8788
)
8889
}
8990

91+
// Prime the Ludusavi save-path registry in the background. The loader itself
92+
// checks cache freshness (7-day TTL) and only hits the network when stale — so
93+
// the SDK Cloud Save Bridge "Use Recommended" button and the launch-time prompt
94+
// are both instant for users once the cache is populated.
95+
appScope.launch {
96+
runCatching { LudusaviRegistry.primeCache(applicationContext) }
97+
.onFailure { Timber.w(it, "Background Ludusavi registry refresh failed") }
98+
}
99+
90100
// Clear any stale temporary config overrides from previous app sessions
91101
try {
92102
IntentLaunchManager.clearAllTemporaryOverrides()

app/src/main/java/app/gamenative/service/SteamAutoCloud.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ object SteamAutoCloud {
125125

126126
// Auto-Cloud games declare saveFilePatterns rooted outside SteamUserData (usually
127127
// GameInstall or WinAppData*). Their cloud payloads are sometimes surfaced without a
128-
// prefix — either because the cloud entry was uploaded by an old Pluvia build under
128+
// prefix — either because the cloud entry was uploaded by an old GameNative build under
129129
// SteamUserData, or because Steam's getAppFileListChange returned an empty prefix list.
130130
// Either way, naively writing those files to <SteamUserData>/<appid>/remote/ hides them
131131
// from the game, which reads from the pattern's real location. Rebase to the matching

app/src/main/java/app/gamenative/service/SteamService.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2217,14 +2217,14 @@ class SteamService : Service(), IChallengeUrlChanged {
22172217
}
22182218

22192219
try {
2220-
// Pluvia is the sole cloud client in both modes: Wine-Steam has
2220+
// GameNative is the sole cloud client in both modes: Wine-Steam has
22212221
// cloudenabled=0 written to localconfig.vdf AND sharedconfig.vdf and
22222222
// is launched with -no-browser so it performs no cloud I/O. Running
2223-
// Pluvia's AutoCloud on launch is required in real-Steam mode so users
2223+
// GameNative's AutoCloud on launch is required in real-Steam mode so users
22242224
// get fresh saves from other devices before the Wine-hosted game loads
22252225
// them — skipping this on launch caused Dead Cells to boot into an
22262226
// empty save. The original "save conflict" dialog that motivated a
2227-
// skip was driven by a ChangeNumber race between Wine-Steam and Pluvia
2227+
// skip was driven by a ChangeNumber race between Wine-Steam and GameNative
22282228
// both writing cloud state; with Wine-Steam's cloud fully suppressed
22292229
// there is no second writer to race with.
22302230
val maxAttempts = 3

app/src/main/java/app/gamenative/ui/PluviaMain.kt

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import app.gamenative.ui.theme.PluviaTheme
9090
import app.gamenative.ui.util.SnackbarManager
9191
import app.gamenative.utils.BestConfigService
9292
import app.gamenative.utils.ContainerUtils
93+
import app.gamenative.utils.SteamUtils
9394
import app.gamenative.utils.PlatformAuthUtils
9495
import app.gamenative.utils.CustomGameScanner
9596
import app.gamenative.utils.ManifestInstaller
@@ -789,6 +790,61 @@ fun PluviaMain(
789790
}
790791
}
791792

793+
DialogType.SDK_CLOUD_BRIDGE_SUGGESTION -> {
794+
val relaunch = {
795+
preLaunchApp(
796+
context = context,
797+
appId = state.launchedAppId,
798+
skipBridgePrompt = true,
799+
setLoadingDialogVisible = viewModel::setLoadingDialogVisible,
800+
setLoadingProgress = viewModel::setLoadingDialogProgress,
801+
setLoadingMessage = viewModel::setLoadingDialogMessage,
802+
setMessageDialogState = setMessageDialogState,
803+
onSuccess = viewModel::launchApp,
804+
isOffline = viewModel.isOffline.value,
805+
)
806+
}
807+
onConfirmClick = {
808+
// Enable: write Ludusavi's suggested subdir, then continue the launch.
809+
msgDialogState = MessageDialogState(false)
810+
CoroutineScope(Dispatchers.IO).launch {
811+
val gameId = ContainerUtils.extractGameIdFromContainerId(state.launchedAppId)
812+
val rec = runCatching {
813+
SteamUtils.getRecommendedSdkCloudSaveSubdirAsync(context, gameId)
814+
}.getOrNull()
815+
if (rec != null) {
816+
runCatching {
817+
val container = ContainerUtils.getContainer(context, state.launchedAppId)
818+
container.sdkCloudSaveSubdir = rec.subdir
819+
container.saveData()
820+
}.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") }
821+
}
822+
withContext(Dispatchers.Main) { relaunch() }
823+
}
824+
}
825+
onDismissClick = {
826+
// Skip this time — continue launch without setting the field.
827+
msgDialogState = MessageDialogState(false)
828+
relaunch()
829+
}
830+
onActionClick = {
831+
// Don't ask again for this game.
832+
msgDialogState = MessageDialogState(false)
833+
CoroutineScope(Dispatchers.IO).launch {
834+
runCatching {
835+
val container = ContainerUtils.getContainer(context, state.launchedAppId)
836+
container.putExtra("sdkCloudBridgePromptDismissed", "1")
837+
container.saveData()
838+
}.onFailure { Timber.w(it, "Failed to persist sdkCloudBridgePromptDismissed") }
839+
withContext(Dispatchers.Main) { relaunch() }
840+
}
841+
}
842+
onDismissRequest = {
843+
msgDialogState = MessageDialogState(false)
844+
relaunch()
845+
}
846+
}
847+
792848
DialogType.SYNC_CONFLICT -> {
793849
onConfirmClick = {
794850
preLaunchApp(
@@ -1519,6 +1575,7 @@ fun preLaunchApp(
15191575
preferredSave: SaveLocation = SaveLocation.None,
15201576
useTemporaryOverride: Boolean = false,
15211577
skipCloudSync: Boolean = false,
1578+
skipBridgePrompt: Boolean = false,
15221579
setLoadingDialogVisible: (Boolean) -> Unit,
15231580
setLoadingProgress: (Float) -> Unit,
15241581
setLoadingMessage: (String) -> Unit,
@@ -1550,6 +1607,41 @@ fun preLaunchApp(
15501607
val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId)
15511608
val isLocalSavesOnly = ContainerUtils.isLocalSavesOnly(context, appId)
15521609

1610+
// First-launch suggestion for Pattern B SDK-cloud games (e.g. Dead Cells).
1611+
// Fires only when real-Steam is on, the subdir is blank, the user hasn't dismissed,
1612+
// Ludusavi knows this game, AND it has no Auto-Cloud saveFilePatterns in PICS UFS.
1613+
// That intersection is a reliable Pattern B signal and keeps false positives low.
1614+
if (!skipBridgePrompt &&
1615+
gameSource == GameSource.STEAM &&
1616+
container.isLaunchRealSteam &&
1617+
container.sdkCloudSaveSubdir.isBlank() &&
1618+
container.getExtra("sdkCloudBridgePromptDismissed", "") != "1"
1619+
) {
1620+
val rec = runCatching {
1621+
SteamUtils.shouldSuggestSdkCloudBridge(context, gameId)
1622+
}.getOrNull()
1623+
if (rec != null) {
1624+
Timber.i("Pattern B bridge prompt for appId=$gameId (\"${rec.name}\" -> \"${rec.subdir}\")")
1625+
setLoadingDialogVisible(false)
1626+
setMessageDialogState(
1627+
MessageDialogState(
1628+
visible = true,
1629+
type = DialogType.SDK_CLOUD_BRIDGE_SUGGESTION,
1630+
title = context.getString(R.string.sdk_cloud_bridge_prompt_title),
1631+
message = context.getString(
1632+
R.string.sdk_cloud_bridge_prompt_message,
1633+
rec.name.ifEmpty { gameId.toString() },
1634+
rec.subdir,
1635+
),
1636+
confirmBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_enable),
1637+
dismissBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_skip),
1638+
actionBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_dont_ask),
1639+
),
1640+
)
1641+
return@launch
1642+
}
1643+
}
1644+
15531645
// When "Open container" is used we boot to desktop/file manager only — skip executable check
15541646
if (!bootToContainer) {
15551647
// Verify we have a launch executable for all platforms before proceeding (fail fast, avoid black screen)

app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ fun ContainerConfigDialog(
143143
default: Boolean = false,
144144
title: String,
145145
initialConfig: ContainerData = ContainerData(),
146+
appId: Int? = null,
146147
onDismissRequest: () -> Unit,
147148
onSave: (ContainerData) -> Unit,
148149
) {
@@ -1037,6 +1038,7 @@ fun ContainerConfigDialog(
10371038
applyScreenSizeToConfig = applyScreenSizeToConfig,
10381039
vkd3dForcedVersion = { vkd3dForcedVersion() },
10391040
currentDxvkContext = { currentDxvkContext() },
1041+
appId = appId,
10401042
)
10411043

10421044
LoadingDialog(

app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,7 @@ class ContainerConfigState(
135135
val applyScreenSizeToConfig: () -> Unit,
136136
val vkd3dForcedVersion: () -> String,
137137
val currentDxvkContext: () -> ManifestComponentHelper.DxvkContext,
138+
/** Steam appId for this container, or null if not a Steam container. Used by
139+
* per-game UI like the SDK-cloud save subdir detect button. */
140+
val appId: Int? = null,
138141
)

0 commit comments

Comments
 (0)