13 Commits

Author SHA1 Message Date
devin 68f42eb661 Add MIT License to NOTICES file 2026-08-20 12:13:12 -04:00
devin ec11bd2f21 Change copyright holder in LICENSE file
Updated copyright holder
2026-08-20 12:12:14 -04:00
devin 2a9414a88b Update copyright holder in LICENSE file 2026-08-20 10:49:54 -04:00
Devin Zuczek 591618af0f attempting to patch some more noise calls 2026-08-05 12:44:31 -04:00
devin f18542f61e Update README with RecFlare project details
Added a project image and description for RecFlare.
2026-08-05 10:43:25 -04:00
devin 2880e46ca7 Add Projects section to README
Added a section about projects powered by the plugin.
2026-08-05 10:39:43 -04:00
Devin Zuczek c086e3d3d4 block amplitude 2026-07-27 11:14:41 -04:00
Devin Zuczek 1127727270 doc cleanup 2026-07-26 18:14:12 -04:00
Devin Zuczek 6d5f06c940 simplify ImageSigningPatch 2026-07-24 00:40:48 -04:00
devin 8ddda007c1 Support for build 20230414 (#5)
* support for 20230331, remove need for metadata patching

* build 20230414
2026-07-23 01:29:06 -04:00
devin 6a62f0cc1d Suppress DUID mismatch check resulting in create account hang (#3)
* test duuid mismatch failures

* add variables to trigger or suppress DUID mismatch

* update docs

* turn on by default
2026-07-14 13:36:52 -04:00
devin a6e4555a00 add link 2026-07-10 17:14:44 -04:00
Devin Zuczek 326188c7a0 Initial RecNetPlugin 2026-07-08 17:11:58 -04:00
16 changed files with 1221 additions and 41 deletions
+309
View File
@@ -0,0 +1,309 @@
# 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lapis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+283
View File
@@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace RecNetPlugin.Patches;
// Amplitude analytics: the client ships telemetry to Amplitude, which a self-hosted setup has no use
// for (and which leaks play data off-box). Prefix every event-logging entrypoint and swallow the call
// so nothing is ever queued, batched or sent.
//
// One knob for all telemetry, `[Analytics] Disable Telemetry`, default true — it gates this file plus
// BacktracePatch and UnityTelemetryPatch. Deliberately not split per vendor: nobody wants Amplitude
// gone but the collector alive, and four switches for one intention is four ways to be half-configured.
//
// Target resolution: AmplitudeAnalytics.AmplitudeAnalyticsClient in RecRoom.Analytics.Runtime.dll,
// concrete (it derives from SingletonMonoBehaviour<T>, so there is no abstract-interface dispatch
// trap here — see gotcha 3 in CLAUDE.md). All five Log* names are UNobfuscated in the 20230414 build.
// They are still strings, so a rename shows up only as a HarmonyX "Could not find method" in
// LogOutput.log, not as a build error — the per-method [AMPLITUDE] blocked log line below is the real
// proof a hook is live.
//
// Why all five: blocking LogEventAsync alone was not enough — a session's room_stats/perf_stats
// events still went out, and the LogEventAsync prefix never logged at all, so that entrypoint was
// never even called. Those events are the pre-serialized batch the client parks in the
// `pending_room_stats` PlayerPref (visible in the DUID-PROBE log), which points at
// LogSerializedEventAsync rather than LogEventAsync.
//
// Why the Log* prefixes alone STILL are not enough — and why `BlockAnalyticsUploadPatch` below is the
// part that actually stops the traffic: uploads to api2.amplitude.com kept showing up in mitmproxy
// with LogEventAsync/LogIdentifyAsync visibly blocked in LogOutput.log. The Log* methods only *queue*;
// the queue is persisted (`pending_room_stats`) and drained later by the client's own flush coroutines
// (Flush / AMEAMPDLJPN / PPOCFIHNKPP), which reach the network through the transport interface
// `FOMPBHDLPDO` — concrete impl `MHBPNDOGOLG` in RecNet.Runtime.dll, i.e. BestHTTP. So a batch queued
// in an earlier session ships on the next launch no matter what we do to the Log* doors. Blocking at
// the BestHTTP layer catches every path, present and future, and costs no obfuscated names.
[HarmonyPatch]
public static class AmplitudePatch
{
private static readonly HashSet<string> _loggedBlocked = new();
// Returns false to skip the original. Logs once per entrypoint so LogOutput.log shows which door
// the client actually used.
private static bool Block(string entrypoint)
{
if (_loggedBlocked.Add(entrypoint))
Plugin.Log.LogInfo($"[AMPLITUDE] analytics disabled — blocked {entrypoint}");
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")]
private static bool LogEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")]
private static bool LogPrevSessionEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogPrevSessionEventAsync");
// The likely culprit for the room_stats/perf_stats batch — takes the already-serialized
// Dictionary<string, object> that gets parked in `pending_room_stats`.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogSerializedEventAsync")]
private static bool LogSerializedEventAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogSerializedEventAsync");
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")]
private static bool LogIdentifyAsyncPrefix() =>
Plugin.DisableTelemetry.Value && Block("LogIdentifyAsync");
// The odd one out: static, and it returns a promise instead of void. Skipping it with a null
// __result would hand the caller something it will chain .Then() on, so we substitute an
// already-resolved promise — the call looks like it succeeded instantly. If we cannot build one,
// we let the original run rather than risk a null-deref at quit time.
[HarmonyPrefix]
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")]
private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result)
{
if (!Plugin.DisableTelemetry.Value)
return true;
var resolved = ResolvedPromise();
if (resolved == null)
return true;
__result = resolved;
return Block("LogOutOfSessionEvent");
}
private static bool _promiseResolved;
private static MethodInfo _resolvedPromiseGetter;
// Finds the concrete Promise class's static `Resolved` property getter without hardcoding its
// obfuscated name. Among the static, 0-param property getters in RecRoom.Promises.Runtime that
// return the promise interface there are exactly two: the real (obfuscated) property getter and
// the compiler-generated `get_<Name>_k__BackingField`. The backing field may be null if the
// property initialises lazily, so we drop it by its compiler-generated name — which the
// obfuscator leaves alone — and keep the other one.
private static LAHBDKNMNHN ResolvedPromise()
{
if (!_promiseResolved)
{
_promiseResolved = true;
var candidates = typeof(LAHBDKNMNHN).Assembly.GetTypes()
.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
.Where(m => m.ReturnType == typeof(LAHBDKNMNHN)
&& m.GetParameters().Length == 0
&& m.IsSpecialName
&& !m.Name.EndsWith("_k__BackingField"))
.ToList();
if (candidates.Count == 1)
_resolvedPromiseGetter = candidates[0];
else
Plugin.Log.LogWarning(
$"[AMPLITUDE] expected exactly one resolved-promise getter, found {candidates.Count} " +
"— LogOutOfSessionEvent will NOT be blocked");
}
if (_resolvedPromiseGetter == null)
return null;
return _resolvedPromiseGetter.Invoke(null, null) as LAHBDKNMNHN;
}
// Analytics hosts we refuse to talk to, matched two different ways because they identify
// themselves two different ways:
//
// AmplitudeDomains — a registrable domain plus everything under it, so api2.amplitude.com
// and api.eu.amplitude.com are both covered.
// CollectorLabelPrefixes — the *first* hostname label, for the telemetry collector that lives
// on whatever domain the deployment uses (datacollection.recflare.net,
// datacollection.rec.net, datacollection-eu.…). The domain varies, the
// label doesn't, so match on the label and stay deployment-agnostic.
//
// Split into separate lists because they're *matched* differently, not configured differently —
// one knob covers the lot.
private static readonly string[] AmplitudeDomains = { "amplitude.com" };
private static readonly string[] CollectorLabelPrefixes = { "datacollection" };
// Backtrace and Unity's perf-events don't normally reach BestHTTP at all — Backtrace goes through
// UnityWebRequest (see BacktracePatch) and Unity's is native (see UnityTelemetryPatch). They're
// listed here anyway because it costs a string comparison to be right if that ever changes, and
// because "the host block covers every host we don't want talked to" is easier to reason about
// than a list with holes in it. Only perf-events is named, not all of cloud.unity3d.com — the
// client uses other Unity services.
private static readonly string[] BacktraceDomains = { "backtrace.io" };
private static readonly string[] UnityTelemetryHosts = { "perf-events.cloud.unity3d.com" };
private static bool IsUnderAnyDomain(string host, string[] domains) =>
domains.Any(d => host.Equals(d, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase));
private static bool IsAmplitudeHost(string host) => IsUnderAnyDomain(host, AmplitudeDomains);
private static bool IsCollectorHost(string host)
{
var dot = host.IndexOf('.');
var firstLabel = dot < 0 ? host : host.Substring(0, dot);
return CollectorLabelPrefixes.Any(p => firstLabel.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
// One knob for the lot. The vendors are split into separate lists above because they're matched
// differently, not because they're configured differently.
private static bool IsBlockedHost(string host)
{
if (string.IsNullOrEmpty(host) || !Plugin.DisableTelemetry.Value)
return false;
return IsAmplitudeHost(host)
|| IsCollectorHost(host)
|| IsUnderAnyDomain(host, BacktraceDomains)
|| UnityTelemetryHosts.Any(h => host.Equals(h, StringComparison.OrdinalIgnoreCase));
}
// The part that actually stops the traffic — see the note at the top of the file. Prefixes the
// same BestHTTP entrypoint SendRequestPatch hooks (HTTPManager is BestHTTP's own type, so no
// obfuscated names are involved and this survives game upgrades) and, for analytics hosts, hands
// the caller a synthetic 200 instead of sending anything.
//
// Faking success rather than failure is deliberate: the transport resolves its promise, the flush
// coroutine considers the batch delivered, and the client clears `pending_room_stats` — so nothing
// accumulates and nothing retries. Failing the request instead would leave the batch queued and
// re-attempted every session.
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
public static class BlockAnalyticsUploadPatch
{
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
{
// SendRequest returns the request it was handed; callers chain off it, so hand it back
// even though we never send it.
__result = request;
return !Drop(request);
}
// Second net, one layer down. Every SendRequest overload funnels into SendRequestImpl, and
// IL2CPP is free to inline the tiny SendRequest(HTTPRequest) body into its callers — a hook on
// it then never fires for those call sites (gotcha: a Harmony patch that loads clean can still
// never run). SendRequestImpl is the last managed-visible chokepoint before the connection, so
// anything that slipped past the hook above is caught here.
[HarmonyPatch(typeof(HTTPManager), "SendRequestImpl", [typeof(HTTPRequest)])]
public static class ImplPatch
{
private static bool Prefix(HTTPRequest request) => !Drop(request);
}
// True when the request was blocked (caller should skip the original).
private static bool Drop(HTTPRequest request)
{
if (!IsBlockedHost(request.Uri.Host))
return false;
// Once per host normally; every request under [Advanced] Debug, since "did this specific
// upload get dropped or did it slip past?" is exactly the question a mitmproxy trace
// raises, and a deduped line can't answer it.
if (Plugin.Debug.Value)
Plugin.Log.LogInfo($"[ANALYTICS] dropped {request.MethodType} {request.Uri.AbsoluteUri}");
else if (_loggedBlocked.Add("upload:" + request.Uri.Host))
Plugin.Log.LogInfo($"[ANALYTICS] dropping uploads to {request.Uri.Host}");
try
{
CompleteWithFakeSuccess(request);
}
catch (Exception e)
{
// Couldn't synthesize the response — still don't send. The request's callback never
// fires, so whatever promise the transport made stays pending; that's a stalled flush
// coroutine at worst, versus telemetry leaving the box.
Plugin.Log.LogWarning($"[ANALYTICS] blocked {request.Uri.Host} but could not fake a response: {e.Message}");
}
return true;
}
private static void CompleteWithFakeSuccess(HTTPRequest request)
{
var body = FakeBodyFor(request);
var response = new HTTPResponse(request, new Il2CppSystem.IO.MemoryStream(), false, false)
{
StatusCode = 200,
Message = "OK",
Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body)),
};
request.Response = response;
request.State = HTTPRequestStates.Finished;
// BestHTTP would normally fire this from HTTPManager's update loop a frame or more later.
// Firing it inline is safe here because the callback is assigned before SendRequest is
// called, and the promise it resolves already exists by then.
request.Callback?.Invoke(request, response);
}
// Whatever the endpoint would have said on a good day. Amplitude's real shapes are known:
// /identify answers with the literal "success", the v2 batch endpoint with a small JSON
// envelope. The collector's shape isn't known, so we fall back to `{"success":true}` — the
// envelope every first-party RecNet endpoint uses (it's what the real deviceId endpoint
// returns, see the DUID case study in CLAUDE.md) and a far better guess than an empty body,
// which the RecNet HTTP wrapper rejects outright with "Response was empty".
private static string FakeBodyFor(HTTPRequest request)
{
if (!IsAmplitudeHost(request.Uri.Host))
return "{\"success\":true}";
return request.Uri.AbsoluteUri.Contains("/identify", StringComparison.OrdinalIgnoreCase)
? "success"
: "{\"code\":200,\"events_ingested\":0,\"payload_size_bytes\":0,\"server_upload_time\":"
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "}";
}
}
}
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using Backtrace.Unity.Json;
using Backtrace.Unity.Model;
using HarmonyLib;
using UnityEngine.Networking;
namespace RecNetPlugin.Patches;
// Backtrace crash reporting — the uploads to submit.backtrace.io.
//
// This one does not go through BestHTTP, so `AmplitudePatch`'s host block never sees it:
// `Backtrace.Unity.dll` references `UnityEngine.UnityWebRequestModule` and nothing else HTTP-shaped.
// The whole SDK is unobfuscated (it's a third-party package, so no per-build name churn to survive),
// and every submission it makes — crash reports, minidumps, metrics — funnels through the four
// `BacktraceHttpClient.Post` overloads. That's the concrete class; `IBacktraceHttpClient` is the
// interface and patching it would silently never run (gotcha 3 in CLAUDE.md).
//
// The two overload shapes need different treatment, because of who owns the send:
//
// void Post(url, jObject, onComplete) - fire-and-forget, the SDK sends internally. We skip it and
// invoke the callback with a 200 ourselves.
// UnityWebRequest Post(...) x3 - builds the request and hands it back; *the caller* sends it
// (`yield return request.SendWebRequest()`). Skipping the
// original would hand the caller a null to dereference, so
// instead we let it build whatever it likes and repoint the
// finished request at a black hole.
//
// Not covered: `RecRoomNativeClient` installs a native crash handler, and a minidump uploaded from
// native code on the next launch never passes through here. If submit.backtrace.io still shows a
// multipart minidump POST with everything below firing, that's the path it took.
[HarmonyPatch]
public static class BacktracePatch
{
// Loopback port 1: nothing listens there, so the send fails with connection-refused in
// microseconds without a packet leaving the machine, and the SDK takes its ordinary offline path.
private const string BlackHoleUrl = "http://127.0.0.1:1/blocked-by-recnet-plugin";
private static readonly HashSet<string> _loggedBlocked = new();
// Fire-and-forget path (metrics). The SDK sends this one itself, so skipping the original is
// enough — but the callback has to be answered or the submission queue keeps the batch it just
// handed us and retries it forever. (statusCode, isError, response): a 200 with no error is what
// it waits for before clearing the batch.
[HarmonyPrefix]
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
typeof(string), typeof(BacktraceJObject), typeof(Il2CppSystem.Action<long, bool, string>))]
private static bool PostWithCallbackPrefix(string __0, Il2CppSystem.Action<long, bool, string> __2)
{
if (!Plugin.DisableTelemetry.Value)
return true;
LogBlocked(__0);
__2?.Invoke(200, false, "{}");
return false;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
typeof(string), typeof(BacktraceJObject))]
private static void PostJObjectPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
[HarmonyPostfix]
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
typeof(string), typeof(string),
typeof(Il2CppSystem.Collections.Generic.IEnumerable<string>),
typeof(Il2CppSystem.Collections.Generic.IDictionary<string, string>))]
private static void PostJsonPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
[HarmonyPostfix]
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
typeof(string), typeof(Il2CppSystem.Collections.Generic.List<IMultipartFormSection>))]
private static void PostFormPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
// Leave the request the SDK built exactly as it is — handlers, headers, body — and change only
// where it points. Rebuilding it ourselves would mean guessing which handlers the caller goes on
// to dereference; this way the coroutine keeps its shape and just gets an error back.
private static void Neuter(string url, UnityWebRequest request)
{
if (!Plugin.DisableTelemetry.Value || request == null)
return;
LogBlocked(url);
request.url = BlackHoleUrl;
}
// Once per host normally, every submission under [Advanced] Debug — same rule as the analytics
// host block, and for the same reason: a deduped line can't answer "did *this* upload get
// dropped?" when you're staring at a proxy trace.
private static void LogBlocked(string url)
{
if (Plugin.Debug.Value)
{
Plugin.Log.LogInfo($"[BACKTRACE] dropped submission to {url}");
return;
}
var host = HostOf(url);
if (_loggedBlocked.Add(host))
Plugin.Log.LogInfo($"[BACKTRACE] telemetry disabled — dropping submissions to {host}");
}
private static string HostOf(string url)
{
try
{
return new Uri(url).Host;
}
catch (Exception)
{
return url;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Test tool: persist a genuinely corrupt STORED device id on this machine, matching the friend's
// condition (stored id truncated, current id healthy).
//
// We can't hand-craft the stored value: it lives in PlayerPrefs under an obfuscated key, encoded as a
// CodeStage ObscuredString, and both the key and the encode method are renamed per game build. So we
// let the game write it: WriteDUIDs() stores ObscuredString(SystemInfo.deviceUniqueIdentifier) under
// the right key. We temporarily spoof deviceUniqueIdentifier to a truncated value around that one
// call, so the game encrypts+stores a bad id with its own (unknown-to-us) key. Afterwards the spoof
// is off, so the current id reads healthy again -> stored != current -> real mismatch on next launch.
[HarmonyPatch]
public static class CorruptDUIDPatch
{
// Only true for the duration of the WriteDUIDs() call below, so the SystemInfo getter is spoofed
// exactly there and nowhere else.
private static bool _spoofActive;
private static string _spoofValue = "";
[HarmonyPrefix]
[HarmonyPatch(typeof(SystemInfo), "get_deviceUniqueIdentifier")]
private static bool DeviceIdGetterPrefix(ref string __result)
{
if (!_spoofActive)
return true;
__result = _spoofValue;
return false;
}
// Returns true if the corruption was written (so the caller marks it done and won't repeat).
public static bool CorruptStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing written");
return false;
}
var real = SystemInfo.deviceUniqueIdentifier; // spoof off -> real id
var bad = real is { Length: >= 7 } ? real.Substring(0, 7) : "badduid";
_spoofValue = bad;
_spoofActive = true;
try
{
cm.WriteDUIDs(); // encodes+stores ObscuredString(bad) under the real key
}
finally
{
_spoofActive = false;
}
Plugin.Log.LogWarning($"[CORRUPT] wrote truncated stored DUID = \"{bad}\" (real id = \"{real}\"). " +
"Set 'Corrupt Stored DUID' back to false and relaunch to drive the real mismatch path.");
return true;
}
// Undo: overwrite the stored value with the real id by calling WriteDUIDs with the spoof off.
public static bool RestoreStored(GameObject cheatMgrGo)
{
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
if (cm == null)
{
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing restored");
return false;
}
cm.WriteDUIDs(); // spoof off -> stores ObscuredString(real deviceUniqueIdentifier)
Plugin.Log.LogWarning($"[CORRUPT] restored stored DUID to real id = \"{SystemInfo.deviceUniqueIdentifier}\". " +
"Set 'Restore Stored DUID' back to false.");
return true;
}
}
+47
View File
@@ -0,0 +1,47 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
// Controls CheatManager.CheckForDUIDMismatch, which returns true when the machine's stored device id
// differs from the freshly-derived one. A true result sends the client down the migration path that
// POSTs PlayerReporting/v1/deviceId and then stalls on Create Account.
//
// Three modes, chosen by config:
// Simulate = true -> force TRUE (fake a mismatch to reproduce the hang without a corrupt value)
// Suppress = true -> force FALSE (the workaround fix: never migrate, never hang)
// both false -> pass through, let the REAL check run against the actual stored value
// (needed to observe a genuinely corrupt stored id, e.g. after Corrupt Stored DUID)
//
// Patch the concrete CheatManager method, NOT the abstract PGECJHKNIEN interface, or the prefix
// never runs.
[HarmonyPatch]
public static class DUIDMismatchPatch
{
private const string SimulatedStoredDeviceId = "491e8b9";
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "CheckForDUIDMismatch")]
// __0 = the out-param (positional). Its obfuscated name changes every game build, so binding it
// by name throws "Parameter ... not found" on upgrade.
private static bool Prefix(ref string __0, ref bool __result)
{
if (Plugin.SimulateDUIDMismatch.Value)
{
__0 = SimulatedStoredDeviceId;
__result = true;
Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}");
return false;
}
if (Plugin.SuppressDUIDMismatch.Value)
{
__0 = string.Empty;
__result = false;
Plugin.Log.LogInfo("[DUID] mismatch check forced to false (suppressed)");
return false;
}
// Pass through to the real check.
return true;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace RecNetPlugin.Patches;
// Diagnostic only. Two jobs:
// 1. Show where the stored device id lives, by logging PlayerPrefs reads/writes.
// 2. Show how far the DUID migration branch gets, by logging CheatManager's other DUID methods.
// If WriteDUIDs() never fires after the deviceId POST, the flow stalls before it.
[HarmonyPatch]
public static class DUIDProbePatch
{
// PlayerPrefs.GetString is called constantly, so log each key only once — except device/DUID
// keys, which we always log so we can watch them change across the migration.
private static readonly HashSet<string> SeenKeys = new();
private static bool IsInteresting(string key) =>
key != null && (key.Contains("DUID") || key.Contains("Duid") || key.Contains("duid")
|| key.Contains("Device") || key.Contains("device")
|| key.Contains("Anon") || key.Contains("anon"));
private static void Note(string op, string key, string value)
{
if (IsInteresting(key))
Plugin.Log.LogWarning($"[DUID-PROBE] {op} {key} = \"{value}\"");
else if (SeenKeys.Add($"{op}:{key}"))
Plugin.Log.LogInfo($"[DUID-PROBE] {op} {key} = \"{value}\"");
}
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string)])]
private static void GetStringPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPostfix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string), typeof(string)])]
private static void GetStringDefaultPostfix(string key, string __result) => Note("get", key, __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.SetString))]
private static void SetStringPrefix(string key, string value) => Note("SET", key, value);
[HarmonyPrefix]
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.DeleteKey))]
private static void DeleteKeyPrefix(string key) => Note("DEL", key, "<deleted>");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() called");
[HarmonyPostfix]
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
private static void WriteDUIDsPostfix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() returned");
[HarmonyPrefix]
[HarmonyPatch(typeof(CheatManager), "ClearDUIDs")]
private static void ClearDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] ClearDUIDs() called");
}
+61
View File
@@ -0,0 +1,61 @@
using System;
using System.Text;
using BestHTTP;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
namespace RecNetPlugin.Patches;
// Experiment harness for the Create Account hang.
//
// On a device-id mismatch the client POSTs PlayerReporting/v1/deviceId, the server answers
// 200 {"success":true}, and then the client stops: CheatManager.WriteDUIDs() is never called, so the
// new id is never persisted and the flow never reaches create_account. That means the client can't
// proceed on what it got back.
//
// This rewrites that one response body before the game sees it, so response shapes can be tried
// without redeploying the server. WriteDUIDs() appearing in the log (see DUIDProbePatch) is the
// pass signal: it means the client accepted the response and resumed the migration.
[HarmonyPatch]
public static class DeviceIdResponsePatch
{
private const string Endpoint = "/PlayerReporting/v1/deviceId";
[HarmonyPrefix]
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
private static void Prefix(HTTPRequest request)
{
if (!request.Uri.AbsoluteUri.Contains(Endpoint, StringComparison.OrdinalIgnoreCase))
return;
var original = request.Callback;
// Whether the game attached a completion callback at all. If this logs False, the client is
// not waiting on this request through the callback API and the "stuck on the response" model
// is wrong -- that would be worth knowing before chasing response shapes any further.
Plugin.Log.LogWarning($"[DEVICEID] request seen; game callback attached = {original != null}");
var body = Plugin.DeviceIdResponseOverride.Value;
if (string.IsNullOrEmpty(body))
return;
var status = Plugin.DeviceIdResponseStatus.Value;
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
{
if (resp != null)
{
// Set both: DataAsText is computed from Data but cached in dataAsText once read,
// and our own HTTP logger may already have read it.
resp.Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body));
resp.dataAsText = body;
resp.StatusCode = status;
Plugin.Log.LogWarning($"[DEVICEID] response overridden -> {status} {body}");
}
original?.Invoke(req, resp);
}));
}
}
+8 -4
View File
@@ -9,7 +9,10 @@ namespace RecNetPlugin.Patches;
public static class EACPatches
{
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "FJLMLEPOKGE")]
// The "is ready" check: the only static, 0-param bool method on EACManager that isn't a property
// getter. 20230414 build: MCFIOBHCFBB (was IMMGELPFGCK, was FJLMLEPOKGE). Method names here are
// strings, so a rename is not a compile error — it shows up as a HarmonyX "method not found" at load.
[HarmonyPatch(typeof(EACManager), "MCFIOBHCFBB")]
private static bool IsReadyPatch(ref bool __result)
{
__result = true;
@@ -18,10 +21,11 @@ public static class EACPatches
[HarmonyPrefix]
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
private static bool GenerateChallengeResponsePatch(string PGCINMIEBJP, ref string __result)
// __0 = the challenge string (positional); obfuscated param names shift between game builds.
private static bool GenerateChallengeResponsePatch(string __0, ref string __result)
{
if (!string.IsNullOrEmpty(PGCINMIEBJP))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(PGCINMIEBJP));
if (!string.IsNullOrEmpty(__0))
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(__0));
else
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
return false;
+58
View File
@@ -0,0 +1,58 @@
using HarmonyLib;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using Il2CppSystem.Security.Cryptography;
namespace RecNetPlugin.Patches;
// Image signing: the client verifies images against an RSA public key whose modulus is a string
// literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework
// level instead, by forcing the mscorlib RSA verify to succeed.
//
// One knob, see [Signing] in the .cfg:
// Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so
// the modulus never has to match and unsigned images load. This
// self-hosted setup does not use image signing.
//
// If images ever stop loading with this on, the client has moved verification off mscorlib RSA onto
// BestHTTP.SecureProtocol.Org.BouncyCastle; the equivalent hooks there are the concrete
// RsaDigestSigner/PssSigner.VerifySignature (NOT the abstract ISigner "interface", which never
// dispatches).
[HarmonyPatch]
public static class ImageSigningPatch
{
private static bool _loggedForced;
// Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS
// uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch
// certificate validation — but it is a blunt instrument, so it stays behind a config knob rather
// than being unconditional.
private static bool ForceVerifyTrue(ref bool __result)
{
if (!Plugin.DisableSignatureVerification.Value)
return true;
if (!_loggedForced)
{
_loggedForced = true;
Plugin.Log.LogWarning("[SIG] signature verification disabled — RSA verify forced to true");
}
__result = true;
return false;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyData))]
private static bool VerifyDataPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
[typeof(Il2CppStructArray<byte>), typeof(int), typeof(Il2CppStructArray<byte>)])]
private static bool VerifyHashPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
[HarmonyPrefix]
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
[typeof(Il2CppStructArray<byte>), typeof(Il2CppStructArray<byte>), typeof(HashAlgorithmName),
typeof(RSASignaturePadding)])]
private static bool VerifyHashPaddingPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
}
+4 -1
View File
@@ -7,7 +7,10 @@ namespace RecNetPlugin.Patches;
/**
Patches Photon to use the App IDs and server hostname/port specified in the plugin config.
*/
[HarmonyPatch(typeof(GPFPFDBGCEK), "AMOHMPKKGHL")]
// Obfuscated names shift every game build. Re-resolve by signature: the target is the only
// instance, 0-param method returning Photon.Realtime.AppSettings in Assembly-CSharp.
// 20230414 build: HPEENKELKDJ.MGKINLFMJLB (was LEALBOODIEE.GBNKOFMAJPA, was GPFPFDBGCEK.AMOHMPKKGHL).
[HarmonyPatch(typeof(HPEENKELKDJ), "MGKINLFMJLB")]
public class PhotonPatches
{
[HarmonyPostfix]
-19
View File
@@ -1,19 +0,0 @@
using HarmonyLib;
namespace RecNetPlugin.Patches;
/**
* This allows the global-metadata.dat to be different on the client
* patched to allow a different modulus so we can sign images.
*/
[HarmonyPatch(typeof(JAPJPGNBMNM), "JOKECJKBJGD")]
public static class PromisePatch
{
public static bool Prefix(out HPHDJAFFHCN<JAPJPGNBMNM.AOFCCEACNNA> __result)
{
var result = JAPJPGNBMNM.AOFCCEACNNA.JGIHNLEFJEL();
var promise = HAAHJPGNIMD.NMOOLKAJDOC(result);
__result = promise;
return false;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ public class SendRequestPatch
}
// Cap logged bodies so a large response/request doesn't flood the log.
private const int MaxLoggedBodyLength = 1000;
private const int MaxLoggedBodyLength = 10000;
private static string Truncate(string s)
{
+78
View File
@@ -0,0 +1,78 @@
using System;
namespace RecNetPlugin.Patches;
// Unity's own telemetry — the uploads to perf-events.cloud.unity3d.com.
//
// !! THIS DOES NOT WORK on the 20230414 build, and that is a known, accepted limitation — don't spend
// another afternoon on it. Confirmed at runtime: the setters below are refused, `enabled` reads back
// True on all five attempts, and perf-events uploads keep flowing. It's left in because it costs
// nothing, is the correct thing to do if a future build stops refusing, and the read-back logs the
// truth either way rather than pretending. The parts of `Disable Telemetry` that carry the actual win
// are Amplitude, the collector and Backtrace — all confirmed dropping — and they take out the bulk of
// the noise. If perf-events ever has to go for real, it needs a hosts-file/DNS block or a native hook;
// there is no managed lever.
//
// There is nothing to hook here, and that's the point: `UnityEngine.Analytics.Analytics` and
// `PerformanceReporting` are thin managed shims over native engine code, and the uploads happen inside
// the player, not on any managed send path a Harmony prefix could sit on. Blocking this one at the HTTP
// layer is equally hopeless — it never touches BestHTTP or UnityWebRequest. What it *does* have is a
// documented opt-out, so we flip the switches at startup and read them back.
//
// Performance Reporting is the exception/crash reporter, Analytics is the event stream; both feed
// perf-events, so both go off. `limitUserTracking` and `deviceStatsEnabled` cover the case where
// something re-enables the event stream behind our back — with those set, what it can collect is
// nothing worth sending.
internal static class UnityTelemetryPatch
{
// Applied from Plugin.Load and again on each scene load until it sticks — these are native
// properties whose setters can be refused (service not initialised yet, build flags), so
// "set it once at load and assume" is exactly how this silently does nothing.
private const int MaxAttempts = 5;
private static bool _done;
private static int _attempts;
public static void Apply()
{
if (_done || !Plugin.DisableTelemetry.Value || _attempts >= MaxAttempts)
return;
_attempts++;
try
{
UnityEngine.Analytics.PerformanceReporting.enabled = false;
UnityEngine.Analytics.Analytics.enabled = false;
UnityEngine.Analytics.Analytics.deviceStatsEnabled = false;
UnityEngine.Analytics.Analytics.limitUserTracking = true;
}
catch (Exception e)
{
// No _done: a later scene load gets another go, up to MaxAttempts.
if (_attempts >= MaxAttempts)
Plugin.Log.LogWarning($"[UNITY-TELEMETRY] gave up flipping the opt-out switches after {_attempts} attempts: {e.Message}");
return;
}
// The read-back is the proof, not the assignment above. A refused setter is silent.
var perf = UnityEngine.Analytics.PerformanceReporting.enabled;
var analytics = UnityEngine.Analytics.Analytics.enabled;
_done = !perf && !analytics;
if (_done)
Plugin.Log.LogInfo(
$"[UNITY-TELEMETRY] disabled — PerformanceReporting.enabled={perf} Analytics.enabled={analytics} " +
$"deviceStats={UnityEngine.Analytics.Analytics.deviceStatsEnabled} " +
$"limitUserTracking={UnityEngine.Analytics.Analytics.limitUserTracking}");
else if (_attempts >= MaxAttempts)
// Info, not a warning: this is the known outcome on this build (see the header), not a
// fault to go chasing. It stays logged so a build that *does* accept the switches is
// visible as a change rather than a surprise.
Plugin.Log.LogInfo(
$"[UNITY-TELEMETRY] switches refused after {_attempts} attempts — " +
$"PerformanceReporting.enabled={perf} Analytics.enabled={analytics}. " +
"Known limitation: perf-events.cloud.unity3d.com uploads continue. The rest of Disable Telemetry is unaffected.");
}
}
+37 -3
View File
@@ -22,6 +22,16 @@ public class Plugin : BasePlugin
public static ConfigEntry<string> PhotonHostname { get; private set; }
public static ConfigEntry<int> PhotonPort { get; private set; }
public static ConfigEntry<bool> Debug { get; private set; }
public static ConfigEntry<bool> SimulateDUIDMismatch { get; private set; }
public static ConfigEntry<bool> SuppressDUIDMismatch { get; private set; }
public static ConfigEntry<bool> CorruptStoredDUID { get; private set; }
public static ConfigEntry<bool> RestoreStoredDUID { get; private set; }
public static ConfigEntry<string> DeviceIdResponseOverride { get; private set; }
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
public static ConfigEntry<bool> DisableTelemetry { get; private set; }
private static bool _corruptDone;
public override void Load()
{
@@ -35,6 +45,20 @@ public class Plugin : BasePlugin
PhotonPort = Config.Bind("Advanced", "Photon NameServer Port", 0, "Custom Photon NameServer Port (if 0, it will be default)");
ServerHostname = Config.Bind("Server", "RecNet NameServer Host", "https://ns.rec.net", "Host for the RecNet NameServer.");
Debug = Config.Bind("Advanced", "Debug", false, "Show debug logs (HTTP tracing, etc. WARNING: will include sensitive information such as passwords and auth tokens in the logs, be careful when sharing them!)");
SimulateDUIDMismatch = Config.Bind("Advanced", "Simulate DUID Mismatch", false, "Force CheckForDUIDMismatch to return TRUE (fakes the comparison only). Reproduces the hang path but does not corrupt any stored value. Leave false for normal play.");
SuppressDUIDMismatch = Config.Bind("Advanced", "Suppress DUID Mismatch", true, "Force CheckForDUIDMismatch to return FALSE (the workaround fix, ON by default): the client never migrates and never takes the Create Account hang path. No-op on healthy machines (the real check returns false anyway); on mismatched machines it skips the hang. Set false only to observe the real mismatch behavior for debugging.");
CorruptStoredDUID = Config.Bind("Advanced", "Corrupt Stored DUID", false, "ONE-SHOT TEST: on next launch, write a truncated device id into the DUID pref via the game's own WriteDUIDs, producing a genuinely corrupt STORED value (real current id) — exactly the friend's condition. After it logs '[CORRUPT] wrote', set this back to false and relaunch to drive the real mismatch path. Use 'Restore Stored DUID' to undo.");
RestoreStoredDUID = Config.Bind("Advanced", "Restore Stored DUID", false, "ONE-SHOT UNDO: on next launch, call WriteDUIDs with the real device id, overwriting any corrupt stored value with a good one. Set back to false after it logs '[CORRUPT] restored'.");
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures — that breadth is deliberate, see CLAUDE.md.");
DisableTelemetry = Config.Bind("Analytics", "Disable Telemetry", true, "Stop the client reporting to third-party telemetry services (ON by default). Covers: Amplitude analytics (every AmplitudeAnalyticsClient.Log* call plus any upload to amplitude.com, so batches queued in earlier sessions can't be flushed later); the data-collection endpoint (any host whose name starts with 'datacollection', e.g. datacollection.recflare.net); and Backtrace crash reports, minidumps and metrics to submit.backtrace.io. Blocked uploads get a synthetic 200 so the client carries on as if they had been accepted. It also asks Unity's own Analytics and Performance Reporting to switch off, but that part is KNOWN NOT TO WORK on this game build — those send from native engine code and the opt-out is refused, so perf-events.cloud.unity3d.com uploads continue; see the [UNITY-TELEMETRY] line in LogOutput.log. Not covered: RudderStack, gamesight, and minidumps sent by the native crash handler on the launch after a hard crash. Set false to let all of it through.");
// Not a patch — Unity's telemetry has a real opt-out, so we just set it. Retried from
// OnSceneLoaded until it takes, since the native setters can refuse this early.
Patches.UnityTelemetryPatch.Apply();
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
@@ -43,6 +67,9 @@ public class Plugin : BasePlugin
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
// No-op once the switches have stuck; must run before the early return below.
Patches.UnityTelemetryPatch.Apply();
// CheatManager boots us out of rooms when it runs, but it's ALSO the DUID service the DI
// container resolves for account creation / login (destroying it removes that service).
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
@@ -51,10 +78,17 @@ public class Plugin : BasePlugin
// each freshly-spawned (active) instance on every load. (GameObject.Find only returns active
// objects, so once deactivated it isn't found again.)
var cheatMgr = GameObject.Find("GameRoot/(Startup)(Clone)/Core Systems/[CheatManager]");
if (cheatMgr != null)
{
if (cheatMgr == null)
return;
// One-shot corruption for testing: must run while the component is still active (before we
// deactivate it below), because it calls the live CheatManager.WriteDUIDs().
if (CorruptStoredDUID.Value && !_corruptDone)
_corruptDone = Patches.CorruptDUIDPatch.CorruptStored(cheatMgr);
else if (RestoreStoredDUID.Value && !_corruptDone)
_corruptDone = Patches.CorruptDUIDPatch.RestoreStored(cheatMgr);
cheatMgr.SetActive(false);
Log.LogInfo("cheatmanager deactivated");
}
}
}
+63 -11
View File
@@ -2,15 +2,21 @@
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the Rec Room client at a self-hosted / private server.
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk (except global-metadata.dat - needed for image signatures). The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning) that would otherwise reject a non-official server.
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk. The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning, image signature verification) that would otherwise reject a non-official server.
> ⚠️ This disables anti-cheat and certificate validation on the client. Use at your own risk.
> ⚠️ This disables anti-cheat, certificate validation, and RSA signature verification on the client. Use at your own risk.
## Projects
[<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/f0b91aa3-49f5-4077-8eb9-5ae676888709" />](https://www.recflare.net)
This plugin powers [RecFlare](https://www.recflare.net) - an open source, cloud-native Rec Room server.
## Safety
Using BepInEx plugins may cause anti-virus scanners or Windows Defender to pick it up as a threat.
If you don't trust the complied .DLL, you can build it yourself.
If you don't trust the compiled .DLL, you can build it yourself.
See https://github.com/djdevin/recnet-plugin#from-source
@@ -21,9 +27,37 @@ See https://github.com/djdevin/recnet-plugin#from-source
| Name-server redirect | `Patches/SendRequestPatch.cs` | Intercepts `BestHTTP` requests and rewrites the host `ns.rec.net` → your configured server. Also provides optional HTTP request/response logging for development. |
| Photon override | `Patches/PhotonPatches.cs` | Replaces the Realtime / Voice / Chat App IDs (and optionally the Photon name server + port) with your own. |
| EAC bypass | `Patches/EACPatches.cs` | Forces EasyAntiCheat "ready" and stubs the challenge-response so the client connects without the official anti-cheat. |
| TLS bypass | `Patches/FuckOffTLS.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
| Promise stub | `Patches/PromisePatch.cs` | Allows custom global-metadata.dat files without the game crashing. |
| TLS bypass | `Patches/DisableTLSPinning.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
| Image signing bypass | `Patches/ImageSigningPatch.cs` | Forces the mscorlib RSA verify to succeed, so images your server serves load without being signed by Rec Room's key. **On by default.** |
| CheatManager handling | `Plugin.cs` | Deactivates the in-game `CheatManager` (which would otherwise boot you from rooms) while keeping it resolvable for account creation / login. |
| DUID mismatch workaround | `Patches/DUIDMismatchPatch.cs` | Forces the device-id mismatch check to "no mismatch" so the Create Account hang (below) is skipped. **On by default**; no-op on healthy machines. |
| DUID diagnostics | `Patches/DUIDProbePatch.cs`, `Patches/CorruptDUIDPatch.cs`, `Patches/DeviceIdResponsePatch.cs` | Investigation tooling for the hang: PlayerPrefs/DUID call logging, deliberately corrupting or restoring the stored id, and rewriting the `deviceId` response in flight. All off by default — see [Configuration](#configuration). |
## The Create Account / DUID hang
Some machines hang forever on **Create Account**. This turned out to be a genuinely nasty one, so it's
worth documenting.
**What happens:** when the client's *stored* device id (DUID) differs from the one derived at runtime,
the client takes a "migration" path — it POSTs to `PlayerReporting/v1/deviceId`, the server answers
`200 {"success":true}`, and then the client **stalls**: it never makes the `create_account` OAuth call
and never persists the new id. Machines whose stored id already matches never take this path, which is
why the bug hits some players and not others (and is hard to reproduce if your own machine is fine).
**The decision point** is `CheatManager.CheckForDUIDMismatch`. Forcing it to return *true* reproduces
the hang on any machine; forcing it *false* skips the whole path. That false-forcing is the shipping
workaround, exposed as the `Suppress DUID Mismatch` config option, which is **on by default**. It's a
no-op on healthy machines (their real check already returns false) and skips the hang on affected ones.
**Still unsolved:** we have not found where the "old" device id in that POST actually comes from. It
survives deleting the entire `HKCU\Software\Against Gravity\Rec Room` registry key, and on the failing
run the local `cm_did_ppk` PlayerPref is never even read — so clearing local storage does **not** fix
it. The leading theory is that it's held server-side (recorded by the server from earlier reports)
and/or cached in memory from a server response, which would make the proper fix server-side. See
`CLAUDE.md` for the full investigation and the diagnostic tooling.
> ⚠️ `Suppress DUID Mismatch` is a workaround: it lets account creation through but does **not** repair
> a genuinely corrupt stored/served id — it just stops the client from acting on the mismatch.
## Requirements
@@ -35,14 +69,18 @@ _Looking for a custom RecNet server?_ Try https://github.com/djdevin/recflare
## Installing
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `7859140924515540835`.
Example: `depotdownloader -app 471710 -depot 471711 -manifest 7859140924515540835`
**You must use this specific version.**
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `6426603215211043630` (the **20230414** build).
Example: `depotdownloader -app 471710 -depot 471711 -manifest 6426603215211043630`
**You must use this specific version.** Rec Room's type and method names are obfuscated and
re-rolled every build, so the patches only bind against the build they were written for.
2. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
3. Launch the game once so BepInEx generates its `config/` folder and the IL2CPP interop assemblies.
Alternatively, use the [RecFlare client](https://github.com/djdevin/recflare-client)
### From release
1. Download a release from [/releases](/releases)
1. Download a release from [Releases](https://github.com/djdevin/recnet-plugin/releases)
2. Drop the `.dll` file into `BepInEx/plugins/`
### From source
@@ -74,7 +112,7 @@ dotnet build
The build validates that `GamePath` is set and that `$(GamePath)\BepInEx\interop` exists, and fails with a clear message otherwise.
A post-build step (the `DeployPlugin` target in the `.csproj`) automatically copies the built `RecNetPatcher.dll` into your Rec Room install's `BepInEx/plugins/` folder after every build. Since `GamePath` already points at your install, you don't need to copy anything by hand — just `dotnet build` and launch the game.
A post-build step (the `DeployPlugin` target in the `.csproj`) automatically copies the built `RecNetPlugin.dll` into your Rec Room install's `BepInEx/plugins/` folder after every build. (The copy will fail if Rec Room is running, since the DLL is locked — close the game and rebuild.) Since `GamePath` already points at your install, you don't need to copy anything by hand — just `dotnet build` and launch the game.
If you need the DLL elsewhere, it's also left in `bin/Debug/net6.0/`.
@@ -92,19 +130,33 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
- `App Id Voice` — Photon Voice App ID.
- `App Id Chat` — Photon Chat App ID.
**[Signing]**
- `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's
private key, so your own server can serve images. **On by default**; leave it alone.
> ⚠️ This forces **all** mscorlib RSA verification to pass, not just image signatures. TLS is
> unaffected (BestHTTP uses its own bundled BouncyCastle).
**[Advanced]**
- `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.
- `Photon NameServer` — custom Photon name server host.
- `Photon NameServer Port` — custom port (`0` uses the default, `4533`).
- `Debug` — verbose HTTP request/response logging (only needed for development)
> ⚠️ Debug logs include **sensitive data** (passwords, auth tokens). Be careful when sharing them.
- `Suppress DUID Mismatch` — skips the Create Account / DUID hang (see above). **On by default**; the
only DUID option meant for normal use. Set `false` only to observe the real mismatch for debugging.
The remaining `[Advanced]` DUID options — `Simulate DUID Mismatch`, `Corrupt Stored DUID`,
`Restore Stored DUID`, `DeviceId Response Override`, `DeviceId Response Status` — are **diagnostic
tools** used to investigate the hang. Leave them at their defaults unless you're debugging it; see
`CLAUDE.md` for what each one does.
## Project layout
| Path | Purpose |
| --- | --- |
| `Plugin.cs` | Plugin entry point, config bindings, Harmony bootstrap |
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon) |
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon, image signing, DUID) |
| `CLAUDE.md` | Developer notes: build gotchas, IL2CPP/interop caveats, and the full DUID-hang investigation |
| `RecNetPlugin.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
| `GamePath.props.example` | Template for your local `GamePath.props` |