Files
recnet-plugin/CLAUDE.md
T
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

8.9 KiB

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

dotnet build -c Debug -p:GamePath="C:\Games\depots\471711\23191908"
  • GamePath points at the Rec Room install root. It's normally set in the gitignored GamePath.props (see GamePath.props.example); the -p:GamePath=... override is handy for one-offs.
  • The project references ~150 interop DLLs from $(GamePath)\BepInEx\interop. Those are generated by Il2CppInterop the first time the game runs under BepInEx — if they're missing, launch the game once.
  • A post-build DeployPlugin target copies RecNetPlugin.dll into $(GamePath)\BepInEx\plugins\. The copy fails while Rec Room is running (the DLL is locked) — that's an MSB3027 error, not a compile error. Close the game and rebuild. The DLL is also always left in bin/Debug/net6.0/.

Hard-won gotchas (read before patching anything)

  1. Interop assemblies are stubs. The real code is native. The DLLs under BepInEx/interop are Il2CppInterop proxies — method bodies just marshal into GameAssembly.dll. dnSpy / managed decompilation of the interop shows no real logic. You cannot read the actual algorithms statically; you learn behavior by patching + logging at runtime.

  2. Obfuscated names differ per game build. Rec Room's type/method names are obfuscated (PGECJHKNIEN, MDBMGOBECDJ, cm_did_ppk, etc.). A dnSpy dump from some build will not necessarily match the interop you compile against. Real example from the DUID work: a dnSpy dump called the method CheckForMismatch and the pref field DBAIOPIEJNC, but in our interop the method is CheckForDUIDMismatch and neither DBAIOPIEJNC nor MDBMGOBECDJ exist at all. Always resolve members against the interop DLLs you actually build against (see the Cecil snippet below), never against a dump from an unknown build.

  3. Patch the concrete class, not the IL2CPP "interface". Il2CppInterop renders IL2CPP interfaces as abstract classes deriving from Il2CppObjectBase. Harmony will happily patch an abstract method and throw no error, but the prefix never runs because the game dispatches to the concrete implementation. This cost us a whole "shipped fix" that did nothing. Concrete impls live in Assembly-CSharp.dll. Example: patch CheatManager.CheckForDUIDMismatch, not the interface PGECJHKNIEN.CheckForDUIDMismatch. Verify with Cecil that IsAbstract == false before trusting a patch.

  4. Obfuscated members live in the global namespace and are referenced unqualified in this codebase (e.g. typeof(JAPJPGNBMNM), PGECJHKNIEN). No using needed.

  5. Harmony prefix conventions here: force a value + return false to skip the original (see Patches/EACPatches.cs). For out-params, take a ref parameter named exactly as the interop shows it (e.g. ref string ALOMDLLNIMD), plus ref bool __result.

Inspecting the game

Use Mono.Cecil for static metadata/signature checks (accessibility, abstract-ness, exact param names, which assembly a concrete impl lives in). Mark-of-the-web will block loading Mono.Cecil.dll directly — copy it somewhere local, Unblock-File, then load via bytes:

$dst = "$scratch\Mono.Cecil.dll"
Copy-Item "$interop\Mono.Cecil.dll" $dst; Unblock-File $dst
[System.Reflection.Assembly]::Load([System.IO.File]::ReadAllBytes($dst)) | Out-Null
$asm = [Mono.Cecil.AssemblyDefinition]::ReadAssembly("$interop\Assembly-CSharp.dll")
# then walk $asm.MainModule.GetTypes(), inspect .Methods / .Fields / .IsAbstract / .IsStatic ...

Notes:

  • Windows PowerShell 5.1 has no ?. null-conditional operator — use explicit $x -eq $null checks.
  • String literals (pref keys, endpoints, GUIDs) are in RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat. grep -a -o -E '[ -~]{4,}' global-metadata.dat | grep -i <thing> extracts them.
  • The BepInEx runtime log is $(GamePath)/BepInEx/LogOutput.log. Our plugin logs under the RecNet Plugin source. [HTTP], [DUID], [DUID-PROBE], [DEVICEID], [CORRUPT] are our tags.
  • PlayerPrefs on Windows live in the registry at HKCU\Software\Against Gravity\Rec Room, value names are <key>_h<unityHash>, values are REG_BINARY. CodeStage AntiCheat stores strings obscured (XOR-encrypted), so a stored id will not appear as plaintext in registry or files.

Case study: the Create Account / DUID hang

The gnarliest bug so far; the diagnostic tooling for it still lives in the repo. Summary:

  • Symptom: on some machines Create Account hangs. Log shows a PlayerReporting/v1/deviceId POST returning 200 {"success":true}, after which the client never calls the create_account OAuth and never persists the id (WriteDUIDs never runs).
  • Trigger: a device-id mismatch. CheatManager.CheckForDUIDMismatch(out string) returns true when the stored id differs from SystemInfo.deviceUniqueIdentifier. True → migration path → POST → hang. Machines whose stored id matches never take the path.
  • Stored id: PlayerPrefs key cm_did_ppk (registry cm_did_ppk_h3478365449), CodeStage ObscuredString-encoded. CheatManager.WriteDUIDs() writes it, ClearDUIDs() deletes it. In our interop these are instance methods (a dnSpy dump showed them static — build difference again).
  • Two surprises:
    1. Deleting the registry value did not change the oldDeviceId in the POST, and the probe showed no cm_did_ppk read that session — i.e. the "old" id is not sourced from local PlayerPrefs on the failing path. Consequence: a registry-reset script does not fix it.
    2. game callback attached = True on that request → the client is genuinely waiting on the response. But the real server already returns {"success":true} at 200 and it still hangs, so the accepting response shape (if one exists) is something more specific.

⚠️ OPEN QUESTION — where does the old DUID actually live?

We have not found the source of the oldDeviceId value. It survives a full delete of the HKCU\Software\Against Gravity\Rec Room registry key, it does not appear as plaintext anywhere in AppData/LocalLow/Against Gravity/Rec Room, and on the failing run the cm_did_ppk PlayerPref is never read. So cm_did_ppk is a copy but not the one that seeds the migration POST. Leading (but unconfirmed) theory: it's held server-side by the archival server, which recorded it from prior POSTs, and/or cached in memory from a server response. Until this is found, the only reliable fix is Suppress (client) or correcting the value server-side — not clearing local storage. Next steps to try: inspect what the recflare backend stores/returns for the account's device id; dump the HTTPCache entries decoded (not plaintext); trace who sets the field the POST body reads from.

  • Working client-side workaround: force CheckForDUIDMismatch → false (skips the migration path entirely). Config: Suppress DUID Mismatch = true.
  • Proper root-cause fix: server-side — make the endpoint stop reporting a stale old id (so old == new, no mismatch) or return whatever the client needs to proceed.

Diagnostic knobs (all in [Advanced])

Suppress DUID Mismatch defaults true (it's the shipped fix). Everything else defaults false — those are investigation tools, not normal config. See the patch files for details.

Config key Patch file What it does
Suppress DUID Mismatch DUIDMismatchPatch.cs The fix (default true). Force CheckForDUIDMismatch → false.
Simulate DUID Mismatch DUIDMismatchPatch.cs Force it → true. Reproduce the hang without a corrupt value.
Corrupt Stored DUID CorruptDUIDPatch.cs One-shot: write a truncated id via WriteDUIDs (spoofing SystemInfo.deviceUniqueIdentifier) to create a genuinely corrupt stored value.
Restore Stored DUID CorruptDUIDPatch.cs One-shot undo: WriteDUIDs with the real id.
DeviceId Response Override / Status DeviceIdResponsePatch.cs Rewrite the deviceId response body/status in-flight to probe what shape the client will accept.
(probe) DUIDProbePatch.cs Logs every PlayerPrefs get/set and the WriteDUIDs/ClearDUIDs calls — WriteDUIDs() called is the "client accepted the response" signal.

DUIDMismatchPatch has three modes: Simulate → force true, Suppress → force false, neither → pass through to the real check (needed to observe a genuinely corrupt stored value).

The diagnostic patches should not ship in a release build — strip them (or at least confirm their knobs default false: Simulate, Corrupt, Restore, DeviceId Response Override) before cutting a release. Suppress DUID Mismatch is the real fix and defaults true, so it stays on.