From 6ee8866599c997002cbb527c2fdfd29272edab45 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Wed, 7 Oct 2026 09:31:33 -0400 Subject: [PATCH] Make Games folders Steam libraries and skip the zero-filled reservation (#388) With Second library on Automatic, every writable Games folder is now a Steam library: it gets the same private prefix and tool binds as the card, the app hands the session the list (BL_STEAM_LIBRARIES), and droiddeck-steam-library registers, relabels and forgets entries from it. Steam offers them in the install dialog as " ()", and Proton mapping, esync, the redist seeder, desktop entries and the compat beat read every registered library instead of only the card. Games folder libraries stop at the fast path's bind budget. Steam's preallocation on Android's FUSE storage either failed outright (NTFS answers EPERM, so installs were cancelled as "not enough disk space") or zero-filled the whole download first (exFAT, minutes for a small game). After fallocate is refused, the Steam client's ftruncate extension of its own staging files is reported as done; the writes that follow fill the file. A 76 MB install to a USB SSD now preallocates instantly instead of taking about 3.5 minutes. A library that is not there is left out of the list and forgotten until it returns, imports whose folder is missing are set aside rather than offered as broken installs, and the front end refreshes when storage is mounted or removed. --- .../com/droiddeck/launcher/MainActivity.kt | 18 +++ .../droiddeck/launcher/frontend/AddedGames.kt | 2 +- .../droiddeck/launcher/frontend/Library.kt | 39 +++-- .../droiddeck/launcher/session/GameStorage.kt | 20 +++ .../launcher/session/SecondaryLibrary.kt | 6 +- .../launcher/session/SessionService.kt | 43 ++++- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 6 +- app/src/main/res/values-ko/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values-zh-rHK/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/strings.xml | 7 +- .../launcher/frontend/AddedGamesScanTest.kt | 30 ++++ .../launcher/session/GameStorageTest.kt | 42 +++++ .../launcher/session/SecondaryLibraryTest.kt | 11 ++ .../usr/local/bin/droiddeck-desktop-games | 10 +- .../overlay/usr/local/bin/droiddeck-esync | 13 +- .../usr/local/bin/droiddeck-seed-redists | 6 +- .../overlay/usr/local/bin/droiddeck-session | 2 +- .../usr/local/bin/droiddeck-steam-games | 48 +++++- .../usr/local/bin/droiddeck-steam-library | 33 +++- .../overlay/usr/local/bin/steam-compatibility | 22 ++- tools/linuxfs/preload/storage.c | 75 +++++++-- tools/tests/test_steam_games.py | 40 +++++ tools/tests/test_steam_libraries.py | 152 ++++++++++++++++++ tools/tests/test_storage_preload.py | 149 +++++++++++++++++ 29 files changed, 746 insertions(+), 70 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt create mode 100644 tools/tests/test_steam_libraries.py create mode 100644 tools/tests/test_storage_preload.py diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 07548b4ec..98ab23849 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -175,6 +175,16 @@ class MainActivity : ComponentActivity() { refreshWifiDiscovery() } } + private val mediaRefresh = Runnable { + refresh() + refreshAddedGames() + } + private val mediaReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context?, intent: Intent?) { + ui.removeCallbacks(mediaRefresh) + ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS) + } + } private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this)) refreshWifiDiscovery() @@ -927,6 +937,11 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION)) + registerReceiver(mediaReceiver, android.content.IntentFilter().apply { + for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED, + Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action) + addDataScheme("file") + }) displayManager.registerDisplayListener(secondScreenDisplayListener, ui) refreshSecondScreenDisplays() } @@ -938,6 +953,8 @@ class MainActivity : ComponentActivity() { override fun onStop() { unregisterReceiver(wifiLocationReceiver) + unregisterReceiver(mediaReceiver) + ui.removeCallbacks(mediaRefresh) displayManager.unregisterDisplayListener(secondScreenDisplayListener) // The session covers the page by now; coming back finds it as it was. flood = null @@ -1625,6 +1642,7 @@ class MainActivity : ComponentActivity() { companion object { private const val TAG = "MainActivity" + private const val MEDIA_SETTLE_MS = 1500L /** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */ private val ZIP_EXT = listOf("zip") private val WCP_EXT = listOf("wcp") 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 949602649..1b85909e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -98,7 +98,7 @@ object AddedGames { if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue } val steamInstalls = steamInstallDirs(dir) for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) { - if (folder.name.lowercase() in steamInstalls) continue + if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue scanGame(context, folder, out) } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 68ac5a7f0..82f7065c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -86,17 +86,20 @@ object Library { return listOfNotNull( root to "internal", GameStorage.effective(context)?.let { File(it.path) to it.label }, - ) + ) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label } } /** Proton keeps each game's prefix below compatdata//pfx in a Steam library. */ - fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? { + fun protonPrefix( + context: Context, appId: Long, preferredLibrary: File? = null, + libraries: List = steamLibraries(context).map { it.first }, + ): File? { val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct() - val secondary = GameStorage.effective(context)?.let { File(it.path) } - fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath) + val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet() + fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary) SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root else root - val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot) + val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot) .distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) } return roots.asSequence() .flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } } @@ -107,6 +110,7 @@ object Library { val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") val cache = File(root, "appcache/librarycache") val libraries = steamLibraries(context) + val roots = libraries.map { it.first } val out = LinkedHashMap() for ((library, label) in libraries) { val steamapps = File(library, "steamapps") @@ -132,7 +136,7 @@ object Library { out[appId] = SteamGame( appId, name, art, label, hero = hero, lastPlayed = lastPlayed, gameFiles = gameFiles, - protonPrefix = protonPrefix(context, appId.toLong(), library), + protonPrefix = protonPrefix(context, appId.toLong(), library, roots), icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png")) ?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) }, ) @@ -142,16 +146,19 @@ object Library { } /** The same installed-game inventory used for links, shortcuts and file exports. */ - fun launchableGames(context: Context, strictRead: Boolean = false): List = (steamGames(context, strictRead) + AddedGames.scan(context).map { g -> - AddedGameArt.resolve(context, g).let { art -> - SteamGame( - g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId, - hero = art.hero ?: art.header, gameFiles = g.folder, - protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId), - icon = art.icon?.takeIf { it.extension.lowercase() != "ico" }, - ) - } - }).distinctBy { it.gameId } + fun launchableGames(context: Context, strictRead: Boolean = false): List { + val roots = steamLibraries(context).map { it.first } + return (steamGames(context, strictRead) + AddedGames.scan(context).map { g -> + AddedGameArt.resolve(context, g).let { art -> + SteamGame( + g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId, + hero = art.hero ?: art.header, gameFiles = g.folder, + protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots), + icon = art.icon?.takeIf { it.extension.lowercase() != "ico" }, + ) + } + }).distinctBy { it.gameId } + } /** Steam stores current library art inside hash-named folders under the app's cache dir. */ private fun steamCacheImage(cache: File, appId: Int, names: List): File? { diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 8c072d44d..824e6b565 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -6,6 +6,7 @@ import android.os.StatFs import android.os.storage.StorageManager import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.AddedGames import java.io.File /** @@ -65,6 +66,25 @@ object GameStorage { } } + class Library(val host: File, val guest: String, val label: String) + + fun gamesFolderLibraries(context: Context): List { + if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList() + val card = effective(context)?.let { canonical(File(it.path)) } + val sm = context.getSystemService(StorageManager::class.java) + return AddedGames.roots(context).mapNotNull { root -> + val host = canonical(root.host) + if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null + if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null + val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null } + val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) } + val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume) + Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' ')) + } + } + + private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath) + /** The label the client shows for a chosen folder: its last name, or the volume's. */ fun labelFor(context: Context, path: String): String = options(context).firstOrNull { it.path == path }?.name diff --git a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt index 74b7448ff..10692167d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt @@ -23,8 +23,10 @@ object SecondaryLibrary { return File(files, "steam-libraries/$digest") } + val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd") + /** Parent binds come first; the more specific private directories override them in PRoot. */ - fun binds(files: File, library: File): List { + fun binds(files: File, library: File, guests: List = CARD_GUESTS): List { val private = privateRoot(files, library) val apps = File(library, "steamapps") val tools = LinkedHashMap(bootstrapTools) @@ -57,7 +59,7 @@ object SecondaryLibrary { if (lower != relative) overrides.add(target to lower) } } - return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest -> + return guests.flatMap { guest -> listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 8fa227685..96d02cee6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -426,7 +426,7 @@ class SessionService : Service() { // whatever a session leaves behind. The client abandons tens of megabytes of streams a run. FileUtils.clear(File(cacheDir, "shm")) - val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest) + val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir) // The fast path is told exactly the rootfs and binds proot is given (ProotFastPath). val fastPathKey = if (ProotFastPath.enabled(this)) { val prootBinds = LinuxRuntime.binds( @@ -777,6 +777,8 @@ class SessionService : Service() { fakeInputDir: File, sessionDir: File, guest: MutableList, + sessionRoot: File, + runtimeDir: File, ): ArrayList { val binds = ArrayList() if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") @@ -897,6 +899,45 @@ class SessionService : Service() { } else if (roms != null) { Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session") } + val libraries = ArrayList>() + if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label) + val gamesLibraries = GameStorage.gamesFolderLibraries(this) + if (gamesLibraries.isNotEmpty()) { + var room = if (ProotFastPath.enabled(this)) { + ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size + } else Int.MAX_VALUE + for (games in gamesLibraries) { + if (games.host.path + ":" + games.guest !in binds) continue + val problem = GameStorage.prepare(this, games.host.path) + if (problem != null) { + Log.w(TAG, "steam library: $problem") + continue + } + try { + val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1) + if (extra.size > room) { + Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session") + continue + } + binds.addAll(extra) + room -= extra.size + libraries.add(games.guest to games.label) + Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")") + } catch (e: Exception) { + Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e) + } + } + } + try { + val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() } + val json = org.json.JSONArray() + for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' '))) + listing.writeText(json.toString()) + val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size + guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path) + } catch (e: Exception) { + Log.w(TAG, "steam library: the list could not be written; the card alone this session", e) + } return binds } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 71f2926b2..797d3e358 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -41,7 +41,7 @@ %1$s: falló el motor %1$s: requiere Lossless Scaling %1$s %2$s · iniciando - Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam. + Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam. Con Segunda biblioteca en Automático, Steam también puede instalar juegos en esta carpeta. Añade una biblioteca de Steam y busca juegos de Windows en ella. Los juegos que tienes usan sus entradas de Steam; el resto se convierten en accesos directos ajenos a Steam. Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan. Entorno del juego @@ -353,8 +353,8 @@ ¿Activar la detección de Wi-Fi? Android exige el permiso de ubicación para leer los nombres de Wi-Fi y detectar redes cercanas. DroidDeck usa este acceso para mostrar esas redes en Steam. No rastreamos tu ubicación. El acceso a internet funciona sin este permiso. Almacenamiento de juegos - Automático - la tarjeta SD si está presente - Automático - la tarjeta SD si está presente (ninguna ahora) + Automático - la tarjeta SD si está presente, y tus carpetas de juegos + Automático - tus carpetas de juegos, y la tarjeta SD si está presente (ninguna ahora) Solo interno Carpeta: %1$s Segunda biblioteca diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1c464ee0d..61e76e8f7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -41,7 +41,7 @@ %1$s : échec du moteur %1$s : nécessite Lossless Scaling %1$s %2$s · Démarrage en cours - Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam. + Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam. Si Bibliothèque secondaire est sur Auto, Steam peut aussi installer des jeux dans ce dossier. Ajoutez une bibliothèque Steam et analysez-y les jeux Windows. Les jeux déjà possédés utiliseront les entrées Steam correspondantes, les autres deviendront des raccourcis non-Steam. « Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque. Environnement de jeu @@ -300,8 +300,8 @@ Activer la découverte Wi-Fi ? Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation. Stockage des jeux - Auto - Utiliser la carte SD si disponible - Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement) + Auto - Utiliser la carte SD si disponible, ainsi que vos dossiers de jeux + Auto - Utiliser vos dossiers de jeux, ainsi que la carte SD si disponible (aucune carte SD actuellement) Stockage interne uniquement Dossier : %1$s Bibliothèque secondaire diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b47291cde..12f3435ab 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -41,7 +41,7 @@ %1$s:エンジンエラー %1$s:Lossless Scalingが必要です %1$s %2$s · 起動中 - 複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。 + 複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。第2ライブラリが自動の場合、Steamはこのフォルダにもゲームをインストールできます。 Steamライブラリを追加し、その中のWindowsゲームをスキャンします。所有しているゲームはSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。 「忘れる」を選択すると、次回のセッションでこれらのショートカットが削除されます。ただし、ファイルはディスク上に残ります。 ゲーム環境 @@ -353,8 +353,8 @@ Wi-Fi検出を有効にしますか? AndroidがWi-Fi名を読み取り、付近のネットワークを検出するには位置情報の権限が必要です。DroidDeckはこの権限を使用してSteamにこれらのネットワークを表示します。位置情報を追跡することはありません。この権限がなくてもインターネットにアクセスできます。 ゲームストレージ - 自動 - SDカードがあればSDカード - 自動 - SDカードがあればSDカード(現在なし) + 自動 - SDカードがあればSDカード、およびゲームフォルダ + 自動 - ゲームフォルダ、およびSDカードがあればSDカード(現在なし) 内部ストレージのみ フォルダ:%1$s 第2ライブラリ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b1630b7b0..ee6c278dc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -41,7 +41,7 @@ %1$s: 엔진 실패 %1$s: Lossless Scaling 필요 %1$s %2$s · 시작 중 - 여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. + 여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. 보조 라이브러리가 자동이면 Steam이 이 폴더에도 게임을 설치할 수 있습니다. Steam 라이브러리를 추가하고 그 안의 Windows 게임을 검사합니다. 소유한 게임은 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. \"잊기\"를 누르면 다음 세션에서 이 바로 가기가 제거됩니다. 하지만 파일은 디스크에 그대로 남습니다. 게임 환경 @@ -353,8 +353,8 @@ Wi-Fi 검색을 활성화하시겠습니까? Android는 Wi-Fi 이름을 읽고 주변 네트워크를 검색하려면 위치 권한이 필요합니다. DroidDeck은 이 권한을 사용하여 Steam에 이러한 네트워크를 표시합니다. 위치를 추적하지 않습니다. 이 권한 없이도 인터넷에 액세스할 수 있습니다. 게임 저장 공간 - 자동 - SD 카드가 있으면 SD 카드 사용 - 자동 - SD 카드가 있으면 SD 카드 사용(현재 SD 카드 없음) + 자동 - SD 카드가 있으면 SD 카드 사용, 게임 폴더도 사용 + 자동 - 게임 폴더 사용, SD 카드가 있으면 SD 카드도 사용(현재 SD 카드 없음) 내부 저장 공간만 폴더: %1$s 보조 라이브러리 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b4234bcd2..e0a1a5b04 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -41,7 +41,7 @@ %1$s: сбой движка %1$s: требуется Lossless Scaling %1$s %2$s · Запуск - Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. + Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку. Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam. «Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске. Игровая среда @@ -300,8 +300,8 @@ Включить обнаружение Wi-Fi? Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения. Хранилище игр - Авто - Использовать SD-карту при наличии - Авто - Использовать SD-карту при наличии (сейчас нет SD-карты) + Авто - Использовать SD-карту при наличии, а также ваши папки с играми + Авто - Использовать ваши папки с играми, а также SD-карту при наличии (сейчас нет SD-карты) Только внутреннее хранилище Папка: %1$s Вторая библиотека diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d1505c0e4..6139fecb8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎失败 %1$s:需要 Lossless Scaling %1$s %2$s · 启动中 - 一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。 + 一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。第二个库设为自动时,Steam 也可以把游戏安装到此文件夹。 添加一个 Steam 库并扫描其中的 Windows 游戏。已拥有的游戏使用其 Steam 条目;其余变成非 Steam 快捷方式。 “移除”会在下次会话删除这些快捷方式。文件仍保留在磁盘上。 游戏环境 @@ -353,8 +353,8 @@ 启用 Wi-Fi 网络发现? Android 需要位置权限才能读取 Wi-Fi 名称并发现附近网络。DroidDeck 用此权限在 Steam 中显示这些网络。我们不会跟踪你的位置。没有此权限互联网访问也能正常工作。 游戏存储 - 自动 — 有 SD 卡时使用 SD 卡 - 自动 — 有 SD 卡时使用 SD 卡(当前没有) + 自动 — 有 SD 卡时使用 SD 卡,以及你的游戏文件夹 + 自动 — 使用你的游戏文件夹,有 SD 卡时也使用 SD 卡(当前没有) 仅内部存储 文件夹:%1$s 第二个库 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b7a9c3e09..6fda24b77 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎故障 %1$s:需要 Lossless Scaling %1$s %2$s · 正在啟動 - 一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。 + 一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。第二個收藏庫設為自動時,Steam 亦可以將遊戲安裝到此資料夾。 新增一個 Steam 收藏庫並掃描其中的 Windows 遊戲。已擁有的遊戲會使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。 「忘記」會在下次會話移除這些捷徑,檔案仍會保留在磁碟上。 遊戲環境 @@ -353,8 +353,8 @@ 啟用 Wi-Fi 探索? Android 需要位置權限才能讀取 Wi-Fi 名稱和尋找附近網絡。DroidDeck 只會用此權限在 Steam 中顯示這些網絡,不會追蹤你的位置。沒有此權限亦可正常上網。 遊戲儲存位置 - 自動 - 有 SD 卡時使用 SD 卡 - 自動 - 有 SD 卡時使用 SD 卡(目前沒有) + 自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾 + 自動 - 使用你的遊戲資料夾,有 SD 卡時亦使用 SD 卡(目前沒有) 只限內部儲存空間 資料夾:%1$s 第二個收藏庫 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c95ceccfc..6af7eb6cf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -41,7 +41,7 @@ %1$s:引擎失敗 %1$s:需要 Lossless Scaling %1$s %2$s · 正在啟動 - 一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。 + 一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。第二收藏庫設為自動時,Steam 也可以將遊戲安裝到此資料夾。 新增一個 Steam 收藏庫,並掃描其中的 Windows 遊戲。已擁有的遊戲會使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。 「忘記」會在下次工作階段中移除這些捷徑。但檔案仍保留在磁碟上。 遊戲環境 @@ -353,8 +353,8 @@ 啟用 Wi-Fi 探索? Android 需要定位權限才能讀取 Wi-Fi 名稱並探索附近網路。DroidDeck 使用此權限在 Steam 中顯示這些網路。我們不會追蹤你的位置。沒有此權限也可以存取網際網路。 遊戲儲存空間 - 自動 - 有 SD 卡時使用 SD 卡 - 自動 - 有 SD 卡時使用 SD 卡(目前無 SD 卡) + 自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾 + 自動 - 使用你的遊戲資料夾,有 SD 卡時也使用 SD 卡(目前無 SD 卡) 僅內部儲存空間 資料夾:%1$s 第二收藏庫 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05416d2f5..71799ae61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,7 +43,7 @@ %1$s: engine failed %1$s: needs Lossless Scaling %1$s %2$s · starting - A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts. + A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts. With Second library on Automatic, Steam can install games into this folder too. Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts. Forget removes the shortcuts next session. Files stay on disk. Game environment @@ -357,8 +357,8 @@ Enable Wi-Fi discovery? Android requires Location permission to read Wi-Fi names and discover nearby networks. DroidDeck uses this access to show those networks in Steam. We don\'t track your location. Internet access works without this permission. Game storage - Automatic - the SD card when present - Automatic - the SD card when present (none now) + Automatic - the SD card when present, plus your Games folders + Automatic - your Games folders, plus the SD card when present (none now) Internal only Folder: %1$s Second library @@ -1851,6 +1851,7 @@ SD card Folder + %1$s (%2$s) Share session logs diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt new file mode 100644 index 000000000..5c6d70442 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.session.SessionPrefs +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGamesScanTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + @Test fun theSteamLibraryInsideAGamesFolderIsNotAGame() { + val games = tmp.newFolder("PC") + File(games, "Example").mkdirs() + File(games, "Example/Example.exe").writeText("game") + File(games, "steamapps/downloading").mkdirs() + File(games, "steamapps/downloading/Depot.exe").writeText("depot") + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + SessionPrefs.setAddedGamesDirs(app, listOf(games.path)) + assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt new file mode 100644 index 000000000..fbda5f5d5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameStorageTest.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.frontend.AddedGames +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class GameStorageTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + @Test fun gamesFoldersBecomeLibrariesOnAutomatic() { + val pc = tmp.newFolder("PC") + val missing = File(tmp.root, "Unplugged") + SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path)) + val libraries = GameStorage.gamesFolderLibraries(app) + assertEquals(listOf(pc), libraries.map { it.host }) + assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest }) + assertEquals(listOf("PC"), libraries.map { it.label }) + } + + @Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() { + SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path)) + SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") + assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty()) + SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen") + assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty()) + } + + @Test fun namesTheClientCannotStoreAreLeftOut() { + SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path)) + assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt index 0f65cecc3..131ad64e5 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt @@ -25,6 +25,17 @@ class SecondaryLibraryTest { } } + @Test fun aGamesFolderLibraryIsBoundOnlyAtItsOwnPath() { + val files = tmp.newFolder("files") + val library = tmp.newFolder("PC") + val binds = SecondaryLibrary.binds(files, library, listOf("/root/Games/PC")) + val private = SecondaryLibrary.privateRoot(files, library) + assertEquals("${library.path}:/root/Games/PC", binds.first()) + assertTrue(binds.contains("$private/steamapps/compatdata:/root/Games/PC/steamapps/compatdata")) + assertTrue(binds.drop(1).all { it.substringAfter(':').startsWith("/root/Games/PC/steamapps/") }) + assertFalse(binds.any { it.contains("/mnt/") }) + } + @Test fun migrationPreservesSavesLinksAndOriginalAndNeverOverwritesNewSaves() { val files = tmp.newFolder("files") val library = tmp.newFolder("card") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index 2d3c32ff4..21cd87d16 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -73,11 +73,19 @@ def client_entries(): return found +def registered(): + try: + with open(os.path.join(STEAM_ROOT, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f: + return [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())] + except OSError: + return [] + + def write_entries(): os.makedirs(APPS_DIR, exist_ok=True) wanted = {} theirs = client_entries() - for library, _ in LIBRARIES: + for library in dict.fromkeys([library for library, _ in LIBRARIES] + registered()): for appid, name in games(library): if appid not in theirs: wanted.setdefault(appid, name) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync index 2f0273ae0..410a9c442 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync @@ -954,8 +954,19 @@ def choose(root, command, env, tool, stock): return command, env +def libraries(steam_root): + found = [steam_root] + try: + with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f: + found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())] + except OSError: + pass + found.append(SD_LIBRARY) + return list(dict.fromkeys(found)) + + def find_tools(steam_root): - candidates = [os.path.join(library, "steamapps", "common", name) for library in (steam_root, SD_LIBRARY) for name in SOURCES] + candidates = [os.path.join(library, "steamapps", "common", name) for library in libraries(steam_root) for name in SOURCES] folder = os.path.join(steam_root, "compatibilitytools.d") try: names = sorted(os.listdir(folder)) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 62202230b..ef0e5d341 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -72,6 +72,7 @@ cache="$steam_root/.droiddeck-game-scripts" libraries="$steam_root" for library in $LIBRARIES; do libraries="$libraries $library"; done +libraries=$(printf '%s\n%s\n' "$libraries" "$(sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)" | awk 'NF && !seen[$0]++') sig=$(while IFS= read -r library; do ls -l --time-style=+%s "$library"/steamapps/appmanifest_*.acf 2>/dev/null; done <<<"$libraries" | cksum) if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then { @@ -133,9 +134,12 @@ $(vcrt "$(date +%s)")" # prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a # changed one as a new template and upgrades every prefix made from the old. for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ + /mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ + /root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \ "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \ "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ - /mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do + /mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \ + /root/Games/*/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue seeded=$(dirname "$reg")/.droiddeck-redists legacy=$(dirname "$reg")/.bannerlator-redists diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 5bdabd997..a2c751a17 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -568,7 +568,7 @@ case $mode in local d [ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0 for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \ - "$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do + "$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps /root/Games/*/steamapps; do [ "$d" -nt "$compat_stamp" ] && return 0 done return 1 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 50802187c..5fe62eb1e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -16,6 +16,7 @@ STATE = ".droiddeck-games.json" WINNATIVE = ".winnative-steam/steamapps" LIBRARY = "/mnt/droiddeck-sd" LEGACY_LIBRARY = "/mnt/bannerlator-sd" +PARKED = ".droiddeck-parked" BASE_ID = 76561197960265728 @@ -282,6 +283,42 @@ def update_not_started(text, steamapps, appid, installdir): and not (steamapps / "downloading" / str(appid)).exists()) +def libraries(steam_root): + try: + text = (Path(steam_root) / "steamapps/libraryfolders.vdf").read_text(errors="replace") + except OSError: + text = "" + paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)] + return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths])) + + +def ours(manifest, appid): + try: + return re.search(rf'"installdir"\s*"DroidDeck-{appid}"', manifest.read_text(errors="replace")) is not None + except OSError: + return False + + +def unpark(steamapps): + for manifest in sorted((steamapps / PARKED).glob("appmanifest_*.acf")): + appid = manifest.stem.removeprefix("appmanifest_") + live = steamapps / manifest.name + if os.path.lexists(live): + manifest.unlink(missing_ok=True) + elif (steamapps / "common" / f"DroidDeck-{appid}").is_dir(): + os.replace(manifest, live) + + +def park(steamapps): + for target in sorted((steamapps / "common").glob("DroidDeck-*")): + appid = target.name.removeprefix("DroidDeck-") + manifest = steamapps / f"appmanifest_{appid}.acf" + if appid.isdigit() and target.is_symlink() and not target.exists() and manifest.is_file() and ours(manifest, appid): + (steamapps / PARKED).mkdir(exist_ok=True) + os.replace(manifest, steamapps / PARKED / manifest.name) + print(f"droiddeck-steam-games: {appid} is not here this session; its install is set aside until it is") + + def seed_install(steam_root, game, appid): steamapps = Path(steam_root) / "steamapps" common = steamapps / "common" @@ -305,7 +342,7 @@ def seed_install(steam_root, game, appid): relink.unlink(missing_ok=True) relink.symlink_to(source, target_is_directory=True) os.replace(relink, target) - for root in [Path(steam_root), Path(LIBRARY)]: + for root in libraries(steam_root): existing = root / "steamapps" / manifest.name if existing.is_file(): if (existing == manifest and source.is_dir() and target.is_symlink() @@ -343,6 +380,11 @@ def route(steam_root, acct, games): if not all(isinstance(value, dict) for value in (owned, candidates, sources)): owned, candidates, sources = {}, {}, {} shortcuts, routes = [], {} + steamapps = Path(steam_root) / "steamapps" + try: + unpark(steamapps) + except OSError as error: + print(f"droiddeck-steam-games: set-aside installs not restored ({error})") for game in games: folder = game.get("folder", "") appid = candidates.get(folder, candidates.get(legacy_path(folder))) @@ -359,6 +401,10 @@ def route(steam_root, acct, games): except OSError as error: print(f"droiddeck-steam-games: import unavailable ({error})") shortcuts.append(game) + try: + park(steamapps) + except OSError as error: + print(f"droiddeck-steam-games: missing installs not set aside ({error})") save_json(Path(acct) / "config/.droiddeck-routes.json", routes) return shortcuts, routes diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library index f5d2b59c5..0a961c87a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library @@ -5,6 +5,7 @@ # droiddeck-steam-library # # Ported from WinNative's gamescope runtime (GPL-3.0). +import json import os import re import sys @@ -168,6 +169,29 @@ def relabel(steam_root, target, label): print("droiddeck-steam-library: labelled %s in its own marker too" % target) +GAMES_DIR = "/root/Games/" + + +def wanted(): + listing = os.environ.get("BL_STEAM_LIBRARIES") + if not listing: + return None + try: + with open(listing, encoding="utf-8") as f: + entries = json.load(f) + return [(str(e["path"]), str(e["label"]).replace('"', " ").replace("\\", " ")) for e in entries] + except (OSError, ValueError, KeyError, TypeError): + return None + + +def registered(steam_root): + try: + with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f: + return re.findall(r'"path"\s+"([^"]*)"', f.read()) + except OSError: + return [] + + def main(): steam_root = sys.argv[1] for library in RETIRED: @@ -175,12 +199,19 @@ def main(): for old, new in RENAMED: rename(steam_root, old, new) relabel(steam_root, steam_root, MAIN_LABEL) - for library, label in LIBRARIES: + libraries = wanted() + managed = libraries is not None + for library, label in libraries if managed else LIBRARIES: if os.path.isdir(os.path.join(library, "steamapps")): register(steam_root, library, label) relabel(steam_root, library, label) else: forget(steam_root, library) + if managed: + keep = {library for library, _ in libraries} + for library in registered(steam_root): + if library not in keep and (library == LIBRARIES[0][0] or library.startswith(GAMES_DIR)): + forget(steam_root, library) if __name__ == "__main__": diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index ee1f1eea1..e8bebdb96 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -277,9 +277,10 @@ done # The depot this tool is named after, newest across the libraries that hold it; the others in the # list stand in only when it is missing. depot= +libraries=$(printf '%%s\\n' "$steam_root" /mnt/droiddeck-sd; sed -n 's/^[[:space:]]*"path"[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null) for name in %s; do best=0 - for library in "$steam_root" /mnt/droiddeck-sd; do + while IFS= read -r library; do candidate="$library/steamapps/common/$name" [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue build=0 @@ -289,7 +290,7 @@ for name in %s; do depot=$candidate best=$build fi - done + done <<< "$libraries" [ -n "$depot" ] && break done if [ -n "$depot" ]; then @@ -615,10 +616,21 @@ NOT_GAMES = frozenset(( )) +def libraries(steam_root): + found = [steam_root] + try: + with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f: + found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())] + except OSError: + pass + found.append(LIBRARY + "-sd") + return list(dict.fromkeys(found)) + + def installed_apps(steam_root): """App ids of the titles the client has installed, tools and runtimes excluded.""" apps = set() - for library in (steam_root, LIBRARY + "-sd"): + for library in libraries(steam_root): steamapps = os.path.join(library, "steamapps") try: names = os.listdir(steamapps) @@ -778,7 +790,7 @@ def fex_missing(steam_root): missing = [] for app in FEX_DEPENDENCIES: if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app)) - for library in (steam_root, LIBRARY + "-sd")): + for library in libraries(steam_root)): missing.append(app) return missing @@ -1065,7 +1077,7 @@ def depot_build(depot): def find_source(steam_root, sources=SOURCES): """The first of `sources` the client has downloaded, newest across the libraries holding it.""" for name in sources: - found = [os.path.join(root, "steamapps", "common", name) for root in (steam_root, "/mnt/droiddeck-sd") + found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root) if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))] if found: return max(found, key=depot_build) diff --git a/tools/linuxfs/preload/storage.c b/tools/linuxfs/preload/storage.c index d31ee0d0f..fb85d1b6f 100644 --- a/tools/linuxfs/preload/storage.c +++ b/tools/linuxfs/preload/storage.c @@ -1,8 +1,6 @@ /* * Opt-in observations for file allocation and writes to the selected Steam library. * - * Steam's allocation failure and any later fallback are left entirely to the caller. These - * wrappers only forward the same arguments, record the result and restore errno after logging. * The session enables them only when storage diagnostics are turned on. Counters are local to * each process, allocation details are capped, writes are sampled, and storage.log has a global * size limit because every Steam child shares it. @@ -154,19 +152,56 @@ static void allocation_line(const char *api, int result, int error, int flags, if (n > 0 && (size_t)n < sizeof(line)) append_line(line, (size_t)n); } +static int steam_client(void) { + static int cached = -1; + if (cached < 0) + cached = strcmp(program_invocation_short_name, "steam") == 0 && + strstr(program_invocation_name, "steamrtarm64") != NULL; + return cached; +} + +static int staging_file(int fd) { + char link[32], path[PATH_MAX]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + long n = syscall(SYS_readlinkat, (long)AT_FDCWD, (long)link, (long)path, (long)sizeof(path) - 1); + if (n <= 0) return 0; + path[n] = '\0'; + return strstr(path, "/steamapps/downloading/") != NULL; +} + +static long long unreserved[16]; +static unsigned int unreserved_count; + +static int cannot_reserve(long long device) { + unsigned int n = __atomic_load_n(&unreserved_count, __ATOMIC_ACQUIRE); + for (unsigned int i = 0; i < n && i < 16; i++) + if (__atomic_load_n(&unreserved[i], __ATOMIC_RELAXED) == device) return 1; + return 0; +} + +static void note_cannot_reserve(int fd) { + struct stat st; + if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat"); + if (next_fstat == NULL || next_fstat(fd, &st) != 0 || cannot_reserve((long long)st.st_dev)) return; + unsigned int slot = __atomic_load_n(&unreserved_count, __ATOMIC_RELAXED); + while (slot < 16 && !__atomic_compare_exchange_n(&unreserved_count, &slot, slot + 1, 0, __ATOMIC_ACQ_REL, + __ATOMIC_RELAXED)) { + } + if (slot < 16) __atomic_store_n(&unreserved[slot], (long long)st.st_dev, __ATOMIC_RELEASE); +} + int fallocate(int fd, int mode, off_t offset, off_t length) { int entry_errno = errno; if (next_fallocate == NULL) next_fallocate = dlsym(RTLD_NEXT, "fallocate"); if (next_fallocate == NULL) { errno = ENOSYS; return -1; } int selected = on_selected_device(fd, NULL); errno = entry_errno; - if (!selected) return next_fallocate(fd, mode, offset, length); - uint64_t start = now_ns(); + uint64_t start = selected ? now_ns() : 0; errno = entry_errno; int result = next_fallocate(fd, mode, offset, length); int call_errno = errno; - uint64_t elapsed = now_ns() - start; - allocation_line("fallocate", result, call_errno, mode, offset, length, elapsed); + if (result != 0 && (call_errno == EOPNOTSUPP || call_errno == ENOSYS) && steam_client()) note_cannot_reserve(fd); + if (selected) allocation_line("fallocate", result, call_errno, mode, offset, length, now_ns() - start); errno = call_errno; return result; } @@ -222,12 +257,28 @@ int ftruncate(int fd, off_t length) { if (next_ftruncate == NULL) next_ftruncate = dlsym(RTLD_NEXT, "ftruncate"); if (next_ftruncate == NULL) { errno = ENOSYS; return -1; } int selected = on_selected_device(fd, NULL); - errno = entry_errno; - if (!selected) return next_ftruncate(fd, length); - uint64_t start = now_ns(); - errno = entry_errno; - int result = next_ftruncate(fd, length); - int call_errno = errno; + uint64_t start = selected ? now_ns() : 0; + struct stat st; + int known = 0; + if (steam_client()) { + if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat"); + known = next_fstat != NULL && next_fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && staging_file(fd); + } + int result = 0, call_errno = entry_errno; + if (!known || length <= st.st_size || !cannot_reserve((long long)st.st_dev)) { + errno = entry_errno; + result = next_ftruncate(fd, length); + call_errno = errno; + if (result != 0 && known && length >= st.st_size && (call_errno == EPERM || call_errno == EOPNOTSUPP)) { + if (length > st.st_size) note_cannot_reserve(fd); + result = 0; + call_errno = entry_errno; + } + } + if (!selected) { + errno = call_errno; + return result; + } uint64_t elapsed = now_ns() - start; add(&stats.ftruncate_calls, 1); if (result != 0) add(&stats.ftruncate_failures, 1); diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 1639c6ada..e7878d554 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -1,6 +1,7 @@ import importlib.machinery import importlib.util import json +import os from pathlib import Path import subprocess import tempfile @@ -231,6 +232,45 @@ class ImportsTest(unittest.TestCase): self.folder.rmdir() self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + def test_absent_folder_sets_its_install_aside_until_it_returns(self): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + manifest = self.steam / 'steamapps/appmanifest_42.acf' + manifest.write_text(manifest.read_text().replace('}', ' "buildid" "99"\n}')) + state = manifest.read_text() + away = self.root / 'unplugged' + self.folder.rename(away) + with patch('builtins.print'): + self.assertEqual(([], {}), imports.route(self.steam, self.acct, [])) + self.assertFalse(manifest.exists()) + self.assertEqual(state, (self.steam / 'steamapps' / imports.PARKED / manifest.name).read_text()) + away.rename(self.folder) + self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game])) + self.assertEqual(state, manifest.read_text()) + self.assertFalse((self.steam / 'steamapps' / imports.PARKED / manifest.name).exists()) + + def test_steam_installs_are_never_set_aside(self): + manifest = self.steam / 'steamapps/appmanifest_42.acf' + manifest.parent.mkdir(parents=True) + manifest.write_text('"AppState"\n{\n "appid" "42"\n "installdir" "Example"\n}\n') + link = self.steam / 'steamapps/common/DroidDeck-42' + link.parent.mkdir(parents=True) + link.symlink_to(self.root / 'missing', target_is_directory=True) + imports.route(self.steam, self.acct, []) + self.assertTrue(manifest.is_file()) + + def test_install_in_another_library_is_used_as_is(self): + other = self.root / 'Games/PC' + (other / 'steamapps').mkdir(parents=True) + (other / 'steamapps/appmanifest_42.acf').write_text('Steam installed it here') + vdf = self.steam / 'steamapps/libraryfolders.vdf' + vdf.parent.mkdir(parents=True) + vdf.write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other) + self.snapshot() + self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game])) + self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists()) + self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42')) + def test_replaced_game_does_not_inherit_cached_ownership(self): self.snapshot() self.exe.write_bytes(b'a different game') diff --git a/tools/tests/test_steam_libraries.py b/tools/tests/test_steam_libraries.py new file mode 100644 index 000000000..757e459a7 --- /dev/null +++ b/tools/tests/test_steam_libraries.py @@ -0,0 +1,152 @@ +import importlib.machinery +import importlib.util +import json +import os +import re +import runpy +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" + + +def load(name): + loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(BIN / name)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +library = load("droiddeck-steam-library") +desktop = load("droiddeck-desktop-games") +COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) + + +def entry(index, path, label=""): + return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"label"\t\t"%s"\n\t}\n' % (index, path, label) + + +def manifest(steamapps, appid, name="Game", installdir="Game", flags="4"): + steamapps.mkdir(parents=True, exist_ok=True) + (steamapps / ("appmanifest_%s.acf" % appid)).write_text( + '"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n}\n' + % (appid, name, flags, installdir)) + + +class LibraryListTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.steam = self.root / "Steam" + self.vdf = self.steam / "steamapps/libraryfolders.vdf" + self.vdf.parent.mkdir(parents=True) + self.games = self.root / "Games" + self.card = self.root / "card" + self.pc = self.games / "PC" + self.old = self.games / "Old" + self.user = self.root / "user-library" + for folder in (self.pc, self.old, self.user, self.card): + (folder / "steamapps").mkdir(parents=True) + manifest(self.pc / "steamapps", 10) + self.vdf.write_text('"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.old, "Old") + + entry(2, self.user, "Mine") + entry(3, self.card, "SD Card") + "}\n") + self.listing = self.root / "steam-libraries.json" + for target in (patch.object(library, "GAMES_DIR", str(self.games) + "/"), + patch.object(library, "LIBRARIES", ((str(self.card), "SD Card"),)), + patch("builtins.print")): + target.start() + self.addCleanup(target.stop) + + def run_main(self, listing=True): + with patch.dict(os.environ), patch("sys.argv", ["droiddeck-steam-library", str(self.steam)]): + os.environ.pop("BL_STEAM_LIBRARIES", None) + if listing: + os.environ["BL_STEAM_LIBRARIES"] = str(self.listing) + library.main() + return self.vdf.read_text() + + def paths(self, text): + return [Path(p) for p in re.findall(r'"path"\s+"([^"]*)"', text)] + + def test_listed_games_folder_is_registered_and_the_rest_are_forgotten(self): + self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC (PNY USB drive)"}])) + text = self.run_main() + self.assertEqual([self.steam, self.user, self.pc], self.paths(text)) + self.assertIn('"label"\t\t"PC (PNY USB drive)"', text) + self.assertIn('"10"\t\t"0"', text) + self.assertIn('"label"\t\t"PC (PNY USB drive)"', (self.pc / "libraryfolder.vdf").read_text()) + + def test_absent_listed_folder_is_forgotten_and_comes_back(self): + self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC"}, {"path": str(self.card), "label": "SD Card"}])) + self.run_main() + moved = self.root / "unplugged" + self.pc.rename(moved) + self.assertNotIn(self.pc, self.paths(self.run_main())) + moved.rename(self.pc) + self.assertIn(self.pc, self.paths(self.run_main())) + self.assertIn(self.card, self.paths(self.vdf.read_text())) + + def test_without_a_list_only_the_card_is_managed(self): + text = self.run_main(listing=False) + self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text)) + + def test_unreadable_list_keeps_games_folder_entries(self): + self.listing.write_text("{not json") + text = self.run_main() + self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text)) + + def test_labels_cannot_break_the_vdf(self): + self.listing.write_text(json.dumps([{"path": str(self.pc), "label": 'P"C\\'}])) + text = self.run_main() + self.assertIn('"label"\t\t"P C "', text) + + +class CompatLibrariesTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.steam = self.root / "Steam" + self.pc = self.root / "Games/My PC" + (self.steam / "steamapps").mkdir(parents=True) + (self.steam / "steamapps/libraryfolders.vdf").write_text( + '"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.pc, "PC") + "}\n") + + def test_titles_and_tools_in_a_registered_library_are_found(self): + manifest(self.steam / "steamapps", 20) + manifest(self.pc / "steamapps", 30) + manifest(self.pc / "steamapps", 3127680, installdir="FEX") + self.assertEqual(["20", "30"], COMPAT["installed_apps"](str(self.steam))) + self.assertEqual(["1628350"], COMPAT["fex_missing"](str(self.steam))) + + def test_newest_proton_across_libraries_wins(self): + for root, build in ((self.steam, "100"), (self.pc, "200")): + depot = root / "steamapps/common/Proton Experimental (ARM64)" + (depot / "files/bin-arm64").mkdir(parents=True) + (depot / "version").write_text(build + " x\n") + self.assertEqual(str(self.pc / "steamapps/common/Proton Experimental (ARM64)"), COMPAT["find_source"](str(self.steam))) + + def test_missing_list_still_reads_the_client_library(self): + (self.steam / "steamapps/libraryfolders.vdf").unlink() + self.assertEqual(str(self.steam), COMPAT["libraries"](str(self.steam))[0]) + + +class DesktopGamesTest(unittest.TestCase): + def test_games_in_registered_libraries_get_entries(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + steam, pc = root / "Steam", root / "Games/PC" + manifest(steam / "steamapps", 40, name="Internal") + manifest(pc / "steamapps", 50, name="Outside") + (steam / "steamapps/libraryfolders.vdf").write_text('"libraryfolders"\n{\n' + entry(0, steam) + entry(1, pc) + "}\n") + with patch.object(desktop, "STEAM_ROOT", str(steam)), patch.object(desktop, "APPS_DIR", str(root / "apps")), \ + patch.object(desktop, "LIBRARIES", ((str(steam), "Steam Games"), (str(root / "card"), "SD Games"))): + self.assertEqual({40: "Internal", 50: "Outside"}, desktop.write_entries()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_storage_preload.py b/tools/tests/test_storage_preload.py new file mode 100644 index 000000000..2bf01eb01 --- /dev/null +++ b/tools/tests/test_storage_preload.py @@ -0,0 +1,149 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +FAKE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static void path_of(int fd, char *out, size_t size) { + char link[32]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + long n = syscall(SYS_readlinkat, AT_FDCWD, link, out, size - 1); + out[n > 0 ? n : 0] = '\0'; +} + +int fallocate(int fd, int mode, off_t offset, off_t length) { + (void)fd; (void)mode; (void)offset; (void)length; + errno = EOPNOTSUPP; + return -1; +} + +int ftruncate(int fd, off_t length) { + char path[4096]; + path_of(fd, path, sizeof(path)); + FILE *log = fopen(getenv("FAKE_LOG"), "a"); + if (log) { fprintf(log, "%s %lld\n", strrchr(path, '/') + 1, (long long)length); fclose(log); } + if (strstr(path, "ntfs")) { errno = EPERM; return -1; } + return (int)syscall(SYS_ftruncate, fd, length); +} +""" + +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + for (int i = 1; i + 2 < argc; i += 3) { + int fd = open(argv[i + 1], O_RDWR | O_CREAT, 0644); + if (fd < 0) return 2; + long long length = atoll(argv[i + 2]); + int result; + errno = 0; + if (strcmp(argv[i], "fallocate") == 0) result = fallocate(fd, 0, 0, length); + else result = ftruncate(fd, length); + int error = result == 0 ? 0 : errno; + struct stat st; + fstat(fd, &st); + printf("%s %d %s %lld\n", argv[i], result, error ? strerrorname_np(error) : "-", (long long)st.st_size); + close(fd); + } + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc"), "needs a C compiler") +class StoragePreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.dir = Path(tempfile.mkdtemp()) + build = cls.dir / "build" + build.mkdir() + (build / "fake.c").write_text(FAKE) + (build / "program.c").write_text(PROGRAM) + cls.storage = build / "storage.so" + cls.fake = build / "fake.so" + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.storage), str(PRELOAD / "storage.c"), "-ldl"], check=True) + subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c")], check=True) + cls.steam = cls.dir / "steamrtarm64/steam" + cls.steam.parent.mkdir() + subprocess.run(["cc", "-O2", "-o", str(cls.steam), str(build / "program.c")], check=True) + cls.other = build / "steamwebhelper" + shutil.copy(cls.steam, cls.other) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.dir, True) + + def setUp(self): + self.lib = Path(tempfile.mkdtemp(dir=self.dir)) + self.staging = self.lib / "steamapps/downloading/252410" + self.staging.mkdir(parents=True) + self.log = self.lib / "ftruncate.log" + + def run_program(self, *steps, program=None): + env = dict(os.environ, LD_PRELOAD="%s:%s" % (self.storage, self.fake), FAKE_LOG=str(self.log)) + env.pop("BL_STORAGE_DIAGNOSTICS", None) + args = [str(program or self.steam)] + for call, path, length in steps: + args += [call, str(path), str(length)] + result = subprocess.run(args, env=env, capture_output=True, text=True, check=True) + return result.stdout.splitlines() + + def reached(self): + return self.log.read_text().splitlines() if self.log.exists() else [] + + def test_reservation_after_failed_fallocate_is_not_zero_filled(self): + data = self.staging / "data.pak" + out = self.run_program(("fallocate", data, 1 << 20), ("ftruncate", data, 1 << 20), ("ftruncate", data, 0)) + self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0", "ftruncate 0 - 0"], out) + self.assertEqual(["data.pak 0"], self.reached()) + + def test_reservation_is_real_until_the_device_has_refused_one(self): + data = self.staging / "data.pak" + self.assertEqual(["ftruncate 0 - 4096"], self.run_program(("ftruncate", data, 4096))) + self.assertEqual(["data.pak 4096"], self.reached()) + + def test_refused_extension_is_reported_as_done_and_remembered(self): + first, second = self.staging / "ntfs-one", self.staging / "two" + out = self.run_program(("ftruncate", first, 8192), ("ftruncate", second, 8192)) + self.assertEqual(["ftruncate 0 - 0", "ftruncate 0 - 0"], out) + self.assertEqual(["ntfs-one 8192"], self.reached()) + + def test_refused_shrink_keeps_its_error(self): + data = self.staging / "ntfs-data" + data.write_bytes(b"x" * 100) + self.assertEqual(["ftruncate -1 EPERM 100"], self.run_program(("ftruncate", data, 10))) + + def test_files_outside_staging_are_untouched(self): + manifest = self.lib / "steamapps/ntfs-appmanifest.acf" + out = self.run_program(("fallocate", self.staging / "a", 4096), ("ftruncate", manifest, 4096)) + self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out) + self.assertEqual(["ntfs-appmanifest.acf 4096"], self.reached()) + + def test_other_programs_are_untouched(self): + data = self.staging / "ntfs-data" + out = self.run_program(("fallocate", data, 4096), ("ftruncate", data, 4096), program=self.other) + self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out) + + +if __name__ == "__main__": + unittest.main()