21 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"
GamePathpoints at the Rec Room install root. It's normally set in the gitignoredGamePath.props(seeGamePath.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
DeployPlugintarget copiesRecNetPlugin.dllinto$(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 inbin/Debug/net6.0/.
Hard-won gotchas (read before patching anything)
-
Interop assemblies are stubs. The real code is native. The DLLs under
BepInEx/interopare Il2CppInterop proxies — method bodies just marshal intoGameAssembly.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. -
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 methodCheckForMismatchand the pref fieldDBAIOPIEJNC, but in our interop the method isCheckForDUIDMismatchand neitherDBAIOPIEJNCnorMDBMGOBECDJexist 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. -
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 inAssembly-CSharp.dll. Example: patchCheatManager.CheckForDUIDMismatch, not the interfacePGECJHKNIEN.CheckForDUIDMismatch. Verify with Cecil thatIsAbstract == falsebefore trusting a patch. -
Obfuscated members live in the global namespace and are referenced unqualified in this codebase (e.g.
typeof(LEALBOODIEE),PGECJHKNIEN). Nousingneeded. -
Harmony prefix conventions here: force a value +
return falseto skip the original (seePatches/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 withParameter "XXX" not found in method .... For out-params takeref string __0plusref bool __result. -
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 inLogOutput.logat 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.GBNKOFMAJPA→HPEENKELKDJ.MGKINLFMJLBEACManager.IMMGELPFGCK→EACManager.MCFIOBHCFBBCheckForDUIDMismatchout-param →BPOGCIINKBB(still bound as__0, no source change)
Renames observed in the 07-21 build (C:\Games\recflare-client), for reference:
CheckForDUIDMismatchout-paramALOMDLLNIMD→LICOPEEMHHG(now bound as__0)GenerateChallengeResponseparamPGCINMIEBJP→__0GPFPFDBGCEK.AMOHMPKKGHL→LEALBOODIEE.GBNKOFMAJPAEACManager.FJLMLEPOKGE→EACManager.IMMGELPFGCKJAPJPGNBMNM,HPHDJAFFHCN<>,HAAHJPGNIMD— gone fromAssembly-CSharp. These were the image-signingPromisePatch, now deleted and replaced byImageSigningPatch(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 concreteRsaDigestSigner/PssSigner.VerifySignature(not the abstractISigner— gotcha 3).
Analytics / telemetry
One knob, [Analytics] Disable Telemetry, default true, gating all three patch files below.
One switch is the deliberate choice: nobody wants Amplitude gone but the collector alive, and per-vendor
knobs are just more ways to end up half-configured.
But they are not one mechanism. Each vendor sends over a different stack, and that, not the hostname, is what decides how you block it — a host blocklist would be dead code for two of these four:
| Vendor | Patch file | Stack it sends over | How it's blocked |
|---|---|---|---|
| Amplitude | AmplitudePatch.cs |
BestHTTP | Log* prefixes + host block on amplitude.com |
| Data collector | AmplitudePatch.cs |
BestHTTP | host block on first label datacollection* |
| Backtrace | BacktracePatch.cs |
UnityWebRequest | BacktraceHttpClient.Post ×4 |
| Unity Analytics | UnityTelemetryPatch.cs |
native (neither) | Unity's own opt-out properties — doesn't work, see below |
The collector is matched on the hostname's first label, not a fixed domain, so it stays right across
deployments (…recflare.net, …rec.net). It's a first-party endpoint, hence its own knob — you may
want Amplitude gone but the collector alive, or the reverse.
The collector is matched on the hostname's first label, not a fixed domain, so it stays right across
deployments (…recflare.net, …rec.net). It's a first-party endpoint, hence its own knob — you may
want Amplitude gone but the collector alive, or the reverse.
Blocking the Log* entrypoints is not sufficient, and this is the trap. LogEventAsync,
LogSerializedEventAsync, LogIdentifyAsync etc. only queue; the queue is persisted to the
pending_room_stats PlayerPref and drained later by the client's own flush coroutines. So a batch
queued before the plugin existed (or during a session where a Log* door we didn't cover was used)
still ships on the next launch, and mitmproxy keeps showing api2.amplitude.com even though
LogOutput.log says [AMPLITUDE] ... blocked LogEventAsync. Symptom-to-cause: prefixes visibly
firing + traffic still leaving means you blocked the producer, not the sender.
The send path, for the record: AmplitudeAnalyticsClient → transport interface FOMPBHDLPDO
(MAJANJBIDMF / KBECOPLKGHL) → concrete impl MHBPNDOGOLG in RecNet.Runtime.dll → BestHTTP.
Note RecRoom.Analytics.Runtime.dll has no assembly reference to BestHTTP/UnityWebRequest — the
transport is injected, so grepping the analytics assembly for an HTTP type finds nothing.
So the actual block is at the BestHTTP layer: a second prefix on
HTTPManager.SendRequest(HTTPRequest) (the same method SendRequestPatch hooks) that drops any
request to a blocked host. Two properties worth keeping:
- No obfuscated names.
HTTPManager/HTTPRequest/HTTPResponseare BestHTTP's own types, so this survives a game upgrade even though every name in the analytics path above will re-roll. - It fakes a 200, not a failure. We build an
HTTPResponse(status 200, Amplitude's real body shapes:successfor/identify, the{"code":200,...}envelope otherwise;{"success":true}for the collector, whose shape we don't know — an empty body would trip the RecNet wrapper's "Response was empty"), setState = Finished, and invokerequest.Callbackinline. The transport resolves its promise, the client considers the batch delivered and clearspending_room_stats. Failing the request instead would leave the batch queued and retried every session forever.
RudderStack (get_OutOfSessionRudderStackKey) and gamesight are not covered — add their hosts to
AmplitudeDomains / CollectorLabelPrefixes if they need to go too.
Backtrace (submit.backtrace.io) — UnityWebRequest, not BestHTTP
Backtrace.Unity.dll references UnityEngine.UnityWebRequestModule and nothing else HTTP-shaped, so
the BestHTTP host block never sees it. Two things make this one easy: the SDK is a third-party package
and therefore unobfuscated (no per-build churn to survive), and every submission it makes — crash
reports, minidumps, metrics — funnels through the four concrete BacktraceHttpClient.Post
overloads (IBacktraceHttpClient is the interface — gotcha 3).
The overloads split by who sends the request, which decides the patch shape:
void Post(url, jObject, onComplete)— the SDK sends internally. Prefix + skip, then invokeonComplete(200, false, "{}")ourselves. Answering it matters: the metrics queue holds the batch until the callback reports success, so a silent skip means it retries forever.UnityWebRequest Post(…)×3 — builds the request and hands it back; the caller sends it (yield return request.SendWebRequest()). A prefix returning null gets dereferenced, so instead a postfix repoints the finished request athttp://127.0.0.1:1/— nothing listens, so it fails connection-refused in microseconds with no packet leaving the box, and the SDK takes its ordinary offline path. Repointing beats rebuilding: the SDK keeps its own handlers and headers, so there's nothing to guess about what the caller dereferences next.
Not covered: RecRoomNativeClient installs a native crash handler, and a minidump it uploads on the
launch after a hard crash never passes through managed code. A multipart minidump POST to
submit.backtrace.io with the hooks visibly firing is that path.
Unity Analytics / Performance Reporting (perf-events.cloud.unity3d.com) — ⚠️ UNSOLVED, and fine
Confirmed not working on the 20230414 build; accepted as-is — don't re-litigate it. The setters are
refused, enabled reads back True on every attempt, and the uploads keep flowing. The other three
[Analytics] knobs all confirmed dropping at runtime, and they account for the bulk of the traffic, so
this one is noise-floor. If it ever has to go for real it needs a hosts-file/DNS block or a native
hook — there is no managed lever. What follows is why, kept because the approach is right even
though this build refuses it.
UnityEngine.Analytics.Analytics and PerformanceReporting are thin managed shims over native engine
code; the uploads happen inside the player, on no managed send path and over neither HTTP stack. What
they do have is a documented opt-out, so UnityTelemetryPatch just sets it: PerformanceReporting. enabled = false, Analytics.enabled = false, deviceStatsEnabled = false, limitUserTracking = true.
Two traps, both handled there: these are native setters that can be silently refused, so the patch
reads the properties back and only believes it worked if they read false — the [UNITY-TELEMETRY]
log line is the proof, the assignment isn't. (That read-back is what caught this build refusing them;
without it the knob would have looked like it worked.) And since "not initialised yet" was the leading
theory for the refusal, it retries from OnSceneLoaded, capped at 5 attempts — which ruled that theory
out, because all five read back True.
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-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 $nullchecks. - 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 theRecNet Pluginsource.[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 areREG_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/deviceIdPOST returning200 {"success":true}, after which the client never calls thecreate_accountOAuth and never persists the id (WriteDUIDsnever runs). - Trigger: a device-id mismatch.
CheatManager.CheckForDUIDMismatch(out string)returns true when the stored id differs fromSystemInfo.deviceUniqueIdentifier. True → migration path → POST → hang. Machines whose stored id matches never take the path. - Stored id: PlayerPrefs key
cm_did_ppk(registrycm_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:
- Deleting the registry value did not change the
oldDeviceIdin the POST, and the probe showed nocm_did_ppkread 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. game callback attached = Trueon 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.
- Deleting the registry value did not change the
⚠️ OPEN QUESTION — where does the old DUID actually live?
We have not found the source of the
oldDeviceIdvalue. It survives a full delete of theHKCU\Software\Against Gravity\Rec Roomregistry key, it does not appear as plaintext anywhere inAppData/LocalLow/Against Gravity/Rec Room, and on the failing run thecm_did_ppkPlayerPref is never read. Socm_did_ppkis 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 isSuppress(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 theHTTPCacheentries 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
oldid (soold == 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.