diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..9a9417516 --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,425 @@ +package com.winlator.cmod.app.shell + +import android.content.Context +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.cmod.R +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** A single Steam launch option (appinfo `config.launch` entry). */ +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated. + val executable: String, + val arguments: String, + val label: String, +) + +// Workshop window palette (WsBg scheme). +private val WsBg = Color(0xFF12121B) +private val WsBorder = Color(0xFF2A2A3A) +private val WsAccent = Color(0xFF1A9FFF) +private val WsAccentGlow = Color(0xFF58A6FF) +private val WsTextPrimary = Color(0xFFF0F4FF) +private val WsTextSecondary = Color(0xFF93A6BC) +private val WsScrim = Color(0xFF000000) + +/** Workshop-styled modal listing the game's `config.launch` entries; selecting a row persists it. */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + val registry = remember { PaneNavRegistry() } + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onClose) + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(WsScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = WsBg, + border = BorderStroke(1.dp, WsBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = WsBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: wrap short lists instead of stretching to max height. + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(options) { index, option -> + LaunchOptionPickerRow( + option = option, + selected = option == selectedOption, + onClick = { onSelect(option) }, + ) + if (index < options.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsHeader( + gameTitle: String, + optionCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(WsAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = WsAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = WsTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = WsTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val optionCountDescription = stringResource(R.string.store_game_launch_options_count, optionCount) + Surface( + modifier = + Modifier.semantics { + contentDescription = optionCountDescription + }, + color = WsAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = WsAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp).paneNavItem(onActivate = onClose)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.common_ui_close), + tint = WsTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .paneNavItem(onActivate = onClick, tapToSelect = true) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + option.label, + color = if (selected) WsAccentGlow else WsTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + buildString { + append(option.executable) + if (option.arguments.isNotBlank()) { + append(" · ") + append(option.arguments) + } + }, + color = WsTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = WsAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** UI state for the launch-options picker, shared by both game-detail screens. */ +@Stable +internal class SteamLaunchOptionsState { + var options by mutableStateOf>(emptyList()) + var selected by mutableStateOf(null) + var showDialog by mutableStateOf(false) + var reloadToken by mutableStateOf(0) + private set + + fun show() { + reloadToken++ + showDialog = true + } + + fun dismiss() { + showDialog = false + } +} + +/** Loads the launch options from cache while [enabled]; the PICS heal rides picker opens. */ +@Composable +internal fun rememberSteamLaunchOptionsState( + appId: Int, + enabled: Boolean, +): SteamLaunchOptionsState { + val appContext = LocalContext.current.applicationContext + val state = remember(appId) { SteamLaunchOptionsState() } + // reloadToken key: re-read from disk on every picker open (not close) — shortcut + // settings can change the extras behind this screen's back. + LaunchedEffect(appId, enabled, state.reloadToken) { + val ready = enabled && withContext(Dispatchers.IO) { SteamService.isAppInstalled(appId) } + if (!ready) { + state.options = emptyList() + state.selected = null + return@LaunchedEffect + } + val (options, selected) = loadSteamLaunchOptions(appContext, appId) + state.options = options + state.selected = selected + // Cache-only until the picker is opened (reloadToken > 0), so browsing game screens never + // hits the network; the once-per-session PICS heal runs on first open. Shares + // picsRefreshedAppsThisSession with depot-selection so the two can't re-fetch the same app. + if (state.reloadToken > 0 && SteamService.picsRefreshedAppsThisSession.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appContext, appId) + state.options = fresh + state.selected = freshSelected + } else { + // Offline or fetch failed — allow a retry on the next open. + SteamService.picsRefreshedAppsThisSession.remove(appId) + } + } + } + return state +} + +/** Hosts the launch-option picker over a game-detail screen; persists the tapped row. */ +@Composable +internal fun SteamLaunchOptionsDialogHost( + appId: Int, + gameTitle: String, + state: SteamLaunchOptionsState, +) { + val context = LocalContext.current + // Scope outlives the dialog so a tap-then-dismiss still persists and reports failures. + val scope = rememberCoroutineScope() + if (!state.showDialog) return + Dialog( + onDismissRequest = state::dismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = state.options, + selectedOption = state.selected, + onSelect = { option -> + persistSteamLaunchOptionSelection(context, appId, option, scope) { state.selected = it } + }, + onClose = state::dismiss, + ) + } +} + +/** Launch-option list (appinfo config.launch) plus the currently effective selection. */ +private suspend fun loadSteamLaunchOptions( + context: Context, + appId: Int, +): Pair, StoreLaunchOptionItem?> = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(appId)) + val allOptions = + SteamService + .getWindowsLaunchInfos(appId) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + } + // Label kept in the key: stale cached rows have "" args and would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(SteamService.normalizeRelativeExe(it.executable).lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk; case-insensitive to match how + // the launch path resolves these same appinfo paths against the depot files. + val onDisk = allOptions.filter { SteamService.fileExistsIgnoreCase(appDir.path, it.executable) } + val options = onDisk.ifEmpty { allOptions } + // Exact match against the explicitly picked option, or no checkmark at all — + // a manually configured exe is never adopted as a launch option. + val selected = + SteamService.getSelectedLaunchOption(context, appId)?.let { (exe, args) -> + // Normalized like every persist/launch-side compare (separators, drive prefix). + val selectedExe = SteamService.normalizeRelativeExe(exe) + options.firstOrNull { + SteamService.normalizeRelativeExe(it.executable).equals(selectedExe, ignoreCase = true) && + it.arguments == args + } + } + options to selected + } + +private fun persistSteamLaunchOptionSelection( + context: Context, + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, +) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + context.applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 66f62fb8f..0b37df3e7 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.EmojiEvents import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Save @@ -140,6 +141,8 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -278,11 +281,13 @@ internal fun LibraryGameLaunchScreen( showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, showAchievements = onAchievements != null, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onAchievements = { onAchievements?.invoke() }, + onLaunchOptions = onLaunchOptions, ) } @@ -821,11 +826,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showAchievements: Boolean = false, + showLaunchOptions: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onAchievements: () -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -902,6 +909,13 @@ private fun SourceTag( label = stringResource(R.string.steam_achievements_title), ) { menuOpen = false; onAchievements() } } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } } } } diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 7697cf1bc..17ea128ee 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate @@ -155,6 +156,8 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -202,7 +205,9 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = showLaunchOptions && isInstalled + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -313,6 +318,7 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -323,6 +329,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, ) } @@ -824,12 +831,14 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, + showLaunchOptions: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var anchorHeightPx by remember { mutableIntStateOf(0) } Box { @@ -913,6 +922,13 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { onMenuOpenChange(false); onWorkshop() } } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { onMenuOpenChange(false); onLaunchOptions() } + } } } } diff --git a/app/src/main/app/shell/UnifiedActivityDownloads.kt b/app/src/main/app/shell/UnifiedActivityDownloads.kt index 60e18c020..d4bf5e149 100644 --- a/app/src/main/app/shell/UnifiedActivityDownloads.kt +++ b/app/src/main/app/shell/UnifiedActivityDownloads.kt @@ -1356,6 +1356,7 @@ internal fun UnifiedActivity.GameManagerDialog( var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = installed == true) var updateInfo by remember(app.id) { mutableStateOf(null) } var updateStatusText by remember(app.id) { mutableStateOf(null) } val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -1547,6 +1548,8 @@ internal fun UnifiedActivity.GameManagerDialog( showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -1686,6 +1689,8 @@ internal fun UnifiedActivity.GameManagerDialog( onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } @Composable diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt index 8d19bc398..170b74dc7 100644 --- a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt +++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt @@ -1678,6 +1678,9 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( val isEpic = app.id >= 2000000000 val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + val isSteamLibraryGame = !isCustom && !isEpic && !isGog + + val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = isSteamLibraryGame) val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -2412,6 +2415,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -2910,6 +2915,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } } } diff --git a/app/src/main/app/shell/UnifiedActivityLaunch.kt b/app/src/main/app/shell/UnifiedActivityLaunch.kt index d7691b39b..527fb6658 100644 --- a/app/src/main/app/shell/UnifiedActivityLaunch.kt +++ b/app/src/main/app/shell/UnifiedActivityLaunch.kt @@ -270,10 +270,9 @@ internal fun UnifiedActivity.launchSteamGame( return@launch } - val shortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == app.id.toString() - } + // Shared resolver: same canonical shortcut the picker writes to (and no + // per-shortcut icon decode the way loadShortcuts() does). + val shortcut = SteamService.locateSteamShortcut(context, app.id, containerManager) val detectedLaunchExecutable = SteamService.getInstalledExe(app.id) if (shortcut != null) { diff --git a/app/src/main/assets/wnsteam/bionic/steam.exe b/app/src/main/assets/wnsteam/bionic/steam.exe index 1b6d8a125..faa67ea42 100755 Binary files a/app/src/main/assets/wnsteam/bionic/steam.exe and b/app/src/main/assets/wnsteam/bionic/steam.exe differ diff --git a/app/src/main/cpp/wn-steam-launcher/src/main.cpp b/app/src/main/cpp/wn-steam-launcher/src/main.cpp index 2b6445701..cf64bd4a0 100644 --- a/app/src/main/cpp/wn-steam-launcher/src/main.cpp +++ b/app/src/main/cpp/wn-steam-launcher/src/main.cpp @@ -448,14 +448,20 @@ static int count_game_processes(const char* exeName) { // real Steam UI/reaper under Wine to consume the request). Safe vs AlreadyRunning // because the clean-shutdown arm reaps the CM session on exit. Logs the "game // process started pid=" marker WnLauncherStatusTailer treats as launch-complete. -static bool create_process_game(const char* gameExe, const char* exeName) { +static bool create_process_game(const char* gameExe, const char* exeName, + const char* extraArgs) { char cwd[MAX_PATH]; snprintf(cwd, sizeof(cwd), "%s", gameExe); char* slash = strrchr(cwd, '\\'); if (slash) *slash = '\0'; else cwd[0] = '\0'; - char cmd[MAX_PATH + 8]; - snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe); + // Steam appends the localconfig args only when IT launches the game, so the + // fallback must carry the picked entry + custom args (argv[2..]) itself. + char cmd[2048]; + if (extraArgs && *extraArgs) + snprintf(cmd, sizeof(cmd), "\"%s\" %s", gameExe, extraArgs); + else + snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe); STARTUPINFOA si; memset(&si, 0, sizeof(si)); @@ -473,7 +479,8 @@ static bool create_process_game(const char* gameExe, const char* exeName) { return false; } log_line("[wn-launcher] game process started pid=%lu via CreateProcess " - "fallback (\"%s\")", (unsigned long) pi.dwProcessId, exeName); + "fallback (\"%s\") args=[%s]", (unsigned long) pi.dwProcessId, exeName, + extraArgs ? extraArgs : ""); if (pi.hThread) CloseHandle(pi.hThread); if (pi.hProcess) CloseHandle(pi.hProcess); return true; @@ -920,6 +927,18 @@ int main(int argc, char** argv) { return 1; } + // argv[2..] are the picked-entry + custom game args (appended Android-side); only the + // CreateProcess fallback needs them — the LaunchApp path gets them from Steam via localconfig. + std::string fallbackArgs; + for (int i = 2; i < argc; ++i) { + if (!fallbackArgs.empty()) fallbackArgs += ' '; + if (strchr(argv[i], ' ') != nullptr) { + fallbackArgs += '"'; fallbackArgs += argv[i]; fallbackArgs += '"'; + } else { + fallbackArgs += argv[i]; + } + } + const char* kSteamDir = "C:\\Program Files (x86)\\Steam"; SetDllDirectoryA(kSteamDir); SetCurrentDirectoryA(kSteamDir); @@ -1328,6 +1347,22 @@ int main(int argc, char** argv) { appMgr_vt[kVtAppMgr_LaunchApp / 8]; uint64_t gameId = (uint64_t)(appId & 0xFFFFFFu); + // config.launch key of the picker-selected entry (0 = default). Steam launches that + // entry with its own args; the user's custom args ride localconfig LaunchOptions, which + // Steam appends to the command line itself. + const char* launchOptEnv = getenv("WN_STEAM_LAUNCH_OPTION"); + uint32_t uLaunchOption = 0; + if (launchOptEnv && launchOptEnv[0] != '\0') { + char* end = NULL; + unsigned long parsed = strtoul(launchOptEnv, &end, 10); + if (end != launchOptEnv && *end == '\0' && parsed <= 0xFFFF) { + uLaunchOption = (uint32_t) parsed; + } else { + // Env is user-editable; garbage (incl. "-1" → huge) must not silently launch the default. + log_line("[wn-steam-launcher] ignoring invalid WN_STEAM_LAUNCH_OPTION=\"%s\"", launchOptEnv); + } + } + // RefreshAppInfo() slot — re-primes appinfo between MissingConfig retries. void* refreshAppInfoP = appMgr_vt[kVtAppMgr_RefreshAppInfo / 8]; @@ -1335,7 +1370,7 @@ int main(int argc, char** argv) { // the 35s watchdog. const int kMaxLaunchAttempts = 5; for (int attempt = 1; attempt <= kMaxLaunchAttempts && !launchedViaApp; ++attempt) { - uint64_t apiCall = launchApp(appMgr, &gameId, 0, 300, ""); + uint64_t apiCall = launchApp(appMgr, &gameId, uLaunchOption, 300, ""); log_line("[wn-launcher] IClientAppManager.LaunchApp(appId=%u) " "attempt=%d/%d -> HSteamAPICall=0x%llx", appId, attempt, kMaxLaunchAttempts, @@ -1520,12 +1555,19 @@ int main(int argc, char** argv) { if (directExe) { log_line("[wn-launcher] direct-exe mode: launching user-selected \"%s\" via " "CreateProcess (Steam LaunchApp skipped)", exeName); + launchedViaFallback = create_process_game(gameExe, exeName, fallbackArgs.c_str()); + } else if (count_game_processes(exeName) > 0) { + // A slow LaunchApp spawn appeared after we stopped waiting — it already has + // Steam's args; don't double-launch it via CreateProcess. + log_line("[wn-launcher] \"%s\" appeared late via LaunchApp — not falling back", + exeName); + launchedViaApp = true; } else { log_line("[wn-launcher] LaunchApp dispatched but \"%s\" never appeared " "— falling back to CreateProcess (%s)", exeName, launchFailureReason); + launchedViaFallback = create_process_game(gameExe, exeName, fallbackArgs.c_str()); } - launchedViaFallback = create_process_game(gameExe, exeName); } if (launchedViaApp || launchedViaFallback) { diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index c79f8eca3..5cf459c8b 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -384,6 +384,9 @@ class GameSettingsStateHolder { val name = mutableStateOf("") val launchExePath = mutableStateOf("") val launchExeDisplayPath = mutableStateOf("") + + // Read-only: args the selected Steam launch option appends at launch (display only). + val launchOptionArgs = mutableStateOf("") val containerEntries = mutableStateOf>(emptyList()) val selectedContainer = mutableIntStateOf(0) val screenSizeEntries = mutableStateOf>(emptyList()) @@ -4824,6 +4827,15 @@ private fun AdvancedSection( ) } + if (state.launchOptionArgs.value.isNotBlank()) { + Spacer(Modifier.height(SettingTightGap)) + Text( + stringResource(R.string.shortcut_launch_option_args, state.launchOptionArgs.value), + color = TextDim, + fontSize = 10.sp + ) + } + Spacer(Modifier.height(SettingItemGap)) SettingCheckbox( diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 75fc5471b..0bab0ae40 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -52,6 +52,7 @@ import com.winlator.cmod.feature.settings.GraphicsDriverConfigUtils import com.winlator.cmod.feature.settings.WineD3DConfigUtils import com.winlator.cmod.feature.setup.SetupWizardActivity import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.runtime.compat.box64.Box64Preset import com.winlator.cmod.runtime.compat.box64.Box64PresetManager import com.winlator.cmod.runtime.container.Container @@ -364,6 +365,18 @@ class ShortcutSettingsComposeDialog private constructor( state.name.value = shortcut.getExtra("custom_name", shortcut.name).ifBlank { shortcut.name } state.launchExePath.value = resolveInitialLaunchExePath() state.launchExeDisplayPath.value = resolveLaunchExeDisplayPath(state.launchExePath.value) + // Hint shows the args the active launch option appends: the explicit pick immediately, then + // (for an untouched Steam shortcut) the default entry's own args resolved from appinfo, so it + // reflects what Steam already applies rather than showing blank. + state.launchOptionArgs.value = shortcut.getExtra("launch_option_args") + if (shortcut.getExtra("game_source") == "STEAM") { + shortcut.getExtra("app_id").toIntOrNull()?.let { appId -> + CoroutineScope(Dispatchers.IO).launch { + val active = SteamService.getSelectedLaunchOption(context, appId)?.second + withContext(Dispatchers.Main) { state.launchOptionArgs.value = active.orEmpty() } + } + } + } syncLibraryArtworkState() val inputType = Integer.parseInt( @@ -1239,6 +1252,14 @@ class ShortcutSettingsComposeDialog private constructor( // Launch EXE path val launchExePath = normalizeLaunchExeForShortcut(state.launchExePath.value) if (launchExePath.isNotEmpty()) { + val storedExePath = SteamService.normalizeRelativeExe(shortcut.getExtra("launch_exe_path")) + // A manual exe CHANGE turns the Steam launch option off ("" marker, not key removal). + // Normalized compare: appinfo '\'/case variants of the same exe must not false-trigger. + if (!SteamService.normalizeRelativeExe(launchExePath).equals(storedExePath, ignoreCase = true)) { + shortcut.putExtra("launch_exe_args", null) + shortcut.putExtra("launch_option_exe", "") + shortcut.putExtra("launch_option_args", null) + } shortcut.putExtra("launch_exe_path", launchExePath) val gameSource = shortcut.getExtra("game_source", "") if (gameSource == "CUSTOM") { diff --git a/app/src/main/feature/stores/steam/data/LaunchInfo.kt b/app/src/main/feature/stores/steam/data/LaunchInfo.kt index 75d747ae1..bbe4bfae1 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,11 @@ data class LaunchInfo( val workingDir: String, val description: String, val type: String, + // Default keeps already-cached appinfo JSON (without this key) decodable. + val arguments: String = "", + // config.launch VDF key ("0","1",…) — Steam's launch-option id, which can differ from the + // list position when keys are non-contiguous. -1 (pre-launchId cache) = fall back to position. + val launchId: Int = -1, @Serializable(with = OsEnumSetSerializer::class) val configOS: java.util.EnumSet, val configArch: OSArch, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 51d9325ac..6959cd04a 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -89,6 +89,7 @@ import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud import com.winlator.cmod.feature.sync.google.CloudSyncManager import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut import com.winlator.cmod.runtime.display.environment.ImageFs import com.winlator.cmod.runtime.system.GPUInformation import com.winlator.cmod.runtime.system.SessionKeepAliveService @@ -1477,6 +1478,53 @@ class SteamService : Service() { fun getInstalledExe(appId: Int): String = getWindowsLaunchInfos(appId).firstOrNull()?.executable ?: "" + /** + * `config.launch` key of the entry matching (executable, arguments), handed to + * `IClientAppManager::LaunchApp` (position fallback for pre-launchId caches; -1 if none). + */ + fun launchOptionIndexFor( + appId: Int, + executable: String, + arguments: String, + ): Int { + val launches = getAppInfoOf(appId)?.config?.launch ?: return -1 + val exe = normalizeRelativeExe(executable) + val args = arguments.trim() + val i = + launches.indexOfFirst { + normalizeRelativeExe(it.executable).equals(exe, ignoreCase = true) && + it.arguments.trim() == args + } + return launchOptionKeyAt(launches, i) + } + + /** + * `config.launch` key of the first entry whose executable matches — exe-only fallback when + * the picker field was cleared (position fallback for pre-launchId caches; -1 if none). + */ + fun launchOptionIndexForExe( + appId: Int, + executable: String, + ): Int { + val launches = getAppInfoOf(appId)?.config?.launch ?: return -1 + val exe = normalizeRelativeExe(executable) + val i = + launches.indexOfFirst { + normalizeRelativeExe(it.executable).equals(exe, ignoreCase = true) + } + return launchOptionKeyAt(launches, i) + } + + private fun launchOptionKeyAt( + launches: List, + index: Int, + ): Int = + if (index < 0) { + -1 + } else { + launches[index].launchId.takeIf { it >= 0 } ?: index + } + fun getLaunchExecutable( appId: String, container: Container, @@ -1485,6 +1533,143 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private data class SteamShortcutHit( + val extras: Map, + val container: Container, + val desktopFile: File, + ) + + /** + * Canonical shortcut for a Steam game: first match over ContainerManager's loaded containers, + * so the pick and launch sides always resolve the same file. Reads `[Extra Data]` straight from + * the .desktop — full Shortcut construction decodes bitmaps, too heavy for a read path. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + manager: ContainerManager = ContainerManager(context), + ): SteamShortcutHit? { + val appIdStr = appId.toString() + var hit: SteamShortcutHit? = null + for (container in manager.containers) { + val files = container.desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files.sortedBy { it.name.lowercase() }) { + // Shortcut owns the .desktop parse — same map the launch path sees via getExtra(). + val extras = Shortcut.parseExtras(file) + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + val existing = hit + if (existing == null) { + hit = SteamShortcutHit(extras, container, file) + } else { + // Same appId in two containers: pick and launch could target different files. + Timber.w( + "Multiple Steam shortcuts for appId=$appId " + + "(using ${existing.container.name}, also in ${container.name})", + ) + return existing + } + } + } + } + return hit + } + + /** Constructs just the canonical shortcut — loadShortcuts() would decode every shortcut's icon. */ + fun locateSteamShortcut( + context: Context, + appId: Int, + manager: ContainerManager = ContainerManager(context), + ): Shortcut? { + val hit = readSteamShortcutExtras(context, appId, manager) ?: return null + return Shortcut(hit.container, hit.desktopFile) + } + + /** Exe path normalized ('/' separators, drive prefix stripped) — the one normalizer for exe compares. */ + @JvmStatic + fun normalizeRelativeExe(path: String): String { + val n = path.replace('\\', '/').trim().trimStart('/') + val hasDrive = n.length > 2 && (n[0] in 'A'..'Z' || n[0] in 'a'..'z') && n[1] == ':' && n[2] == '/' + return if (hasDrive) n.substring(3) else n + } + + /** + * Persists the pick on the shortcut; Steam's default entry clears the args + * override so launch returns to LaunchApp. Call on IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + // One manager for both lookups — each locateSteamShortcut default-constructs (disk scan) otherwise. + val manager = ContainerManager(context) + var shortcut = locateSteamShortcut(context, appId, manager) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = locateSteamShortcut(context, appId, manager) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + val default = getWindowsLaunchInfos(appId).firstOrNull() + val isDefault = + default != null && + normalizeRelativeExe(executable).equals(normalizeRelativeExe(default.executable), ignoreCase = true) && + arguments.trim() == default.arguments.trim() + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", if (isDefault) null else arguments.ifBlank { null }) + // Explicit picker selection, stored verbatim — display state, separate from the + // launch-side extras above. A manual exe change removes these keys (option off). + shortcut.putExtra("launch_option_exe", executable) + shortcut.putExtra("launch_option_args", arguments) + shortcut.saveData() + // No container write here: Container.saveData() would clobber dxwrapperConfig (DXVK/VKD3D). + return true + } + + /** + * The active option as (executable, arguments): the explicit pick, or the default while + * untouched; null once a manual exe change turned options off. Call on IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair? { + val extras = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull()?.extras.orEmpty() + val picked = extras["launch_option_exe"] + if (picked != null) { + return if (picked.isBlank()) null else picked to extras["launch_option_args"].orEmpty() + } + val default = getWindowsLaunchInfos(appId).firstOrNull() ?: return null + val exePath = extras["launch_exe_path"].orEmpty() + val untouched = + exePath.isBlank() || + normalizeRelativeExe(exePath).equals(normalizeRelativeExe(default.executable), ignoreCase = true) + return if (untouched) default.executable to default.arguments else null + } + + /** True when [relativePath] is a file under [baseDirPath], matching segments case-insensitively. */ + fun fileExistsIgnoreCase( + baseDirPath: String, + relativePath: String, + ): Boolean = resolvePathCaseInsensitive(baseDirPath, relativePath)?.isFile == true + + /** Re-fetches one app's PICS appinfo to heal cached rows predating newly parsed fields. */ + suspend fun refreshAppInfoFromPics(appId: Int): Boolean { + val fresh = + try { + fetchLatestSteamAppInfo(appId) + } catch (e: Exception) { + Timber.w(e, "refreshAppInfoFromPics failed for appId=$appId") + null + } ?: return false + persistLatestSteamAppInfo(appId, fresh) + return true + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -1831,7 +2016,7 @@ class SteamService : Service() { ?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> // since configOS was unreliable and configArch was even more unreliable - launchInfo.executable.endsWith(".exe") + launchInfo.executable.endsWith(".exe", ignoreCase = true) } }.orEmpty() diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index bdb6d3d23..4fbd841b6 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -195,10 +195,12 @@ fun WnKeyValue.generateSteamApp(): SteamApp = launch = this["config"]["launch"].children.map { LaunchInfo( + launchId = it.name?.trim()?.toIntOrNull() ?: -1, executable = it["executable"].value?.replace('\\', '/').orEmpty(), workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), + arguments = it["arguments"].value.orEmpty(), configOS = OS.from(it["config"]["oslist"].value), configArch = OSArch.from(it["config"]["osarch"].value), ) diff --git a/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt index d1668b534..01663b485 100644 --- a/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt +++ b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt @@ -31,6 +31,26 @@ object SteamLaunchOptions { @JvmStatic fun parseEnvVars(execArgs: String?): Map = parse(execArgs).env + /** + * Splices a launch option's own args (`launch_exe_args`) into the user's custom args, + * honoring the `%command%` placeholder so wrappers like `FOO=1 %command%` keep working. + */ + @JvmStatic + fun combineSteamLaunchArgs( + selectedArgs: String?, + customArgs: String?, + ): String { + val selected = selectedArgs.orEmpty().trim() + val custom = customArgs.orEmpty().trim() + if (selected.isEmpty()) return custom + if (custom.isEmpty()) return selected + return if (custom.contains(COMMAND)) { + custom.replace(COMMAND, "$COMMAND $selected") + } else { + "$selected $custom" + } + } + private fun tokenize(s: String): List { val out = ArrayList() val sb = StringBuilder() diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 40c66427b..51584c676 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1490,10 +1490,6 @@ object SteamUtils { } } - /** - * Updates localconfig.vdf with the container's LaunchOptions for the given appId, - * and updates UserConfig/MountedConfig language in the ACF manifest. - */ /** * Disables Steam Cloud sync for a single app entry inside the parsed localconfig.vdf. * @@ -1522,39 +1518,53 @@ object SteamUtils { } } + // Per-process native state, like the pushed command line — re-push on warm launches too. + @JvmStatic + fun pushAppFamilyShared(appId: String) { + val appIdInt = appId.toIntOrNull() ?: 0 + val familyShared: Boolean = + if (appIdInt <= 0) { + false + } else { + val license = SteamService.getPkgInfoOf(appIdInt) + val selfAcct = PrefManager.steamUserAccountId + val owners = license?.ownerAccountId.orEmpty() + when { + owners.isEmpty() -> false + owners.contains(selfAcct) -> false // direct + owners.any { it in SteamService.familyMembers } -> true + else -> false + } + } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppFamilyShared(familyShared) + if (familyShared) { + Timber.i("Bound app $appId is family-shared (owner outside self)") + } + } + + /** + * Writes the custom launch args into localconfig.vdf LaunchOptions — Steam appends them to the + * game's command line itself when it launches the entry — and updates UserConfig/MountedConfig + * language in the ACF manifest. + */ @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + // Custom args only — the picked entry's own args reach the game via the launch-option index. + exeCommandLine: String = container.execArgs, ) { try { - val exeCommandLine = container.execArgs + // Clean game args only — env is stripped (env goes through the container env). + val launchOptionsValue = SteamLaunchOptions.gameArgs(exeCommandLine) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setLaunchCommandLine(exeCommandLine) + .setLaunchCommandLine(launchOptionsValue) - val appIdInt = appId.toIntOrNull() ?: 0 - val familyShared: Boolean = - if (appIdInt <= 0) { - false - } else { - val license = SteamService.getPkgInfoOf(appIdInt) - val selfAcct = PrefManager.steamUserAccountId - val owners = license?.ownerAccountId.orEmpty() - when { - owners.isEmpty() -> false - owners.contains(selfAcct) -> false // direct - owners.any { it in SteamService.familyMembers } -> true - else -> false - } - } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppFamilyShared(familyShared) - if (familyShared) { - Timber.i("Bound app $appId is family-shared (owner outside self)") - } + pushAppFamilyShared(appId) val steamPath = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") val userDataPath = File(steamPath, "userdata/$steamUserId64") @@ -1571,9 +1581,9 @@ object SteamUtils { val app = vdfData["Software"]["Valve"]["Steam"]["apps"][appId] val option = app.children.firstOrNull { it.name == "LaunchOptions" } if (option != null) { - option.value = exeCommandLine.orEmpty() + option.value = launchOptionsValue } else { - app.children.add(KeyValue("LaunchOptions", exeCommandLine)) + app.children.add(KeyValue("LaunchOptions", launchOptionsValue)) } disableSteamCloudForApp(app) vdfData.saveToFile(localConfigFile, false) @@ -1581,7 +1591,7 @@ object SteamUtils { } } else { val vdfData = KeyValue("UserLocalConfigStore") - val option = KeyValue("LaunchOptions", exeCommandLine) + val option = KeyValue("LaunchOptions", launchOptionsValue) val software = KeyValue("Software") val valve = KeyValue("Valve") val steam = KeyValue("Steam") diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 9919ec9cb..5558afc0e 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -115,6 +115,10 @@ Error al buscar actualizaciones Actualizar Workshop + Opciones de lanzamiento + No se pudo guardar la opción de lanzamiento + %1$d opciones de lanzamiento + Argumentos de la opción de lanzamiento: %1$s Verificar archivos Verificando %1$s — revisa la pestaña Descargas Opciones de Steam diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7c2bc7ae8..23e3972c5 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -113,6 +113,10 @@ Kunne ikke tjekke for opdatering Opdater Workshop + Startindstillinger + Kunne ikke gemme startindstillingen + %1$d startindstillinger + Startindstillingens argumenter: %1$s Bekræft filer Bekræfter %1$s — se fanen Downloads Steam-indstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 303dbfa8e..f0fe0459b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -113,6 +113,10 @@ Update-Prüfung fehlgeschlagen Aktualisieren Workshop + Startoptionen + Startoption konnte nicht gespeichert werden + %1$d Startoptionen + Argumente der Startoption: %1$s Dateien prüfen %1$s wird geprüft — siehe Downloads-Tab Steam-Optionen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5cc622090..27a8604b0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -113,6 +113,10 @@ Error al comprobar actualizaciones Actualizar Workshop + Opciones de lanzamiento + No se pudo guardar la opción de lanzamiento + %1$d opciones de lanzamiento + Argumentos de la opción de lanzamiento: %1$s Verificar archivos Verificando %1$s — revisa la pestaña Descargas Opciones de Steam diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f62d0a67c..5819a3a1f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -115,6 +115,10 @@ Päivitysten tarkistus epäonnistui Päivitä Workshop + Käynnistysasetukset + Käynnistysasetuksen tallentaminen epäonnistui + %1$d käynnistysasetusta + Käynnistysasetuksen argumentit: %1$s Tarkista tiedostot Tarkistetaan %1$s — katso Lataukset-välilehteä Steam-asetukset diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e8865ddbc..a5166ea37 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -113,6 +113,10 @@ Échec de la vérification des mises à jour Mettre à jour Workshop + Options de lancement + Impossible d\'enregistrer l\'option de lancement + %1$d options de lancement + Arguments de l\'option de lancement : %1$s Vérifier les fichiers Vérification de %1$s — consultez l\'onglet Téléchargements Options Steam diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 63edff4cd..4c615df6d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -114,6 +114,10 @@ अपडेट जाँच विफल रही अपडेट वर्कशॉप + लॉन्च विकल्प + लॉन्च विकल्प सहेजा नहीं जा सका + %1$d लॉन्च विकल्प + लॉन्च विकल्प के आर्गुमेंट: %1$s फ़ाइलें सत्यापित करें %1$s का सत्यापन - डाउनलोड टैब की जाँच करें Steam विकल्प diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 99e2cf56c..2816a1ba8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -113,6 +113,10 @@ Controllo aggiornamenti non riuscito Aggiorna Workshop + Opzioni di avvio + Impossibile salvare l\'opzione di avvio + %1$d opzioni di avvio + Argomenti dell\'opzione di avvio: %1$s Verifica file Verifica di %1$s — controlla la scheda Download Opzioni Steam diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f444592f9..edcad5b74 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -115,6 +115,10 @@ 更新の確認に失敗しました 更新 ワークショップ + 起動オプション + 起動オプションを保存できませんでした + %1$d件の起動オプション + 起動オプションの引数: %1$s ファイルを検証 %1$s を検証中 — ダウンロードタブを確認してください Steamオプション diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3d9dd0d1c..601a2fb5e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -113,6 +113,10 @@ 업데이트 확인 실패 업데이트 창작마당 + 실행 옵션 + 실행 옵션을 저장하지 못했습니다 + 실행 옵션 %1$d개 + 실행 옵션 인수: %1$s 파일 확인 %1$s 확인 중 — 다운로드 탭을 확인하세요 Steam 옵션 diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 144ab9a8e..35cb9111c 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -115,6 +115,10 @@ Oppdateringssjekk mislyktes Oppdater Workshop + Oppstartsalternativer + Kunne ikke lagre oppstartsalternativet + %1$d oppstartsalternativer + Argumenter for oppstartsalternativ: %1$s Bekreft filer Bekrefter %1$s — sjekk Nedlastinger-fanen Steam-alternativer diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 33f7e49df..4b13759b8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -113,6 +113,10 @@ Nie udało się sprawdzić aktualizacji Aktualizuj Workshop + Opcje uruchamiania + Nie udało się zapisać opcji uruchamiania + Opcje uruchamiania: %1$d + Argumenty opcji uruchamiania: %1$s Zweryfikuj pliki Weryfikowanie %1$s — sprawdź kartę Pobrane Opcje Steam diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 144328d65..e2bb94d2f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -113,6 +113,10 @@ Falha ao verificar atualização Atualizar Workshop + Opções de inicialização + Não foi possível salvar a opção de inicialização + %1$d opções de inicialização + Argumentos da opção de inicialização: %1$s Verificar arquivos Verificando %1$s — confira a aba Downloads Opções do Steam diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 36753863d..8989433da 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -115,6 +115,10 @@ Falha na verificação de atualização Atualizar Workshop + Opções de inicialização + Não foi possível guardar a opção de inicialização + %1$d opções de inicialização + Argumentos da opção de inicialização: %1$s Verificar Ficheiros A verificar %1$s — consulte o separador Transferências Opções do Steam diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 2577a0474..7d40bb695 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -113,6 +113,10 @@ Verificarea actualizărilor a eșuat Actualizează Workshop + Opțiuni de lansare + Nu s-a putut salva opțiunea de lansare + %1$d opțiuni de lansare + Argumentele opțiunii de lansare: %1$s Verifică fișierele Se verifică %1$s — verifică fila Descărcări Opțiuni Steam diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d63098f20..b0840fc6e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -114,6 +114,10 @@ Не удалось проверить обновления Обновить Мастерская + Параметры запуска + Не удалось сохранить параметр запуска + Параметров запуска: %1$d + Аргументы параметра запуска: %1$s Проверить файлы Проверка %1$s — смотрите вкладку «Загрузки». Параметры Steam diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 5a9dfd660..f17ddc537 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -115,6 +115,10 @@ Uppdateringskontroll misslyckades Uppdatera Workshop + Startalternativ + Det gick inte att spara startalternativet + %1$d startalternativ + Argument för startalternativ: %1$s Verifiera filer Verifierar %1$s — kontrollera fliken Nedladdningar Steam-alternativ diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 048f4e759..086b3a446 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -115,6 +115,10 @@ ตรวจสอบการอัปเดตไม่สำเร็จ อัปเดต Workshop + ตัวเลือกการเปิดเกม + ไม่สามารถบันทึกตัวเลือกการเปิดเกมได้ + ตัวเลือกการเปิดเกม %1$d รายการ + อาร์กิวเมนต์ของตัวเลือกการเปิดเกม: %1$s ตรวจสอบไฟล์ กำลังตรวจสอบ %1$s — ดูที่แท็บดาวน์โหลด ตัวเลือก Steam diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index acd1fe4f0..88a42b176 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -115,6 +115,10 @@ Güncelleme denetimi başarısız Güncelle Workshop + Başlatma Seçenekleri + Başlatma seçeneği kaydedilemedi + %1$d başlatma seçeneği + Başlatma seçeneği argümanları: %1$s Dosyaları Doğrula %1$s doğrulanıyor — İndirilenler sekmesini denetleyin Steam seçenekleri diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index aa2251751..6bc2cdadb 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -113,6 +113,10 @@ Не вдалося перевірити оновлення Оновити Workshop + Параметри запуску + Не вдалося зберегти параметр запуску + Параметрів запуску: %1$d + Аргументи параметра запуску: %1$s Перевірити файли Перевірка %1$s — дивіться вкладку Завантаження Параметри Steam diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fb359c30b..71e6c0469 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -113,6 +113,10 @@ 更新检查失败 更新 创意工坊 + 启动选项 + 无法保存启动选项 + %1$d 个启动选项 + 启动选项参数:%1$s 验证文件 正在验证 %1$s — 请查看下载标签页 Steam 选项 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6312633aa..04fe13758 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -113,6 +113,10 @@ 更新檢查失敗 更新 工作坊 + 啟動選項 + 無法儲存啟動選項 + %1$d 個啟動選項 + 啟動選項參數:%1$s 驗證檔案 正在驗證 %1$s — 請查看下載分頁 Steam 選項 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 364f9c208..8c04e61df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option + %1$d launch options + Launch option arguments: %1$s Verify Files Verifying %1$s — check the Downloads tab Steam options diff --git a/app/src/main/runtime/compat/SteamBridge.java b/app/src/main/runtime/compat/SteamBridge.java index fbaa65bc0..71fe69388 100644 --- a/app/src/main/runtime/compat/SteamBridge.java +++ b/app/src/main/runtime/compat/SteamBridge.java @@ -43,6 +43,41 @@ public static String getInstalledExe(int appId) { } } + /** config.launch index of the selected option (Steam LaunchApp launch-option key), or -1. */ + public static int launchOptionIndexFor(int appId, String executable, String arguments) { + try { + Class companion = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService$Companion"); + Object instance = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService") + .getField("Companion") + .get(null); + Method method = + companion.getMethod("launchOptionIndexFor", int.class, String.class, String.class); + return (Integer) method.invoke(instance, appId, executable, arguments); + } catch (Exception e) { + Log.e(TAG, "Failed to call SteamService.launchOptionIndexFor", e); + return -1; + } + } + + /** First config.launch index whose executable matches (exe-only fallback), or -1. */ + public static int launchOptionIndexForExe(int appId, String executable) { + try { + Class companion = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService$Companion"); + Object instance = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService") + .getField("Companion") + .get(null); + Method method = companion.getMethod("launchOptionIndexForExe", int.class, String.class); + return (Integer) method.invoke(instance, appId, executable); + } catch (Exception e) { + Log.e(TAG, "Failed to call SteamService.launchOptionIndexForExe", e); + return -1; + } + } + /** Downloads experimental-drm if missing, then extracts it. Blocking call. */ public static boolean ensureColdClientSupportReady(Context context) { try { diff --git a/app/src/main/runtime/container/Shortcut.java b/app/src/main/runtime/container/Shortcut.java index ffae664ed..3964472af 100644 --- a/app/src/main/runtime/container/Shortcut.java +++ b/app/src/main/runtime/container/Shortcut.java @@ -12,7 +12,9 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import org.json.JSONException; import org.json.JSONObject; @@ -33,6 +35,28 @@ public class Shortcut { private static final String COVER_ART_DIR = "app_data/cover_arts/"; // Removed leading "/" to keep it relative + /** + * Extras-only parse of a .desktop file's [Extra Data] section; keep in sync with the + * constructor's section handling. Never throws, so a corrupt file can't abort callers' scans. + */ + public static Map parseExtras(File desktopFile) { + Map extras = new LinkedHashMap<>(); + String section = ""; + for (String line : FileUtils.readLines(desktopFile)) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + if (line.startsWith("[")) { + int end = line.indexOf("]"); + section = end > 0 ? line.substring(1, end) : ""; + } else if (section.equals("Extra Data")) { + int index = line.indexOf("="); + if (index <= 0) continue; + extras.put(line.substring(0, index), line.substring(index + 1)); + } + } + return extras; + } + public Shortcut(Container container, File file) { this.container = container; this.file = file; diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index b5429ae71..c26129c19 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -51,6 +51,7 @@ import com.winlator.cmod.BuildConfig; import com.winlator.cmod.feature.leaderboard.SessionRecordingController; import com.winlator.cmod.feature.stores.steam.enums.Marker; +import com.winlator.cmod.feature.stores.steam.service.SteamService; import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils; import com.winlator.cmod.feature.stores.steam.utils.PrefManager; import com.winlator.cmod.feature.stores.steam.utils.SteamUtils; @@ -304,6 +305,8 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private final EnvVars envVars = new EnvVars(); // True when the chosen launch exe differs from Steam's configured entry: launcher skips Steam LaunchApp and CreateProcess'es the selected exe directly. Recomputed per launch. private boolean wnSteamDirectExeOverride = false; + // config.launch index to launch via Steam's LaunchApp (>=0), else -1 for direct-exe. Per launch. + private int wnSteamLaunchOptionIndex = -1; private boolean firstTimeBoot = false; private SharedPreferences preferences; private boolean isMouseDisabled = false; @@ -2317,17 +2320,10 @@ private boolean isSuspiciousSteamInstallLeaf(String value) { return "common".equalsIgnoreCase(normalized) || "steamapps".equalsIgnoreCase(normalized); } + /** Delegates to the shared normalizer so the persist and launch sides compare identically. */ private String normalizeRelativeExeCandidate(String value) { if (value == null) return ""; - - String normalized = value.trim().replace('\\', '/'); - while (normalized.startsWith("/")) { - normalized = normalized.substring(1); - } - if (normalized.matches("^[A-Za-z]:/.*")) { - normalized = normalized.substring(3); - } - return normalized; + return SteamService.normalizeRelativeExe(value); } private File resolveImmediateChildCaseInsensitive(File parent, String childName) { @@ -7200,7 +7196,16 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { "Steam Launcher: WN_STEAM_DIRECT_EXE=1 — user-overridden " + "launch exe; launcher will CreateProcess the selected exe " + "directly (Steam LaunchApp skipped)"); + } else if (wnSteamLaunchOptionIndex >= 0) { + envVars.put("WN_STEAM_LAUNCH_OPTION", + String.valueOf(wnSteamLaunchOptionIndex)); + Log.i("XServerDisplayActivity", + "Steam Launcher: WN_STEAM_LAUNCH_OPTION=" + + wnSteamLaunchOptionIndex + " — launcher will use Steam LaunchApp " + + "with the selected launch-option index"); } + // Custom args reach the game through localconfig LaunchOptions, which Steam + // appends to the entry itself when it launches it. File planWCa = new File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/wnsteam_cacert.pem"); if (planWCa.exists() && planWCa.length() > 0) { @@ -8694,9 +8699,10 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = appendSteamJoinConnect( - com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions - .gameArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); + wnSteamLaunchOptionIndex = -1; + // Combined entry+custom args on the launcher argv — used only by the CreateProcess + // fallback (cold appinfo); the LaunchApp path gets them from Steam via localconfig. + String steamExtraArgs = steamCombinedGameArgs(); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -8746,8 +8752,24 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone // Goldberg launches through steamapps/common to avoid drive-letter drift. String gameDirName = (gameInstPath != null) ? new File(gameInstPath).getName() : ""; String relativeExe = resolveRelativeGameExe(appId, gameInstPath); - // If the resolved exe isn't Steam's configured launch entry the user overrode it; tell the launcher to skip LaunchApp and start the selected exe directly. - wnSteamDirectExeOverride = isUserOverriddenSteamExe(appId, relativeExe); + boolean exeOverridden = isUserOverriddenSteamExe(appId, relativeExe); + String launchExeArgs = shortcut.getExtra("launch_exe_args"); + // Resolve the picked entry's config.launch index so Steam's LaunchApp launches + // that entry natively; direct-exe stays only for an exe that isn't in appinfo. + String pickedExe = shortcut.getExtra("launch_option_exe"); + String pickedArgs = shortcut.getExtra("launch_option_args"); + int optIndex = -1; + if (pickedExe != null && !pickedExe.isEmpty()) { + optIndex = SteamBridge.launchOptionIndexFor(appId, pickedExe, + pickedArgs != null ? pickedArgs : ""); + } + if (optIndex < 0 && !relativeExe.isEmpty()) { + optIndex = SteamBridge.launchOptionIndexForExe(appId, relativeExe); + } + wnSteamLaunchOptionIndex = optIndex; + boolean useLaunchOptionIndex = optIndex >= 0; + wnSteamDirectExeOverride = !useLaunchOptionIndex + && (exeOverridden || !launchExeArgs.isEmpty()); if (!relativeExe.isEmpty() && !gameDirName.isEmpty()) { String steamGameExe = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\" @@ -9080,8 +9102,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela exeRunDir = exePath.substring(0, lastSep); } - String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(perGameExecArgs)); + String exeCommandLine = steamCombinedGameArgs(); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -9166,7 +9187,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(perGameExecArgs)); + String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(combineWithSelectedLaunchArgs(perGameExecArgs))); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -9270,12 +9291,39 @@ private boolean isUserOverriddenSteamExe(int appId, String resolvedRelativeExe) if (resolvedRelativeExe == null || resolvedRelativeExe.isEmpty()) return false; String steamDefaultExe = SteamBridge.getInstalledExe(appId); if (steamDefaultExe == null || steamDefaultExe.isEmpty()) return false; - String resolved = exeBaseName(resolvedRelativeExe); - String configured = exeBaseName(steamDefaultExe); + // Full-path compare: entries may differ only by directory (x64/ vs x86/ builds). + String resolved = normalizeRelativeExeCandidate(resolvedRelativeExe); + String configured = normalizeRelativeExeCandidate(steamDefaultExe); return !resolved.isEmpty() && !configured.isEmpty() && !resolved.equalsIgnoreCase(configured); } + /** + * Picked entry's own args (the persisted picker selection, else the {@code launch_exe_args} + * override) merged with the given custom args. Feeds only the CreateProcess fallback argv; + * the LaunchApp path gets the entry args from the index and the custom args from localconfig. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + String entryArgs = ""; + if (shortcut != null) { + entryArgs = !shortcut.getExtra("launch_option_exe").isEmpty() + ? shortcut.getExtra("launch_option_args") + : shortcut.getExtra("launch_exe_args"); + } + return com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.combineSteamLaunchArgs( + entryArgs, customArgs); + } + + /** Combined (picked-entry + custom) game args for the launcher argv (ColdClient/Goldberg + fallback). */ + private String steamCombinedGameArgs() { + String perGameExecArgs = shortcut != null + ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) + : container.getExecArgs(); + return appendSteamJoinConnect( + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions + .gameArgs(combineWithSelectedLaunchArgs(perGameExecArgs))); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -10880,12 +10928,30 @@ private void setupSteamEnvironment(int appId, File gameDir) { long steamIdLong = com.winlator.cmod.feature.stores.steam.utils.PrefManager.INSTANCE.getSteamUserSteamId64(); String steamId64 = steamIdLong > 0 ? String.valueOf(steamIdLong) : "76561198000000000"; int steamAccountId = com.winlator.cmod.feature.stores.steam.utils.PrefManager.INSTANCE.getSteamUserAccountId(); - String steamUserDataId = steamAccountId > 0 ? String.valueOf(steamAccountId) : steamId64; + // Steam's userdata folder is the 32-bit account id; when the pref is unset (0), derive it + // from steamId64 (as the ActiveUser registry does) — the raw steamId64 is never a folder name. + String steamUserDataId; + if (steamAccountId > 0) { + steamUserDataId = String.valueOf(steamAccountId); + } else if (steamIdLong > 0) { + steamUserDataId = String.valueOf(steamIdLong & 0xFFFFFFFFL); + } else { + steamUserDataId = steamId64; + } - // Stamp-cache the registry/userdata/local-config edits so warm launches skip the per-launch file-copy / VDF-parse work. Stamp key appId|userDataId — change either and it re-runs. + // Stamp-cache the registry/userdata edits so warm launches skip the per-launch file-copy / + // VDF-parse work. Key appId|userDataId only — args aren't in it because the warm path + // rewrites localconfig unconditionally, so an arg change needs no cold reconcile. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + // The user's custom args only — these go into localconfig LaunchOptions; the picked + // entry's own args come from the launch-option index, so combining would double them. + String customLaunchOptions = (shortcut != null + ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) + : container.getExecArgs()); + if (customLaunchOptions == null) customLaunchOptions = ""; + customLaunchOptions = customLaunchOptions.trim(); + String expectedStamp = "v3|" + appId + "|" + steamUserDataId; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -10900,7 +10966,8 @@ private void setupSteamEnvironment(int appId, File gameDir) { skipFirstTimeSteamSetup(winePrefix); reconcileSteamUserdata(steamDir, steamUserDataId, steamId64); - SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId); + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId, + customLaunchOptions); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -10910,9 +10977,15 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // Warm cache: skip the expensive autoLogin/userdata reconcile, but still rewrite + // localconfig LaunchOptions so a stale value can't linger — Steam reads it at launch, + // so a reused container would otherwise hand the game the previous run's args. + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), + steamUserDataId, customLaunchOptions); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId - + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin"); + + ", userId=" + steamUserDataId + ") — refreshed localconfig, " + + "skipping reconcile + autoLogin"); } boolean planWActiveBootstrapSkip = com.winlator.cmod.feature.stores.steam.utils