Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: PR CI

on:
workflow_dispatch:
push:
branches: [ main ]
paths-ignore:
Expand Down
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 @@ -52,6 +52,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 @@ -148,6 +149,8 @@ internal fun LibraryGameLaunchScreen(
showVerifyFiles: Boolean = true,
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
showLaunchOptions: Boolean = false,
onLaunchOptions: () -> Unit = {},
playEnabled: Boolean = true,
playDisabledLabel: String? = null,
/**
Expand Down Expand Up @@ -305,13 +308,15 @@ internal fun LibraryGameLaunchScreen(
showCheckForUpdate = showCheckForUpdate,
showWorkshop = showWorkshop,
showAchievements = onAchievements != null,
showLaunchOptions = showLaunchOptions,
showCheats = onCheats != null,
cheatsEnabled = cheatsEnabled,
areSteamActionsEnabled = areSteamActionsEnabled,
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
onAchievements = { onAchievements?.invoke() },
onLaunchOptions = onLaunchOptions,
onCheats = { onCheats?.invoke() },
)
}
Expand Down Expand Up @@ -888,13 +893,15 @@ private fun SourceTag(
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
showAchievements: Boolean = false,
showLaunchOptions: Boolean = false,
showCheats: Boolean = false,
cheatsEnabled: Boolean = true,
areSteamActionsEnabled: Boolean = true,
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
onAchievements: () -> Unit = {},
onLaunchOptions: () -> Unit = {},
onCheats: () -> Unit = {},
) {
var menuOpen by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -973,6 +980,13 @@ private fun SourceTag(
label = stringResource(R.string.steam_achievements_title),
) { menuOpen = false; onAchievements() }
}
if (menuEnabled && showLaunchOptions) {
LaunchSourceMenuItem(
icon = Icons.Outlined.RocketLaunch,
label = stringResource(R.string.store_game_launch_options),
enabled = areSteamActionsEnabled,
) { menuOpen = false; onLaunchOptions() }
}
if (showCheats) {
LaunchSourceMenuItem(
icon = Icons.Outlined.Bolt,
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 @@ -1698,6 +1698,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 @@ -2657,6 +2660,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
(!isEpic || epicGame?.isInstalled == true) &&
(!isGog || gogGame?.isInstalled == true),
showWorkshop = !isCustom && !isEpic && !isGog,
showLaunchOptions = launchOptionsState.options.size >= 2,
onLaunchOptions = launchOptionsState::show,
areSteamActionsEnabled =
when {
isEpic -> !hasBlockingEpicDownloadForLibrary
Expand Down Expand Up @@ -3163,6 +3168,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
35 changes: 35 additions & 0 deletions app/src/main/feature/library/GameSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,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 @@ -427,6 +430,7 @@ class GameSettingsStateHolder {
val sgsrEnabled = mutableStateOf(false)
val sgsrUpscaleMode = mutableIntStateOf(1)
val sgsrSharpness = mutableIntStateOf(100)
val displayScaleFilter = mutableIntStateOf(0)

val frameGenEnabled = mutableStateOf(false)
val frameGenMultiplier = mutableIntStateOf(2)
Expand Down Expand Up @@ -1576,6 +1580,28 @@ private fun GeneralSection(
}
}

// Per-game (the in-game drawer's Scale, same stored value); None presents at the screen size above.
if (!state.isContainerEditMode.value) {
Spacer(Modifier.height(SettingItemGap))
SettingPairRow {
Box(Modifier.weight(1f)) {
SettingDropdown(
label = stringResource(R.string.session_drawer_scale),
entries = listOf(
stringResource(R.string.container_presets_built_in),
stringResource(R.string.session_drawer_scale_nearest),
stringResource(R.string.session_drawer_scale_linear),
stringResource(R.string.session_drawer_scale_bicubic),
stringResource(R.string.session_drawer_scale_none),
),
selectedIndex = state.displayScaleFilter.intValue,
onSelected = { state.displayScaleFilter.intValue = it }
)
}
Box(Modifier.weight(1f))
}
}

// Custom resolution fields when "Custom" is selected (index 0)
if (state.selectedScreenSize.intValue == 0) {
Spacer(Modifier.height(SettingItemGap))
Expand Down Expand Up @@ -5225,6 +5251,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
Loading
Loading