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.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-20 00:29:21 -04:00
1 parent cee84fd12a
commit 57799bef5a
2 files changed
+70 -2

No files matched your search

@@ -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<AddedExes.Entry>, 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
@@ -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"))