Skip to content

Fix Steam Launcher for Multiplayer/Networking/Cloud Saves - #720

Open
maxjivi05 wants to merge 29 commits into
WinNative-Emu:mainfrom
maxjivi05:fix/steam-launcher
Open

Fix Steam Launcher for Multiplayer/Networking/Cloud Saves#720
maxjivi05 wants to merge 29 commits into
WinNative-Emu:mainfrom
maxjivi05:fix/steam-launcher

Conversation

@maxjivi05

Copy link
Copy Markdown
Contributor

No description provided.

Adopted from the SteamLite VAC handoff by The412Banner (Bannerlator),
https://github.com/The412Banner/winlator-contents. Their agent is itself
a derivative of WinNative's wn-steam-launcher and they handed the work
back so WinNative could reach VAC-secured servers. Their handoff, notice
and reference scripts are kept under wn-steam-launcher/steamlite-handoff,
and CREDITS.md there records exactly what was taken.

IClientAppManager::LaunchApp only returns NoError when the app is
registered under steamapps\common\<InstallDir> against a matching
appmanifest, where InstallDir is Steam's canonical install-folder name.
The manifest, the symlink and the launch path were all built from the
on-disk folder name instead, which diverges for custom install locations
and for apps that fall back to the store name. When it diverged LaunchApp
failed with EAppUpdateError=18 and the launcher fell back to
CreateProcess, which starts the game -insecure, and VAC-secured servers
refuse that connection.

The canonical name now drives the manifest installdir, the common
symlink and the launch path together. ColdClient keeps the on-disk name
its INI is written against, and the shared call site links both names
when they differ so neither mode regresses.

The launcher also accepts a spec file, by argv or WN_STEAM_GAMEEXE_FILE,
carrying the exe path and an appId override, so game paths no longer have
to survive Wine command-line quoting. A bare argv path still works.
Verified the SteamLite handoff against the built agent under Wine 9.0.

The handoff's argv[1] handling fopen()s the argument and treats any file
that opens as a spec, so a bare game-exe path - the documented back-compat
form, and the fallback taken when the spec cannot be written - had the
first line of its PE header parsed as the game path. The first line must
now look like an absolute Windows path before the argument is accepted as
a spec.

Creating the canonical steamapps/common link as a second call to
ensureSteamappsCommonSymlink also re-copied the game's _CommonRedist into
Steamworks Shared on every launch. Both link names are now created in one
pass with the redists staged once, which also restores the on-disk-named
link at the setupSteamEnvironment call site.

The reference scripts vendored from the handoff were being dropped by the
repo-wide *.py ignore; force-added so the attribution is complete.

Credit: The412Banner (Bannerlator),
https://github.com/The412Banner/winlator-contents - see
wn-steam-launcher/steamlite-handoff/CREDITS.md
Resolve SteamUtils.createAppManifest: keep the branch's canonical
installdir name for the steamapps/common symlink and take upstream's
cached installSizeOnDisk() in place of the per-call directory walk.
…f, insecure fallback

The Steam Launcher reached VAC-secured servers only intermittently and some
live-service titles refused to go online. Four separate defects, all verified
on device against a real account.

1. App ownership ticket was never fetched.
   IClientUser::GetAppOwnershipTicketLength returned 0 for the app on every
   launch, which is the state steamclient itself names in
   "GetAuthSessionTicket called but no app ownership ticket available" - the
   game's GetAuthSessionTicket then yields nothing for a game server or a
   publisher backend to validate. The agent now calls
   BUpdateAppOwnershipTicket after the licence sync and waits for the ticket,
   logging its length before and after (measured 0 -> 178 bytes on device).

   The vtable slots are hardcoded per client build and were derived for
   steamclient64 md5 8a1a789229c222017b741fad46d73fee by locating the
   IClientUser vtable in .rdata from a known slot address, then mapping each
   method-name literal's RIP-relative lea site back to the enclosing slot.
   Validated against BIsSubscribedApp(181), SetLoginToken(56),
   BHasCachedCredentials(49) and BLoggedOn(4), then confirmed at runtime:
     BUpdateAppOwnershipTicket    slot 69  0x228
     GetAppOwnershipTicketLength  slot 103 0x338
   WN_STEAM_OWNERSHIP_SLOT still overrides the default.

2. Launcher hand-off looked like a failed launch.
   LaunchApp starts the app's configured entry, which for most engines re-execs
   an arch-specific sibling and exits (cstrike.exe -> cstrike_win64.exe,
   Foo.exe -> Foo-Win64-Shipping.exe). Matching the image name literally saw
   zero processes and fell through to the insecure CreateProcess fallback,
   which both loses VAC eligibility and trips "Only one instance of the game
   can be running". Processes are now matched by a stem that folds arch and
   hand-off suffixes, plus any process whose image path sits under
   steamapps\common\<installdir> for renames the stem cannot catch. The same
   matcher drives the exit watch and teardown, so the real child is reaped.

3. The insecure fallback raced the secure launch.
   The appear window is now 120s with a 5s heartbeat, error and null-handle
   paths get a 30s grace instead of falling through immediately, and there is
   a final re-check plus a guard inside create_process_game. The tailer
   re-arms its 35s watchdog on the heartbeat, so a slow cold start no longer
   reads as a failure while a dead agent still times out.

4. Ownership/licence and appinfo were not waited on.
   BIsSubscribedApp is polled before appinfo and LaunchApp (3-5s on device),
   because PICS appinfo for a token-gated app needs the licence list first.
   The appinfo wait is 8s, dropping to 1.5s once an app is recorded as never
   delivering AppInfoUpdateComplete_t; LaunchApp's MissingConfig retry is the
   real safety net. WN_STEAM_SKIP_APPINFO and WN_STEAM_APPINFO_WAIT_MS tune it.

App side:
- isUserOverriddenSteamExe compared bare base names, so auto-detect picking the
  64-bit sibling looked like a user override and skipped LaunchApp entirely -
  a guaranteed insecure launch. It now compares exe families.
- WN_STEAM_BUILD_ID and WN_STEAM_DEPOTS fed the agent's ACF writer with the
  newest published build id and manifest gids rather than what is installed,
  overwriting the correct values the Kotlin path computes. Both now go through
  SteamBranchSelection.installedBuildId/.installedManifest.
- checkForAppUpdate reported "no updates" whenever every depot's manifest
  failed to resolve from PICS; it now cross-checks the recorded install build.
- steamservice and its version VDFs are also staged to
  Common Files\Steam, the location steamclient looks in.

Diagnostics the agent now writes to wn-launcher.log: game identity and install
root, per-DLL steam_api genuineness (restoring from .orig when an emulator swap
is found), the ownership ticket length either side of the update, the
ActiveProcess registry read back after launch, and the game process's loaded
Steam modules.

Adds JVM tests for the exe-family matcher.
VAC's process monitor lives in steamservice.dll, not steamclient: string
analysis shows VacProcessMonitor (src\common\vacprocessmonitor.cpp) with two
references in steamservice.dll and zero in steamclient64.dll. steamservice.dll
is 32-bit, so a 64-bit agent can never load it in-process - the client logged
"Failed to load Steam Service (GLE 193 = ERROR_BAD_EXE_FORMAT)" on every launch
and Dota 2 reported "VAC was unable to verify that your machine is secure".

Real Steam's own topology is a 32-bit client host, so the agent now builds for
i386 as well and is selected by default. 64-bit games are unaffected: they load
steamclient64.dll in their own process and IPC to the 32-bit host, exactly as on
Windows.

Device result: Dota 2 reaches the main menu, logged in, with no VAC dialog, and
"Failed to load Steam Service" is gone from the client's service_log.

The port's real difficulty was calling-convention ARITY, not vtable slots. x86
__thiscall is callee-cleanup, so every wrong argument count drifts the stack 4
bytes per call; on x86-64 there is no callee cleanup, so the same mistakes are
invisible, which is why they survived. True arity is readable from each
function's "ret 0xN" epilogue (N/4 = stack args), disassembled rather than
byte-scanned. Three were wrong:

  IClientEngine::GetIClientUser        declared 3 (hUser, hPipe, version string)
                                       actual   2 - there is no version arg; the
                                       body reads only 0x8(%ebp) and 0xc(%ebp)
  IClientUser::BUpdateAppOwnershipTicket declared 3 (app, bool, bool)
                                       actual   2 (app, bool)
  IClientAppManager::LaunchApp         declared 4 (pGameId, opt, src, args)
                                       actual   5 - CGameID goes by VALUE on x86

GetIClientUser was the one that mattered: it corrupted appId before the first
ownership call, so BIsSubscribedApp and the ticket fetch ran against a garbage
app id and execution eventually jumped to address 1.

Vtable constants are now pointer-size-agnostic indices rather than byte offsets,
since the class layouts are identical between builds - one set of constants is
correct for both. Verified against the 32-bit tables: engine rva 0x10278e0,
IClientUser 0xffe8c8, IClientApps 0x1004190, IClientAppManager 0x10044e4.

Also in this change:
- preferLauncherExe, from Bannerlator, whose comments record it as device-proven
  on CS:S (cstrike_win64.exe launches insecure, cstrike.exe is VAC-secure).
- log_line gains a gnu_printf format attribute; a %s/argument mismatch added
  while making the client DLL name arch-conditional had crashed the agent at
  startup, and the attribute makes that class of bug a compile error.
- Per-shortcut Steam launch options now reach LaunchApp via WN_STEAM_USER_ARGS;
  they were appended to the agent's command line, which the agent never reads.
- Interface validation plus an engine-vtable dump, so a bad interface pointer
  logs a diagnosis instead of calling into garbage.
- The 32-bit agent is selected by PrefManager.wnSteamAgent32 or the marker file
  filesDir/.wn_steam_agent_32; prefs are EncryptedSharedPreferences and cannot
  be edited over adb, so the marker exists to toggle builds without a rebuild.

Known gaps on the 32-bit path: cloud sync is skipped because the RemoteStorage
indices were derived from the 64-bit table and are unverified, and gamemodules
cannot snapshot a 64-bit game from a 32-bit agent (GLE 299), which is diagnostic
only.
Team Fortress 2 was launching insecure. Steam's configured entry for app 440 is
tf_win64.exe; preferLauncherExe correctly rewrites that to the tf.exe launcher
for process watching, because Steam spawns the launcher and the arch exe is its
child. But the override check ran after the rewrite, so it compared our own
rewrite against Steam's entry, concluded the user had picked a different exe,
and set WN_STEAM_DIRECT_EXE=1 - which tells the agent to skip LaunchApp and
CreateProcess the exe itself, the insecure path with no VAC session.

Device log before:

  watch exe 'tf_win64.exe' -> launcher 'tf.exe'
  resolved exe 'tf.exe' is not in the family of Steam's configured entry
    'tf_win64.exe' - treating as a user override
  WN_STEAM_DIRECT_EXE=1 - user-overridden launch exe

after:

  LaunchApp(appId=440 launchOption=0) -> EAppUpdateError=0
  "tf.exe" is running (secure launch)

The override check and the launch-option lookup now run on the resolved exe,
before preferLauncherExe; only the watched exe is the launcher. A rewrite we
performed ourselves is by definition not a user override.

The family match was also wrong for this pair independently. exeStem refuses to
strip a suffix that would leave fewer than EXE_STEM_MIN_LENGTH characters, which
was 3, so "tf_win64" could not shed "_win64" (remainder "tf" is two) and instead
shed the shorter "64" to give "tf_win", which does not equal "tf". Lowering the
floor to 2 makes tf_win64 -> tf while leaving every other case unchanged,
including the ones the floor exists to protect: x64, 32, game and launcher are
all still returned intact, since a suffix equal in length to the whole name is
skipped before the floor is consulted. Covered by two new tests.

TF2 now reaches its main menu logged in, with the store and friends panels
populated, and no VAC error. Its reported "freeze while loading" is a slow first
load off FUSE-backed external storage - roughly six minutes, during which the
process is demonstrably progressing (rchar climbing ~25 MB/s early on), not
deadlocked.
Brawlhalla shows "INCORRECT VERSION" on WinNative while the identical bytes work
under real Steam on desktop, so the question was whether our hosted client fails
to serve some interface the game needs. Nothing static could answer that: the
game's Steam layer is an AIR native extension that writes no diagnostics, and
release Adobe AIR discards trace().

wn-iface-probe.exe replicates exactly what that extension does. It loads the
game's own steam_api64.dll, calls SteamInternal_SteamAPI_Init, then
SteamInternal_FindOrCreateUserInterface for each interface version string the
extension imports, and reports which resolve. The agent runs it after ownership
sync and folds its output into wn-launcher.log. It is gated on the marker file
C:\wn-iface-probe.on so normal launches are untouched.

Device result for Brawlhalla (291550): init returns 0 (OK), HSteamUser=1, and
all eleven interfaces the game asks for resolve - Apps008, UserStats012,
RemoteStorage016, UGC020, HTTP003, HTMLSurface_005, Screenshots003,
RemotePlay002, Music001, MusicRemote001, ParentalSettings001. HTMLSurface
mattering was the leading theory, since we ship no steamwebhelper; it is served,
and Banner's package ships no steamwebhelper either.

So the client-side interface surface is complete and is not the fault. Recorded
because this closes a whole class of theories that kept looking plausible.

The probe deliberately builds a 64-bit PE even though the agent is now 32-bit:
the game is 64-bit and binds steamclient64.dll in its own process, so probing
from a 32-bit host would measure the wrong side of the IPC.

Note when reading its output: interfaces are named by two different conventions.
The extension uses STEAMAPPS_INTERFACE_VERSION008 style, but User/Friends/Utils/
MatchMaking/Networking use SteamUser023 / SteamFriends017 style. Querying the
former spelling for the latter group returns NULL and means nothing.
Extends wn-iface-probe to measure what the client actually reports to the game,
through the same calls the game makes. Device result for Brawlhalla (291550):

  ISteamApps::GetAppBuildId()  = 24843973   (matches the live public build)
  ISteamApps::BIsSubscribed()  = 1
  GetHSteamUser=1 BLoggedOn=1 GetSteamID=76561198448983170
  GetAuthSessionTicket -> handle=2 length=234

So the client reports the correct version, the session is logged on as the right
account, and a signed auth session ticket is issued. Both were live theories for
the "INCORRECT VERSION" dialog and both are now disproven by measurement.

Uses the flat C API (SteamAPI_ISteamUser_*) rather than walking vtables. The
first attempt indexed the ISteamUser vtable directly and got GetHSteamUser and
BLoggedOn right but garbage from GetSteamID; "fixing" that by treating CSteamID
as an sret return crashed the probe (0xC0000354). CSteamID is trivially
copyable so it comes back in RAX - the original call was ABI-correct and index 2
simply is not GetSteamID on this build. The flat exports take the interface
pointer as an ordinary first argument and let the DLL resolve the layout, which
removes the guesswork entirely; GetSteamID then returns the real id.

Anchor checks earned their keep: they caught the bad layout before the probe
called a wrong index with a 2 KB output buffer.

GetEncryptedAppTicket reports rc=0 length=0 here, which is expected rather than a
finding - that API needs a prior RequestEncryptedAppTicket and a callback
round-trip, which this pass does not do. Worth testing properly, since the
encrypted app ticket is how a Steam game authenticates to a publisher backend,
and app access tokens for this appid are denied by Steam.
Adds the RequestEncryptedAppTicket -> RunCallbacks -> GetEncryptedAppTicket
round-trip to the probe. The encrypted app ticket is how a Steam game
authenticates to a publisher's own backend, which is what Brawlhalla does with
Ubisoft's servers, and it was the last untested Steam-side mechanism behind the
"INCORRECT VERSION" dialog.

Device result: RequestEncryptedAppTicket returns a call handle and
GetEncryptedAppTicket then yields rc=1 length=143 within 500ms. So it works, and
that theory is disproven too. The earlier rc=0 length=0 reading was an artifact
of calling the getter without the request first, not a defect.

The suspicion was that Steam denies app access tokens for this appid
("Requested 17 app access tokens, 0 received, 17 denied" in appinfo_log), and
that the encrypted app ticket would fail for the same reason. It does not - the
denial does not block ticket issuance.

Everything the game asks Steam for is now measured correct: build id 24843973,
BIsSubscribed, logged-on session with the right SteamID, a 234-byte auth session
ticket, a 143-byte encrypted app ticket, and all eleven interfaces served.
Banner's SteamLite v3 handoff pack (agent source, received 2026-08-30) states
the cause of Brawlhalla's "INCORRECT VERSION" as: the host fetches app-info
app-side and seeds the prefix appcache, then the in-Wine RequestAppInfoUpdate
fails because the CM denies PICS access tokens to a steam.exe-replacement agent,
and that failed refresh poisons the seeded build id so GetAppBuildId returns 0.
His fix is WN_STEAM_SKIP_APPINFO=1.

Tested here and that mechanism does not occur on WinNative. The probe now runs
immediately before the LaunchApp dispatch rather than right after ownership
sync, so it measures what the game will actually see, and it reports
GetAppBuildId = 24843973 - the correct live build - both with and without the
skip. Our build id is never 0, and setting the flag changed nothing. The flag is
therefore not adopted: Banner's own RealSteamLauncher leaves it off by default
"so the proven VAC launch path (which relies on the refresh) is unchanged", and
enabling it would be an unverified behaviour change for Dota 2 and TF2, which
currently work.

Probe position matters and is the reason this was worth measuring: the earlier
placement ran before the appinfo phase, so a poisoned build id would have been
invisible to it. Keep it adjacent to the dispatch.

Also adds an optional debug CA import to the probe (gated on C:\wn-mitm-ca.der)
for intercepting a game's HTTPS. It does not help for Adobe AIR titles: AIR
bundles its own OpenSSL (opensslsocket.cpp / OpenSSL_CryptImpl.cpp strings in
Adobe AIR.dll) and compiles in its roots, so it never consults Wine's schannel
store. Kept because it is correct for games that do use schannel.
Both come from Banner's SteamLite v3 handoff pack (agent source + host
reference, received 2026-08-30). Diffing it against ours showed only five real
differences; these are the two that could plausibly affect behaviour.

Controller passthrough (RealSteamLauncher.applyControllerPassthrough parity),
applied when staging the prefix:
  - delete GameOverlayRenderer.dll / GameOverlayRenderer64.dll from the staged
    Steam dir - their FEX HID hook breaks DirectInput
  - set SteamController_{XBox,GenericGamepad,PS,Switch}Support to "0" in
    userdata/<accountid>/config/localconfig.vdf so Steam Input stops reserving
    XInput slots

Measured on device, and the second lever does NOT work here: the in-guest Steam
client rewrites localconfig.vdf during launch and drops the keys. Verified twice
- once written at stage time, once written directly with the app stopped - and
both times the key count went 4 to 0, with the file's mtime moving to ~1 minute
after the write. The first lever is a correct no-op for us since our package
ships no overlay DLLs. Kept because it is right for prefixes that do have them,
but it is not a working passthrough yet; making it stick needs a hook after the
client has loaded its config, not before.

Minimal ACF (SteamLite's ACF shape: no buildid, empty InstalledDepots, just
appid/universe/LauncherPath/name/StateFlags/installdir/LastOwner + language),
gated on the marker C:\wn-minimal-acf.on so it can be A/B'd without a rebuild.
Default is unchanged.

Result is genuinely interesting but not a fix. On the first run Brawlhalla got
PAST the version check for the first time ever - its loading sequence showed
"Launched Client / Loading Game Files 9% / Connecting to Brawlhalla Servers" and
it opened a connection to AWS 34.202.88.182, having previously never gone beyond
api.brawlhalla.com. It then froze. The next two runs gave a hard freeze
(rchar static across 12s) and then INCORRECT VERSION again. So the ACF shape
does influence the version verdict, but the minimal form is unstable and also
lets the client write back buildid=0 and SizeOnDisk=0.

Left disabled by default for that reason. Note this inverts Banner's stated
mechanism: he says buildid=0 causes the error, but here the run that got
furthest was the one with buildid=0.
The preceding commit said the minimal-ACF run "got PAST the version check for
the first time ever" and reached AWS 34.202.88.182 "having previously never gone
beyond api.brawlhalla.com". Both statements are wrong.

The loading checklist that run reached ("Launched Client / Loading Game Files 9%
/ Connecting to Brawlhalla Servers") appears EARLY in startup. The INCORRECT
VERSION dialog appears later - in every baseline run it is drawn over the Code of
Conduct screen, after loading completes. So that run froze during loading and
never reached the version check at all, which is worse than baseline rather than
progress.

34.202.88.182 was likewise not new: it is present in the baseline /proc/net
endpoint capture taken before any of these changes. The real backend
(166.117.x.x, AWS Global Accelerator) was absent both before and after.

So the minimal ACF is a pure regression - two runs froze during loading, the
third reproduced INCORRECT VERSION - and the claim that ACF shape influences the
version verdict is not supported by this evidence. It stays disabled by default.
LaunchApp was returning EAppUpdateError=16 and the launcher silently fell
back to CreateProcess, giving an insecure, VAC-ineligible session with no
indication to the user. Reverse-engineering steamclient64.dll shows 16 is
"Application running": the client still has a game registered, usually left
by a session that died without Steam_LogOff, and it refuses to start a
second one. 35 is the separate "Other session playing" case.

Detect both before dispatching LaunchApp and again on those two errors,
surface the blocking game by name, and let the user clear it:
ShutdownApp for a stale local registration, BKickOtherPlayingSession for
another device. If the user declines or the state cannot be cleared, abort
rather than downgrade the launch.

Slot indices and calling conventions were taken from the shipped binaries
and verified identical for x86 and x64: GetRunningGameID returns CGameID
through an sret pointer, and BIsOtherSessionPlaying ends "ret 4", so both
are called through WN_THISCALL.

Also drop kVtAppMgr_RefreshAppInfo, which pointed at slot 83
RefreshLibraryFolders; no interface in this build has a RefreshAppInfo, so
every launch was asking Steam to rescan its library folders right before
LaunchApp. The MissingConfig retry now calls IClientApps
RequestAppInfoUpdate, which is what the readiness path already used.

Replace the hand-written error legend with the 58-entry EAppUpdateError
table read out of the client, so launch failures name themselves.

docs/steam holds the reverse-engineering reference: 54 interfaces, 2029
slot-indexed methods, and the extraction method.
IClientAppManager slot 21, bool(AppId_t), signature read off the shipped
stubs and identical for x86 and x64.

It does not discriminate: on device it returns 0 for Brawlhalla, which
fails its own version check, and also 0 for Team Fortress 2, which
launches and plays. Our installs are a symlink plus a hand-written
appmanifest rather than a Steam-managed install, so the client has no
depot bookkeeping to compare against and reports everything as out of
date. Keep it as a diagnostic line, do not gate a launch on it.
Adds a marker-gated pass to the interface probe that walks Brawlhalla's
eight AIR native extensions, loads each one, and reports the result along
with the shared libraries they depend on.

Load the AIR runtime first and use LOAD_WITH_ALTERED_SEARCH_PATH: every
ANE imports from "Adobe AIR.dll", so a plain LoadLibraryA on one fails
with ERROR_MOD_NOT_FOUND and all eight look broken when nothing is wrong.

On device seven of the eight load, DnaManager among them with its
DnaInitializer export present, and every dependency resolves. EpicAir is
the exception; it wants EOSSDK-Win64-Shipping.dll, which the Steam build
does not ship.
The probe sets SteamAppId and calls SteamAPI_Init, which registers the
probe process itself with the Steam client as a running instance of the
game. It ran immediately before LaunchApp, so the client correctly
refused with EAppUpdateError=16 "Application running" and the launcher
fell back to an insecure CreateProcess.

Across the captured logs the correlation is exact: every run where the
probe reached SteamAPI_Init returned 16 and fell back; every run without
it returned 0 and launched secure. The blocking pid reported by the
new stale-session prompt was the probe's own.

Probe mode is now exclusive - when the marker is present the probe runs
and the launch is skipped, because the two cannot share a client session.

Also stop an insecure launch presenting as a successful one. The tailer
counted "game process started pid=" as terminal, so a CreateProcess
fallback fired onLaunchComplete and looked identical to a secure launch.
It now raises a warning naming the game, unless the user chose direct-exe
mode, where CreateProcess is expected.
The agent bitness was pinned to 32-bit for every container by
wnSteamAgent32 defaulting true, with no regard for the prefix. Both
prefixes we ship are 64-bit Windows, so the 64-bit agent is the natural
match: it loads the same steamclient64.dll the games themselves load
rather than driving the client through the 32-bit build over IPC.

Device-verified on the arm64ec prefix (ntdll machine 0xaa64): the agent
now stages as steam.exe, loads steamclient64.dll at a 64-bit base, takes
an app ownership ticket and gets EAppUpdateError=0 with a secure launch
for Team Fortress 2. The 32-bit agent remains verified on both the
arm64ec and x86-64 prefixes.

The pref lives in EncryptedSharedPreferences and can only be reached from
the settings UI, which made the 64-bit path untestable from a shell.
A .wn_steam_agent_64 marker in filesDir now forces 64-bit, mirroring the
existing .wn_steam_agent_32, and takes precedence over both the marker
and the pref.
The Steam path forced Wine module/loaddll logging on even when the user had
debug logging off, and left steam.exe and steamservice.exe outside the
container's CPU affinity policy while every other Wine service was pinned.

Recovering the IClientEngine vtable (RVA 0x13151B0) shows all 80 slots: 55 are
interface getters that differ only by a cached-pointer field offset, filled by
the container Init at 0xA0A650 (per-user) and 0x95779B (per-pipe). Resolving
each factory's vtable through RTTI names 49 of the 54 documented interfaces and
reproduces all 5 previously known slots unchanged. The other 5 live in
steamservice.dll behind its own pipe, which is why their ipc ids collide with
client-pipe ones.

Adds IClientUser::BGameConnectTokensAvailable to the readiness log: without
game-connect tokens a game cannot authenticate to a secure server even when the
launch itself is secure.
Valve ships steamservice.dll as i386 only — every copy across every client
version available here is machine 0x14c, as are Steam.dll and Steam2.dll, while
steamclient/tier0_s/vstdlib_s each ship both. That is the shape of the real
product: the client host process is 32-bit, and steamclient64.dll exists to be
loaded into 64-bit game processes, which the device logs confirm the game does
on its own.

So a 64-bit host cannot load the service DLL (GLE 193 ERROR_BAD_EXE_FORMAT), and
since the channel to the elevated host is a CCrossProcessPipe the client creates
during that same sequence, the pipe cannot be provisioned any other way. The
five service-side interfaces are simply unreachable. Measured, agent bitness is
the only variable: 32-bit preloads the DLL, 64-bit fails.

Reverts the default flipped in 0b96398. The .wn_steam_agent_64 marker still
forces the 64-bit agent, now with a documented cost.
"Failed to create Service pipe (GLE 2)" was ours. The client resolves its Steam
Service connection from the SteamClientService environment variable, and the
bionic-steam block publishes SteamClientService=127.0.0.1:57344 for every Steam
launch — an endpoint served by wn-libsteamclient's tcp_services, which Plan W
does not run. So the real client dialled a dead port instead of using its own
service connection, then failed the seven-slot check with "Failed to connect to
Steam Service".

Unset it under Plan W. The 32-bit host fix in fd6b34d was necessary but not
sufficient: it cleared the GLE 193 load failure and the service now starts and
writes its own log, but the client still could not reach it.
…alls

The agent now drives Steam Cloud through Valve's own IClientRemoteStorage
instead of the Android-side reimplementation, and several launch-path stalls
are gone.

Cloud sync
- Call EvaluateRemoteStorageSyncState before reading sync state. Without it
  GetLastKnownSyncState returns kSyncUnknown forever, which is why launch
  sync never converged. Signature (AppId_t, bool) was derived from the 32-bit
  steamclient.dll: the vtable stub ends in ret 0x8 and marshals a 4-byte
  appid plus a 1-byte bool. The previous one-argument call corrupted the
  stack under __thiscall and faulted.
- Give every RemoteStorage vtable typedef WN_THISCALL. On x86 __thiscall
  passes this in ECX; the plain pointers passed garbage and crashed the
  launcher before LaunchApp ever ran.
- Break out of the settle loop as soon as the state reads synchronized.
  IsAppSyncInProgress stays true long after the transfer ends and was
  burning the full 60s budget on exit.
- Log the RemoteStorage vtable RVA and IsCloudEnabledForAccount/App so a
  wrong interface or a disabled app is visible immediately.

Launch speed
- Run the steam_api genuineness scan on a background thread and join it
  right before LaunchApp. It walks the whole install tree (56k entries for
  a 10GB game) and was blocking the critical path for 15.8s.
- Drop the post-miss appinfo wait to 400ms for apps that have never
  delivered AppInfoUpdateComplete_t.
- Claim an install script's directories when its hasrunkey says the entry
  is already installed. Only the run path claimed them, so the redist scan
  re-ran installers every launch; DXSETUP.exe was failing on each one.

Stuck launches
- Track whether Steam registers the app as running. If it never registers,
  the dispatch did not take effect; if it registers and then de-registers
  without the process appearing, the game started and crashed. Either way
  stop waiting instead of burning the full 120s.

Exit
- Raise the launcher shutdown handshake ceiling to 90s so the container is
  not torn down mid-upload, and give the game 10s to flush its save before
  the hard kill.
- Drive the closing screen from the launcher log: waiting for the game to
  save, uploading, uploaded.

Diagnostics
- Timestamp every launcher log line and stamp the build date and arch on
  the first line, so a stale staged binary is obvious.
nsiproxy was set to demand-start (Start=3) by the service-trimming pass, and
at startupSelection 2 it was disabled outright. It is the driver that creates
\Device\Nsi, and nothing demand-starts a driver, so nsi.dll's CreateFileW on
\\.\Nsi failed and every NSI-backed API returned ERROR_FILE_NOT_FOUND with no
results: GetAdaptersInfo, GetAdaptersAddresses, GetIfTable, GetIpAddrTable,
GetNetworkParams. Containers looked like machines with no network cards.

Set nsiproxy to auto-start and add it to the forced-auto-start list so the
aggressive presets cannot disable it, and bump LAUNCH_REGISTRY_POLICY_VERSION
so existing containers re-apply instead of skipping on an unchanged stamp.

Add libnetshim.so, preloaded ahead of libfakeinput.so so its ioctl hook wins
the symbol lookup, forwarding everything else down via RTLD_NEXT. Android
denies SIOCGIFHWADDR to app uids (rc=-1), which left every interface at
MIB_IF_TYPE_OTHER with a zero-length MAC, so the shim substitutes a stable
per-interface address derived from ANDROID_ID and reports lo as
ARPHRD_LOOPBACK so GetAdaptersInfo excludes it. getifaddrs and if_nameindex
fall back to synthetic entries only when the real ones come back empty.

Fix the probe's adapter dump to use IP_ADAPTER_INFO instead of hardcoded
32-bit field offsets, which read garbage in a 64-bit PE and always printed
addrlen=0.

Measured on device: GetAdaptersInfo goes from rc=2 with 0 adapters to rc=0
with 5, type 6 (ETHERNET), addrlen 6, wlan0 carrying the real LAN address.

This does not resolve Brawlhalla's INCORRECT VERSION, which persists with
adapters and MACs fully restored; the MAC-derived hardware-id theory is
refuted. Filed separately: changeServicesStatus's registry writes do not
land, so this policy change is not yet effective without a manual system.reg
edit, and winehid and HTTP are affected the same way.
Capturing Brawlhalla's login previously needed a rooted tcpdump, which meant
only the phone could be used. netshim already loads into every Wine process,
so hooking send/sendto/sendmsg/recv/recvmsg and filtering on peer port via
getpeername gives the same visibility with no root and works on the tablet.

Gated behind WN_NET_TAP=1 and capped at 400 events. Only the socket calls are
hooked, deliberately not read/write, so libfakeinput's evdev path is untouched.

First capture, with 5 adapters and real MACs present:

  OUT 80 14 | 00 00 00 00 | 02 5f | 00 ... 0e "MaxsTechReview"
  IN  00 10 | 00 23 | 00 20 | "Incorrect Version, Please Update"

0x025f is 607, identical to the pre-MAC-fix body, and the 0x40 presence flag
in body byte 1 is still clear. The 16-byte optional field is therefore not
MAC-derived - that theory is now refuted by measurement rather than inference.
Brawlhalla is 32-bit and reaches Steam through steam_api.dll, while the
existing probe was 64-bit on steam_api64.dll - an untested-different path that
could have explained the empty app ticket the DNA gate rejects. Build the probe
for both arches, select the DLL by target arch, and have the launcher run each
into its own log.

Also report ISteamUtils::GetAppID (the pipe's app context) and call
GetEncryptedAppTicket once before any RequestEncryptedAppTicket, which is what
SteamAir sees if it never issues a request of its own.

Result: both arches report app context 291550 and 143 bytes, before and after
requesting. The ticket is globally visible once the launcher pre-warm has run,
so neither arch nor pipe context nor a missing request explains the gate.
…Cs, add Networking settings

Brawlhalla's Steam extension (SteamAir.dll) builds a hardware id from the first network
adapter whose MAC passes a validity check and embeds it in the encrypted app ticket it
requests from Steam; the game server rejects a login whose ticket lacks it with the generic
"Incorrect Version, Please Update". The check drops virtualisation OUIs and any address whose
first byte has the multicast or locally-administered bit set. Every MAC libnetshim synthesized
began with 0x02, so all adapters were discarded and the login frame was 16 bytes shorter than a
desktop client's. Proven by proxying the extension on the working desktop copy and the tablet:
the call sequences are identical up to the login, differing only by those 16 bytes, and the
validator was read straight out of the disassembly (OUI blacklist 00:50:56 00:05:69 00:0c:29
00:1c:14 00:03:ff 00:0f:4b 00:16:3e, then testb $3 on the first byte).

netshim
- Derive synthesized MACs from a table of real vendor OUIs plus three hash bytes instead of
  02:xx; treat kernel MACs with the multicast or locally-administered bits as unusable.
- Honour WN_NET_MAC for the uplink interfaces (wlan*, eth*, rmnet*, ccmni*, usb*) and
  WN_NET_SHIM=0 to disable every substitution; reject malformed or locally-administered values.
- Prefix log lines with milliseconds since load; gate connect/getaddrinfo logging on WN_NET_TAP.

Settings
- New Networking section in both the container and the shortcut settings: network driver
  (WinNative Network Driver, on by default for every container, or Wine default) and an
  optional MAC address with validation and the automatic address shown. Stored as the
  netDriver/netMac extras with the usual shortcut override semantics and applied at launch
  through NetworkingSettings.applyEnv.

Launcher agent
- The launch-time cloud sync drained the callback queue with an unbounded loop; when the client
  keeps re-posting callbacks (seen after an unclean exit) it never returned and the preloader sat
  on "Syncing Steam Cloud saves" indefinitely. Cap the drain per tick, log a heartbeat every 5s,
  and break out as soon as Steam reports the sync disabled, not only synchronized.

docs/networking-driver.md describes the driver, the settings and the environment contract.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The Steam launcher re-ran VC_redist.x86/x64.exe before LaunchApp (~136s
each under translation), freezing the preloader on "Verifying install"
even though the VC++ runtime was already seeded. Detect the installed
runtime and mark the installscript and redist-scan entries done without
running the installer. Map the install/redist phases to preloader text so
a real install shows "Installing Microsoft Visual C++ ..." not a stale phase.
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