diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs
index 71dd26d..89228ce 100644
--- a/DorkNet.ClientMod/Mod.cs
+++ b/DorkNet.ClientMod/Mod.cs
@@ -1,6 +1,6 @@
-// MelonLoader port of the DorkNet.ClientPatch BepInEx plugin. Applies
-// the minimum set of Harmony patches needed for the 2020 watch to
-// connect to a DorkNet server:
+// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
+// Harmony patches needed for the 2020 watch to connect to a DorkNet
+// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. Photon AppId — override the baked-in Photon Cloud AppId
@@ -48,11 +48,9 @@ public class Mod : MelonMod
private int _diagnosticRetryFrame;
private int _photonOverridePollFrame;
- // Loader-agnostic config — same .Value-shaped accessor the patches
- // would see in the BepInEx port, just backed by a plain JSON file
- // instead of BepInEx's TOML config system. Defaults match the BepInEx
- // plugin's defaults so dropping this mod in without writing a config
- // file gives the same behaviour as the recommended install.
+ // Config backed by a plain JSON file under MelonLoader/UserData.
+ // The defaults below match the recommended install, so dropping this
+ // mod in without writing a config file still gives working behaviour.
public static class Cfg
{
public static string ServerHost = "localhost";
diff --git a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj
deleted file mode 100644
index ee2dfcf..0000000
--- a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj
+++ /dev/null
@@ -1,106 +0,0 @@
-
-
-
-
- net6.0
- latest
- enable
- DorkNet.ClientPatch
- DorkNet.ClientPatch
- false
- false
-
-
-
-
-
-
- $(BepInExCore)\BepInEx.Core.dll
- false
-
-
- $(BepInExCore)\BepInEx.Unity.IL2CPP.dll
- false
-
-
- $(BepInExCore)\0Harmony.dll
- false
-
-
- $(BepInExCore)\Il2CppInterop.Runtime.dll
- false
-
-
- $(BepInExCore)\Il2CppInterop.Common.dll
- false
-
-
- $(BepInExCore)\MonoMod.Utils.dll
- false
-
-
-
-
- $(BepInExInterop)\Il2Cppmscorlib.dll
- false
-
-
- $(BepInExInterop)\Il2CppSystem.dll
- false
-
-
- $(BepInExInterop)\Assembly-CSharp.dll
- false
-
-
- $(BepInExInterop)\Assembly-CSharp-firstpass.dll
- false
-
-
- $(BepInExInterop)\Photon3Unity3D.dll
- false
-
-
- $(BepInExInterop)\UnityEngine.UI.dll
- false
-
-
- $(BepInExInterop)\Unity.TextMeshPro.dll
- false
-
-
- $(BepInExInterop)\UnityEngine.CoreModule.dll
- false
-
-
- $(BepInExInterop)\RecRoom.Datastructures.Runtime.dll
- false
-
-
- $(BepInExInterop)\Unity.Addressables.dll
- false
-
-
- $(BepInExInterop)\RecRoom.Async.dll
- false
-
-
- $(BepInExInterop)\RecRoom.Promises.Runtime.dll
- false
-
-
-
-
diff --git a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache
deleted file mode 100644
index 91f7f97..0000000
--- a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache
+++ /dev/null
@@ -1,231 +0,0 @@
-version=1
-
-# This file caches language service data to improve the performance of C# Dev Kit.
-# It is not intended for manual editing. It can safely be deleted and will be
-# regenerated automatically. For more information, see https://aka.ms/lscache
-#
-# To control where cache files are stored, use the following VS Code setting:
-# "dotnet.projectsystem.cacheInProjectFolder": true
-
-[project]
-language=C#
-primary
-lastDtbSucceeded
-
-[properties]
-AssemblyName=DorkNet.ClientPatch
-CommandLineArgsForDesignTimeEvaluation=-langversion:latest -define:TRACE
-CompilerGeneratedFilesOutputPath=
-MaxSupportedLangVersion=10.0
-ProjectAssetsFile=obj/project.assets.json
-RootNamespace=DorkNet.ClientPatch
-RunAnalyzers=
-RunAnalyzersDuringLiveAnalysis=
-SolutionPath=../DorkNet.sln
-TargetFrameworkIdentifier=.NETCoreApp
-TargetPath=bin/Debug/DorkNet.ClientPatch.dll
-TargetRefPath=obj/Debug/ref/DorkNet.ClientPatch.dll
-TemporaryDependencyNodeTargetIdentifier=net6.0
-
-[commandLineArguments]
-/noconfig
-/unsafe-
-/checked-
-/nowarn:1701,1702,1701,1702
-/fullpaths
-/nostdlib+
-/errorreport:prompt
-/warn:6
-/define:TRACE;DEBUG;NET;NET6_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
-/highentropyva+
-/nullable:enable
-/debug+
-/debug:portable
-/filealign:512
-/optimize-
-/out:obj\Debug\DorkNet.ClientPatch.dll
-/refout:obj\Debug\refint\DorkNet.ClientPatch.dll
-/target:library
-/warnaserror-
-/utf8output
-/deterministic+
-/langversion:latest
-/warnaserror+:NU1605
-
-[sourceFiles]
-obj/Debug/
- .NETCoreApp,Version=v6.0.AssemblyAttributes.cs
- DorkNet.ClientPatch.AssemblyInfo.cs
-Plugin.cs
-
-[metadataReferences]
-/microsoft.netcore.app.ref/6.0.36/ref/net6.0/
- Microsoft.CSharp.dll
- Microsoft.VisualBasic.Core.dll
- Microsoft.VisualBasic.dll
- Microsoft.Win32.Primitives.dll
- Microsoft.Win32.Registry.dll
- mscorlib.dll
- netstandard.dll
- System.AppContext.dll
- System.Buffers.dll
- System.Collections.Concurrent.dll
- System.Collections.dll
- System.Collections.Immutable.dll
- System.Collections.NonGeneric.dll
- System.Collections.Specialized.dll
- System.ComponentModel.Annotations.dll
- System.ComponentModel.DataAnnotations.dll
- System.ComponentModel.dll
- System.ComponentModel.EventBasedAsync.dll
- System.ComponentModel.Primitives.dll
- System.ComponentModel.TypeConverter.dll
- System.Configuration.dll
- System.Console.dll
- System.Core.dll
- System.Data.Common.dll
- System.Data.DataSetExtensions.dll
- System.Data.dll
- System.Diagnostics.Contracts.dll
- System.Diagnostics.Debug.dll
- System.Diagnostics.DiagnosticSource.dll
- System.Diagnostics.FileVersionInfo.dll
- System.Diagnostics.Process.dll
- System.Diagnostics.StackTrace.dll
- System.Diagnostics.TextWriterTraceListener.dll
- System.Diagnostics.Tools.dll
- System.Diagnostics.TraceSource.dll
- System.Diagnostics.Tracing.dll
- System.dll
- System.Drawing.dll
- System.Drawing.Primitives.dll
- System.Dynamic.Runtime.dll
- System.Formats.Asn1.dll
- System.Globalization.Calendars.dll
- System.Globalization.dll
- System.Globalization.Extensions.dll
- System.IO.Compression.Brotli.dll
- System.IO.Compression.dll
- System.IO.Compression.FileSystem.dll
- System.IO.Compression.ZipFile.dll
- System.IO.dll
- System.IO.FileSystem.AccessControl.dll
- System.IO.FileSystem.dll
- System.IO.FileSystem.DriveInfo.dll
- System.IO.FileSystem.Primitives.dll
- System.IO.FileSystem.Watcher.dll
- System.IO.IsolatedStorage.dll
- System.IO.MemoryMappedFiles.dll
- System.IO.Pipes.AccessControl.dll
- System.IO.Pipes.dll
- System.IO.UnmanagedMemoryStream.dll
- System.Linq.dll
- System.Linq.Expressions.dll
- System.Linq.Parallel.dll
- System.Linq.Queryable.dll
- System.Memory.dll
- System.Net.dll
- System.Net.Http.dll
- System.Net.Http.Json.dll
- System.Net.HttpListener.dll
- System.Net.Mail.dll
- System.Net.NameResolution.dll
- System.Net.NetworkInformation.dll
- System.Net.Ping.dll
- System.Net.Primitives.dll
- System.Net.Requests.dll
- System.Net.Security.dll
- System.Net.ServicePoint.dll
- System.Net.Sockets.dll
- System.Net.WebClient.dll
- System.Net.WebHeaderCollection.dll
- System.Net.WebProxy.dll
- System.Net.WebSockets.Client.dll
- System.Net.WebSockets.dll
- System.Numerics.dll
- System.Numerics.Vectors.dll
- System.ObjectModel.dll
- System.Reflection.DispatchProxy.dll
- System.Reflection.dll
- System.Reflection.Emit.dll
- System.Reflection.Emit.ILGeneration.dll
- System.Reflection.Emit.Lightweight.dll
- System.Reflection.Extensions.dll
- System.Reflection.Metadata.dll
- System.Reflection.Primitives.dll
- System.Reflection.TypeExtensions.dll
- System.Resources.Reader.dll
- System.Resources.ResourceManager.dll
- System.Resources.Writer.dll
- System.Runtime.CompilerServices.Unsafe.dll
- System.Runtime.CompilerServices.VisualC.dll
- System.Runtime.dll
- System.Runtime.Extensions.dll
- System.Runtime.Handles.dll
- System.Runtime.InteropServices.dll
- System.Runtime.InteropServices.RuntimeInformation.dll
- System.Runtime.Intrinsics.dll
- System.Runtime.Loader.dll
- System.Runtime.Numerics.dll
- System.Runtime.Serialization.dll
- System.Runtime.Serialization.Formatters.dll
- System.Runtime.Serialization.Json.dll
- System.Runtime.Serialization.Primitives.dll
- System.Runtime.Serialization.Xml.dll
- System.Security.AccessControl.dll
- System.Security.Claims.dll
- System.Security.Cryptography.Algorithms.dll
- System.Security.Cryptography.Cng.dll
- System.Security.Cryptography.Csp.dll
- System.Security.Cryptography.Encoding.dll
- System.Security.Cryptography.OpenSsl.dll
- System.Security.Cryptography.Primitives.dll
- System.Security.Cryptography.X509Certificates.dll
- System.Security.dll
- System.Security.Principal.dll
- System.Security.Principal.Windows.dll
- System.Security.SecureString.dll
- System.ServiceModel.Web.dll
- System.ServiceProcess.dll
- System.Text.Encoding.CodePages.dll
- System.Text.Encoding.dll
- System.Text.Encoding.Extensions.dll
- System.Text.Encodings.Web.dll
- System.Text.Json.dll
- System.Text.RegularExpressions.dll
- System.Threading.Channels.dll
- System.Threading.dll
- System.Threading.Overlapped.dll
- System.Threading.Tasks.Dataflow.dll
- System.Threading.Tasks.dll
- System.Threading.Tasks.Extensions.dll
- System.Threading.Tasks.Parallel.dll
- System.Threading.Thread.dll
- System.Threading.ThreadPool.dll
- System.Threading.Timer.dll
- System.Transactions.dll
- System.Transactions.Local.dll
- System.ValueTuple.dll
- System.Web.dll
- System.Web.HttpUtility.dll
- System.Windows.dll
- System.Xml.dll
- System.Xml.Linq.dll
- System.Xml.ReaderWriter.dll
- System.Xml.Serialization.dll
- System.Xml.XDocument.dll
- System.Xml.XmlDocument.dll
- System.Xml.XmlSerializer.dll
- System.Xml.XPath.dll
- System.Xml.XPath.XDocument.dll
- WindowsBase.dll
-
-[analyzerReferences]
-/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/
- Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
- Microsoft.CodeAnalysis.NetAnalyzers.dll
-/microsoft.netcore.app.ref/6.0.36/analyzers/dotnet/cs/System.Text.Json.SourceGeneration.dll
-
-[analyzerConfigFiles]
-/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.globalconfig
-obj/Debug/DorkNet.ClientPatch.GeneratedMSBuildEditorConfig.editorconfig
diff --git a/DorkNet.ClientPatch/NuGet.Config b/DorkNet.ClientPatch/NuGet.Config
deleted file mode 100644
index 3bbbc47..0000000
--- a/DorkNet.ClientPatch/NuGet.Config
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/DorkNet.ClientPatch/Plugin.cs b/DorkNet.ClientPatch/Plugin.cs
deleted file mode 100644
index 26a6fe8..0000000
--- a/DorkNet.ClientPatch/Plugin.cs
+++ /dev/null
@@ -1,2011 +0,0 @@
-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;
-
-///
-/// 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.
-///
-[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
-public sealed class Plugin : BasePlugin
-{
- internal static new ManualLogSource Log = null!;
-
- /// Replacement domain for every *.rec.net URL the watch
- /// would otherwise hit. "localhost" means
- /// https://ns.rec.net/?v=2 → https://ns.localhost/?v=2 at
- /// runtime (Uri ctor patch). Empty disables the rewrite.
- public static ConfigEntry ServerHost = null!;
-
- /// Photon Cloud Realtime AppId. When non-empty, replaces
- /// PhotonNetwork.PhotonServerSettings.AppID right before
- /// ConnectUsingSettings hands off to NetworkingPeer.
- public static ConfigEntry PhotonAppId = null!;
-
- /// Photon Voice AppId. Empty falls back to
- /// .
- public static ConfigEntry PhotonVoiceAppId = null!;
-
- /// Send userid+LoginLock AuthValues with the
- /// Photon connect so DorkNet's /photon/customauth endpoint
- /// can validate the session. Disable while debugging Photon
- /// connectivity if AuthValues are interfering.
- public static ConfigEntry InjectAuthValues = null!;
-
- /// 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).
- public static ConfigEntry BrandName = null!;
- public static ConfigEntry EnableBrandRename = null!;
-
- /// Skip the Steam DRM "RestartAppIfNecessary" check. The
- /// vanilla watch calls
- /// Steamworks.SteamAPI.RestartAppIfNecessary(471710) 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.
- public static ConfigEntry SkipSteamRestart = null!;
-
- /// 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.
- public static ConfigEntry FakeSteamApi = null!;
-
- /// SteamID64 to fake when 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.
- public static ConfigEntry FakeSteamId = null!;
-
- /// Display name returned from
- /// Steamworks.SteamFriends.GetPersonaName when
- /// is on. Empty = the OS user name.
- public static ConfigEntry FakeAccountName = null!;
- public static ConfigEntry EnableTlsTrustBypass = null!;
- public static ConfigEntry EnablePhotonRegionBypass = null!;
- public static ConfigEntry EnableBootDiagnostics = null!;
- public static ConfigEntry DetailedUnityLogs = null!;
-
- /// 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.
- public static ConfigEntry DetailedAvatarStoreLogs = null!;
-
- /// 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.
- public static ConfigEntry SubstituteUnknownAvatarItems = null!;
-
- /// 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.
- public static ConfigEntry SkipStorefrontObjectivePosts = null!;
-
- /// Legacy mirror guard that blocks direct torso unequip. This can
- /// interfere with normal outfit editing, so it is opt-in only.
- public static ConfigEntry BlockDirectTorsoUnequip = null!;
-
- /// Cleanup exception suppressor used only while debugging teardown
- /// crashes. Keep off by default so real crashes stay visible.
- public static ConfigEntry SuppressScreenCleanupNullRef = null!;
- public static ConfigEntry FailFastRequiredPatches = null!;
- public static ConfigEntry 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) ? "" : "")}, " +
- $"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) ? "" : "")}");
- }
- 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 ." 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 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 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> __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>();
- promise.Complete(dict.Cast>());
- // Il2CppInterop's Promise wrapper isn't covariantly
- // related to IPromise at the C# level, so go through
- // Cast() (every interop wrapper exposes it).
- __result = promise.Cast>>();
-
- // 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
- // 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 swatchLookup/maskLookup) instead of
-// AvatarItem guids (in avatarItemDataLookup). 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 FALLBACK_GUID — 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? _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(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 _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) })]
-internal static class AvatarItemParseSubstituteTwoArg
-{
- private static int _subCount;
- private static readonly System.Collections.Concurrent.ConcurrentDictionary _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 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(Func read)
- {
- try { return read()?.ToString() ?? ""; }
- catch (Exception ex) { return $""; }
- }
-
- public static string DescribeObject(object? value)
- {
- if (value == null) return "";
- try
- {
- var text = value.ToString();
- return string.IsNullOrWhiteSpace(text)
- ? value.GetType().FullName ?? value.GetType().Name
- : text;
- }
- catch (Exception ex)
- {
- return $"";
- }
- }
-
- public static string DescribeBalance(object? value)
- {
- if (value == null) return "";
- 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 "";
- var recNet = Safe(() => InvokeNoArg(value, "ToRecNetString"));
- if (!recNet.StartsWith("" &&
- !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 "";
- }
-
- 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 ? "" : 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 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 "";
- var parts = new List();
- 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 "";
- if (arg is System.Collections.IEnumerable seq && arg is not string)
- {
- var items = new List();
- 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 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 "";
- var parts = new List();
- 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 "";
- if (arg is string s) return s;
- if (arg is System.Collections.IEnumerable seq)
- {
- var items = new List();
- 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 = $""; }
- Plugin.Log.LogWarning($"[boot-error] '{err ?? ""}'");
- try
- {
- Plugin.Log.LogWarning(new System.Diagnostics.StackTrace(1, true).ToString());
- }
- catch { /* StackTrace can fail in IL2CPP; swallow */ }
- }
-}
-
-internal static class MatchmakingHelpers
-{
- /// Reads the watch's persistent LoginLock from PlayerPrefs.
- /// The key is LoginLockTokenV2 (verified at
- /// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:1810 — the
- /// Move rdi, "LoginLockTokenV2" + Call PlayerPrefs.GetString
- /// pair inside Matchmaking.LoadLockToken). Stored value is a
- /// JSON object of RecNet.Matchmaking+LoginLockTokenParams
- /// (Cpp2IL_CS/.../RecNet/Matchmaking.cs:129) with three fields:
- /// Platform (int), PlatformId (string), LockToken
- /// (string). We extract LockToken by scanning for the literal
- /// "LockToken":"…" pair — cheaper than parsing JSON for one
- /// string, and the watch's serialiser doesn't produce escapes that'd
- /// break a naive scan.
- ///
- 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; }
- }
-}
diff --git a/DorkNet.ClientPatch/README.md b/DorkNet.ClientPatch/README.md
deleted file mode 100644
index e365d1b..0000000
--- a/DorkNet.ClientPatch/README.md
+++ /dev/null
@@ -1,217 +0,0 @@
-# DorkNet ClientPatch BepInEx Plugin
-
-Single client-side artefact that does **everything** DorkNet needs to
-patch into the 2020 watch:
-
-1. Rewrites every `*.rec.net` URL the watch builds → user-configured
- replacement host (default `localhost`). Catches the bootstrap
- `https://ns.rec.net/?v=2`; every other service URL flows in from
- the nameserver response which the DorkNet server controls. No
- more hosts file, no more wildcard mkcert (Cloudflare Tunnel terminates
- real TLS at the edge of the configured host).
-2. Overrides Photon Cloud `AppId` / `VoiceAppId` right before
- `PhotonNetwork.ConnectUsingSettings`. No more byte-edits to
- `resources.assets`, no more hardcoded offsets to chase across game
- versions.
-3. Attaches `userid` + `LoginLock` to `PhotonNetwork.AuthValues` right
- before any authenticate op leaves the wire, so DorkNet's
- `/photon/customauth` endpoint can identify the player and enforce
- single-session.
-
-Configuration lives in `BepInEx/config/sh.dork.clientpatch.cfg`. Edit
-values, restart the client, patches re-apply with the new settings.
-
-## Quickstart (recommended)
-
-Use the wrapper script — handles BepInEx install, interop generation,
-plugin build, and config drop in one go:
-
-```pwsh
-.\tools\install-plugin.ps1 `
- -RecRoomPath "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\Recroom_Release_Data" `
- -PhotonAppId
-```
-
-The script auto-downloads a pinned BepInEx 6 IL2CPP build from
-`builds.bepinex.dev` on first run and caches it at
-`%LOCALAPPDATA%\DorkNet\BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.696.zip`
-so re-runs on the same machine don't re-download. To override the
-pinned build (newer/older), pass `-BepInExZip `.
-
-After a client update, just re-run with the same args. The script
-re-builds the plugin against the new game's interop assemblies.
-
-To remove: `.\tools\install-plugin.ps1 -RecRoomPath ... -Revert`.
-
-The legacy `patch-client.ps1` (asset-byte-edit + hosts file + mkcert
-wildcard) is kept for backwards compat — see the file's header for
-when you'd still want it.
-
-## Manual install (if you don't want to use install-plugin.ps1)
-
-Same steps, just done by hand. Useful for understanding the layout.
-
-## Why this exists
-
-The vanilla 2020 client never sets `PhotonNetwork.AuthValues` — verified
-by an exhaustive grep across both `Assembly-CSharp.dll` and the Photon
-`Assembly-CSharp-firstpass.dll` (no callers of
-`PhotonNetwork.AuthValues =`, `new AuthenticationValues(...)`,
-`AddAuthParameter(...)`, or `SetAuthPostData(...)`). Photon Cloud
-therefore assigns a random per-connection UserId and our
-`/photon/customauth` callback receives no client identity, so we can't
-reject duplicate sessions.
-
-This plugin patches `NetworkingPeer.OpAuthenticate` /
-`OpAuthenticateOnce` (the single chokepoint right before Photon's
-authenticate op leaves the wire) and attaches:
-
-- `AuthType = CustomAuthenticationType.Custom`
-- `UserId = `
-- `AuthGetParameters: userid=, LoginLock=`
-
-That makes the Photon Cloud `{userid}` / `{LoginLock}` placeholders
-populate, our endpoint validates `LoginLock` against
-`PlayerPresenceService` (Redis), and Photon's own UserId-collision
-detection kicks duplicate connections automatically.
-
-## Prerequisites
-
-- 2020 client build (Steam build `20200306`, AppID `471710`).
-- BepInEx **6.x IL2CPP** — *not* the regular Mono build. Download from
- https://github.com/BepInEx/BepInEx/releases (look for the
- `BepInEx_unix_x86_64_6.0.0-be.696_*.zip` line, Windows variant).
-- .NET 6 SDK on the build machine (this project uses `net6.0`).
-
-## One-time Setup
-
-1. **Install BepInEx 6 IL2CPP into the client directory.** Extract the
- release zip into the folder containing `Recroom_Release.exe`. After
- extraction the layout looks like:
-
- ```
- /
- Recroom_Release.exe
- BepInEx/
- core/
- config/
- plugins/ ← we drop our DLL here
- dotnet/ ← BepInEx-shipped runtime
- winhttp.dll ← BepInEx loader
- doorstop_config.ini
- ```
-
-2. **Generate Il2Cpp interop assemblies.** Run the game once. BepInEx
- inspects the IL2CPP metadata on first launch and writes interop
- assemblies to `BepInEx/interop/`. This is a one-time ~30s step;
- re-do it any time the game updates.
-
- You should see lines like
- `[Info: BepInEx] Generating Il2Cpp interop assemblies` in
- `BepInEx/LogOutput.log`.
-
-3. **Quit the game.** The interop assemblies are now sitting in
- `BepInEx/interop/Il2CppAssembly-CSharp.dll` etc.
-
-## Building the plugin
-
-The csproj references BepInEx core assemblies via `BepInExCore` and generated
-interop assemblies via `BepInExInterop`. Build from the repo root:
-
-```pwsh
-$env:BepInExCore = "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\BepInEx\core"
-$env:BepInExInterop = "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\BepInEx\interop"
-dotnet build DorkNet.ClientPatch -c Release
-```
-
-Output lands at:
-
-```
-DorkNet.ClientPatch/bin/Release/DorkNet.ClientPatch.dll
-```
-
-## Installing the plugin
-
-Copy the built DLL into the game's `BepInEx/plugins/` folder:
-
-```pwsh
-Copy-Item `
- DorkNet.ClientPatch/bin/Release/DorkNet.ClientPatch.dll `
- "$env:BepInExInterop\..\plugins\"
-```
-
-Or use the patcher (next section).
-
-## Integration with `patch-client.ps1`
-
-The patcher tool grew an `-InstallBepInEx` switch. Pass the path to a
-freshly-extracted BepInEx 6 IL2CPP zip and it'll:
-
-1. Extract BepInEx into the client folder if not already present.
-2. Build this plugin against the existing `BepInEx/interop` directory.
-3. Copy the DLL into `BepInEx/plugins/`.
-4. Append a `Photon CustomAuth integration` step to the patch summary.
-
-```pwsh
-.\tools\patch-client.ps1 `
- -RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" `
- -PhotonAppId cb0880d9-… `
- -InstallBepInEx "C:\Downloads\BepInEx_unix_x86_64_6.0.0-be.696_…zip"
-```
-
-If the interop folder doesn't exist yet (game never launched
-post-BepInEx-install), the patcher will tell you to launch the game
-once and re-run with `-InstallBepInEx` to finish the build step.
-
-## Verifying the plugin loaded
-
-Launch the client. Tail `BepInEx/LogOutput.log` and look for:
-
-```
-[Info : BepInEx] Loading [DorkNet ClientPatch 1.0.0]
-[Info :DorkNet ClientPatch] DorkNet ClientPatch v1.0.0 loading…
-[Info :DorkNet ClientPatch] Photon AuthValues injection patches applied.
-```
-
-After login + the first Photon connect, also expect:
-
-```
-[Info :DorkNet ClientPatch] [auth-injector] set Photon AuthValues — userid=1811750, LoginLock=
-```
-
-On the server side, the `[photon-auth] received query={...}` log line
-should now contain `userid=1811750&LoginLock=`.
-
-## Server-side: flip strict validation back on
-
-Once the plugin is shipping LoginLock in production:
-
-1. Restore the strict-mode body of
- [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs)
- — accept the parameters, look up the canonical lock from
- `PlayerPresenceService.ValidateLock(playerId, loginLock)`, and
- return `ResultCode 3` on mismatch.
-2. In the Photon dashboard, flip **"Reject if Auth Failed"** ON.
-3. Now a duplicate-account login on a *different* machine fails Photon
- auth → never reaches the dorm.
-
-## Troubleshooting
-
-**`Could not load file or assembly 'Il2CppAssembly-CSharp'`**
-The interop folder isn't where the csproj expects. Set the
-`BepInExInterop` env var to your install's interop path before `dotnet
-build`.
-
-**`No interop assemblies found, Il2CppType.Of<…>().GetField fails`**
-Game never launched post-BepInEx-install. Launch once, quit, re-build.
-
-**Plugin loads but `[auth-injector] skip — no LocalAccountId yet` on every connect**
-The Photon connect happens before login completes. Inspect the log
-order — if RecNet auth genuinely hasn't run yet, the plugin can't help
-on that particular connect; it'll succeed on the next one (post-login
-Photon reconnect when joining a room).
-
-**Game crashes on launch with BepInEx but works without**
-Either the plugin threw on Load (check `LogOutput.log` for stack
-trace) or the BepInEx version mismatches — confirm you grabbed the
-IL2CPP, *not* Mono, build for Windows x86_64.
diff --git a/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs b/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs
index bebfde6..953cc40 100644
--- a/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs
+++ b/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs
@@ -10,7 +10,7 @@ namespace DorkNet.Server.Controllers.Auth;
///
/// Photon Cloud sends a GET (or POST) with the parameters the client
/// attached via AuthenticationValues.AddAuthParameter. The
-/// DorkNet BepInEx plugin (DorkNet.ClientPatch) injects two:
+/// DorkNet client mod (DorkNet.ClientMod) injects two:
///
/// - userid — the player's DorkNet account id (long).
/// - LoginLock — the per-session GUID stored in
@@ -118,7 +118,7 @@ public class PhotonCustomAuthController(
// Anonymous connect: pre-login Photon region pings + the watch's
// initial NameServer probe both fire CustomAuth WITHOUT
- // AuthValues attached (the BepInEx plugin only injects AuthValues
+ // AuthValues attached (the client mod only injects AuthValues
// once /player/login has run and Core.LocalAccountId is set).
// Returning ResultCode 1 here lets those bootstrap connects
// succeed; the actual gameplay session has populated AuthValues
diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs
index 5c518cd..2eb1eda 100644
--- a/DorkNet.Server/Program.cs
+++ b/DorkNet.Server/Program.cs
@@ -218,8 +218,7 @@ var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " +
- "add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " +
- "script generates one automatically on same-machine setup).");
+ "add `Jwt:Secret` to appsettings.Local.json.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
diff --git a/DorkNet.sln b/DorkNet.sln
index 17398cf..e94908d 100644
--- a/DorkNet.sln
+++ b/DorkNet.sln
@@ -7,8 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ClientPatch", "DorkNet.ClientPatch\DorkNet.ClientPatch.csproj", "{CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}"
-EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}"
@@ -47,12 +45,6 @@ Global
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.Build.0 = Release|Any CPU
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.ActiveCfg = Release|Any CPU
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.Build.0 = Release|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|x64.ActiveCfg = Debug|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|x86.ActiveCfg = Debug|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|x64.ActiveCfg = Release|Any CPU
- {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|x86.ActiveCfg = Release|Any CPU
{70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.ActiveCfg = Debug|Any CPU
diff --git a/README.md b/README.md
index 3798c54..8379f8b 100644
--- a/README.md
+++ b/README.md
@@ -85,19 +85,21 @@ For Docker / VPS, see [docs/deploy.md](docs/deploy.md).
## Patching your client
```pwsh
-.\tools\install-plugin.ps1 `
+.\tools\install-melon.ps1 `
-RecRoomPath "C:\Path\To\Recroom_Release_Data" `
-PhotonAppId "" `
-PhotonVoiceAppId ""
```
-This installs the BepInEx 6 plugin into the client's `BepInEx/plugins/`
-and writes its config under `BepInEx/config/`. The plugin rewrites all
+This installs the MelonLoader mod (`DorkNet.ClientMod`) into the
+client's `Mods/` folder and writes its config to
+`MelonLoader/UserData/dorknet-clientmod.json`. The mod rewrites all
`*.rec.net` URIs to your configured DorkNet apex, swaps the Photon
AppIds, and bypasses TLS verification on the client's HTTPS calls so
-self-signed certs work.
+self-signed certs work. (First run prints "launch the game once"; do
+that, then re-run with `-ResumeBuild`.)
-If BepInEx isn't installable on the user's machine, the legacy
+If MelonLoader isn't installable on the user's machine, the legacy
`tools/install-legacy-client.ps1` does the same patches via direct
byte-edits of `resources.assets` + `GameAssembly.dll`. Slower setup,
no Harmony runtime needed.
@@ -111,8 +113,7 @@ no Harmony runtime needed.
| `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) |
| `DorkNet.Server/Migrations/` | EF Core schema migrations applied at boot |
| `DorkNet.Models/` | DTOs shared between server and client mod |
-| `DorkNet.ClientPatch/` | BepInEx IL2CPP plugin (production patcher) |
-| `DorkNet.ClientMod/` | MelonLoader port (alternative patcher) |
+| `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher |
| `tools/` | Installers + Cloudflare tunnel templates |
Compared to `december-2020-12-18`, this branch's server is closer to
diff --git a/docs/deploy.md b/docs/deploy.md
index ac385ee..d204396 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -227,40 +227,32 @@ With "Reject if Auth Failed" ON, every Photon connect would fail with
NameServer 32736 — same symptom we tried to solve. With it OFF, the
client connects fine, the endpoint exists for future client patching.
-### Patching the watch to send AuthValues — `DorkNet.ClientPatch/`
+### Patching the watch to send AuthValues — `DorkNet.ClientMod/`
The vanilla 2020 watch never sets `PhotonNetwork.AuthValues` (verified
by an exhaustive grep across both `Assembly-CSharp.dll` and the
Photon `Assembly-CSharp-firstpass.dll` library). To make Photon
-forward `userid` + `LoginLock` to our `/photon/customauth` endpoint we
-ship a **BepInEx 6 IL2CPP plugin** that patches
-`NetworkingPeer.OpAuthenticate` and attaches the values in flight.
+forward `userid` + `LoginLock` to our `/photon/customauth` endpoint a
+Harmony patch on the watch's Photon auth path has to attach the values
+in flight. That hook lives in the **MelonLoader IL2CPP mod**
+(`DorkNet.ClientMod`); the AuthValues injector itself is currently
+parked (see the notes in `DorkNet.ClientMod/Mod.cs`) since the
+`/photon/customauth` endpoint runs in permissive mode.
-Source + build instructions: [DorkNet.ClientPatch/README.md](../DorkNet.ClientPatch/README.md).
-
-End-to-end install on a tester's machine:
+Install the mod on a tester's machine with:
```pwsh
-.\tools\patch-client.ps1 `
+.\tools\install-melon.ps1 `
-RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" `
- -PhotonAppId cb0880d9-… `
- -InstallBepInEx "C:\Downloads\BepInEx_unix_x86_64_6.0.0-be.696_…zip"
+ -PhotonAppId cb0880d9-…
```
-The patcher will:
+The script unpacks MelonLoader, builds `DorkNet.ClientMod.dll`, and
+drops it into the client's `Mods/` folder. On the first run it prints
+"launch the game once" so MelonLoader can generate its IL2CPP
+assemblies; relaunch the script with `-ResumeBuild` to finish.
-1. Do its usual asset/hosts/cert work.
-2. Extract BepInEx into the client folder (skipped if already
- present).
-3. If `BepInEx/interop/` exists, build
- `DorkNet.ClientPatch.dll` against it and drop it into
- `BepInEx/plugins/`.
-4. If `interop/` doesn't exist yet (BepInEx never launched), prompt
- the operator to launch the game once so BepInEx generates interop
- assemblies, then re-run the patcher with the same `-InstallBepInEx`
- path to finish step 3.
-
-Once the plugin is in production:
+Once AuthValues injection is re-enabled in the mod and in production:
1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs)
from permissive-mode (`ResultCode 1` always) back to strict —
diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1
index ab2a63e..b82bdae 100644
--- a/tools/install-melon.ps1
+++ b/tools/install-melon.ps1
@@ -6,9 +6,8 @@
One command that:
1. Downloads MelonLoader (IL2CPP build, x64) from GitHub releases
and extracts it into the Rec Room install dir.
- 2. Builds DorkNet.ClientMod (the MelonLoader port of the BepInEx
- ClientPatch plugin) against MelonLoader's generated
- Il2CppAssemblies.
+ 2. Builds DorkNet.ClientMod (the MelonLoader IL2CPP client mod)
+ against MelonLoader's generated Il2CppAssemblies.
3. Drops the built DLL into \Mods\ and writes
\MelonLoader\UserData\dorknet-clientmod.json
with the user-supplied Photon AppId / server host / etc.
@@ -155,7 +154,7 @@ $global:LASTEXITCODE = 0
# Pinned MelonLoader release. "latest" would be more convenient but
# pinning means a regression upstream doesn't silently land on tester
-# machines — same rationale as the BepInEx pin in install-plugin.ps1.
+# machines — a regression upstream doesn't silently land on testers.
# Bump when there's a known-good newer build.
$Script:MelonLoaderUrl = 'https://github.com/LavaGang/MelonLoader/releases/download/v0.6.6/MelonLoader.x64.zip'
$Script:MelonLoaderCacheName = 'MelonLoader.x64-0.6.6.zip'
@@ -579,10 +578,9 @@ if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container) -and $AutoB
Invoke-DrmStrip -ExePath $RecRoomExe
} catch {
Write-Err "DRM strip failed: $($_.Exception.Message)"
- Write-Host ' Falling back to the manual two-step flow. Either run' -ForegroundColor DarkGray
- Write-Host ' tools\install-plugin.ps1 -InstallBepInEx on this same .exe' -ForegroundColor DarkGray
- Write-Host ' (it has the same Steamless logic) or launch the game from Steam' -ForegroundColor DarkGray
- Write-Host ' once and re-run with -ResumeBuild.' -ForegroundColor DarkGray
+ Write-Host ' Falling back to the manual flow: launch the game once' -ForegroundColor DarkGray
+ Write-Host ' (e.g. from Steam) so it self-strips the DRM, then re-run' -ForegroundColor DarkGray
+ Write-Host ' this script with -ResumeBuild.' -ForegroundColor DarkGray
}
} else {
Write-OK "Recroom_Release.exe is already DRM-stripped."
diff --git a/tools/install-plugin.ps1 b/tools/install-plugin.ps1
deleted file mode 100644
index 6bdf3bd..0000000
--- a/tools/install-plugin.ps1
+++ /dev/null
@@ -1,596 +0,0 @@
-<#
-.SYNOPSIS
- BepInEx-only setup for DorkNet ClientPatch.
-
-.DESCRIPTION
- Replaces the legacy patch-client.ps1 (asset-byte-edit + hosts file +
- mkcert wildcard). Now everything lives in the
- DorkNet.ClientPatch BepInEx plugin: Photon AppId override, *.rec.net
- → user-configured-host URL rewrite, and Photon CustomAuth identity
- injection.
-
- After this script runs once successfully, all you need to do on a
- client version update is download the new build and re-run
- this script — it'll re-extract BepInEx and rebuild the plugin
- against the new game's interop assemblies. No more hardcoded
- resources.assets offsets, no more hosts file, no more wildcard
- cert.
-
-.PARAMETER RecRoomPath
- Path to the Recroom_Release_Data folder (the one containing
- resources.assets). The patcher uses this to locate the actual
- install root (its parent directory).
-
-.PARAMETER BepInExZip
- Optional path to a BepInEx 6.x IL2CPP zip. Only needed if you want
- to override the pinned build the script auto-downloads. On first
- install (or when -ForceBepInEx is set), the script fetches a
- pinned BepInEx build from builds.bepinex.dev and caches it under
- %LOCALAPPDATA%\DorkNet so repeated runs don't re-download. Pass
- this only if you need a specific (newer/older) build.
-
-.PARAMETER ServerHost
- Domain that replaces *.rec.net in every URL the watch builds.
- Default "localhost" — set to your own apex if hosting elsewhere.
-
-.PARAMETER PhotonAppId
- Photon Cloud Realtime AppId (GUID without curly braces).
-
-.PARAMETER PhotonVoiceAppId
- Photon Cloud Voice AppId. Defaults to PhotonAppId.
-
-.PARAMETER ForceBepInEx
- Re-extract BepInEx even if it's already installed. Use after a client
- update.
-
-.PARAMETER Revert
- Remove BepInEx + the plugin from the client install. Doesn't
- touch the game's own files.
-
-.EXAMPLE
- # First-time install — script auto-downloads BepInEx
- .\tools\install-plugin.ps1 `
- -RecRoomPath "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\Recroom_Release_Data" `
- -PhotonAppId
-
-.EXAMPLE
- # Override the pinned BepInEx build with your own zip
- .\tools\install-plugin.ps1 `
- -RecRoomPath "C:\…\Recroom_Release_Data" `
- -BepInExZip "C:\Downloads\BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.NNN.zip" `
- -PhotonAppId cb0880d9-…
-
-.EXAMPLE
- # After a client update (interop changed) — re-run with
- # the same args, optionally adding -ForceBepInEx if BepInEx itself
- # got clobbered.
- .\tools\install-plugin.ps1 `
- -RecRoomPath "C:\…\Recroom_Release_Data" `
- -PhotonAppId cb0880d9-…
-
-.EXAMPLE
- # Remove the plugin
- .\tools\install-plugin.ps1 -RecRoomPath D:\RecRoom\Recroom_Release_Data -Revert
-#>
-
-[CmdletBinding(DefaultParameterSetName = 'Install')]
-param(
- [Parameter(Mandatory = $true)]
- [string]$RecRoomPath,
-
- [Parameter(ParameterSetName = 'Install')]
- [string]$BepInExZip,
-
- [Parameter(ParameterSetName = 'Install')]
- [string]$ServerHost = 'localhost',
-
- [Parameter(Mandatory = $true, ParameterSetName = 'Install')]
- [ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
- [string]$PhotonAppId,
-
- [Parameter(ParameterSetName = 'Install')]
- [ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
- [string]$PhotonVoiceAppId,
-
- [Parameter(ParameterSetName = 'Install')]
- [switch]$ForceBepInEx,
-
- # Tell the plugin to fake the Steamworks SDK so the watch boots
- # without Steam.exe running. Pure C#-level Harmony patches inside
- # the plugin DLL — no replacement steam_api64.dll, no Goldberg /
- # SmartSteamEmu (which Defender flags as PUA). Default off so
- # Steam-launched runs behave normally.
- [Parameter(ParameterSetName = 'Install')]
- [switch]$FakeSteamApi,
-
- # SteamID64 the plugin should report from SteamUser.GetSteamID
- # when -FakeSteamApi is on. Empty = deterministic per-machine ID
- # derived from OS user + machine name. Pin an explicit value to
- # keep the same DorkNet account across machines.
- [Parameter(ParameterSetName = 'Install')]
- [string]$FakeSteamId,
-
- # Display name returned from SteamFriends.GetPersonaName when
- # -FakeSteamApi is on. Empty = OS user name.
- [Parameter(ParameterSetName = 'Install')]
- [string]$FakeAccountName,
-
- [Parameter(Mandatory = $true, ParameterSetName = 'Revert')]
- [switch]$Revert
-)
-
-$ErrorActionPreference = 'Stop'
-Set-StrictMode -Version Latest
-
-# Pinned BepInEx 6 IL2CPP win-x64 build. Auto-downloaded when neither
-# BepInEx is already installed nor -BepInExZip is provided. Pin a
-# specific build rather than 'latest' so a regression upstream
-# doesn't silently land on tester machines.
-$Script:BepInExUrl = 'https://builds.bepinex.dev/projects/bepinex_be/755/BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755%2B3fab71a.zip'
-$Script:BepInExCacheName = 'BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755.zip'
-
-# Pinned Steamless release. atom0s ships it as a single zip with a
-# CLI exe inside. Cached alongside BepInEx under %LOCALAPPDATA%\DorkNet
-# so a one-time download per machine handles both DRM-stripping and
-# mod loading.
-$Script:SteamlessUrl = 'https://github.com/atom0s/Steamless/releases/download/v3.1.0.5/Steamless.v3.1.0.5.-.by.atom0s.zip'
-$Script:SteamlessCacheName = 'Steamless.v3.1.0.5.zip'
-
-# NOTE: Goldberg Steam Emu integration was tried and removed.
-# Windows Defender flags every Steam emulator as PUA and
-# quarantines mid-download. The "boot without Steam running"
-# capability now lives entirely inside DorkNet.ClientPatch —
-# Harmony patches on the Steamworks.NET C# wrappers
-# (SteamAPI.Init, SteamUser.GetSteamID, GetAuthSessionTicket, …)
-# so the watch's SteamPlatformManager.Initialize succeeds without
-# Steam.exe running. Toggled via the -FakeSteamApi switch on this
-# script (writes the [Steam] FakeSteamApi=true config the plugin
-# reads at load).
-
-function Write-Step($msg) { Write-Host ('▸ ' + $msg) -ForegroundColor Cyan }
-function Write-OK($msg) { Write-Host ('✓ ' + $msg) -ForegroundColor Green }
-function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow }
-function Write-Err($msg) { Write-Host ('✗ ' + $msg) -ForegroundColor Red }
-
-function Test-SteamDrmWrapped {
- # Detects the Steamworks DRM wrapper by checking whether the PE's
- # AddressOfEntryPoint falls inside a '.bind' section. Valve's
- # drm_wrap tool injects .bind carrying the DRM stub and rewrites
- # the entry point to land there; SteamStub jumps to the real
- # .text once it's done. After Steamless strips, the entry point
- # is rewritten back into .text and .bind is left as dead weight
- # (the section header survives but execution never visits it),
- # so we'd false-positive if we just looked at .bind's size.
- #
- # PE layout walked here (offsets relative to start of file):
- # e_lfanew @ 0x3C → 4-byte offset to PE header
- # PE+0 "PE\0\0" + COFF header (Sections @ +6, OptHeader size @ +20)
- # OptionalHeader+0x10 = AddressOfEntryPoint (RVA)
- # Section table starts at PE+24+OptHeaderSize, 40 bytes/entry:
- # Name(8) VirtSize(4) VirtAddr(4) RawSize(4) RawPtr(4) ...
- param([string]$ExePath)
- try {
- $fs = [System.IO.File]::OpenRead($ExePath)
- try {
- $br = New-Object System.IO.BinaryReader($fs)
- $fs.Seek(0x3C, 'Begin') | Out-Null
- $peOff = $br.ReadInt32()
- if ($peOff -lt 0 -or $peOff + 24 -gt $fs.Length) { return $false }
- $fs.Seek($peOff + 6, 'Begin') | Out-Null
- $sections = $br.ReadUInt16()
- $fs.Seek($peOff + 20, 'Begin') | Out-Null
- $optSize = $br.ReadUInt16()
- $fs.Seek($peOff + 24 + 16, 'Begin') | Out-Null
- $entryRva = $br.ReadUInt32()
- $secStart = $peOff + 24 + $optSize
- for ($i = 0; $i -lt $sections; $i++) {
- $fs.Seek($secStart + $i * 40, 'Begin') | Out-Null
- $name = [System.Text.Encoding]::ASCII.GetString($br.ReadBytes(8)).TrimEnd([char]0)
- $vSize = $br.ReadUInt32()
- $vAddr = $br.ReadUInt32()
- if ($name -eq '.bind' -and $entryRva -ge $vAddr -and $entryRva -lt ($vAddr + $vSize)) {
- return $true
- }
- }
- return $false
- } finally { $fs.Close() }
- } catch {
- return $false
- }
-}
-
-
-function Get-Steamless {
- # Returns the path to a usable Steamless.CLI.exe. Downloads + extracts
- # the pinned release on first run, caches under %LOCALAPPDATA%\DorkNet.
- param()
- $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
- $extractDir = Join-Path $cacheDir 'Steamless'
- $cli = Join-Path $extractDir 'Steamless.CLI.exe'
- if (Test-Path -LiteralPath $cli -PathType Leaf) {
- return $cli
- }
- New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
- $zipPath = Join-Path $cacheDir $Script:SteamlessCacheName
- if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) {
- Write-Step "Downloading Steamless → $zipPath"
- Write-Host " $Script:SteamlessUrl" -ForegroundColor DarkGray
- try {
- Invoke-WebRequest -Uri $Script:SteamlessUrl -OutFile $zipPath `
- -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
- } catch {
- Write-Err "Steamless download failed: $($_.Exception.Message)"
- Write-Host ' Manually download from https://github.com/atom0s/Steamless/releases' -ForegroundColor DarkGray
- Write-Host " and extract to $extractDir, then re-run." -ForegroundColor DarkGray
- throw
- }
- }
- Write-Step "Extracting Steamless → $extractDir"
- Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
- if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
- # Some Steamless zips put the CLI inside a subfolder; locate it.
- $found = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'Steamless.CLI.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
- if ($found) { $cli = $found.FullName }
- }
- if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
- throw "Steamless.CLI.exe not found after extracting $zipPath"
- }
- return $cli
-}
-
-function Invoke-DrmStrip {
- # Runs Steamless against the wrapped exe in-place. Steamless writes
- # an unpacked copy alongside the original; we move the original to
- # .drm-original.exe and rename the unpacked file in place so the
- # rest of the script doesn't have to know which is which.
- param([string]$ExePath)
- $cli = Get-Steamless
- Write-Step "Stripping Steam DRM from $ExePath"
- $exeDir = Split-Path -Parent $ExePath
- # Steamless emits .exe.unpacked.exe by default. Run with
- # --quiet to keep our log clean; --keepbind 0 lets it strip the
- # .bind section so subsequent runs don't re-trigger the detector.
- $proc = Start-Process -FilePath $cli `
- -ArgumentList @('--quiet', '--keepbind', '0', $ExePath) `
- -NoNewWindow -PassThru -Wait
- if ($proc.ExitCode -ne 0) {
- throw "Steamless exited with code $($proc.ExitCode). The exe may not be a recognised Steam DRM build."
- }
- $unpacked = "$ExePath.unpacked.exe"
- if (-not (Test-Path -LiteralPath $unpacked -PathType Leaf)) {
- throw "Steamless ran but didn't produce $unpacked. Check the tool's output."
- }
- $bak = Join-Path $exeDir 'Recroom_Release.drm-original.exe'
- Move-Item -LiteralPath $ExePath -Destination $bak -Force
- Move-Item -LiteralPath $unpacked -Destination $ExePath -Force
- Write-OK "DRM stripped. Original kept as $bak"
-}
-
-function Get-BepInExZip {
- # Returns a path to a usable BepInEx zip. Reuses a cached download
- # under %LOCALAPPDATA%\DorkNet so a repeated install on the same
- # box doesn't re-download the ~9 MB blob each run.
- param()
- $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
- New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
- $cached = Join-Path $cacheDir $Script:BepInExCacheName
- if (Test-Path -LiteralPath $cached -PathType Leaf) {
- Write-OK "Using cached BepInEx zip → $cached"
- return $cached
- }
- Write-Step "Downloading BepInEx → $cached"
- Write-Host " $Script:BepInExUrl" -ForegroundColor DarkGray
- try {
- # -UseBasicParsing for compatibility with Windows PowerShell 5.1
- # (PS7 ignores it). 90-second timeout — the file is small but a
- # cold builds.bepinex.dev cdn can be sluggish from some regions.
- Invoke-WebRequest -Uri $Script:BepInExUrl -OutFile $cached `
- -UseBasicParsing -TimeoutSec 90 -ErrorAction Stop
- } catch {
- Write-Err "Download failed: $($_.Exception.Message)"
- Write-Host ' Manually download the zip and pass it via -BepInExZip .' -ForegroundColor DarkGray
- Write-Host " Releases page: https://github.com/BepInEx/BepInEx/releases" -ForegroundColor DarkGray
- throw
- }
- $size = (Get-Item -LiteralPath $cached).Length
- Write-OK ("Downloaded {0:N1} MB" -f ($size / 1MB))
- return $cached
-}
-
-# Resolve via a two-step assignment instead of the ?. null-conditional
-# operator — that's PS 7+ only and we want to support Windows
-# PowerShell 5.1 (still the default on stock Win10/11).
-$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue
-if ($resolved) { $RecRoomPath = $resolved.Path } else { $RecRoomPath = $null }
-if (-not $RecRoomPath -or -not (Test-Path -LiteralPath $RecRoomPath -PathType Container)) {
- Write-Err "RecRoomPath '$RecRoomPath' does not exist."
- exit 1
-}
-
-# RecRoomPath = …\Recroom_Release_Data, BepInEx lives one level up
-$RecRoomRoot = Split-Path -Parent $RecRoomPath
-$BepInExDir = Join-Path $RecRoomRoot 'BepInEx'
-$ConfigDir = Join-Path $BepInExDir 'config'
-$PluginsDir = Join-Path $BepInExDir 'plugins'
-$InteropDir = Join-Path $BepInExDir 'interop'
-$RepoRoot = Split-Path -Parent $PSScriptRoot
-$PluginProj = Join-Path $RepoRoot 'DorkNet.ClientPatch'
-
-if ($Revert) {
- Write-Step "Removing BepInEx + plugin from $RecRoomRoot"
- if (Test-Path -LiteralPath $BepInExDir -PathType Container) {
- Remove-Item -LiteralPath $BepInExDir -Recurse -Force
- Write-OK "Removed $BepInExDir"
- }
- foreach ($leaf in @('winhttp.dll', 'doorstop_config.ini', '.doorstop_version', 'changelog.txt')) {
- $p = Join-Path $RecRoomRoot $leaf
- if (Test-Path -LiteralPath $p -PathType Leaf) {
- Remove-Item -LiteralPath $p -Force
- Write-OK "Removed $leaf"
- }
- }
- # Restore the pristine global-metadata.dat from the backup we made
- # before host-patching, so the watch points at *.rec.net again.
- $meta = Join-Path $RecRoomPath 'il2cpp_data\Metadata\global-metadata.dat'
- $backup = $meta + '.dorknet-original'
- if (Test-Path -LiteralPath $backup -PathType Leaf) {
- Copy-Item -LiteralPath $backup -Destination $meta -Force
- Remove-Item -LiteralPath $backup -Force
- Write-OK 'Restored stock global-metadata.dat from backup.'
- }
- Write-OK 'Plugin reverted. Rec Room is now stock.'
- exit 0
-}
-
-if (-not (Test-Path -LiteralPath $PluginProj -PathType Container)) {
- Write-Err "Plugin project missing at '$PluginProj'. Run from a DorkNet repo checkout."
- exit 1
-}
-
-# steam_appid.txt — Steamworks SDK uses this file as "app id is
-# pre-configured, don't relaunch via Steam." Without it, even a
-# DRM-stripped binary still calls SteamAPI.RestartAppIfNecessary
-# at startup and gets handed back to Steam.exe before the .NET
-# payload (and therefore our plugin) has a chance to run. Drop it
-# next to the exe before we test the DRM-strip path.
-$AppIdFile = Join-Path $RecRoomRoot 'steam_appid.txt'
-if (-not (Test-Path -LiteralPath $AppIdFile -PathType Leaf)) {
- '471710' | Out-File -LiteralPath $AppIdFile -Encoding ASCII -NoNewline
- Write-OK "Wrote $AppIdFile (Rec Room AppID 471710 — skips Steam relaunch on boot)"
-}
-
-# DRM check + auto-strip — Steamworks DRM stub runs before BepInEx's
-# winhttp.dll doorstop fires, so a stock Steam-wrapped exe would just
-# exit and ask Steam to relaunch (BepInEx + plugin never get a chance).
-# Detect via .bind entry-point check, then run Steamless against it.
-# Original kept as Recroom_Release.drm-original.exe.
-$RecRoomExe = Join-Path $RecRoomRoot 'Recroom_Release.exe'
-if (Test-Path -LiteralPath $RecRoomExe -PathType Leaf) {
- if (Test-SteamDrmWrapped -ExePath $RecRoomExe) {
- Write-Step 'Steam DRM wrapper detected on Recroom_Release.exe — stripping with Steamless'
- Invoke-DrmStrip -ExePath $RecRoomExe
- # Sanity-check: if Steamless somehow left .bind in place, bail.
- if (Test-SteamDrmWrapped -ExePath $RecRoomExe) {
- Write-Err 'Stripped exe still reports a .bind section — Steamless may not support this build.'
- Write-Host ' Manually strip with the Steamless GUI and re-run this script.' -ForegroundColor DarkGray
- exit 1
- }
- Write-OK 'Recroom_Release.exe is now DRM-stripped.'
- } else {
- Write-OK 'Recroom_Release.exe is DRM-stripped (no .bind section).'
- }
-}
-
-# Steam-without-Steam.exe handling now lives inside the plugin via
-# the [Steam] FakeSteamApi=true config — no DLL replacement, no
-# Goldberg AV trip. We just write the config in step 4 below.
-
-# ── 0b. Patch ns.rec.net / www.rec.net in IL2CPP global metadata ──────────
-# The watch hard-codes the bootstrap URL "https://ns.rec.net/?v=2" as a
-# string literal in IL2CPP global-metadata.dat. It builds the URL via
-# BestHTTP.HTTPRequest directly with the raw string — never goes through
-# `new System.Uri(string)` — so the plugin's URL-rewrite Harmony patch
-# can't see it. Same for the WWW link "www.rec.net". Both literals are
-# the same length as their localhost equivalents, so we can byte-replace
-# in place without shifting offsets.
-#
-# This is the ONLY hardcoded-byte edit the install does, and it's
-# auto-detected & re-applied on every install run. The original file
-# is preserved as `global-metadata.dat.dorknet-original` so -Revert
-# can put it back.
-function Invoke-MetadataHostPatch {
- param([string]$DataDir, [string]$ServerHost)
- $meta = Join-Path $DataDir 'il2cpp_data\Metadata\global-metadata.dat'
- if (-not (Test-Path -LiteralPath $meta -PathType Leaf)) {
- Write-Warn2 "global-metadata.dat not found at $meta — skipping host byte-patch."
- return
- }
- $backup = $meta + '.dorknet-original'
- if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) {
- Copy-Item -LiteralPath $meta -Destination $backup
- }
- # Always patch from the pristine backup so re-runs are idempotent and
- # don't accidentally compound substitutions if the host changes.
- $bytes = [System.IO.File]::ReadAllBytes($backup)
- $subs = @(
- @{ Old = 'ns.rec.net'; New = "ns.$ServerHost" },
- @{ Old = 'www.rec.net'; New = "www.$ServerHost" }
- )
- $totalReplacements = 0
- foreach ($s in $subs) {
- $oldB = [System.Text.Encoding]::ASCII.GetBytes($s.Old)
- $newB = [System.Text.Encoding]::ASCII.GetBytes($s.New)
- if ($oldB.Length -ne $newB.Length) {
- Write-Err "Host '$($s.Old)' (len $($oldB.Length)) and '$($s.New)' (len $($newB.Length)) differ in length — refusing to patch."
- return
- }
- $i = 0
- while ($i -le $bytes.Length - $oldB.Length) {
- $match = $true
- for ($k = 0; $k -lt $oldB.Length; $k++) {
- if ($bytes[$i + $k] -ne $oldB[$k]) { $match = $false; break }
- }
- if ($match) {
- [Array]::Copy($newB, 0, $bytes, $i, $newB.Length)
- $totalReplacements++
- $i += $newB.Length
- } else {
- $i++
- }
- }
- }
- [System.IO.File]::WriteAllBytes($meta, $bytes)
- Write-OK "global-metadata.dat host-patched ($totalReplacements substitution$( if ($totalReplacements -ne 1) { 's' }))."
-}
-Invoke-MetadataHostPatch -DataDir $RecRoomPath -ServerHost $ServerHost
-
-# ── 1. BepInEx install ────────────────────────────────────────────────────
-$bepInExPresent = Test-Path -LiteralPath $BepInExDir -PathType Container
-if (-not $bepInExPresent -or $ForceBepInEx) {
- # Auto-fetch the pinned build when the operator didn't supply one.
- # First-run UX: just `install-plugin.ps1 -RecRoomPath ... -PhotonAppId ...`
- # works without the operator having to manually download a zip.
- if (-not $BepInExZip) {
- $BepInExZip = Get-BepInExZip
- }
- if (-not (Test-Path -LiteralPath $BepInExZip -PathType Leaf)) {
- Write-Err "BepInExZip '$BepInExZip' not found."
- exit 1
- }
- if ($ForceBepInEx -and $bepInExPresent) {
- Write-Step "Removing existing BepInEx install (-ForceBepInEx)"
- Remove-Item -LiteralPath $BepInExDir -Recurse -Force
- }
- Write-Step "Extracting BepInEx → $RecRoomRoot"
- Expand-Archive -LiteralPath $BepInExZip -DestinationPath $RecRoomRoot -Force
- Write-OK 'BepInEx extracted.'
-} else {
- Write-OK "BepInEx already installed at $BepInExDir"
-}
-
-# ── 2. Interop assemblies ─────────────────────────────────────────────────
-if (-not (Test-Path -LiteralPath $InteropDir -PathType Container)) {
- Write-Warn2 'Interop assemblies not found.'
- Write-Host ' Launch the client ONCE so BepInEx generates them, then re-run this script.' -ForegroundColor DarkGray
- Write-Host ' (One-time step per game version — takes ~30s on first launch.)' -ForegroundColor DarkGray
- exit 0
-}
-Write-OK "Interop assemblies present at $InteropDir"
-
-# ── 3. Build plugin ───────────────────────────────────────────────────────
-$env:BepInExInterop = $InteropDir
-$env:BepInExCore = Join-Path $BepInExDir 'core'
-Write-Step 'Building DorkNet.ClientPatch'
-$build = & dotnet build $PluginProj -c Release --nologo 2>&1
-if ($LASTEXITCODE -ne 0) {
- Write-Err 'Plugin build failed:'
- Write-Host ($build -join "`n") -ForegroundColor DarkGray
- exit 1
-}
-$pluginDll = Join-Path $PluginProj 'bin\Release\DorkNet.ClientPatch.dll'
-if (-not (Test-Path -LiteralPath $pluginDll -PathType Leaf)) {
- Write-Err "Plugin DLL not produced at $pluginDll"
- exit 1
-}
-New-Item -ItemType Directory -Path $PluginsDir -Force | Out-Null
-$oldPluginDll = Join-Path $PluginsDir ('DorkNet.' + 'Rec' + 'RoomPatch.dll')
-if (Test-Path -LiteralPath $oldPluginDll -PathType Leaf) {
- Remove-Item -LiteralPath $oldPluginDll -Force
- Write-OK 'Removed old client patch plugin.'
-}
-Copy-Item -LiteralPath $pluginDll -Destination $PluginsDir -Force
-Write-OK ('Plugin DLL → ' + (Join-Path $PluginsDir 'DorkNet.ClientPatch.dll'))
-
-# ── 4. Plugin config ──────────────────────────────────────────────────────
-# Write the BepInEx config file with the operator's chosen values.
-# Format mirrors what the plugin's Config.Bind defaults produce on first
-# run, so editing afterwards via BepInEx config manager works as expected.
-$cfgFile = Join-Path $ConfigDir 'sh.dork.clientpatch.cfg'
-New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
-
-$voiceId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' }
-$cfg = @"
-## Settings file was created by DorkNet install-plugin.ps1
-## Plugin GUID: sh.dork.clientpatch
-
-[Server]
-
-## Domain that replaces *.rec.net in every URL the watch builds.
-## Empty disables the rewrite (use only if pointing at the official servers).
-# Setting type: String
-# Default value: localhost
-Host = $ServerHost
-
-[Photon]
-
-## Photon Cloud Realtime AppId (UUID, no curly braces).
-## Empty leaves the AppId baked into the build untouched.
-# Setting type: String
-# Default value:
-AppId = $PhotonAppId
-
-## Photon Cloud Voice AppId. Empty falls back to AppId.
-# Setting type: String
-# Default value:
-VoiceAppId = $voiceId
-
-## 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.
-# Setting type: Boolean
-# Default value: true
-InjectAuthValues = true
-
-[UI]
-
-## 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.
-# Setting type: String
-# Default value: DorkNet
-BrandName = DorkNet
-
-[Steam]
-
-## 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).
-# Setting type: Boolean
-# Default value: true
-SkipRestartAppIfNecessary = true
-
-## 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.
-# Setting type: Boolean
-# Default value: false
-FakeSteamApi = $($FakeSteamApi.IsPresent.ToString().ToLower())
-
-## SteamID64 returned by SteamUser.GetSteamID when FakeSteamApi=true.
-## Empty = deterministic value derived from OS user + machine name.
-## Pin an explicit ID to keep the same DorkNet account across
-## different machines.
-# Setting type: String
-# Default value:
-FakeSteamId = $FakeSteamId
-
-## Display name returned by SteamFriends.GetPersonaName when
-## FakeSteamApi=true. Empty = OS user name.
-# Setting type: String
-# Default value:
-FakeAccountName = $FakeAccountName
-"@
-$cfg | Out-File -LiteralPath $cfgFile -Encoding utf8 -Force
-Write-OK "Plugin config → $cfgFile"
-
-Write-Host ''
-Write-OK 'All done. Launch the client from Steam.'
-Write-Host ' Tail BepInEx/LogOutput.log for `[auth-injector] set Photon AuthValues …`' -ForegroundColor DarkGray
-Write-Host ' to confirm the plugin loaded and ran.' -ForegroundColor DarkGray