Skip to content

feat(Steam): add the SteamCMD acquisition path (refs #289) - #293

Open
bcupello wants to merge 2 commits into
MythicApp:mainfrom
bcupello:feat/steam-acquisition
Open

bcupello wants to merge 2 commits into
MythicApp:mainfrom
bcupello:feat/steam-acquisition

Conversation

@bcupello

@bcupello bcupello commented Aug 21, 2026 •

Copy link
Copy Markdown

Please read #289 first — it asks how you would like this split, and flags that one board item is not deliverable as written. Opened as requested rather than assumed to be wanted as-is.

Stacked on #292. This branch contains that commit too, because both touch the same switch in AnyGame.init(from:). Happy to rebase once it lands.

Implements most of the Steam Development Roadmap on the current engine. SteamCMD is a native macOS binary and never involves Wine, so installing, updating, verifying and enumerating all work today; Wine is only involved in launching a downloaded title, through the path that already exists.

Covers: SteamCMD installation, Functional signin + steam guard support, Fetch game list, Game installing + progress handling, Game moving, Game update checker, Custom install directory, SteamCMD autoupdate parser.

Layering

ValveDataFormat and SteamCMDOutput are dependency-free readers for Valve KeyValues and for SteamCMD output. SteamCMD drives the binary. Steam is the storefront service — the peer of Legendary. SteamGameManager conforms to StorefrontGameManager.

Things that had to be measured

Each of these produced wrong behaviour first, so each is recorded at its call site:

  • licenses_print caps the app IDs printed per package and says so ((N in total)). On a real account 171 of 455 sat behind one elided line, recovered with package_info_print.
  • @ShutdownOnFailedCommand defaults to 1, abandoning every remaining command once one fails. An empty app_info_print counts, so a 25-app metadata batch returned exactly one result.
  • common/type casing is inconsistent — game for Terraria and Dota 2, Game for Palworld, in one batch.
  • The bootstrapper localises its output mid-stream, so only the numeric [ n%] / [----] prefixes are matched, never English words.
  • force_install_dir must precede login, or SteamCMD refuses outright.
  • Exit code 42 is the self-update mechanism, handled by steamcmd.sh — which is why the script is invoked, not the binary.
  • The mobile-authenticator flow reports its outcome on its own line (Waiting for confirmation...OK); to Steam Public... never gains a verdict in that case.
  • SteamCMD writes its session relative to HOME, by default the user’s real Steam installation. HOME is scoped: 15 files written under Mythic’s directory, zero touched in the user’s.
  • Bulk output is read whole, not streamed. Process.runStreamed splits whatever a single read() returned, so a line spanning two reads arrives as two fragments — an enumeration that received 188 licence lines parsed 7. Streaming stays for downloads and sign-in, which tolerate fragments.
  • Concurrent runs share one session directory and corrupt each other rather than failing: two overlapping enumerations returned 4 and 18 app IDs where one returns 455.

Not attempted

The Steam client's own UI does not run on the engine — its Chromium 126 helper crash-loops on wine-7.7 — so the client can be started but never signs in, and anything needing a Steam auth ticket fails.

That is narrower than "cannot launch", which is what I first wrote here and have corrected in #289. The game I tested does launch and run; what fails is online authentication, and it says so itself (ConnectLoginNoEAS() failed due to the platform OSS giving an empty auth token). A title that never asks for a ticket may well be fine. Launches that do fail now explain themselves instead of silently doing nothing.

Verification

Exercised end to end against a real account on Engine 2.6.1, in the app rather than in a harness:

Step Result
SteamCMD installed from the Accounts pane 15 files under Mythic's own directory, zero touched in the user's real Steam install
Signed in Steam Mobile App approval; Waiting for confirmation...OK, then [Logged On] with a real SteamID
Library enumerated 259 owned app IDs from 113 licences → 103 games, DLC and soundtracks filtered by common/type
Title installed 41.3 GB requested, 38 GB on disk, Success! App '1623730' fully installed.
Update state read from appmanifest_<appid>.acf: StateFlags 4, buildid == TargetBuildID
Launch starts, reaches RHI init, then exits — it needs a live signed-in client (measured; see the issue comment)

xcodebuild clean and swiftlint clean for every file touched — the three warnings it still reports all predate this branch.

The parsing layers additionally carry 67 assertions over verbatim SteamCMD captures, compiled standalone since there is no test target. Not included here (it trips force_try under your .swiftlint.yml); offered in #289.

Screenshots

Hosted on a branch of the fork rather than committed here, so they add nothing to this diff.

Library, after enumeration — 103 titles, artwork from Steam's CDN, app ID on each badge:

Library with 103 Steam games

Import by app ID — the tab that was hidden and .hidden() upstream. Empty state explains itself rather than asking the CDN for artwork it cannot have:

Import by app ID

A launch that fails now says so. Previously the operation completed without error, so the UI showed a brief spinner and nothing else:

Launch failure explains itself

Interface language is the tester's; the strings this PR adds are English source entries in the catalogue.

bcupello and others added 2 commits August 21, 2026 15:36
`AnyGame.init(from:)` decoded the storefront tag with `try`, so a tag the
running build does not recognise threw. `GameDataStore` decodes the whole
`[AnyGame]` array in one call and `UserDefaults.decodeAndGet` swallows any
error into `nil`, which the observer then reads as an empty library -- so a
single unreadable row discards every game from every other storefront too.

Measured on 0.6.0 by seeding `games` with one unknown storefront tag plus
one ordinary local game:

    0.6.0        0 of 2 survive -- library wiped, local game included
    this change  2 of 2 survive -- unknown tag degraded, local untouched

Worth shipping ahead of any new storefront case, because the build that
needs the tolerance is the older one: by the time a new tag exists on disk,
the release that cannot read it is already out.

`Game.storefront` on the base class logs instead of asserting, since a bare
`Game` is now a legitimate decode outcome rather than a programmer error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements most of the Steam Development Roadmap on the current engine.
SteamCMD is a native macOS binary and never involves Wine, so installing,
updating, verifying and enumerating all work today; Wine is only involved
in launching a downloaded title, through the path that already exists.

Covers the board's "SteamCMD installation", "Functional signin + steam
guard support", "Fetch game list", "Game installing + progress handling",
"Game moving", "Game update checker", "Custom install directory" and
"SteamCMD autoupdate parser".

**Layering.** `ValveDataFormat` and `SteamCMDOutput` are dependency-free
readers for Valve's KeyValues format and for SteamCMD's own output.
`SteamCMD` drives the binary; `Steam` is the storefront service, the peer
of `Legendary`; `SteamGameManager` conforms to `StorefrontGameManager`.

**Things that had to be measured rather than assumed**, recorded at the
call sites because each one produced wrong behaviour first:

- `licenses_print` caps the app IDs it prints per package and says so
  (`(N in total)`). On a real account 171 of 455 sat behind one elided
  line, recovered by re-reading that package with `package_info_print`.
- `@ShutdownOnFailedCommand` defaults to 1, which abandons every remaining
  command once one fails. An empty `app_info_print` counts, so a 25-app
  metadata batch returned exactly one result.
- `common/type` casing is inconsistent -- `game` for Terraria and Dota 2,
  `Game` for Palworld, in one batch -- so it is normalised before the
  filter that keeps DLC and soundtracks out of a game library.
- SteamCMD's bootstrapper output is localised mid-stream, so only its
  numeric `[ n%]` / `[----]` prefixes are matched, never English words.
- `force_install_dir` must precede `login`, or SteamCMD refuses outright.
- Exit code 42 *is* the self-update mechanism, handled by `steamcmd.sh`,
  which is why the script is invoked rather than the binary.
- The mobile-authenticator flow reports its outcome on its own line
  (`Waiting for confirmation...OK`); the `to Steam Public...` line never
  gains a verdict in that case.
- SteamCMD writes its session relative to `HOME`, which by default is the
  user's real Steam installation. `HOME` is scoped: measured at 15 files
  written under Mythic's own directory and zero touched in the user's.
- Bulk output is read whole rather than streamed. `Process.runStreamed`
  splits whatever a single `read()` returned, so a line spanning two reads
  arrives as two fragments; an enumeration that received 188 licence lines
  parsed 7 of them. Streaming stays for downloads and for signing in.
- Concurrent SteamCMD runs share one session directory and corrupt each
  other rather than failing: two overlapping enumerations returned 4 and 18
  app IDs where one returns 455. Runs are serialised, and enumeration
  collapses concurrent callers onto one task.

**Deliberately not attempted.** Titles that authenticate through a live
Steam client cannot launch: the client's Chromium 126 helper does not
survive the engine's `wine-7.7`. They download correctly and then fail to
start, and that failure now explains itself rather than silently doing
nothing. `Fetch game list` covers owned titles; see the issue for why a
complete list needs one of three separate problems solved first.

Localisation: the pt-BR values are only for strings introduced here, added
while testing in that locale -- happy to strip them if Crowdin should own
them. No existing catalogue entry is modified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Morialkar added a commit to Morialkar/Mythic that referenced this pull request Oct 6, 2026
…#293

MythicApp#273 renamed `Rosetta.exists` to `Rosetta.isInstalled`; MythicApp#293 still called the old name,
so the combined branch did not compile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant