mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-08 14:41:30 -07:00
unstable patch for 202312+
This commit is contained in:
@@ -1,309 +1,189 @@
|
||||
# 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.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
This is the **native (C/Win32) redirector** that points the Rec Room client at a self-hosted server
|
||||
**without any managed mod loader**. It replaces the sibling `../recnet-patcher` project (a BepInEx/
|
||||
MelonLoader Harmony plugin) — both loaders fail on current Rec Room builds (BepInEx crashes in
|
||||
`il2cpp_init`; the loader trips the anti-cheat memory-integrity scan). This build is loaded as a
|
||||
`version.dll` proxy and hooks Winsock + il2cpp methods directly in native code. Read `README.md` 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"
|
||||
Rec Room / `GameAssembly.dll` is **64-bit — you must build x64**. A 32-bit DLL silently fails to load.
|
||||
The default VS dev shell is x86, and the PowerShell tool **does not persist env vars between calls**,
|
||||
so the amd64 env import and the cmake/build must run in the **same** call, else you get an x86 DLL:
|
||||
|
||||
```powershell
|
||||
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
cmd /c "`"$vcvars`" amd64 >nul 2>&1 && set" | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { Set-Item -Path "Env:$($matches[1])" -Value $matches[2] } }
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # add -DGAME_DIR="C:\Games\recflare-client-unstable" to deploy
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
- `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/`.
|
||||
- **Verify arch** from the PE header after building: machine word at `(int32 @0x3C)+4` must be **0x8664**,
|
||||
not 0x014C. `cmake` refuses to configure a non-64-bit toolchain (guard in `CMakeLists.txt`).
|
||||
- Toolchain: VS 2022 Community; `cmake`/`ninja` ship with it. Installed Windows SDK is **10.0.19041.0**;
|
||||
the VS-generator MSBuild probe can't find it without the VC env — another reason to use Ninja inside
|
||||
the imported amd64 env.
|
||||
- Build output is **`build\version.dll`** (`OUTPUT_NAME version`, `PREFIX ""`) and that is the *only*
|
||||
artifact — it is self-contained. It used to ship alongside a `version_orig.dll` (a copy of the system
|
||||
DLL) that its exports statically forwarded to; that dependency was removed in f3eb296 in favour of
|
||||
runtime forwarding, so **do not** re-add a copy step for it. `-DGAME_DIR=...` copies `version.dll`
|
||||
into the game folder. **The copy fails while Rec Room is running** (DLL locked) — close the game first.
|
||||
|
||||
## Hard-won gotchas (read before patching anything)
|
||||
## Architecture
|
||||
|
||||
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.
|
||||
`DllMain` (`src/dllmain.c`) spawns one background thread — `HookThread` in
|
||||
`src/hooks/hook_manager.c` — which gates on `IsGameProcess()`, installs the ws2_32 hooks inline, then
|
||||
fans each il2cpp patch out onto **its own thread**, since they each wait independently on the runtime
|
||||
coming up. Order matters only for the memcheck patch (started first, see 5). The pipeline:
|
||||
|
||||
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.
|
||||
1. **Loader vector — `version.dll` proxy** (`src/proxy/version_proxy.c`). `RecRoom.exe` imports
|
||||
`VERSION.dll` by name, and the loader searches the app dir before System32, so our `version.dll`
|
||||
loads very early (before `UnityPlayer.dll`). All 17 real exports are satisfied by local `my_*`
|
||||
wrappers, aliased to the real names via `#pragma comment(linker, "/export:NAME=my_NAME")` (aliases,
|
||||
**not** PE forwarders — no dot in the target). Each wrapper lazily `LoadLibraryW`s the genuine
|
||||
`%SYSTEM32%\version.dll` **by absolute path** (so the app-dir search can't loop back into us) and
|
||||
calls through. `DllMain` starts the hook thread. To retarget at `winhttp.dll` instead, swap this
|
||||
file's export list for winhttp's.
|
||||
|
||||
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.
|
||||
2. **DNS host rewrite** (`src/hooks/dns_hook.c`, detours `ws2_32!getaddrinfo`). On an **exact-match**
|
||||
lookup it swaps the hostname (`ns.rec.net` → `ns.recflare.net`) and delegates to the real
|
||||
`getaddrinfo`, so the client reaches the target's *current* IP. This alone is **not sufficient** —
|
||||
it only changes DNS resolution; SNI and the HTTP `Host:` header still say `ns.rec.net`. Kept as a
|
||||
safety net.
|
||||
|
||||
4. **Obfuscated members live in the global namespace** and are referenced unqualified in this codebase
|
||||
(e.g. `typeof(LEALBOODIEE)`, `PGECJHKNIEN`). No `using` needed.
|
||||
3. **HTTP host rewrite** (`src/unity/http_rewrite.c`) — the real fix. The alternate backend
|
||||
(`ns.recflare.net`) serves its own vhost/cert, so requests must carry that host in URL + SNI + Host.
|
||||
This replicates the managed `SendRequestPatch`: it waits for the il2cpp runtime, resolves the
|
||||
concrete static `BestHTTP.HTTPManager.SendRequest(HTTPRequest)`, reads `req.get_Uri().get_AbsoluteUri()`,
|
||||
swaps the host, and `req.set_Uri(new System.Uri(...))` before forwarding. **This is a call-through
|
||||
hook** (must run the original) so it depends on the trampoline in `detour.c`.
|
||||
|
||||
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`.
|
||||
4. **TLS pinning bypass** (`src/unity/ssl_patch.c`). Redirecting to a mismatched cert fails the
|
||||
handshake; this resolves the concrete `Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication`
|
||||
`.NotifyServerCertificate` and detours it to an accept-all no-op. **Replace-only** hook (never calls
|
||||
the original).
|
||||
|
||||
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.
|
||||
5. **Memory-integrity scan neutralizer** (`src/unity/memcheck_patch.c`) — **the reason 3/4/6 are
|
||||
possible at all.** A background scan hashes `GameAssembly.dll` code against baked-in hashes; our
|
||||
inline patches change that memory, so boot dies *"Launch validation failed. Is Rec Room installed
|
||||
correctly?"*. The scanner class is obfuscated and **rotates every build**, so it is matched by
|
||||
*shape*: the class in `Assembly-CSharp.dll` carrying both a `System.Threading.Thread` and a
|
||||
`CancellationTokenSource` field. Its public/instance/0-param/non-void method is the scan entry; its
|
||||
return type is the promise the boot step awaits. We detour that entry (replace-only) to return an
|
||||
already-resolved promise, obtained by `il2cpp_runtime_invoke` on the promise type's static
|
||||
special-name 0-param `Resolved` getter (found by sweeping every image for a getter returning that
|
||||
exact class, skipping `_k__BackingField`). The getter is test-invoked **before** committing the
|
||||
detour — if it returns null or throws we skip the hook rather than hand boot a null promise.
|
||||
`PatchMemoryIntegrityCheck` is started **first** among the il2cpp patches: the boot step that awaits
|
||||
the scan can fire early and the reflection sweep takes ~700 ms, so it needs the head start.
|
||||
|
||||
### Surviving a game-version upgrade
|
||||
6. **EAC neutralizer** (`src/unity/eac_patch.c`). Two replace-only hooks on the literal
|
||||
`RecRoom.AntiCheat.EACManager` (namespace+class are *not* obfuscated; the methods are):
|
||||
- **Readiness → true.** The real check needs EasyAntiCheat services that no longer exist. Matched by
|
||||
shape: the sole static, 0-param, `bool`-returning, **non**-special-name method (excluding
|
||||
special-name is what keeps property getters out). Hook returns 1 — note the native signature is
|
||||
`(void *methodInfo)`, since a static il2cpp method still gets `MethodInfo*` in RCX.
|
||||
- **`GenerateChallengeResponse(string)` → `base64(challenge)`**, `base64("nothing")` for null/empty.
|
||||
Unobfuscated name, resolved directly. `g_gcr_static` is read from the method flags because it
|
||||
decides whether the string arg arrives in RCX or RDX — get that wrong and you base64 a `this`
|
||||
pointer.
|
||||
|
||||
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:
|
||||
Both are only safe because 5 has already neutralized the hash check.
|
||||
|
||||
| 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` |
|
||||
`src/core/` = logger + JSON-ish config + process info (incl. `IsGameProcess`). `src/utils/strings.c` = host match/rewrite.
|
||||
`src/debug/` and `connect_hook.c` are logging stubs / the disabled connect hook. Config is
|
||||
`redirector.json` next to `RecRoom.exe` (sample `.example`), parsed by a **flat key-scan, not real
|
||||
JSON** — keep it flat, one object per rewrite.
|
||||
|
||||
> **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.
|
||||
## Hard-won gotchas (read before touching hooks)
|
||||
|
||||
Renames observed in the **20230414 build** (`C:\Games\recflare-client`, Steam manifest
|
||||
`6426603215211043630`):
|
||||
1. **The detour engine has two modes; picking wrong corrupts code** (`src/memory/detour.c`).
|
||||
`InstallDetour(target, hook, backup, outTrampoline)`:
|
||||
- `outTrampoline != NULL` → **call-through**: it length-decodes the prologue (`decode`/`steal_len`),
|
||||
copies **whole instructions** (≥14 bytes) into a trampoline with relocation fixups
|
||||
(`RelocateInto`), and NOP-pads. Use when the hook calls the original (DNS, HTTP).
|
||||
- `outTrampoline == NULL` → **replace-only**: a blind 14-byte overwrite. Only safe when the hook
|
||||
never calls through (SSL), because we jump away immediately so a torn trailing instruction is never
|
||||
executed.
|
||||
The original crash bug was a *blind* 14-byte copy on a call-through target: `ws2_32!getaddrinfo`'s
|
||||
prologue has instruction boundaries at 3/7/11/**15**, so 14 bytes tore the 4th `mov` and the
|
||||
trampoline ran garbage → whichever thread called the original died. The symptom was subtle: the game
|
||||
booted (the first lookup runs on a Unity *background* thread that died silently) but the client's real
|
||||
API lookup later hit the same broken trampoline and its thread died before any request left the process.
|
||||
|
||||
- `LEALBOODIEE.GBNKOFMAJPA` → `HPEENKELKDJ.MGKINLFMJLB`
|
||||
- `EACManager.IMMGELPFGCK` → `EACManager.MCFIOBHCFBB`
|
||||
- `CheckForDUIDMismatch` out-param → `BPOGCIINKBB` (still bound as `__0`, no source change)
|
||||
2. **The length decoder relocates two cases and bails on the rest.** `decode()` classifies each
|
||||
instruction: `RK_RIPREL` (rip-relative disp32, `mod=00,rm=101`) and `RK_REL32` (`E8`/`E9`) are
|
||||
**relocated** — `AllocNear` places the trampoline within ±2 GB of the target so the rewritten
|
||||
displacements still fit in int32, and each fixup is range-checked. rip is computed from the *end of
|
||||
the whole instruction*, past any trailing immediate. `decode` returns 0 on a two-byte (`0x0F`)
|
||||
opcode or anything it doesn't model, and `rel8` branches are measured but flagged `RK_UNSUPPORTED`
|
||||
(they'd need a rel8→rel32 rewrite); in all those cases `InstallDetour` **refuses the hook** (logs
|
||||
it) rather than corrupt code. This is what unblocked the call-through hook on
|
||||
`HTTPManager.SendRequest`, whose prologue is the usual il2cpp class-init check `cmp byte [rip+disp],
|
||||
0` + `jne` — it now steals 16 bytes and relocates cleanly. ws2_32 stubs are position-independent and
|
||||
hook fine as-is (`getaddrinfo` steals 15).
|
||||
|
||||
Renames observed in the **07-21 build** (`C:\Games\recflare-client`), for reference:
|
||||
3. **il2cpp method resolution.** Resolve types by literal namespace+name across all loaded assemblies
|
||||
(`il2cpp_domain_get_assemblies` → `il2cpp_assembly_get_image` → `il2cpp_class_from_name` — the last
|
||||
only searches the image you give it, so sweep). Get methods with `il2cpp_class_get_method_from_name`
|
||||
(argc counts declared params only). **There is no `il2cpp_method_get_pointer` export** — read the
|
||||
compiled entry from `MethodInfo` offset 0 (`methodPointer`, `*(void**)method`). Wait for
|
||||
`il2cpp_domain_get()` to be non-NULL (runtime init) before resolving, and `il2cpp_thread_attach` your
|
||||
native thread before any metadata call. Interop DLLs name the type `Il2CppSystem.Uri`, but the runtime
|
||||
metadata namespace is plain `System`/`Uri`.
|
||||
|
||||
- `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.
|
||||
4. **Framework names are stable; patch the concrete class.** `SendRequest`, `get_Uri`/`set_Uri`,
|
||||
`AbsoluteUri`, `NotifyServerCertificate`, `HTTPManager`, `LegacyTlsAuthentication` are unobfuscated and
|
||||
have survived build changes — this is *why* these are the hook points. As in the managed project, hook
|
||||
the **concrete** impl, never an il2cpp interface. Verify signatures with Mono.Cecil against the interop
|
||||
in `../recflare-client/BepInEx/interop` when they drift (see the sibling `../recnet-patcher/CLAUDE.md`
|
||||
for the Cecil load snippet; that project's interop has the same types).
|
||||
|
||||
## Image signature verification
|
||||
5. **version.dll loads into multiple processes — log per-PID.** Our DLL loads into the game, the
|
||||
EasyAntiCheat launcher/bootstrap, and the crash handler. They previously shared `redirector.log` opened
|
||||
with `"w"` and truncated each other (the EAC process, stuck forever in `WaitForUnity` because
|
||||
`UnityPlayer.dll` never loads there, buried the game's diagnostics under module dumps). The logger now
|
||||
writes **`redirector_<pid>.log`**, and `WaitForUnity` is time-bounded. `HookThread` now returns
|
||||
immediately unless `IsGameProcess()` (`src/core/process.c`, basename == `RecRoom.exe`) — that check
|
||||
sits **before** `InitConsole`/`InitLogger`, because `AllocConsole` in the crash-handler process was
|
||||
opening a second debug window on every launch. So only `RecRoom.exe` writes a log at all now; if you
|
||||
need diagnostics from a sibling process, move the gate to wrap `InitConsole` alone.
|
||||
|
||||
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.
|
||||
6. **The connect hook is intentionally disabled.** `src/hooks/connect_hook.c` blindly redirects *all*
|
||||
:443 traffic (would break Photon/CDN/telemetry). `getaddrinfo` covers the il2cpp DNS path surgically.
|
||||
`gethostbyname` also has a latent self-recursion bug (calls `real_gethostbyname`, not a trampoline) if
|
||||
ever installed.
|
||||
|
||||
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`.
|
||||
7. **Rec Room's own names are obfuscated and rotate every build — match by shape, never by name.**
|
||||
Gotcha 4's framework names are the exception; anything in `Assembly-CSharp` is an 11-char scramble
|
||||
(`NAEMGPMOPED`, `JMCKLNABHHJ`) that differs next build, so **never hard-code one you saw in a log**.
|
||||
The two patches that need such a target (5, 6) locate it through the il2cpp reflection API by
|
||||
structure instead — field types, static-ness, param count, return type, special-name flag. Rules
|
||||
that follow from getting this wrong:
|
||||
- **Log every candidate; warn on >1.** The *method* searches count matches and log
|
||||
`WARNING N candidates` when ambiguous (scan-start in 5, readiness in 6). The **scanner class**
|
||||
search in 5 does not — it logs each `[MEMCHECK] scanner candidate` and silently keeps the last.
|
||||
Real logs already show **two**, so that field signature is *not* unique and the patch is riding on
|
||||
ordering. It works today; treat it as the most fragile thing here, and read those lines before
|
||||
trusting a boot.
|
||||
- **Never fall back to "close enough".** Every resolver bails with a log line rather than hooking a
|
||||
guess — a wrong detour on a rotating target corrupts an unrelated method.
|
||||
- **Verify before committing an irreversible detour** where you can (5 test-invokes the `Resolved`
|
||||
getter first).
|
||||
|
||||
## 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.
|
||||
- **Runtime**: the per-PID log is the source of truth. Our tags: `[STATUS] [HOOK] [DETOUR] [DNS ...]`
|
||||
`[REWRITE] [REDIRECT] [SSL] [HTTP] [MEMCHECK] [EAC]`. Success = `[HTTP] host rewrite installed on
|
||||
SendRequest` then `[HTTP] https://ns.rec.net/... -> https://ns.recflare.net/...` per request. A
|
||||
`[DETOUR] ... refusing hook` line means gotcha 2 — the decoder hit a prologue it won't relocate
|
||||
(`0x0F` opcode, `rel8` branch, or an unmodelled opcode), and **that patch is not active**.
|
||||
- **Static il2cpp**: dump prologue bytes from `GameAssembly.dll` by converting the logged runtime `code=`
|
||||
address to an RVA (subtract the logged `GameAssembly.dll Base`) and mapping RVA→file offset via the PE
|
||||
section headers. Method/field signatures: Mono.Cecil over the interop DLLs (see gotcha 4).
|
||||
- PowerShell here is Windows PowerShell 5.1 — no `?.`; use explicit `$x -eq $null`. Avoid `2>&1` on native
|
||||
exes.
|
||||
|
||||
Reference in New Issue
Block a user