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