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.
This commit is contained in:
Alexa
2026-06-23 10:42:25 +03:00
parent 142f52569a
commit 4034449a6d
15 changed files with 823 additions and 641 deletions
+109 -480
View File
@@ -3,9 +3,7 @@
// server: // server:
// //
// 1. URI rewrite — `.rec.net` → user-configured host // 1. URI rewrite — `.rec.net` → user-configured host
// 2. Photon AppId — override the baked-in Photon Cloud AppId // 2. TLS trust bypass — make BouncyCastle never reject server certs
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
// 4. TLS trust bypass — make BouncyCastle never reject server certs
// //
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The // Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
// install-melon.ps1 wrapper writes that file on install; you can also // 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 class Cfg
{ {
public static string ServerHost = "localhost"; 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; public static bool EnableTlsTrustBypass = true;
// Log-only trace for the stuck first-dorm "Change Username?" // Log-only trace for the stuck first-dorm "Change Username?"
// dialog. When true, RegistrationPatches hooks the dorm // dialog. When true, RegistrationPatches hooks the dorm
@@ -169,53 +152,6 @@ public class Mod : MelonMod
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon"); DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ==="); Log.Msg("=== Registering client patches ===");
RegisterNetworkPatches(); 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", TryPatchByName("RecRoom.AntiCheat.EACManager",
"GenerateChallengeResponse", "GenerateChallengeResponse",
args: new[] { typeof(string) }, args: new[] { typeof(string) },
@@ -240,6 +176,7 @@ public class Mod : MelonMod
"NotifyServerCertificate", "NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
} }
PatchFileHashCheckerCallback();
// Post-save blob-name refresh. The 2020 watch's // Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by // RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push // /goto-driven flows — neither the SubscriptionUpdateRoom push
@@ -557,6 +494,63 @@ public class Mod : MelonMod
return patched > 0; 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 == "<InitializeFileHashChecker>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() private void RegisterNetworkPatches()
{ {
if (_networkPatchesRegistered) return; if (_networkPatchesRegistered) return;
@@ -726,10 +720,6 @@ public class Mod : MelonMod
var doc = JsonDocument.Parse(File.ReadAllText(path)); var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement; var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; 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, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = 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(); if (list.Count > 0) Cfg.DevCommands = list.ToArray();
} }
// "InjectAuthValues" key in the template is now ignored — Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
// see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
} }
catch (Exception ex) catch (Exception ex)
@@ -925,6 +913,8 @@ public class Mod : MelonMod
{ {
var t = asm.GetType(name, throwOnError: false); var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t); 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); var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed); if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with // 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) private static bool MatchesRequestedType(Type type, string requested)
{ {
var prefixed = "Il2Cpp" + 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; if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == 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; type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
} }
private static bool ReturnTypeMatches(Type type, string requested) private static bool ReturnTypeMatches(Type type, string requested)
{ {
var prefixed = "Il2Cpp" + 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; if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == 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; 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 // Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups. // 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); var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m; if (m is not null) return m;
@@ -1060,6 +1054,14 @@ public class Mod : MelonMod
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses); 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 // SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
// pure-logging diagnostics that fired during Unity's very first // pure-logging diagnostics that fired during Unity's very first
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam // 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}"); }
}
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
/// and writes <c>PreferredRegion</c>. Honors any region the server
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
/// instance has been received yet (initial boot).</summary>
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}"); }
}
/// <summary>Reads
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> 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.</summary>
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.
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
/// <c>Photon.Realtime.AppSettings</c> it just built.
///
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
/// <c>AppIdVoice</c> with our configured values before the AppSettings
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). 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.</summary>
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}"); }
}
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
/// 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.</summary>
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() ?? "<null>";
var authType = Read("AuthType");
var userId = Read("UserId");
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
var postStr = postData is byte[] bytes
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
: postData?.ToString() ?? "<null>";
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<Type, object?> 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;
}
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
/// — 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").</summary>
public static bool OnCustomAuthenticationFailed_Prefix(object __0)
{
try
{
var debugMessage = __0?.ToString() ?? "<null>";
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() ?? "<null>";
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;
}
/// <summary>Logs every public field + property on <paramref name="obj"/>
/// — 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.).</summary>
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 ? "<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 ? "<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 internal static class SavePatches
{ {
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for // 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 internal static class TlsPatches
{ {
// Both BouncyCastle TLS authenticators have the same `void // Both BouncyCastle TLS authenticators have the same `void
@@ -3016,16 +2624,23 @@ internal static class DiagnosticPatches
catch { } catch { }
} }
public static void ApplicationQuit_Prefix() public static bool ApplicationQuit_Prefix()
{ {
Write("[quit] UnityEngine.Application.Quit()"); Write("[quit] UnityEngine.Application.Quit()");
WriteStack(); WriteStack();
return true;
} }
public static void ApplicationQuitInt_Prefix(int exitCode) public static bool ApplicationQuitInt_Prefix(int exitCode)
{ {
Write($"[quit] UnityEngine.Application.Quit({exitCode})"); Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack(); 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) public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
@@ -3047,6 +2662,20 @@ internal static class DiagnosticPatches
return false; 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() private static void WriteStack()
{ {
try try
@@ -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) ? "<missing>" : "<set>")}");
}
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;
}
@@ -1,8 +1,5 @@
{ {
"ServerHost": "{HOST}", "ServerHost": "{HOST}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}, "EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"QuestMaxTeamSize": 0, "QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [ "QuestMaxTeamSizeRooms": [
@@ -21,6 +21,102 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")] [HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty }); public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
/// audio/core initialization. Returning 404 leaves the static backtrace
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
/// crash upload/deobfuscation disabled locally while preserving the
/// object shape and readable config keys the client inspects.
/// </summary>
[HttpGet("backtrace")]
public ActionResult<object> 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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["ToxMod.Enabled"] = false,
["ToxMod.SampleRate"] = 0.0,
["ToxMod.SessionRecordingEnabled"] = false,
["ToxMod.Endpoint"] = string.Empty,
["ToxMod.ApiKey"] = string.Empty,
["Enabled"] = false,
["enabled"] = false,
["toxModEnabled"] = false,
});
}
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the /// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean. /// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the /// This server does not run the timed free-gift campaign, so the
@@ -187,11 +187,11 @@ public class CustomAvatarItemsController(
[HttpGet("isCreationEnabled")] [HttpGet("isCreationEnabled")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult IsCreationEnabled() => Ok(new { IsCreationEnabled = true, Enabled = true }); public IActionResult IsCreationEnabled() => Content("true", "application/json");
[HttpGet("isRenderingEnabled")] [HttpGet("isRenderingEnabled")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult IsRenderingEnabled() => Ok(new { IsRenderingEnabled = true, Enabled = true }); public IActionResult IsRenderingEnabled() => Content("true", "application/json");
[HttpGet("minPriceForPublicItem")] [HttpGet("minPriceForPublicItem")]
[AllowAnonymous] [AllowAnonymous]
@@ -95,8 +95,15 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
private static object ToInfluencerWire(PlayerEntity player) => new private static object ToInfluencerWire(PlayerEntity player) => new
{ {
AccountId = (int)player.Id, AccountId = (int)player.Id,
RawUsername = player.Username,
player.Username, player.Username,
DisplayName = player.DisplayName, DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty, ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
IsInfluencer = true,
SupportedInfluencerId = (int?)null,
LocalSupportedInfluencerId = (int?)null,
}; };
} }
@@ -46,6 +46,25 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
return Ok(rows.Select(ToWire)); return Ok(rows.Select(ToWire));
} }
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> 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")] [HttpGet("globalconfig")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new public IActionResult GlobalConfig() => Ok(new
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId) private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{ {
var pid = this.CurrentPlayerId(); 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(); var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType) if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}"; detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
@@ -216,6 +216,13 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
Category = "hile", Category = "hile",
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { success = true }); return HileResult();
} }
private static IActionResult HileResult() => new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = StatusCodes.Status200OK,
};
} }
@@ -1532,6 +1532,144 @@ public class RoomsController(
[HttpGet("api/rooms/v2/myrecent")] [HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>()); public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
[HttpGet("rooms/{roomId:long}/playerdata/me")]
[Authorize]
public async Task<IActionResult> 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<IActionResult> RoomsRequiring(string restriction)
{
var key = (restriction ?? string.Empty).Trim().TrimStart('#').ToLowerInvariant();
if (key.Length == 0) return Ok(new List<object>());
IQueryable<RoomEntity> 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<IActionResult> CuratedPlaylistsCompat()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(BuildPlaylistUnionEntry).ToList());
}
[HttpGet("clubhousesearch/mostactivenow")]
[HttpGet("roomserver/clubhousesearch/mostactivenow")]
[Authorize]
public async Task<IActionResult> 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 ─────────────────────────────────────────────── // ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")] [HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
@@ -100,6 +100,11 @@ public class CdnController(
// String.Concat("/invention/", filename). Same byte-fetch flow as // String.Concat("/invention/", filename). Same byte-fetch flow as
// /room/, routed to S3 via BlobRouter on the filename. // /room/, routed to S3 via BlobRouter on the filename.
[Route("/invention/{*path:minlength(1)}")] [Route("/invention/{*path:minlength(1)}")]
// 2023 builds a CDN URL as `/sigs/<signature-set-id>` 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 // ASP.NET route templates treat `[` / `]` as token markers
// (e.g. `[controller]`). To use them as literals inside a regex // (e.g. `[controller]`). To use them as literals inside a regex
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`. // constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
@@ -266,6 +271,17 @@ public class CdnController(
// ── CDN serve path (cdn.*, storage.*, data.*, apex) ──────────────── // ── CDN serve path (cdn.*, storage.*, data.*, apex) ────────────────
private async Task<IActionResult> ServeCdn(string? path) private async Task<IActionResult> 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<byte>();
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)) if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase))
{ {
// Tips live in the LoadingScreenTips DB table; admins // Tips live in the LoadingScreenTips DB table; admins
@@ -17,10 +17,12 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController] [ApiController]
[Authorize] [Authorize]
public class MatchController( public class MatchController(
IConfiguration config,
GameSessionService sessionService, GameSessionService sessionService,
PlayerPresenceService presence, PlayerPresenceService presence,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
RoomService rooms,
DorkNetDbContext db, DorkNetDbContext db,
ILogger<MatchController> logger) : ControllerBase ILogger<MatchController> logger) : ControllerBase
{ {
@@ -84,6 +86,138 @@ public class MatchController(
return Ok(); return Ok();
} }
[HttpPost("/matchmake/none")]
public async Task<IActionResult> 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<IActionResult> 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<RoomInstanceDto> 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<string> 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();
}
/// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by /// <summary>PUT <c>/roominstance/{id}/inprogress</c> — fired by
/// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c> /// <c>RecNet.Matchmaking.SetGameInProgress(bool inProgress)</c>
@@ -38,6 +38,25 @@ public class MatchPlayerController(
[HttpPost("/player/login")] [HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
=> await RecordLoginLockAsync(loginLock, "player-login");
/// <summary>
/// POST <c>/player/exclusivelogin</c> — newer clients use this instead
/// of <c>/player/login</c> and include a
/// <c>TakeOverExclusiveSession</c> form value. The lock semantics are
/// otherwise the same: the latest LoginLock wins for this account.
/// </summary>
[HttpPost("/player/exclusivelogin")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> PlayerExclusiveLogin(
[FromForm(Name = "LoginLock")] string? loginLock,
[FromForm(Name = "TakeOverExclusiveSession")] bool? takeOverExclusiveSession)
=> await RecordLoginLockAsync(loginLock, "player-exclusive-login", takeOverExclusiveSession);
private async Task<IActionResult> RecordLoginLockAsync(
string? loginLock,
string logScope,
bool? takeOverExclusiveSession = null)
{ {
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -46,8 +65,8 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( log.LogWarning(
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push", "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
playerId); logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet // The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's // (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the // boot sequence — verified in the live trace), so the
+2 -2
View File
@@ -7,7 +7,7 @@
install made by tools/install-plugin.ps1, then runs tools/patch-client.ps1 install made by tools/install-plugin.ps1, then runs tools/patch-client.ps1
in its legacy mode: 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 - byte-patches the native BestHTTP/BouncyCastle TLS certificate
verifier return in GameAssembly.dll; verifier return in GameAssembly.dll;
- points *.rec.net at the DorkNet server through the hosts file; - 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-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) { if ($CertPath -and $DryRun) {
& $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath -DryRun & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath -DryRun
} elseif ($CertPath) { } elseif ($CertPath) {
+5 -17
View File
@@ -10,9 +10,9 @@
against MelonLoader's generated Il2CppAssemblies. against MelonLoader's generated Il2CppAssemblies.
3. Drops the built DLL into <RecRoomInstall>\Mods\ and writes 3. Drops the built DLL into <RecRoomInstall>\Mods\ and writes
<RecRoomInstall>\MelonLoader\UserData\dorknet-clientmod.json <RecRoomInstall>\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 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 image signature verifier, and the CodeStage Anti-Cheat Toolkit
neutraliser). neutraliser).
@@ -99,16 +99,12 @@ param(
[Parameter(ParameterSetName = 'Install')] [Parameter(ParameterSetName = 'Install')]
[string]$PhotonVoiceAppId, [string]$PhotonVoiceAppId,
[Parameter(ParameterSetName = 'Install')] # Read PhotonAppId / VoiceAppId from the DorkNet.Server
[ValidateSet('us','eu','asia','jp','au','usw','sa','cae','kr','in','ru','rue')]
[string]$PhotonCloudRegion = 'us',
# Read PhotonAppId / VoiceAppId / CloudRegion from the DorkNet.Server
# appsettings file at this path. Use the value from # appsettings file at this path. Use the value from
# `DorkNet.Server\appsettings.Local.json` (falls back to # `DorkNet.Server\appsettings.Local.json` (falls back to
# `appsettings.json`) so you don't have to retype the GUIDs you # `appsettings.json`) so you don't have to retype the GUIDs you
# already configured on the server side. Any explicit -PhotonAppId / # already configured on the server side. Any explicit -PhotonAppId /
# -PhotonVoiceAppId / -PhotonCloudRegion override the file values. # -PhotonVoiceAppId override the file values.
[Parameter(ParameterSetName = 'Install')] [Parameter(ParameterSetName = 'Install')]
[string]$FromServerConfig, [string]$FromServerConfig,
@@ -334,13 +330,9 @@ if ($PSCmdlet.ParameterSetName -eq 'Install') {
if (-not $cfg) { continue } if (-not $cfg) { continue }
if (-not $PhotonAppId -and $cfg.AppId) { $PhotonAppId = $cfg.AppId } if (-not $PhotonAppId -and $cfg.AppId) { $PhotonAppId = $cfg.AppId }
if (-not $PhotonVoiceAppId -and $cfg.VoiceAppId) { $PhotonVoiceAppId = $cfg.VoiceAppId } 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) " + Write-OK ("Loaded Photon config from $($cfg.Source) " +
"(AppId=$(if($cfg.AppId){'<set>'}else{'<unset>'}), " + "(AppId=$(if($cfg.AppId){'<set>'}else{'<unset>'}), " +
"VoiceAppId=$(if($cfg.VoiceAppId){'<set>'}else{'<unset>'}), " + "VoiceAppId=$(if($cfg.VoiceAppId){'<set>'}else{'<unset>'}))")
"CloudRegion=$(if($cfg.CloudRegion){$cfg.CloudRegion}else{'<unset>'}))")
break break
} }
@@ -435,10 +427,6 @@ New-Item -ItemType Directory -Path $ModsDir -Force | Out-Null
$cfgPath = Join-Path $UserDataDir 'dorknet-clientmod.json' $cfgPath = Join-Path $UserDataDir 'dorknet-clientmod.json'
$cfg = [ordered]@{ $cfg = [ordered]@{
ServerHost = $ServerHost ServerHost = $ServerHost
PhotonAppId = $PhotonAppId
PhotonVoiceAppId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' }
PhotonCloudRegion = $PhotonCloudRegion
InjectAuthValues = $true
EnableTlsTrustBypass = $true EnableTlsTrustBypass = $true
# Opt-in: force-enable the client's built-in debug console (dev gate # Opt-in: force-enable the client's built-in debug console (dev gate
# bypassed) + silence CheatManager so SetTimeScale / Fly / Teleport etc. # bypassed) + silence CheatManager so SetTimeScale / Fly / Teleport etc.
+264
View File
@@ -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.'
}