From 82c6f72219df67e719f473750b9cab203f5d0951 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 30 May 2026 02:12:00 +0300 Subject: [PATCH] Remove BepInEx client patcher (DorkNet.ClientPatch) MelonLoader (DorkNet.ClientMod) is the sole client patcher now: - delete the DorkNet.ClientPatch project + tools/install-plugin.ps1 - drop the project from DorkNet.sln - README / deploy docs: present DorkNet.ClientMod (install-melon.ps1) as the patcher instead of the BepInEx plugin - drop the dead install-plugin.ps1 reference from Program.cs's JWT error and install-melon.ps1's DRM-fallback hint - tidy stray 'BepInEx port' comments Legacy-cleanup paths that REMOVE an old BepInEx install (install-legacy-client.ps1, build-patcher-zip.ps1's old_plugin_paths) are kept intentionally. --- DorkNet.ClientMod/Mod.cs | 14 +- .../DorkNet.ClientPatch.csproj | 106 - .../DorkNet.ClientPatch.csproj.lscache | 231 -- DorkNet.ClientPatch/NuGet.Config | 14 - DorkNet.ClientPatch/Plugin.cs | 2011 ----------------- DorkNet.ClientPatch/README.md | 217 -- .../Auth/PhotonCustomAuthController.cs | 4 +- DorkNet.Server/Program.cs | 3 +- DorkNet.sln | 8 - README.md | 15 +- docs/deploy.md | 38 +- tools/install-melon.ps1 | 14 +- tools/install-plugin.ps1 | 596 ----- 13 files changed, 38 insertions(+), 3233 deletions(-) delete mode 100644 DorkNet.ClientPatch/DorkNet.ClientPatch.csproj delete mode 100644 DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache delete mode 100644 DorkNet.ClientPatch/NuGet.Config delete mode 100644 DorkNet.ClientPatch/Plugin.cs delete mode 100644 DorkNet.ClientPatch/README.md delete mode 100644 tools/install-plugin.ps1 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(""; - } - - 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