Files
DorkNet/DorkNet.ClientPatch/Plugin.cs
T
Alexa 4edf25b2d5 Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
2026-05-27 01:04:12 +03:00

2012 lines
88 KiB
C#

using System;
using System.Collections.Generic;
using System.Reflection;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Unity.IL2CPP;
using BepInEx.Logging;
using HarmonyLib;
using ExitGames.Client.Photon.LoadBalancing; // AuthenticationValues, CustomAuthenticationType
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using RecNet; // Core, Login, Matchmaking
using Steamworks;
using UnityEngine;
namespace DorkNet.ClientPatch;
/// <summary>
/// BepInEx 6 IL2CPP plugin that owns every client-side change DorkNet
/// needs to make to the 2020 watch — Photon AppId override, RecNet
/// domain rewrite, and Photon CustomAuth identity injection.
///
/// Replaces the previous patch-client.ps1 byte-edit approach so the
/// flow on a client update is now: download the new build → drop the
/// plugin in BepInEx/plugins/ → done. No more hardcoded resources.assets
/// offsets, no more hosts-file edits, no more mkcert wildcard.
///
/// Configuration lives in BepInEx/config/sh.dork.clientpatch.cfg
/// (auto-created on first run). Edit values, restart the client, the
/// patches re-apply with the new settings.
/// </summary>
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public sealed class Plugin : BasePlugin
{
internal static new ManualLogSource Log = null!;
/// <summary>Replacement domain for every <c>*.rec.net</c> URL the watch
/// would otherwise hit. <c>"localhost"</c> means
/// <c>https://ns.rec.net/?v=2</c> → <c>https://ns.localhost/?v=2</c> at
/// runtime (Uri ctor patch). Empty disables the rewrite.</summary>
public static ConfigEntry<string> ServerHost = null!;
/// <summary>Photon Cloud Realtime AppId. When non-empty, replaces
/// <c>PhotonNetwork.PhotonServerSettings.AppID</c> right before
/// <c>ConnectUsingSettings</c> hands off to NetworkingPeer.</summary>
public static ConfigEntry<string> PhotonAppId = null!;
/// <summary>Photon Voice AppId. Empty falls back to
/// <see cref="PhotonAppId"/>.</summary>
public static ConfigEntry<string> PhotonVoiceAppId = null!;
/// <summary>Send <c>userid</c>+<c>LoginLock</c> AuthValues with the
/// Photon connect so DorkNet's <c>/photon/customauth</c> endpoint
/// can validate the session. Disable while debugging Photon
/// connectivity if AuthValues are interfering.</summary>
public static ConfigEntry<bool> InjectAuthValues = null!;
/// <summary>Replacement branding string. Every user-facing UI
/// label that contains "RecNet" gets the substring replaced
/// with this value at runtime — so error dialogs read
/// "Failed to connect to DorkNet" instead of
/// "Failed to connect to RecNet". Empty disables the rewrite
/// (useful if you want logs to keep matching upstream
/// documentation).</summary>
public static ConfigEntry<string> BrandName = null!;
public static ConfigEntry<bool> EnableBrandRename = null!;
/// <summary>Skip the Steam DRM "RestartAppIfNecessary" check. The
/// vanilla watch calls
/// <c>Steamworks.SteamAPI.RestartAppIfNecessary(471710)</c> on
/// boot, which forces a relaunch through Steam if the .exe was
/// started directly (Steam not running, or launched from
/// outside Steam). Enabling this returns false from that call so
/// the watch keeps booting wherever it was started. Has no effect
/// if you're launching from Steam normally.</summary>
public static ConfigEntry<bool> SkipSteamRestart = null!;
/// <summary>Fake out the Steamworks SDK so the watch can boot
/// without Steam.exe running. Patches the C#-level Steamworks.NET
/// wrapper methods to return canned success values WITHOUT calling
/// the underlying native steam_api64.dll, so we don't need
/// Goldberg / SmartSteamEmu / etc. (which trip Defender as PUA).
///
/// Patches applied when on:
/// - SteamAPI.Init → true
/// - SteamAPI.IsSteamRunning → true
/// - SteamAPI.GetHSteamPipe → fake non-zero handle
/// - SteamAPI.GetHSteamUser → fake non-zero handle
/// - SteamUser.GetSteamID → CSteamID built from FakeSteamId
/// - SteamFriends.GetPersonaName → FakeAccountName
/// - SteamPlatformManager.GetAuthSessionTicket → fake bytes
///
/// Our DorkNet auth backend doesn't actually validate the Steam
/// ticket — it just uses platformId (SteamID) as the
/// cached_login lookup key — so the fake ticket is fine.</summary>
public static ConfigEntry<bool> FakeSteamApi = null!;
/// <summary>SteamID64 to fake when <see cref="FakeSteamApi"/> is on.
/// Match the value our auth backend has on file for the player —
/// the same SteamID lets the player keep their account across
/// machines. Empty = deterministic per-machine ID derived from
/// the OS user + machine name.</summary>
public static ConfigEntry<string> FakeSteamId = null!;
/// <summary>Display name returned from
/// <c>Steamworks.SteamFriends.GetPersonaName</c> when
/// <see cref="FakeSteamApi"/> is on. Empty = the OS user name.</summary>
public static ConfigEntry<string> FakeAccountName = null!;
public static ConfigEntry<bool> EnableTlsTrustBypass = null!;
public static ConfigEntry<bool> EnablePhotonRegionBypass = null!;
public static ConfigEntry<bool> EnableBootDiagnostics = null!;
public static ConfigEntry<bool> DetailedUnityLogs = null!;
/// <summary>Targeted diagnostics for avatar/store DTOs and UI binding
/// failures. This is intentionally narrower than raw HTTP tracing so the
/// logs identify crash-causing item ids/GUIDs without dumping tokens or
/// every request body.</summary>
public static ConfigEntry<bool> DetailedAvatarStoreLogs = null!;
/// <summary>Legacy crash guard that replaces avatar item GUIDs unknown to
/// the local client with a known-safe item before the client parser runs.
/// Keep off by default now that the server should only send client-known
/// wardrobe items; enabling it changes the visible equipped item.</summary>
public static ConfigEntry<bool> SubstituteUnknownAvatarItems = null!;
/// <summary>Legacy guard for stale servers that returned the wrong shape
/// from storefront objective completion. Keep off by default so the client
/// exercises the real server endpoint.</summary>
public static ConfigEntry<bool> SkipStorefrontObjectivePosts = null!;
/// <summary>Legacy mirror guard that blocks direct torso unequip. This can
/// interfere with normal outfit editing, so it is opt-in only.</summary>
public static ConfigEntry<bool> BlockDirectTorsoUnequip = null!;
/// <summary>Cleanup exception suppressor used only while debugging teardown
/// crashes. Keep off by default so real crashes stay visible.</summary>
public static ConfigEntry<bool> SuppressScreenCleanupNullRef = null!;
public static ConfigEntry<bool> FailFastRequiredPatches = null!;
public static ConfigEntry<bool> CatchAllManagedExceptions = null!;
public override void Load()
{
Log = base.Log;
ServerHost = Config.Bind(
"Server", "Host", "localhost",
"Domain that replaces *.rec.net in every URL the watch builds. " +
"Empty disables the rewrite (use only if pointing at the official servers).");
PhotonAppId = Config.Bind(
"Photon", "AppId", string.Empty,
"Photon Cloud Realtime AppId (UUID, no curly braces). " +
"Empty leaves the AppId baked into the build untouched.");
PhotonVoiceAppId = Config.Bind(
"Photon", "VoiceAppId", string.Empty,
"Photon Cloud Voice AppId. Empty falls back to AppId.");
InjectAuthValues = Config.Bind(
"Photon", "InjectAuthValues", true,
"Attach userid + LoginLock to PhotonNetwork.AuthValues right before " +
"OpAuthenticate so DorkNet's /photon/customauth endpoint can identify " +
"the player. Disable to debug pure Photon connectivity issues.");
BrandName = Config.Bind(
"UI", "BrandName", "DorkNet",
"Replacement branding string. Any UI label containing 'RecNet' " +
"gets that substring replaced with this value at runtime, so " +
"error dialogs and the loading screen show the private-server " +
"brand. Empty disables the rewrite.");
EnableBrandRename = Config.Bind(
"UI", "EnableBrandRename", false,
"Patch Unity/TMP text setters to replace RecNet with BrandName. " +
"Disabled by default because broad UI text setter hooks add a lot " +
"of IL2CPP detours for cosmetic value only.");
SkipSteamRestart = Config.Bind(
"Steam", "SkipRestartAppIfNecessary", true,
"Bypass Steamworks.SteamAPI.RestartAppIfNecessary so the watch " +
"doesn't relaunch itself through Steam on boot. No effect when " +
"launched normally from the Steam library; useful when running " +
"the game directly via Recroom_Release.exe (BepInEx mod-loader " +
"shims often need this to avoid an infinite restart loop).");
FakeSteamApi = Config.Bind(
"Steam", "FakeSteamApi", false,
"Pretend the Steam SDK initialised even when Steam.exe isn't " +
"running. Patches Steamworks.NET wrappers to return canned " +
"success values so the watch's SteamPlatformManager.Initialize " +
"succeeds without a live Steam process. DorkNet's auth backend " +
"uses SteamID as a lookup key only — no real ticket validation — " +
"so the fake ticket is accepted. Default off so launches via " +
"Steam itself behave normally.");
FakeSteamId = Config.Bind(
"Steam", "FakeSteamId", string.Empty,
"SteamID64 returned by SteamUser.GetSteamID when FakeSteamApi=true. " +
"Empty = random 63-bit ID generated on first run and persisted " +
"to BepInEx/config/sh.dork.clientpatch.deviceid. Reuses the " +
"same ID on every subsequent launch on the same install. " +
"Pin an explicit ID to share an account across different " +
"machines (e.g. paste your real SteamID64 to migrate from a " +
"previous manually-modded client).");
FakeAccountName = Config.Bind(
"Steam", "FakeAccountName", string.Empty,
"Display name returned by SteamFriends.GetPersonaName when " +
"FakeSteamApi=true. Empty = OS user name.");
EnableTlsTrustBypass = Config.Bind(
"Network", "EnableTlsTrustBypass", true,
"Bypass the old BestHTTP/BouncyCastle certificate verifier. " +
"Required for many modern certificates, but kept as a switch so " +
"the patch can be removed when testing plugin stability.");
EnablePhotonRegionBypass = Config.Bind(
"Photon", "EnableRegionBypass", true,
"Bypass the 2020 Photon region ping coroutine and force us. " +
"Required when modern Photon returns region codes this client " +
"does not know.");
EnableBootDiagnostics = Config.Bind(
"Diagnostics", "EnableBootDiagnostics", false,
"Patch BootSequence.RegisterError to log extra stack traces. " +
"Disabled by default because it hooks a fragile IL2CPP boot method.");
DetailedUnityLogs = Config.Bind(
"Diagnostics", "DetailedUnityLogs", true,
"Enable full Unity stack traces for Log/Warning/Error/Exception " +
"messages and mirror Unity exceptions into the BepInEx log. " +
"Useful while chasing client teardown crashes.");
DetailedAvatarStoreLogs = Config.Bind(
"Diagnostics", "DetailedAvatarStoreLogs", true,
"Log avatar/store DTOs and BrowsableAvatarItem binding failures. " +
"Useful when the watch tears down after opening the store, mirror, " +
"or gift UI. Logs are capped per run to keep LogOutput.log readable.");
SubstituteUnknownAvatarItems = Config.Bind(
"Compatibility", "SubstituteUnknownAvatarItems", true,
"Replace avatar item GUIDs the local client does not know with a " +
"safe fallback item. This prevents parser crashes from bad server " +
"data, but can change clothing appearance for unsupported items.");
SkipStorefrontObjectivePosts = Config.Bind(
"Compatibility", "SkipStorefrontObjectivePosts", false,
"Skip RecNet.Storefronts.CompleteObjectives client calls. This was " +
"only for stale servers that returned the wrong response shape; " +
"leave off so the server endpoint is tested normally.");
BlockDirectTorsoUnequip = Config.Bind(
"Compatibility", "BlockDirectTorsoUnequip", false,
"Block PlayerAvatar.UnequipOutfitItem when the target body part is " +
"torso. This can stop the mirror UI from unequipping shirts, so it " +
"is disabled by default.");
SuppressScreenCleanupNullRef = Config.Bind(
"Diagnostics", "SuppressScreenCleanupNullRef", false,
"Suppress one known ScreenPlayerController.CleanupLocalPlayer " +
"NullReferenceException during teardown. Disabled by default so " +
"crashes stay visible in logs.");
FailFastRequiredPatches = Config.Bind(
"Diagnostics", "FailFastRequiredPatches", false,
"Abort plugin startup when a required Harmony patch cannot be " +
"applied. Disabled by default so one IL2CPP target mismatch is " +
"logged without taking down every other patch.");
CatchAllManagedExceptions = Config.Bind(
"Compatibility", "CatchAllManagedExceptions", true,
"Install broad managed exception guards: observe task exceptions, " +
"log AppDomain unhandled exceptions, and swallow exceptions that " +
"escape the Rec Room scheduler frame loop. This cannot recover " +
"native Unity access violations.");
GlobalManagedCatchAll.Install();
UnityDetailedLogBridge.Install();
Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME} v{MyPluginInfo.PLUGIN_VERSION} loaded. " +
$"ServerHost={ServerHost.Value}, PhotonAppId={(string.IsNullOrEmpty(PhotonAppId.Value) ? "<unchanged>" : "<set>")}, " +
$"InjectAuthValues={InjectAuthValues.Value}, DetailedUnityLogs={DetailedUnityLogs.Value}, " +
$"DetailedAvatarStoreLogs={DetailedAvatarStoreLogs.Value}, " +
$"EnableBrandRename={EnableBrandRename.Value}, EnableTlsTrustBypass={EnableTlsTrustBypass.Value}, " +
$"EnablePhotonRegionBypass={EnablePhotonRegionBypass.Value}, EnableBootDiagnostics={EnableBootDiagnostics.Value}, " +
$"SubstituteUnknownAvatarItems={SubstituteUnknownAvatarItems.Value}, " +
$"SkipStorefrontObjectivePosts={SkipStorefrontObjectivePosts.Value}, " +
$"BlockDirectTorsoUnequip={BlockDirectTorsoUnequip.Value}, " +
$"SuppressScreenCleanupNullRef={SuppressScreenCleanupNullRef.Value}, " +
$"FailFastRequiredPatches={FailFastRequiredPatches.Value}, " +
$"CatchAllManagedExceptions={CatchAllManagedExceptions.Value}");
var harmony = new Harmony(MyPluginInfo.PLUGIN_GUID);
ApplyPatches(harmony);
}
private static void ApplyPatches(Harmony harmony)
{
// Do not use PatchAll here. On IL2CPP, even discovering and
// preparing unused patch classes can touch fragile generated methods.
// Keep the active detour set explicit and close to the manual patcher.
PatchRequired(harmony, typeof(UriStringCtorPatch), UriStringCtorPatch.Prepare(), "URI string rewrite");
PatchRequired(harmony, typeof(UriStringKindCtorPatch), UriStringKindCtorPatch.Prepare(), "URI string/kind rewrite");
PatchRequired(harmony, typeof(PhotonAppIdOverride), PhotonAppIdOverride.Prepare(), "Photon AppId override");
PatchRequired(harmony, typeof(AuthValuesInjector), AuthValuesInjector.Prepare(), "Photon auth injector");
PatchRequired(harmony, typeof(TlsTrustServerOnly), TlsTrustServerOnly.Prepare(), "BestHTTP TLS trust bypass");
PatchRequired(harmony, typeof(TlsTrustLegacy), TlsTrustLegacy.Prepare(), "legacy TLS trust bypass");
PatchRequired(harmony, typeof(PhotonRegionPingShortcut), PhotonRegionPingShortcut.Prepare(), "Photon region shortcut");
PatchOptional(harmony, typeof(TMPTextBrandRename), TMPTextBrandRename.Prepare(), "TMP brand rename");
PatchOptional(harmony, typeof(UITextBrandRename), UITextBrandRename.Prepare(), "Unity UI brand rename");
PatchOptional(harmony, typeof(SteamRestartBypass), SteamRestartBypass.Prepare(), "Steam restart bypass");
PatchOptional(harmony, typeof(InteropHelpTestClientFake), SteamFakery.Enabled, "fake Steam client availability");
PatchOptional(harmony, typeof(InteropHelpTestServerFake), SteamFakery.Enabled, "fake Steam server availability");
PatchOptional(harmony, typeof(InteropHelpTestPlatformFake), SteamFakery.Enabled, "fake Steam platform availability");
PatchOptional(harmony, typeof(SteamApiInitFake), SteamFakery.Enabled, "fake Steam init");
PatchOptional(harmony, typeof(SteamApiIsRunningFake), SteamFakery.Enabled, "fake Steam running");
PatchOptional(harmony, typeof(SteamApiGetPipeFake), SteamFakery.Enabled, "fake Steam pipe");
PatchOptional(harmony, typeof(SteamApiGetUserFake), SteamFakery.Enabled, "fake Steam user");
PatchOptional(harmony, typeof(SteamUserIdFake), SteamFakery.Enabled, "fake Steam id");
PatchOptional(harmony, typeof(SteamFriendsPersonaNameFake), SteamFakery.Enabled, "fake Steam persona");
PatchOptional(harmony, typeof(SteamFriendsCountFake), SteamFakery.Enabled, "fake Steam friend count");
PatchOptional(harmony, typeof(SteamFriendsByIndexFake), SteamFakery.Enabled, "fake Steam friend index");
PatchOptional(harmony, typeof(SteamApiRunCallbacksFake), SteamFakery.Enabled, "fake Steam callbacks");
PatchOptional(harmony, typeof(SteamApiShutdownFake), SteamFakery.Enabled, "fake Steam shutdown");
PatchOptional(harmony, typeof(SteamClientWarningHookFake), SteamFakery.Enabled, "fake Steam warning hook");
PatchOptional(harmony, typeof(SteamCallbackNativeRegistrationFake), SteamFakery.Enabled, "fake Steam callback registration");
PatchOptional(harmony, typeof(SteamFriendsFriendNameFake), SteamFakery.Enabled, "fake Steam friend name");
PatchOptional(harmony, typeof(SteamFriendsAvatarFake), SteamFakery.Enabled, "fake Steam avatar");
PatchOptional(harmony, typeof(SteamFriendsRichPresenceFake), SteamFakery.Enabled, "fake Steam rich presence");
PatchOptional(harmony, typeof(SteamUtilsImageRGBAFake), SteamFakery.Enabled, "fake Steam image rgba");
PatchOptional(harmony, typeof(SteamUtilsImageSizeFake), SteamFakery.Enabled, "fake Steam image size");
PatchOptional(harmony, typeof(SteamGetAuthTicketShortcut), SteamFakery.Enabled, "fake Steam platform ticket");
PatchOptional(harmony, typeof(SteamUserGetAuthTicketFake), SteamFakery.Enabled, "fake Steam user ticket");
PatchOptional(harmony, typeof(AvatarItemParseSubstitute), AvatarItemParseSubstitute.Prepare(), "avatar item substitute");
PatchOptional(harmony, typeof(AvatarItemParseSubstituteTwoArg), AvatarItemParseSubstituteTwoArg.Prepare(), "avatar item substitute two-arg");
PatchOptional(harmony, typeof(DynamicAvatarItemImposterCatchAll), GlobalManagedCatchAll.Enabled, "avatar imposter catch-all");
PatchOptional(harmony, typeof(SchedulerManagedCatchAll), GlobalManagedCatchAll.Enabled, "scheduler managed catch-all");
PatchOptional(harmony, typeof(StorefrontCompleteObjectivesGuard), StorefrontCompleteObjectivesGuard.Prepare(), "storefront objective guard");
PatchOptional(harmony, typeof(StorefrontGiftDropDiagnostics), AvatarStoreDiagnostics.Enabled, "storefront gift diagnostics");
PatchOptional(harmony, typeof(UnlockedAvatarItemDiagnostics), AvatarStoreDiagnostics.Enabled, "unlocked avatar diagnostics");
PatchOptional(harmony, typeof(GiftPackageDiagnostics), AvatarStoreDiagnostics.Enabled, "gift package diagnostics");
PatchOptional(harmony, typeof(StorefrontBalanceDiagnostics), AvatarStoreDiagnostics.Enabled, "storefront balance diagnostics");
PatchOptional(harmony, typeof(PlayerAvatarOutfitDiagnostics), AvatarStoreDiagnostics.Enabled, "player outfit diagnostics");
PatchOptional(harmony, typeof(RequiredTorsoUnequipGuard), RequiredTorsoUnequipGuard.Prepare(), "torso unequip guard");
PatchOptional(harmony, typeof(ScreenPlayerCleanupGuard), ScreenPlayerCleanupGuard.Prepare(), "screen cleanup guard");
PatchOptional(harmony, typeof(BootSequenceRegisterErrorTrace), BootSequenceRegisterErrorTrace.Prepare(), "boot diagnostics");
Log.LogInfo("Explicit Harmony patch list applied.");
}
private static void PatchRequired(Harmony harmony, Type patchType, bool enabled, string label)
{
if (!enabled)
{
Log.LogInfo($"[patch-skip] {label}");
return;
}
try
{
harmony.CreateClassProcessor(patchType).Patch();
Log.LogInfo($"[patch-ok] {label}");
}
catch (Exception ex)
{
Log.LogError($"[patch-failed] {label}: {ex}");
if (FailFastRequiredPatches?.Value == true)
throw;
}
}
private static void PatchOptional(Harmony harmony, Type patchType, bool enabled, string label)
{
if (!enabled)
{
Log.LogDebug($"[patch-skip] {label}");
return;
}
try
{
harmony.CreateClassProcessor(patchType).Patch();
Log.LogInfo($"[patch-ok] {label}");
}
catch (Exception ex)
{
Log.LogWarning($"[patch-failed] optional {label}: {ex}");
}
}
}
internal static class MyPluginInfo
{
public const string PLUGIN_GUID = "sh.dork.clientpatch";
public const string PLUGIN_NAME = "DorkNet ClientPatch";
public const string PLUGIN_VERSION = "1.4.8";
}
// ── 1. URI rewrite — every Uri the watch builds with a .rec.net host
// gets its host substituted to ServerHost. Catches the bootstrap
// https://ns.rec.net/?v=2 (the only hard-coded URL in the watch —
// every other service URL comes back in that response, which our
// server controls). Also catches any odd hard-coded Photon Voice /
// realtime URLs the SDK might construct.
//
// Patching the BCL Uri constructor is broader than ideal — any
// string containing ".rec.net" will be rewritten — but in practice
// the watch doesn't construct any non-URL strings with that
// substring. If false positives appear we'll narrow to the
// HTTPRequest constructor or transpiler the call site.
[HarmonyPatch(typeof(Uri), MethodType.Constructor, new[] { typeof(string) })]
internal static class UriStringCtorPatch
{
public static bool Prepare() =>
Plugin.ServerHost != null && !string.IsNullOrEmpty(Plugin.ServerHost.Value);
public static void Prefix(ref string uriString)
{
if (string.IsNullOrEmpty(uriString)) return;
if (string.IsNullOrEmpty(Plugin.ServerHost.Value)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
var rewritten = uriString.Replace(".rec.net", "." + Plugin.ServerHost.Value);
if (rewritten != uriString)
{
Plugin.Log.LogInfo($"[uri-rewrite] {uriString} → {rewritten}");
uriString = rewritten;
}
}
}
[HarmonyPatch(typeof(Uri), MethodType.Constructor, new[] { typeof(string), typeof(UriKind) })]
internal static class UriStringKindCtorPatch
{
public static bool Prepare() => UriStringCtorPatch.Prepare();
public static void Prefix(ref string uriString)
{
// Reuse the string-only patch's logic by piping through it. Simpler
// than copy-pasting and keeps a single rewrite implementation.
UriStringCtorPatch.Prefix(ref uriString);
}
}
// ── 2. Photon AppId override — patched at the latest possible moment
// (right before NetworkingPeer.Connect uses ServerSettings.AppID).
// Patching the ScriptableObject directly at plugin Load doesn't
// work because ServerSettings is loaded from Resources lazily; by
// the time Connect runs, it's loaded and we can mutate it.
[HarmonyPatch(typeof(PhotonNetwork), nameof(PhotonNetwork.ConnectUsingSettings))]
internal static class PhotonAppIdOverride
{
public static bool Prepare() =>
Plugin.PhotonAppId != null && !string.IsNullOrEmpty(Plugin.PhotonAppId.Value);
public static void Prefix()
{
if (string.IsNullOrEmpty(Plugin.PhotonAppId.Value)) return;
try
{
var settings = PhotonNetwork.PhotonServerSettings;
if (settings == null) return;
var newAppId = Plugin.PhotonAppId.Value;
var newVoiceId = string.IsNullOrEmpty(Plugin.PhotonVoiceAppId.Value)
? newAppId
: Plugin.PhotonVoiceAppId.Value;
if (settings.AppID != newAppId || settings.VoiceAppID != newVoiceId)
{
Plugin.Log.LogInfo(
$"[photon-appid] override AppID={settings.AppID}→{newAppId}, " +
$"VoiceAppID={settings.VoiceAppID}→{newVoiceId}");
settings.AppID = newAppId;
settings.VoiceAppID = newVoiceId;
}
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[photon-appid] override failed: {ex.Message}");
}
}
}
// ── 3. Photon AuthValues injector — sets userid + LoginLock in
// PhotonNetwork.AuthValues right before any authenticate op leaves
// the wire. Verified empirically: vanilla 2020 watch never sets
// AuthValues itself, so we have to do it externally for our
// /photon/customauth endpoint to receive identity.
// NetworkingPeer.CallAuthenticate is the watch's single internal entry
// point that ultimately calls LoadBalancingPeer.OpAuthenticate /
// OpAuthenticateOnce on the wire. The vanilla 2020 watch never sets
// PhotonNetwork.AuthValues itself, so we set it here just before
// CallAuthenticate forwards to the base op.
[HarmonyPatch(typeof(NetworkingPeer), nameof(NetworkingPeer.CallAuthenticate))]
internal static class AuthValuesInjector
{
public static bool Prepare() =>
Plugin.InjectAuthValues != null && Plugin.InjectAuthValues.Value;
[HarmonyPrefix]
public static void EnsureAuthValues(NetworkingPeer __instance)
{
if (!Plugin.InjectAuthValues.Value) return;
try
{
var existing = PhotonNetwork.AuthValues;
if (existing != null && !string.IsNullOrEmpty(existing.UserId))
{
// Already set this run — don't clobber. Lets a future
// patch upstream us if needed.
return;
}
var accountId = Core.LocalAccountId;
if (accountId <= 0)
{
// Pre-login Photon ping has no account yet; let it go
// through anonymous, the post-login reconnect will pick
// up our values.
Plugin.Log.LogInfo("[auth-injector] skip — no LocalAccountId yet");
return;
}
// Read the LoginLock straight from PlayerPrefs (key
// LoginLockTokenV2). The watch persists it there in
// Matchmaking.Login at login time, so by the time any
// Photon op fires it's already on disk — no need to call
// the private Matchmaking.LoadLockToken().
var token = MatchmakingHelpers.GetCurrentLockToken();
var av = new AuthenticationValues
{
AuthType = CustomAuthenticationType.Custom,
UserId = accountId.ToString(),
};
av.AddAuthParameter("userid", accountId.ToString());
av.AddAuthParameter("LoginLock", token ?? string.Empty);
PhotonNetwork.AuthValues = av;
Plugin.Log.LogInfo(
$"[auth-injector] set Photon AuthValues userid={accountId} " +
$"LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[auth-injector] failed: {ex.Message}");
}
}
}
// ── 4. UI brand rename — replace "RecNet" → BrandName in any text the
// watch displays. Hook the two universal text-set entry points
// (Unity UI Text + TMP_Text) so we catch both legacy and modern
// UI bindings. The watch's hardcoded error strings ("Failed to
// connect to RecNet (error code: …)", "Connecting to RecNet…",
// etc.) flow through these setters before reaching the user.
[HarmonyPatch(typeof(TMPro.TMP_Text), nameof(TMPro.TMP_Text.text), MethodType.Setter)]
internal static class TMPTextBrandRename
{
public static bool Prepare() =>
Plugin.EnableBrandRename != null && Plugin.EnableBrandRename.Value &&
Plugin.BrandName != null && !string.IsNullOrEmpty(Plugin.BrandName.Value);
public static void Prefix(ref string value)
{
BrandRenameHelper.Apply(ref value);
}
}
[HarmonyPatch(typeof(UnityEngine.UI.Text), nameof(UnityEngine.UI.Text.text), MethodType.Setter)]
internal static class UITextBrandRename
{
public static bool Prepare() => TMPTextBrandRename.Prepare();
public static void Prefix(ref string value)
{
BrandRenameHelper.Apply(ref value);
}
}
internal static class BrandRenameHelper
{
public static void Apply(ref string value)
{
if (string.IsNullOrEmpty(value)) return;
var brand = Plugin.BrandName.Value;
if (string.IsNullOrEmpty(brand)) return;
if (value.IndexOf("RecNet", StringComparison.Ordinal) < 0) return;
var replaced = value.Replace("RecNet", brand);
if (replaced != value) value = replaced;
}
}
// ── 5. Steam DRM bypass — short-circuit
// Steamworks.SteamAPI.RestartAppIfNecessary so the watch doesn't
// forcibly re-launch through Steam when the .exe was started
// directly. Has no effect when launched from the Steam library
// (RestartAppIfNecessary returns false anyway in that case);
// matters when running the game outside Steam, which is the
// common modding/dev path.
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.RestartAppIfNecessary))]
internal static class SteamRestartBypass
{
public static bool Prepare() =>
Plugin.SkipSteamRestart != null && Plugin.SkipSteamRestart.Value;
public static bool Prefix(ref bool __result)
{
if (!Plugin.SkipSteamRestart.Value) return true; // run original
Plugin.Log.LogInfo("[steam-bypass] short-circuiting RestartAppIfNecessary → false");
__result = false;
return false; // skip original
}
}
// ── 6. Fake Steamworks SDK — when FakeSteamApi=true we hijack the
// C#-level Steamworks.NET wrapper methods to return canned values
// WITHOUT calling steam_api64.dll. This is the AV-friendly
// alternative to Goldberg/SmartSteamEmu — pure managed code,
// no replacement DLL, no PUA flag.
//
// Coverage is just-enough for the watch's boot path. We patch:
// - SteamAPI.Init / IsSteamRunning / GetHSteamPipe / GetHSteamUser
// - SteamUser.GetSteamID / GetAuthSessionTicket
// - SteamFriends.GetPersonaName
// If the watch ever calls a Steamworks method we don't fake, the
// underlying DLL stub returns its default (usually "no" / 0) and
// that path quietly does nothing — better than crashing.
internal static class SteamFakery
{
public static bool Enabled =>
Plugin.FakeSteamApi != null && Plugin.FakeSteamApi.Value;
private static ulong? _resolvedSteamId;
public static ulong ResolvedSteamId
{
get
{
if (_resolvedSteamId.HasValue) return _resolvedSteamId.Value;
var raw = Plugin.FakeSteamId.Value;
if (!string.IsNullOrEmpty(raw) && ulong.TryParse(raw, out var explicitId))
{
_resolvedSteamId = explicitId;
return explicitId;
}
// Per-install random ID, persisted to BepInEx/config/
// sh.dork.clientpatch.deviceid. Generated once on first
// run; reused on every subsequent launch on the same
// install. Keeps the same DorkNet account across plugin
// updates and game updates, but a clean reinstall (or a
// different machine without the file copied over) gets a
// brand-new account.
//
// Why not SHA256(username + machine)? The mod-31-bit
// collision space (we packed it into the 9-digit
// 76561198_xxxxxxxxx slot) made birthday-paradox collisions
// realistic — Alexa@DESKTOP-XYZ is plausibly the same as
// someone else's. Random 63-bit gives ~2³¹ users before
// 50% collision, which we'll comfortably never hit.
var deviceFile = System.IO.Path.Combine(
BepInEx.Paths.ConfigPath, "sh.dork.clientpatch.deviceid");
ulong rand;
try
{
if (System.IO.File.Exists(deviceFile) &&
ulong.TryParse(System.IO.File.ReadAllText(deviceFile).Trim(), out var stored))
{
rand = stored;
}
else
{
var bytes = new byte[8];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
// Mask top bit so the resulting SteamID64 fits the
// valid Public-universe individual range (the
// top byte stays 0x01).
rand = System.BitConverter.ToUInt64(bytes, 0) & 0x7FFF_FFFF_FFFF_FFFFUL;
System.IO.File.WriteAllText(deviceFile, rand.ToString());
Plugin.Log.LogInfo($"[steam-fake] generated new deviceid → {deviceFile}");
}
}
catch (Exception ex)
{
// Filesystem unavailable for some reason — fall back
// to a one-shot random ID so the boot still proceeds.
// (Account won't persist across this session, but at
// least the player can play.)
Plugin.Log.LogWarning($"[steam-fake] deviceid persistence failed ({ex.Message}); using ephemeral ID");
var bytes = new byte[8];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
rand = System.BitConverter.ToUInt64(bytes, 0) & 0x7FFF_FFFF_FFFF_FFFFUL;
}
// SteamID64 layout: top 32 bits = universe(8) + accountType(4) + instance(20),
// bottom 32 bits = accountID. The 76561197960265728 prefix is
// (Public, Individual, Desktop). We OR our random low-32 bits in.
const ulong SteamId64Base = 76561197960265728UL;
_resolvedSteamId = SteamId64Base + (rand & 0xFFFF_FFFFUL);
return _resolvedSteamId.Value;
}
}
public static string ResolvedAccountName =>
string.IsNullOrEmpty(Plugin.FakeAccountName.Value)
? (System.Environment.UserName ?? "DorkNetTester")
: Plugin.FakeAccountName.Value;
}
// InteropHelp.TestIfAvailableClient/TestIfPlatformSupported/TestIfAvailable
// GameServer are the gate every Steamworks.NET wrapper passes through
// before delegating to the native steam_api64.dll. With FakeSteamApi
// they would otherwise throw "Steamworks is not initialized" because
// no real native pipe was opened — even though our SteamAPI.Init
// prefix returned true, the wrapper checks an internal sentinel set
// by the native init callback. Patch these three to no-op so EVERY
// Steamworks.NET method behind them silently uses our faked output
// (or quietly does nothing for ones we haven't faked).
[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfAvailableClient))]
internal static class InteropHelpTestClientFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value; // skip original when faking
}
[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfAvailableGameServer))]
internal static class InteropHelpTestServerFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value;
}
[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfPlatformSupported))]
internal static class InteropHelpTestPlatformFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value;
}
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.Init))]
internal static class SteamApiInitFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref bool __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
Plugin.Log.LogInfo("[steam-fake] SteamAPI.Init → true");
__result = true;
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.IsSteamRunning))]
internal static class SteamApiIsRunningFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref bool __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = true;
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.GetHSteamPipe))]
internal static class SteamApiGetPipeFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref Steamworks.HSteamPipe __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
// HSteamPipe is a struct wrapping a uint — pick anything non-zero.
__result = new Steamworks.HSteamPipe(1);
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.GetHSteamUser))]
internal static class SteamApiGetUserFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref Steamworks.HSteamUser __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = new Steamworks.HSteamUser(1);
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamUser), nameof(Steamworks.SteamUser.GetSteamID))]
internal static class SteamUserIdFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref Steamworks.CSteamID __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = new Steamworks.CSteamID(SteamFakery.ResolvedSteamId);
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetPersonaName))]
internal static class SteamFriendsPersonaNameFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref string __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = SteamFakery.ResolvedAccountName;
return false;
}
}
// SteamPlatformManager.PostLoginInitialize iterates the Steam friends
// list (GetFriendCount → for-loop GetFriendByIndex). Without these
// stubs each call falls through TestIfAvailableClient (no-op'd above)
// straight into native steam_api64.dll, which segfaults on our fake
// pipe/user handles. Returning 0 from GetFriendCount short-circuits
// the whole loop so the watch never queries any friend data.
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendCount))]
internal static class SteamFriendsCountFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref int __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = 0;
return false;
}
}
// Defensive: should never be hit if GetFriendCount returns 0, but if
// some other code path indexes regardless we return an empty CSteamID.
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendByIndex))]
internal static class SteamFriendsByIndexFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref Steamworks.CSteamID __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = new Steamworks.CSteamID(0UL);
return false;
}
}
// Audit (2026-05-11): grepping every "Call <SteamClass>.<Method>" in
// the watch's IL2CPP ISIL turned up exactly 14 Steamworks methods used
// across the entire binary. The patches above cover Init / IsRunning /
// GetHSteamPipe / GetHSteamUser / GetSteamID / GetPersonaName /
// GetFriendCount / GetFriendByIndex / GetAuthSessionTicket /
// RestartAppIfNecessary. The remaining 6 are stubbed below — every
// one of them needs a no-op or zero return to avoid falling through
// to a native steam_api64.dll call with our fake handles, which
// segfaults the process.
// SteamAPI.RunCallbacks is called every frame from a MonoBehaviour.
// Native call dispatches queued callbacks; with no real pipe, it'd
// dereference null and crash. No-op skips the dispatch — our synthetic
// callbacks (e.g. GetAuthSessionTicketResponse) are fired directly,
// not through this queue.
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.RunCallbacks))]
internal static class SteamApiRunCallbacksFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value;
}
// SteamAPI.Shutdown — called on app exit. Native expects to clean up
// a real pipe; no-op so a successful exit doesn't crash.
[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.Shutdown))]
internal static class SteamApiShutdownFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value;
}
// SteamClient.SetWarningMessageHook installs a callback for Steam's
// internal warnings. No-op — we don't need warnings, and the native
// call would dereference our zero pipe.
[HarmonyPatch(typeof(Steamworks.SteamClient), nameof(Steamworks.SteamClient.SetWarningMessageHook))]
internal static class SteamClientWarningHookFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix() => !Plugin.FakeSteamApi.Value;
}
// The watch registers a few Steam callback bases during
// SteamPlatformManager.Initialize. With FakeSteamApi=true there is no valid
// native Steam pipe behind those callback bases. The one callback the boot path
// needs is synthesized below, so skip native callback registration in fake mode.
[HarmonyPatch]
internal static class SteamCallbackNativeRegistrationFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static IEnumerable<MethodBase> TargetMethods()
{
foreach (var callbackName in new[]
{
"GetAuthSessionTicketResponse_t",
"GameRichPresenceJoinRequested_t",
"MicroTxnAuthorizationResponse_t",
"GameOverlayActivated_t"
})
{
var type = AccessTools.TypeByName("Steamworks.SteamCallbacks+" + callbackName);
if (type == null)
{
Plugin.Log.LogWarning($"[steam-fake] callback type not found: {callbackName}");
continue;
}
var register = AccessTools.Method(type, "RegisterCallback");
if (register != null) yield return register;
var unregister = AccessTools.Method(type, "UnregisterCallback");
if (unregister != null) yield return unregister;
}
}
public static bool Prefix(MethodBase __originalMethod)
{
if (!Plugin.FakeSteamApi.Value) return true;
Plugin.Log.LogDebug($"[steam-fake] skip native callback registration: {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
return false;
}
}
// SteamFriends.GetFriendPersonaName / GetMediumFriendAvatar — only
// reachable if GetFriendCount > 0, but defensively stubbed in case
// some other call path indexes a friend directly.
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendPersonaName))]
internal static class SteamFriendsFriendNameFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref string __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = string.Empty;
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetMediumFriendAvatar))]
internal static class SteamFriendsAvatarFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref int __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = 0;
return false;
}
}
// SteamFriends.SetRichPresence — sets the "playing X" string visible
// to Steam friends. Cosmetic; native call would crash. Return true
// (success) so the watch doesn't log a failure.
[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.SetRichPresence))]
internal static class SteamFriendsRichPresenceFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref bool __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = true;
return false;
}
}
// SteamUtils.GetImageRGBA / GetImageSize — used to fetch Steam avatar
// pixel data. Without a real avatar handle (we return 0 from
// GetMediumFriendAvatar), these would normally not be called, but
// stubbed defensively to fail-fast if the watch tries.
[HarmonyPatch(typeof(Steamworks.SteamUtils), nameof(Steamworks.SteamUtils.GetImageRGBA))]
internal static class SteamUtilsImageRGBAFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref bool __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = false;
return false;
}
}
[HarmonyPatch(typeof(Steamworks.SteamUtils), nameof(Steamworks.SteamUtils.GetImageSize))]
internal static class SteamUtilsImageSizeFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(ref bool __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
__result = false;
return false;
}
}
// SteamPlatformManager.GetAuthSessionTicket() returns an IPromise that
// only completes when Steam fires the GetAuthSessionTicketResponse_t
// callback (real Steam dispatches it asynchronously a few hundred ms
// after GetAuthSessionTicket). With FakeSteamApi=true there's no
// native Steam to fire it, so the watch waits ~5s and then logs
// "Timed out waiting for Steam GetAuthSessionTicketResponse" → login
// stalls for 5s before the boot continues.
//
// Postfix-patch the method so the callback fires synthetically right
// after GetAuthSessionTicket returns: build a k_EResultOK response
// pointing at our fake HAuthTicket and feed it back through the watch's
// own OnGetAuthSessionTicketResponse handler. That completes the
// inner promise immediately and the boot doesn't pay the 5s timeout.
[HarmonyPatch(typeof(SteamPlatformManager), nameof(SteamPlatformManager.GetAuthSessionTicket))]
internal static class SteamGetAuthTicketShortcut
{
public static bool Prepare() => SteamFakery.Enabled;
public static void Postfix(SteamPlatformManager __instance)
{
if (!Plugin.FakeSteamApi.Value) return;
try
{
var response = new Steamworks.GetAuthSessionTicketResponse_t
{
m_eResult = Steamworks.EResult.k_EResultOK,
m_hAuthTicket = new Steamworks.HAuthTicket(0xDEADBEEFu),
};
__instance.OnGetAuthSessionTicketResponse(response);
Plugin.Log.LogInfo("[steam-fake] synth GetAuthSessionTicketResponse → k_EResultOK");
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[steam-fake] synth ticket-response failed: {ex.Message}");
}
}
}
// SteamUser.GetAuthSessionTicket is the entry point for the watch's
// auth flow. Real signature:
// HAuthTicket GetAuthSessionTicket(byte[] pTicket, int cbMaxTicket,
// out uint pcbTicket)
// We fake a 64-byte ticket containing the SteamID + a marker so our
// /cachedlogin endpoint could in principle distinguish forged vs real
// (it currently doesn't validate — see auth/AuthController.cs).
[HarmonyPatch(typeof(Steamworks.SteamUser), nameof(Steamworks.SteamUser.GetAuthSessionTicket))]
internal static class SteamUserGetAuthTicketFake
{
public static bool Prepare() => SteamFakery.Enabled;
public static bool Prefix(
Il2CppStructArray<byte> pTicket,
int cbMaxTicket,
ref uint pcbTicket,
ref Steamworks.HAuthTicket __result)
{
if (!Plugin.FakeSteamApi.Value) return true;
const int desiredLen = 64;
var len = System.Math.Min(desiredLen, cbMaxTicket);
// Fill the ticket with deterministic but-not-zero bytes. First
// 8 bytes = SteamID little-endian; rest = a magic marker so a
// future server-side validator could detect a forged session.
var sid = System.BitConverter.GetBytes(SteamFakery.ResolvedSteamId);
for (int i = 0; i < len; i++)
{
pTicket[i] = i < sid.Length ? sid[i] : (byte)('D' ^ (i & 0x0F));
}
pcbTicket = (uint)len;
__result = new Steamworks.HAuthTicket(0xDEADBEEF);
Plugin.Log.LogInfo($"[steam-fake] GetAuthSessionTicket → {len} bytes, SteamID={SteamFakery.ResolvedSteamId}");
return false;
}
}
// ── 7. TLS cert trust — make BouncyCastle (the watch's BestHTTP TLS
// stack, vendored circa 2020) accept any server certificate.
//
// Why: Coolify auto-issues Let's Encrypt ECDSA certs (E-series
// intermediate → ISRG Root X2). The 2020 BouncyCastle bundle
// pre-dates ISRG Root X2's 2020 launch, so chain validation can't
// reach a trusted root and the TLS handshake aborts with alert 90
// user_canceled. Swap the cert verifier hooks for no-ops so chain
// validation is bypassed; the actual ECDSA crypto still runs.
//
// Two layers patched:
// a) NotifyServerCertificate (Legacy + ServerOnly Tls
// Authentication) — these are the per-handshake callbacks BC
// invokes after parsing the cert chain. Default impls in this
// build wrap an ICertificateVerifyer; we short-circuit them.
// b) AlwaysValidVerifyer.IsValid is already always-true, so any
// code path that already uses it is fine — the patches above
// convert the rest.
[HarmonyPatch(typeof(BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication),
nameof(BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication.NotifyServerCertificate))]
internal static class TlsTrustServerOnly
{
public static bool Prepare() =>
Plugin.EnableTlsTrustBypass != null && Plugin.EnableTlsTrustBypass.Value;
public static bool Prefix() => false; // skip original — never reject
}
[HarmonyPatch(typeof(Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication),
nameof(Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication.NotifyServerCertificate))]
internal static class TlsTrustLegacy
{
public static bool Prepare() =>
Plugin.EnableTlsTrustBypass != null && Plugin.EnableTlsTrustBypass.Value;
public static bool Prefix() => false; // skip original — never reject
}
// ── 8z. Photon region ping bypass — short-circuit
// PUNNetworkManager.PingPhotonRegions() with a pre-resolved
// {us:1} dictionary so the broken coroutine never runs.
//
// The original PingPhotonRegionsInternal coroutine ToDictionary's
// PhotonNetwork's available-region list keyed by
// `CloudRegionCode`. The 2020 watch's CloudRegionCode enum knows
// 13 codes (eu, us, asia, jp, au, usw, sa, cae, kr, in, ru, rue,
// none) — anything else from Photon Cloud's modern NameServer
// (e.g. usw2, eu2, tr, za, jp2) parses as `none = 4`. Two
// unknown codes → two regions with key `none` → ToDictionary
// throws `ArgumentException: An item with the same key has
// already been added. Key: none` (output_log.txt:209-220), and
// the coroutine fails before completing the promise — every
// consumer awaiting it is stuck.
//
// We can't fix Photon's region list (it comes from
// Photon Cloud's NameServer, governed by the AppId's dashboard
// config). Patching the dashboard to disable unknown regions
// works in theory but the user's AppId is configured for the
// full set. Cheaper to just hand the watch a "single us region"
// promise — Rec Room only ever connected to one region per
// session anyway, and our dorknet config (Photon:CloudRegion)
// is "us" by default.
[HarmonyPatch(typeof(PUNNetworkManager), nameof(PUNNetworkManager.PingPhotonRegions))]
internal static class PhotonRegionPingShortcut
{
public static bool Prepare() =>
Plugin.EnablePhotonRegionBypass != null && Plugin.EnablePhotonRegionBypass.Value;
public static bool Prefix(
ref RecRoom.Async.IPromise<Il2CppSystem.Collections.Generic.IReadOnlyDictionary<
CloudRegionCode, int>> __result)
{
try
{
// Single-entry dict {us = 1ms}. Watch awaits this
// promise then picks the "best" (only) region. No ping
// is actually performed — the promise resolves
// immediately so downstream Photon connect logic
// proceeds without waiting on a broken coroutine.
var dict = new Il2CppSystem.Collections.Generic.Dictionary<
CloudRegionCode, int>();
dict[CloudRegionCode.us] = 1;
var promise = new RecRoom.Async.Promise<Il2CppSystem.Collections.Generic.IReadOnlyDictionary<
CloudRegionCode, int>>();
promise.Complete(dict.Cast<Il2CppSystem.Collections.Generic.IReadOnlyDictionary<
CloudRegionCode, int>>());
// Il2CppInterop's Promise<T> wrapper isn't covariantly
// related to IPromise<T> at the C# level, so go through
// Cast<T>() (every interop wrapper exposes it).
__result = promise.Cast<RecRoom.Async.IPromise<Il2CppSystem.Collections.Generic.IReadOnlyDictionary<
CloudRegionCode, int>>>();
// The ORIGINAL coroutine doesn't just return a dict — at
// PingPhotonRegionsInternal.txt:928+940 it also calls
// PhotonNetwork.OverrideBestCloudServer(bestCode) +
// ConnectToBestCloudServer(gameVersion), which is what
// actually opens the wire connection to the chosen region.
// We bypassed the coroutine, so we have to issue those
// calls ourselves — otherwise PhotonNetwork.IsConnected
// stays false and BootSequence's
// <OnEnterPostLoadInitialSceneState>b__68_5 fires
// RegisterError("Unable to connect to RecNet game
// servers") (BootSequence.txt:10490).
PhotonNetwork.OverrideBestCloudServer(CloudRegionCode.us);
// gameVersion: empty string keeps the watch's existing
// PhotonServerSettings.AppVersion. The original coroutine
// at line 940 passes a similarly-stub value.
PhotonNetwork.ConnectToBestCloudServer(string.Empty);
Plugin.Log.LogInfo("[photon-region] short-circuit → {us:1} + ConnectToBestCloudServer");
return false; // skip original
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[photon-region] short-circuit failed: {ex.Message}; falling back to original");
return true;
}
}
}
// ── 8a. Avatar parse exception swallow — wrap
// AvatarItem.FromRecNetString(string) so an "InvalidOperationException:
// Missing data for guid …" / AvatarParseException("Bad outfit
// string …") doesn't propagate up the IL2CPP stack and tear down
// the watch's player.
//
// The dorm scene's hardcoded ImposterSpawnManager prefabs reference
// avatar items by GUID — some of those GUIDs are SWATCH/MASK asset
// guids (in <c>swatchLookup</c>/<c>maskLookup</c>) instead of
// AvatarItem guids (in <c>avatarItemDataLookup</c>). The watch's
// parser only checks the AvatarItem dict and throws on misses;
// those throws bubble through ImposterSpawnManager.ProcessQueue →
// Scheduler → Update and ultimately destroy the local player
// (output_log.txt:1568-1599). Returning a default AvatarItem
// for the bad guid lets the queue processor skip the item and
// keep going.
//
// The two-arg overload (called by the one-arg overload) is what
// actually throws — patch the entry point so both call sites
// benefit.
// Substitute unknown GUIDs with a known-safe fallback BEFORE the
// original parser runs — purely returning default(AvatarItem) on
// throw leaves the watch's callers with a struct whose
// AvatarItemData / AvatarItemVisualData are null, which they then
// dereference (NullReferenceException → caught by the finalizer →
// caller dereferences the still-default struct → IL2CPP runtime
// segfault, no managed log).
//
// Strategy: check the GUID prefix against
// AvatarItemWardrobeRuntimeConfig.Config.avatarItemDataLookup
// (loaded lazily from the client's own runtime config). If the GUID
// isn't in the dict, rewrite avatarItemDesc / itemGuid to point
// at <c>FALLBACK_GUID</c> — a confirmed-safe AvatarItem (Hat_Angler)
// that always renders. The original method then runs unchanged on
// the substituted GUID and returns a fully-populated AvatarItem.
// Callers see a real AvatarItem, render the fallback prefab, and
// continue without segfaulting.
internal static class AvatarItemSafelist
{
// Hat_Angler — first entry in StarterOutfitSelections, slot 0
// (Head). Its AvatarItem is in avatarItemDataLookup AND its
// prefab is in avatarItemPrefabLookup, so any caller can render
// it as a fallback without triggering further misses.
public const string FallbackGuid = "03f8c394-28fa-4087-978b-8d108f0bd969";
private static System.Collections.Generic.HashSet<string>? _safe;
private static int _failCount;
public static bool IsSafe(string guid)
{
if (Plugin.SubstituteUnknownAvatarItems == null ||
!Plugin.SubstituteUnknownAvatarItems.Value)
return true;
if (string.IsNullOrEmpty(guid)) return false;
if (_safe is null)
{
try
{
var config = RecRoom.Avatar.Data.Runtime.AvatarItemWardrobeRuntimeConfig.Config;
if (config == null)
{
if (System.Threading.Interlocked.Increment(ref _failCount) <= 3)
Plugin.Log.LogWarning("[avatar-safelist] runtime config is null — returning false");
return false;
}
if (config.avatarItemDataLookup == null)
{
if (System.Threading.Interlocked.Increment(ref _failCount) <= 3)
Plugin.Log.LogWarning("[avatar-safelist] avatarItemDataLookup is null — returning false");
return false;
}
var set = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var k in config.avatarItemDataLookup.Keys)
{
if (!string.IsNullOrEmpty(k)) set.Add(k);
}
_safe = set;
Plugin.Log.LogInfo($"[avatar-safelist] initialized with {set.Count} GUIDs");
}
catch (Exception ex)
{
if (System.Threading.Interlocked.Increment(ref _failCount) <= 3)
Plugin.Log.LogWarning($"[avatar-safelist] init failed: {ex.GetType().Name} {ex.Message}");
return false;
}
}
return _safe.Contains(guid);
}
}
[HarmonyPatch(typeof(RecRoom.Avatar.Data.Runtime.AvatarItem),
nameof(RecRoom.Avatar.Data.Runtime.AvatarItem.FromRecNetString),
new[] { typeof(string) })]
internal static class AvatarItemParseSubstitute
{
private static int _subCount;
// Per-GUID first-time log dedup so the log shows which UNIQUE
// unknown GUIDs are being hit (cap at 50 different guids; after
// that we suppress to keep the log readable). Without this the
// first 5 hits of one repeating guid burn the global cap and
// hide every other guid from the log — which made it impossible
// to tell whether wardrobe store tiles were being substituted.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _seenGuids
= new(StringComparer.OrdinalIgnoreCase);
public static bool Prepare() =>
Plugin.SubstituteUnknownAvatarItems != null && Plugin.SubstituteUnknownAvatarItems.Value;
public static void Prefix(ref string avatarItemDesc)
{
if (string.IsNullOrEmpty(avatarItemDesc)) return;
var comma = avatarItemDesc.IndexOf(',');
var guid = comma < 0 ? avatarItemDesc : avatarItemDesc.Substring(0, comma);
if (AvatarItemSafelist.IsSafe(guid)) return;
var hitN = System.Threading.Interlocked.Increment(ref _subCount);
// Log the FIRST occurrence of each unique unknown guid (up to
// 50 unique guids), plus a periodic heartbeat every 250 hits
// total so we know substitution volume without spam.
var firstSeen = _seenGuids.TryAdd(guid, 0);
if ((firstSeen && _seenGuids.Count <= 50) || hitN % 250 == 0)
Plugin.Log.LogWarning(
$"[avatar-parse] substitute unknown guid '{guid}' → fallback (Hat_Angler) " +
$"[unique #{_seenGuids.Count} / total #{hitN}]");
// Keep whatever customization tail the caller had so the
// 5-segment AvatarItemSelection format stays well-formed.
var tail = comma < 0 ? ",,," : avatarItemDesc.Substring(comma);
avatarItemDesc = AvatarItemSafelist.FallbackGuid + tail;
}
// Belt-and-suspenders: if a GUID slips past IsSafe but the
// original still throws (e.g. transient lookup race), return a
// default item. Better a missing prefab than a hard crash.
[HarmonyFinalizer]
public static Exception? Finalizer(
Exception __exception,
string avatarItemDesc,
ref RecRoom.Avatar.Data.Runtime.AvatarItem __result)
{
if (__exception == null) return null;
if (Plugin.SubstituteUnknownAvatarItems == null ||
!Plugin.SubstituteUnknownAvatarItems.Value)
return __exception;
Plugin.Log.LogWarning(
$"[avatar-parse] finalizer swallowed {__exception.GetType().Name}: " +
$"{__exception.Message} — desc='{avatarItemDesc}'");
__result = default!;
return null;
}
}
[HarmonyPatch(typeof(RecRoom.Avatar.Data.Runtime.AvatarItem),
nameof(RecRoom.Avatar.Data.Runtime.AvatarItem.FromRecNetString),
new[] { typeof(string), typeof(Il2CppSystem.ArraySegment<string>) })]
internal static class AvatarItemParseSubstituteTwoArg
{
private static int _subCount;
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _seenGuids
= new(StringComparer.OrdinalIgnoreCase);
public static bool Prepare() =>
Plugin.SubstituteUnknownAvatarItems != null && Plugin.SubstituteUnknownAvatarItems.Value;
public static void Prefix(ref string itemGuid)
{
if (string.IsNullOrEmpty(itemGuid)) return;
if (AvatarItemSafelist.IsSafe(itemGuid)) return;
var hitN = System.Threading.Interlocked.Increment(ref _subCount);
var firstSeen = _seenGuids.TryAdd(itemGuid, 0);
if ((firstSeen && _seenGuids.Count <= 50) || hitN % 250 == 0)
Plugin.Log.LogWarning(
$"[avatar-parse] (2-arg) substitute unknown guid '{itemGuid}' → fallback (Hat_Angler) " +
$"[unique #{_seenGuids.Count} / total #{hitN}]");
itemGuid = AvatarItemSafelist.FallbackGuid;
}
[HarmonyFinalizer]
public static Exception? Finalizer(
Exception __exception,
string itemGuid,
ref RecRoom.Avatar.Data.Runtime.AvatarItem __result)
{
if (__exception == null) return null;
if (Plugin.SubstituteUnknownAvatarItems == null ||
!Plugin.SubstituteUnknownAvatarItems.Value)
return __exception;
Plugin.Log.LogWarning(
$"[avatar-parse] finalizer swallowed {__exception.GetType().Name}: " +
$"{__exception.Message} — guid='{itemGuid}'");
__result = default!;
return null;
}
}
internal static class GlobalManagedCatchAll
{
private static int _installed;
private static int _logCount;
public static bool Enabled =>
Plugin.CatchAllManagedExceptions != null && Plugin.CatchAllManagedExceptions.Value;
public static void Install()
{
if (!Enabled) return;
if (System.Threading.Interlocked.Exchange(ref _installed, 1) == 1) return;
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
Log("appdomain", args.ExceptionObject as Exception);
};
System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, args) =>
{
Log("task", args.Exception);
args.SetObserved();
};
}
public static Exception? Swallow(Exception? exception, string source)
{
if (exception == null) return null;
if (!Enabled) return exception;
Log(source, exception);
return null;
}
private static void Log(string source, Exception? ex)
{
if (System.Threading.Interlocked.Increment(ref _logCount) > 80) return;
if (ex == null)
Plugin.Log.LogWarning($"[catch-all:{source}] swallowed unknown managed exception");
else
Plugin.Log.LogWarning($"[catch-all:{source}] swallowed {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
}
}
internal static class UnityDetailedLogBridge
{
private static int _installed;
public static bool Enabled =>
Plugin.DetailedUnityLogs != null && Plugin.DetailedUnityLogs.Value;
public static void Install()
{
if (!Enabled) return;
if (System.Threading.Interlocked.Exchange(ref _installed, 1) == 1) return;
try
{
Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.ScriptOnly);
Application.SetStackTraceLogType(LogType.Warning, StackTraceLogType.Full);
Application.SetStackTraceLogType(LogType.Assert, StackTraceLogType.Full);
Application.SetStackTraceLogType(LogType.Error, StackTraceLogType.Full);
Application.SetStackTraceLogType(LogType.Exception, StackTraceLogType.Full);
Plugin.Log.LogInfo("[unity-logs] enabled full stack traces for warnings/errors/exceptions");
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[unity-logs] install failed: {ex.GetType().Name} {ex.Message}");
}
}
}
[HarmonyPatch(typeof(RRUI.Data.Components.DynamicAvatarItemImposter), "OnAvatarItemDescChange")]
internal static class DynamicAvatarItemImposterCatchAll
{
public static bool Prepare() => GlobalManagedCatchAll.Enabled;
[HarmonyFinalizer]
public static Exception? Finalizer(Exception __exception)
{
return GlobalManagedCatchAll.Swallow(__exception, "avatar-imposter");
}
}
[HarmonyPatch]
internal static class SchedulerManagedCatchAll
{
public static bool Prepare() => GlobalManagedCatchAll.Enabled;
public static System.Collections.Generic.IEnumerable<MethodBase> TargetMethods()
{
var scheduler = AccessTools.TypeByName("RecRoom.Core.Scheduler");
if (scheduler == null) yield break;
foreach (var name in new[]
{
"Update",
"UpdateQueue",
"LateUpdate",
"FixedUpdate",
"OnPostUpdate",
"OnPreRenderUpdate",
"OnRigidbodyExLateUpdate"
})
{
var method = AccessTools.Method(scheduler, name);
if (method != null) yield return method;
}
}
[HarmonyFinalizer]
public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod)
{
return GlobalManagedCatchAll.Swallow(__exception, __originalMethod.Name);
}
}
// ── 9. Storefront objective guard — older DorkNet deployments returned
// objective-progress JSON from api/storefronts/v1/objectives, but the
// March 2020 client deserializes that POST as
// Storefronts.BalanceUpdateResponseDTO. The mismatch throws
// KeyNotFoundException inside RecNet.Storefronts and tears down the
// player shortly after entering RecCenter. The server endpoint is fixed
// in this repo, but this guard keeps clients alive against stale servers.
[HarmonyPatch(typeof(RecNet.Storefronts), nameof(RecNet.Storefronts.CompleteObjectives))]
internal static class StorefrontCompleteObjectivesGuard
{
public static bool Prepare() =>
Plugin.SkipStorefrontObjectivePosts != null && Plugin.SkipStorefrontObjectivePosts.Value;
public static bool Prefix()
{
if (Plugin.SkipStorefrontObjectivePosts == null ||
!Plugin.SkipStorefrontObjectivePosts.Value)
return true;
Plugin.Log.LogInfo("[storefront-objectives] skipped client POST; server-side reward sync is disabled");
return false;
}
}
// ── 9b. Targeted avatar/store diagnostics — the Unity output log often
// shows only the RRUI binding property that failed, not the RecNet DTO
// that populated it. These patches log the exact gift/drop/unlocked item
// descriptors the client accepted. Avoid patching tiny RRUI property
// getters here; HarmonyX wrappers around IL2CPP getters can fault before
// managed finalizers run.
internal static class AvatarStoreDiagnostics
{
private const int MaxDtoLogs = 120;
private static int _dtoLogs;
public static bool Enabled =>
Plugin.DetailedAvatarStoreLogs != null && Plugin.DetailedAvatarStoreLogs.Value;
public static void LogStorefrontGiftDrop(RecNet.StorefrontGiftDrop drop, string source)
{
if (!Enabled || drop == null || !ShouldLogDto()) return;
Plugin.Log.LogWarning(
$"[diag:{source}] StorefrontGiftDrop id={Safe(() => drop.GiftDropId)} " +
$"type={Safe(() => drop.AvatarItemType)} content={Safe(() => drop.Content)} " +
$"rarity={Safe(() => drop.Rarity)} name='{Safe(() => drop.FriendlyName)}' " +
$"desc='{Safe(() => drop.AvatarItemDescOrHairDyeDesc)}' " +
$"consumable='{Safe(() => drop.ConsumableItemDesc)}' " +
$"equipment='{Safe(() => drop.EquipmentPrefabName)}' mod='{Safe(() => drop.EquipmentModificationGuid)}'");
}
public static void LogUnlockedAvatarItem(RecNet.Avatars.UnlockedAvatarItem item, string source)
{
if (!Enabled || item == null || !ShouldLogDto()) return;
Plugin.Log.LogWarning(
$"[diag:{source}] UnlockedAvatarItem type={Safe(() => item.AvatarItemType)} " +
$"platform={Safe(() => item.PlatformMask)} rarity={Safe(() => item.Rarity)} " +
$"name='{Safe(() => item.FriendlyName)}' desc='{Safe(() => item.AvatarItemDesc)}'");
}
public static void LogGiftPackage(RecNet.Avatars.GiftPackage gift, string source)
{
if (!Enabled || gift == null || !ShouldLogDto()) return;
Plugin.Log.LogWarning(
$"[diag:{source}] GiftPackage id={Safe(() => gift.Id)} valid={Safe(() => gift.IsValid)} " +
$"consumed={Safe(() => gift.Consumed)} platformOk={Safe(() => gift.SupportsCurrentPlatform)} " +
$"type={Safe(() => gift.AvatarItemType)} context={Safe(() => gift.GiftContext)} " +
$"rarity={Safe(() => gift.GiftRarity)} error='{Safe(() => gift.ErrorMessage)}' " +
$"desc='{Safe(() => gift.AvatarItemDescOrHairDyeDesc)}' " +
$"consumable='{Safe(() => gift.ConsumableItemDesc)}' " +
$"equipment='{Safe(() => gift.EquipmentPrefabName)}' mod='{Safe(() => gift.EquipmentModificationGuid)}'");
}
private static bool ShouldLogDto() =>
System.Threading.Interlocked.Increment(ref _dtoLogs) <= MaxDtoLogs;
public static string Safe<T>(Func<T> read)
{
try { return read()?.ToString() ?? "<null>"; }
catch (Exception ex) { return $"<err:{ex.GetType().Name}>"; }
}
public static string DescribeObject(object? value)
{
if (value == null) return "<null>";
try
{
var text = value.ToString();
return string.IsNullOrWhiteSpace(text)
? value.GetType().FullName ?? value.GetType().Name
: text;
}
catch (Exception ex)
{
return $"<describe-err:{ex.GetType().Name}>";
}
}
public static string DescribeBalance(object? value)
{
if (value == null) return "<null>";
return "CurrencyType=" + Safe(() => ReadMember(value, "CurrencyType")) +
" BalanceType=" + Safe(() => ReadMember(value, "BalanceType")) +
" Balance=" + Safe(() => ReadMember(value, "Balance"));
}
public static string DescribeAvatarObject(object? value)
{
if (value == null) return "<null>";
var recNet = Safe(() => InvokeNoArg(value, "ToRecNetString"));
if (!recNet.StartsWith("<err:", StringComparison.Ordinal) &&
recNet != "<null>" &&
!string.IsNullOrWhiteSpace(recNet))
return recNet;
var type = value.GetType();
return (type.FullName ?? type.Name) +
" AvatarItemData=" + Safe(() => ReadMember(value, "AvatarItemData")) +
" BodyPart=" + Safe(() => ReadMember(value, "BodyPart"));
}
private static object? ReadMember(object value, string name)
{
var type = value.GetType();
var prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop != null) return prop.GetValue(value);
var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field != null) return field.GetValue(value);
return "<missing>";
}
private static object? InvokeNoArg(object value, string name)
{
var method = value.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
return method == null ? "<missing>" : method.Invoke(value, null);
}
}
[HarmonyPatch(typeof(RecNet.StorefrontGiftDrop), nameof(RecNet.StorefrontGiftDrop.Deserialize))]
internal static class StorefrontGiftDropDiagnostics
{
public static bool Prepare() => AvatarStoreDiagnostics.Enabled;
public static void Postfix(RecNet.StorefrontGiftDrop __instance)
{
AvatarStoreDiagnostics.LogStorefrontGiftDrop(__instance, "deserialize");
}
}
[HarmonyPatch(typeof(RecNet.Avatars.UnlockedAvatarItem), nameof(RecNet.Avatars.UnlockedAvatarItem.Deserialize))]
internal static class UnlockedAvatarItemDiagnostics
{
public static bool Prepare() => AvatarStoreDiagnostics.Enabled;
public static void Postfix(RecNet.Avatars.UnlockedAvatarItem __instance)
{
AvatarStoreDiagnostics.LogUnlockedAvatarItem(__instance, "deserialize");
}
}
[HarmonyPatch(typeof(RecNet.Avatars.GiftPackage), nameof(RecNet.Avatars.GiftPackage.Deserialize))]
internal static class GiftPackageDiagnostics
{
public static bool Prepare() => AvatarStoreDiagnostics.Enabled;
public static void Postfix(RecNet.Avatars.GiftPackage __instance)
{
AvatarStoreDiagnostics.LogGiftPackage(__instance, "deserialize");
}
}
[HarmonyPatch]
internal static class StorefrontBalanceDiagnostics
{
private static int _logCount;
public static bool Prepare() => AvatarStoreDiagnostics.Enabled;
public static System.Collections.Generic.IEnumerable<MethodBase> TargetMethods()
{
foreach (var method in AccessTools.GetDeclaredMethods(typeof(RecNet.Storefronts)))
{
if (method.Name is "UpdateCachedBalance" or "UpdateCachedBalances" or
"RaiseBalanceUpdatedEvent" or "OnStorefrontBalanceUpdated" or
"OnStorefrontBalancePurchased" or "OnStorefrontBalanceAddReceived")
yield return method;
}
}
public static void Prefix(MethodBase __originalMethod, object[] __args)
{
if (!AvatarStoreDiagnostics.Enabled || System.Threading.Interlocked.Increment(ref _logCount) > 160)
return;
try
{
Plugin.Log.LogWarning($"[diag:storefront:{__originalMethod.Name}] args={DescribeArgs(__args)}");
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[diag:storefront:{__originalMethod.Name}] log failed: {ex.GetType().Name} {ex.Message}");
}
}
[HarmonyFinalizer]
public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod, object[] __args)
{
if (__exception == null) return null;
Plugin.Log.LogWarning(
$"[diag:storefront:{__originalMethod.Name}] exception {__exception.GetType().Name}: {__exception.Message} " +
$"args={DescribeArgs(__args)}\n{__exception.StackTrace}");
return GlobalManagedCatchAll.Swallow(__exception, "storefront:" + __originalMethod.Name);
}
private static string DescribeArgs(object[]? args)
{
if (args == null || args.Length == 0) return "<none>";
var parts = new List<string>();
for (var i = 0; i < args.Length; i++)
parts.Add($"#{i}={DescribeArg(args[i])}");
return string.Join("; ", parts);
}
private static string DescribeArg(object? arg)
{
if (arg == null) return "<null>";
if (arg is System.Collections.IEnumerable seq && arg is not string)
{
var items = new List<string>();
var count = 0;
foreach (var item in seq)
{
if (++count <= 8) items.Add(AvatarStoreDiagnostics.DescribeBalance(item));
}
return $"[{string.Join(" | ", items)}] count>={count}";
}
var typeName = arg.GetType().FullName ?? arg.GetType().Name;
if (typeName.Contains("Balance", StringComparison.OrdinalIgnoreCase))
return AvatarStoreDiagnostics.DescribeBalance(arg);
return AvatarStoreDiagnostics.DescribeObject(arg);
}
}
[HarmonyPatch]
internal static class PlayerAvatarOutfitDiagnostics
{
private static int _logCount;
public static bool Prepare() => AvatarStoreDiagnostics.Enabled;
public static System.Collections.Generic.IEnumerable<MethodBase> TargetMethods()
{
foreach (var method in AccessTools.GetDeclaredMethods(typeof(PlayerAvatar)))
{
if (method.Name is "ApplyLocalPlayerAvatarSettings" or "EquipOutfitItem" or
"EquipOutfitItemInternal" or "SetLocalPlayerSelectedOutfit" or
"LocalLoadSavedOutfit" or "UnequipOutfitItem" or "OnPlayerOutfitSelectionsChanged")
yield return method;
}
}
public static void Prefix(MethodBase __originalMethod, object[] __args)
{
if (!AvatarStoreDiagnostics.Enabled || System.Threading.Interlocked.Increment(ref _logCount) > 140)
return;
try
{
Plugin.Log.LogWarning($"[diag:outfit:{__originalMethod.Name}] args={DescribeArgs(__args)}");
}
catch (Exception ex)
{
Plugin.Log.LogWarning($"[diag:outfit:{__originalMethod.Name}] log failed: {ex.GetType().Name} {ex.Message}");
}
}
[HarmonyFinalizer]
public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod, object[] __args)
{
if (__exception == null) return null;
Plugin.Log.LogWarning(
$"[diag:outfit:{__originalMethod.Name}] exception {__exception.GetType().Name}: {__exception.Message} " +
$"args={DescribeArgs(__args)}\n{__exception.StackTrace}");
return GlobalManagedCatchAll.Swallow(__exception, "outfit:" + __originalMethod.Name);
}
private static string DescribeArgs(object[]? args)
{
if (args == null || args.Length == 0) return "<none>";
var parts = new List<string>();
for (var i = 0; i < args.Length; i++)
parts.Add($"#{i}={DescribeArg(args[i])}");
return string.Join("; ", parts);
}
private static string DescribeArg(object? arg)
{
if (arg == null) return "<null>";
if (arg is string s) return s;
if (arg is System.Collections.IEnumerable seq)
{
var items = new List<string>();
var count = 0;
foreach (var item in seq)
{
if (++count <= 8) items.Add(AvatarStoreDiagnostics.DescribeAvatarObject(item));
}
return $"[{string.Join(" | ", items)}] count>={count}";
}
var typeName = arg.GetType().FullName ?? arg.GetType().Name;
if (typeName.Contains("Avatar", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("Outfit", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("BodyPart", StringComparison.OrdinalIgnoreCase))
return AvatarStoreDiagnostics.DescribeAvatarObject(arg);
return AvatarStoreDiagnostics.DescribeObject(arg);
}
}
// ── 10. Mirror outfit guard -- the March 2020 mirror UI can try to remove
// the required torso item when clicking the currently equipped shirt. The
// client then force-adds a default torso ("Adding required torso item!") and
// the local player teardown follows shortly after. Treat a direct torso
// unequip as a no-op; normal shirt swaps still go through EquipOutfitItem.
[HarmonyPatch(typeof(PlayerAvatar), nameof(PlayerAvatar.UnequipOutfitItem))]
internal static class RequiredTorsoUnequipGuard
{
private const int TorsoBodyPart = 1;
public static bool Prepare() =>
Plugin.BlockDirectTorsoUnequip != null && Plugin.BlockDirectTorsoUnequip.Value;
public static bool Prefix(object[] __args, ref bool __result)
{
if (Plugin.BlockDirectTorsoUnequip == null ||
!Plugin.BlockDirectTorsoUnequip.Value)
return true;
if (TryReadBodyPart(__args, out var bodyPart) && bodyPart == TorsoBodyPart)
{
Plugin.Log.LogInfo("[avatar-outfit] blocked direct torso unequip from mirror UI");
__result = false;
return false;
}
return true;
}
private static bool TryReadBodyPart(object[]? args, out int bodyPart)
{
bodyPart = int.MinValue;
if (args is not { Length: > 1 } || args[1] is null) return false;
try
{
bodyPart = Convert.ToInt32(args[1]);
return true;
}
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
{
var text = args[1].ToString();
if (text?.EndsWith("Torso", StringComparison.OrdinalIgnoreCase) == true)
{
bodyPart = TorsoBodyPart;
return true;
}
if (int.TryParse(text, out bodyPart))
return true;
Plugin.Log.LogDebug($"[avatar-outfit] could not decode bodyPart argument: {text}");
return false;
}
}
}
// ── 11. Screen cleanup guard — the March 2020 desktop HUD can throw a
// NullReferenceException while tearing down the local player if the
// ScreenHUD/cursor objects are already gone. That exception appears in the
// current crash path after the local player is destroyed. Suppress only this
// cleanup exception so we can keep the process alive long enough to expose
// the real disconnect/crash trigger in the logs.
[HarmonyPatch(typeof(RecRoom.ScreenPlayerController), "CleanupLocalPlayer")]
internal static class ScreenPlayerCleanupGuard
{
public static bool Prepare() =>
Plugin.SuppressScreenCleanupNullRef != null && Plugin.SuppressScreenCleanupNullRef.Value;
public static Exception? Finalizer(Exception __exception)
{
if (__exception == null) return null;
if (Plugin.SuppressScreenCleanupNullRef == null ||
!Plugin.SuppressScreenCleanupNullRef.Value)
return __exception;
if (IsCleanupHudNullReference(__exception))
{
Plugin.Log.LogWarning("[screen-cleanup] swallowed CleanupLocalPlayer null-ref during local player teardown");
return null;
}
return __exception;
}
private static bool IsCleanupHudNullReference(Exception exception)
{
if (exception is NullReferenceException) return true;
// IL2CPP exceptions arrive through Harmony as Il2CppException wrappers,
// so match the IL2CPP stack text rather than only the managed type.
var text = exception.ToString();
return text.Contains("System.NullReferenceException", StringComparison.Ordinal)
&& text.Contains("RecRoom.ScreenPlayerController.CleanupLocalPlayer", StringComparison.Ordinal)
&& text.Contains("RecRoom.UI.ScreenHUDElement.get_RectTransform", StringComparison.Ordinal);
}
}
// ── 12. Diagnostics — log every BootSequence.RegisterError invocation
// with a managed-side stack trace so we can tell which API call
// actually failed (the IL2CPP stack traces in Player.log are
// obfuscated to method symbols like "RKHMMYKOXZK"). The watch's
// boot pipeline collapses any failure into "Rec Room update
// required", which is useless without knowing the actual cause.
[HarmonyPatch(typeof(BootSequence), "RegisterError")]
internal static class BootSequenceRegisterErrorTrace
{
public static bool Prepare() =>
Plugin.EnableBootDiagnostics != null && Plugin.EnableBootDiagnostics.Value;
// The original Prefix(string error) was emitting blank text — Harmony's
// IL2CPP string marshaling for an instance-method first parameter was
// dropping the string value (every entry came out as "[boot-error] " in
// the log). Use HarmonyX's __args to pull the raw arguments and decode
// them manually, plus dump the actual call site so we know which of
// the two known callers
// - "Error initializing Core Systems" (BootSequence:5555)
// - "Unable to connect to RecNet game servers" (BootSequence:10490)
// fired.
public static void Prefix(object[] __args, BootSequence __instance)
{
string? err = null;
try
{
if (__args is { Length: > 0 } && __args[0] is { } a0)
{
err = a0 switch
{
string s => s,
Il2CppSystem.String i2cs => i2cs.ToString(),
_ => a0.ToString(),
};
}
}
catch (Exception ex) { err = $"<unmarshalled: {ex.GetType().Name}>"; }
Plugin.Log.LogWarning($"[boot-error] '{err ?? "<null>"}'");
try
{
Plugin.Log.LogWarning(new System.Diagnostics.StackTrace(1, true).ToString());
}
catch { /* StackTrace can fail in IL2CPP; swallow */ }
}
}
internal static class MatchmakingHelpers
{
/// <summary>Reads the watch's persistent LoginLock from PlayerPrefs.
/// The key is <c>LoginLockTokenV2</c> (verified at
/// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:1810 — the
/// <c>Move rdi, "LoginLockTokenV2"</c> + <c>Call PlayerPrefs.GetString</c>
/// pair inside <c>Matchmaking.LoadLockToken</c>). Stored value is a
/// JSON object of <c>RecNet.Matchmaking+LoginLockTokenParams</c>
/// (Cpp2IL_CS/.../RecNet/Matchmaking.cs:129) with three fields:
/// <c>Platform</c> (int), <c>PlatformId</c> (string), <c>LockToken</c>
/// (string). We extract <c>LockToken</c> by scanning for the literal
/// <c>"LockToken":"…"</c> pair — cheaper than parsing JSON for one
/// string, and the watch's serialiser doesn't produce escapes that'd
/// break a naive scan.
/// </summary>
public static string? GetCurrentLockToken()
{
try
{
var raw = PlayerPrefs.GetString("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; }
}
}