From 57799bef5a1223fa0cb68a4fe5eac470409556ee Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 00:29:21 -0400 Subject: [PATCH] fix: add games picked inside a folder game as their own games (#1155) Picking an exe anywhere under an added folder treated it as that game, so a folder added with "Add all games in this folder" (or a removed one) swallowed every game below it: picking another game's exe switched the folder game's target, or brought the removed folder game back under its old name and art. Only treat a pick as the same game when it sits in the folder root, in the same top-level subfolder as the game's current exe, or is another build of that exe, and prefer the deepest matching folder. --- .../droiddeck/launcher/frontend/AddedGames.kt | 24 +++++++++- .../launcher/frontend/AddedExesTest.kt | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index 0c72812d1..aa10c0705 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -204,8 +204,10 @@ object AddedGames { GamesFolderMigration.run(context) val path = canonical(exe) fun holds(f: File) = inside(path, canonical(f)) - AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it } - SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it } + val entries = AddedExes.list(context) + fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe) + entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } + SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it } StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it } val dirs = listOfNotNull( GameStorage.effective(context)?.let { File(it.path, "steamapps/common") }, @@ -220,6 +222,24 @@ object AddedGames { return null } + private fun currentExe(context: Context, entries: List, folder: File): File? = + SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File) + ?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) } + ?: rank(folder).exes.firstOrNull() + + private fun sameGame(folder: File, current: File?, pick: File): Boolean { + val base = canonical(folder) + if (current == null || pick.parentFile?.let(::canonical) == base) return true + val known = canonical(current) + if (!inside(known, base) || known.substringBeforeLast('/') == base) return true + val picked = canonical(pick) + fun top(p: String) = p.removePrefix("$base/").substringBefore('/') + fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() } + return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) } + } + + private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$") + fun addExe(context: Context, exe: File): AddResult { val app = context.applicationContext if (!exe.isFile) return AddResult.NotFound diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt index 6cc36e909..65949fec6 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedExesTest.kt @@ -85,6 +85,54 @@ class AddedExesTest { assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder }) } + @Test fun aGameInsideAFolderAddedAsOneGameIsAddedOnItsOwn() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Pro Evolution Soccer 2017/PES2017.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals(pick.parentFile!!.path, (added as AddedGames.AddResult.Added).game.folder.path) + assertEquals(setOf(held.path, pick.path), AddedGames.scan(app).map { it.exe.path }.toSet()) + } + + @Test fun aRemovedFolderGameDoesNotComeBackForAnotherGame() { + noGamesFolders() + val held = onStorage("Download/ADM/Death Stranding/ds.exe") + val pick = onStorage("Download/ADM/Blur Game/Blur.exe") + val container = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(container.path, held.path, picked = false)) + SessionPrefs.setAddedGameName(app, container.path, "Death Stranding Directors Cut") + AddedGames.remove(app, container) + val added = AddedGames.addExe(app, pick) + assertTrue(added is AddedGames.AddResult.Added) + assertEquals("Blur Game", (added as AddedGames.AddResult.Added).game.name) + assertTrue(container.path in SessionPrefs.removedAddedGames(app)) + assertEquals(listOf(pick.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun anotherBuildOfTheSameExeInASiblingFolderSwitchesTheGame() { + noGamesFolders() + val held = onStorage("Games2/Oddworld/x64/Oddworld.exe") + val other = onStorage("Games2/Oddworld/x86/Oddworld.exe") + val folder = held.parentFile!!.parentFile!! + AddedExes.put(app, AddedExes.Entry(folder.path, held.path, picked = false)) + val switched = AddedGames.addExe(app, other) + assertTrue(switched is AddedGames.AddResult.Switched) + assertEquals(folder.path, (switched as AddedGames.AddResult.Switched).game.folder.path) + assertEquals(listOf(other.path), AddedGames.scan(app).map { it.exe.path }) + } + + @Test fun aDifferentGameWithANumberedNameIsNotTakenAsAnotherBuild() { + noGamesFolders() + val held = onStorage("Games2/id/Doom/Doom.exe") + val pick = onStorage("Games2/id/Doom64/Doom64.exe") + AddedExes.put(app, AddedExes.Entry(held.parentFile!!.parentFile!!.path, held.path, picked = false)) + assertTrue(AddedGames.addExe(app, pick) is AddedGames.AddResult.Added) + assertEquals(2, AddedGames.scan(app).size) + } + @Test fun anExeTheSessionCannotSeeIsNotAdded() { noGamesFolders() val outside = exe(File(tmp.newFolder("elsewhere"), "Game/Game.exe"))