From 4034449a6de11661cab125b183ef5e9565effc7d Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:42:25 +0300 Subject: [PATCH] Refactor configuration endpoints and update client installation scripts - Removed Photon configuration fields from dorknet-clientmod.json.template. - Added new backtrace and Azure Speech configuration endpoints in ConfigController. - Updated response format for IsCreationEnabled and IsRenderingEnabled endpoints in CustomAvatarItemsController. - Enhanced InfluencerPartnerProgramController to include additional player properties. - Implemented RoomKeepsakes endpoint in KeepsakesController to retrieve keepsakes by room ID. - Modified ReportsController to standardize response for Hile reporting. - Added PlayerDataForMe and RoomsRequiring endpoints in RoomsController for enhanced player data retrieval. - Introduced new dorm matchmaking methods in MatchController for player session management. - Updated MatchPlayerController to handle exclusive login functionality. - Revised install scripts to reflect changes in Photon AppId handling and patching process. - Added new patch-client.ps1 script for applying static byte patches to the Rec Room client. --- DorkNet.ClientMod/Mod.cs | 589 ++++-------------- .../attic/AuthValuesInjector.cs.attic | 132 ---- .../dorknet-clientmod.json.template | 3 - .../API/Config/V1/ConfigController.cs | 96 +++ .../CustomAvatarItemsController.cs | 4 +- .../InfluencerPartnerProgramController.cs | 9 +- .../API/Keepsakes/KeepsakesController.cs | 19 + .../API/PlayerReporting/ReportsController.cs | 11 +- .../API/Rooms/V2/RoomsController.cs | 138 ++++ .../Controllers/Cdn/CdnController.cs | 16 + .../Controllers/Match/MatchController.cs | 134 ++++ .../Match/MatchPlayerController.cs | 23 +- tools/install-legacy-client.ps1 | 4 +- tools/install-melon.ps1 | 22 +- tools/patch-client.ps1 | 264 ++++++++ 15 files changed, 823 insertions(+), 641 deletions(-) delete mode 100644 DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic create mode 100644 tools/patch-client.ps1 diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index c119ab0..5605ddf 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -3,9 +3,7 @@ // server: // // 1. URI rewrite — `.rec.net` → user-configured host -// 2. Photon AppId — override the baked-in Photon Cloud AppId -// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth -// 4. TLS trust bypass — make BouncyCastle never reject server certs +// 2. TLS trust bypass — make BouncyCastle never reject server certs // // Config lives in \UserData\dorknet-clientmod.json. The // install-melon.ps1 wrapper writes that file on install; you can also @@ -58,21 +56,6 @@ public class Mod : MelonMod public static class Cfg { public static string ServerHost = "localhost"; - public static string PhotonAppId = ""; - public static string PhotonVoiceAppId = ""; - // Forced PhotonServerSettings.PreferredRegion (with HostType - // switched to PhotonCloud) so two watches don't auto-ping - // themselves into different "best" regions and end up in - // parallel Photon rooms sharing only a name. PhotonPatches - // .ApplyForcedRegion prefers the server-told region from - // Matchmaking.LocalRoomInstance.PhotonRegionId when available - // and falls back to this config value on initial boot before - // any /goto has returned. Default matches the server's - // appsettings.json Photon:CloudRegion ("eu"). - public static string PhotonCloudRegion = "eu"; - // Photon Custom Auth injector parked 2026-05-28; see - // attic/AuthValuesInjector.cs.attic for the code + restore notes. - public static bool BypassPhotonCustomAuth = false; public static bool EnableTlsTrustBypass = true; // Log-only trace for the stuck first-dorm "Change Username?" // dialog. When true, RegistrationPatches hooks the dorm @@ -169,53 +152,6 @@ public class Mod : MelonMod DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon"); Log.Msg("=== Registering client patches ==="); RegisterNetworkPatches(); - TryPatchByName("PhotonNetwork", "ConnectUsingSettings", - prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix)); - // Hook the watch's own AppSettings + AuthValues builders directly. - // Both are private PUNNetworkManager methods with obfuscated 2020.12 - // names — we know they always run before any Photon connect because - // ConnectUsingSettings reads their return values. Postfix logs them - // after construction. - // - // FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at - // PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely - // "for voice"). Two call paths in the binary. - // EMGGKBALALH returns Photon.Realtime.AuthenticationValues — - // verified at PUNNetworkManager.txt:7560. Builds AuthValues with - // environment + accessToken AuthGetParameters. - // - // Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by - // return type first so a future platform/reseed does not make these - // two critical hooks depend on the private method names. - TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings", - fallbackMethodName: "FJOLIPKKIBE", - postfix: nameof(PhotonPatches.LogAppSettings_Postfix)); - TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues", - fallbackMethodName: "EMGGKBALALH", - postfix: nameof(PhotonPatches.LogAuthValues_Postfix)); - // PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever - // string-like arg the IL2CPP method takes (declared `string` in - // dump.cs but Il2CppInterop wraps it in a way that breaks - // Harmony's IL emit when we declare `string` directly). Using - // `object` lets Harmony pass through whatever wrapper the IL2CPP - // proxy uses without compile-error. - TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed", - prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix)); - TryPatchByName("DFPKLPCMNEK", "OnCustomAuthenticationFailed", - prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix), - logMiss: false); - TryPatchByName("LOPBLNLAEKB", "OnCustomAuthenticationFailed", - prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix), - logMiss: false); - TryPatchByName("LOPBLNLAEKB", "OnDisconnected", - prefix: nameof(PhotonPatches.OnDisconnected_Prefix), - logMiss: false); - TryPatchByName("OLPEMKMGEHE", "OnDisconnected", - prefix: nameof(PhotonPatches.OnDisconnected_Prefix), - logMiss: false); - TryPatchByName("DFPKLPCMNEK", "OnDisconnected", - prefix: nameof(PhotonPatches.OnDisconnected_Prefix), - logMiss: false); TryPatchByName("RecRoom.AntiCheat.EACManager", "GenerateChallengeResponse", args: new[] { typeof(string) }, @@ -240,6 +176,7 @@ public class Mod : MelonMod "NotifyServerCertificate", prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); } + PatchFileHashCheckerCallback(); // Post-save blob-name refresh. The 2020 watch's // RoomPersistenceManager.RoomDataBlobName is only ever set by // /goto-driven flows — neither the SubscriptionUpdateRoom push @@ -557,6 +494,63 @@ public class Mod : MelonMod return patched > 0; } + private bool PatchFileHashCheckerCallback() + { + var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix)); + var patched = 0; + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var type in GetLoadableTypes(asm)) + { + if (!MatchesFileHashCheckerCallbackType(type)) continue; + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + if (method.IsAbstract || method.ContainsGenericParameters) continue; + if (!MatchesFileHashCheckerCallbackMethod(method)) continue; + try + { + HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod)); + patched++; + Log.Msg($"[patch-ok] file hash checker callback {type.FullName}.{method.Name}"); + } + catch (Exception ex) + { + Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}"); + } + } + } + } + + if (patched == 0) + { + Log.Warning("[patch-miss] file hash checker callback"); + return false; + } + + return true; + } + + private static bool MatchesFileHashCheckerCallbackType(Type type) + { + var fullName = type.FullName ?? type.Name; + return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal) + && fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal); + } + + private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method) + { + if (method.ReturnType != typeof(void)) return false; + var parameters = method.GetParameters(); + if (parameters.Length != 1) return false; + + var name = method.Name; + if (name == "b__0") return true; + if (name == "_InitializeFileHashChecker_b__0") return true; + return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal) + && name.Contains("b__0", StringComparison.Ordinal); + } + private void RegisterNetworkPatches() { if (_networkPatchesRegistered) return; @@ -726,10 +720,6 @@ public class Mod : MelonMod var doc = JsonDocument.Parse(File.ReadAllText(path)); var r = doc.RootElement; if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; - if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; - if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; - if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; - if (TryGetConfigValue(r, "BypassPhotonCustomAuth", out v)) Cfg.BypassPhotonCustomAuth = v.GetBoolean(); if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); @@ -759,9 +749,7 @@ public class Mod : MelonMod } if (list.Count > 0) Cfg.DevCommands = list.ToArray(); } - // "InjectAuthValues" key in the template is now ignored — - // see attic/AuthValuesInjector.cs.attic. - Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + + Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " + $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); } catch (Exception ex) @@ -925,6 +913,8 @@ public class Mod : MelonMod { var t = asm.GetType(name, throwOnError: false); if (t is not null) return CacheResolvedType(name, t); + var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false); + if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed); var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false); if (prefixed is not null) return CacheResolvedType(name, prefixed); // For namespaced types like "RecNet.Core", also try with @@ -980,18 +970,22 @@ public class Mod : MelonMod private static bool MatchesRequestedType(Type type, string requested) { var prefixed = "Il2Cpp" + requested; - if (type.FullName == requested || type.FullName == prefixed) return true; + var dottedPrefixed = "Il2Cpp." + requested; + if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true; if (type.Name == requested || type.Name == prefixed) return true; return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true || + type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true || type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true; } private static bool ReturnTypeMatches(Type type, string requested) { var prefixed = "Il2Cpp" + requested; - if (type.FullName == requested || type.FullName == prefixed) return true; + var dottedPrefixed = "Il2Cpp." + requested; + if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true; if (type.Name == requested || type.Name == prefixed) return true; return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true || + type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true || type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true; } @@ -1008,7 +1002,7 @@ public class Mod : MelonMod { // Look in all three patch holder classes — small enough that a // linear scan is cheaper than per-class lookups. - foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(PhotonPatches), typeof(AntiCheatPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(FileHashCheckerPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; @@ -1060,6 +1054,14 @@ public class Mod : MelonMod complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + args: new[] { typeof(int) }, + prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit", + args: new[] { typeof(int), typeof(string) }, + prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix), + logMiss: logMisses); // SteamManager.Awake + SteamPlatformManager.*LoginInitialize were // pure-logging diagnostics that fired during Unity's very first // wake-up tick. On december (EAC build, IL2CPP wrapped Steam @@ -2328,409 +2330,6 @@ internal static class UriPatches } } -internal static class PhotonPatches -{ - public static void PhotonAppIdOverride_Prefix() - { - if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return; - try - { - // PhotonNetwork.PhotonServerSettings — accessed via reflection - // so the type-name resolution stays in one place. Mutating the - // ScriptableObject in-place is what the BepInEx plugin does - // too; the Photon Connect path reads AppID / VoiceAppID off - // this object immediately after. - var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork"); - if (photonNetwork is null) return; - var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static); - var settings = settingsProp?.GetValue(null); - if (settings is null) return; - - var settingsType = settings.GetType(); - var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId"); - var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId"); - - var newId = Mod.Cfg.PhotonAppId; - var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId; - - // Diagnostic: dump every readable AppSettings field before - // we mutate, so a Photon Custom Auth failure on first connect - // is debuggable from a single log dump. Lists every public - // property + field on PhotonServerSettings (Server, Port, - // NameServer, AuthMode, HostType, PreferredRegion, AppID, - // VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03 - // (Server/Port) and 2020.12 (AppSettings) layouts. - Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:"); - DumpSettings(settings, settingsType, indent: " "); - - appIdProp?.SetValue(settings, newId); - voiceProp?.SetValue(settings, newVoice); - Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}"); - - // Force a deterministic Photon region so two watches running - // this mod end up on the same Photon master and "JoinByName" - // produces ONE shared room (not two parallel ones in eu/us - // because each watch auto-picked a different best region). - // - // The 2020 watch's default HostType is BestRegion(4) — every - // launch pings all Photon Cloud regions and picks the lowest - // latency. Two players geographically apart hit different - // "best" regions; same room name, two regions = two rooms. - // Server-side our matchmaking response carries - // `photonRegionId` but the watch's Photon code never reads - // it; the only consumer is Matchmaking.SetPhotonRegionPings - // which only reports pings *back* to the server. - // - // Strategy: switch HostType to PhotonCloud(1) and set - // PreferredRegion. Prefer the server-told region (the most - // recent Matchmaking.LocalRoomInstance.PhotonRegionId, if - // any) over the config default — that way a server-side - // region change propagates without redeploying the mod. - ApplyForcedRegion(settings, settingsType); - } - catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); } - } - - /// Forces PhotonServerSettings.HostType = PhotonCloud - /// and writes PreferredRegion. Honors any region the server - /// has handed us in Matchmaking.LocalRoomInstance.PhotonRegionId - /// first; falls back to Cfg.PhotonCloudRegion when no room - /// instance has been received yet (initial boot). - private static void ApplyForcedRegion(object settings, Type settingsType) - { - try - { - var hostTypeProp = settingsType.GetField("HostType") - ?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType"); - var preferredProp = settingsType.GetField("PreferredRegion") - ?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion"); - if (hostTypeProp is null || preferredProp is null) - { - Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found"); - return; - } - - // ServerSettings.HostingOption.PhotonCloud = 1 (verified in - // Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum - // type from the field/property so we don't depend on a - // specific Il2Cpp proxy namespace. - Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType - ?? ((PropertyInfo)hostTypeProp).PropertyType; - Type? regionEnum = (preferredProp as FieldInfo)?.FieldType - ?? ((PropertyInfo)preferredProp).PropertyType; - - // Prefer server-provided region over config. We read it via - // the same reflection style as the existing patches so the - // Il2Cpp type-name suffix doesn't bite us. - string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us"; - - object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud - object regionValue; - try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); } - catch - { - Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us"); - regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1 - region = "us"; - } - - if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue); - else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue); - if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue); - else ((PropertyInfo)preferredProp).SetValue(settings, regionValue); - - Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}"); - } - catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); } - } - - /// Reads - /// RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId as a - /// lowercase enum name (eu, us, asia, …). Returns null if no room - /// instance has been received yet, or if any link in the chain is - /// null. Server-told region wins over the static config default - /// because the server is authoritative about which Photon region - /// hosts the room the watch is about to join. - private static string? ReadServerProvidedRegion() - { - try - { - var matchmaking = FindGameType("RecNet.Matchmaking"); - var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); - if (localRoom is null) return null; - var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom); - if (regionObj is null) return null; - // CloudRegionCode enum.ToString() gives the lowercase region - // name (eu, us, asia, …) — see CloudRegionCode.cs. - return regionObj.ToString(); - } - catch { return null; } - } - - // AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a - // harmony hook in this branch anyway. See - // attic/AuthValuesInjector.cs.attic. - - /// Postfix on PUNNetworkManager.FJOLIPKKIBE — the - /// 2020.12 watch's private AppSettings builder. __result is the - /// Photon.Realtime.AppSettings it just built. - /// - /// **CRITICAL OVERRIDE**: replaces AppIdRealtime and - /// AppIdVoice with our configured values before the AppSettings - /// reaches PhotonNetwork.ConnectUsingSettings. The 2020.12 - /// watch hardcodes Rec Room's official AppIds as IL2CPP string - /// literals (verified at PUNNetworkManager.txt:7205,7208: - /// "9372aa8d-..." and "e93ae440-..."). Patching - /// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help — - /// those are unrelated leftover strings, NOT the AppIds the SDK - /// actually reads. The only reliable swap point is here, after - /// FJOLIPKKIBE constructs the AppSettings object but before the - /// LoadBalancingClient consumes it. - /// - /// Then dumps every readable field/property for diagnostics. - public static void LogAppSettings_Postfix(object __result) - { - try - { - if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; } - - // ── AppId injection ──────────────────────────────────────── - var t = __result.GetType(); - var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId; - var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) - ? (newPun ?? null) - : Mod.Cfg.PhotonVoiceAppId; - if (newPun is not null) - { - var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion"); - var oldPun = realtimeProp?.GetValue(__result) as string; - realtimeProp?.SetValue(__result, newPun); - Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}"); - } - if (newVoice is not null) - { - var voiceProp = t.GetProperty("AppIdVoice"); - var oldVoice = voiceProp?.GetValue(__result) as string; - voiceProp?.SetValue(__result, newVoice); - Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}"); - } - // Also force FixedRegion to the configured region so two clients - // never end up on different regions of the same room name. - if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion)) - { - var fixedRegionProp = t.GetProperty("FixedRegion"); - if (fixedRegionProp is not null && fixedRegionProp.CanWrite) - { - fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion); - Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}"); - } - } - - Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):"); - DumpSettings(__result, t, indent: " "); - } - catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); } - } - - /// Postfix on PUNNetworkManager.EMGGKBALALH — the - /// 2020.12 watch's private AuthenticationValues builder. __result - /// is the AuthValues it just built with environment + accessToken - /// AuthGetParameters. Logs AuthType, UserId, and the full - /// AuthGetParameters / AuthPostData payload that Photon Cloud will - /// forward to the configured Custom Auth URL. - public static void LogAuthValues_Postfix(object __result) - { - try - { - if (__result is null) - { - Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)"); - return; - } - var t = __result.GetType(); - if (Mod.Cfg.BypassPhotonCustomAuth) - { - DisableCustomAuthValues(__result, t); - } - string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? ""; - var authType = Read("AuthType"); - var userId = Read("UserId"); - var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? ""; - var postData = t.GetProperty("AuthPostData")?.GetValue(__result); - var postStr = postData is byte[] bytes - ? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>" - : postData?.ToString() ?? ""; - Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}"); - Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}"); - Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}"); - } - catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); } - } - - private static void DisableCustomAuthValues(object authValues, Type t) - { - try - { - SetMember(t, authValues, "AuthType", type => AuthTypeNoneValue(type)); - SetMember(t, authValues, "AuthGetParameters", type => type == typeof(string) ? string.Empty : null); - SetMember(t, authValues, "AuthPostData", _ => null); - SetMember(t, authValues, "Token", type => type == typeof(string) ? string.Empty : null); - Mod.Log.Msg("[photon-auth] custom auth bypass enabled for this launch"); - } - catch (Exception ex) - { - Mod.Log.Warning($"[photon-auth] custom auth bypass failed: {ex.Message}"); - } - } - - private static object? AuthTypeNoneValue(Type type) - { - if (type.IsEnum) return Enum.ToObject(type, 255); - if (type == typeof(byte)) return byte.MaxValue; - if (type == typeof(sbyte)) return (sbyte)-1; - if (type == typeof(short)) return (short)255; - if (type == typeof(ushort)) return (ushort)255; - if (type == typeof(int)) return 255; - if (type == typeof(uint)) return 255u; - if (type == typeof(long)) return 255L; - if (type == typeof(ulong)) return 255UL; - return Convert.ChangeType(255, type); - } - - private static bool SetMember(Type type, object instance, string name, Func valueFactory) - { - const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; - var prop = type.GetProperty(name, flags); - if (prop is not null && prop.CanWrite) - { - prop.SetValue(instance, valueFactory(prop.PropertyType)); - return true; - } - - var field = type.GetField(name, flags); - if (field is not null) - { - field.SetValue(instance, valueFactory(field.FieldType)); - return true; - } - - return false; - } - - /// Prefix on PUNNetworkManager.OnCustomAuthenticationFailed - /// — the watch's UI-error formatter only logs a truncated Unity- - /// stringified version of the debugMessage. Capturing the raw - /// string before the watch's handler runs gives us the FULL - /// Photon error including the URL Photon Cloud actually tried to - /// call (which is the smoking-gun answer to "is the dashboard URL - /// saved or is Photon hitting a stale one"). - public static bool OnCustomAuthenticationFailed_Prefix(object __0) - { - try - { - var debugMessage = __0?.ToString() ?? ""; - Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:"); - Mod.Log.Error(debugMessage); - if (Mod.Cfg.BypassPhotonCustomAuth) - { - Mod.Log.Warning("[photon-auth] custom auth failure suppressed for this launch"); - return false; - } - // Also dump current AppSettings — if Photon rejected the auth - // we want to know what the watch's actual final config looked - // like (post-override). - var photonNetwork = FindGameType("PhotonNetwork"); - var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); - if (settings is not null) - { - Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:"); - DumpSettings(settings, settings.GetType(), indent: " "); - } - } - catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); } - return true; - } - - public static bool OnDisconnected_Prefix(object __0) - { - try - { - var cause = __0?.ToString() ?? ""; - if (Mod.Cfg.BypassPhotonCustomAuth && - cause.IndexOf("CustomAuthenticationFailed", StringComparison.OrdinalIgnoreCase) >= 0) - { - Mod.Log.Warning("[photon-auth] CustomAuthenticationFailed disconnect suppressed for this launch"); - return false; - } - } - catch (Exception ex) { Mod.Log.Warning($"[photon-auth] disconnect bypass check failed: {ex.Message}"); } - return true; - } - - /// Logs every public field + property on - /// — used by the photon-diag patches above to dump AppSettings in one - /// pass without coupling to specific field names that differ across - /// PUN versions (Server/Port vs AppSettings.NameServer in newer - /// PUN 2.x, etc.). - private static void DumpSettings(object obj, Type t, string indent) - { - // DeclaredOnly so we don't walk Il2CppObjectBase's properties - // (ObjectClass, Pointer, WasCollected) — and, importantly, so - // we don't reflect over any property whose getter internally - // constructs a System.Uri. The Uri ctor is harmony-patched, so - // the FIRST call to a Uri-returning getter triggers MonoMod's - // lazy CompileMethodHook for the ctor, which fatal-CLRs - // (0x80131506) on this build. The override has already run by - // the time DumpSettings is called — the dump is diagnostic - // only, so it's safer to stay scoped to user-land properties. - const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; - try - { - foreach (var p in t.GetProperties(flags)) - { - if (p.GetIndexParameters().Length > 0) continue; - if (!p.CanRead) continue; - // Defensively skip any property whose declared return - // type is Uri (would still trigger ctor on .ToString() - // path even if we tried to read it). - if (p.PropertyType == typeof(Uri)) continue; - object? v; - try { v = p.GetValue(obj); } catch { continue; } - if (v is Uri) continue; - var str = v is null ? "" : v.ToString(); - if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6) - { - Mod.Log.Msg($"{indent}{p.Name} (nested):"); - DumpSettings(v, v.GetType(), indent + " "); - continue; - } - Mod.Log.Msg($"{indent}{p.Name} = {str}"); - } - foreach (var f in t.GetFields(flags)) - { - if (f.FieldType == typeof(Uri)) continue; - object? v; - try { v = f.GetValue(obj); } catch { continue; } - if (v is Uri) continue; - var str = v is null ? "" : v.ToString(); - if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6) - { - Mod.Log.Msg($"{indent}{f.Name} (nested):"); - DumpSettings(v, v.GetType(), indent + " "); - continue; - } - Mod.Log.Msg($"{indent}{f.Name} = {str}"); - } - } - catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); } - } - - // GetCurrentLockToken parked alongside the injector — see - // attic/AuthValuesInjector.cs.attic. - - private static Type? FindGameType(string name) => Mod.ResolveType(name); -} - internal static class SavePatches { // Last DataBlobName parsed off a SubscriptionUpdateRoom push for @@ -2989,6 +2588,15 @@ internal static class AntiCheatPatches } } +internal static class FileHashCheckerPatches +{ + public static bool InitializeCallback_Prefix(object? result) + { + Mod.Log.Msg($"[filecheck] suppressed verifier callback result={result?.GetType().FullName ?? "null"}"); + return false; + } +} + internal static class TlsPatches { // Both BouncyCastle TLS authenticators have the same `void @@ -3016,16 +2624,23 @@ internal static class DiagnosticPatches catch { } } - public static void ApplicationQuit_Prefix() + public static bool ApplicationQuit_Prefix() { Write("[quit] UnityEngine.Application.Quit()"); WriteStack(); + return true; } - public static void ApplicationQuitInt_Prefix(int exitCode) + public static bool ApplicationQuitInt_Prefix(int exitCode) { Write($"[quit] UnityEngine.Application.Quit({exitCode})"); WriteStack(); + if (exitCode == 1550502249 || exitCode == 533223478) + { + Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}"); + return false; + } + return true; } public static void AnalyticsCheat_Prefix(MethodBase __originalMethod) @@ -3047,6 +2662,20 @@ internal static class DiagnosticPatches return false; } + public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod) + { + Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})"); + WriteStack(); + return false; + } + + public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod) + { + Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})"); + WriteStack(); + return false; + } + private static void WriteStack() { try diff --git a/DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic b/DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic deleted file mode 100644 index b39c289..0000000 --- a/DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic +++ /dev/null @@ -1,132 +0,0 @@ -// ───────────────────────────────────────────────────────────────────── -// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18) -// ───────────────────────────────────────────────────────────────────── -// -// Filename suffix is .cs.attic on purpose so the .NET SDK's globber -// doesn't pick it up. Don't rename to .cs unless you're putting the -// feature back. -// -// What this used to do -// -------------------- -// Built a Photon AuthenticationValues with `userid` + `LoginLock` -// custom-auth parameters from the in-game RecNet account state. The -// server's /photon/customauth controller (PhotonCustomAuthController.cs) -// validated those parameters against the player's /player/login lock. -// -// Status in this branch -// --------------------- -// The method existed but was **never actually wired to a harmony -// hook** in this branch's OnLateInitializeMelon. December's Photon -// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix -// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it -// doesn't inject. So this code was effectively dead in december. -// -// Removed because: -// 1. Photon Cloud is currently configured with Custom Authentication -// disabled at the AppId level (anonymous connects allowed), so -// no server-side auth probe. -// 2. Even when the watch did send AuthValues, the upstream Cloudflare -// / Localtunnel layer 403'd Photon's auth probe before our -// controller could decide anything. -// -// How to put it back -// ------------------ -// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix). -// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig -// line. -// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's -// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so -// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and -// mutating its returned AuthValues in place — that's the mangled -// method december verifies fires (LogAuthValues_Postfix already -// proves this entry point works). -// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template. -// 5. If you also want the server to enforce the lock, set -// Photon__RequireCustomAuth=true on the server. - -using System.Reflection; - -namespace DorkNet.ClientMod.Attic; - -internal static class AuthValuesInjector -{ - public static void AuthValuesInjector_Prefix(object __instance) - { - if (!Mod.Cfg.InjectAuthValues) return; - try - { - // Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020 - // watch never sets them itself, so we set them right before - // NetworkingPeer.CallAuthenticate forwards to the wire op. The - // userid is RecNet.Core.LocalAccountId, the LoginLock comes - // from PlayerPrefs key LoginLockTokenV2. - // RecNet.Core.LocalAccountId is declared `int` in the game - // build (dump.cs:573622 → 0x30 backing field). Convert via - // Convert.ToInt64 so boxing as int still gives us the long we - // need below; a direct (long) cast on boxed int would throw. - var coreType = FindGameType("RecNet.Core"); - var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); - var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj); - if (accountId <= 0) - { - Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)"); - return; - } - - var photonNetwork = FindGameType("PhotonNetwork"); - var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static); - var existing = avProp?.GetValue(null); - if (existing is not null) - { - var userIdProp = existing.GetType().GetProperty("UserId"); - var existingUserId = userIdProp?.GetValue(existing) as string; - if (!string.IsNullOrEmpty(existingUserId)) return; - } - - var token = GetCurrentLockToken(); - - var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues"); - if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; } - var av = Activator.CreateInstance(authValuesType); - var authTypeProp = authValuesType.GetProperty("AuthType"); - if (authTypeProp is not null) - { - var customAuthValue = authTypeProp.PropertyType.IsEnum - ? Enum.ToObject(authTypeProp.PropertyType, 0) - : Convert.ChangeType(0, authTypeProp.PropertyType); - authTypeProp.SetValue(av, customAuthValue); - } - authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString()); - var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) }); - addParam?.Invoke(av, new object[] { "userid", accountId.ToString() }); - addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty }); - - avProp?.SetValue(null, av); - Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "" : "")}"); - } - catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); } - } - - private static string? GetCurrentLockToken() - { - try - { - var playerPrefs = FindGameType("UnityEngine.PlayerPrefs"); - var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) }); - var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty }); - if (string.IsNullOrEmpty(raw)) return null; - const string key = "\"LockToken\":\""; - var ix = raw.IndexOf(key, StringComparison.Ordinal); - if (ix < 0) return null; - ix += key.Length; - var end = raw.IndexOf('"', ix); - return end < 0 ? null : raw[ix..end]; - } - catch { return null; } - } - - // FindGameType in the live Mod.cs is a delegate to Mod.ResolveType. - // Use that on restore; this is just a placeholder to keep the file - // compilable if someone untreats it without re-checking the wiring. - private static Type? FindGameType(string name) => null; -} diff --git a/DorkNet.ClientMod/dorknet-clientmod.json.template b/DorkNet.ClientMod/dorknet-clientmod.json.template index 5518642..5eab105 100644 --- a/DorkNet.ClientMod/dorknet-clientmod.json.template +++ b/DorkNet.ClientMod/dorknet-clientmod.json.template @@ -1,8 +1,5 @@ { "ServerHost": "{HOST}", - "PhotonAppId": "{PHOTON_APPID}", - "PhotonVoiceAppId": "{PHOTON_VOICE_APPID}", - "PhotonCloudRegion": "{PHOTON_REGION}", "EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}, "QuestMaxTeamSize": 0, "QuestMaxTeamSizeRooms": [ diff --git a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs index c2de06f..1aab71d 100644 --- a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs +++ b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs @@ -21,6 +21,102 @@ public class ConfigController(ConfigService configService) : ControllerBase [HttpGet("amplitude")] public ActionResult GetAmplitude() => Ok(new { AmplitudeKey = string.Empty }); + /// + /// GET api/config/v1/backtrace — 2023 boot downloads this before + /// audio/core initialization. Returning 404 leaves the static backtrace + /// config null and later crashes ConfigSettings consumers. Keep + /// crash upload/deobfuscation disabled locally while preserving the + /// object shape and readable config keys the client inspects. + /// + [HttpGet("backtrace")] + public ActionResult GetBacktrace( + [FromQuery(Name = "platformType")] string? platformType, + [FromQuery(Name = "allocate")] bool? allocate) + { + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var submissionUrl = $"{baseUrl}/backtrace/submit"; + var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump"; + var config = new Dictionary + { + ["Backtrace.SampleRate"] = 0.0, + ["Backtrace.ReportBudget"] = 0, + ["Backtrace.deobfuscationRate"] = 0.0, + ["Backtrace.Deobfuscate"] = false, + ["Backtrace.DeobfuscationUrl"] = string.Empty, + ["Backtrace.Enabled"] = false, + ["Backtrace.PlatformType"] = platformType ?? string.Empty, + ["Backtrace.Allocate"] = allocate ?? false, + }; + + return Ok(new Dictionary + { + ["MaxMessagesPerMinute"] = 0, + ["MaxReportsPerMinute"] = 0, + ["SampleRate"] = 0.0, + ["ReportBudget"] = 0, + ["ErrorReportBudget"] = 0, + ["WarningReportBudget"] = 0, + ["LogReportBudget"] = 0, + ["SubmissionUrl"] = submissionUrl, + ["MinidumpSubmissionUrl"] = minidumpSubmissionUrl, + ["ServerUrl"] = submissionUrl, + ["DatabasePath"] = "backtrace/database", + ["Config"] = config, + ["maxMessagesPerMinute"] = 0, + ["maxReportsPerMinute"] = 0, + ["sampleRate"] = 0.0, + ["reportBudget"] = 0, + ["errorReportBudget"] = 0, + ["warningReportBudget"] = 0, + ["logReportBudget"] = 0, + ["submissionUrl"] = submissionUrl, + ["minidumpSubmissionUrl"] = minidumpSubmissionUrl, + ["serverUrl"] = submissionUrl, + ["databasePath"] = "backtrace/database", + ["config"] = config, + }); + } + + [HttpGet("azurespeech")] + public IActionResult GetAzureSpeech() + { + return Ok(new Dictionary + { + ["Enabled"] = false, + ["SpeechRecognitionEnabled"] = false, + ["SpeechSynthesisEnabled"] = false, + ["Region"] = string.Empty, + ["SubscriptionKey"] = string.Empty, + ["TokenEndpoint"] = string.Empty, + ["SpeechRecognitionEndpoint"] = string.Empty, + ["SpeechSynthesisEndpoint"] = string.Empty, + ["enabled"] = false, + ["speechRecognitionEnabled"] = false, + ["speechSynthesisEnabled"] = false, + ["region"] = string.Empty, + ["subscriptionKey"] = string.Empty, + ["tokenEndpoint"] = string.Empty, + ["speechRecognitionEndpoint"] = string.Empty, + ["speechSynthesisEndpoint"] = string.Empty, + }); + } + + [HttpGet("/voice/config")] + public IActionResult GetVoiceConfig() + { + return Ok(new Dictionary + { + ["ToxMod.Enabled"] = false, + ["ToxMod.SampleRate"] = 0.0, + ["ToxMod.SessionRecordingEnabled"] = false, + ["ToxMod.Endpoint"] = string.Empty, + ["ToxMod.ApiKey"] = string.Empty, + ["Enabled"] = false, + ["enabled"] = false, + ["toxModEnabled"] = false, + }); + } + /// GET api/config/v1/freegiftbutton — the /// 2020.12 client deserializes this endpoint as a raw Boolean. /// This server does not run the timed free-gift campaign, so the diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index 4efa969..f2414eb 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -187,11 +187,11 @@ public class CustomAvatarItemsController( [HttpGet("isCreationEnabled")] [AllowAnonymous] - public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true }); + public IActionResult IsCreationEnabled() => Content("true", "application/json"); [HttpGet("isRenderingEnabled")] [AllowAnonymous] - public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true }); + public IActionResult IsRenderingEnabled() => Content("true", "application/json"); [HttpGet("minPriceForPublicItem")] [AllowAnonymous] diff --git a/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs index 1b4ca2c..0ef329e 100644 --- a/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs +++ b/DorkNet.Server/Controllers/API/InfluencerPartnerProgram/InfluencerPartnerProgramController.cs @@ -95,8 +95,15 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle private static object ToInfluencerWire(PlayerEntity player) => new { AccountId = (int)player.Id, + RawUsername = player.Username, player.Username, - DisplayName = player.DisplayName, + DisplayName = player.DisplayName ?? player.Username, ProfileImage = player.ProfileImageName ?? string.Empty, + TreatAsJunior = false, + HasBirthday = true, + Platforms = 1, + IsInfluencer = true, + SupportedInfluencerId = (int?)null, + LocalSupportedInfluencerId = (int?)null, }; } diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index c14f9fc..0374404 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs @@ -46,6 +46,25 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con return Ok(rows.Select(ToWire)); } + [HttpGet("rooms/{roomId:long}")] + public async Task RoomKeepsakes(long roomId) + { + var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId); + if (!roomExists) return NotFound(); + + var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture); + var rows = await db.Keepsakes + .Where(k => k.Category == "room" + && (k.EventKey == $"room:{roomIdText}" + || k.EventKey == $"room/{roomIdText}" + || k.EventKey == roomIdText + || EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%") + || EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%"))) + .OrderByDescending(k => k.EarnedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + [HttpGet("globalconfig")] [AllowAnonymous] public IActionResult GlobalConfig() => Ok(new diff --git a/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs index a1e4df6..9bf2aa3 100644 --- a/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs @@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase private async Task SaveHileAsync(string? rawDetail, int? type, long? gameSessionId) { var pid = this.CurrentPlayerId(); - if (pid is not long me) return Ok(new { success = true }); + if (pid is not long me) return HileResult(); var detail = (rawDetail ?? string.Empty).Trim(); if (type is int hileType) detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}"; @@ -216,6 +216,13 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase Category = "hile", }); await db.SaveChangesAsync(); - return Ok(new { success = true }); + return HileResult(); } + + private static IActionResult HileResult() => new ContentResult + { + Content = "true", + ContentType = "application/json", + StatusCode = StatusCodes.Status200OK, + }; } diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 57218a8..3fb1975 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1532,6 +1532,144 @@ public class RoomsController( [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); + [HttpGet("rooms/{roomId:long}/playerdata/me")] + [Authorize] + public async Task PlayerDataForMe(long roomId) + { + var pid = CurrentPlayerId; + if (pid is null) return Unauthorized(); + + var room = await db.Rooms.AsNoTracking() + .Where(r => r.Id == roomId) + .Select(r => new { r.Id, r.VisitCount, r.CheerCount, r.FavoriteCount }) + .FirstOrDefaultAsync(); + if (room is null) return NotFound(); + + var favorited = await db.RoomBookmarks + .AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid.Value); + var cheered = await db.Cheers + .AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid.Value); + var visit = await db.RoomVisits.AsNoTracking() + .Where(v => v.RoomId == roomId && v.PlayerId == pid.Value) + .Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt }) + .FirstOrDefaultAsync(); + + return Ok(new + { + RoomId = room.Id, + PlayerId = pid.Value, + Favorite = favorited, + Favorited = favorited, + IsFavorite = favorited, + Cheer = cheered, + Cheered = cheered, + IsCheered = cheered, + IsBookmarked = favorited, + IsCheering = cheered, + VisitCount = visit?.VisitCount ?? 0, + FirstVisitedAt = visit?.FirstVisitAt, + LastVisitedAt = visit?.LastVisitAt, + RoomVisitCount = room.VisitCount, + RoomCheerCount = room.CheerCount, + RoomFavoriteCount = room.FavoriteCount, + }); + } + + [HttpGet("rooms/requiring/{restriction}")] + [HttpGet("roomserver/rooms/requiring/{restriction}")] + [Authorize] + public async Task RoomsRequiring(string restriction) + { + var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant(); + if (key.Length == 0) return Ok(new List()); + + IQueryable query = db.Rooms.AsNoTracking() + .Where(r => !r.HiddenFromBrowse && r.State == 0); + + query = key switch + { + "developer" or "studio" or "rrstudio" => query.Where(r => + r.IsStudioRoom + || r.IsRoomLinkedToRecRoomStudio + || EF.Functions.Like(r.TagsCsv, "%developer%") + || EF.Functions.Like(r.TagsCsv, "%studio%")), + "rrplus" or "recroomplus" => query.Where(r => + EF.Functions.Like(r.TagsCsv, "%rrplus%") + || EF.Functions.Like(r.TagsCsv, "%recroomplus%")), + _ => query.Where(r => EF.Functions.Like(r.TagsCsv, $"%{key}%")), + }; + + var rows = await query + .OrderByDescending(r => r.HotScore) + .ThenBy(r => r.Name) + .Take(100) + .ToListAsync(); + return Ok(rows.Select(RoomService.ToWireRoom).ToList()); + } + + [HttpGet("rooms/curated_playlists")] + [HttpGet("roomserver/rooms/curated_playlists")] + [Authorize] + public async Task CuratedPlaylistsCompat() + { + var curated = await playlists.CuratedAsync(); + return Ok(curated.Select(BuildPlaylistUnionEntry).ToList()); + } + + [HttpGet("clubhousesearch/mostactivenow")] + [HttpGet("roomserver/clubhousesearch/mostactivenow")] + [Authorize] + public async Task MostActiveClubhouses() + { + var rows = await db.Clubs.AsNoTracking() + .Where(c => c.State == 0 && c.ClubhouseRoomId != null) + .GroupJoin( + db.ClubMemberships.AsNoTracking(), + c => c.Id, + m => m.ClubId, + (club, memberships) => new + { + Club = club, + MemberCount = memberships.Count(), + }) + .Join( + db.Rooms.AsNoTracking(), + c => c.Club.ClubhouseRoomId!.Value, + r => r.Id, + (c, room) => new + { + c.Club, + c.MemberCount, + Room = room, + }) + .OrderByDescending(x => x.MemberCount) + .ThenByDescending(x => x.Room.HotScore) + .Take(50) + .ToListAsync(); + + return Ok(rows.Select(x => new + { + ClubId = x.Club.Id, + x.Club.Name, + x.Club.Description, + MainImageName = x.Club.ImageName, + ImageName = x.Club.ImageName, + State = x.Club.State, + CreatorAccountId = x.Club.CreatorPlayerId, + Category = x.Club.Category, + Visibility = x.Club.Visibility, + Joinability = x.Club.Joinability, + x.Club.AllowJuniors, + MemberCount = x.MemberCount, + x.Club.IsRRO, + x.Club.ClubhouseRoomId, + x.Club.ClubType, + Room = RoomService.ToWireRoom(x.Room), + RoomId = x.Room.Id, + RoomName = x.Room.Name, + }).ToList()); + } + // ── Bookmark mutations ─────────────────────────────────────────────── [HttpPost("api/rooms/v1/bookmark/{roomId:long}")] diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index ab8ed63..552bc11 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -100,6 +100,11 @@ public class CdnController( // String.Concat("/invention/", filename). Same byte-fetch flow as // /room/, routed to S3 via BlobRouter on the filename. [Route("/invention/{*path:minlength(1)}")] + // 2023 builds a CDN URL as `/sigs/` during boot + // file-hash verification. The original Rec Room CDN no longer + // serves the embedded 2023 id, so keep this on a dedicated path and + // fail soft instead of letting the request 404. + [Route("/sigs/{*path:minlength(1)}")] // ASP.NET route templates treat `[` / `]` as token markers // (e.g. `[controller]`). To use them as literals inside a regex // constraint they must be doubled — so `[^/]` becomes `[[^/]]`. @@ -266,6 +271,17 @@ public class CdnController( // ── CDN serve path (cdn.*, storage.*, data.*, apex) ──────────────── private async Task ServeCdn(string? path) { + if (!string.IsNullOrEmpty(path) && + path.StartsWith("sigs/", StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("[cdn] signature set MISS host={Host} path={Path} -> empty signature payload", + Request.Host.Host, path); + var payload = Array.Empty(); + Response.Headers.CacheControl = "public, max-age=60"; + signatures.AddContentSignature(Response, payload); + return new FileContentResult(payload, "application/octet-stream"); + } + if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase)) { // Tips live in the LoadingScreenTips DB table; admins diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index 29c59bb..9890601 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -17,10 +17,12 @@ namespace DorkNet.Server.Controllers.Match; [ApiController] [Authorize] public class MatchController( + IConfiguration config, GameSessionService sessionService, PlayerPresenceService presence, PrivateInstanceService privateInstances, JoinTimeoutService joinTimeouts, + RoomService rooms, DorkNetDbContext db, ILogger logger) : ControllerBase { @@ -84,6 +86,138 @@ public class MatchController( return Ok(); } + [HttpPost("/matchmake/none")] + public async Task MatchmakeNone() + { + var pid = this.CurrentPlayerId() ?? 0; + if (pid == 0) return Unauthorized(); + + var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid); + presence.SetRoom(pid, current); + presence.MarkActive(pid); + logger.LogInformation("[matchmake-none] player={Player}", pid); + return Ok(new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = current, + }); + } + + [HttpPost("/matchmake/dorm")] + public async Task MatchmakeDorm() + { + var pid = this.CurrentPlayerId() ?? 0; + if (pid == 0) return Unauthorized(); + + var room = await BuildDormRoomInstanceAsync(pid); + presence.SetRoom(pid, room); + presence.MarkActive(pid); + await RecordVisitAsync(pid, room.RoomId); + logger.LogInformation( + "[matchmake-dorm] player={Player} room={Room} instance={Instance} photon={Photon}", + pid, room.RoomId, room.RoomInstanceId, room.PhotonRoomId); + return Ok(new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = room, + }); + } + + private async Task BuildDormRoomInstanceAsync(long playerId) + { + var personal = await rooms.EnsurePersonalDormAsync(playerId); + var dataBlob = await rooms.ResolveDormDataBlobNameAsync(playerId, personal.Id); + if (string.IsNullOrWhiteSpace(dataBlob)) + dataBlob = personal.CurrentDataBlobName; + + var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); + var photonRoomId = $"^dormroom_p{playerId}"; + var dormInstanceName = await BuildDormInstanceNameAsync(playerId); + + var dorm = await privateInstances.EnsureForDormAsync( + ownerPlayerId: playerId, + roomId: personal.Id, + subRoomId: 0, + baseName: dormInstanceName, + location: personal.LocationReplicationId, + dataBlob: dataBlob ?? string.Empty, + photonRegion: photonRegion, + maxCapacity: personal.MaxCapacity, + photonRoomId: photonRoomId); + + return new RoomInstanceDto + { + RoomInstanceId = dorm.InstanceId, + RoomId = personal.Id, + SubRoomId = 0, + Location = personal.LocationReplicationId, + PhotonRegionId = photonRegion, + PhotonRoomId = photonRoomId, + Name = dormInstanceName, + MaxCapacity = personal.MaxCapacity, + IsFull = false, + IsPrivate = true, + IsInProgress = true, + DataBlob = dataBlob ?? string.Empty, + EventId = 0, + }; + } + + private async Task BuildDormInstanceNameAsync(long ownerPlayerId) + { + var player = await db.Players.AsNoTracking() + .Where(p => p.Id == ownerPlayerId) + .Select(p => new { p.DisplayName, p.Username }) + .FirstOrDefaultAsync(); + var name = !string.IsNullOrWhiteSpace(player?.DisplayName) + ? player!.DisplayName + : !string.IsNullOrWhiteSpace(player?.Username) + ? player!.Username + : $"Player{ownerPlayerId}"; + var cleaned = new string(name + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_') + .ToArray()).Trim('_'); + return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm"; + } + + private async Task RecordVisitAsync(long playerId, long roomId) + { + var visit = await db.RoomVisits + .FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId); + var isFirst = visit is null; + if (isFirst) + { + visit = new RoomVisitEntity + { + RoomId = roomId, + PlayerId = playerId, + FirstVisitAt = DateTime.UtcNow, + }; + db.RoomVisits.Add(visit); + } + visit!.LastVisitAt = DateTime.UtcNow; + visit.VisitCount += 1; + + if (isFirst) + { + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) + .SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1) + .SetProperty(r => r.HotScore, r => r.HotScore + 2.0)); + } + else + { + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) + .SetProperty(r => r.HotScore, r => r.HotScore + 0.25)); + } + await db.SaveChangesAsync(); + } + /// PUT /roominstance/{id}/inprogress — fired by /// RecNet.Matchmaking.SetGameInProgress(bool inProgress) diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 04e70fb..362323e 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -38,6 +38,25 @@ public class MatchPlayerController( [HttpPost("/player/login")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] public async Task PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) + => await RecordLoginLockAsync(loginLock, "player-login"); + + /// + /// POST /player/exclusivelogin — newer clients use this instead + /// of /player/login and include a + /// TakeOverExclusiveSession form value. The lock semantics are + /// otherwise the same: the latest LoginLock wins for this account. + /// + [HttpPost("/player/exclusivelogin")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task PlayerExclusiveLogin( + [FromForm(Name = "LoginLock")] string? loginLock, + [FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession) + => await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession); + + private async Task RecordLoginLockAsync( + string? loginLock, + string logScope, + bool? takeOverExclusiveSession = null) { var playerId = TryGetCurrentPlayerId(); if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) @@ -46,8 +65,8 @@ public class MatchPlayerController( if (replaced) { log.LogWarning( - "[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push", - playerId); + "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push", + logScope, playerId, takeOverExclusiveSession); // The new session hasn't opened its SignalR socket yet // (negotiate fires AFTER /player/login on the watch's // boot sequence — verified in the live trace), so the diff --git a/tools/install-legacy-client.ps1 b/tools/install-legacy-client.ps1 index 4908404..4b7782a 100644 --- a/tools/install-legacy-client.ps1 +++ b/tools/install-legacy-client.ps1 @@ -7,7 +7,7 @@ install made by tools/install-plugin.ps1, then runs tools/patch-client.ps1 in its legacy mode: - - byte-patches Photon Realtime/Voice AppIds in resources.assets; + - byte-patches Photon Realtime/Voice AppIds in GameAssembly.dll; - byte-patches the native BestHTTP/BouncyCastle TLS certificate verifier return in GameAssembly.dll; - points *.rec.net at the DorkNet server through the hosts file; @@ -169,7 +169,7 @@ if (-not $KeepBepInEx) { Write-Warn 'Keeping BepInEx because -KeepBepInEx was supplied.' } -Write-Step 'Applying legacy resources.assets/hosts/cert patch' +Write-Step 'Applying legacy GameAssembly/hosts/cert patch' if ($CertPath -and $DryRun) { & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath -DryRun } elseif ($CertPath) { diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1 index 3f45237..cadc2ac 100644 --- a/tools/install-melon.ps1 +++ b/tools/install-melon.ps1 @@ -10,9 +10,9 @@ against MelonLoader's generated Il2CppAssemblies. 3. Drops the built DLL into \Mods\ and writes \MelonLoader\UserData\dorknet-clientmod.json - with the user-supplied Photon AppId / server host / etc. + with the server host and runtime toggles. 4. Calls tools\patch-client.ps1 to apply the static byte patches - (Photon AppId in resources.assets, image signing public key, + (Photon AppIds in GameAssembly.dll, image signing public key, image signature verifier, and the CodeStage Anti-Cheat Toolkit neutraliser). @@ -99,16 +99,12 @@ param( [Parameter(ParameterSetName = 'Install')] [string]$PhotonVoiceAppId, - [Parameter(ParameterSetName = 'Install')] - [ValidateSet('us','eu','asia','jp','au','usw','sa','cae','kr','in','ru','rue')] - [string]$PhotonCloudRegion = 'us', - - # Read PhotonAppId / VoiceAppId / CloudRegion from the DorkNet.Server + # Read PhotonAppId / VoiceAppId from the DorkNet.Server # appsettings file at this path. Use the value from # `DorkNet.Server\appsettings.Local.json` (falls back to # `appsettings.json`) so you don't have to retype the GUIDs you # already configured on the server side. Any explicit -PhotonAppId / - # -PhotonVoiceAppId / -PhotonCloudRegion override the file values. + # -PhotonVoiceAppId override the file values. [Parameter(ParameterSetName = 'Install')] [string]$FromServerConfig, @@ -334,13 +330,9 @@ if ($PSCmdlet.ParameterSetName -eq 'Install') { if (-not $cfg) { continue } if (-not $PhotonAppId -and $cfg.AppId) { $PhotonAppId = $cfg.AppId } if (-not $PhotonVoiceAppId -and $cfg.VoiceAppId) { $PhotonVoiceAppId = $cfg.VoiceAppId } - if (-not $PSBoundParameters.ContainsKey('PhotonCloudRegion') -and $cfg.CloudRegion) { - $PhotonCloudRegion = $cfg.CloudRegion - } Write-OK ("Loaded Photon config from $($cfg.Source) " + "(AppId=$(if($cfg.AppId){''}else{''}), " + - "VoiceAppId=$(if($cfg.VoiceAppId){''}else{''}), " + - "CloudRegion=$(if($cfg.CloudRegion){$cfg.CloudRegion}else{''}))") + "VoiceAppId=$(if($cfg.VoiceAppId){''}else{''}))") break } @@ -435,10 +427,6 @@ New-Item -ItemType Directory -Path $ModsDir -Force | Out-Null $cfgPath = Join-Path $UserDataDir 'dorknet-clientmod.json' $cfg = [ordered]@{ ServerHost = $ServerHost - PhotonAppId = $PhotonAppId - PhotonVoiceAppId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' } - PhotonCloudRegion = $PhotonCloudRegion - InjectAuthValues = $true EnableTlsTrustBypass = $true # Opt-in: force-enable the client's built-in debug console (dev gate # bypassed) + silence CheatManager so SetTimeScale / Fly / Teleport etc. diff --git a/tools/patch-client.ps1 b/tools/patch-client.ps1 new file mode 100644 index 0000000..cfe695f --- /dev/null +++ b/tools/patch-client.ps1 @@ -0,0 +1,264 @@ +<# +.SYNOPSIS + Applies static DorkNet byte patches to an extracted Rec Room client. + +.DESCRIPTION + RecRoomPath must point at the Unity data folder, usually + Recroom_Release_Data or RecRoom_Data. The script resolves the install root + from that folder, then patches the hardcoded Photon Realtime and Voice + AppIds wherever the build stores them: GameAssembly.dll, + il2cpp_data\Metadata\global-metadata.dat, and resources.assets. + + The replacement values must be GUID strings, so the byte length stays + unchanged and the patch can be done in place. + +.PARAMETER RecRoomPath + Path to Recroom_Release_Data. + +.PARAMETER PhotonAppId + Photon Cloud Realtime AppId. + +.PARAMETER PhotonVoiceAppId + Photon Cloud Voice AppId. Defaults to PhotonAppId. + +.PARAMETER DisableSignatureChecks + Deprecated compatibility switch. File-check bypass is handled by + DorkNet.ClientMod so the embedded build/signature identifiers stay valid. + +.PARAMETER Revert + Restore patched files from backups created by this script. + +.PARAMETER DryRun + Show what would be patched without writing files. +#> + +[CmdletBinding(DefaultParameterSetName = 'Patch')] +param( + [Parameter(Mandatory = $true)] + [string]$RecRoomPath, + + [Parameter(Mandatory = $true, ParameterSetName = 'Patch')] + [ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonAppId, + + [Parameter(ParameterSetName = 'Patch')] + [ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonVoiceAppId, + + [Parameter(ParameterSetName = 'Patch')] + [switch]$DisableSignatureChecks, + + [Parameter(ParameterSetName = 'Patch')] + [string]$ImageSigningPublicKeyPath, + + [Parameter(ParameterSetName = 'Patch')] + [string]$ServerIp, + + [Parameter(ParameterSetName = 'Patch')] + [string]$CertPath, + + [Parameter(Mandatory = $true, ParameterSetName = 'Revert')] + [switch]$Revert, + + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$OfficialPhotonAppId = '9372aa8d-d3f4-44a0-986d-419e145a2b83' +$OfficialPhotonVoiceAppId = 'e93ae440-f238-4b6c-848f-1df89faf14f5' + +function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host ('OK: ' + $msg) -ForegroundColor Green } +function Write-Warn($msg) { Write-Host ('WARN: ' + $msg) -ForegroundColor Yellow } + +function Find-SequenceOffsets { + param( + [Parameter(Mandatory = $true)][byte[]]$Haystack, + [Parameter(Mandatory = $true)][byte[]]$Needle + ) + + $offsets = New-Object System.Collections.Generic.List[int] + if ($Needle.Length -eq 0 -or $Haystack.Length -lt $Needle.Length) { + return $offsets + } + + $last = $Haystack.Length - $Needle.Length + for ($i = 0; $i -le $last; $i++) { + if ($Haystack[$i] -ne $Needle[0]) { continue } + $matched = $true + for ($j = 1; $j -lt $Needle.Length; $j++) { + if ($Haystack[$i + $j] -ne $Needle[$j]) { + $matched = $false + break + } + } + if ($matched) { $offsets.Add($i) } + } + return $offsets +} + +function Format-HexOffset([int]$Offset) { + return '0x{0:X}' -f $Offset +} + +function Apply-AsciiStringPatch { + param( + [Parameter(Mandatory = $true)][byte[]]$Bytes, + [Parameter(Mandatory = $true)][string]$Original, + [Parameter(Mandatory = $true)][string]$Replacement, + [Parameter(Mandatory = $true)][string]$Label + ) + + $enc = [System.Text.Encoding]::ASCII + $originalBytes = $enc.GetBytes($Original) + $replacementBytes = $enc.GetBytes($Replacement) + if ($originalBytes.Length -ne $replacementBytes.Length) { + throw "$Label replacement length differs from original. Expected $($originalBytes.Length) bytes, got $($replacementBytes.Length)." + } + + $offsets = @(Find-SequenceOffsets -Haystack $Bytes -Needle $originalBytes) + if ($offsets.Count -eq 0) { + $replacementOffsets = @(Find-SequenceOffsets -Haystack $Bytes -Needle $replacementBytes) + if ($replacementOffsets.Count -gt 0) { + Write-OK "$Label already patched at $((@($replacementOffsets) | ForEach-Object { Format-HexOffset $_ }) -join ', ')" + return $false + } + Write-Warn "$Label official AppId '$Original' was not found, and replacement '$Replacement' is not already present." + return $null + } + + foreach ($offset in $offsets) { + [System.Array]::Copy($replacementBytes, 0, $Bytes, $offset, $replacementBytes.Length) + } + Write-OK "$Label patched at $((@($offsets) | ForEach-Object { Format-HexOffset $_ }) -join ', ')" + return $true +} + +function Get-ClientPaths { + param([Parameter(Mandatory = $true)][string]$Path) + + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { + throw "RecRoomPath '$Path' does not exist." + } + + $dataPath = $resolved.Path + if (-not (Test-Path -LiteralPath (Join-Path $dataPath 'resources.assets') -PathType Leaf)) { + throw "resources.assets not found in '$dataPath'. Point RecRoomPath at the Recroom_Release_Data folder." + } + + $root = Split-Path -Parent $dataPath + $gameAssembly = Join-Path $root 'GameAssembly.dll' + if (-not (Test-Path -LiteralPath $gameAssembly -PathType Leaf)) { + throw "GameAssembly.dll not found next to '$dataPath'." + } + + [pscustomobject]@{ + DataPath = $dataPath + Root = $root + GameAssembly = $gameAssembly + Resources = Join-Path $dataPath 'resources.assets' + GlobalMetadata = Join-Path $dataPath 'il2cpp_data\Metadata\global-metadata.dat' + } +} + +$paths = Get-ClientPaths -Path $RecRoomPath +$patchTargets = @( + [pscustomobject]@{ Label = 'GameAssembly.dll'; Path = $paths.GameAssembly }, + [pscustomobject]@{ Label = 'global-metadata.dat'; Path = $paths.GlobalMetadata }, + [pscustomobject]@{ Label = 'resources.assets'; Path = $paths.Resources } +) | Where-Object { Test-Path -LiteralPath $_.Path -PathType Leaf } + +if ($Revert) { + $restored = 0 + foreach ($target in $patchTargets) { + $backup = "$($target.Path).dorknet-appid-backup" + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + continue + } + Write-Step "Reverting $($target.Label) from $backup" + if ($DryRun) { + Write-Host "DRY RUN: would restore $($target.Path) from $backup" + $restored++ + continue + } + Copy-Item -LiteralPath $backup -Destination $target.Path -Force + Write-OK "$($target.Label) restored." + $restored++ + } + if ($restored -eq 0) { + throw "No DorkNet AppId backups found under $($paths.Root)." + } + exit 0 +} + +if (-not $PhotonVoiceAppId) { + $PhotonVoiceAppId = $PhotonAppId +} + +if ($ImageSigningPublicKeyPath) { + Write-Warn 'ImageSigningPublicKeyPath is accepted for installer compatibility, but this patcher currently only applies Photon AppId byte patches.' +} +if ($ServerIp -or $CertPath) { + Write-Warn 'ServerIp/CertPath are accepted for legacy installer compatibility, but hosts/cert patching is not applied by this script.' +} +if ($DisableSignatureChecks) { + Write-Warn 'DisableSignatureChecks is handled by DorkNet.ClientMod; no signature metadata bytepatch is applied.' +} + +if ($patchTargets.Count -eq 0) { + throw "No patchable client files found under $($paths.Root)." +} + +$anyChanged = $false +$anyFound = $false +foreach ($target in $patchTargets) { + Write-Step "Patching Photon AppIds in $($target.Path)" + $bytes = [System.IO.File]::ReadAllBytes($target.Path) + $fileChanged = $false + + $realtimeResult = Apply-AsciiStringPatch -Bytes $bytes -Original $OfficialPhotonAppId -Replacement $PhotonAppId.ToLowerInvariant() -Label "$($target.Label) Photon Realtime AppId" + if ($null -ne $realtimeResult) { + $anyFound = $true + $fileChanged = $realtimeResult -or $fileChanged + } + + $voiceResult = Apply-AsciiStringPatch -Bytes $bytes -Original $OfficialPhotonVoiceAppId -Replacement $PhotonVoiceAppId.ToLowerInvariant() -Label "$($target.Label) Photon Voice AppId" + if ($null -ne $voiceResult) { + $anyFound = $true + $fileChanged = $voiceResult -or $fileChanged + } + + if (-not $fileChanged) { + Write-OK "No $($target.Label) changes needed." + continue + } + + $anyChanged = $true + $backup = "$($target.Path).dorknet-appid-backup" + if ($DryRun) { + Write-Host "DRY RUN: would write patched $($target.Label)" + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + Write-Host "DRY RUN: would create backup $backup" + } + continue + } + + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + Copy-Item -LiteralPath $target.Path -Destination $backup + Write-OK "Backup written to $backup" + } + + [System.IO.File]::WriteAllBytes($target.Path, $bytes) + Write-OK "$($target.Label) Photon AppIds patched." +} + +if (-not $anyFound) { + throw "Official Photon AppIds were not found in any patch target, and requested replacements were not already present." +} + +if (-not $anyChanged) { + Write-OK 'No Photon AppId changes needed.' +}