From 99c363c7da11c69cd251525ca42d7f10fbcd65fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Monge?= Date: Sun, 8 Nov 2026 20:39:50 -0300 Subject: [PATCH] Steam join: resolve lobby in-process, drop RestartAppIfNecessary in worker Joining a Steam P2P lobby always spawned the separate worker process, which must load steam_api64.dll and init Steam again. On some installs that throws DllNotFoundException('steam_api64'), and when the DLL is found the worker's SteamAPI.RestartAppIfNecessary can make Steam relaunch the game, after which the real game fails "Steam init" until Steam is restarted. - Resolve the lobby in the game process first (TryRunJoin -> TryRunJoinInProcess), using the SteamAPI the game already initialized, like the in-process P2P host. It runs on the existing background task, never takes the pump lock or calls RunCallbacks off the main thread; the worker is only a fallback when in-process Steam is unavailable. - Remove RestartAppIfNecessary from the worker; set SteamAppId/SteamGameId. - Preload steam_api64.dll by full path in PrepareSteamNativePath. --- SteamP2P/SteamConnect.cs | 112 ++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/SteamP2P/SteamConnect.cs b/SteamP2P/SteamConnect.cs index 9d87dd22..c8b76323 100644 --- a/SteamP2P/SteamConnect.cs +++ b/SteamP2P/SteamConnect.cs @@ -262,13 +262,13 @@ internal static bool TryResolveJoinEndpointFromClipboard(out JoinLobbyResult res LobbyCode = lobbyCode }; - var workerSucceeded = TryRunWorker(request, out var response); + var workerSucceeded = TryRunJoin(request, out var response); if (!workerSucceeded && IsTransientJoinWorkerError(response.Error)) { for (var attempt = 0; attempt < JoinWorkerRetryCount; attempt++) { Thread.Sleep(JoinWorkerRetryDelayMs); - workerSucceeded = TryRunWorker(request, out response); + workerSucceeded = TryRunJoin(request, out response); if (workerSucceeded || !IsTransientJoinWorkerError(response.Error)) break; } @@ -342,13 +342,13 @@ internal static bool TryResolveJoinEndpointFromLobbyId(ulong lobbyId, out JoinLo LobbyCode = string.Empty }; - var workerSucceeded = TryRunWorker(request, out var response); + var workerSucceeded = TryRunJoin(request, out var response); if (!workerSucceeded && IsTransientJoinWorkerError(response.Error)) { for (var attempt = 0; attempt < JoinWorkerRetryCount; attempt++) { Thread.Sleep(JoinWorkerRetryDelayMs); - workerSucceeded = TryRunWorker(request, out response); + workerSucceeded = TryRunJoin(request, out response); if (workerSucceeded || !IsTransientJoinWorkerError(response.Error)) break; } @@ -696,6 +696,71 @@ private static bool TryRunHostWorker(WorkerRequest request, out WorkerResponse r } } + /// + /// Local patch: resolve the Steam lobby inside the game process, using the SteamAPI the + /// modded game already initialized (the same approach the P2P host already uses). The + /// separate worker process re-loads steam_api64.dll and re-inits Steam, which fails on + /// some installs (DllNotFoundException) and can make Steam relaunch the game. The worker + /// is kept only as a fallback when in-process Steam is unavailable. + /// + private static bool TryRunJoin(WorkerRequest request, out WorkerResponse response) + { + if (TryRunJoinInProcess(request, out response)) + return true; + + // A definitive answer from Steam (wrong build, own lobby, not joinable...) must not + // be retried through the worker; only fall back when in-process Steam is unusable. + if (!response.Error.StartsWith("[in-process unavailable]", StringComparison.Ordinal)) + return false; + + ModEntry.Instance?.Logger?.Warning("[NetMod][Steam] In-process join unavailable ({Error}); falling back to worker", response.Error); + return TryRunWorker(request, out response); + } + + private static bool TryRunJoinInProcess(WorkerRequest request, out WorkerResponse response) + { + response = new WorkerResponse + { + Success = false, + Error = "[in-process unavailable] SteamAPI is not initialized in the game process" + }; + + try + { + if (!ModEntry.EnsureSteamApiForNetworking("in-process lobby join")) + return false; + + ModEntry.Instance?.Logger?.Information("[NetMod][Steam] Resolving lobby in-process (lobbyId={LobbyId})", request.LobbyId); + // Do NOT take the Steam pump lock or call SteamAPI.RunCallbacks from this + // background thread: the game's main-thread title/frame hooks keep pumping + // callbacks, and off-main-thread RunCallbacks can crash the game (0xC0000005). + s_inProcessJoinThread = true; + try + { + response = ExecuteJoin(request); + } + finally + { + s_inProcessJoinThread = false; + } + ModEntry.Instance?.Logger?.Information( + "[NetMod][Steam] In-process lobby resolve result: ok={Ok} hostSteamId={HostSteamId} error={Error}", + response.Success, + response.HostSteamId, + response.Error); + return response.Success; + } + catch (Exception ex) + { + response = new WorkerResponse + { + Success = false, + Error = "[in-process unavailable] " + ex.GetType().Name + ": " + ex.Message + }; + return false; + } + } + private static bool TryRunWorker(WorkerRequest request, out WorkerResponse response) { response = new WorkerResponse @@ -946,14 +1011,11 @@ private static WorkerResponse ExecuteWorkerRequest(WorkerRequest request, string responseWritten = false; PrepareSteamNativePath(); - if (SteamAPI.RestartAppIfNecessary(new AppId_t(DeadCellsAppId))) - { - return new WorkerResponse - { - Success = false, - Error = "Steam requested app restart" - }; - } + // Local patch: RestartAppIfNecessary removed. In a helper process it can make Steam + // relaunch Dead Cells, which leaves Steam in a state where the real game then fails + // "Steam init". The worker gets SteamAppId via its environment instead. + Environment.SetEnvironmentVariable("SteamAppId", DeadCellsAppId.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("SteamGameId", DeadCellsAppId.ToString(CultureInfo.InvariantCulture)); if (!SteamAPI.Init()) { @@ -1559,8 +1621,15 @@ private static bool TryResolveLobbyIdByCode(string lobbyCode, out ulong lobbyId, /// FileNotFoundException('GameProxy') on every join. Pumping SteamAPI directly here keeps /// the worker self-contained and touches nothing from the main mod assembly. /// + [ThreadStatic] + private static bool s_inProcessJoinThread; + private static void RunWorkerSteamCallbacks() { + // In-process join: the game's main thread pumps Steam callbacks; just wait. + if (s_inProcessJoinThread) + return; + if (!Monitor.TryEnter(s_workerSteamCallbackLock)) return; try @@ -1766,6 +1835,7 @@ private static void PrepareSteamNativePath() continue; SetDllDirectory(path); + PreloadSteamApi(steamApiPath); return; } catch @@ -1778,6 +1848,7 @@ private static void PrepareSteamNativePath() try { SetDllDirectory(fallbackPath); + PreloadSteamApi(Path.Combine(fallbackPath, steamApiName)); } catch { @@ -1785,6 +1856,23 @@ private static void PrepareSteamNativePath() } } + /// + /// Local patch: SetDllDirectory is ignored when Steamworks.NET's DllImport uses the safe + /// LOAD_LIBRARY_SEARCH_* flags, so the worker threw DllNotFoundException('steam_api64'). + /// Loading the module by full path first makes every later by-name load reuse it. + /// + private static void PreloadSteamApi(string steamApiPath) + { + try + { + if (File.Exists(steamApiPath)) + NativeLibrary.Load(steamApiPath); + } + catch + { + } + } + private static IEnumerable GetSteamNativeSearchPaths() { var seen = new HashSet(StringComparer.OrdinalIgnoreCase);