Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
425 changes: 425 additions & 0 deletions app/src/main/app/shell/LaunchOptionsScreen.kt

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions app/src/main/app/shell/LibraryGameLaunchScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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() }
}
}
}
}
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/app/shell/StoreGameDetailScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -155,6 +156,8 @@ internal fun StoreGameDetailScreen(
showWorkshop: Boolean = false,
showVerifyFiles: Boolean = false,
areSteamActionsEnabled: Boolean = true,
showLaunchOptions: Boolean = false,
onLaunchOptions: () -> Unit = {},
dlcs: List<StoreDlcItem> = emptyList(),
selectedDlcIds: Set<Int> = emptySet(),
isDlcSelectionEnabled: Boolean = true,
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -313,6 +318,7 @@ internal fun StoreGameDetailScreen(
showCheckForUpdate = updateCheckAvailable,
showVerifyFiles = verifyFilesAvailable,
showWorkshop = workshopAvailable,
showLaunchOptions = launchOptionsAvailable,
isCheckingForUpdate = isCheckingForUpdate,
areSteamActionsEnabled = areSteamActionsEnabled,
isUpdateCheckEnabled =
Expand All @@ -323,6 +329,7 @@ internal fun StoreGameDetailScreen(
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
onLaunchOptions = onLaunchOptions,
)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() }
}
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/app/shell/UnifiedActivityDownloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SteamService.SteamUpdateInfo?>(null) }
var updateStatusText by remember(app.id) { mutableStateOf<String?>(null) }
val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1686,6 +1689,8 @@ internal fun UnifiedActivity.GameManagerDialog(
onDismissRequest = { showWorkshopDialog = false },
)
}

SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState)
}

@Composable
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/app/shell/UnifiedActivityGameDialogs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2910,6 +2915,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
onDismissRequest = { showWorkshopDialog = false },
)
}

SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState)
}
}
}
7 changes: 3 additions & 4 deletions app/src/main/app/shell/UnifiedActivityLaunch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Binary file modified app/src/main/assets/wnsteam/bionic/steam.exe
Binary file not shown.
54 changes: 48 additions & 6 deletions app/src/main/cpp/wn-steam-launcher/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1328,14 +1347,30 @@ 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];

// Cold launch may see 1-2 fast MissingConfig(9) retries; 5 stays inside
// 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,
Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/feature/library/GameSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<String>>(emptyList())
val selectedContainer = mutableIntStateOf(0)
val screenSizeEntries = mutableStateOf<List<String>>(emptyList())
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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") {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/feature/stores/steam/data/LaunchInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<OS>,
val configArch: OSArch,
Expand Down
Loading