Repository navigation
feat(builder): Add to Steam on Linux, for the Steam Deck and other PCs with Steam - #2547
Draft
hckrman101 wants to merge 1 commit into
Draft
hckrman101 wants to merge 1 commit into
hckrman101 wants to merge 1 commit into
Conversation
…s with Steam builder/linux/steam_shortcut.py adds the game folder to Steam's library as a non-Steam game, in every Steam account's userdata/<account>/config/shortcuts.vdf: it refuses while Steam runs (Steam writes its own list over a change when it exits), keeps the file as it was as shortcuts.vdf.bak, and updates its own entry in place on a second add (the app id is crc32(Exe + AppName) with the top bit set, as Steam's). The Linux launcher and the "Your game is ready" page get an Add to Steam button when Steam is on the PC; from a shell: python3 -I builder/linux/steam_shortcut.py <game folder>. The binary KeyValues reader and writer are lethevimlet's tools/bc250/add_steam_shortcut.py (lethevimlet/oot-bc250, GPL-3.0), made a module. tests/test_steam_shortcut.py checks it against a temporary Steam folder. Co-authored-by: lethevimlet <lethevimlet@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101
pushed a commit
that referenced
this pull request
Nov 12, 2026
**What this is for:** make Linux Builder Play wait for the game’s actual scope hand-off before closing, and start it directly if that hand-off fails. This follows #2540 and @toniosim’s analysis in #2529; thanks to @klikso for the original fix. **What is measured:** game-free checks and 26 transient-user-unit trials on Arch/Hyprland with systemd 262.1, comparing `9c5612f0` with the runtime code in `6ba5433d`. The two injected failures start nothing on main and start the stand-in on this branch; ten ordinary launches work on each. **Not measured:** a real game, a graphical Play click, a rebuilt AppImage, or logout. Refs #2529 Refs #2540 ## Problem `own_scope()` proves that `systemd-run … true` works. `play()` then starts a *different*, asynchronous `systemd-run` and immediately returns to `play_and_close()`, which destroys the Builder window. Two controlled cases reproduce the remaining gap: - The first scope trial genuinely succeeds, but the actual launch exits 72: Play neither starts the game nor falls back. - The actual runner takes another 250 ms before executing systemd-run: with the issue’s `Type=simple`, `ExitType=main`, `KillMode=control-group` service, the Builder exits and its service stops that runner before the hand-off. The delays/failures are deliberate injections, not an estimate of their frequency on players’ desktops. ## What changed - `builder/linux/hookshot_builder.py`: share the existing unified-cgroup reader, then poll the *real* child until it leaves the launcher’s cgroup and its descendants. Stop at the first proof; otherwise stop on child exit or a two-second bound. - The scoped command waits behind a small socket-backed shell gate. The parent sends permission only after that proof. On failure, closing the gate without permission prevents a late scope transfer from launching a second game; the runner is cancelled/reaped before the plain fallback. This also handles failure to execute the real runner. - The gate uses otherwise-discarded stdout and restores `/dev/null` before executing the existing diagnostics command. The game’s stdin/stderr socket arrangement is unchanged. - `builder/linux/play-diagnostics.sh`: include the fallback notice alongside the existing exit status in `hookshot_startup.log`, when that log is published after the game exits. - `tests/test_linux_play_scope.py`: injected clock, cgroups and process outcomes cover actual-call failure, a delayed hand-off, bounded fallback exactly once, first-proof return without a fixed delay, and exec failure. `--source` and `--case` allow each regression to be checked against main independently. - `tests/test_linux_play_diagnostics.py`: exercise publication of the fallback notice while preserving bounded stderr and the game’s own logging behavior. - `readme.md`: describe the bounded hand-off and the limitation of a plain fallback. The original scope-selection policy and successful trial remain. Terminal launches, existing scopes, absent systemd, and an unavailable user manager retain their previous choice of launch path. ## How it was tested Arch/Hyprland, kernel `7.2.15-1-omarchy`, systemd `262.1-1-arch`, GCC 16.2.1 and Clang 23.1.3, 2026-11-11. Shared host; no window or GPU use, and no performance claim. The runs below were made on `6ba5433d` over base `9c5612f0`. The branch has since been rebased onto main `de866017` as `80f16e6d`; the launcher and its two tests are unchanged by the rebase, and both tests were run again there (pass), as were the five cases against main's launcher (each fails). ### Game-free checks - All five new cases fail against main’s source and pass against the branch. Failures on main are the missing fallback/acknowledgment behavior, not a missing-helper assertion. - The full scope-choice/hand-off test passes. - The affected diagnostics fixture passes with GCC and Clang, including its compiled check that the game still writes its own log and the new fallback notice. - Linux-selected checks: **13 passed, 1 failed, 0 skipped**. The failure is `test_linux_docker_packages.py` (PPA parser sees `--no-update` instead of the PPA). - Full Clang suite: **454 passed, 14 failed, 20 skipped**. Each of those 14 checks was then run once on untouched main at `9c5612f0` and failed with the same error signature. This is not a green-suite claim; every check touching the launcher passes. The inherited failures are listed below and are not changed in this PR. - After those full-suite and real-unit runs, the regression fixture's fake SIGKILL was made independent of the host platform (Windows does not expose SIGKILL). The full scope test and all five individual cases were rerun both ways. No runtime, shell or documentation code changed after the real-unit/full-suite runs. <details> <summary>Full-suite failure list</summary> `test_wayland_libdecor_hint.py`, `test_display_startup_failure.py`, `test_texel_buffer_ownership.py`, `test_audio_aux.py`, `test_audio_biquad.py`, `test_dlss_checks.py`, `test_env_on.py`, `test_framegen_preset.py`, `test_hdr_encode.py`, `test_linux_docker_packages.py`, `test_mods_size_table.py`, `test_mouse_native.py`, `test_readback_part.py`, `test_rumble_menu.py`. </details> ### Real transient units: every trial The real imported Builder module’s `Builder.play_and_close()` was called without constructing Tk; only the object’s `destroy` was replaced. The executable was an authored heartbeat script with a start counter, not a mock of the launch code. | Trial | Main | This branch | |---|---|---| | Actual call exits 72 after a successful probe | No child | One plain child; fallback logged | | Actual call delayed 250 ms | No child | One scoped child after launcher collection | | Actual call delayed 2.5 s, beyond the bound | One scoped child after its delay | One plain child; no later second start; fallback logged | | Ordinary 0 | One scoped child | One scoped child | | Ordinary 1 | One scoped child | One scoped child | | Ordinary 2 | One scoped child | One scoped child | | Ordinary 3 | One scoped child | One scoped child | | Ordinary 4 | One scoped child | One scoped child | | Ordinary 5 | One scoped child | One scoped child | | Ordinary 6 | One scoped child | One scoped child | | Ordinary 7 | One scoped child | One scoped child | | Ordinary 8 | One scoped child | One scoped child | | Ordinary 9 | One scoped child | One scoped child | The delayed/ordinary trials use `ExitType=main` to model the reported launcher lifetime. The exit-72 and timeout trials use `ExitType=cgroup` to isolate fallback: a plain child cannot escape a main-lifetime service, and this change does not claim otherwise. These properties model the issue, not every GNOME/KDE/COSMIC release’s defaults. The fallback’s actual startup log: ```text injected actual-scope launch failure Hookshot exited with status 0 Hookshot: systemd scope hand-off failed; started without a separate scope. ``` All 48 named transient services/scopes were collected, all stand-ins and shims removed, and the final all-state unit listing for the test prefix was empty. No persistent unit, desktop file, configuration change, or user game-file change was made. ## Not changed here Logout policy is unchanged. In the earlier review, the generated scope had empty `PartOf`/`BindsTo`; stopping an isolated mock session boundary stopped a deliberately bound control scope but left that scope alive. **Inference, not a real logout result:** a lingering user manager could leave it running unless the game or desktop independently exits it. This patch does not add session dependencies. The existing stale-game closer is Windows-only; this patch does not add a Linux closer. It also does not change the game’s response to SIGTERM. ## Not tested A real desktop’s Play click; a GNOME, KDE or COSMIC session; actual logout; Flatpak confinement; a rebuilt AppImage; Windows or a real game/GPU run. The stand-in tests do not establish how the real game responds to display loss. ## Risks - A user manager taking more than two seconds to transfer the real runner falls back to the previous direct-launch behavior. On a main-lifetime launcher service that direct child can still be stopped with the launcher. - Cgroup visibility is necessary for acknowledgment. If it disappears during launch, the attempt is cancelled and falls back rather than assuming success. - The new gate must not change diagnostics descriptors or permit a late second launch; the socket/compiled-logging checks, timeout case, and real start counters cover those boundaries. ## Overlaps The overlap check found #2547 adds its Add to Steam paragraph at the same README insertion point; the hunk was read. This is a documentation-ordering conflict, not a shared launcher implementation change. #1759, #1872, #1958, #2317, #2373, #2430 and #2465 share a changed code file and merge cleanly according to the overlap check. This follows, rather than replaces, merged #2540. Assisted-by: GPT-6 Astra Co-Authored-By: GPT-6 Astra <noreply@openai.com> Co-authored-by: GPT-6 Astra <noreply@openai.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch: feat/add-to-steam, head 4fa4f08 on main 940eb9e
Steam Deck players and others who start games from Steam had no simple way to put Hookshot in Steam's library: they had to add it by hand as a non-Steam game, with the right launcher and start folder. This adds an Add to Steam button to the Linux builder, and a command for the same thing.
What changes
builder/linux/steam_shortcut.pyadds the game folder to every Steam account'sshortcuts.vdfas a non-Steam game (its launcher, its icon, Steam's quoting).shortcuts.vdf.bak.python3 -I builder/linux/steam_shortcut.py <game folder>(--steam <folder>,--dry-run).Overlap with #2373: #2373 rewrites
builder/linux/hookshot_builder.pyinto one launcher window. Only the two button hookups here touch that file. Whichever PR lands second moves them (for #2373, into its Menu panel). The module, its command line and its test don't depend on the window.Credit
The binary KeyValues reader and writer are lethevimlet's
tools/bc250/add_steam_shortcut.pyfrom lethevimlet/oot-bc250 (GPL-3.0, commit 98cceccfec), made a module with Steam folder discovery, every account, and the running-Steam check.Verified
tests/test_steam_shortcut.pychecks:tests/run_all.pyon Linux on this branch: 408 passed, 12 skipped.test_appimage_package_retry.pyfailed once in the full run and passed twice alone; it times an AppImage packaging retry and doesn't touch this change.Against a temporary Steam folder (never the real one) with a built game folder: the entry is added (
Exe "<folder>/hookshot",StartDir "<folder>/"), the file ends in 08 08 08, and a second run updates it in place.Not tested