fix: a folder of games with an extra non-game folder still lists each game (Rance 01) (#1822)

* fix: a folder of games still splits into its games when it also holds a folder with no game, as with Rance 01 in a VNs folder

A visual-novel folder on game storage held a steamapps folder with no exe in it. The group check asked every subfolder to hold a game, so the whole folder stayed one game and a pick in Rance 01 switched that game instead of adding its own. Folders without a game and Steam library folders no longer count, and old games folders being moved to entries split such a folder the same way.

* Check a folder's own entry, removal and store ownership before splitting it, and keep folders with redist or extras as one game

An old Games folder that was removed keeps its games removed after the split, a folder already added stays as it is, and a store game's folder is never split.

* fix: a split games folder keeps the game it launched and that game's settings

When an old games folder entry is split into one entry per game, the
folder holding the selected exe is kept even if ranking left it out (or
the parent stays whole when the exe sits directly in it), and the
parent's name, app ID, launch options and other settings carry over to
that game without overwriting settings it already has.

* fix: a split games folder never migrates steamapps and keeps the launched exe

A saved exe under a steamapps or hidden folder keeps the old entry whole
instead of adding that folder as a game, and the game that inherits the
old entry's settings keeps the exe the old entry launched when nothing
was picked.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-30 16:58:23 -04:00
1 parent 18f6305977
commit 2ff682a4f5
6 files changed
+166 -21

No files matched your search

@@ -170,9 +170,9 @@ object AddedGames {
val files = folder.listFiles() ?: return null
if (files.any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) }) return null
if (declaredExes(folder).isNotEmpty()) return null
val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") }.sortedBy { it.name.lowercase() }
if (dirs.size < 2 || dirs.any { it.name.lowercase() in GAME_PARTS }) return null
return dirs.takeIf { all -> all.all { rank(it).exes.isNotEmpty() } }
val dirs = files.filter { it.isDirectory && !it.name.startsWith(".") && !isSteamLibraryFolder(it.name) }.sortedBy { it.name.lowercase() }
if (dirs.any { it.name.lowercase() in GAME_PARTS || GameExePicker.auxiliaryDir(it) }) return null
return dirs.filter { rank(it).exes.isNotEmpty() }.takeIf { it.size >= 2 }
}
private fun expand(folder: File, entries: Set<String>): List<File> =
@@ -89,6 +89,8 @@ object GameExePicker {
return score
}
internal fun auxiliaryDir(dir: File): Boolean = skipDir(dir, dir.name)
private fun skipDir(dir: File, relative: String): Boolean {
val name = dir.name.lowercase().trim()
if (name in SKIP_DIRS || SKIP_DIR_PREFIX.any { name.startsWith(it) }) return true
@@ -24,15 +24,33 @@ object GamesFolderMigration {
for (root in roots) {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
entries.add(folder.path)
if (folder.path !in removed) n++
for (top in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (AddedGames.isSteamLibraryFolder(top.name) || top.name.lowercase() in steamInstalls) continue
if (top.absolutePath in storeRoots || StoreGameSidecar.read(top) != null) continue
if (top.path in entries) continue
val topPick = SessionPrefs.addedGameExe(context, top.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val groups = AddedGames.groupGames(top)
val played = topPick ?: groups?.let { AddedGames.rank(top).exes.firstOrNull() }
val owner = played?.let { exe -> top.listFiles { f -> f.isDirectory }?.firstOrNull { exe.path.startsWith(it.path + File.separator) } }
val folders = when {
groups == null -> listOf(top)
topPick == null || owner in groups -> groups
owner == null || owner.name.startsWith(".") || AddedGames.isSteamLibraryFolder(owner.name) -> listOf(top)
else -> (groups + owner).sortedBy { it.name.lowercase() }
}
for (folder in folders) {
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
?: topPick?.takeIf { it.path.startsWith(folder.path + File.separator) }
val ranked = AddedGames.rank(folder).exes.firstOrNull()
val exe = picked ?: played?.takeIf { folder == owner } ?: ranked ?: continue
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null || exe != ranked))
entries.add(folder.path)
if (folder != top && folder == owner) SessionPrefs.inheritAddedGame(context, top.path, folder.path)
if (folder != top && top.path in removed) SessionPrefs.setAddedGameRemoved(context, folder.path, true)
else if (folder.path !in removed) n++
}
}
Log.i(TAG, "games: migrated ${root.host} → $n games")
}
@@ -1023,21 +1023,34 @@ object SessionPrefs {
if (previous != null && !p.contains("addedAppId:$folderPath")) {
val moved = movedAddedGame(p.all, previous, folderPath)
moved.keys.forEach { edit.remove(it) }
moved.values.forEach { (name, value) ->
when (value) {
is String -> edit.putString(name, value)
is Long -> edit.putLong(name, value)
is Int -> edit.putInt(name, value)
is Boolean -> edit.putBoolean(name, value)
is Float -> edit.putFloat(name, value)
}
}
moved.values.forEach { (name, value) -> edit.putValue(name, value) }
val removed = removedAddedGames(context)
if (previous in removed) edit.putString("removedAddedGames", removed.map { if (it == previous) folderPath else it }.joinToString("\n"))
}
edit.apply()
}
fun inheritAddedGame(context: Context, from: String, to: String) = synchronized(addedGameLock) {
val p = prefs(context)
val all = p.all
val edit = p.edit()
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.forEach { (name, value) ->
val key = name.removeSuffix(from) + to
if (key !in all) edit.putValue(key, value)
}
edit.apply()
}
private fun android.content.SharedPreferences.Editor.putValue(name: String, value: Any?) {
when (value) {
is String -> putString(name, value)
is Long -> putLong(name, value)
is Int -> putInt(name, value)
is Boolean -> putBoolean(name, value)
is Float -> putFloat(name, value)
}
}
internal fun movedAddedGame(all: Map<String, *>, from: String, to: String): Map<String, Pair<String, Any?>> =
all.filterKeys { it.startsWith("added") && it.endsWith(":$from") }.mapValues { (name, value) ->
val moved = if (value is String && (value == from || value.startsWith("$from/"))) to + value.removePrefix(from) else value
@@ -138,6 +138,19 @@ class AddedExesTest {
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
}
@Test fun aGroupFolderWithFoldersHoldingNoGameStillListsEachGame() {
val storage = visualNovels()
val vns = File(storage, "VNs")
File(vns, "steamapps/common").mkdirs()
File(vns, "Notes/readme.txt").apply { parentFile!!.mkdirs(); writeText("x") }
assertEquals(listOf("Albatross", "Angel Beats", "Rance 01", "Sayonara").map { File(vns, it).path },
AddedGames.scan(app).map { it.folder.path })
val pick = File(vns, "Rance 01/Rance01.exe")
assertEquals(pick.parentFile!!.path, result(AddedGames.addExe(app, pick)).folder.path)
assertEquals(4, AddedGames.scan(app).size)
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
}
@Test fun anExePickedInAGroupFolderOnGameStorageIsItsOwnGame() {
val storage = visualNovels()
val vns = File(storage, "VNs")
@@ -67,6 +67,105 @@ class GamesFolderMigrationTest {
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
@Test fun aFolderOfGamesInAnOldGamesFolderBecomesOneEntryPerGame() {
val pc = tmp.newFolder("PC")
val rance = File(pc, "VNs/Rance 01"); exe(pc, "VNs/Rance 01/Rance01.exe")
val albatross = File(pc, "VNs/Albatross"); exe(pc, "VNs/Albatross/Albatross Log.exe")
File(pc, "VNs/steamapps").mkdirs()
val solo = File(pc, "Solo"); exe(pc, "Solo/Solo.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
GamesFolderMigration.run(app)
assertEquals(setOf(albatross.path, rance.path, solo.path), AddedExes.list(app).map { it.folder }.toSet())
assertEquals(setOf(albatross.path, rance.path, solo.path), AddedGames.scan(app).map { it.folder.path }.toSet())
}
@Test fun aRemovedOrAlreadyAddedFolderOfGamesKeepsItsState() {
val pc = tmp.newFolder("PC")
val gone = File(pc, "Gone"); exe(pc, "Gone/A/a.exe"); exe(pc, "Gone/B/b.exe")
val kept = File(pc, "Kept"); val keptExe = exe(pc, "Kept/A/a.exe"); exe(pc, "Kept/B/b.exe")
val store = File(pc, "Store"); exe(pc, "Store/Content/game.exe"); exe(pc, "Store/Tools/tool.exe")
StoreGameSidecar.file(store).writeText("""{"store":"gog","id":"2","title":"Store Game","exe":"Content/game.exe"}""")
val solo = File(pc, "Solo"); exe(pc, "Solo/Extras/viewer.exe"); exe(pc, "Solo/Content/solo.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.setAddedGameRemoved(app, gone.path, true)
AddedExes.put(app, AddedExes.Entry(kept.path, keptExe.path))
GamesFolderMigration.run(app)
val entries = AddedExes.list(app).map { it.folder }.toSet()
assertEquals(setOf(File(gone, "A").path, File(gone, "B").path, kept.path, solo.path), entries)
assertTrue(SessionPrefs.removedAddedGames(app).containsAll(listOf(File(gone, "A").path, File(gone, "B").path)))
assertEquals(setOf(kept.path, solo.path), AddedGames.scan(app).map { it.folder.path }.toSet())
}
@Test fun aGameSelectedOutsideTheRankedFoldersStaysListed() {
val pc = tmp.newFolder("PC")
val vns = File(pc, "VNs")
exe(pc, "VNs/A/a.exe"); exe(pc, "VNs/B/b.exe")
val pick = exe(pc, "VNs/C/helper.exe")
val loose = File(pc, "Loose"); exe(pc, "Loose/A/a.exe"); exe(pc, "Loose/B/b.exe")
val loosePick = exe(pc, "Loose/setup.exe")
val lib = File(pc, "Lib"); exe(pc, "Lib/A/a.exe"); exe(pc, "Lib/B/b.exe")
val libPick = exe(pc, "Lib/steamapps/common/Tool/helper.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.setAddedGameExe(app, vns.path, pick.path)
SessionPrefs.setAddedGameExe(app, loose.path, loosePick.path)
SessionPrefs.setAddedGameExe(app, lib.path, libPick.path)
GamesFolderMigration.run(app)
val entries = AddedExes.list(app).associateBy { it.folder }
assertEquals(setOf(File(vns, "A").path, File(vns, "B").path, File(vns, "C").path, loose.path, lib.path), entries.keys)
assertEquals(libPick.path, entries.getValue(lib.path).exe)
assertEquals(pick.path, entries.getValue(File(vns, "C").path).exe)
assertTrue(entries.getValue(File(vns, "C").path).picked)
assertEquals(loosePick.path, entries.getValue(loose.path).exe)
val games = AddedGames.scan(app).associateBy { it.folder.path }
assertEquals(pick.path, games.getValue(File(vns, "C").path).exe.path)
assertEquals(loosePick.path, games.getValue(loose.path).exe.path)
}
@Test fun aSplitFolderHandsItsSettingsToTheGameItLaunched() {
val pc = tmp.newFolder("PC")
val vns = File(pc, "VNs")
exe(pc, "VNs/Albatross/Albatross.exe")
val pick = exe(pc, "VNs/Rance 01/Rance01.exe")
File(pc, "VNs/Notes").mkdirs()
val plain = File(pc, "Plain"); exe(pc, "Plain/A/a.exe"); exe(pc, "Plain/B/b.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.setAddedGameExe(app, vns.path, pick.path)
SessionPrefs.addedGameAppId(app, vns.path, 0x8111_2222L)
SessionPrefs.setAddedGameName(app, vns.path, "Rance")
SessionPrefs.setAddedGameLaunch(app, vns.path, "-windowed")
SessionPrefs.setAddedGameName(app, File(vns, "Rance 01").path, "Rance 01 Remake")
SessionPrefs.addedGameAppId(app, plain.path, 0x8333_4444L)
GamesFolderMigration.run(app)
val rance = File(vns, "Rance 01").path
val games = AddedGames.scan(app).associateBy { it.folder.path }
assertEquals(0x8111_2222L, games.getValue(rance).appId)
assertEquals("Rance 01 Remake", games.getValue(rance).name)
assertEquals("-windowed", games.getValue(rance).launchOptions)
assertEquals(pick.path, games.getValue(rance).exe.path)
assertTrue(games.getValue(File(vns, "Albatross").path).appId != 0x8111_2222L)
assertEquals("", SessionPrefs.addedGameLaunch(app, File(vns, "Albatross").path))
assertEquals(0x8333_4444L, games.getValue(File(plain, "A").path).appId)
assertTrue(games.getValue(File(plain, "B").path).appId != 0x8333_4444L)
}
@Test fun aSplitFolderKeepsTheExeItLaunchedWithoutAPick() {
val pc = tmp.newFolder("PC")
val vns = File(pc, "VNs")
val launched = exe(pc, "VNs/Rance/VNsPlayer.exe"); exe(pc, "VNs/Rance/Rance.exe")
val other = exe(pc, "VNs/Other/Other.exe")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
SessionPrefs.addedGameAppId(app, vns.path, 0x8555_6666L)
GamesFolderMigration.run(app)
val entries = AddedExes.list(app).associateBy { it.folder }
assertEquals(launched.path, entries.getValue(File(vns, "Rance").path).exe)
assertTrue(entries.getValue(File(vns, "Rance").path).picked)
assertFalse(entries.getValue(File(vns, "Other").path).picked)
val games = AddedGames.scan(app).associateBy { it.folder.path }
assertEquals(launched.path, games.getValue(File(vns, "Rance").path).exe.path)
assertEquals(0x8555_6666L, games.getValue(File(vns, "Rance").path).appId)
assertEquals(other.path, games.getValue(File(vns, "Other").path).exe.path)
}
@Test fun runningAgainChangesNothing() {
val pc = tmp.newFolder("PC")
exe(pc, "Alpha/Alpha.exe")