diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 546ef8469..7961dab23 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -9280,7 +9280,7 @@ class UnifiedActivity : } catch (e: Exception) { 0L } - val isInstallEnabled = totalInstallSize == 0L || availableBytes >= totalInstallSize + val isInstallEnabled = installed == true || totalInstallSize == 0L || availableBytes >= totalInstallSize 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/other/OtherSettingsFragment.kt b/app/src/main/feature/settings/other/OtherSettingsFragment.kt index 149185fc2..e7ab4f46a 100644 --- a/app/src/main/feature/settings/other/OtherSettingsFragment.kt +++ b/app/src/main/feature/settings/other/OtherSettingsFragment.kt @@ -29,6 +29,8 @@ import com.winlator.cmod.app.config.SettingsConfig import com.winlator.cmod.app.update.UpdateChecker import com.winlator.cmod.feature.shortcuts.FrontendExporter import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.utils.PrefManager import com.winlator.cmod.runtime.audio.midi.MidiManager import com.winlator.cmod.runtime.display.environment.ImageFsInstaller import com.winlator.cmod.shared.ui.toast.WinToast @@ -116,6 +118,11 @@ class OtherSettingsFragment : Fragment() { // AppCompatDelegate recreates attached activities automatically. } }, + onContainerLanguageSelected = { index -> + val langName = Language.containerLangForIndex(index) + PrefManager.containerLanguage = langName + refresh() + }, onSoundFontSelected = { index -> // Selection is display-only; no persistence in legacy code. uiState = uiState.copy(soundFontIndex = index) @@ -219,11 +226,17 @@ class OtherSettingsFragment : Fragment() { ctx, ) + // Game container language + val containerLanguageLabels = Language.displayLabels() + val containerLanguageIndex = Language.indexForContainerLang(PrefManager.containerLanguage) + uiState = OtherSettingsState( checkForUpdates = preferences.getBoolean("check_for_updates", false), languageLabels = languageLabels, languageIndex = languageIndex, + containerLanguageLabels = containerLanguageLabels, + containerLanguageIndex = containerLanguageIndex, soundFontFiles = soundFontFiles, soundFontIndex = soundFontIndex, winlatorPath = winlatorPath, diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index ed511b430..61d314a72 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -99,6 +99,8 @@ data class OtherSettingsState( val checkForUpdates: Boolean = true, val languageLabels: List = emptyList(), val languageIndex: Int = 0, + val containerLanguageLabels: List = emptyList(), + val containerLanguageIndex: Int = 0, val soundFontFiles: List = emptyList(), val soundFontIndex: Int = 0, val winlatorPath: String = "", @@ -141,6 +143,7 @@ fun OtherSettingsScreen( onCheckForUpdatesChanged: (Boolean) -> Unit, onCheckForUpdatesNow: () -> Unit, onLanguageSelected: (Int) -> Unit, + onContainerLanguageSelected: (Int) -> Unit, onSoundFontSelected: (Int) -> Unit, onInstallSoundFont: () -> Unit, onRemoveSoundFont: () -> Unit, @@ -217,6 +220,17 @@ fun OtherSettingsScreen( ) } + item(key = "game_language_card") { + SettingsDropdownCard( + title = stringResource(R.string.settings_other_game_language_title), + subtitle = stringResource(R.string.settings_other_game_language_summary), + icon = Icons.Outlined.Language, + options = state.containerLanguageLabels, + selectedIndex = state.containerLanguageIndex, + onOptionSelected = onContainerLanguageSelected, + ) + } + item(key = "audio_section") { SectionLabel(stringResource(R.string.settings_audio_sound), modifier = Modifier.padding(top = 8.dp)) } 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 9e4dcc7ad..d124a0000 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -1829,6 +1829,7 @@ class SteamService : Service() { for (dlcApp in indirectDlcApps) { val entitledDlcDepotIds = getEntitledDepotIds(dlcApp.packageId) for ((depotId, depot) in dlcApp.depots) { + if (isDepotEntitled(depotId, depot, entitledDlcDepotIds) && filterForDownloadableDepots(depot, has64Bit, preferredLanguage, null) ) { @@ -1844,7 +1845,7 @@ class SteamService : Service() { language = depot.language, lowViolence = depot.lowViolence, manifests = depot.manifests, - encryptedManifests = depot.encryptedManifests, + encryptedManifests = depot.encryptedManifests ) } } @@ -4477,6 +4478,7 @@ class SteamService : Service() { .distinct(), ) } + mainAppDlcIds.addAll(calculatedDlcAppIds) 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..aa689524f 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 @@ -821,8 +822,25 @@ object SteamUtils { 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 +857,11 @@ object SteamUtils { } @JvmStatic + @JvmOverloads fun createAppManifest( context: Context, steamAppId: Int, + language: String = "english", ) { try { Timber.i("Attempting to createAppManifest for appId: $steamAppId") @@ -892,24 +912,64 @@ object SteamUtils { installedDlcAppIds = installedDlcAppIds, ) + // 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).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 + allKnownDepots.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 +982,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 +998,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 +1038,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 +1179,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 +1602,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 55112c0af..c4fd96f85 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -818,6 +818,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 4b49c3466..3e526f577 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -818,6 +818,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 1802e01eb..abd39bee4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -818,6 +818,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 2f4327492..00e3ea5eb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -818,6 +818,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 6560580c8..da00575fb 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -924,6 +924,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 021d11ef9..96f4b3a3b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -818,6 +818,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 f86d87750..7a663b01e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -818,6 +818,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 e1c831317..4bae8dd33 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -824,6 +824,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 fc1255d27..a480a3385 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -818,6 +818,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 b95a77cc9..dee278d5e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -818,6 +818,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 7a137931c..f1e632c7b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -885,6 +885,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 a96e61520..873d2321d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -824,6 +824,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 28a2eb5ea..2d21f03a3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -818,6 +818,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 1250957ff..3d980ddd1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -818,6 +818,8 @@ 語言 選擇顯示語言 + 遊戲語言 + Steam/Epic/GOG 下載的偏好語言 系統預設 整合 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe64026f4..0354e18b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1105,6 +1105,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 23adfb000..34d40cb71 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -5844,8 +5844,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")); @@ -6666,6 +6669,175 @@ 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); + } + 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", @@ -10164,15 +10336,21 @@ 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"); - if (defaultAcf.exists()) { + // Only sync if the container doesn't already have a manifest — the user may + // have custom depot config that must not be clobbered. + if (defaultAcf.exists() && !containerAcf.exists()) { try { - java.nio.file.Files.copy(defaultAcf.toPath(), containerAcf.toPath(), - java.nio.file.StandardCopyOption.REPLACE_EXISTING); + java.nio.file.Files.copy(defaultAcf.toPath(), containerAcf.toPath()); Log.d("XServerDisplayActivity", "Synced ACF manifest to container steamapps dir"); } catch (Exception e) { Log.w("XServerDisplayActivity", "Failed to copy ACF to container steamapps", e);