fix: carry the installed Steam build into imported games so Steam sto… (#108)

* fix: carry the installed Steam build into imported games so Steam stops re-verifying them

A second-library game downloaded by WinNative was registered with a bare
manifest (no buildid or InstalledDepots), so Steam treated it as build 0 and
verified all ~92 GB of Final Fantasy XV through proot. Reuse WinNative's
.winnative-steam manifest, or a completed .DepotDownloader/depot.config, so
Steam only fetches what changed since the recorded build.

* fix: give queued Steam fresh installs of imported games the recorded build

Steam rewrites a bare import manifest into a fresh install at build 0
before downloading anything. While nothing is downloaded or staged, the
WinNative build is still safe to carry over, so Steam skips the full
re-download (FF7 Remake was queued for 98 GB this way).

* fix: mount the game storage at /mnt/droiddeck-sd

The second library was bound at /mnt/bannerlator-sd, a name left from the
Bannerlator port, which is what Steam shows in its paths and errors. Bind
it at /mnt/droiddeck-sd, keep the old path as an alias for links and
prefixes that still name it, move Steam's library entry in place, re-point
import links, read ownership snapshots taken under the old path, and key
added-game shortcut ids by the old path so their prefixes stay put.

* fix: restart Steam once when owned added games are confirmed

On a fresh install the importer runs before any ownership snapshot exists,
so every added game became a non-Steam shortcut and stayed one until the
client happened to restart. Once the watcher confirms owned titles that are
not imported, it asks the session for one restart, while no game runs and
no download is in progress, and the importer turns them into Steam entries.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-09-30 19:34:15 -04:00
1 parent 771dca7048
commit 1d5d3acc46
10 files changed
+301 -32

No files matched your search

@@ -12,6 +12,8 @@ import java.util.zip.CRC32
/** Windows game folders shared with the Steam session. */
object AddedGames {
private const val TAG = "AddedGames"
private const val LIBRARY = "/mnt/droiddeck-sd"
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
class Game(
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
@@ -65,7 +67,7 @@ object AddedGames {
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
}
GameStorage.effective(context)?.let { lib ->
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
}
return null
}
@@ -108,7 +110,8 @@ object AddedGames {
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val name = folder.name
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
@@ -599,7 +599,7 @@ object SessionPrefs {
// ── Game storage ────────────────────────────────────────────────────────────────────────
/**
* A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
* registered with the client, which then asks where to install every game and shows both
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
@@ -697,9 +697,12 @@ class SessionService : Service() {
if (library != null) {
val problem = GameStorage.prepare(library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
binds.add("${library.path}:/mnt/droiddeck-sd")
// Links, prefixes and Steam entries made before the rename still name the old path.
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
binds.add("${library.path}:/mnt/bannerlator-sd")
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
} else {
Log.w(TAG, "game storage: $problem; internal only this session")
}
+11 -6
View File
@@ -21,16 +21,21 @@ The resulting snapshot is stored under that account's `userdata/<id>/config` and
refreshed every 30 seconds while logged in. Title lookups retry every five minutes and
when the source changes, so an early network failure does not end the watcher. Changed
status and probe failures are written to the session log. Startup and post-exit import
use the last successful snapshot for the selected account. A newly identified title
therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts
again. An unavailable client or offline first launch falls back to shortcuts; existing
use the last successful snapshot for the selected account. The client reads manifests
and shortcuts only at start, so when a snapshot confirms owned titles that are not yet
imported, the watcher restarts the client once (not while a game runs or a download is in
progress) and the importer runs between the exit and the next start. An unavailable client or offline first launch falls back to shortcuts; existing
snapshots remain usable offline. Steam still enforces the current license at launch.
For a confirmed game without a manifest, the importer links the source folder into the
internal Steam library and writes an installed manifest with `Universe=1` and
`StateFlags=4`. Steam ignores manifests without the universe field. This registers
existing files; it does not assert a verified build ID or depot manifest. Steam can
still require verification, updates or redistributables. Steam updates and uninstall
`StateFlags=4`. Steam ignores manifests without the universe field. A manifest without
installed depots makes Steam verify every file, so the importer carries the build another
client recorded: WinNative's `.winnative-steam/steamapps` manifest beside the game folder,
or a completed download's `.DepotDownloader/depot.config` depot manifest IDs. Steam then
only downloads what changed since that build. Bare manifests from earlier imports are
upgraded while Steam has not touched them. Without either record Steam still verifies.
WinNative's manifest also identifies the folder without a store lookup. Steam updates and uninstall
operate on the linked game files, so imported folders must be writable. Existing Steam
manifests and directories are preserved. Only the exact minimal manifests generated by
the earlier importer are migrated, with the matching source symlink still present. If
@@ -11,7 +11,7 @@ import sys
HOME = os.environ.get("HOME", "/root")
STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam")
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/bannerlator-sd", "SD Games"))
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games"))
# The client's own tools and runtimes live in steamapps beside the games; they are not titles.
NOT_GAMES = {228980, 1493710, 3127680, 4183110, 4427310, 4185400}
APPS_DIR = os.path.join(HOME, ".local/share/applications")
@@ -79,7 +79,7 @@ block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
for reg in "$steam_root"/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/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.bannerlator-redists
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
@@ -234,7 +234,7 @@ case $mode in
bannerlator-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
# Register the card library (bound at /mnt/bannerlator-sd) with the client - internal storage is
# Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is
# its main library already - and the ARM64 Proton that lets it launch the Windows ones.
# A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts,
# so it is in the Compatibility dropdown the first time the user looks. One per line, each the
@@ -446,7 +446,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/bannerlator-sd/steamapps; do
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do
[ "$d" -nt "$compat_stamp" ] && return 0
done
return 1
@@ -13,6 +13,9 @@ import urllib.parse
import urllib.request
STATE = ".droiddeck-games.json"
WINNATIVE = ".winnative-steam/steamapps"
LIBRARY = "/mnt/droiddeck-sd"
LEGACY_LIBRARY = "/mnt/bannerlator-sd"
BASE_ID = 76561197960265728
@@ -40,6 +43,11 @@ def edition_key(name):
return title_key(name)
def legacy_path(path):
path = str(path)
return LEGACY_LIBRARY + path[len(LIBRARY):] if path == LIBRARY or path.startswith(LIBRARY + "/") else path
def source_stamp(game):
try:
exe = Path(game["exe"])
@@ -53,19 +61,23 @@ def source_stamp(game):
return None
def manifest_ids(steamapps, name):
matches = []
for manifest in Path(steamapps).glob("appmanifest_*.acf"):
try:
install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I)
if install and install[1] == name:
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
except (OSError, ValueError):
pass
return matches
def identify(game):
folder = Path(game["folder"])
steamapps = folder.parent.parent
if folder.parent.name == "common" and steamapps.name == "steamapps":
matches = []
for manifest in steamapps.glob("appmanifest_*.acf"):
try:
text = manifest.read_text()
install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I)
if install and install[1] == folder.name:
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
except (OSError, ValueError):
pass
for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]:
matches = manifest_ids(library, folder.name)
if matches:
return matches[0] if len(matches) == 1 else None
ids = set()
@@ -144,6 +156,27 @@ def game_running():
return False
def downloads_idle(steam_root):
try:
return not any(Path(steam_root, "steamapps/downloading").iterdir())
except OSError:
return True
def request_restart(steam_root):
"""The client reads manifests and shortcuts only at start, so an import needs one restart;
the session's loop runs the importer between the exit and the next start."""
Path(os.environ.get("HOME", "/root"), ".bl-restart-once").touch()
subprocess.run([str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
def unimported(steam_root, snapshot, candidates):
routes = read_json(Path(steam_root) / "userdata" / snapshot["account"] / "config/.droiddeck-routes.json", {})
routed = set(routes.values()) if isinstance(routes, dict) else set()
return sorted({appid for appid in candidates.values() if snapshot["owned"].get(str(appid)) is True} - routed)
def watch(steam_root, listing):
games = read_json(listing, [])
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
@@ -158,6 +191,7 @@ def watch(steam_root, listing):
# beat (a client that is not logged on fails it for as long as it stays that way), and none
# starts while a game is running: the answer can wait, the game's cores cannot.
probed, next_probe, failures = None, 0, 0
pending, requested = [], []
while True:
now = time.monotonic()
for game in games:
@@ -195,6 +229,7 @@ def watch(steam_root, listing):
snapshot["candidates"] = candidates
snapshot["sources"] = sources
save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot)
pending = unimported(steam_root, snapshot, candidates)
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
status = "ownership check unavailable: " + str(error)
@@ -202,12 +237,51 @@ def watch(steam_root, listing):
next_probe = now + min(300, 30 * 2 ** failures)
elif candidates:
status = last_status
if pending and pending != requested and not game_running() and downloads_idle(steam_root):
requested = pending
print("bannerlator-steam-games: restarting the client once to import %s" % pending, flush=True)
try:
request_restart(steam_root)
except (OSError, subprocess.TimeoutExpired) as error:
print("bannerlator-steam-games: restart unavailable: %s" % error, flush=True)
if status != last_status:
print("bannerlator-steam-games: " + status, flush=True)
last_status = status
time.sleep(30)
def installed_build(folder, appid, installdir):
"""The build another Steam client recorded for these files, so Steam updates from it
instead of verifying every file."""
try:
text = (folder.parent / WINNATIVE / f"appmanifest_{appid}.acf").read_text()
install = re.search(r'("installdir"\s*")([^"\\]*)(")', text, re.I)
flags = re.search(r'"StateFlags"\s*"(\d+)"', text)
if (install and install[2] == folder.name and flags and flags[1] == "4"
and re.search(rf'"appid"\s*"{appid}"', text) and '"InstalledDepots"' in text):
return text[:install.start(2)] + installdir + text[install.end(2):]
except OSError:
pass
if not (folder / ".download_complete").is_file():
return None
try:
ids = json.loads((folder / ".DepotDownloader/depot.config").read_text())["installedManifestIDs"]
depots = sorted((int(depot), int(gid)) for depot, gid in ids.items())
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return None
if not depots or not all(0 < depot < 0x80000000 and 0 < gid < 0x7FFFFFFFFFFFFFFF for depot, gid in depots):
return None
return "".join(f' "{depot}"\n {{\n "manifest" "{gid}"\n }}\n' for depot, gid in depots)
def update_not_started(text, steamapps, appid, installdir):
fields = dict(re.findall(r'^\s*"(\w+)"\s*"([^"]*)"', text, re.M))
return (fields.get("installdir") == installdir and fields.get("buildid", "0") == "0"
and fields.get("BytesDownloaded", "0") == "0" and fields.get("BytesStaged", "0") == "0"
and re.search(r'"InstalledDepots"\s*\{\s*\}', text) is not None
and not (steamapps / "downloading" / str(appid)).exists())
def seed_install(steam_root, game, appid):
steamapps = Path(steam_root) / "steamapps"
common = steamapps / "common"
@@ -215,16 +289,30 @@ def seed_install(steam_root, game, appid):
target = common / name
source = Path(game["folder"])
title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
minimal = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
+ f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n')
legacy = content.replace(' "Universe" "1"\n', '')
legacy = minimal.replace(' "Universe" "1"\n', '')
build = installed_build(source, appid, name)
if build is None:
content = minimal
elif build.lstrip().startswith('"AppState"'):
content = build
else:
content = minimal[:-2] + ' "InstalledDepots"\n {\n' + build + ' }\n}\n'
manifest = steamapps / f"appmanifest_{appid}.acf"
for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]:
if target.is_symlink() and os.readlink(target) != str(source) and os.readlink(target) == legacy_path(source):
relink = target.with_name(name + ".droiddeck")
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)]:
existing = root / "steamapps" / manifest.name
if existing.is_file():
if (existing == manifest and source.is_dir() and target.is_symlink()
and os.readlink(target) == str(source)
and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))):
and existing.read_text() != content
and (existing.read_text() in (minimal, legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))
or (build is not None and update_not_started(existing.read_text(), steamapps, appid, name)))):
temp = manifest.with_suffix(".droiddeck")
temp.write_text(content)
os.replace(temp, manifest)
@@ -256,11 +344,13 @@ def route(steam_root, acct, games):
owned, candidates, sources = {}, {}, {}
shortcuts, routes = [], {}
for game in games:
appid = candidates.get(game.get("folder"))
folder = game.get("folder", "")
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
stamp = source_stamp(game)
recorded = sources.get(folder, sources.get(legacy_path(folder)))
confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000
and owned.get(str(appid)) is True and stamp is not None
and sources.get(game.get("folder")) == stamp)
and recorded in (stamp, [stamp[0], legacy_path(stamp[1])] + stamp[2:]))
if confirmed:
try:
if seed_install(steam_root, game, appid):
@@ -1,5 +1,5 @@
#!/usr/bin/python3
# Registers the card the app installs to (bound at /mnt/bannerlator-sd) as a library folder of the
# Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the
# native Steam client, so they show as installed there instead of being downloaded a second time.
#
# bannerlator-steam-library <steam root>
@@ -14,7 +14,7 @@ import sys
# The app binds the storage the user chose (Steam cog -> Game storage) at this path and names it
# in BL_LIBRARY_LABEL; nothing bound = internal only, and the entry is removed so the client does
# not keep offering a place that is not there.
LIBRARIES = (("/mnt/bannerlator-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),)
LIBRARIES = (("/mnt/droiddeck-sd", os.environ.get("BL_LIBRARY_LABEL", "SD Card")),)
# The client calls its own library "Local Drive (/)", which says nothing about where that is.
# It is the phone's internal storage, so say so.
MAIN_LABEL = "Internal Storage"
@@ -66,6 +66,26 @@ def register(steam_root, library, label):
RETIRED = ("/mnt/bannerlator",)
RENAMED = (("/mnt/bannerlator-sd", "/mnt/droiddeck-sd"),)
def rename(steam_root, old, new):
"""Moves a library registered under an earlier mount point to the current one, keeping its
entry (content id, app sizes) rather than registering it anew."""
path = os.path.join(steam_root, "steamapps", "libraryfolders.vdf")
if not os.path.isfile(path):
return
with open(path, encoding="utf-8", errors="replace") as f:
text = f.read()
old_path = re.compile(r'("path"\s+")%s(")' % re.escape(old))
if not old_path.search(text):
return
if re.search(r'"path"\s+"%s"' % re.escape(new), text):
forget(steam_root, old)
return
with open(path, "w", encoding="utf-8") as f:
f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text))
print("bannerlator-steam-library: moved %s to %s" % (old, new))
def forget(steam_root, library):
@@ -152,6 +172,8 @@ def main():
steam_root = sys.argv[1]
for library in RETIRED:
forget(steam_root, library)
for old, new in RENAMED:
rename(steam_root, old, new)
relabel(steam_root, steam_root, MAIN_LABEL)
for library, label in LIBRARIES:
if os.path.isdir(os.path.join(library, "steamapps")):
+146
View File
@@ -20,6 +20,7 @@ def load(name):
imports = load('bannerlator-steam-games')
shortcuts = load('bannerlator-steam-shortcuts')
library = load('bannerlator-steam-library')
class ImportsTest(unittest.TestCase):
@@ -73,6 +74,7 @@ class ImportsTest(unittest.TestCase):
from unittest.mock import Mock
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
patch.object(imports, 'request_restart'), \
patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \
patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \
patch.object(imports.subprocess, 'run', return_value=result), \
@@ -83,6 +85,35 @@ class ImportsTest(unittest.TestCase):
snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {})
self.assertTrue(snapshot['owned']['42'])
def run_watch(self, beats, routes=None, busy=False):
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
if routes is not None:
imports.save_json(self.acct / 'config/.droiddeck-routes.json', routes)
if busy:
(self.steam / 'steamapps/downloading/7').mkdir(parents=True)
from unittest.mock import Mock
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', return_value=42), \
patch.object(imports, 'game_running', return_value=False), \
patch.object(imports, 'request_restart') as restart, \
patch.object(imports.time, 'monotonic', side_effect=list(range(0, 30 * beats, 30))), \
patch.object(imports.time, 'sleep', side_effect=[None] * (beats - 1) + [RuntimeError('stop')]), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):
imports.watch(self.steam, listing)
return restart
def test_newly_owned_game_restarts_the_client_once(self):
self.assertEqual(1, self.run_watch(3).call_count)
def test_imported_game_does_not_restart_the_client(self):
self.assertEqual(0, self.run_watch(2, routes={'1': 42}).call_count)
def test_restart_waits_for_downloads(self):
self.assertEqual(0, self.run_watch(2, busy=True).call_count)
def test_unowned_unknown_and_different_account_stay_shortcuts(self):
for owned, account in [(False, '123'), (True, '456')]:
self.snapshot(owned, account)
@@ -91,6 +122,93 @@ class ImportsTest(unittest.TestCase):
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps').exists())
def winnative_manifest(self, flags='4', installdir='Example'):
path = self.folder.parent / imports.WINNATIVE / 'appmanifest_42.acf'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n'
'\t"buildid"\t\t"7"\n\t"InstalledDepots"\n\t{\n\t\t"43"\n\t\t{\n\t\t\t"manifest"\t\t"99"\n'
'\t\t\t"size"\t\t"4"\n\t\t}\n\t}\n}\n' % (flags, installdir))
return path
def test_winnative_manifest_carries_installed_build(self):
source = self.winnative_manifest()
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
self.assertEqual(source.read_text().replace('"Example"', '"DroidDeck-42"'), text)
def test_winnative_manifest_identifies_folder(self):
self.winnative_manifest()
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(self.game))
def test_incomplete_winnative_manifest_is_ignored(self):
for flags, installdir in [('1026', 'Example'), ('4', 'Other')]:
self.winnative_manifest(flags, installdir)
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
def test_depot_config_records_installed_depots(self):
config = self.folder / '.DepotDownloader/depot.config'
config.parent.mkdir()
config.write_text(json.dumps({'installedManifestIDs': {'43': 99}}))
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
(self.folder / '.download_complete').touch()
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
text = (self.steam / 'steamapps/appmanifest_42.acf').read_text()
self.assertIn('"InstalledDepots"\n {\n "43"\n {\n "manifest" "99"', text)
self.assertTrue(text.endswith(' }\n}\n'))
config.write_text(json.dumps({'installedManifestIDs': {'43': 0x7FFFFFFFFFFFFFFF}}))
self.assertIsNone(imports.installed_build(self.folder, 42, 'DroidDeck-42'))
def test_bare_import_manifest_gains_installed_build(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.winnative_manifest()
imports.route(self.steam, self.acct, [self.game])
self.assertIn('"buildid"\t\t"7"', (self.steam / 'steamapps/appmanifest_42.acf').read_text())
def steam_fresh_install(self, downloaded='0'):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
path.write_text('"AppState"\n{\n\t"appid"\t\t"42"\n\t"StateFlags"\t\t"6"\n\t"installdir"\t\t"DroidDeck-42"\n'
'\t"buildid"\t\t"0"\n\t"BytesDownloaded"\t\t"%s"\n\t"BytesStaged"\t\t"0"\n'
'\t"InstalledDepots"\n\t{\n\t}\n}\n' % downloaded)
self.winnative_manifest()
return path
def test_queued_fresh_install_takes_installed_build(self):
path = self.steam_fresh_install()
imports.route(self.steam, self.acct, [self.game])
self.assertIn('"buildid"\t\t"7"', path.read_text())
def test_started_fresh_install_is_left_to_steam(self):
for downloaded, staging in [('5', False), ('0', True)]:
path = self.steam_fresh_install(downloaded)
if staging:
(self.steam / 'steamapps/downloading/42').mkdir(parents=True)
before = path.read_text()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(before, path.read_text())
def test_links_and_snapshots_from_the_old_mount_carry_over(self):
old = self.root / 'old-mount'
with patch.object(imports, 'LIBRARY', str(self.root / 'Games')), \
patch.object(imports, 'LEGACY_LIBRARY', str(old)):
legacy_game = dict(self.game, folder=str(old / 'Example'), exe=str(old / 'Example/Example.exe'))
stamp = imports.source_stamp(self.game)
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account='123', owned={'42': True}, candidates={legacy_game['folder']: 42},
sources={legacy_game['folder']: [stamp[0], legacy_game['exe']] + stamp[2:]}))
link = self.steam / 'steamapps/common/DroidDeck-42'
link.parent.mkdir(parents=True)
link.symlink_to(legacy_game['folder'], target_is_directory=True)
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual([], games)
self.assertEqual({str(self.game['appid']): 42}, routes)
self.assertEqual(str(self.folder), imports.os.readlink(link))
def test_existing_manifest_is_never_overwritten(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
@@ -188,6 +306,34 @@ class ImportsTest(unittest.TestCase):
self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text()))
class LibraryRenameTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.steam = Path(temp.name)
self.vdf = self.steam / 'steamapps/libraryfolders.vdf'
self.vdf.parent.mkdir()
def entry(self, index, path):
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"contentid"\t\t"%d"\n\t}\n' % (index, path, 7 + index)
def test_old_mount_keeps_its_entry_under_the_new_path(self):
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/root') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
with patch('builtins.print'):
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
text = self.vdf.read_text()
self.assertIn('"path"\t\t"/mnt/droiddeck-sd"\n\t\t"contentid"\t\t"8"', text)
self.assertNotIn('bannerlator-sd', text)
def test_old_mount_is_dropped_when_new_path_is_registered(self):
self.vdf.write_text('"libraryfolders"\n{\n' + self.entry(0, '/mnt/droiddeck-sd') + self.entry(1, '/mnt/bannerlator-sd') + '}\n')
with patch('builtins.print'):
library.rename(str(self.steam), '/mnt/bannerlator-sd', '/mnt/droiddeck-sd')
text = self.vdf.read_text()
self.assertEqual(1, text.count('"path"'))
self.assertIn('/mnt/droiddeck-sd', text)
class SteamProbeTest(unittest.TestCase):
def test_probe_reads_account_and_licenses_and_releases_handles(self):
import ctypes as C