diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index e94f5775d..f5597a84d 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -7656,7 +7656,9 @@ class UnifiedActivity : } catch (e: Exception) { 0L } - val isInstallEnabled = installed || totalInstallSize == 0L || availableBytes >= totalInstallSize + // Installed game: base content is on disk, so only gate on the newly-selected DLC bytes. + val requiredBytes = if (installed) selectedDlcInstallBytes else totalInstallSize + val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes val installActionEnabled = isInstallEnabled && !hasBlockingEpicDownload val installPathDisplay = if (installed) app.installPath else (customPath ?: EpicConstants.defaultEpicGamesPath(context)) @@ -9927,6 +9929,7 @@ class UnifiedActivity : val context = LocalContext.current var isLoading by remember { mutableStateOf(true) } var selectedManifestSizes by remember { mutableStateOf(SteamService.ManifestSizes()) } + var baseInstallSize by remember(app.id) { mutableStateOf(0L) } var dlcApps by remember { mutableStateOf>(emptyList()) } var dlcSizes by remember { mutableStateOf>(emptyMap()) } var installedDlcIds by remember(app.id) { mutableStateOf>(emptySet()) } @@ -9992,6 +9995,7 @@ class UnifiedActivity : installedDlcIds = loadData.installedDlcIds selectedDlcIds.removeAll(loadData.installedDlcIds) selectedManifestSizes = loadData.baseManifestSizes + baseInstallSize = loadData.baseManifestSizes.installSize installed = loadData.installed isLoading = false } @@ -10019,7 +10023,11 @@ class UnifiedActivity : } catch (e: Exception) { 0L } - val isInstallEnabled = totalInstallSize == 0L || availableBytes >= totalInstallSize + // For an already-installed game the base content is on disk, so only require free + // space for the newly-selected DLC (already-installed DLC is excluded from selection). + val requiredBytes = + if (installed == true) (totalInstallSize - baseInstallSize).coerceAtLeast(0L) else totalInstallSize + val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes val installPathDisplay = customPath ?: SteamService.defaultAppInstallPath val dlcItems = diff --git a/app/src/main/assets/wnsteam/bionic/steam.exe b/app/src/main/assets/wnsteam/bionic/steam.exe index 7260a95f5..1b6d8a125 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 e452cf14a..6fd88401b 100644 --- a/app/src/main/cpp/wn-steam-launcher/src/main.cpp +++ b/app/src/main/cpp/wn-steam-launcher/src/main.cpp @@ -231,6 +231,24 @@ static void stage_steam_config(void) { } } +// Escape a free-text value for a VDF/ACF quoted field: double backslashes, then +// escape quotes and newlines. Mirrors the Kotlin escapeString() so the C++ and +// Kotlin manifest paths produce identical, well-formed output. +static std::string vdf_escape(const char* s) { + std::string out; + if (!s) return out; + for (const char* p = s; *p; ++p) { + switch (*p) { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + default: out += *p; break; + } + } + return out; +} + static void stage_app_manifest(uint32_t appId, const char* gameExe) { if (appId == 0 || !gameExe) return; const char* marker = "\\steamapps\\common\\"; @@ -258,33 +276,155 @@ static void stage_app_manifest(uint32_t appId, const char* gameExe) { snprintf(acf, sizeof(acf), "C:\\Program Files (x86)\\Steam\\steamapps\\appmanifest_%u.acf", appId); + const char* owner = getenv("WN_STEAM_STEAMID"); + const char* depotsEnv = getenv("WN_STEAM_DEPOTS"); + const char* sharedEnv = getenv("WN_STEAM_SHARED_DEPOTS"); + const char* appName = getenv("WN_STEAM_APP_NAME"); + const char* installScriptsEnv = getenv("WN_STEAM_INSTALL_SCRIPTS"); + const char* language = getenv("WN_STEAM_LANGUAGE"); + const char* buildIdStr = getenv("WN_STEAM_BUILD_ID"); + const char* sizeOnDiskStr = getenv("WN_STEAM_SIZE_ON_DISK"); + const char* bytesToDownloadStr = getenv("WN_STEAM_BYTES_TO_DOWNLOAD"); + const char* bytesToStageStr = getenv("WN_STEAM_BYTES_TO_STAGE"); + if (!appName || !*appName) appName = installdir; + if (!language || !*language) language = "english"; + unsigned long long buildId = (buildIdStr && *buildIdStr) ? strtoull(buildIdStr, NULL, 10) : 0ULL; + unsigned long long sizeOnDisk = (sizeOnDiskStr && *sizeOnDiskStr) ? strtoull(sizeOnDiskStr, NULL, 10) : 0ULL; + unsigned long long bytesToDownload = (bytesToDownloadStr && *bytesToDownloadStr) ? strtoull(bytesToDownloadStr, NULL, 10) : 0ULL; + unsigned long long bytesToStage = (bytesToStageStr && *bytesToStageStr) ? strtoull(bytesToStageStr, NULL, 10) : 0ULL; FILE* f = fopen(acf, "w"); if (!f) { log_line("[wn-launcher] app manifest: fopen(%s) failed", acf); return; } - const char* owner = getenv("WN_STEAM_STEAMID"); + std::string nameEsc = vdf_escape(appName); + std::string installdirEsc = vdf_escape(installdir); + std::string languageEsc = vdf_escape(language); fprintf(f, "\"AppState\"\n" "{\n" "\t\"appid\"\t\t\"%u\"\n" - "\t\"Universe\"\t\t\"1\"\n" + "\t\"universe\"\t\t\"1\"\n" + "\t\"LauncherPath\"\t\t\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\"\n" "\t\"name\"\t\t\"%s\"\n" "\t\"StateFlags\"\t\t\"4\"\n" "\t\"installdir\"\t\t\"%s\"\n" - "\t\"LastUpdated\"\t\t\"0\"\n" - "\t\"SizeOnDisk\"\t\t\"0\"\n" - "\t\"buildid\"\t\t\"0\"\n" + "\t\"LastUpdated\"\t\t\"%llu\"\n" + "\t\"LastPlayed\"\t\t\"0\"\n" + "\t\"SizeOnDisk\"\t\t\"%llu\"\n" + "\t\"StagingSize\"\t\t\"0\"\n" + "\t\"buildid\"\t\t\"%llu\"\n" "\t\"LastOwner\"\t\t\"%s\"\n" - "\t\"InstalledDepots\"\n" + "\t\"DownloadType\"\t\t\"1\"\n" + "\t\"UpdateResult\"\t\t\"0\"\n" + "\t\"BytesToDownload\"\t\t\"%llu\"\n" + "\t\"BytesDownloaded\"\t\t\"%llu\"\n" + "\t\"BytesToStage\"\t\t\"%llu\"\n" + "\t\"BytesStaged\"\t\t\"%llu\"\n" + "\t\"TargetBuildID\"\t\t\"%llu\"\n" + "\t\"AutoUpdateBehavior\"\t\t\"0\"\n" + "\t\"AllowOtherDownloadsWhileRunning\"\t\t\"0\"\n" + "\t\"ScheduledAutoUpdate\"\t\t\"0\"\n", + appId, nameEsc.c_str(), installdirEsc.c_str(), + (unsigned long long)time(NULL), + sizeOnDisk, buildId, + (owner && *owner) ? owner : "0", + bytesToDownload, bytesToDownload, + bytesToStage, bytesToStage, buildId); + // Write InstalledDepots with depot data from WN_STEAM_DEPOTS env var. + // Format: depotId:manifestGid:size[:dlcAppId],... + if (depotsEnv && *depotsEnv) { + fprintf(f, "\t\"InstalledDepots\"\n\t{\n"); + std::vector buf(strlen(depotsEnv) + 1); + memcpy(buf.data(), depotsEnv, buf.size()); + char* token = strtok(buf.data(), ","); + while (token) { + // Parse depotId:manifestGid:size[:dlcAppId] + char* colon1 = strchr(token, ':'); + if (!colon1) { token = strtok(NULL, ","); continue; } + *colon1 = '\0'; + const char* depotIdStr = token; + char* manifestStart = colon1 + 1; + char* colon2 = strchr(manifestStart, ':'); + if (!colon2) { token = strtok(NULL, ","); continue; } + *colon2 = '\0'; + const char* manifestStr = manifestStart; + char* sizeStart = colon2 + 1; + char* colon3 = strchr(sizeStart, ':'); + const char* sizeStr, *dlcAppIdStr; + if (colon3) { + *colon3 = '\0'; + sizeStr = sizeStart; + dlcAppIdStr = colon3 + 1; + } else { + sizeStr = sizeStart; + dlcAppIdStr = NULL; + } + fprintf(f, "\t\t\"%s\"\n\t\t{\n" + "\t\t\t\"manifest\"\t\t\"%s\"\n" + "\t\t\t\"size\"\t\t\"%s\"\n", + depotIdStr, manifestStr, sizeStr); + if (dlcAppIdStr && *dlcAppIdStr) { + fprintf(f, "\t\t\t\"dlcappid\"\t\t\"%s\"\n", dlcAppIdStr); + } + fprintf(f, "\t\t}\n"); + token = strtok(NULL, ","); + } + fprintf(f, "\t}\n"); + } else { + fprintf(f, "\t\"InstalledDepots\"\n\t{\n\t}\n"); + } + // Write InstallScripts from WN_STEAM_INSTALL_SCRIPTS env var. + // Format: depotId:scriptFilename,... + if (installScriptsEnv && *installScriptsEnv) { + fprintf(f, "\t\"InstallScripts\"\n\t{\n"); + std::vector isbuf(strlen(installScriptsEnv) + 1); + memcpy(isbuf.data(), installScriptsEnv, isbuf.size()); + char* istoken = strtok(isbuf.data(), ","); + while (istoken) { + char* iscolon = strchr(istoken, ':'); + if (!iscolon) { istoken = strtok(NULL, ","); continue; } + *iscolon = '\0'; + std::string scriptEsc = vdf_escape(iscolon + 1); + fprintf(f, "\t\t\"%s\"\t\t\"%s\"\n", istoken, scriptEsc.c_str()); + istoken = strtok(NULL, ","); + } + fprintf(f, "\t}\n"); + } + // Write SharedDepots from WN_STEAM_SHARED_DEPOTS env var. + // Format: sourceDepotId:targetAppId,... + if (sharedEnv && *sharedEnv) { + fprintf(f, "\t\"SharedDepots\"\n\t{\n"); + std::vector sbuf(strlen(sharedEnv) + 1); + memcpy(sbuf.data(), sharedEnv, sbuf.size()); + char* stoken = strtok(sbuf.data(), ","); + while (stoken) { + char* scolon = strchr(stoken, ':'); + if (!scolon) { stoken = strtok(NULL, ","); continue; } + *scolon = '\0'; + fprintf(f, "\t\t\"%s\"\t\t\"%s\"\n", stoken, scolon + 1); + stoken = strtok(NULL, ","); + } + fprintf(f, "\t}\n"); + } + fprintf(f, + "\t\"UserConfig\"\n" + "\t{\n" + "\t\t\"language\"\t\t\"%s\"\n" + "\t}\n" + "\t\"MountedConfig\"\n" "\t{\n" + "\t\t\"language\"\t\t\"%s\"\n" "\t}\n" "}\n", - appId, installdir, installdir, - (owner && *owner) ? owner : "0"); + languageEsc.c_str(), languageEsc.c_str()); fclose(f); - log_line("[wn-launcher] app manifest staged: %s (installdir=\"%s\", StateFlags=4)", - acf, installdir); + log_line("[wn-launcher] app manifest staged: %s (installdir=\"%s\", " + "depots=%s shared=%s scripts=%s)", + acf, installdir, + depotsEnv && *depotsEnv ? depotsEnv : "(none)", + sharedEnv && *sharedEnv ? sharedEnv : "(none)", + installScriptsEnv && *installScriptsEnv ? installScriptsEnv : "(none)"); } // Counts running game processes (matches LaunchApp's canonical name or the literal diff --git a/app/src/main/feature/settings/stores/StoresFragment.kt b/app/src/main/feature/settings/stores/StoresFragment.kt index 1f6c5e0e9..d4633c1d2 100644 --- a/app/src/main/feature/settings/stores/StoresFragment.kt +++ b/app/src/main/feature/settings/stores/StoresFragment.kt @@ -25,6 +25,7 @@ import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager import com.winlator.cmod.feature.stores.gog.service.GOGService import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.enums.Language import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.shared.android.DirectoryPickerDialog @@ -150,6 +151,11 @@ class StoresFragment : Fragment() { onPickSteamFolder = { pickFolder(PrefManager.steamDownloadFolder) { PrefManager.steamDownloadFolder = it } }, onPickEpicFolder = { pickFolder(PrefManager.epicDownloadFolder) { PrefManager.epicDownloadFolder = it } }, onPickGogFolder = { pickFolder(PrefManager.gogDownloadFolder) { PrefManager.gogDownloadFolder = it } }, + onContainerLanguageSelected = { index -> + val langName = Language.containerLangForIndex(index) + PrefManager.containerLanguage = langName + refresh() + }, bridge = (requireActivity() as? UnifiedActivity)?.settingsNavBridge, ) } @@ -165,6 +171,8 @@ class StoresFragment : Fragment() { // Helpers private fun refresh() { val ctx = context ?: return + val containerLanguageLabels = Language.displayLabels() + val containerLanguageIndex = Language.indexForContainerLang(PrefManager.containerLanguage) storeState = StoreState( isSteamLoggedIn = SteamService.isLoggedIn, @@ -178,6 +186,8 @@ class StoresFragment : Fragment() { steamFolder = resolveUri(PrefManager.steamDownloadFolder, ctx), epicFolder = resolveUri(PrefManager.epicDownloadFolder, ctx), gogFolder = resolveUri(PrefManager.gogDownloadFolder, ctx), + containerLanguageLabels = containerLanguageLabels, + containerLanguageIndex = containerLanguageIndex, ) } diff --git a/app/src/main/feature/settings/stores/StoresScreen.kt b/app/src/main/feature/settings/stores/StoresScreen.kt index e2d2ae872..56e414afe 100644 --- a/app/src/main/feature/settings/stores/StoresScreen.kt +++ b/app/src/main/feature/settings/stores/StoresScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.FolderShared import androidx.compose.material.icons.outlined.Gamepad import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Wifi @@ -109,6 +110,8 @@ data class StoreState( val steamFolder: String = "", val epicFolder: String = "", val gogFolder: String = "", + val containerLanguageLabels: List = emptyList(), + val containerLanguageIndex: Int = 0, ) @Composable @@ -128,6 +131,7 @@ fun StoresScreen( onPickSteamFolder: () -> Unit, onPickEpicFolder: () -> Unit, onPickGogFolder: () -> Unit, + onContainerLanguageSelected: (Int) -> Unit, bridge: SettingsNavBridge? = null, ) { val layoutDirection = LocalLayoutDirection.current @@ -258,6 +262,18 @@ fun StoresScreen( onOptionSelected = onDownloadServerChanged, ) + val gameLanguageOptions = remember(state.containerLanguageLabels) { + state.containerLanguageLabels.mapIndexed { index, label -> index to label } + } + SettingsDropdownCard( + title = stringResource(R.string.settings_other_game_language_title), + subtitle = stringResource(R.string.settings_other_game_language_summary), + icon = Icons.Outlined.Language, + selectedValue = state.containerLanguageIndex, + options = gameLanguageOptions, + onOptionSelected = onContainerLanguageSelected, + ) + Spacer(Modifier.height(24.dp + navBarBottomPadding)) } } diff --git a/app/src/main/feature/stores/steam/enums/Language.kt b/app/src/main/feature/stores/steam/enums/Language.kt index 64f8f77be..8477e0294 100644 --- a/app/src/main/feature/stores/steam/enums/Language.kt +++ b/app/src/main/feature/stores/steam/enums/Language.kt @@ -1,35 +1,53 @@ package com.winlator.cmod.feature.stores.steam.enums import timber.log.Timber -enum class Language { - english, - german, - french, - italian, - koreana, - spanish, - schinese, - sc_schinese, - tchinese, - russian, - japanese, - polish, - brazilian, - latam, - vietnamese, - portuguese, - danish, - dutch, - swedish, - norwegian, - finnish, - turkish, - thai, - czech, - unknown, +enum class Language(val displayName: String) { + english("English"), + german("German"), + french("French"), + italian("Italian"), + koreana("Korean"), + spanish("Spanish"), + schinese("Simplified Chinese"), + sc_schinese("Simplified Chinese (SC)"), + tchinese("Traditional Chinese"), + russian("Russian"), + japanese("Japanese"), + polish("Polish"), + brazilian("Portuguese (Brazil)"), + latam("Spanish (Latin America)"), + vietnamese("Vietnamese"), + portuguese("Portuguese"), + danish("Danish"), + dutch("Dutch"), + swedish("Swedish"), + norwegian("Norwegian"), + finnish("Finnish"), + turkish("Turkish"), + thai("Thai"), + czech("Czech"), + unknown("Unknown"), ; companion object { + /** All display names in enum order, excluding [unknown] and the non-standard [sc_schinese] duplicate. */ + fun displayLabels(): List = + entries.filter { it != unknown && it != sc_schinese }.map { it.displayName } + + /** Index of the entry whose [name] matches [containerLang], or the index of [english] as fallback. */ + fun indexForContainerLang(containerLang: String?): Int { + val code = containerLang?.lowercase() ?: return 0 + val filtered = entries.filter { it != unknown && it != sc_schinese } + val match = filtered.indexOfFirst { it.name == code } + return if (match >= 0) match else 0 + } + + /** Container language enum name for a given display-name [index] (0-based into the filtered list). */ + fun containerLangForIndex(index: Int): String { + val filtered = entries.filter { it != unknown && it != sc_schinese } + return filtered.getOrNull(index)?.name ?: english.name + } + fun from(keyValue: String?): Language = when (keyValue?.lowercase()) { english.name -> { diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 665e65695..87d53d0f7 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -4173,6 +4173,7 @@ class SteamService : Service() { .distinct(), ) } + mainAppDlcIds.addAll(calculatedDlcAppIds.filter { it !in mainAppDlcIds }) runBlocking(Dispatchers.IO) { if (mainAppDepots.isNotEmpty()) { diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..40c66427b 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -17,6 +17,7 @@ import com.winlator.cmod.runtime.wine.WineUtils import com.winlator.cmod.runtime.wine.WineRegistryEditor import com.winlator.cmod.feature.stores.steam.enums.EOSType import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.getOwnedAppDlc import kotlinx.coroutines.runBlocking import okhttp3.Call import okhttp3.Callback @@ -814,15 +815,33 @@ object SteamUtils { branch: String, installedDepotIds: Set, installedDlcAppIds: Set, + language: String, ): LinkedHashMap { val allKnownDepots = linkedMapOf() appInfo.depots.forEach { (depotId, depot) -> allKnownDepots[depotId] = depot } - SteamService.getDownloadableDepots(steamAppId).forEach { (depotId, depot) -> + SteamService.getDownloadableDepots(steamAppId, language).forEach { (depotId, depot) -> allKnownDepots[depotId] = depot } + val installedDlcDepotsIds = SteamService.getInstalledDlcDepotsOf(steamAppId) + installedDlcDepotsIds?.forEach { appId -> + try { + runBlocking { + getOwnedAppDlc(appId).forEach { (depotId, depot) -> + allKnownDepots[depotId] = depot + } + } + } catch (e: Exception) { + Timber.w(e, "Failed to resolve owned DLC depots for appId=$appId") + } + } + + val installedDepots = linkedMapOf() allKnownDepots.forEach { (depotId, depotInfo) -> + // Exclude shared depots from InstalledDepots — they belong in SharedDepots only + if (depotInfo.sharedInstall) return@forEach + val shouldInclude = depotId in installedDepotIds || ( @@ -839,9 +858,11 @@ object SteamUtils { } @JvmStatic + @JvmOverloads fun createAppManifest( context: Context, steamAppId: Int, + language: String = "english", ) { try { Timber.i("Attempting to createAppManifest for appId: $steamAppId") @@ -890,26 +911,68 @@ object SteamUtils { branch = selectedBranch, installedDepotIds = installedDepotIds, installedDlcAppIds = installedDlcAppIds, + language = language, ) + // Compute total download and stage sizes from resolved depots + val totalBytesToDownload = installedDepots.values.sumOf { it.download } + val totalBytesToStage = installedDepots.values.sumOf { it.size } + + // Collect dlcAppId mapping for InstalledDepots entries + val allKnownDepots = linkedMapOf() + appInfo.depots.forEach { (depotId, depot) -> allKnownDepots[depotId] = depot } + SteamService.getDownloadableDepots(steamAppId, language).forEach { (depotId, depot) -> + allKnownDepots[depotId] = depot + } + + // Collect shared depots for SharedDepots section + val sharedDepots = linkedMapOf() + allKnownDepots.forEach { (depotId, depotInfo) -> + if (depotInfo.sharedInstall && depotInfo.depotFromApp != SteamService.INVALID_APP_ID) { + sharedDepots[depotId] = depotInfo.depotFromApp + } + } + + // Collect install scripts for InstallScripts section + val installScripts = linkedMapOf() + if (appInfo.installScript.isNotBlank()) { + // Map the base game depot(s) to the install script (base app's own depots only, + // matching the native launcher env-var path in XServerDisplayActivity). + appInfo.depots.forEach { (depotId, depotInfo) -> + if (depotInfo.dlcAppId == SteamService.INVALID_APP_ID && + depotInfo.depotFromApp == SteamService.INVALID_APP_ID + ) { + installScripts[depotId] = appInfo.installScript + } + } + } + val acfContent = buildString { appendLine("\"AppState\"") appendLine("{") appendLine("\t\"appid\"\t\t\"$steamAppId\"") - appendLine("\t\"Universe\"\t\t\"1\"") + appendLine("\t\"universe\"\t\t\"1\"") + appendLine("\t\"LauncherPath\"\t\t\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\"") appendLine("\t\"name\"\t\t\"${escapeString(appInfo.name)}\"") appendLine("\t\"StateFlags\"\t\t\"4\"") - appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") - appendLine("\t\"SizeOnDisk\"\t\t\"$sizeOnDisk\"") - appendLine("\t\"buildid\"\t\t\"$buildId\"") val actualInstallDir = gameName appendLine("\t\"installdir\"\t\t\"${escapeString(actualInstallDir)}\"") + appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") + appendLine("\t\"LastPlayed\"\t\t\"0\"") + appendLine("\t\"SizeOnDisk\"\t\t\"$sizeOnDisk\"") + appendLine("\t\"StagingSize\"\t\t\"0\"") + appendLine("\t\"buildid\"\t\t\"$buildId\"") appendLine("\t\"LastOwner\"\t\t\"$ownerSteamId\"") - appendLine("\t\"BytesToDownload\"\t\t\"0\"") - appendLine("\t\"BytesDownloaded\"\t\t\"0\"") + appendLine("\t\"DownloadType\"\t\t\"1\"") + appendLine("\t\"UpdateResult\"\t\t\"0\"") + appendLine("\t\"BytesToDownload\"\t\t\"$totalBytesToDownload\"") + appendLine("\t\"BytesDownloaded\"\t\t\"$totalBytesToDownload\"") + appendLine("\t\"BytesToStage\"\t\t\"$totalBytesToStage\"") + appendLine("\t\"BytesStaged\"\t\t\"$totalBytesToStage\"") + appendLine("\t\"TargetBuildID\"\t\t\"$buildId\"") appendLine("\t\"AutoUpdateBehavior\"\t\t\"0\"") appendLine("\t\"AllowOtherDownloadsWhileRunning\"\t\t\"0\"") appendLine("\t\"ScheduledAutoUpdate\"\t\t\"0\"") @@ -922,6 +985,12 @@ object SteamUtils { appendLine("\t\t{") appendLine("\t\t\t\"manifest\"\t\t\"${manifest.gid}\"") appendLine("\t\t\t\"size\"\t\t\"${manifest.size}\"") + val dlcAppId = allKnownDepots[depotId]?.dlcAppId + if (dlcAppId != null && dlcAppId != SteamService.INVALID_APP_ID) { + appendLine("\t\t\t\"dlcappid\"\t\t\"$dlcAppId\"") + } else if (depotId in installedDlcAppIds) { + appendLine("\t\t\t\"dlcappid\"\t\t\"$depotId\"") + } appendLine("\t\t}") } appendLine("\t}") @@ -932,8 +1001,32 @@ object SteamUtils { ) } - appendLine("\t\"UserConfig\" { \"language\" \"english\" }") - appendLine("\t\"MountedConfig\" { \"language\" \"english\" }") + if (installScripts.isNotEmpty()) { + appendLine("\t\"InstallScripts\"") + appendLine("\t{") + installScripts.forEach { (depotId, script) -> + appendLine("\t\t\"$depotId\"\t\t\"${escapeString(script)}\"") + } + appendLine("\t}") + } + + if (sharedDepots.isNotEmpty()) { + appendLine("\t\"SharedDepots\"") + appendLine("\t{") + sharedDepots.forEach { (depotId, appTarget) -> + appendLine("\t\t\"$depotId\"\t\t\"$appTarget\"") + } + appendLine("\t}") + } + + appendLine("\t\"UserConfig\"") + appendLine("\t{") + appendLine("\t\t\"language\"\t\t\"$language\"") + appendLine("\t}") + appendLine("\t\"MountedConfig\"") + appendLine("\t{") + appendLine("\t\t\"language\"\t\t\"$language\"") + appendLine("\t}") appendLine("}") } @@ -948,13 +1041,43 @@ object SteamUtils { appendLine("\"AppState\"") appendLine("{") appendLine("\t\"appid\"\t\t\"228980\"") - appendLine("\t\"Universe\"\t\t\"1\"") + appendLine("\t\"universe\"\t\t\"1\"") + appendLine("\t\"LauncherPath\"\t\t\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\"") appendLine("\t\"name\"\t\t\"Steamworks Common Redistributables\"") appendLine("\t\"StateFlags\"\t\t\"4\"") appendLine("\t\"installdir\"\t\t\"Steamworks Shared\"") + appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") + appendLine("\t\"LastPlayed\"\t\t\"0\"") + appendLine("\t\"SizeOnDisk\"\t\t\"0\"") + appendLine("\t\"StagingSize\"\t\t\"0\"") appendLine("\t\"buildid\"\t\t\"1\"") + appendLine("\t\"LastOwner\"\t\t\"0\"") + appendLine("\t\"DownloadType\"\t\t\"1\"") + appendLine("\t\"UpdateResult\"\t\t\"0\"") appendLine("\t\"BytesToDownload\"\t\t\"0\"") appendLine("\t\"BytesDownloaded\"\t\t\"0\"") + appendLine("\t\"BytesToStage\"\t\t\"0\"") + appendLine("\t\"BytesStaged\"\t\t\"0\"") + appendLine("\t\"TargetBuildID\"\t\t\"1\"") + appendLine("\t\"AutoUpdateBehavior\"\t\t\"0\"") + appendLine("\t\"AllowOtherDownloadsWhileRunning\"\t\t\"0\"") + appendLine("\t\"ScheduledAutoUpdate\"\t\t\"0\"") + appendLine("\t\"InstalledDepots\"") + appendLine("\t{") + appendLine("\t\t\"228980\"") + appendLine("\t\t{") + appendLine("\t\t\t\"manifest\"\t\t\"0\"") + appendLine("\t\t\t\"size\"\t\t\"0\"") + appendLine("\t\t}") + appendLine("\t}") + appendLine("\t\"UserConfig\"") + appendLine("\t{") + appendLine("\t\t\"language\"\t\t\"english\"") + appendLine("\t}") + appendLine("\t\"MountedConfig\"") + appendLine("\t{") + appendLine("\t\t\"language\"\t\t\"english\"") + appendLine("\t}") appendLine("}") } @@ -1059,7 +1182,7 @@ object SteamUtils { private fun escapeString(input: String?): String { if (input == null) return "" - return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + return input.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") } private fun calculateDirectorySize(directory: File): Long { @@ -1482,8 +1605,8 @@ object SteamUtils { container .getExtra("containerLanguage", null) ?.takeIf { it.isNotEmpty() } - ?: "english" - }.getOrDefault("english") + ?: PrefManager.containerLanguage + }.getOrDefault(PrefManager.containerLanguage) val steamappsDir = File(steamPath, "steamapps") val appManifestFile = File(steamappsDir, "appmanifest_$appId.acf") diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4f146e6a8..0ff272bb8 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -824,6 +824,8 @@ F.eks. META for META-tast, \n Sprog Vælg visningssprog + Spilsprog + Foretrukket sprog til Steam/Epic/GOG-downloads Systemstandard Integration diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3db58b93d..54ffb32d7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -824,6 +824,8 @@ Z. B. META für Meta-Taste, \n Sprache Anzeigesprache auswählen + Spielsprache + Bevorzugte Sprache für Steam-/Epic-/GOG-Downloads Systemstandard Integration diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f173826f6..b3d41d0aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -824,6 +824,8 @@ Ej. META para la tecla META, \n Idioma Elige el idioma de visualización + Idioma del juego + Idioma preferido para las descargas de Steam/Epic/GOG Predeterminado del sistema Integración diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15d7a9801..bdaa34f88 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -824,6 +824,8 @@ Par ex. META pour la touche META, \n Langue Choisir la langue d\'affichage + Langue du jeu + Langue préférée pour les téléchargements Steam/Epic/GOG Paramètre système Intégration diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 73340af82..8c7f2d1c3 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -930,6 +930,8 @@ %1$s हटाएं भाषा प्रदर्शन भाषा चुनें + गेम भाषा + Steam/Epic/GOG डाउनलोड के लिए पसंदीदा भाषा सिस्टम डिफ़ॉल्ट इंटीग्रेशन सेटअप विज़ार्ड diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8ce77f6ba..8f93d1b03 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -824,6 +824,8 @@ Ad es. META per il tasto META, \n Lingua Scegli la lingua di visualizzazione + Lingua del gioco + Lingua preferita per i download di Steam/Epic/GOG Predefinita di sistema Integrazione diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d3e12a025..78cc5a1c3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -824,6 +824,8 @@ 언어 표시 언어 선택 + 게임 언어 + Steam/Epic/GOG 다운로드에 사용할 기본 언어 시스템 기본값 통합 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 98613951f..9029fb2fd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -830,6 +830,8 @@ Np. META dla klawisza META, \n Język Wybierz język wyświetlania + Język gry + Preferowany język pobierania ze Steam/Epic/GOG Domyślny systemu Integracja diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7bd36eee2..a0a63ae69 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -824,6 +824,8 @@ Ex. META para tecla META, \n Idioma Escolher o idioma de exibição + Idioma do jogo + Idioma preferido para downloads da Steam/Epic/GOG Padrão do sistema Integração diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 8411c5bc1..ba83b3740 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -824,6 +824,8 @@ De ex. META pentru tasta META, \n Limbă Alege limba de afișare + Limba jocului + Limba preferată pentru descărcările Steam/Epic/GOG Implicit sistem Integrare diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 392f3549a..234250637 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -891,6 +891,8 @@ Удалить %1$s Язык Выберите язык интерфейса + Язык игры + Предпочитаемый язык для загрузок Steam/Epic/GOG Системный язык Интеграция Мастер установки diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 45c92c010..89f732be6 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -830,6 +830,8 @@ Мова Виберіть мову інтерфейсу + Мова гри + Бажана мова для завантажень Steam/Epic/GOG За замовчуванням системи Інтеграція diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 20e4aec3e..8ca809864 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -824,6 +824,8 @@ 语言 选择显示语言 + 游戏语言 + Steam/Epic/GOG 下载的首选语言 系统默认 集成 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 90001b4e8..e02d28820 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -824,6 +824,8 @@ 語言 選擇顯示語言 + 遊戲語言 + Steam/Epic/GOG 下載的偏好語言 系統預設 整合 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index faae17665..45d60c5cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1113,6 +1113,8 @@ E.g. META for META key, \n Language Choose the display language System default + Game Language + Preferred language for Steam/Epic/GOG downloads Integration Setup Wizard diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 85a0ec55b..bab8847e7 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -5926,8 +5926,11 @@ private void setupSteamGameFiles() { int appId = Integer.parseInt(shortcut.getExtra("app_id")); String gameInstallPath = resolveSteamGameInstallPath(appId); File gameDir = new File(gameInstallPath); - String language = container.getExtra("containerLanguage", "english"); - if (language == null || language.isEmpty()) language = "english"; + String language = PrefManager.INSTANCE.getContainerLanguage(); + String containerLang = container.getExtra("containerLanguage", null); + if (containerLang != null && !containerLang.isEmpty()) { + language = containerLang; + } boolean isOfflineMode = parseBoolean( getShortcutSetting("steamOfflineMode", container.isSteamOfflineMode() ? "1" : "0")); @@ -6766,6 +6769,179 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { envVars.put("WN_STEAM_STEAMID", planWSid); envVars.put("WN_STEAM_TOKEN", planWTok); envVars.put("WN_STEAM_APPID", String.valueOf(bsAppId)); + // Pass language for native launcher ACF UserConfig/MountedConfig + String acfLang = PrefManager.INSTANCE.getContainerLanguage(); + String acfContainerLang = container.getExtra("containerLanguage", null); + if (acfContainerLang != null && !acfContainerLang.isEmpty()) { + acfLang = acfContainerLang; + } + if (acfLang != null && !acfLang.isEmpty()) { + envVars.put("WN_STEAM_LANGUAGE", acfLang); + } + // Pass DLC depot data for native launcher ACF + try { + com.winlator.cmod.feature.stores.steam.data.SteamApp depotAppInfo = + com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .getAppInfoOf(bsAppId); + if (depotAppInfo != null) { + envVars.put("WN_STEAM_APP_NAME", depotAppInfo.getName()); + String installScript = depotAppInfo.getInstallScript(); + if (installScript != null && !installScript.isEmpty()) { + StringBuilder installScriptsSb = new StringBuilder(); + java.util.Map allDepots = + depotAppInfo.getDepots(); + for (java.util.Map.Entry entry : allDepots.entrySet()) { + com.winlator.cmod.feature.stores.steam.data.DepotInfo di = entry.getValue(); + if (di.getDlcAppId() == com.winlator.cmod.feature.stores.steam.service.SteamService.INVALID_APP_ID + && di.getDepotFromApp() == com.winlator.cmod.feature.stores.steam.service.SteamService.INVALID_APP_ID) { + if (installScriptsSb.length() > 0) installScriptsSb.append(","); + installScriptsSb.append(entry.getKey()).append(":").append(installScript); + } + } + if (installScriptsSb.length() > 0) { + envVars.put("WN_STEAM_INSTALL_SCRIPTS", installScriptsSb.toString()); + } + } + java.util.Map depots = + depotAppInfo.getDepots(); + String branch = com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .resolveSelectedBetaName(bsAppId); + if (branch == null || branch.isEmpty()) branch = "public"; + + // Resolve buildId from branch info + java.util.Map branches = + depotAppInfo.getBranches(); + long buildId = 0L; + if (branches != null) { + com.winlator.cmod.feature.stores.steam.data.BranchInfo branchInfo = branches.get(branch); + if (branchInfo != null) { + buildId = branchInfo.getBuildId(); + } else if (branches.containsKey("public")) { + buildId = branches.get("public").getBuildId(); + } + } + envVars.put("WN_STEAM_BUILD_ID", String.valueOf(buildId)); + + // Compute sizeOnDisk recursively from game directory (match Kotlin + // calculateDirectorySize); null-guard listFiles() so a transient I/O + // error never NPEs and silently drops all depot data. + String gameInstallPath = resolveSteamGameInstallPath(bsAppId); + long sizeOnDisk = 0L; + if (gameInstallPath != null) { + java.util.ArrayDeque stack = new java.util.ArrayDeque<>(); + stack.push(new java.io.File(gameInstallPath)); + while (!stack.isEmpty()) { + java.io.File cur = stack.pop(); + if (cur.isDirectory()) { + java.io.File[] children = cur.listFiles(); + if (children != null) { + for (java.io.File c : children) stack.push(c); + } + } else if (cur.isFile()) { + sizeOnDisk += cur.length(); + } + } + } + envVars.put("WN_STEAM_SIZE_ON_DISK", String.valueOf(sizeOnDisk)); + + // Collect installed depot IDs and DLC app IDs (same as Kotlin collectInstalledDepotManifests) + java.util.Set installedDepotIds = new java.util.HashSet<>(); + java.util.List installedDepotsList = com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .getInstalledDepotsOf(bsAppId); + if (installedDepotsList != null) installedDepotIds.addAll(installedDepotsList); + + java.util.Set installedDlcAppIds = new java.util.HashSet<>(); + java.util.List installedDlcList = com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .getInstalledDlcDepotsOf(bsAppId); + if (installedDlcList != null) installedDlcAppIds.addAll(installedDlcList); + + // Collect all known depots (app depots + downloadable depots) + java.util.LinkedHashMap allKnownDepots = new java.util.LinkedHashMap<>(); + allKnownDepots.putAll(depots); + java.util.Map downloadableDepots = com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .getDownloadableDepots(bsAppId, acfLang != null ? acfLang : ""); + if (downloadableDepots != null) allKnownDepots.putAll(downloadableDepots); + + // Also add DLC depots from getOwnedAppDlc (matches Kotlin collectInstalledDepotManifests) + if (installedDlcList != null) { + for (Integer dlcAppId : installedDlcList) { + try { + @SuppressWarnings("unchecked") + java.util.Map ownedDlc = + (java.util.Map) + kotlinx.coroutines.BuildersKt.runBlocking( + kotlinx.coroutines.Dispatchers.getIO(), + (scope, continuation) -> com.winlator.cmod.feature.stores.steam.service.SteamService.Companion + .getOwnedAppDlc(dlcAppId, continuation) + ); + if (ownedDlc != null) allKnownDepots.putAll(ownedDlc); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } catch (Exception ignored) {} + } + } + + StringBuilder depotSb = new StringBuilder(); + StringBuilder sharedSb = new StringBuilder(); + long totalBytesToDownload = 0L; + long totalBytesToStage = 0L; + for (java.util.Map.Entry entry : allKnownDepots.entrySet()) { + int depotId = entry.getKey(); + com.winlator.cmod.feature.stores.steam.data.DepotInfo di = entry.getValue(); + + // Shared depots are excluded from InstalledDepots entirely (match Kotlin + // collectInstalledDepotManifests); emit to SharedDepots only when the source app is known. + if (di.getSharedInstall()) { + if (di.getDepotFromApp() != com.winlator.cmod.feature.stores.steam.service.SteamService.INVALID_APP_ID) { + if (sharedSb.length() > 0) sharedSb.append(","); + sharedSb.append(depotId).append(":").append(di.getDepotFromApp()); + } + continue; + } + + // Match Kotlin collectInstalledDepotManifests: include if depot is installed, + // or its DLC is installed + boolean shouldInclude = installedDepotIds.contains(depotId) + || (di.getDlcAppId() != com.winlator.cmod.feature.stores.steam.service.SteamService.INVALID_APP_ID + && installedDlcAppIds.contains(di.getDlcAppId())); + if (!shouldInclude) continue; + + com.winlator.cmod.feature.stores.steam.data.ManifestInfo manifest = null; + java.util.Map manifests = di.getManifests(); + if (manifests.containsKey(branch)) manifest = manifests.get(branch); + else if (!branch.equals("public") && manifests.containsKey("public")) manifest = manifests.get("public"); + if (manifest != null && manifest.getGid() != 0L) { + if (depotSb.length() > 0) depotSb.append(","); + depotSb.append(depotId).append(":").append(manifest.getGid()).append(":").append(manifest.getSize()); + int dlcAppId = di.getDlcAppId(); + if (dlcAppId != com.winlator.cmod.feature.stores.steam.service.SteamService.INVALID_APP_ID) { + depotSb.append(":").append(dlcAppId); + } else if (installedDlcAppIds.contains(depotId)) { + // Mirror Kotlin createAppManifest: fall back to depotId as the dlcappid + // when the depot carries no dlcAppId but is itself a tracked DLC. + depotSb.append(":").append(depotId); + } + totalBytesToDownload += manifest.getDownload(); + totalBytesToStage += manifest.getSize(); + } + } + if (depotSb.length() > 0) { + envVars.put("WN_STEAM_DEPOTS", depotSb.toString()); + } + if (sharedSb.length() > 0) { + envVars.put("WN_STEAM_SHARED_DEPOTS", sharedSb.toString()); + } + envVars.put("WN_STEAM_BYTES_TO_DOWNLOAD", String.valueOf(totalBytesToDownload)); + envVars.put("WN_STEAM_BYTES_TO_STAGE", String.valueOf(totalBytesToStage)); + Log.i("XServerDisplayActivity", + "Steam Launcher: depots=" + depotSb + " shared=" + sharedSb + + " buildId=" + buildId + " sizeOnDisk=" + sizeOnDisk + + " dlBytes=" + totalBytesToDownload + " stageBytes=" + totalBytesToStage); + } + } catch (Exception depotIgnored) { + Log.w("XServerDisplayActivity", + "Steam Launcher: Could not query depot data", depotIgnored); + } if (wnSteamDirectExeOverride) { envVars.put("WN_STEAM_DIRECT_EXE", "1"); Log.i("XServerDisplayActivity", @@ -10394,11 +10570,20 @@ private void setupSteamEnvironment(int appId, File gameDir) { commonDir.mkdirs(); WineUtils.ensureSteamappsCommonSymlink(container, gameDir.getAbsolutePath()); - SteamUtils.createAppManifest(this, appId); + String acfLanguage = PrefManager.INSTANCE.getContainerLanguage(); + String containerLang = container.getExtra("containerLanguage", null); + if (containerLang != null && !containerLang.isEmpty()) { + acfLanguage = containerLang; + } + SteamUtils.createAppManifest(this, appId, acfLanguage); File defaultAcf = new File(imageFs.getRootDir(), ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam/steamapps/appmanifest_" + appId + ".acf"); File containerAcf = new File(steamappsDir, "appmanifest_" + appId + ".acf"); + // Refresh the container manifest from the freshly generated one on every launch so + // newly installed DLC / language changes propagate. The generated manifest is the + // source of truth (the native launcher rewrites this same file too), so a stale + // container copy must not be left in place. if (defaultAcf.exists()) { try { java.nio.file.Files.copy(defaultAcf.toPath(), containerAcf.toPath(), @@ -10416,7 +10601,7 @@ private void setupSteamEnvironment(int appId, File gameDir) { String steamworksAcfContent = "\"AppState\"\n" + "{\n" + "\t\"appid\"\t\t\"228980\"\n" + - "\t\"Universe\"\t\t\"1\"\n" + + "\t\"universe\"\t\t\"1\"\n" + "\t\"name\"\t\t\"Steamworks Common Redistributables\"\n" + "\t\"StateFlags\"\t\t\"4\"\n" + "\t\"installdir\"\t\t\"Steamworks Shared\"\n" +