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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1091,13 +1091,13 @@ fun preLaunchApp(
}
}
}
if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "experimental-drm-20260101.tzst")) {
if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "experimental-drm-20260116.tzst")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compared the files inside two version, old version have steamclient_extra_x32.dll and steamclient_extra_x64.dll would you want to delete them?

Btw I don't know what is the source of these dlls can't give much comment.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new tzst, those two files don't exist, only the new .dll for stubbing. I tried with one game and it worked ok.

The files come from experimental_steamclient build of gbe-fork

setLoadingMessage("Downloading extras")
SteamService.downloadFile(
onDownloadProgress = { setLoadingProgress(it / 1.0f) },
this,
context = context,
"experimental-drm-20260101.tzst"
"experimental-drm-20260116.tzst"
).await()
}
if (container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "steam.tzst")) {
Expand Down
128 changes: 68 additions & 60 deletions app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Copy link
Contributor

@joshuatam joshuatam Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this PR aiming to reduce the number of exe to be processed by Steamless? the changes only make differences to whether to run it, but the number of exes does not change (as filterExecutablesForSteamless is untouched).

I agree to the logic to run it tough, !isLaunchRealSteam and isUseLegacyDRM

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it aims to reduce the number of exes, and also sometimes Steamless doesn't work - people report errors. This should work in all situations. Just worried it might break.

Original file line number Diff line number Diff line change
Expand Up @@ -1914,7 +1914,7 @@ private fun filterExecutablesForSteamless(executables: List<String>): List<Strin
}
private fun exit(winHandler: WinHandler?, environment: XEnvironment?, frameRating: FrameRating?, appInfo: SteamApp?, container: Container, onExit: () -> Unit, navigateBack: () -> Unit) {
Timber.i("Exit called")

// Prevent duplicate PostHog events when multiple exit triggers fire simultaneously
if (isExiting.compareAndSet(false, true)) {
PostHog.capture(
Expand Down Expand Up @@ -2257,77 +2257,85 @@ private fun unpackExecutableFile(

output = StringBuilder()

// Scan all executables from A: drive and filter them
val allExecutables = ContainerUtils.scanExecutablesInADrive(container.drives)
Timber.i("Found ${allExecutables.size} executables in A: drive")
if (!container.isLaunchRealSteam && container.isUseLegacyDRM) {
// Scan all executables from A: drive and filter them
val allExecutables = ContainerUtils.scanExecutablesInADrive(container.drives)
Timber.i("Found ${allExecutables.size} executables in A: drive")

val filteredExecutables = filterExecutablesForSteamless(allExecutables)
Timber.i("Filtered to ${filteredExecutables.size} executables for Steamless processing")
val filteredExecutables = filterExecutablesForSteamless(allExecutables)
Timber.i("Filtered to ${filteredExecutables.size} executables for Steamless processing")

if (filteredExecutables.isEmpty()) {
Timber.w("No executables to process with Steamless")
} else {
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM..."))
if (filteredExecutables.isEmpty()) {
Timber.w("No executables to process with Steamless")
} else {
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM..."))

// Process each executable individually to handle errors per file
filteredExecutables.forEachIndexed { index, exePath ->
var batchFile: File? = null
try {
val normalizedPath = exePath.replace('/', '\\')
val windowsPath = "A:\\$normalizedPath"
// Process each executable individually to handle errors per file
filteredExecutables.forEachIndexed { index, exePath ->
var batchFile: File? = null
try {
val normalizedPath = exePath.replace('/', '\\')
val windowsPath = "A:\\$normalizedPath"

PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM... (${index + 1}/${filteredExecutables.size})"))
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM... (${index + 1}/${filteredExecutables.size})"))

// Create a batch file that Wine can execute, to handle paths with spaces in them
batchFile = File(imageFs.getRootDir(), "tmp/steamless_wrapper_${index}.bat")
batchFile.parentFile?.mkdirs()
batchFile.writeText("@echo off\r\nz:\\Steamless\\Steamless.CLI.exe \"$windowsPath\"\r\n")
// Create a batch file that Wine can execute, to handle paths with spaces in them
batchFile = File(imageFs.getRootDir(), "tmp/steamless_wrapper_${index}.bat")
batchFile.parentFile?.mkdirs()
batchFile.writeText("@echo off\r\nz:\\Steamless\\Steamless.CLI.exe \"$windowsPath\"\r\n")

val slCmd = "wine z:\\tmp\\steamless_wrapper_${index}.bat"
val slOutput = guestProgramLauncherComponent.execShellCommand(slCmd)
output.append(slOutput)
} catch (e: Exception) {
Timber.e(e, "Error running Steamless on $exePath, continuing with next file")
output.append("Error processing $exePath: ${e.message}\n")
} finally {
// Clean up batch file
batchFile?.delete()
val slCmd = "wine z:\\tmp\\steamless_wrapper_${index}.bat"
val slOutput = guestProgramLauncherComponent.execShellCommand(slCmd)
output.append(slOutput)
} catch (e: Exception) {
Timber.e(e, "Error running Steamless on $exePath, continuing with next file")
output.append("Error processing $exePath: ${e.message}\n")
} finally {
// Clean up batch file
batchFile?.delete()
}
}
}

Timber.i("Finished processing ${filteredExecutables.size} executables. Result: $output")

// Process file moving for all filtered executables
for (exePath in filteredExecutables) {
try {
// Paths from scanExecutablesInADrive use forward slashes (Unix format from URI)
// Use as-is for File operations (forward slashes work on Unix/Android)
val unixPath = exePath.replace('\\', '/')
val exe = File(imageFs.wineprefix + "/dosdevices/a:/" + unixPath)
val unpackedExe = File(
imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".unpacked.exe",
)
val originalExe = File(
imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".original.exe",
)

// For logging, show Windows format
val windowsPath = "A:\\${exePath.replace('/', '\\')}"
Timber.i("Finished processing ${filteredExecutables.size} executables. Result: $output")

Timber.i("Moving files for $windowsPath")
if (exe.exists() && unpackedExe.exists()) {
Files.copy(exe.toPath(), originalExe.toPath(), REPLACE_EXISTING)
Files.copy(unpackedExe.toPath(), exe.toPath(), REPLACE_EXISTING)
Timber.i("Successfully moved files for $windowsPath")
} else {
val errorMsg =
"Either original exe or unpacked exe does not exist for $windowsPath. Original: ${exe.exists()}, Unpacked: ${unpackedExe.exists()}"
Timber.w(errorMsg)
// Process file moving for all filtered executables
for (exePath in filteredExecutables) {
try {
// Paths from scanExecutablesInADrive use forward slashes (Unix format from URI)
// Use as-is for File operations (forward slashes work on Unix/Android)
val unixPath = exePath.replace('\\', '/')
val exe = File(imageFs.wineprefix + "/dosdevices/a:/" + unixPath)
val unpackedExe = File(
imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".unpacked.exe",
)
val originalExe = File(
imageFs.wineprefix + "/dosdevices/a:/" + unixPath + ".original.exe",
)

// For logging, show Windows format
val windowsPath = "A:\\${exePath.replace('/', '\\')}"

Timber.i("Moving files for $windowsPath")
if (exe.exists() && unpackedExe.exists()) {
if (originalExe.exists()) {
Timber.i("Original backup exists for $windowsPath; skipping overwrite")
} else {
Files.copy(exe.toPath(), originalExe.toPath(), REPLACE_EXISTING)
}
Files.copy(unpackedExe.toPath(), exe.toPath(), REPLACE_EXISTING)
Timber.i("Successfully moved files for $windowsPath")
} else {
val errorMsg =
"Either exe or unpacked exe does not exist for $windowsPath. Exe: ${exe.exists()}, Unpacked: ${unpackedExe.exists()}"
Timber.w(errorMsg)
}
} catch (e: Exception) {
Timber.e(e, "Error moving files for $exePath, continuing with next executable")
}
} catch (e: Exception) {
Timber.e(e, "Error moving files for $exePath, continuing with next executable")
}
}
} else {
Timber.i("Skipping Steamless (launchRealSteam=${container.isLaunchRealSteam}, useLegacyDRM=${container.isUseLegacyDRM})")
}

output = StringBuilder()
Expand Down
41 changes: 21 additions & 20 deletions app/src/main/java/app/gamenative/utils/SteamUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,14 @@ object SteamUtils {
backupSteamclientFiles(context, steamAppId)

val imageFs = ImageFs.find(context)
val downloaded = File(imageFs.getFilesDir(), "experimental-drm-20260101.tzst")
val downloaded = File(imageFs.getFilesDir(), "experimental-drm-20260116.tzst")
TarCompressorUtils.extract(
TarCompressorUtils.Type.ZSTD,
downloaded,
imageFs.getRootDir(),
)
putBackSteamDlls(appDirPath)
restoreUnpackedExecutable(context, steamAppId)
restoreOriginalExecutable(context, steamAppId)

// Get ticket and pass to ensureSteamSettings
val ticketBase64 = SteamService.instance?.getEncryptedAppTicketBase64(steamAppId)
Expand Down Expand Up @@ -336,6 +336,7 @@ object SteamUtils {

[Injection]
IgnoreLoaderArchDifference=1
DllsToInjectFolder=extra_dlls
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this injection is missing before?
What is expected between new version and old version?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In new version after this PR, without running Steamless, we will be able to launch games because we are loading the extra_dlls which contains a new dll: StubDrm64.dll

""".trimIndent(),
)
}
Expand Down Expand Up @@ -756,28 +757,28 @@ object SteamUtils {
val imageFs = ImageFs.find(context)
val dosDevicesPath = File(imageFs.wineprefix, "dosdevices/a:")

dosDevicesPath.walkTopDown().maxDepth(10).firstOrNull {
it.isFile && it.name.endsWith(".original.exe", ignoreCase = true)
}?.let { file ->
try {
val origPath = file.toPath()
val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe"))
Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...")
dosDevicesPath.walkTopDown().maxDepth(10)
.filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) }
.forEach { file ->
try {
val origPath = file.toPath()
val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe"))
Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...")

// Delete the current exe if it exists
if (Files.exists(originalPath)) {
Files.delete(originalPath)
}
// Delete the current exe if it exists
if (Files.exists(originalPath)) {
Files.delete(originalPath)
}

// Copy the backup back to the original location
Files.copy(origPath, originalPath)
// Copy the backup back to the original location
Files.copy(origPath, originalPath)

Timber.i("Restored ${originalPath.fileName} from backup")
restoredCount++
} catch (e: IOException) {
Timber.w(e, "Failed to restore ${file.name} from backup")
Timber.i("Restored ${originalPath.fileName} from backup")
restoredCount++
} catch (e: IOException) {
Timber.w(e, "Failed to restore ${file.name} from backup")
}
Comment on lines +760 to +780
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Case sensitivity mismatch between filter and suffix removal.

The filter uses ignoreCase = true but removeSuffix(".original.exe") is case-sensitive. If a file is named Game.ORIGINAL.EXE, the filter will match it, but removeSuffix won't strip the suffix correctly, leading to an incorrect target path (e.g., attempting to copy to Game.ORIGINAL.EXE instead of Game).

Proposed fix
         dosDevicesPath.walkTopDown().maxDepth(10)
             .filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) }
             .forEach { file ->
                 try {
                     val origPath = file.toPath()
-                    val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe"))
+                    val baseName = origPath.name.let { name ->
+                        val suffixIndex = name.lastIndexOf(".original.exe", ignoreCase = true)
+                        if (suffixIndex > 0) name.substring(0, suffixIndex) else name
+                    }
+                    val originalPath = origPath.parent.resolve(baseName)
                     Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
dosDevicesPath.walkTopDown().maxDepth(10)
.filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) }
.forEach { file ->
try {
val origPath = file.toPath()
val originalPath = origPath.parent.resolve(origPath.name.removeSuffix(".original.exe"))
Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...")
// Delete the current exe if it exists
if (Files.exists(originalPath)) {
Files.delete(originalPath)
}
// Delete the current exe if it exists
if (Files.exists(originalPath)) {
Files.delete(originalPath)
}
// Copy the backup back to the original location
Files.copy(origPath, originalPath)
// Copy the backup back to the original location
Files.copy(origPath, originalPath)
Timber.i("Restored ${originalPath.fileName} from backup")
restoredCount++
} catch (e: IOException) {
Timber.w(e, "Failed to restore ${file.name} from backup")
Timber.i("Restored ${originalPath.fileName} from backup")
restoredCount++
} catch (e: IOException) {
Timber.w(e, "Failed to restore ${file.name} from backup")
}
dosDevicesPath.walkTopDown().maxDepth(10)
.filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) }
.forEach { file ->
try {
val origPath = file.toPath()
val baseName = origPath.name.replace(Regex("\\.original\\.exe$", RegexOption.IGNORE_CASE), "")
val originalPath = origPath.parent.resolve(baseName)
Timber.i("Found ${origPath.name} at ${origPath.absolutePathString()}, restoring...")
// Delete the current exe if it exists
if (Files.exists(originalPath)) {
Files.delete(originalPath)
}
// Copy the backup back to the original location
Files.copy(origPath, originalPath)
Timber.i("Restored ${originalPath.fileName} from backup")
restoredCount++
} catch (e: IOException) {
Timber.w(e, "Failed to restore ${file.name} from backup")
}

}
}

Timber.i("Finished restoreOriginalExecutable for appId: $steamAppId. Restored $restoredCount executable(s)")
}
Expand Down
22 changes: 15 additions & 7 deletions app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,13 @@ class SteamUtilsFileSearchTest {
val dosDevicesPath = File(imageFs.wineprefix, "dosdevices/a:")
dosDevicesPath.mkdirs()

// Create .original.exe file
// Create multiple .original.exe files in different folders
val origExeFile = File(dosDevicesPath, "game.exe.original.exe")
origExeFile.writeBytes("original exe content".toByteArray())
val nestedDir = File(dosDevicesPath, "bin")
nestedDir.mkdirs()
val origExeFile2 = File(nestedDir, "game2.exe.original.exe")
origExeFile2.writeBytes("original exe content 2".toByteArray())

// Call the actual function
SteamUtils.restoreOriginalExecutable(context, steamAppId)
Expand All @@ -237,6 +241,10 @@ class SteamUtilsFileSearchTest {
assertTrue("Should restore exe to original location", restoredFile.exists())
assertEquals("Restored content should match backup",
"original exe content", restoredFile.readText())
val restoredFile2 = File(nestedDir, "game2.exe")
assertTrue("Should restore exe to original location in subdirectory", restoredFile2.exists())
assertEquals("Restored content should match backup for second exe",
"original exe content 2", restoredFile2.readText())
}

@Test
Expand Down Expand Up @@ -645,7 +653,7 @@ class SteamUtilsFileSearchTest {

// Verify game.exe is NOT overwritten after first replaceSteamClientDll call
assertEquals("game.exe should be overwritten after replaceSteamClientDll",
"unpacked exe content", gameExe.readText())
"original exe content", gameExe.readText())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Inconsistent assertion message: The comment says 'NOT overwritten' but the assertion message says 'should be overwritten'. Compare with the similar fix at line 770 where the message was correctly updated to 'should not be overwritten'. This makes test failures confusing to debug.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt, line 656:

<comment>Inconsistent assertion message: The comment says 'NOT overwritten' but the assertion message says 'should be overwritten'. Compare with the similar fix at line 770 where the message was correctly updated to 'should not be overwritten'. This makes test failures confusing to debug.</comment>

<file context>
@@ -645,7 +653,7 @@ class SteamUtilsFileSearchTest {
         // Verify game.exe is NOT overwritten after first replaceSteamClientDll call
         assertEquals("game.exe should be overwritten after replaceSteamClientDll",
-            "unpacked exe content", gameExe.readText())
+            "original exe content", gameExe.readText())
 
         // Verify marker was set
</file context>


Comment on lines 654 to 657
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix assertion message to match the new expectation.
The message still says “should be overwritten” while the expected content is the original EXE.

💡 Suggested tweak
-        assertEquals("game.exe should be overwritten after replaceSteamClientDll",
+        assertEquals("game.exe should NOT be overwritten after replaceSteamClientDll",
             "original exe content", gameExe.readText())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Verify game.exe is NOT overwritten after first replaceSteamClientDll call
assertEquals("game.exe should be overwritten after replaceSteamClientDll",
"unpacked exe content", gameExe.readText())
"original exe content", gameExe.readText())
// Verify game.exe is NOT overwritten after first replaceSteamClientDll call
assertEquals("game.exe should NOT be overwritten after replaceSteamClientDll",
"original exe content", gameExe.readText())
🤖 Prompt for AI Agents
In `@app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt` around
lines 654 - 657, The assertion message in SteamUtilsFileSearchTest.kt is
incorrect: update the message for the assertEquals that checks
gameExe.readText() (the assertion about "game.exe should be overwritten after
replaceSteamClientDll") to reflect the actual expectation that the file is NOT
overwritten (e.g., say "game.exe should NOT be overwritten after
replaceSteamClientDll" or similar) so the message matches the expected value
"original exe content".

// Verify marker was set
assertTrue("Should add STEAM_COLDCLIENT_USED marker",
Expand Down Expand Up @@ -762,8 +770,8 @@ class SteamUtilsFileSearchTest {
steamAppId.toString(), steamAppIdFile.readText().trim())

// Verify game.exe is NOT overwritten after second replaceSteamClientDll call
assertEquals("game.exe should be overwritten after second replaceSteamClientDll",
"unpacked exe content", gameExe.readText())
assertEquals("game.exe should not be overwritten after second replaceSteamClientDll",
"original exe content", gameExe.readText())

// Verify marker was set
assertTrue("Should add STEAM_COLDCLIENT_USED marker",
Expand Down Expand Up @@ -871,7 +879,7 @@ class SteamUtilsFileSearchTest {

// Verify game.exe is NOT overwritten after first replaceSteamClientDll call
assertEquals("game.exe should not be overwritten after replaceSteamClientDll",
"unpacked exe content", gameExe.readText())
"original exe content", gameExe.readText())

// Verify marker was set
assertTrue("Should add STEAM_COLDCLIENT_USED marker",
Expand Down Expand Up @@ -917,8 +925,8 @@ class SteamUtilsFileSearchTest {
SteamUtils.replaceSteamclientDll(context, testAppId)

// Verify restoreUnpackedExecutable overwrites game.exe with game.exe.unpacked.exe content
assertEquals("game.exe should be overwritten with game.exe.unpacked.exe content after second replaceSteamClientDll",
"unpacked exe content", gameExe.readText())
assertEquals("game.exe should be overwritten with game.exe.original.exe content after second replaceSteamClientDll",
"original exe content", gameExe.readText())
Comment on lines 927 to +929
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the test comment to reflect original.exe restoration.
The assertion now expects original.exe content, but the preceding comment still references restoreUnpackedExecutable/unpacked.exe.

💡 Suggested tweak
-        // Verify restoreUnpackedExecutable overwrites game.exe with game.exe.unpacked.exe content
+        // Verify restoreOriginalExecutable overwrites game.exe with game.exe.original.exe content
🤖 Prompt for AI Agents
In `@app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt` around
lines 927 - 929, The test comment above the assertEquals in
SteamUtilsFileSearchTest.kt is outdated — it mentions
restoreUnpackedExecutable/unpacked.exe but the assertion now checks that
game.exe contains the original.exe content; update the comment to say the test
verifies that restoring the original executable (game.exe.original.exe)
overwrites game.exe with the original content (matching the asserted "original
exe content") so the comment matches the assertion and references
game.exe.original.exe and the assertEquals check.


// Verify steam_settings folder still exists next to steamclient.dll in Steam directory
assertTrue("steam_settings folder should still exist in Steam directory",
Expand Down