# 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 ~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.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 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). ## 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`/`HTTPResponse` are 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: `success` for `/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"), set `State = Finished`, and invoke `request.Callback` inline. The transport resolves its promise, the client considers the batch delivered and clears `pending_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 invoke `onComplete(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 at `http://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: ```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 ` 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 `_h`, 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.