using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using System.Threading; using BD2.GameNames; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; using static BD2.GameNames.Game; using static Bd2LoginUI.LoginPanel; using static Bd2LoginUI.LoginRuntime; using static Bd2LoginUI.ControlRequests; using static Bd2LoginUI.LoginController; namespace Bd2LoginUI; // Owns recovery generations, monitor coroutines, game relay and recovery overlay. internal static class SessionRecovery { internal static int SessionRecoveryInProgress; private static RecoveryHost Owner; private static SecureGameRelay GameRelay; private static readonly List> GameRequests = new List>(); internal static bool EstablishedGameSession; private static GameObject RecoveryOverlay; private static Text RecoveryText; internal static string ServerInstanceID; private static int RecoveryRestartScheduled; private static int RecoveryEnterScheduled; internal static int RecoveryGeneration; internal static int RuntimeProbeFailures; internal static CancellationTokenSource ControlProbeCancellation = new CancellationTokenSource(); internal static void DisposeGameRelay() { SecureGameRelay relay = GameRelay; GameRelay = null; relay?.Dispose(); } internal static void EnsureGameRelay() { if (GameRelay == null && ServerRoot != null && ServerRoot.Scheme == Uri.UriSchemeHttps && PlatformControlHttp.IsSupported) { GameRelay = SecureGameRelay.Start(ServerRoot, Log); } } private static bool TryGetOwnedRequestUri(string url, out Uri canonical) { canonical = null; if (!Uri.TryCreate(url, UriKind.Absolute, out Uri parsed)) return false; if (GameRelay != null && GameRelay.TryResolve(parsed, out Uri original)) parsed = original; if (ServerRoot == null || !SameOrigin(ServerRoot, parsed)) return false; canonical = parsed; return true; } private static void CancelControlProbes() { CancellationTokenSource previous = ControlProbeCancellation; ControlProbeCancellation = new CancellationTokenSource(); previous.Cancel(); previous.Dispose(); } internal static void IntroAwakePostfix(object __instance) { try { EnsureRecoveryHost(); } catch (Exception ex) { Log?.LogError("Could not restore login recovery host: " + ex); } ConfigureLoginPanel(__instance); } // The BepInEx plugin component can disappear while its static Harmony hooks remain. // Keep coroutines on a scene-independent host and recreate it if Unity destroys it. internal static void EnsureRecoveryHost() { if (Owner != null && Owner.gameObject.activeInHierarchy) { return; } if (Owner != null) { UnityEngine.Object.Destroy(Owner.gameObject); } GameObject host = new GameObject("BD2 Login Recovery Host"); UnityEngine.Object.DontDestroyOnLoad(host); Owner = host.AddComponent(); Log?.LogInfo("Created persistent login recovery coroutine host"); CancelControlProbes(); Interlocked.Increment(ref RecoveryGeneration); Interlocked.Exchange(ref RecoveryRestartScheduled, 0); Interlocked.Exchange(ref RecoveryEnterScheduled, 0); Owner.StartCoroutine(RuntimeMonitor()); } public sealed class RecoveryHost : MonoBehaviour { internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource(); private void OnDestroy() { Lifetime.Cancel(); Lifetime.Dispose(); Log?.LogWarning("Login recovery coroutine host was destroyed"); } } internal static void SendWebRequestPrefix(UnityWebRequest __instance) { if (__instance == null || !TryGetOwnedRequestUri(__instance.url, out Uri canonical)) return; bool gameRequest = canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal); bool telemetryRequest = canonical.AbsolutePath.Equals("/logs", StringComparison.Ordinal); if (!gameRequest && !telemetryRequest) return; EnsureGameRelay(); if (gameRequest) { GameRequests.RemoveAll(reference => !reference.TryGetTarget(out _)); if (GameRequests.Count >= 128) GameRequests.RemoveAt(0); GameRequests.Add(new WeakReference(__instance)); } if (GameRelay != null) { __instance.url = GameRelay.Rewrite(canonical).AbsoluteUri; } } internal static void DisposeWebRequestPrefix(UnityWebRequest __instance) { if (__instance == null || !__instance.isDone) { return; } GameRequests.RemoveAll(reference => !reference.TryGetTarget(out UnityWebRequest request) || ReferenceEquals(request, __instance)); InspectCompletedGameRequest(__instance); } private static void InspectCompletedGameRequest(UnityWebRequest request) { try { if (!IsCurrentGameRequest(request, out Uri requestUri)) { return; } if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) && request.responseCode >= 200 && request.responseCode < 300) { EnsureRecoveryHost(); EstablishedGameSession = true; SetRecoveryMessage("正在同步玩家数据……\nSynchronizing player data…"); return; } bool accessExpired = requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) && request.responseCode == 401 && string.Equals(request.GetResponseHeader("X-BD2-Access-Expired"), "1", StringComparison.Ordinal); if (accessExpired) { AccessTokens.Clear(); BeginSessionRecovery("expired game access credential"); return; } bool sessionExpired = request.responseCode == 401 && string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal); bool serverRestarting = request.responseCode == 503 && string.Equals(request.GetResponseHeader("X-BD2-Reconnect"), "1", StringComparison.Ordinal); if (!sessionExpired && !serverRestarting) { return; } BeginSessionRecovery(serverRestarting ? "server restart" : "expired game session"); } catch (Exception ex) { Log?.LogError("Could not inspect the completed game request: " + ex); } } private static bool IsCurrentGameRequest(UnityWebRequest request, out Uri requestUri) { requestUri = null; if (request == null || !TryGetOwnedRequestUri(request.url, out Uri canonical) || !canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal)) return false; requestUri = canonical; return true; } internal static bool BeginSessionRecovery(string reason) { try { if (ServerRoot == null) { Log?.LogWarning("Cannot recover game session without a configured server"); return false; } EnsureRecoveryHost(); if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0) { Log?.LogWarning("Starting automatic game-session recovery: " + reason); ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…"); } else { Log?.LogWarning("Restarting automatic game-session recovery: " + reason); } ScheduleRecoveryRestart(); return true; } catch (Exception ex) { FinishRecovery(false, "could not start recovery: " + ex); return false; } } internal static bool ExponentialBackoffPrefix(object __0) { try { if (ServerRoot == null || !IsConfiguredServerFailure(__0) || !IsTransportFailure(__0)) { return true; } if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { return !BeginSessionRecovery("transport failure during recovery"); } return !EstablishedGameSession || !BeginSessionRecovery("transport failure"); } catch (Exception ex) { Log?.LogError("Could not classify game request failure: " + ex); return true; } } private static bool IsTransportFailure(object packetException) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; object packet = packetException.GetType().GetGameProperty("PacketData", flags)?.GetValue(packetException, null); string server = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string; string path = packet?.GetType().GetGameProperty("SendPath", flags)?.GetValue(packet, null) as string; if (!Uri.TryCreate(server + path, UriKind.Absolute, out Uri packetUri)) { return false; } UnityWebRequest match = null; foreach (WeakReference reference in GameRequests) { if (!reference.TryGetTarget(out UnityWebRequest request) || !request.isDone || !TryGetOwnedRequestUri(request.url, out Uri requestUri) || requestUri != packetUri) { continue; } if (match != null) { return false; // Ambiguous concurrent requests must retain client error handling. } match = request; } // HTTP errors (including 501) are ProtocolError, even though the game calls // ExponetialBackOff for every non-Success result. Do not infer status from error text. return match != null && match.result == UnityWebRequest.Result.ConnectionError; } private static bool IsConfiguredServerFailure(object packetException) { if (packetException == null || ServerRoot == null) { return false; } const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; Type exceptionType = packetException.GetType(); string url = exceptionType.GetGameProperty("Url", flags)?.GetValue(packetException, null) as string; object packet = exceptionType.GetGameProperty("PacketData", flags)?.GetValue(packetException, null); string requestServer = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string; foreach (string candidate in new[] { requestServer, url }) { if (string.IsNullOrWhiteSpace(candidate)) { continue; } if (!Uri.TryCreate(candidate, UriKind.Absolute, out Uri parsed)) { parsed = new Uri(ServerRoot, candidate.TrimStart('/')); } if (SameOrigin(ServerRoot, parsed)) { return true; } } return false; } private static void ScheduleRecoveryRestart() { CancelControlProbes(); Interlocked.Increment(ref RecoveryGeneration); AuthenticationLoadingOrigin = null; LoginInProgress = false; ContinueMaintenance = false; EstablishedGameSession = false; RuntimeProbeFailures = 0; ServerInstanceID = null; Interlocked.Exchange(ref RecoveryEnterScheduled, 0); if (Interlocked.CompareExchange(ref RecoveryRestartScheduled, 1, 0) == 0) { Owner.StartCoroutine(StartLatestRecoveryGeneration()); } } private static IEnumerator StartLatestRecoveryGeneration() { int generation; do { generation = Volatile.Read(ref RecoveryGeneration); yield return new WaitForSecondsRealtime(0.25f); } while (generation != Volatile.Read(ref RecoveryGeneration)); Interlocked.Exchange(ref RecoveryRestartScheduled, 0); if (Volatile.Read(ref SessionRecoveryInProgress) != 0) { Owner.StartCoroutine(WaitForServerAndRestart(generation)); } } private static IEnumerator WaitForServerAndRestart(int generation) { float delay = 0.5f; string lastError = null; CancellationToken lifetime = Owner.Lifetime.Token; while (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation == Volatile.Read(ref RecoveryGeneration)) { SetRecoveryMessage("等待服务器启动……\nWaiting for server…"); ControlProbeResult result = null; yield return RequestControlEndpoint(new Uri(ServerRoot, "readyz"), UnityWebRequest.kHttpVerbGET, null, null, 6, lifetime, ControlProbeCancellation.Token, response => result = response); if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || lifetime.IsCancellationRequested) yield break; if (result.Success && result.StatusCode == 200) break; if (result.Error != lastError) { Log?.LogWarning("Server readiness probe failed: " + result.Error); lastError = result.Error; } yield return new WaitForSecondsRealtime(delay); delay = Math.Min(delay * 2f, 5f); } if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } Log?.LogInfo("Server became ready; restarting client to restore session"); SetRecoveryMessage("正在恢复登录会话……\nRestoring session…"); if (!RestartClientForRecovery()) { FinishRecovery(false, "client restart methods are unavailable", generation); } } private static bool RestartClientForRecovery() { BDNetwork.NetworkManager network = FindUnitySingleton(); AppManager app = FindUnitySingleton(); MethodInfo refresh = network?.GetType().GetGameMethod(nameof(BDNetwork.NetworkManager.Refresh), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); MethodInfo restart = app?.GetType().GetGameMethod(nameof(AppManager.AppReStart), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); if (refresh == null || restart == null) { return false; } AuthenticationLoadingOrigin = null; LoginInProgress = false; ContinueMaintenance = false; Interlocked.Exchange(ref RecoveryEnterScheduled, 0); ServerInstanceID = null; refresh.Invoke(network, null); restart.Invoke(app, null); return true; } private static IEnumerator RuntimeMonitor() { WaitForSecondsRealtime interval = new WaitForSecondsRealtime(5f); while (true) { if (ServerRoot == null || !EstablishedGameSession || Volatile.Read(ref SessionRecoveryInProgress) != 0) { yield return interval; continue; } int generation = Volatile.Read(ref RecoveryGeneration); CancellationToken lifetime = Owner.Lifetime.Token; { ControlProbeResult result = null; yield return RequestControlEndpoint(new Uri(ServerRoot, "client/runtime"), UnityWebRequest.kHttpVerbGET, null, null, 6, lifetime, ControlProbeCancellation.Token, response => result = response); if (lifetime.IsCancellationRequested) yield break; if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || Volatile.Read(ref SessionRecoveryInProgress) != 0) continue; if (!result.Success) { Log?.LogWarning("Server runtime probe failed: " + result.Error); RuntimeProbeFailures++; if (RuntimeProbeFailures >= 3) { BeginSessionRecovery("runtime probe failed three consecutive times"); } } else { RuntimeProbeFailures = 0; RuntimeStatus status = null; try { status = JsonUtility.FromJson(result.Body); } catch (Exception ex) { Log?.LogWarning("Server runtime response was invalid: " + ex.Message); } if (status != null && !string.IsNullOrEmpty(status.instance_id)) { if (ServerInstanceID == null) { ServerInstanceID = status.instance_id; } else if (ServerInstanceID != status.instance_id || status.status == "draining") { ServerInstanceID = status.instance_id; BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed"); } } } } yield return interval; } } internal static bool SuppressNetworkErrorDuringRecovery() { return Volatile.Read(ref SessionRecoveryInProgress) == 0; } internal static void SetIntroStatePostfix(object __instance, object __0) { if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || __0 == null || Convert.ToInt32(__0) != 9 || Owner == null) { return; } if (Interlocked.CompareExchange(ref RecoveryEnterScheduled, 1, 0) != 0) { return; } Owner.StartCoroutine(EnterAfterAuthoritativeLoad(__instance, Volatile.Read(ref RecoveryGeneration))); } private static IEnumerator EnterAfterAuthoritativeLoad(object introUI, int generation) { SetRecoveryMessage("正在返回安全场景……\nReturning to a safe scene…"); yield return null; if (generation != Volatile.Read(ref RecoveryGeneration)) { yield break; } try { EnterGame.Invoke(introUI, null); } catch (Exception ex) { BeginSessionRecovery("could not enter safe scene: " + ex.Message); yield break; } float deadline = Time.realtimeSinceStartup + 30f; bool fieldLoaded = false; while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration)) { GameFieldManager field = FindUnitySingleton(); PropertyInfo loaded = field?.GetType().GetGameProperty(nameof(GameFieldManager.IsLoadedField), BindingFlags.Instance | BindingFlags.Public); if (loaded != null && loaded.GetValue(field) is bool ready && ready) { fieldLoaded = true; break; } if (IsPackCollectionActive()) { fieldLoaded = true; break; } yield return new WaitForSecondsRealtime(0.25f); } if (!fieldLoaded) { if (generation == Volatile.Read(ref RecoveryGeneration)) { BeginSessionRecovery("safe scene load timed out"); } yield break; } FinishRecovery(true, null, generation); } private static bool IsPackCollectionActive() { Type uiManager = typeof(UIManager); MethodInfo getUI = uiManager?.GetGameMethod( "GetUI", BindingFlags.Static | BindingFlags.Public, null, new[] { typeof(string) }, null); Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component; return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy; } private static void ShowRecoveryOverlay(string message) { if (RecoveryOverlay == null) { RecoveryOverlay = new GameObject( "BD2 Recovery Overlay", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster), typeof(Image)); UnityEngine.Object.DontDestroyOnLoad(RecoveryOverlay); Canvas canvas = RecoveryOverlay.GetComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = short.MaxValue; Image background = RecoveryOverlay.GetComponent(); background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f); RectTransform root = RecoveryOverlay.GetComponent(); root.anchorMin = Vector2.zero; root.anchorMax = Vector2.one; root.offsetMin = root.offsetMax = Vector2.zero; GameObject label = new GameObject("Status", typeof(RectTransform), typeof(Text)); label.transform.SetParent(RecoveryOverlay.transform, false); RecoveryText = label.GetComponent(); RecoveryText.font = Resources.GetBuiltinResource("Arial.ttf"); RecoveryText.fontSize = 28; RecoveryText.alignment = TextAnchor.MiddleCenter; RecoveryText.color = Color.white; RectTransform rect = label.GetComponent(); rect.anchorMin = new Vector2(0.15f, 0.35f); rect.anchorMax = new Vector2(0.85f, 0.65f); rect.offsetMin = rect.offsetMax = Vector2.zero; } RecoveryOverlay.SetActive(true); SetRecoveryMessage(message); } private static void SetRecoveryMessage(string message) { if (RecoveryText != null) { RecoveryText.text = message; } } internal static void FinishRecovery(bool success, string error, int expectedGeneration = 0) { if (expectedGeneration != 0 && expectedGeneration != Volatile.Read(ref RecoveryGeneration)) { return; } if (!success) { Log?.LogError("Automatic session recovery failed: " + error); } if (RecoveryOverlay != null) { UnityEngine.Object.Destroy(RecoveryOverlay); RecoveryOverlay = null; RecoveryText = null; } CancelControlProbes(); Interlocked.Increment(ref RecoveryGeneration); Interlocked.Exchange(ref SessionRecoveryInProgress, 0); Interlocked.Exchange(ref RecoveryRestartScheduled, 0); Interlocked.Exchange(ref RecoveryEnterScheduled, 0); Log?.LogInfo(success ? "Automatic session recovery completed" : "Automatic session recovery stopped"); } [Serializable] private sealed class RuntimeStatus { public string status = null; public string instance_id = null; public int retry_after_ms = 0; } }