27 Commits

Author SHA1 Message Date
Devin Zuczek 7c78a0187a Update license link 2026-06-30 11:01:31 -04:00
Devin Zuczek ff9749483a updating readme 2026-06-30 10:49:09 -04:00
Devin Zuczek 4b413728a7 add readme 2026-06-30 10:16:16 -04:00
Devin Zuczek c28ec48bbb skip logging some collection endpoints 2026-06-30 10:10:53 -04:00
Lapis e36f1e447e Merge pull request #2 from djdevin/2023-improve-logging 2026-06-29 16:25:26 -04:00
Lapis 733a13682d Merge pull request #3 from djdevin/2023-project-path-variables 2026-06-29 16:22:02 -04:00
Devin Zuczek 65a3560fc7 improve logging 2026-06-29 16:12:54 -04:00
Devin Zuczek 9148e7b6dd configurable rec room location 2026-06-29 16:05:59 -04:00
Lapis ede406e25b Merge pull request #1 from djdevin/2023-eac-account-creation 2026-06-29 15:33:05 -04:00
Devin Zuczek 35d0a4281a disable cheatmanager to preserve DUID function 2026-06-29 15:21:43 -04:00
Lapis 9bafbcd188 fix account creation... ooops... 2026-06-18 11:20:38 -04:00
Lapis f56ed0d8de fix room loading + metadata changes allowed 2026-06-18 09:22:48 -04:00
Lapis dcb7faec5d fix photon patches 2026-06-14 06:04:49 -04:00
Lapis 837543f5e2 add advanced settings 2026-06-04 18:58:56 -04:00
Lapis 6855abe9f6 kill cheatmanager on any scene 2026-06-04 18:49:44 -04:00
Lapis 9c6448a3f4 2023 eac patch 2026-06-03 18:07:50 -04:00
Lapis d0299ae50e fix: scene name 2026-06-03 15:05:15 -04:00
Lapis 095b199568 oops 2026-06-03 09:34:54 -04:00
Lapis 0e53f35fb6 fix cheatmanager destroy 2026-06-02 18:41:58 -04:00
Lapis e1459b2de2 fix: 2023 references 2026-06-02 18:13:57 -04:00
Lapis 2448d8daae chore: clean up project file 2026-05-21 15:50:50 -04:00
Lapis b4e83bddbf fix for VR 2026-04-05 14:49:32 -04:00
Lapis 5a8b4b2d52 kill cheatmanager, add configs 2026-04-03 13:39:51 -04:00
Lapis 78a9a2e824 stupid dumb photon patch 2026-04-02 14:10:24 -04:00
Lapis f70ed0c548 Merge branch 'main' of https://github.com/LapisGit/CannedNet.Client 2026-04-01 09:19:22 -04:00
Lapis a442466f98 h 2026-04-01 09:19:01 -04:00
Lapis 7c103ac7e1 Initial commit 2026-04-01 09:17:03 -04:00
19 changed files with 119 additions and 579 deletions
+7 -3
View File
@@ -1,3 +1,7 @@
GamePath.props
obj
bin
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/.idea
CannedNet.Client.sln.DotSettings.user
-130
View File
@@ -1,130 +0,0 @@
# CLAUDE.md
Guidance for working in this repo. This is a **BepInEx 6 (IL2CPP)** Harmony plugin that points the
Rec Room client at a self-hosted server. Read the README for the user-facing overview; this file is
the stuff you only learn by getting burned.
## Build & deploy
```sh
dotnet build -c Debug -p:GamePath="C:\Games\depots\471711\23191908"
```
- `GamePath` points at the Rec Room install root. It's normally set in the gitignored
`GamePath.props` (see `GamePath.props.example`); the `-p:GamePath=...` override is handy for one-offs.
- The project references ~150 interop DLLs from `$(GamePath)\BepInEx\interop`. Those are generated by
Il2CppInterop the first time the game runs under BepInEx — if they're missing, launch the game once.
- A post-build `DeployPlugin` target copies `RecNetPlugin.dll` into `$(GamePath)\BepInEx\plugins\`.
**The copy fails while Rec Room is running** (the DLL is locked) — that's an MSB3027 error, not a
compile error. Close the game and rebuild. The DLL is also always left in `bin/Debug/net6.0/`.
## Hard-won gotchas (read before patching anything)
1. **Interop assemblies are stubs. The real code is native.** The DLLs under `BepInEx/interop` are
Il2CppInterop proxies — method bodies just marshal into `GameAssembly.dll`. dnSpy / managed
decompilation of the interop shows *no real logic*. You cannot read the actual algorithms
statically; you learn behavior by patching + logging at runtime.
2. **Obfuscated names differ per game build.** Rec Room's type/method names are obfuscated
(`PGECJHKNIEN`, `MDBMGOBECDJ`, `cm_did_ppk`, etc.). A dnSpy dump from *some* build will not
necessarily match the interop you compile against. Real example from the DUID work: a dnSpy dump
called the method `CheckForMismatch` and the pref field `DBAIOPIEJNC`, but in our interop the
method is `CheckForDUIDMismatch` and neither `DBAIOPIEJNC` nor `MDBMGOBECDJ` exist at all. **Always
resolve members against the interop DLLs you actually build against** (see the Cecil snippet below),
never against a dump from an unknown build.
3. **Patch the concrete class, not the IL2CPP "interface".** Il2CppInterop renders IL2CPP interfaces
as abstract classes deriving from `Il2CppObjectBase`. Harmony will happily patch an abstract method
and throw no error, but the prefix **never runs** because the game dispatches to the concrete
implementation. This cost us a whole "shipped fix" that did nothing. Concrete impls live in
`Assembly-CSharp.dll`. Example: patch `CheatManager.CheckForDUIDMismatch`, *not* the interface
`PGECJHKNIEN.CheckForDUIDMismatch`. Verify with Cecil that `IsAbstract == false` before trusting a
patch.
4. **Obfuscated members live in the global namespace** and are referenced unqualified in this codebase
(e.g. `typeof(JAPJPGNBMNM)`, `PGECJHKNIEN`). No `using` needed.
5. **Harmony prefix conventions here:** force a value + `return false` to skip the original (see
`Patches/EACPatches.cs`). For out-params, take a `ref` parameter named exactly as the interop shows
it (e.g. `ref string ALOMDLLNIMD`), plus `ref bool __result`.
## Inspecting the game
Use **Mono.Cecil** for static metadata/signature checks (accessibility, abstract-ness, exact param
names, which assembly a concrete impl lives in). Mark-of-the-web will block loading `Mono.Cecil.dll`
directly — copy it somewhere local, `Unblock-File`, then load via bytes:
```powershell
$dst = "$scratch\Mono.Cecil.dll"
Copy-Item "$interop\Mono.Cecil.dll" $dst; Unblock-File $dst
[System.Reflection.Assembly]::Load([System.IO.File]::ReadAllBytes($dst)) | Out-Null
$asm = [Mono.Cecil.AssemblyDefinition]::ReadAssembly("$interop\Assembly-CSharp.dll")
# then walk $asm.MainModule.GetTypes(), inspect .Methods / .Fields / .IsAbstract / .IsStatic ...
```
Notes:
- Windows PowerShell 5.1 has **no** `?.` null-conditional operator — use explicit `$x -eq $null` checks.
- String literals (pref keys, endpoints, GUIDs) are in `RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat`.
`grep -a -o -E '[ -~]{4,}' global-metadata.dat | grep -i <thing>` extracts them.
- The BepInEx runtime log is `$(GamePath)/BepInEx/LogOutput.log`. Our plugin logs under the
`RecNet Plugin` source. `[HTTP]`, `[DUID]`, `[DUID-PROBE]`, `[DEVICEID]`, `[CORRUPT]` are our tags.
- PlayerPrefs on Windows live in the registry at `HKCU\Software\Against Gravity\Rec Room`, value names
are `<key>_h<unityHash>`, values are `REG_BINARY`. CodeStage AntiCheat stores strings *obscured*
(XOR-encrypted), so a stored id will not appear as plaintext in registry or files.
## Case study: the Create Account / DUID hang
The gnarliest bug so far; the diagnostic tooling for it still lives in the repo. Summary:
- **Symptom:** on some machines Create Account hangs. Log shows a `PlayerReporting/v1/deviceId` POST
returning `200 {"success":true}`, after which the client never calls the `create_account` OAuth and
never persists the id (`WriteDUIDs` never runs).
- **Trigger:** a device-id **mismatch**. `CheatManager.CheckForDUIDMismatch(out string)` returns true
when the stored id differs from `SystemInfo.deviceUniqueIdentifier`. True → migration path → POST →
hang. Machines whose stored id matches never take the path.
- **Stored id:** PlayerPrefs key `cm_did_ppk` (registry `cm_did_ppk_h3478365449`), CodeStage
ObscuredString-encoded. `CheatManager.WriteDUIDs()` writes it, `ClearDUIDs()` deletes it. In our
interop these are **instance** methods (a dnSpy dump showed them static — build difference again).
- **Two surprises:**
1. Deleting the registry value did **not** change the `oldDeviceId` in the POST, and the probe
showed no `cm_did_ppk` read that session — i.e. the "old" id is **not sourced from local
PlayerPrefs** on the failing path. Consequence: **a registry-reset script does not fix it.**
2. `game callback attached = True` on that request → the client is genuinely waiting on the
response. But the real server already returns `{"success":true}` at 200 and it still hangs, so the
accepting response shape (if one exists) is something more specific.
> ### ⚠️ OPEN QUESTION — where does the old DUID actually live?
> We have **not** found the source of the `oldDeviceId` value. It survives a full delete of the
> `HKCU\Software\Against Gravity\Rec Room` registry key, it does not appear as plaintext anywhere in
> `AppData/LocalLow/Against Gravity/Rec Room`, and on the failing run the `cm_did_ppk` PlayerPref is
> never read. So `cm_did_ppk` is *a* copy but not the one that seeds the migration POST. Leading (but
> unconfirmed) theory: it's held server-side by the archival server, which recorded it from prior
> POSTs, and/or cached in memory from a server response. **Until this is found, the only reliable fix
> is `Suppress` (client) or correcting the value server-side — not clearing local storage.** Next
> steps to try: inspect what the recflare backend stores/returns for the account's device id; dump the
> `HTTPCache` entries decoded (not plaintext); trace who sets the field the POST body reads from.
- **Working client-side workaround:** force `CheckForDUIDMismatch` → false (skips the migration path
entirely). Config: `Suppress DUID Mismatch = true`.
- **Proper root-cause fix:** server-side — make the endpoint stop reporting a stale `old` id (so
`old == new`, no mismatch) or return whatever the client needs to proceed.
### Diagnostic knobs (all in `[Advanced]`)
`Suppress DUID Mismatch` defaults **true** (it's the shipped fix). Everything else defaults **false**
those are investigation tools, not normal config. See the patch files for details.
| Config key | Patch file | What it does |
| --- | --- | --- |
| `Suppress DUID Mismatch` | `DUIDMismatchPatch.cs` | **The fix (default true).** Force `CheckForDUIDMismatch` → false. |
| `Simulate DUID Mismatch` | `DUIDMismatchPatch.cs` | Force it → true. Reproduce the hang without a corrupt value. |
| `Corrupt Stored DUID` | `CorruptDUIDPatch.cs` | One-shot: write a truncated id via `WriteDUIDs` (spoofing `SystemInfo.deviceUniqueIdentifier`) to create a *genuinely* corrupt stored value. |
| `Restore Stored DUID` | `CorruptDUIDPatch.cs` | One-shot undo: `WriteDUIDs` with the real id. |
| `DeviceId Response Override` / `Status` | `DeviceIdResponsePatch.cs` | Rewrite the `deviceId` response body/status in-flight to probe what shape the client will accept. |
| (probe) | `DUIDProbePatch.cs` | Logs every PlayerPrefs get/set and the `WriteDUIDs`/`ClearDUIDs` calls — `WriteDUIDs() called` is the "client accepted the response" signal. |
`DUIDMismatchPatch` has three modes: `Simulate` → force true, `Suppress` → force false, neither →
pass through to the real check (needed to observe a genuinely corrupt stored value).
**The diagnostic patches should not ship in a release build** — strip them (or at least confirm their
knobs default false: `Simulate`, `Corrupt`, `Restore`, `DeviceId Response Override`) before cutting a
release. `Suppress DUID Mismatch` is the real fix and defaults **true**, so it stays on.
+16
View File
@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CannedNet.Client", "CannedNet.Client\CannedNet.Client.csproj", "{B6704474-3934-43AE-9E89-E5851F6A266B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B6704474-3934-43AE-9E89-E5851F6A266B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6704474-3934-43AE-9E89-E5851F6A266B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B6704474-3934-43AE-9E89-E5851F6A266B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6704474-3934-43AE-9E89-E5851F6A266B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+1
View File
@@ -0,0 +1 @@
GamePath.props
@@ -2,8 +2,8 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<AssemblyName>RecNetPlugin</AssemblyName>
<Product>RecNetPlugin</Product>
<AssemblyName>CannedNet.Client</AssemblyName>
<Product>My first plugin</Product>
<Version>1.0.0</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>latest</LangVersion>
@@ -12,7 +12,7 @@
https://nuget.bepinex.dev/v3/index.json;
https://nuget.samboy.dev/v3/index.json
</RestoreAdditionalProjectSources>
<RootNamespace>RecNetPlugin</RootNamespace>
<RootNamespace>CannedNet.Client</RootNamespace>
</PropertyGroup>
<!-- GamePath = root of a Rec Room install whose BepInEx/interop/ has been populated.
@@ -3,7 +3,7 @@ using RecRoom.AntiCheat;
using System.Text;
using Il2CppSystem;
namespace RecNetPlugin.Patches;
namespace CannedNet.Client.Patches;
[HarmonyPatch]
public static class EACPatches
@@ -23,7 +23,7 @@ public static class EACPatches
if (!string.IsNullOrEmpty(PGCINMIEBJP))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(PGCINMIEBJP));
else
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("i hate this"));
return false;
}
}
@@ -1,13 +1,9 @@
using HarmonyLib;
using Org.BouncyCastle.Crypto.Tls;
namespace RecNetPlugin.Patches;
namespace CannedNet.Client.Patches;
/**
Disables TLS certificate pinning. Even though we connect over SSL it seems some certificates
might be pinned.
*/
public class DisableTLSPinning
public class FuckOffTLS
{
[HarmonyPatch(typeof(LegacyTlsAuthentication), "NotifyServerCertificate")]
public class TlsPatch
@@ -17,4 +13,4 @@ public class DisableTLSPinning
return false;
}
}
}
}
@@ -1,18 +1,19 @@
using System;
using System.Reflection;
using ExitGames.Client.Photon;
using HarmonyLib;
using Photon.Realtime;
namespace RecNetPlugin.Patches;
namespace CannedNet.Client.Patches;
/**
Patches Photon to use the App IDs and server hostname/port specified in the plugin config.
*/
[HarmonyPatch(typeof(GPFPFDBGCEK), "AMOHMPKKGHL")]
public class PhotonPatches
{
[HarmonyPostfix]
private static void Postfix(ref AppSettings __result)
{
Plugin.Log.LogInfo("okay im patching now");
if (__result != null)
{
__result.AppIdRealtime = Plugin.AppIdRT.Value;
@@ -1,11 +1,5 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
/**
* This allows the global-metadata.dat to be different on the client
* patched to allow a different modulus so we can sign images.
*/
[HarmonyPatch(typeof(JAPJPGNBMNM), "JOKECJKBJGD")]
public static class PromisePatch
{
@@ -3,7 +3,7 @@ using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
namespace RecNetPlugin.Patches;
namespace CannedNet.Client.Patches;
/**
Intercept a variety of HTTP requests and rewrite them to point to our own custom server.
@@ -20,7 +20,6 @@ public class SendRequestPatch
"/data/heartbeat",
"/identify",
"/httpapi",
"/data/event",
};
private static bool IsIgnoredForLogging(string url)
@@ -31,16 +30,6 @@ public class SendRequestPatch
return false;
}
// Cap logged bodies so a large response/request doesn't flood the log.
private const int MaxLoggedBodyLength = 10000;
private static string Truncate(string s)
{
if (string.IsNullOrEmpty(s) || s.Length <= MaxLoggedBodyLength)
return s;
return s.Substring(0, MaxLoggedBodyLength) + $"... <truncated {s.Length - MaxLoggedBodyLength} chars>";
}
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
public class ConnectToRecNetPatch
{
@@ -55,10 +44,10 @@ public class SendRequestPatch
if (entityBody == null)
body = "<none>";
else if (IsBinaryContentType(request.GetFirstHeaderValue("content-type")) || LooksBinary(entityBody))
body = BinaryPreview(entityBody);
body = "<binary>";
else
body = System.Text.Encoding.UTF8.GetString(entityBody);
Plugin.Log.LogInfo($"[HTTP] {request.MethodType} {request.Uri.AbsoluteUri} body={Truncate(body)}");
Plugin.Log.LogInfo($"[HTTP] {request.MethodType} {request.Uri.AbsoluteUri} body={body}");
}
var host = request.Uri.Host;
@@ -103,7 +92,7 @@ public class SendRequestPatch
text = resp.DataAsText;
if (string.IsNullOrEmpty(text)) text = "<empty>";
}
var msg = $"[HTTP] <- {resp.StatusCode} {url} body={Truncate(text)}";
var msg = $"[HTTP] <- {resp.StatusCode} {url} body={text}";
if (resp.StatusCode is >= 200 and < 300)
Plugin.Log.LogInfo(msg);
else
@@ -139,31 +128,6 @@ public class SendRequestPatch
return true;
}
// Render the leading bytes of a binary body as text so structured framing (e.g. multipart form
// boundaries and part headers) stays readable, while raw bytes are shown as \xNN escapes. Capped
// at MaxLoggedBodyLength since the interesting framing is at the front.
private static string BinaryPreview(byte[] data)
{
if (data.Length == 0) return "<binary empty>";
var sb = new System.Text.StringBuilder(MaxLoggedBodyLength + 32);
sb.Append("<binary ").Append(data.Length).Append(" bytes> ");
var i = 0;
// Cap on rendered length, not byte count: escapes expand a byte to 4 chars, so this keeps the
// preview near MaxLoggedBodyLength and avoids a second pass by Truncate at the log site.
for (; i < data.Length && sb.Length < MaxLoggedBodyLength; i++)
{
var b = data[i];
if (b == 0x09 || b == 0x0A || b == 0x0D || (b >= 0x20 && b < 0x7F))
sb.Append((char)b);
else
sb.Append("\\x").Append(b.ToString("x2"));
}
if (i < data.Length)
sb.Append($"... <truncated {data.Length - i} bytes>");
return sb.ToString();
}
// Content sniff for raw request bytes — the Content-Type header isn't reliably set at
// SendRequest time (e.g. multipart form bodies set it lazily, and the body still embeds the
// raw image), so look at the bytes: a NUL byte, or a high ratio of non-text control bytes in
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Collections;
using System.Text.Json;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using BepInEx.Unity.IL2CPP.Utils.Collections;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.SceneManagement;
using UnityEngine.Networking;
namespace CannedNet.Client;
[BepInPlugin("lapis.cannednet.client", "CannedNet Client", "1.0.0")]
public class Plugin : BasePlugin
{
internal static new ManualLogSource Log;
public static ConfigEntry<string> AppIdRT { get; private set; }
public static ConfigEntry<string> AppIdVoice { get; private set; }
public static ConfigEntry<string> AppIdChat { get; private set; }
public static ConfigEntry<string> ServerHostname { get; private set; }
public static ConfigEntry<bool> EnableAdvancedSettings { get; private set; }
public static ConfigEntry<string> PhotonHostname { get; private set; }
public static ConfigEntry<int> PhotonPort { get; private set; }
public static ConfigEntry<bool> Debug { get; private set; }
public override void Load()
{
Log = base.Log;
AppIdRT = Config.Bind("Photon", "App Id Realtime", "", "Photon Realtime App ID");
AppIdVoice = Config.Bind("Photon", "App Id Voice", "", "Photon Voice App ID");
AppIdChat = Config.Bind("Photon", "App Id Chat", "", "Photon Chat App ID");
EnableAdvancedSettings = Config.Bind("Advanced", "Enabled Advanced Settings", false, "Allows other fields below in the advanced section to be modified.");
PhotonHostname = Config.Bind("Advanced", "Photon NameServer", "", "Custom Photon NameServer");
PhotonPort = Config.Bind("Advanced", "Photon NameServer Port", 0, "Custom Photon NameServer Port (if 0, it will be default)");
ServerHostname = Config.Bind("Server", "RecNet NameServer Host", "https://ns.lapis.codes", "Host for the RecNet NameServer.");
Debug = Config.Bind("Advanced", "Debug", false, "Show debug logs (HTTP tracing, etc. WARNING: will include sensitive information such as passwords and auth tokens in the logs, be careful when sharing them!)");
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
SceneManager.sceneLoaded += (Action<Scene, LoadSceneMode>)OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
// CheatManager boots us out of rooms when it runs, but it's ALSO the DUID service the DI
// container resolves for account creation / login (destroying it removes that service).
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
// coroutines, so no boot) while the component still exists, so the DI container can still
// resolve PGECJHKNIEN and call its DUID methods. It's recreated per scene, so deactivate
// each freshly-spawned (active) instance on every load. (GameObject.Find only returns active
// objects, so once deactivated it isn't found again.)
var cheatMgr = GameObject.Find("GameRoot/(Startup)(Clone)/Core Systems/[CheatManager]");
if (cheatMgr != null)
{
cheatMgr.SetActive(false);
Log.LogInfo("cheatmanager deactivated");
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 djdevin
Copyright (c) 2026 Lapis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-77
View File
@@ -1,77 +0,0 @@
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Test tool: persist a genuinely corrupt STORED device id on this machine, matching the friend's
// condition (stored id truncated, current id healthy).
//
// We can't hand-craft the stored value: it lives in PlayerPrefs under an obfuscated key, encoded as a
// CodeStage ObscuredString, and both the key and the encode method are renamed per game build. So we
// let the game write it: WriteDUIDs() stores ObscuredString(SystemInfo.deviceUniqueIdentifier) under
// the right key. We temporarily spoof deviceUniqueIdentifier to a truncated value around that one
// call, so the game encrypts+stores a bad id with its own (unknown-to-us) key. Afterwards the spoof
// is off, so the current id reads healthy again -> stored != current -> real mismatch on next launch.
[HarmonyPatch]
public static class CorruptDUIDPatch
{
// Only true for the duration of the WriteDUIDs() call below, so the SystemInfo getter is spoofed
// exactly there and nowhere else.
private static bool _spoofActive;
private static string _spoofValue = "";
[HarmonyPrefix]
[HarmonyPatch(typeof(SystemInfo), "get_deviceUniqueIdentifier")]
private static bool DeviceIdGetterPrefix(ref string __result)
{
if (!_spoofActive)
return true;
__result = _spoofValue;
return false;
}
// Returns true if the corruption was written (so the caller marks it done and won't repeat).
public static bool CorruptStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing written");
return false;
}
var real = SystemInfo.deviceUniqueIdentifier; // spoof off -> real id
var bad = real is { Length: >= 7 } ? real.Substring(0, 7) : "badduid";
_spoofValue = bad;
_spoofActive = true;
try
{
cm.WriteDUIDs(); // encodes+stores ObscuredString(bad) under the real key
}
finally
{
_spoofActive = false;
}
Plugin.Log.LogWarning($"[CORRUPT] wrote truncated stored DUID = \"{bad}\" (real id = \"{real}\"). " +
"Set 'Corrupt Stored DUID' back to false and relaunch to drive the real mismatch path.");
return true;
}
// Undo: overwrite the stored value with the real id by calling WriteDUIDs with the spoof off.
public static bool RestoreStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing restored");
return false;
}
cm.WriteDUIDs(); // spoof off -> stores ObscuredString(real deviceUniqueIdentifier)
Plugin.Log.LogWarning($"[CORRUPT] restored stored DUID to real id = \"{SystemInfo.deviceUniqueIdentifier}\". " +
"Set 'Restore Stored DUID' back to false.");
return true;
}
}
-45
View File
@@ -1,45 +0,0 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
// Controls CheatManager.CheckForDUIDMismatch, which returns true when the machine's stored device id
// differs from the freshly-derived one. A true result sends the client down the migration path that
// POSTs PlayerReporting/v1/deviceId and then stalls on Create Account.
//
// Three modes, chosen by config:
// Simulate = true -> force TRUE (fake a mismatch to reproduce the hang without a corrupt value)
// Suppress = true -> force FALSE (the workaround fix: never migrate, never hang)
// both false -> pass through, let the REAL check run against the actual stored value
// (needed to observe a genuinely corrupt stored id, e.g. after Corrupt Stored DUID)
//
// Patch the concrete CheatManager method, NOT the abstract PGECJHKNIEN interface, or the prefix
// never runs.
[HarmonyPatch]
public static class DUIDMismatchPatch
{
private const string SimulatedStoredDeviceId = "491e8b9";
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "CheckForDUIDMismatch")]
private static bool Prefix(ref string ALOMDLLNIMD, ref bool __result)
{
if (Plugin.SimulateDUIDMismatch.Value)
{
ALOMDLLNIMD = SimulatedStoredDeviceId;
__result = true;
Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}");
return false;
}
if (Plugin.SuppressDUIDMismatch.Value)
{
ALOMDLLNIMD = string.Empty;
__result = false;
Plugin.Log.LogInfo("[DUID] mismatch check forced to false (suppressed)");
return false;
}
// Pass through to the real check.
return true;
}
}
-58
View File
@@ -1,58 +0,0 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Diagnostic only. Two jobs:
// 1. Show where the stored device id lives, by logging PlayerPrefs reads/writes.
// 2. Show how far the DUID migration branch gets, by logging CheatManager's other DUID methods.
// If WriteDUIDs() never fires after the deviceId POST, the flow stalls before it.
[HarmonyPatch]
public static class DUIDProbePatch
{
// PlayerPrefs.GetString is called constantly, so log each key only once — except device/DUID
// keys, which we always log so we can watch them change across the migration.
private static readonly HashSet<string> SeenKeys = new();
private static bool IsInteresting(string key) =>
key != null && (key.Contains("DUID") || key.Contains("Duid") || key.Contains("duid")
|| key.Contains("Device") || key.Contains("device")
|| key.Contains("Anon") || key.Contains("anon"));
private static void Note(string op, string key, string value)
{
if (IsInteresting(key))
Plugin.Log.LogWarning($"[DUID-PROBE] {op} {key} = \"{value}\"");
else if (SeenKeys.Add($"{op}:{key}"))
Plugin.Log.LogInfo($"[DUID-PROBE] {op} {key} = \"{value}\"");
}
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string)])]
private static void GetStringPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string), typeof(string)])]
private static void GetStringDefaultPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.SetString))]
private static void SetStringPrefix(string key, string value) => Note("SET", key, value);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.DeleteKey))]
private static void DeleteKeyPrefix(string key) => Note("DEL", key, "<deleted>");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() called");
[HarmonyPostfix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPostfix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() returned");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "ClearDUIDs")]
private static void ClearDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] ClearDUIDs() called");
}
-61
View File
@@ -1,61 +0,0 @@
using System;
using System.Text;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace RecNetPlugin.Patches;
// Experiment harness for the Create Account hang.
//
// On a device-id mismatch the client POSTs PlayerReporting/v1/deviceId, the server answers
// 200 {"success":true}, and then the client stops: CheatManager.WriteDUIDs() is never called, so the
// new id is never persisted and the flow never reaches create_account. That means the client can't
// proceed on what it got back.
//
// This rewrites that one response body before the game sees it, so response shapes can be tried
// without redeploying the server. WriteDUIDs() appearing in the log (see DUIDProbePatch) is the
// pass signal: it means the client accepted the response and resumed the migration.
[HarmonyPatch]
public static class DeviceIdResponsePatch
{
private const string Endpoint = "/PlayerReporting/v1/deviceId";
[HarmonyPrefix]
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
private static void Prefix(HTTPRequest request)
{
if (!request.Uri.AbsoluteUri.Contains(Endpoint, StringComparison.OrdinalIgnoreCase))
return;
var original = request.Callback;
// Whether the game attached a completion callback at all. If this logs False, the client is
// not waiting on this request through the callback API and the "stuck on the response" model
// is wrong -- that would be worth knowing before chasing response shapes any further.
Plugin.Log.LogWarning($"[DEVICEID] request seen; game callback attached = {original != null}");
var body = Plugin.DeviceIdResponseOverride.Value;
if (string.IsNullOrEmpty(body))
return;
var status = Plugin.DeviceIdResponseStatus.Value;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
{
if (resp != null)
{
// Set both: DataAsText is computed from Data but cached in dataAsText once read,
// and our own HTTP logger may already have read it.
resp.Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body));
resp.dataAsText = body;
resp.StatusCode = status;
Plugin.Log.LogWarning($"[DEVICEID] response overridden -> {status} {body}");
}
original?.Invoke(req, resp);
}));
}
}
-81
View File
@@ -1,81 +0,0 @@
using System;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace RecNetPlugin;
[BepInPlugin("net.rec.plugin", "RecNet Plugin", "1.0.0")]
public class Plugin : BasePlugin
{
internal static new ManualLogSource Log;
public static ConfigEntry<string> AppIdRT { get; private set; }
public static ConfigEntry<string> AppIdVoice { get; private set; }
public static ConfigEntry<string> AppIdChat { get; private set; }
public static ConfigEntry<string> ServerHostname { get; private set; }
public static ConfigEntry<bool> EnableAdvancedSettings { get; private set; }
public static ConfigEntry<string> PhotonHostname { get; private set; }
public static ConfigEntry<int> PhotonPort { get; private set; }
public static ConfigEntry<bool> Debug { get; private set; }
public static ConfigEntry<bool> SimulateDUIDMismatch { get; private set; }
public static ConfigEntry<bool> SuppressDUIDMismatch { get; private set; }
public static ConfigEntry<bool> CorruptStoredDUID { get; private set; }
public static ConfigEntry<bool> RestoreStoredDUID { get; private set; }
public static ConfigEntry<string> DeviceIdResponseOverride { get; private set; }
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
private static bool _corruptDone;
public override void Load()
{
Log = base.Log;
AppIdRT = Config.Bind("Photon", "App Id Realtime", "", "Photon Realtime App ID");
AppIdVoice = Config.Bind("Photon", "App Id Voice", "", "Photon Voice App ID");
AppIdChat = Config.Bind("Photon", "App Id Chat", "", "Photon Chat App ID");
EnableAdvancedSettings = Config.Bind("Advanced", "Enabled Advanced Settings", false, "Allows other fields below in the advanced section to be modified.");
PhotonHostname = Config.Bind("Advanced", "Photon NameServer", "", "Custom Photon NameServer");
PhotonPort = Config.Bind("Advanced", "Photon NameServer Port", 0, "Custom Photon NameServer Port (if 0, it will be default)");
ServerHostname = Config.Bind("Server", "RecNet NameServer Host", "https://ns.rec.net", "Host for the RecNet NameServer.");
Debug = Config.Bind("Advanced", "Debug", false, "Show debug logs (HTTP tracing, etc. WARNING: will include sensitive information such as passwords and auth tokens in the logs, be careful when sharing them!)");
SimulateDUIDMismatch = Config.Bind("Advanced", "Simulate DUID Mismatch", false, "Force CheckForDUIDMismatch to return TRUE (fakes the comparison only). Reproduces the hang path but does not corrupt any stored value. Leave false for normal play.");
SuppressDUIDMismatch = Config.Bind("Advanced", "Suppress DUID Mismatch", true, "Force CheckForDUIDMismatch to return FALSE (the workaround fix, ON by default): the client never migrates and never takes the Create Account hang path. No-op on healthy machines (the real check returns false anyway); on mismatched machines it skips the hang. Set false only to observe the real mismatch behavior for debugging.");
CorruptStoredDUID = Config.Bind("Advanced", "Corrupt Stored DUID", false, "ONE-SHOT TEST: on next launch, write a truncated device id into the DUID pref via the game's own WriteDUIDs, producing a genuinely corrupt STORED value (real current id) — exactly the friend's condition. After it logs '[CORRUPT] wrote', set this back to false and relaunch to drive the real mismatch path. Use 'Restore Stored DUID' to undo.");
RestoreStoredDUID = Config.Bind("Advanced", "Restore Stored DUID", false, "ONE-SHOT UNDO: on next launch, call WriteDUIDs with the real device id, overwriting any corrupt stored value with a good one. Set back to false after it logs '[CORRUPT] restored'.");
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
SceneManager.sceneLoaded += (Action<Scene, LoadSceneMode>)OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
// CheatManager boots us out of rooms when it runs, but it's ALSO the DUID service the DI
// container resolves for account creation / login (destroying it removes that service).
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
// coroutines, so no boot) while the component still exists, so the DI container can still
// resolve PGECJHKNIEN and call its DUID methods. It's recreated per scene, so deactivate
// each freshly-spawned (active) instance on every load. (GameObject.Find only returns active
// objects, so once deactivated it isn't found again.)
var cheatMgr = GameObject.Find("GameRoot/(Startup)(Clone)/Core Systems/[CheatManager]");
if (cheatMgr == null)
return;
// One-shot corruption for testing: must run while the component is still active (before we
// deactivate it below), because it calls the live CheatManager.WriteDUIDs().
if (CorruptStoredDUID.Value && !_corruptDone)
_corruptDone = Patches.CorruptDUIDPatch.CorruptStored(cheatMgr);
else if (RestoreStoredDUID.Value && !_corruptDone)
_corruptDone = Patches.CorruptDUIDPatch.RestoreStored(cheatMgr);
cheatMgr.SetActive(false);
Log.LogInfo("cheatmanager deactivated");
}
}
+12 -61
View File
@@ -1,18 +1,10 @@
# RecNet Plugin
# CannedNet Client
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the Rec Room client at a self-hosted / private server.
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the **Rec Room** client at a self-hosted / private "CannedNet" server instead of the official Rec Room backend.
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk (except global-metadata.dat - needed for image signatures). The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning) that would otherwise reject a non-official server.
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk. The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning) that would otherwise reject a non-official server.
> ⚠️ This disables anti-cheat and certificate validation on the client. Use at your own risk.
## Safety
Using BepInEx plugins may cause anti-virus scanners or Windows Defender to pick it up as a threat.
If you don't trust the complied .DLL, you can build it yourself.
See https://github.com/djdevin/recnet-plugin#from-source
> ⚠️ **For private/experimental servers only.** This redirects traffic away from official Rec Room infrastructure and disables anti-cheat and certificate validation on the client. Do not use it against `rec.net` or any service you don't control. Use at your own risk.
## What it does
@@ -21,36 +13,9 @@ See https://github.com/djdevin/recnet-plugin#from-source
| Name-server redirect | `Patches/SendRequestPatch.cs` | Intercepts `BestHTTP` requests and rewrites the host `ns.rec.net` → your configured server. Also provides optional HTTP request/response logging for development. |
| Photon override | `Patches/PhotonPatches.cs` | Replaces the Realtime / Voice / Chat App IDs (and optionally the Photon name server + port) with your own. |
| EAC bypass | `Patches/EACPatches.cs` | Forces EasyAntiCheat "ready" and stubs the challenge-response so the client connects without the official anti-cheat. |
| TLS bypass | `Patches/DisableTLSPinning.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
| TLS bypass | `Patches/FuckOffTLS.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
| Promise stub | `Patches/PromisePatch.cs` | Allows custom global-metadata.dat files without the game crashing. |
| CheatManager handling | `Plugin.cs` | Deactivates the in-game `CheatManager` (which would otherwise boot you from rooms) while keeping it resolvable for account creation / login. |
| DUID mismatch workaround | `Patches/DUIDMismatchPatch.cs` | Forces the device-id mismatch check to "no mismatch" so the Create Account hang (below) is skipped. **On by default**; no-op on healthy machines. |
## The Create Account / DUID hang
Some machines hang forever on **Create Account**. This turned out to be a genuinely nasty one, so it's
worth documenting.
**What happens:** when the client's *stored* device id (DUID) differs from the one derived at runtime,
the client takes a "migration" path — it POSTs to `PlayerReporting/v1/deviceId`, the server answers
`200 {"success":true}`, and then the client **stalls**: it never makes the `create_account` OAuth call
and never persists the new id. Machines whose stored id already matches never take this path, which is
why the bug hits some players and not others (and is hard to reproduce if your own machine is fine).
**The decision point** is `CheatManager.CheckForDUIDMismatch`. Forcing it to return *true* reproduces
the hang on any machine; forcing it *false* skips the whole path. That false-forcing is the shipping
workaround, exposed as the `Suppress DUID Mismatch` config option, which is **on by default**. It's a
no-op on healthy machines (their real check already returns false) and skips the hang on affected ones.
**Still unsolved:** we have not found where the "old" device id in that POST actually comes from. It
survives deleting the entire `HKCU\Software\Against Gravity\Rec Room` registry key, and on the failing
run the local `cm_did_ppk` PlayerPref is never even read — so clearing local storage does **not** fix
it. The leading theory is that it's held server-side (recorded by the server from earlier reports)
and/or cached in memory from a server response, which would make the proper fix server-side. See
`CLAUDE.md` for the full investigation and the diagnostic tooling.
> ⚠️ `Suppress DUID Mismatch` is a workaround: it lets account creation through but does **not** repair
> a genuinely corrupt stored/served id — it just stops the client from acting on the mismatch.
## Requirements
@@ -58,16 +23,14 @@ and/or cached in memory from a server response, which would make the proper fix
- **.NET 6 SDK** to build the plugin.
- Your own server endpoints: a RecNet name server, and [Photon](https://www.photonengine.com) app keys.
_Looking for a custom RecNet server?_ Try https://github.com/djdevin/recflare
_Looking for a custom RecNet server?_ Try https://github.com/CannedNet/CannedNet.Client
## Installing
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `7859140924515540835`.
Example: `depotdownloader -app 471710 -depot 471711 -manifest 7859140924515540835`
**You must use this specific version.**
3. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
Alternatively, use the [RecFlare client](https://github.com/djdevin/recflare-client)
2. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
### From release
@@ -103,7 +66,7 @@ dotnet build
The build validates that `GamePath` is set and that `$(GamePath)\BepInEx\interop` exists, and fails with a clear message otherwise.
A post-build step (the `DeployPlugin` target in the `.csproj`) automatically copies the built `RecNetPlugin.dll` into your Rec Room install's `BepInEx/plugins/` folder after every build. (The copy will fail if Rec Room is running, since the DLL is locked — close the game and rebuild.) Since `GamePath` already points at your install, you don't need to copy anything by hand — just `dotnet build` and launch the game.
A post-build step (the `DeployPlugin` target in the `.csproj`) automatically copies the built `CannedNet.Client.dll` into your Rec Room install's `BepInEx/plugins/` folder after every build. Since `GamePath` already points at your install, you don't need to copy anything by hand — just `dotnet build` and launch the game.
If you need the DLL elsewhere, it's also left in `bin/Debug/net6.0/`.
@@ -111,10 +74,10 @@ If you need the DLL elsewhere, it's also left in `bin/Debug/net6.0/`.
Start the game for the first time. In `BepInEx` you should now see a `config` folder. If not, verify BepInEx installation and version.
Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
Inside `config`, edit the `lapis.cannednet.client.cfg` file and update as needed:
**[Server]**
- `RecNet NameServer Host` — base URL of your RecNet name server (like `https://ns.rec.net`).
- `RecNet NameServer Host` — base URL of your RecNet name server (default `https://ns.lapis.codes`).
**[Photon]**
- `App Id Realtime` — Photon Realtime App ID.
@@ -127,22 +90,14 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
- `Photon NameServer Port` — custom port (`0` uses the default, `4533`).
- `Debug` — verbose HTTP request/response logging (only needed for development)
> ⚠️ Debug logs include **sensitive data** (passwords, auth tokens). Be careful when sharing them.
- `Suppress DUID Mismatch` — skips the Create Account / DUID hang (see above). **On by default**; the
only DUID option meant for normal use. Set `false` only to observe the real mismatch for debugging.
The remaining `[Advanced]` DUID options — `Simulate DUID Mismatch`, `Corrupt Stored DUID`,
`Restore Stored DUID`, `DeviceId Response Override`, `DeviceId Response Status` — are **diagnostic
tools** used to investigate the hang. Leave them at their defaults unless you're debugging it; see
`CLAUDE.md` for what each one does.
## Project layout
| Path | Purpose |
| --- | --- |
| `Plugin.cs` | Plugin entry point, config bindings, Harmony bootstrap |
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon, DUID) |
| `CLAUDE.md` | Developer notes: build gotchas, IL2CPP/interop caveats, and the full DUID-hang investigation |
| `RecNetPlugin.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
| `Patches/` | Harmony patches (networking, EAC, TLS, Photon) |
| `CannedNet.Client.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
| `GamePath.props.example` | Template for your local `GamePath.props` |
## FAQ
@@ -151,10 +106,6 @@ tools** used to investigate the hang. Leave them at their defaults unless you're
Yes. That's the point.
## Credits
Based on https://github.com/CannedNet/CannedNet.Client
## License
[MIT](LICENSE)