mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-09 15:11:32 -07:00
211 lines
14 KiB
Markdown
211 lines
14 KiB
Markdown
# 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).
|
||
|
||
## 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.
|