mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
+109
-480
@@ -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]
|
||||||
|
|||||||
+8
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user