-
-
Notifications
You must be signed in to change notification settings - Fork 368
Workshop implementation, relative mouse movement toggle, and ControlsProfile binding fix #977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
4bd9a00
1ff45b0
74d4661
6c06043
da34e96
63f1916
70a6e1a
0110cfd
1ac4d72
ba96bd9
ecf111d
c5558be
7c086dd
0d3067e
bac8a26
370c4d6
d4c7285
1bd82ee
7307010
f655e3a
e8958e3
7ad1e05
ad437d4
577f681
0143850
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -97,6 +97,7 @@ import app.gamenative.utils.UpdateChecker | |
| import app.gamenative.utils.UpdateInfo | ||
| import app.gamenative.utils.UpdateInstaller | ||
| import app.gamenative.utils.LaunchDependencies | ||
| import app.gamenative.workshop.WorkshopManager | ||
| import com.google.android.play.core.splitcompat.SplitCompat | ||
| import com.winlator.container.Container | ||
| import com.winlator.container.ContainerData | ||
|
|
@@ -106,6 +107,7 @@ import com.winlator.xenvironment.ImageFs | |
| import com.winlator.xenvironment.ImageFsInstaller | ||
| import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation | ||
| import java.io.File | ||
| import java.util.Locale | ||
| import java.util.Date | ||
| import java.util.EnumSet | ||
| import kotlin.reflect.KFunction2 | ||
|
|
@@ -1039,6 +1041,26 @@ fun PluviaMain( | |
| openContainerConfigForAppId = null | ||
| } | ||
| }, | ||
| onDeleteWorkshopMods = { | ||
| scope.launch { | ||
| try { | ||
| withContext(Dispatchers.IO) { | ||
| val appIdInt = appId.toIntOrNull() | ||
| val gameRootDir = appIdInt?.let { SteamService.getAppDirPath(it) } | ||
| ?.let { File(it) } | ||
| val gameName = appIdInt?.let { SteamService.getAppInfoOf(it)?.name } ?: "" | ||
| WorkshopManager.deleteWorkshopMods( | ||
| context, appId, | ||
| gameRootDir, gameName, | ||
| ) | ||
| } | ||
| SnackbarManager.show("Workshop mods deleted") | ||
| } catch (e: Exception) { | ||
| Timber.e(e, "Failed to delete workshop mods") | ||
| SnackbarManager.show("Failed to delete workshop mods") | ||
| } | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
| } | ||
|
|
@@ -1740,6 +1762,163 @@ fun preLaunchApp( | |
| val prefixToPath: (String) -> String = { prefix -> | ||
| PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) | ||
| } | ||
|
|
||
| // Workshop mod sync: download subscribed mods and configure symlinks | ||
| if (container.isWorkshopMods) { | ||
| try { | ||
| setLoadingMessage("Checking Workshop mods...") | ||
| setLoadingProgress(-1f) | ||
| val steamClient = SteamService.instance?.steamClient | ||
| val steamId = SteamService.userSteamId | ||
| if (steamClient != null && steamId != null) { | ||
| val imageFs = ImageFs.find(context) | ||
| val winePrefix = imageFs.wineprefix | ||
| val fetchResult = WorkshopManager.getSubscribedItems(gameId, steamClient, steamId) | ||
| val items = fetchResult.items | ||
| if (items.isNotEmpty()) { | ||
| Timber.tag("Workshop").i("Found ${items.size} subscribed workshop items for appId=$gameId") | ||
| val workshopContentDir = WorkshopManager.getWorkshopContentDir(winePrefix, gameId) | ||
|
|
||
| if (fetchResult.isComplete) { | ||
| WorkshopManager.cleanupUnsubscribedItems(items, workshopContentDir) | ||
| } else { | ||
| Timber.tag("Workshop").w( | ||
| "Subscription fetch incomplete, skipping cleanup to preserve existing mods" | ||
| ) | ||
| } | ||
| val itemsToSync = WorkshopManager.getItemsNeedingSync(items, workshopContentDir) | ||
|
|
||
| if (itemsToSync.isNotEmpty()) { | ||
| // Check available disk space before downloading | ||
| // Use 2x safety margin to account for LZMA decompression | ||
| // and CKM extraction creating temp files alongside originals | ||
| val requiredBytes = itemsToSync.sumOf { it.fileSizeBytes } * 2 | ||
| // usableSpace returns 0 for non-existent dirs; walk up to an existing ancestor. | ||
| // Use -1L sentinel so a truly-full volume (0 bytes) still triggers the guard. | ||
| val spaceDir = generateSequence(workshopContentDir) { it.parentFile } | ||
| .firstOrNull { it.exists() } | ||
| val availableBytes = spaceDir?.usableSpace ?: -1L | ||
| if (requiredBytes > 0 && availableBytes >= 0 && requiredBytes > availableBytes) { | ||
| val reqMB = String.format(Locale.US, "%.0f", requiredBytes / 1_048_576.0) | ||
| val avlMB = String.format(Locale.US, "%.0f", availableBytes / 1_048_576.0) | ||
| Timber.tag("Workshop").e( | ||
| "Insufficient disk space: need ${reqMB}MB, have ${avlMB}MB" | ||
| ) | ||
| SnackbarManager.show( | ||
| "Not enough space for workshop mods (need ${reqMB} MB, have ${avlMB} MB)" | ||
| ) | ||
| } else { | ||
| Timber.tag("Workshop").i("Downloading ${itemsToSync.size} workshop items...") | ||
| setLoadingMessage("Downloading Workshop Mods (0/${itemsToSync.size})") | ||
| val licenses = SteamService.getLicensesFromDb() | ||
|
|
||
| // Track item-level progress so byte callbacks can include it | ||
| var currentCompleted = 0 | ||
| var currentTitle = itemsToSync.firstOrNull()?.title ?: "" | ||
|
|
||
| val successCount = WorkshopManager.downloadItems( | ||
| items = itemsToSync, | ||
| steamClient = steamClient, | ||
| licenses = licenses, | ||
| workshopContentDir = workshopContentDir, | ||
| onItemProgress = { completed, total, title -> | ||
| currentCompleted = completed | ||
| currentTitle = title | ||
| Timber.tag("Workshop").d("Progress: $completed/$total - $title") | ||
| setLoadingMessage("Downloading Workshop Mods ($completed/$total)\n$title") | ||
| }, | ||
| onBytesProgress = { downloaded, total -> | ||
| if (total > 0) { | ||
| setLoadingProgress(downloaded.toFloat() / total.toFloat()) | ||
| val dlMB = String.format(Locale.US, "%.1f", downloaded / 1_048_576.0) | ||
| val totalMB = String.format(Locale.US, "%.1f", total / 1_048_576.0) | ||
| setLoadingMessage( | ||
| "Downloading Workshop Mods ($currentCompleted/${itemsToSync.size})\n" + | ||
| "$currentTitle\n" + | ||
| "${dlMB} MB / ${totalMB} MB" | ||
| ) | ||
| } | ||
| }, | ||
| onOverallProgress = { progress -> | ||
| Timber.tag("Workshop").d("Overall: ${(progress * 100).toInt()}%") | ||
| }, | ||
| ) | ||
|
Comment on lines
+1933
to
+1943
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we already have stringUtils for doing calculations for download progress. Best check there! (Or network utils, I forget exactly).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll have a look tonight, thanks for the suggestion! |
||
|
|
||
| val failedCount = itemsToSync.size - successCount | ||
| if (failedCount > 0) { | ||
| Timber.tag("Workshop").w("$failedCount workshop mod(s) failed to download") | ||
| SnackbarManager.show( | ||
| "$failedCount workshop mod(s) failed to download" | ||
| ) | ||
| } | ||
|
|
||
| WorkshopManager.fixItemFileNames(itemsToSync, workshopContentDir) | ||
| } | ||
| } | ||
|
|
||
| // Post-processing runs every launch (not just after downloads) so | ||
| // that files from a previous broken run get fixed retroactively. | ||
| // Order matters: decompress LZMA first so fixFileExtensions can | ||
| // read the real magic bytes (e.g. GMAD) instead of LZMA's 0x5D. | ||
| WorkshopManager.extractCkmFiles(workshopContentDir) | ||
| WorkshopManager.decompressLzmaFiles(workshopContentDir) | ||
| WorkshopManager.fixFileExtensions(workshopContentDir) | ||
|
|
||
| WorkshopManager.updateMarkerTimestamps(items, workshopContentDir) | ||
|
|
||
| setLoadingMessage("Configuring Workshop mods...") | ||
| setLoadingProgress(-1f) | ||
| val gameRootDir = File(SteamService.getAppDirPath(gameId)) | ||
| val gameName = SteamService.getAppInfoOf(gameId)?.name ?: "" | ||
| WorkshopManager.configureModSymlinks( | ||
| gameRootDir = gameRootDir, | ||
| workshopContentDir = workshopContentDir, | ||
| items = items, | ||
| winePrefix = winePrefix, | ||
| gameName = gameName, | ||
| ) | ||
| Timber.tag("Workshop").i("Workshop mod sync complete for appId=$gameId") | ||
| } else if (!fetchResult.succeeded) { | ||
| // Fetch failed and returned no items — don't wipe existing mods. | ||
| // Still configure symlinks so previously downloaded mods work offline. | ||
| Timber.tag("Workshop").w( | ||
| "Workshop fetch failed for appId=$gameId, preserving existing on-disk mods" | ||
| ) | ||
| val workshopContentDir = WorkshopManager.getWorkshopContentDir(winePrefix, gameId) | ||
| if (workshopContentDir.exists()) { | ||
| setLoadingMessage("Configuring Workshop mods...") | ||
| setLoadingProgress(-1f) | ||
| val gameRootDir = File(SteamService.getAppDirPath(gameId)) | ||
| val gameName = SteamService.getAppInfoOf(gameId)?.name ?: "" | ||
| WorkshopManager.configureModSymlinks( | ||
| gameRootDir = gameRootDir, | ||
| workshopContentDir = workshopContentDir, | ||
| items = emptyList(), | ||
| winePrefix = winePrefix, | ||
| gameName = gameName, | ||
| ) | ||
| } | ||
| } else { | ||
| Timber.tag("Workshop").d("No subscribed workshop items for appId=$gameId") | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| // Clean up previously downloaded mods and installed symlinks/copies | ||
| val gameRootDir = File(SteamService.getAppDirPath(gameId)) | ||
| val gameName = SteamService.getAppInfoOf(gameId)?.name ?: "" | ||
| WorkshopManager.deleteWorkshopMods( | ||
| context = context, | ||
| containerId = gameId.toString(), | ||
| gameRootDir = gameRootDir, | ||
| gameName = gameName, | ||
| ) | ||
| Timber.tag("Workshop").i("Cleaned up workshop content for appId=$gameId") | ||
| } | ||
| } else { | ||
| Timber.tag("Workshop").w("Steam client or Steam ID not available, skipping workshop sync") | ||
| } | ||
| } catch (e: Exception) { | ||
| Timber.tag("Workshop").e(e, "Workshop mod sync failed, continuing without mods") | ||
| } | ||
| } | ||
|
|
||
| setLoadingMessage("Syncing cloud saves") | ||
| setLoadingProgress(-1f) | ||
| val postSyncInfo = SteamService.beginLaunchApp( | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd recommend removing the relative mouse functionality & controlsPorfile bindng changes and put them into a different PR. It makes this PR more difficult to review and overall, we should have 1 intent per PR.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright, should be good to go now, sorry about that!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just FYI I feel like this disclaimer should be mentioned somewhere in the discord in regards to workshop support to hopefully help with "customer support" lol: *** DISCLAIMER *** If workshop mods don't appear to work for you, you could try going to Open Container then navigating to C:/Program Files x86/Steam/steamapps/workshop/content/game's id/ and copy all of the mod folders there that are just a bunch of numbers then move them to wherever your game's mod folder is if there is one. Look up instructions on how to mod your game, mods might be in the A drive (your game's directory) in a Mods folder. Or it might be in C:/Users/xuser/Appdata then in either Local, LocalLow or Roaming somewhere but you have to find the game folder there yourself since it might be under the studio's name as an example. Workshop support can be very picky sometimes files need to be decompressed, or renamed to a different file type, so don't expect mods to work perfectly for every game. Some game's workshop support will not work without internet connection and/or steam client running, Portal 2 and Killing Floor are two examples. That being said, these are games that I've confirmed work for me on a Fold 7: Age of Empires 2 HD Banished Broforce Command and Conquer Generals Zero Hour Dont Starve Together Garry's Mod Going Medieval Insurgency (mostly, issue with text in the UI being re-named. Singleplayer renamed to #GameUI_Singleplayer for instance. Still works though) Left 4 Dead 2 Mount and Blade Warband Rimworld Star Wars Knights of the Old Republic 2 Starbound Superliminal Terraria Skyrim Tooth and Tail Total Tank Simulator Viscera Cleanup Detail Will not work: Portal 2 Killing Floor, it seems like the mods download correctly and are listed in the main menu but you can not click on "Steam Workshop content/mods", seemingly because you don't have an internet connection or steam isn't on. Half Life 2, seems like it needs to be online too?? Strangely enough... Men of War Assault Squad 2 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ fun GeneralTabContent( | |
| state: ContainerConfigState, | ||
| nonzeroResolutionError: String, | ||
| aspectResolutionError: String, | ||
| onDeleteWorkshopMods: (() -> Unit)? = null, | ||
| ) { | ||
| val config = state.config.value | ||
| val graphicsDrivers = state.graphicsDrivers.value | ||
|
|
@@ -335,6 +336,46 @@ fun GeneralTabContent( | |
| onCheckedChange = { state.config.value = config.copy(unpackFiles = it) }, | ||
| ) | ||
| } | ||
| SettingsSwitch( | ||
| colors = settingsTileColorsAlt(), | ||
| title = { Text(text = stringResource(R.string.workshop_mods)) }, | ||
| subtitle = { Text(text = stringResource(R.string.workshop_mods_description)) }, | ||
| state = config.workshopMods, | ||
| onCheckedChange = { state.config.value = config.copy(workshopMods = it) }, | ||
| ) | ||
| if (onDeleteWorkshopMods != null) { | ||
| var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) } | ||
| if (showDeleteConfirmation) { | ||
| AlertDialog( | ||
| onDismissRequest = { showDeleteConfirmation = false }, | ||
| title = { Text(text = stringResource(R.string.delete_workshop_mods)) }, | ||
| text = { Text(text = stringResource(R.string.delete_workshop_mods_confirm)) }, | ||
| confirmButton = { | ||
| TextButton(onClick = { | ||
| showDeleteConfirmation = false | ||
| onDeleteWorkshopMods() | ||
| state.config.value = state.config.value.copy(workshopMods = false) | ||
| }) { | ||
| Text(text = stringResource(R.string.delete)) | ||
| } | ||
| }, | ||
| dismissButton = { | ||
| TextButton(onClick = { showDeleteConfirmation = false }) { | ||
| Text(text = stringResource(R.string.cancel)) | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
| TextButton( | ||
| onClick = { showDeleteConfirmation = true }, | ||
| modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), | ||
| ) { | ||
| Text( | ||
| text = stringResource(R.string.delete_workshop_mods), | ||
| color = androidx.compose.material3.MaterialTheme.colorScheme.error, | ||
| ) | ||
| } | ||
| } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we're adding workshop mods deletion in the general tab of edit container?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| SettingsSwitch( | ||
| colors = settingsTileColorsAlt(), | ||
| title = { Text(text = stringResource(R.string.steam_offline_mode)) }, | ||
|
|
||

Uh oh!
There was an error while loading. Please reload this page.