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 4f080f4c3..c5d011636 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -98,18 +98,26 @@ object AddedGames { return null } - /** The .exe files a game folder offers, best first ([GameExePicker]); also what its exe box lists, in that order. */ + /** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */ fun candidates(folder: File): List = rank(folder).exes - /** [GameExePicker]'s ranking of [folder], without the names this list never offers. */ + /** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */ fun rank(folder: File): GameExePicker.Ranking { val key = "${folder.path}|${folder.lastModified()}" val now = System.currentTimeMillis() ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second } - return GameExePicker.rank(folder) { !SKIP.matches(it.name) }.also { - if (ranked.size > 256) ranked.clear() - ranked[key] = now to it - } + val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) } + val declared = declaredExes(folder) + val preferred = declared.map { it.path }.toSet() + val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking( + declared + guessed.exes.filter { it.path !in preferred }, + List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores) + .filter { it.first.path !in preferred }.map { it.second }, + uncertain = false, + ) + if (ranked.size > 256) ranked.clear() + ranked[key] = now to result + return result } /** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */ @@ -160,6 +168,24 @@ object AddedGames { else -> peName } } + /** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */ + internal fun declaredExes(folder: File): List { + val names = DECLARED.flatMap { (file, pattern) -> + runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList()) + } + val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList() + return names.asSequence() + .map { File(folder, it.trim().replace('\\', '/').trimStart('/')) } + .filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile } + .filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) } + .distinctBy { it.path } + .toList() + } + + private val DECLARED = listOf( + "MicrosoftGame.config" to Regex("]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE), + "appxmanifest.xml" to Regex("]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE), + ) fun scan(context: Context): List { val out = ArrayList() diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt index 5c6d70442..37a4ae776 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt @@ -27,4 +27,45 @@ class AddedGamesScanTest { SessionPrefs.setAddedGamesDirs(app, listOf(games.path)) assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name }) } + + @Test fun aStorePackageLaunchesTheExecutableItDeclares() { + val game = tmp.newFolder("Minecraft Dungeons") + File(game, "Dungeons.exe").writeText("unreal bootstrap") + File(game, "Dungeons/Binaries/Win64").mkdirs() + File(game, "Dungeons/Binaries/Win64/Dungeons.exe").writeText("the game, renamed from Dungeons-Win64-Shipping.exe") + File(game, "MicrosoftGame.config").writeText( + "\n \n", + ) + assertEquals(File(game, "Dungeons/Binaries/Win64/Dungeons.exe").path, AddedGames.candidates(game).first().path) + assertEquals(2, AddedGames.candidates(game).size) + } + + @Test fun anAppxManifestNamesTheGameWhenThereIsNoConfig() { + val game = tmp.newFolder("Store Game") + File(game, "Launcher.exe").writeText("bootstrap") + File(game, "Game/Binaries/Win64").mkdirs() + File(game, "Game/Binaries/Win64/Game.exe").writeText("game") + File(game, "appxmanifest.xml").writeText("") + assertEquals(File(game, "Game/Binaries/Win64/Game.exe").path, AddedGames.candidates(game).first().path) + } + + @Test fun aDeclaredTopLevelExecutableKeepsItsPlace() { + val game = tmp.newFolder("Minecraft Dungeons II") + File(game, "Dungeons.exe").writeText("bootstrap") + File(game, "Other.exe").writeText("a much longer other executable") + File(game, "MicrosoftGame.config").writeText("") + assertEquals(listOf("Dungeons.exe", "Other.exe"), AddedGames.candidates(game).map { it.name }) + } + + @Test fun declaredHelpersMissingFilesAndPathsOutsideTheFolderAreIgnored() { + val game = tmp.newFolder("Pass Game") + File(game, "PassGame.exe").writeText("game") + File(game, "gamelaunchhelper.exe").writeText("needs gaming services") + tmp.newFile("Outside.exe").writeText("not this game") + File(game, "MicrosoftGame.config").writeText( + "", + ) + assertEquals(emptyList(), AddedGames.declaredExes(game)) + assertEquals(listOf("PassGame.exe"), AddedGames.candidates(game).map { it.name }) + } }