mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Start every title with a Valve pick on the Compatible Proton (#224)
The client runs in Steam Deck mode, where a title's "Default" is not the "0" mapping but Valve's own pick for it: the Steam Deck compatibility recommended_runtime (proton-stable, a pinned Proton, or native for the Steam Linux Runtime) or an app_mappings entry in the SteamPlay manifests. A title the session had not mapped yet - not installed, or installed while the client was running on a build without the live helper - showed "Default (Proton Stable)" and launched with a Proton that cannot run here. Every playable Windows title in the appinfo cache with such a pick is now mapped to the default tool up front, before the client starts and in the live helper, the same way installed titles are. A runnable tool the user picks for one of them is kept, and they follow a change of the default.
This commit is contained in:
1 parent
3858a69867
commit
6dc74523c3
3 files changed
+117
-39
No files matched your search
@@ -397,11 +397,12 @@ class Helper:
|
||||
default, state = COMPAT["choose_default"](top["selected"], state, request, live, apply_request=not waiting)
|
||||
installed = COMPAT["installed_apps"](self.steam_root)
|
||||
self.installed = installed
|
||||
apps = sorted(set(installed) | set(state.get("auto", {})), key=COMPAT["app_order"])
|
||||
picks = COMPAT["valve_picks"](self.steam_root)
|
||||
apps = sorted(set(installed) | set(picks) | set(state.get("auto", {})), key=COMPAT["app_order"])
|
||||
current = dict(page.compat("many(%s)" % json.dumps(apps)) or {})
|
||||
current["0"] = top["selected"]
|
||||
native = COMPAT["native_targets"](self.steam_root, installed)
|
||||
changes, auto = COMPAT["plan_mapping"](current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native)
|
||||
changes, auto = COMPAT["plan_mapping"](current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks)
|
||||
for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])):
|
||||
page.compat("set(%d, %s)" % (int(app), json.dumps(tool)))
|
||||
say("%s -> %s" % ("default" if app == "0" else "app %s" % app, tool))
|
||||
|
||||
@@ -632,17 +632,18 @@ def installed_apps(steam_root):
|
||||
|
||||
|
||||
APPINFO_CACHE = {}
|
||||
STEAM_PLAY_MANIFESTS = 891390
|
||||
|
||||
|
||||
def appinfo_platforms(path, wanted):
|
||||
def appinfo_entries(path):
|
||||
import struct
|
||||
try:
|
||||
stamp = os.stat(path).st_mtime_ns
|
||||
except OSError:
|
||||
return {}
|
||||
cached = APPINFO_CACHE.get(path)
|
||||
if cached and cached[0] == stamp and wanted <= cached[1].keys() | cached[2]:
|
||||
return {app: cached[1][app] for app in wanted if app in cached[1]}
|
||||
if cached and cached[0] == stamp:
|
||||
return cached[1]
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
@@ -655,16 +656,19 @@ def appinfo_platforms(path, wanted):
|
||||
return {}
|
||||
offset = 8
|
||||
strings = None
|
||||
if magic == 0x07564429:
|
||||
table = struct.unpack_from("<q", data, offset)[0]
|
||||
offset += 8
|
||||
count = struct.unpack_from("<I", data, table)[0]
|
||||
position = table + 4
|
||||
strings = []
|
||||
for _ in range(count):
|
||||
end = data.index(b"\0", position)
|
||||
strings.append(data[position:end].decode("utf-8", "replace"))
|
||||
position = end + 1
|
||||
try:
|
||||
if magic == 0x07564429:
|
||||
table = struct.unpack_from("<q", data, offset)[0]
|
||||
offset += 8
|
||||
count = struct.unpack_from("<I", data, table)[0]
|
||||
position = table + 4
|
||||
strings = []
|
||||
for _ in range(count):
|
||||
end = data.index(b"\0", position)
|
||||
strings.append(data[position:end].decode("utf-8", "replace"))
|
||||
position = end + 1
|
||||
except (ValueError, IndexError, struct.error):
|
||||
return {}
|
||||
header = 4 + 4 + 8 + 20 + 4 + (20 if magic >= 0x07564428 else 0)
|
||||
|
||||
def section(position):
|
||||
@@ -697,23 +701,58 @@ def appinfo_platforms(path, wanted):
|
||||
raise ValueError("unknown appinfo field type %d" % kind)
|
||||
|
||||
found = {}
|
||||
try:
|
||||
while offset + 8 <= len(data):
|
||||
while offset + 8 <= len(data):
|
||||
try:
|
||||
app, size = struct.unpack_from("<II", data, offset)
|
||||
if app == 0:
|
||||
break
|
||||
start = offset + 8
|
||||
offset = start + size
|
||||
if str(app) not in wanted:
|
||||
continue
|
||||
except struct.error:
|
||||
break
|
||||
if app == 0:
|
||||
break
|
||||
start = offset + 8
|
||||
offset = start + size
|
||||
try:
|
||||
info = section(start + header)[0].get("appinfo", {})
|
||||
common = info.get("common", {}) if isinstance(info, dict) else {}
|
||||
oslist = {part.strip().lower() for part in str(common.get("oslist", "")).split(",") if part.strip()}
|
||||
found[str(app)] = (oslist, str(common.get("osarch", "")).lower(), str(common.get("type", "")).lower())
|
||||
except (ValueError, IndexError, struct.error):
|
||||
return {}
|
||||
APPINFO_CACHE[path] = (stamp, found, set(wanted))
|
||||
return {app: found[app] for app in wanted if app in found}
|
||||
except (ValueError, IndexError, struct.error):
|
||||
continue
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
common = info.get("common", {}) if isinstance(info.get("common"), dict) else {}
|
||||
deck = common.get("steam_deck_compatibility")
|
||||
configuration = deck.get("configuration") if isinstance(deck, dict) else None
|
||||
recommended = configuration.get("recommended_runtime", "") if isinstance(configuration, dict) else ""
|
||||
entry = {
|
||||
"oslist": {part.strip().lower() for part in str(common.get("oslist", "")).split(",") if part.strip()},
|
||||
"osarch": str(common.get("osarch", "")).lower(),
|
||||
"type": str(common.get("type", "")).lower(),
|
||||
"recommended": str(recommended),
|
||||
}
|
||||
if app == STEAM_PLAY_MANIFESTS:
|
||||
extended = info.get("extended", {})
|
||||
mappings = extended.get("app_mappings", {}) if isinstance(extended, dict) else {}
|
||||
entry["app_mappings"] = {str(m.get("appid", key)): str(m.get("tool", "")) for key, m in mappings.items()
|
||||
if isinstance(m, dict)} if isinstance(mappings, dict) else {}
|
||||
found[str(app)] = entry
|
||||
APPINFO_CACHE[path] = (stamp, found)
|
||||
return found
|
||||
|
||||
|
||||
def appinfo_platforms(path, wanted):
|
||||
entries = appinfo_entries(path)
|
||||
return {app: (entries[app]["oslist"], entries[app]["osarch"], entries[app]["type"]) for app in wanted if app in entries}
|
||||
|
||||
|
||||
def valve_picks(steam_root):
|
||||
entries = appinfo_entries(os.path.join(steam_root, "appcache", "appinfo.vdf"))
|
||||
mapped = {app for app, tool in entries.get(str(STEAM_PLAY_MANIFESTS), {}).get("app_mappings", {}).items() if tool}
|
||||
picks = set()
|
||||
for app, entry in entries.items():
|
||||
if app in NOT_GAMES or entry["type"] not in PLAYABLE_TYPES:
|
||||
continue
|
||||
if entry["oslist"] and "windows" not in entry["oslist"]:
|
||||
continue
|
||||
if entry["recommended"] or app in mapped:
|
||||
picks.add(app)
|
||||
return sorted(picks, key=int)
|
||||
|
||||
|
||||
def native_targets(steam_root, apps):
|
||||
@@ -811,21 +850,22 @@ def app_order(app):
|
||||
return (len(app), app)
|
||||
|
||||
|
||||
def plan_mapping(current, installed, runnable, default, auto, native=None):
|
||||
def plan_mapping(current, installed, runnable, default, auto, native=None, picks=()):
|
||||
runnable = {n.lower() for n in runnable}
|
||||
installed = set(installed)
|
||||
picks = set(picks)
|
||||
native = native or {}
|
||||
changes, kept = {}, {}
|
||||
if current.get("0", "") != default:
|
||||
changes["0"] = default
|
||||
for app in sorted((set(current) | installed | set(auto)) - {"0"}, key=app_order):
|
||||
for app in sorted((set(current) | installed | picks | set(auto)) - {"0"}, key=app_order):
|
||||
name = current.get(app, "")
|
||||
mine = auto.get(app)
|
||||
target = native.get(app, default)
|
||||
stale = unrunnable(name, runnable) or (app in native and linux_runtime_tool(name))
|
||||
if name and not stale and (mine is None or name != mine):
|
||||
continue
|
||||
if not name and app not in installed:
|
||||
if not name and app not in installed and app not in picks:
|
||||
continue
|
||||
if name != target:
|
||||
changes[app] = target
|
||||
@@ -976,7 +1016,7 @@ def live_helper_running(directory=None):
|
||||
return False
|
||||
|
||||
|
||||
def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False):
|
||||
def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False, picks=()):
|
||||
"""Writes the mapping: the "0" default and every title that follows it (see plan_mapping).
|
||||
With settle, the default itself is decided first (see choose_default): before the client
|
||||
starts, config.vdf holds what the client last kept, menu changes included."""
|
||||
@@ -999,7 +1039,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
|
||||
else:
|
||||
default = state.get("default") if state.get("default") in names else TOOL
|
||||
steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path)))
|
||||
changes, auto = plan_mapping(current, apps, names, default, followers(state, current), native_targets(steam_root, apps))
|
||||
changes, auto = plan_mapping(current, apps, names, default, followers(state, current), native_targets(steam_root, apps), picks)
|
||||
for app, tool in changes.items():
|
||||
set_mapping(tokens, mapping, app, tool)
|
||||
text = serialize(tokens)
|
||||
@@ -1096,8 +1136,9 @@ def main():
|
||||
print("steam-compatibility: the live helper keeps the client's mapping; tools listed")
|
||||
elif os.path.isfile(config):
|
||||
apps = installed_apps(steam_root)
|
||||
default = register_default(config, apps, protect=extras, catalog=catalog, settle=not steam_running())
|
||||
print("steam-compatibility: default and %d installed title(s) set to %s" % (len(apps), default))
|
||||
picks = valve_picks(steam_root)
|
||||
default = register_default(config, apps, protect=extras, catalog=catalog, settle=not steam_running(), picks=picks)
|
||||
print("steam-compatibility: default, %d installed title(s) and %d title(s) with a Valve pick set to %s" % (len(apps), len(picks), default))
|
||||
sync = os.path.join(os.path.dirname(os.path.realpath(__file__)), "droiddeck-esync")
|
||||
if os.path.isfile(sync):
|
||||
try:
|
||||
|
||||
@@ -465,8 +465,12 @@ class AppInfoTest(unittest.TestCase):
|
||||
return out + b"\x08"
|
||||
|
||||
body = b""
|
||||
for app, (oslist, osarch, kind) in apps.items():
|
||||
data = section({"appinfo": {"appid": str(app), "common": {"type": kind, "oslist": oslist, "osarch": osarch}}})
|
||||
for app, entry in apps.items():
|
||||
if isinstance(entry, dict):
|
||||
data = section({"appinfo": dict(entry, appid=str(app))})
|
||||
else:
|
||||
oslist, osarch, kind = entry
|
||||
data = section({"appinfo": {"appid": str(app), "common": {"type": kind, "oslist": oslist, "osarch": osarch}}})
|
||||
header = struct.pack("<IIQ", 2, 0, 0) + b"\0" * 20 + struct.pack("<I", 1) + (b"\0" * 20 if magic >= 0x07564428 else b"")
|
||||
body += struct.pack("<II", app, len(header) + len(data)) + header + data
|
||||
body += struct.pack("<I", 0)
|
||||
@@ -490,6 +494,38 @@ class AppInfoTest(unittest.TestCase):
|
||||
targets = self.COMPAT["native_targets"](str(steam), ["10", "20", "30", "40", "50", "70", "80", "90", "1628350", "60"])
|
||||
self.assertEqual(targets, {"20": "droiddeck-fex", "50": "droiddeck-fex"}, hex(magic))
|
||||
|
||||
def test_titles_with_a_valve_pick_are_found_installed_or_not(self):
|
||||
def deck(oslist, runtime, kind="game"):
|
||||
common = {"type": kind, "oslist": oslist, "steam_deck_compatibility": {"configuration": {"recommended_runtime": runtime}}}
|
||||
if oslist is None:
|
||||
del common["oslist"]
|
||||
return {"common": common}
|
||||
for magic in (0x07564427, 0x07564428, 0x07564429):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
steam = Path(tmp)
|
||||
(steam / "appcache").mkdir()
|
||||
(steam / "appcache/appinfo.vdf").write_bytes(self.appinfo({
|
||||
10: deck("windows", "proton-stable"), 11: deck("windows,linux", "native"), 12: deck(None, "proton-7.0-5"),
|
||||
13: deck("linux", "native"), 14: deck("windows", "proton-stable", "Tool"), 15: ("windows", "", "game"),
|
||||
16: ("windows", "", "game"), 1628350: deck("linux", "native", "game"),
|
||||
891390: {"common": {"type": "Config"}, "extended": {"app_mappings": {
|
||||
"16": {"appid": "16", "tool": "proton-stable"}, "17": {"appid": "17", "tool": ""}}}},
|
||||
}, magic))
|
||||
self.COMPAT["APPINFO_CACHE"].clear()
|
||||
self.assertEqual(self.COMPAT["valve_picks"](str(steam)), ["10", "11", "12", "16"], hex(magic))
|
||||
|
||||
def test_titles_with_a_valve_pick_follow_the_default_until_the_user_picks(self):
|
||||
tool = self.COMPAT["TOOL"]
|
||||
changes, auto = self.COMPAT["plan_mapping"](
|
||||
{"0": tool, "31": "GE-Proton11-7", "32": "proton_11_arm64", "33": "steamlinuxruntime"},
|
||||
["40"], [tool, "GE-Proton11-7"], tool, {}, {}, ["30", "31", "32", "33"])
|
||||
self.assertEqual(changes, {"30": tool, "32": tool, "40": tool})
|
||||
self.assertEqual(auto, {"30": tool, "32": tool, "40": tool})
|
||||
changes, auto = self.COMPAT["plan_mapping"](
|
||||
{"0": "GE-Proton11-7", "30": tool, "31": "GE-Proton11-7", "32": tool}, [], [tool, "GE-Proton11-7"], "GE-Proton11-7", auto, {}, ["30", "31", "32"])
|
||||
self.assertEqual(changes, {"30": "GE-Proton11-7", "32": "GE-Proton11-7"})
|
||||
self.assertEqual(auto, {"30": "GE-Proton11-7", "32": "GE-Proton11-7"})
|
||||
|
||||
def test_the_mapping_sends_native_titles_to_fex_and_keeps_picks(self):
|
||||
tool, fex = self.COMPAT["TOOL"], self.COMPAT["FEX_TOOL"]
|
||||
changes, auto = self.COMPAT["plan_mapping"](
|
||||
|
||||
Reference in new issue
Block a user