4 Commits

Author SHA1 Message Date
devin 8ddda007c1 Support for build 20230414 (#5)
* support for 20230331, remove need for metadata patching

* build 20230414
2026-07-23 01:29:06 -04:00
devin 6a62f0cc1d Suppress DUID mismatch check resulting in create account hang (#3)
* test duuid mismatch failures

* add variables to trigger or suppress DUID mismatch

* update docs

* turn on by default
2026-07-14 13:36:52 -04:00
devin a6e4555a00 add link 2026-07-10 17:14:44 -04:00
Devin Zuczek 326188c7a0 Initial RecNetPlugin 2026-07-08 17:11:58 -04:00
12 changed files with 736 additions and 34 deletions
+213
View File
@@ -0,0 +1,213 @@
# 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.
+77
View File
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,61 @@
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);
}));
}
}
+8 -4
View File
@@ -9,7 +9,10 @@ namespace RecNetPlugin.Patches;
public static class EACPatches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "FJLMLEPOKGE")]
// 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;
@@ -18,10 +21,11 @@ public static class EACPatches
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
private static bool GenerateChallengeResponsePatch(string PGCINMIEBJP, ref string __result)
// __0 = the challenge string (positional); obfuscated param names shift between game builds.
private static bool GenerateChallengeResponsePatch(string __0, ref string __result)
{
if (!string.IsNullOrEmpty(PGCINMIEBJP))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(PGCINMIEBJP));
if (!string.IsNullOrEmpty(__0))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(__0));
else
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
return false;
+187
View File
@@ -0,0 +1,187 @@
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);
}
+4 -1
View File
@@ -7,7 +7,10 @@ namespace RecNetPlugin.Patches;
/**
Patches Photon to use the App IDs and server hostname/port specified in the plugin config.
*/
[HarmonyPatch(typeof(GPFPFDBGCEK), "AMOHMPKKGHL")]
// 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")]
public class PhotonPatches
{
[HarmonyPostfix]
-19
View File
@@ -1,19 +0,0 @@
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
{
public static bool Prefix(out HPHDJAFFHCN<JAPJPGNBMNM.AOFCCEACNNA> __result)
{
var result = JAPJPGNBMNM.AOFCCEACNNA.JGIHNLEFJEL();
var promise = HAAHJPGNIMD.NMOOLKAJDOC(result);
__result = promise;
return false;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ public class SendRequestPatch
}
// Cap logged bodies so a large response/request doesn't flood the log.
private const int MaxLoggedBodyLength = 1000;
private const int MaxLoggedBodyLength = 10000;
private static string Truncate(string s)
{
+31 -5
View File
@@ -22,6 +22,16 @@ public class Plugin : BasePlugin
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()
{
@@ -35,6 +45,15 @@ public class Plugin : BasePlugin
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);
@@ -51,10 +70,17 @@ public class Plugin : BasePlugin
// 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");
}
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");
}
}
+49 -4
View File
@@ -21,9 +21,36 @@ 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/FuckOffTLS.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
| TLS bypass | `Patches/DisableTLSPinning.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
@@ -38,7 +65,9 @@ _Looking for a custom RecNet server?_ Try https://github.com/djdevin/recflare
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.**
2. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
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)
### From release
@@ -74,7 +103,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 `RecNetPatcher.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.
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.
If you need the DLL elsewhere, it's also left in `bin/Debug/net6.0/`.
@@ -92,19 +121,35 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
- `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) |
| `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 |
| `GamePath.props.example` | Template for your local `GamePath.props` |