From 3fef1f3fa1ac1cd4d8800415267366defdddc9b6 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 12:03:21 -0500 Subject: [PATCH] fix(proton): a Proton picked for a game in Steam is kept (#2284) fix(proton): a Valve Proton picked for a game in Steam stays on the ARM64 build made from it Steam lists Valve's own Proton Experimental and Proton 11.0 next to the DroidDeck tools. They are not runnable here, so a per-game pick of one was replaced with the default. That used to be Proton Experimental ARM64, so the pick looked kept; since an installed DroidDeck-Proton became the default, the game is moved to DroidDeck-Proton instead. When Steam records one of those tools for a title the sync already knows (a title it mapped or one with a recorded pick), the title now gets the DroidDeck tool built from the same Valve depot, stops following the default and the choice is mirrored into the app's per-game setting. The same applies to Steam's default. Titles nobody picked anything for still follow the default, and Valve tools with no ARM64 build still fall back to it. --- .../usr/local/bin/droiddeck-steam-compat | 16 ++++++-- .../overlay/usr/local/bin/steam-compatibility | 24 +++++++++++- tools/tests/test_droiddeck_proton_default.py | 37 ++++++++++++++++++- tools/tests/test_game_environment.py | 2 +- tools/tests/test_steam_compat_live.py | 28 ++++++++++++++ 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 95be74815..901caf2f4 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -1078,8 +1078,9 @@ class Helper: wanted = COMPAT["requested_tool"](request, catalog) newer = isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0) waiting = newer and wanted is not None and wanted not in self.known - default, state = COMPAT["choose_default"](top["selected"], state, request, catalog, apply_request=not waiting, - listed=[t["name"] for t in live]) + listed = [t["name"] for t in live] + default, state = COMPAT["choose_default"](COMPAT["built_from"](top["selected"], catalog, listed) or top["selected"], + state, request, catalog, apply_request=not waiting, listed=listed) installed = COMPAT["installed_apps"](self.steam_root) self.installed = installed picks = COMPAT["valve_picks"](self.steam_root) @@ -1124,13 +1125,20 @@ class Helper: current[app] = "" elif not current.get(app): current.pop(app, None) + moved = COMPAT["rebuilt_picks"](current, catalog, set(prior_auto) | picked, listed) + if moved: + following = COMPAT["followers"](state, current) + state["auto"] = {app: name for app, name in following.items() if app not in moved} if isinstance(following, dict) else {} + prior_auto = {app: name for app, name in prior_auto.items() if app not in moved} + current.update(moved) requests = COMPAT["settled_game_requests"](state, current, catalog) - overrides = COMPAT["requested_games"](requests or {}, catalog, [t["name"] for t in live]) + overrides = COMPAT["requested_games"](requests or {}, catalog, listed) overrides = {app: tool for app, tool in overrides.items() if app not in refused} changes, auto = COMPAT["plan_mapping"]( - current, installed, [t["name"] for t in live], default, + current, installed, listed, default, COMPAT["followers"](state, current), native, picks, overrides, prior_auto.keys(), native_choices) + changes = dict(moved, **changes) 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 if tool is not None else ""))) say("%s -> %s" % ("default" if app == "0" else "app %s" % app, diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 2f964d96a..071ff0681 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -16,6 +16,8 @@ LEGACY_LAUNCHER = "bannerlator-proton" LIBRARY = "/mnt/droiddeck" SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)") OWN_TOOLS = ((TOOL, SOURCES), (TOOL_11, ("Proton 11.0 (ARM64)",))) +VALVE_DEPOTS = {"proton_experimental": SOURCES[0], "proton-experimental-arm64": SOURCES[0], + "proton_11": SOURCES[1], "proton_11-arm64": SOURCES[1]} FEX_TOOL = "droiddeck-fex" FEX_LAUNCHER = "droiddeck-fex-tool" FEX_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", FEX_LAUNCHER) @@ -1160,6 +1162,18 @@ def tool_catalog(steam_root, extras=None): return catalog +def built_from(name, catalog, runnable=None): + depot = VALVE_DEPOTS.get(name) + tools = [t["name"] for t in catalog if depot and t["valve"] and t["dir"] == depot and + (runnable is None or t["name"] in runnable)] + return tools[-1] if tools else None + + +def rebuilt_picks(current, catalog, known, runnable=None): + picks = {app: built_from(name, catalog, runnable) for app, name in current.items() if app in known} + return {app: tool for app, tool in picks.items() if tool} + + def requested_tool(request, catalog): name = request.get("name") if isinstance(name, str) and any(t["name"] == name for t in catalog): @@ -1513,7 +1527,8 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non state["owned"] = sorted(set(owned), key=app_order) if default is None: if settle: - default, state = choose_default(current.get("0", ""), state, read_json(state_path("request.json", directory)), catalog) + default, state = choose_default(built_from(current.get("0", ""), catalog) or current.get("0", ""), state, + read_json(state_path("request.json", directory)), catalog) else: default = state.get("default") if state.get("default") in names else TOOL if settle and newest and default == TOOL and state.get("source") not in ("app", "steam"): @@ -1526,6 +1541,13 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non current.update(replacements) if isinstance(state.get("auto"), dict): state["auto"] = {app: newest if name in gone else name for app, name in state["auto"].items()} + following = followers(state, current) + following = following if isinstance(following, dict) else {} + moved = rebuilt_picks(current, catalog, set(following) | picked_apps(state)) + if moved: + state["auto"] = {app: name for app, name in following.items() if app not in moved} + set_mappings(tokens, mapping, moved) + current.update(moved) steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) requests = settled_game_requests(state, current, catalog, not steam_running(), directory) overrides = requested_games(requests or {}, catalog) diff --git a/tools/tests/test_droiddeck_proton_default.py b/tools/tests/test_droiddeck_proton_default.py index 4da81a63b..2173a223a 100644 --- a/tools/tests/test_droiddeck_proton_default.py +++ b/tools/tests/test_droiddeck_proton_default.py @@ -10,7 +10,8 @@ COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) TOOL = COMPAT["TOOL"] OLD = {"name": "DroidDeck-Proton-11-11.2", "display": "DroidDeck-Proton 11-11.2", "dir": "DroidDeck-Proton 11-11.2", "valve": False} NEW = {"name": "DroidDeck-Proton-11-11.4", "display": "DroidDeck-Proton 11-11.4", "dir": "DroidDeck-Proton 11-11.4", "valve": False} -VALVE = {"name": TOOL, "display": "Proton Experimental ARM64", "dir": "Proton - Experimental (ARM64)", "valve": True} +VALVE = {"name": TOOL, "display": "Proton Experimental ARM64", "dir": COMPAT["SOURCES"][0], "valve": True} +VALVE_11 = {"name": COMPAT["TOOL_11"], "display": "Proton 11.0 ARM64", "dir": COMPAT["SOURCES"][1], "valve": True} GE = {"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False} GE10 = {"name": "GE-Proton10-3", "display": "GE-Proton10-3", "dir": "GE-Proton10-3", "valve": False} NEWER = {"name": "DroidDeck-Proton-11-12.1", "display": "DroidDeck-Proton 11-12.1", "dir": "DroidDeck-Proton 11-12.1", "valve": False} @@ -164,6 +165,40 @@ class DroidDeckProtonDefaultTest(unittest.TestCase): self.assertEqual(mapping, {"0": GE10["name"], "3000000001": TOOL, "3000000002": GE10["name"], "3000000003": GE["name"], "3000000004": GE10["name"], "42": GE["name"], "44": GE["name"]}) + def test_proton_experimental_picked_for_a_game_in_steam_stays(self): + default, mapping, state = self.run_default( + {"default": NEW["name"], "written": NEW["name"], "source": "update", + "auto": {"42": NEW["name"], "43": NEW["name"], "45": NEW["name"]}}, + [("0", NEW["name"]), ("42", "proton_experimental"), ("43", NEW["name"]), ("45", TOOL)], [VALVE, NEW], + ["42", "43", "44", "45"]) + self.assertEqual(default, NEW["name"]) + self.assertEqual(mapping, {"0": NEW["name"], "42": TOOL, "43": NEW["name"], "44": NEW["name"], "45": TOOL}) + self.assertEqual(state["auto"], {"43": NEW["name"], "44": NEW["name"]}) + self.assertTrue({"42", "45"} <= set(state["picked"])) + default, mapping, state = self.run_default(state, sorted(mapping.items()), [VALVE, NEWER], ["42", "43", "44", "45"]) + self.assertEqual(default, NEWER["name"]) + self.assertEqual(mapping, {"0": NEWER["name"], "42": TOOL, "43": NEWER["name"], "44": NEWER["name"], "45": TOOL}) + + def test_a_steam_pick_of_a_valve_proton_moves_to_the_build_made_from_it(self): + catalog = [VALVE, VALVE_11, NEW] + default, mapping, state = self.run_default( + {"default": NEW["name"], "written": NEW["name"], "source": "update", "auto": {"42": TOOL, "43": NEW["name"]}, + "picked": ["44"]}, + [("0", NEW["name"]), ("42", "proton-experimental-arm64"), ("43", "proton_11"), ("44", "proton_11-arm64"), + ("45", "proton_9")], catalog, ["42", "43", "44", "45"]) + self.assertEqual(mapping, {"0": NEW["name"], "42": TOOL, "43": VALVE_11["name"], "44": VALVE_11["name"], "45": NEW["name"]}) + self.assertEqual(state["auto"], {"45": NEW["name"]}) + _, mapping, _ = self.run_default(state, sorted(mapping.items()), catalog, ["42", "43", "44", "45"], + request={"seq": 1, "valve": False, "dir": GE["dir"]}) + self.assertEqual(mapping, {"0": NEW["name"], "42": TOOL, "43": VALVE_11["name"], "44": VALVE_11["name"], "45": NEW["name"]}) + + def test_proton_experimental_picked_as_steams_default_stays(self): + default, mapping, state = self.run_default( + {"default": NEW["name"], "written": NEW["name"], "source": "update"}, [("0", "proton_experimental")], [VALVE, NEW]) + self.assertEqual((default, mapping, state["source"]), (TOOL, {"0": TOOL}, "steam")) + default, mapping, state = self.run_default(state, [("0", TOOL)], [VALVE, NEW, NEWER]) + self.assertEqual((default, mapping), (TOOL, {"0": TOOL})) + def test_the_newest_build_is_found_by_version(self): newest, gone = COMPAT["own_proton_moves"]({"tools": [OLD]}, [dict(NEW, name="a", dir="DroidDeck-Proton 11-9.10"), dict(NEW, name="b", dir="DroidDeck-Proton 11-11.1")]) self.assertEqual(newest, "b") diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index dfc65de1c..6e21ec2de 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -2024,7 +2024,7 @@ class ProtonDefaultTest(unittest.TestCase): self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL}) saved = json.loads((Path(tmp) / "state.json").read_text()) self.assertEqual(saved["native_choices"], []) - self.assertEqual(saved["auto"], {"427520": self.TOOL}) + self.assertEqual(saved["auto"], {}) finally: globals_["appinfo_platforms"] = original_platforms diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index 2902bef08..15ae63bb8 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -769,6 +769,34 @@ class ReconcileTest(unittest.TestCase): self.assertEqual(default, build) self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": build, "10": build, "11": build}) + def test_proton_experimental_picked_for_a_game_in_steam_is_kept_and_mirrored(self): + build = "DroidDeck-Proton 11-12.3" + (self.steam / "compatibilitytools.d" / build).mkdir(parents=True) + (self.steam / "compatibilitytools.d" / build / COMPAT["EXTRA_WRAPPER"]).write_text("") + (self.state / "state.json").write_text(json.dumps( + {"default": build, "written": build, "source": "update", "auto": {"11": build}, "picked": ["10", "11"], "tools": [], + "games_applied": {"10": {"valve": False, "dir": build}}})) + self.write_games({"10": {"valve": False, "dir": build}}) + page = FakePage({"0": build, "10": "proton_experimental", "11": build}, [TOOL, TOOL_11, build, "proton_experimental"]) + state = self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL)]) + self.assertEqual(state["auto"], {"11": build}) + self.assertEqual(self.games(), {"10": {"valve": True, "dir": COMPAT["SOURCES"][0]}}) + config = self.steam / "config/config.vdf" + config.parent.mkdir(parents=True) + vdf = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' + entry = '"%s" { "name" "%s" "config" "" "priority" "250" }' + config.write_text(vdf % "".join(entry % (app, page.mapping[app]) for app in ("0", "10", "11"))) + with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False): + default = COMPAT["register_default"](str(config), ["10", "11"], catalog=COMPAT["tool_catalog"](str(self.steam)), + directory=str(self.state), settle=True) + self.assertEqual(default, build) + self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": build, "10": TOOL, "11": build}) + page.sets.clear() + self.write_games({"10": {"valve": True, "dir": COMPAT["SOURCES"][1]}}) + self.reconcile(page) + self.assertEqual(page.sets, [("10", TOOL_11)]) + def test_file_mode_defers_to_the_live_helper(self): (self.state / "live.pid").write_text("%d\n" % os.getpid()) self.assertTrue(COMPAT["live_helper_running"]())