Files
recnet-plugin/CLAUDE.md
T
2026-07-27 11:14:41 -04:00

14 KiB
Raw Blame History

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 ~300 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 6426603215211043630):

  • LEALBOODIEE.GBNKOFMAJPAHPEENKELKDJ.MGKINLFMJLB
  • EACManager.IMMGELPFGCKEACManager.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 ALOMDLLNIMDLICOPEEMHHG (now bound as __0)
  • GenerateChallengeResponse param PGCINMIEBJP__0
  • GPFPFDBGCEK.AMOHMPKKGHLLEALBOODIEE.GBNKOFMAJPA
  • EACManager.FJLMLEPOKGEEACManager.IMMGELPFGCK
  • JAPJPGNBMNM, HPHDJAFFHCN<>, HAAHJPGNIMDgone 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 base64-decodes it and hand-builds RSAParameters, then verifies with mscorlib RSA — plain unobfuscated names. Rather than touch the modulus, we just force the verify to succeed. Confirmed working at runtime: images load with no signing check.

Patches/ImageSigningPatch.cs hooks, all in Il2Cppmscorlib.dll:

Hook Purpose
RSACryptoServiceProvider.VerifyData / VerifyHash ×2 the fix: forces the verify to succeed

Config lives in [Signing], a single knob: Disable Signature Verification defaults true — that's the shipped behaviour, since this setup doesn't use image signing. The patch only removes the check (forces verify-true); it does not swap in a replacement key.

Two things to remember:

  • Blast radius is the point, not a wart. Forcing verify-true affects all mscorlib RSA verification, not just images. That breadth is load-bearing — see the warning below. 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: images failing to load with the knob on means verification moved off mscorlib RSA onto BestHTTP.SecureProtocol.Org.BouncyCastle; the equivalent hooks there are the concrete RsaDigestSigner/PssSigner.VerifySignature (not the abstract ISigner — gotcha 3).

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:

$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-CSharpEACManager 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 ship — the DUID hang is still unsolved, so the tooling stays in the build where affected users can turn it on. Before cutting a release, confirm their knobs still default false (Simulate, Corrupt, Restore, DeviceId Response Override); an accidentally-true default would break normal play. Suppress DUID Mismatch is the real fix and defaults true, so it stays on.