Files
recnet-plugin/CLAUDE.md
T
2026-08-05 12:44:31 -04:00

310 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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.