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
21 changed files with 160 additions and 893 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
-213
View File
@@ -1,213 +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(LEALBOODIEE)`, `PGECJHKNIEN`). No `using` needed.
5. **Harmony prefix conventions here:** force a value + `return false` to skip the original (see
`Patches/EACPatches.cs`). **Bind parameters positionally (`__0`, `__1`, …), never by their
obfuscated name** — obfuscated *parameter* names change per build just like type names, and binding
by name fails at load with `Parameter "XXX" not found in method ...`. For out-params take
`ref string __0` plus `ref bool __result`.
6. **Method names in `[HarmonyPatch]` are strings — the compiler does not check them.** A renamed
*type* is a build error; a renamed *method* builds fine and only shows up as a HarmonyX error in
`LogOutput.log` at load, or (worse) as a patch that silently never runs. After a game upgrade,
re-verify every string method name with Cecil, don't just trust a green build.
### Surviving a game-version upgrade
Obfuscated names are re-rolled every build. Unobfuscated names (`CheckForDUIDMismatch`, `WriteDUIDs`,
`ClearDUIDs`, `SendRequest`, `NotifyServerCertificate`, `GenerateChallengeResponse`) have been stable
across upgrades so far; everything else must be re-resolved. Don't guess from the old name — **search
the new interop by signature**, which is what actually identifies the target:
| Patch | Target | Signature that identifies it |
| --- | --- | --- |
| `PhotonPatches` | `HPEENKELKDJ.MGKINLFMJLB` | only instance, 0-param method returning `Photon.Realtime.AppSettings` in `Assembly-CSharp` (the static/2-param `PUNNetworkManager` sibling also returns it — exclude it) |
| `EACPatches` (is-ready) | `EACManager.MCFIOBHCFBB` | only static, 0-param `bool` on `EACManager` that isn't a property getter; type lives in `RecRoom.Rranticheat.Runtime.dll` |
> **Il2CppInterop regenerates on launch and obfuscation can differ between generations.** Only scan the
> interop the game *actually loaded* — check that its mtime is *after* the last game launch, and treat a
> clean HarmonyX load (no "Could not find method") as the real proof. A stale/mismatched interop
> generation once produced a whole different name set (`IHODDIDPEOD.JEGOHKJDPFH`, `EACManager.KOIFGPGJGKB`)
> that got overwritten on the next launch back to the names below — patching against it failed at load.
Renames observed in the **20230414 build** (`C:\Games\recflare-client`, Steam manifest
`3668280474894052876`):
- `LEALBOODIEE.GBNKOFMAJPA``HPEENKELKDJ.MGKINLFMJLB`
- `EACManager.IMMGELPFGCK``EACManager.MCFIOBHCFBB`
- `CheckForDUIDMismatch` out-param → `BPOGCIINKBB` (still bound as `__0`, no source change)
Renames observed in the **07-21 build** (`C:\Games\recflare-client`), for reference:
- `CheckForDUIDMismatch` out-param `ALOMDLLNIMD``LICOPEEMHHG` (now bound as `__0`)
- `GenerateChallengeResponse` param `PGCINMIEBJP``__0`
- `GPFPFDBGCEK.AMOHMPKKGHL``LEALBOODIEE.GBNKOFMAJPA`
- `EACManager.FJLMLEPOKGE``EACManager.IMMGELPFGCK`
- `JAPJPGNBMNM`, `HPHDJAFFHCN<>`, `HAAHJPGNIMD`**gone** from `Assembly-CSharp`. These were the
image-signing `PromisePatch`, now deleted and replaced by `ImageSigningPatch` (see below), which
hooks framework types instead and so has no obfuscated names left to break.
## Image signature verification
The client verifies images against an RSA public key whose modulus is a string literal in
`global-metadata.dat`. Patching that literal is fragile; **don't**. Hook the framework instead.
The decisive observation: the literal base64-decodes to **exactly 256 bytes and does not start with
`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client therefore cannot hand it
to a key-import API — it has to base64-decode it and hand-build `RSAParameters { Modulus, Exponent }`.
Those are plain `mscorlib` calls with unobfuscated names. Confirmed working at runtime: images load
with no signing check.
`Patches/ImageSigningPatch.cs` hooks, all in `Il2Cppmscorlib.dll`:
| Hook | Purpose |
| --- | --- |
| `Convert.FromBase64String` | catches the literal regardless of which crypto stack consumes it (guarded by a length check first — it runs for every base64 decode in the game) |
| `RSACryptoServiceProvider.ImportParameters` | swaps the modulus in place (both keys are 2048-bit, so no realloc) |
| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | forces the verify to succeed |
Config lives in `[Signing]`. `Disable Signature Verification` defaults **true** — that's the shipped
behaviour, since this setup doesn't use image signing. `Signing Modulus Override` is the secondary
path for a deployment that *does* want signed images (keeps real verification, against your keypair).
Two things to remember:
- **Blast radius:** forcing verify-true affects *all* mscorlib RSA verification, not just images.
BestHTTP's TLS uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred
from assembly layout, not proven. Keep it behind the config knob.
- **If it ever stops working:** the patch logs `[SIG] stock modulus seen at <hook>` on first sighting.
No such line = verification moved to `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent
hooks there are `RsaKeyParameters..ctor(bool, BigInteger, BigInteger)` and the **concrete**
`RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
- The stock modulus is hardcoded in the patch. If a future build re-rolls the key, the match silently
stops firing; that log line is how you'd notice.
## 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
$interop = "$env:GamePath\BepInEx\interop"
$dst = "$scratch\Mono.Cecil.dll"
# NOTE: Mono.Cecil.dll ships in BepInEx/core, NOT in BepInEx/interop.
Copy-Item "$env:GamePath\BepInEx\core\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 ...
```
Not every target is in `Assembly-CSharp``EACManager` is in `RecRoom.Rranticheat.Runtime.dll`,
`HTTPManager`/`LegacyTlsAuthentication` in `RecNet.Runtime.dll`. When a lookup comes up empty, sweep
all ~305 DLLs in `interop/` (`ReadAssembly` each, `.Dispose()` after) before concluding it's gone.
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.
+29
View File
@@ -0,0 +1,29 @@
using HarmonyLib;
using RecRoom.AntiCheat;
using System.Text;
using Il2CppSystem;
namespace CannedNet.Client.Patches;
[HarmonyPatch]
public static class EACPatches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "FJLMLEPOKGE")]
private static bool IsReadyPatch(ref bool __result)
{
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
private static bool GenerateChallengeResponsePatch(string PGCINMIEBJP, ref string __result)
{
if (!string.IsNullOrEmpty(PGCINMIEBJP))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(PGCINMIEBJP));
else
__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,21 +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.
*/
// Obfuscated names shift every game build. Re-resolve by signature: the target is the only
// instance, 0-param method returning Photon.Realtime.AppSettings in Assembly-CSharp.
// 20230414 build: HPEENKELKDJ.MGKINLFMJLB (was LEALBOODIEE.GBNKOFMAJPA, was GPFPFDBGCEK.AMOHMPKKGHL).
[HarmonyPatch(typeof(HPEENKELKDJ), "MGKINLFMJLB")]
[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;
+13
View File
@@ -0,0 +1,13 @@
using HarmonyLib;
[HarmonyPatch(typeof(JAPJPGNBMNM), "JOKECJKBJGD")]
public static class PromisePatch
{
public static bool Prefix(out HPHDJAFFHCN<JAPJPGNBMNM.AOFCCEACNNA> __result)
{
var result = JAPJPGNBMNM.AOFCCEACNNA.JGIHNLEFJEL();
var promise = HAAHJPGNIMD.NMOOLKAJDOC(result);
__result = promise;
return false;
}
}
@@ -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;
}
}
-47
View File
@@ -1,47 +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")]
// __0 = the out-param (positional). Its obfuscated name changes every game build, so binding it
// by name throws "Parameter ... not found" on upgrade.
private static bool Prefix(ref string __0, ref bool __result)
{
if (Plugin.SimulateDUIDMismatch.Value)
{
__0 = SimulatedStoredDeviceId;
__result = true;
Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}");
return false;
}
if (Plugin.SuppressDUIDMismatch.Value)
{
__0 = 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);
}));
}
}
-33
View File
@@ -1,33 +0,0 @@
using HarmonyLib;
using RecRoom.AntiCheat;
using System.Text;
using Il2CppSystem;
namespace RecNetPlugin.Patches;
[HarmonyPatch]
public static class EACPatches
{
[HarmonyPrefix]
// The "is ready" check: the only static, 0-param bool method on EACManager that isn't a property
// getter. 20230414 build: MCFIOBHCFBB (was IMMGELPFGCK, was FJLMLEPOKGE). Method names here are
// strings, so a rename is not a compile error — it shows up as a HarmonyX "method not found" at load.
[HarmonyPatch(typeof(EACManager), "MCFIOBHCFBB")]
private static bool IsReadyPatch(ref bool __result)
{
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
// __0 = the challenge string (positional); obfuscated param names shift between game builds.
private static bool GenerateChallengeResponsePatch(string __0, ref string __result)
{
if (!string.IsNullOrEmpty(__0))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(__0));
else
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
return false;
}
}
-187
View File
@@ -1,187 +0,0 @@
using System;
using HarmonyLib;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using Il2CppSystem.Security.Cryptography;
using Convert = Il2CppSystem.Convert;
namespace RecNetPlugin.Patches;
// Image signing: the client verifies images against an RSA public key whose modulus is a string
// literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework
// level instead, between the literal and the verify.
//
// The stored value is 256 bytes once base64-decoded and does NOT start with 0x30, so it is a RAW
// 2048-bit modulus, not a DER/SPKI blob. The client therefore has to base64-decode it and hand-build
// an RSAParameters { Modulus, Exponent }. Both of those steps are plain framework calls with
// unobfuscated names, which is why hooking here survives game rebuilds (unlike the obfuscated
// PromisePatch this replaces).
//
// Two knobs, see [Signing] in the .cfg:
// Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so
// the modulus never has to match and unsigned images load. This
// self-hosted setup does not use image signing.
// Signing Modulus Override -> secondary: swap in your own modulus and keep real verification,
// for a deployment that DOES want signed images. Ignored (well,
// redundant) while the verify is disabled.
//
// Both are belt-and-braces: we swap at Convert.FromBase64String (catches the value regardless of
// which crypto stack consumes it) AND at ImportParameters (catches it if the client uses some other
// base64 decoder). Whichever fires first wins; the second sees the already-swapped value and no-ops.
//
// If the log never shows "[SIG] stock modulus seen", the client is not using mscorlib RSA at all —
// the fallback is BestHTTP.SecureProtocol.Org.BouncyCastle, where the equivalent hooks are
// RsaKeyParameters..ctor(bool, BigInteger, BigInteger) and the concrete RsaDigestSigner/PssSigner
// .VerifySignature (NOT the ISigner "interface", which is abstract and never dispatches).
[HarmonyPatch]
public static class ImageSigningPatch
{
// The stock 2048-bit public modulus baked into global-metadata.dat (base64, 344 chars).
private const string StockModulusBase64 =
"X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIP" +
"MaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJ" +
"AijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo" +
"9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ==";
private const int ModulusBytes = 256;
private static byte[] _stockModulus;
private static byte[] _override;
private static bool _overrideResolved;
private static bool _loggedSeen;
private static bool _loggedForced;
private static byte[] Stock => _stockModulus ??= System.Convert.FromBase64String(StockModulusBase64);
// Decoded lazily and cached, so a malformed config value is reported once instead of per call.
private static byte[] Override
{
get
{
if (_overrideResolved)
return _override;
_overrideResolved = true;
var raw = Plugin.SigningModulusOverride.Value;
if (string.IsNullOrWhiteSpace(raw))
return _override = null;
try
{
var bytes = System.Convert.FromBase64String(raw.Trim());
if (bytes.Length != ModulusBytes)
{
Plugin.Log.LogError(
$"[SIG] 'Signing Modulus Override' decoded to {bytes.Length} bytes, expected {ModulusBytes} " +
"(a raw 2048-bit modulus). Ignoring it. Note this must be the bare modulus, NOT a PEM/DER key.");
return _override = null;
}
Plugin.Log.LogWarning("[SIG] signing modulus override active");
return _override = bytes;
}
catch (FormatException)
{
Plugin.Log.LogError("[SIG] 'Signing Modulus Override' is not valid base64. Ignoring it.");
return _override = null;
}
}
}
private static bool IsStock(Il2CppStructArray<byte> value)
{
if (value == null || value.Length != ModulusBytes)
return false;
var stock = Stock;
for (var i = 0; i < ModulusBytes; i++)
if (value[i] != stock[i])
return false;
return true;
}
private static void NoteSeen(string where)
{
if (_loggedSeen)
return;
_loggedSeen = true;
Plugin.Log.LogWarning($"[SIG] stock modulus seen at {where}");
}
// Choke point 1: the base64 decode of the literal. Guarded by a length check first, because this
// runs for every base64 decode in the game.
[HarmonyPrefix]
[HarmonyPatch(typeof(Convert), nameof(Convert.FromBase64String))]
private static bool FromBase64StringPrefix(string __0, ref Il2CppStructArray<byte> __result)
{
if (__0 == null || __0.Length != StockModulusBase64.Length || __0 != StockModulusBase64)
return true;
NoteSeen("Convert.FromBase64String");
var replacement = Override;
if (replacement == null)
return true;
var arr = new Il2CppStructArray<byte>(ModulusBytes);
for (var i = 0; i < ModulusBytes; i++)
arr[i] = replacement[i];
__result = arr;
return false;
}
// Choke point 2: the key import. Mutates the modulus in place — both are 2048-bit, so the array
// is already the right size and no reallocation is needed.
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.ImportParameters))]
private static void ImportParametersPrefix(RSAParameters __0)
{
var modulus = __0?.Modulus;
if (!IsStock(modulus))
return;
NoteSeen("RSACryptoServiceProvider.ImportParameters");
var replacement = Override;
if (replacement == null)
return;
for (var i = 0; i < ModulusBytes; i++)
modulus[i] = replacement[i];
}
// Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS
// uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch
// certificate validation — but it is a blunt instrument, so it stays behind a config knob rather
// than being unconditional.
private static bool ForceVerifyTrue(ref bool __result)
{
if (!Plugin.DisableSignatureVerification.Value)
return true;
if (!_loggedForced)
{
_loggedForced = true;
Plugin.Log.LogWarning("[SIG] signature verification disabled — RSA verify forced to true");
}
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyData))]
private static bool VerifyDataPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
[typeof(Il2CppStructArray<byte>), typeof(int), typeof(Il2CppStructArray<byte>)])]
private static bool VerifyHashPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
[typeof(Il2CppStructArray<byte>), typeof(Il2CppStructArray<byte>), typeof(HashAlgorithmName),
typeof(RSASignaturePadding)])]
private static bool VerifyHashPaddingPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
}
-86
View File
@@ -1,86 +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; }
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
public static ConfigEntry<string> SigningModulusOverride { 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.");
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. Set false only if you actually want signed images, in which case use 'Signing Modulus Override' instead. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures.");
SigningModulusOverride = Config.Bind("Signing", "Signing Modulus Override", "", "Optional alternative to disabling verification: your own RSA public modulus, base64, RAW 2048-bit (256 bytes decoded) — NOT a PEM/DER key. When set, it is substituted for the modulus baked into global-metadata.dat and real verification still runs, so images stay signed with your keypair. Redundant while 'Disable Signature Verification' is true. Empty = leave the stock modulus alone.");
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 -69
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,46 +74,30 @@ 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.
- `App Id Voice` — Photon Voice App ID.
- `App Id Chat` — Photon Chat App ID.
**[Signing]**
- `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's
private key, so your own server can serve images. **On by default**; leave it alone unless you
specifically want signed images.
- `Signing Modulus Override` — only for setups that *do* want image signing: your own RSA public
modulus (base64, raw 2048-bit — not a PEM/DER key). Keeps real verification, against your keypair.
Leave empty otherwise.
**[Advanced]**
- `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.
- `Photon NameServer` — custom Photon name server host.
- `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
@@ -159,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)