mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -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
|
// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
|
||||||
// the minimum set of Harmony patches needed for the 2020 watch to
|
// Harmony patches needed for the 2020 watch to connect to a DorkNet
|
||||||
// connect to a DorkNet server:
|
// server:
|
||||||
//
|
//
|
||||||
// 1. URI rewrite — `.rec.net` → user-configured host
|
// 1. URI rewrite — `.rec.net` → user-configured host
|
||||||
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
// 2. Photon AppId — override the baked-in Photon Cloud AppId
|
||||||
@@ -49,11 +49,9 @@ public class Mod : MelonMod
|
|||||||
private int _diagnosticRetryFrame;
|
private int _diagnosticRetryFrame;
|
||||||
private bool _firstUpdateLogged;
|
private bool _firstUpdateLogged;
|
||||||
|
|
||||||
// Loader-agnostic config — same .Value-shaped accessor the patches
|
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||||
// would see in the BepInEx port, just backed by a plain JSON file
|
// The defaults below match the recommended install, so dropping this
|
||||||
// instead of BepInEx's TOML config system. Defaults match the BepInEx
|
// mod in without writing a config file still gives working behaviour.
|
||||||
// plugin's defaults so dropping this mod in without writing a config
|
|
||||||
// file gives the same behaviour as the recommended install.
|
|
||||||
public static class Cfg
|
public static class Cfg
|
||||||
{
|
{
|
||||||
public static string ServerHost = "localhost";
|
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
|
/// Photon Cloud sends a GET (or POST) with the parameters the client
|
||||||
/// attached via <c>AuthenticationValues.AddAuthParameter</c>. The
|
/// attached via <c>AuthenticationValues.AddAuthParameter</c>. The
|
||||||
/// DorkNet BepInEx plugin (DorkNet.ClientPatch) injects two:
|
/// DorkNet client mod (DorkNet.ClientMod) injects two:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>userid</c> — the player's DorkNet account id (long).</item>
|
/// <item><c>userid</c> — the player's DorkNet account id (long).</item>
|
||||||
/// <item><c>LoginLock</c> — the per-session GUID stored in
|
/// <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
|
// Anonymous connect: pre-login Photon region pings + the watch's
|
||||||
// initial NameServer probe both fire CustomAuth WITHOUT
|
// 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).
|
// once /player/login has run and Core.LocalAccountId is set).
|
||||||
// Returning ResultCode 1 here lets those bootstrap connects
|
// Returning ResultCode 1 here lets those bootstrap connects
|
||||||
// succeed; the actual gameplay session has populated AuthValues
|
// succeed; the actual gameplay session has populated AuthValues
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.S
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}"
|
||||||
EndProject
|
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}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}"
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.ActiveCfg = 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
|
{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.ActiveCfg = Debug|Any CPU
|
||||||
{70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.Build.0 = 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
|
{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
|
## Patching your client
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
.\tools\install-plugin.ps1 `
|
.\tools\install-melon.ps1 `
|
||||||
-RecRoomPath "C:\Path\To\Recroom_Release_Data" `
|
-RecRoomPath "C:\Path\To\Recroom_Release_Data" `
|
||||||
-PhotonAppId "<your-photon-realtime-app-id>" `
|
-PhotonAppId "<your-photon-realtime-app-id>" `
|
||||||
-PhotonVoiceAppId "<your-photon-voice-app-id>"
|
-PhotonVoiceAppId "<your-photon-voice-app-id>"
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs the BepInEx 6 plugin into the client's `BepInEx/plugins/`
|
This installs the MelonLoader mod (`DorkNet.ClientMod`) into the
|
||||||
and writes its config under `BepInEx/config/`. The plugin rewrites all
|
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
|
`*.rec.net` URIs to your configured DorkNet apex, swaps the Photon
|
||||||
AppIds, and bypasses TLS verification on the client's HTTPS calls so
|
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.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) |
|
||||||
| `DorkNet.Models/` | DTOs shared between server and client mod |
|
| `DorkNet.Models/` | DTOs shared between server and client mod |
|
||||||
| `DorkNet.ClientPatch/` | BepInEx IL2CPP plugin (production patcher) |
|
| `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher |
|
||||||
| `DorkNet.ClientMod/` | MelonLoader port (alternative patcher) |
|
| `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates |
|
||||||
| `tools/` | Installers (`install-plugin.ps1`, `install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates |
|
|
||||||
|
|
||||||
See [docs/architecture.md](docs/architecture.md) for the full mental
|
See [docs/architecture.md](docs/architecture.md) for the full mental
|
||||||
model (project layout, request lifecycle, watch-mirror controller
|
model (project layout, request lifecycle, watch-mirror controller
|
||||||
|
|||||||
+7
-10
@@ -16,23 +16,20 @@ files in this folder.
|
|||||||
DorkNet.sln
|
DorkNet.sln
|
||||||
├── DorkNet.Server ASP.NET Core 9 backend — the bulk of the code
|
├── DorkNet.Server ASP.NET Core 9 backend — the bulk of the code
|
||||||
├── DorkNet.Models Shared DTO + serialization types
|
├── DorkNet.Models Shared DTO + serialization types
|
||||||
├── DorkNet.ClientMod MelonLoader plugin (portable / experimental)
|
└── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher
|
||||||
└── DorkNet.ClientPatch BepInEx 6 IL2CPP plugin (production)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Project | Responsibility | Target |
|
| Project | Responsibility | Target |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DorkNet.Server` | REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 |
|
| `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.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.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
||||||
| `DorkNet.ClientPatch` | BepInEx 6 IL2CPP plugin — the standard client patcher, TOML config | .NET 6 |
|
|
||||||
|
|
||||||
`ClientMod` and `ClientPatch` are intentional twins: both apply the
|
`ClientMod` applies the client-side patches needed to point the 2020
|
||||||
same patches (URI rewrite `.rec.net` → configured apex, Photon AppId
|
watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex),
|
||||||
override, TLS trust bypass, auth injection) against different mod
|
Photon AppId override, and TLS trust bypass. It loads under
|
||||||
loaders. ClientPatch is the supported deployment; ClientMod exists for
|
MelonLoader 0.6.x and reads its settings from a JSON config under
|
||||||
contributors who already have MelonLoader installed and don't want to
|
`MelonLoader/UserData/`.
|
||||||
swap loaders.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+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
|
NameServer 32736 — same symptom we tried to solve. With it OFF, the
|
||||||
client connects fine, the endpoint exists for future client patching.
|
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
|
The vanilla 2020 watch never sets `PhotonNetwork.AuthValues` (verified
|
||||||
by an exhaustive grep across both `Assembly-CSharp.dll` and the
|
by an exhaustive grep across both `Assembly-CSharp.dll` and the
|
||||||
Photon `Assembly-CSharp-firstpass.dll` library). To make Photon
|
Photon `Assembly-CSharp-firstpass.dll` library). To make Photon
|
||||||
forward `userid` + `LoginLock` to our `/photon/customauth` endpoint we
|
forward `userid` + `LoginLock` to our `/photon/customauth` endpoint a
|
||||||
ship a **BepInEx 6 IL2CPP plugin** that patches
|
Harmony patch on the watch's Photon auth path has to attach the values
|
||||||
`NetworkingPeer.OpAuthenticate` and attaches the values in flight.
|
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).
|
Install the mod on a tester's machine with:
|
||||||
|
|
||||||
End-to-end install on a tester's machine:
|
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
.\tools\patch-client.ps1 `
|
.\tools\install-melon.ps1 `
|
||||||
-RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" `
|
-RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" `
|
||||||
-PhotonAppId cb0880d9-… `
|
-PhotonAppId cb0880d9-…
|
||||||
-InstallBepInEx "C:\Downloads\BepInEx_unix_x86_64_6.0.0-be.696_…zip"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Once AuthValues injection is re-enabled in the mod and in production:
|
||||||
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:
|
|
||||||
|
|
||||||
1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs)
|
1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs)
|
||||||
from permissive-mode (`ResultCode 1` always) back to strict —
|
from permissive-mode (`ResultCode 1` always) back to strict —
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
One command that:
|
One command that:
|
||||||
1. Downloads MelonLoader (IL2CPP build, x64) from GitHub releases
|
1. Downloads MelonLoader (IL2CPP build, x64) from GitHub releases
|
||||||
and extracts it into the Rec Room install dir.
|
and extracts it into the Rec Room install dir.
|
||||||
2. Builds DorkNet.ClientMod (the MelonLoader port of the BepInEx
|
2. Builds DorkNet.ClientMod (the MelonLoader IL2CPP client mod)
|
||||||
ClientPatch plugin) against MelonLoader's generated
|
against MelonLoader's generated Il2CppAssemblies.
|
||||||
Il2CppAssemblies.
|
|
||||||
3. Drops the built DLL into <RecRoomInstall>\Mods\ and writes
|
3. Drops the built DLL into <RecRoomInstall>\Mods\ and writes
|
||||||
<RecRoomInstall>\MelonLoader\UserData\dorknet-clientmod.json
|
<RecRoomInstall>\MelonLoader\UserData\dorknet-clientmod.json
|
||||||
with the user-supplied Photon AppId / server host / etc.
|
with the user-supplied Photon AppId / server host / etc.
|
||||||
@@ -155,7 +154,7 @@ $global:LASTEXITCODE = 0
|
|||||||
|
|
||||||
# Pinned MelonLoader release. "latest" would be more convenient but
|
# Pinned MelonLoader release. "latest" would be more convenient but
|
||||||
# pinning means a regression upstream doesn't silently land on tester
|
# 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.
|
# 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:MelonLoaderUrl = 'https://github.com/LavaGang/MelonLoader/releases/download/v0.6.6/MelonLoader.x64.zip'
|
||||||
$Script:MelonLoaderCacheName = 'MelonLoader.x64-0.6.6.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
|
Invoke-DrmStrip -ExePath $RecRoomExe
|
||||||
} catch {
|
} catch {
|
||||||
Write-Err "DRM strip failed: $($_.Exception.Message)"
|
Write-Err "DRM strip failed: $($_.Exception.Message)"
|
||||||
Write-Host ' Falling back to the manual two-step flow. Either run' -ForegroundColor DarkGray
|
Write-Host ' Falling back to the manual flow: launch the game once' -ForegroundColor DarkGray
|
||||||
Write-Host ' tools\install-plugin.ps1 -InstallBepInEx <zip> on this same .exe' -ForegroundColor DarkGray
|
Write-Host ' (e.g. from Steam) so it self-strips the DRM, then re-run' -ForegroundColor DarkGray
|
||||||
Write-Host ' (it has the same Steamless logic) or launch the game from Steam' -ForegroundColor DarkGray
|
Write-Host ' this script with -ResumeBuild.' -ForegroundColor DarkGray
|
||||||
Write-Host ' once and re-run with -ResumeBuild.' -ForegroundColor DarkGray
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-OK "Recroom_Release.exe is already DRM-stripped."
|
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