From 6a62f0cc1de905b0879ce1020edae55fe8ffe357 Mon Sep 17 00:00:00 2001 From: devin Date: Tue, 14 Jul 2026 13:36:52 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 130 +++++++++++++++++++++++++++++++ Patches/CorruptDUIDPatch.cs | 77 ++++++++++++++++++ Patches/DUIDMismatchPatch.cs | 45 +++++++++++ Patches/DUIDProbePatch.cs | 58 ++++++++++++++ Patches/DeviceIdResponsePatch.cs | 61 +++++++++++++++ Patches/SendRequestPatch.cs | 2 +- Plugin.cs | 31 ++++++-- README.md | 41 +++++++++- 8 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Patches/CorruptDUIDPatch.cs create mode 100644 Patches/DUIDMismatchPatch.cs create mode 100644 Patches/DUIDProbePatch.cs create mode 100644 Patches/DeviceIdResponsePatch.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a4bec52 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# 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 ~150 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(JAPJPGNBMNM)`, `PGECJHKNIEN`). No `using` needed. + +5. **Harmony prefix conventions here:** force a value + `return false` to skip the original (see + `Patches/EACPatches.cs`). For out-params, take a `ref` parameter named exactly as the interop shows + it (e.g. `ref string ALOMDLLNIMD`), plus `ref bool __result`. + +## 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 +$dst = "$scratch\Mono.Cecil.dll" +Copy-Item "$interop\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 ... +``` + +Notes: +- Windows PowerShell 5.1 has **no** `?.` null-conditional operator — use explicit `$x -eq $null` checks. +- String literals (pref keys, endpoints, GUIDs) are in `RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat`. + `grep -a -o -E '[ -~]{4,}' global-metadata.dat | grep -i ` extracts them. +- The BepInEx runtime log is `$(GamePath)/BepInEx/LogOutput.log`. Our plugin logs under the + `RecNet Plugin` source. `[HTTP]`, `[DUID]`, `[DUID-PROBE]`, `[DEVICEID]`, `[CORRUPT]` are our tags. +- PlayerPrefs on Windows live in the registry at `HKCU\Software\Against Gravity\Rec Room`, value names + are `_h`, values are `REG_BINARY`. CodeStage AntiCheat stores strings *obscured* + (XOR-encrypted), so a stored id will not appear as plaintext in registry or files. + +## Case study: the Create Account / DUID hang + +The gnarliest bug so far; the diagnostic tooling for it still lives in the repo. Summary: + +- **Symptom:** on some machines Create Account hangs. Log shows a `PlayerReporting/v1/deviceId` POST + returning `200 {"success":true}`, after which the client never calls the `create_account` OAuth and + never persists the id (`WriteDUIDs` never runs). +- **Trigger:** a device-id **mismatch**. `CheatManager.CheckForDUIDMismatch(out string)` returns true + when the stored id differs from `SystemInfo.deviceUniqueIdentifier`. True → migration path → POST → + hang. Machines whose stored id matches never take the path. +- **Stored id:** PlayerPrefs key `cm_did_ppk` (registry `cm_did_ppk_h3478365449`), CodeStage + ObscuredString-encoded. `CheatManager.WriteDUIDs()` writes it, `ClearDUIDs()` deletes it. In our + interop these are **instance** methods (a dnSpy dump showed them static — build difference again). +- **Two surprises:** + 1. Deleting the registry value did **not** change the `oldDeviceId` in the POST, and the probe + showed no `cm_did_ppk` read that session — i.e. the "old" id is **not sourced from local + PlayerPrefs** on the failing path. Consequence: **a registry-reset script does not fix it.** + 2. `game callback attached = True` on that request → the client is genuinely waiting on the + response. But the real server already returns `{"success":true}` at 200 and it still hangs, so the + accepting response shape (if one exists) is something more specific. + +> ### ⚠️ OPEN QUESTION — where does the old DUID actually live? +> We have **not** found the source of the `oldDeviceId` value. It survives a full delete of the +> `HKCU\Software\Against Gravity\Rec Room` registry key, it does not appear as plaintext anywhere in +> `AppData/LocalLow/Against Gravity/Rec Room`, and on the failing run the `cm_did_ppk` PlayerPref is +> never read. So `cm_did_ppk` is *a* copy but not the one that seeds the migration POST. Leading (but +> unconfirmed) theory: it's held server-side by the archival server, which recorded it from prior +> POSTs, and/or cached in memory from a server response. **Until this is found, the only reliable fix +> is `Suppress` (client) or correcting the value server-side — not clearing local storage.** Next +> steps to try: inspect what the recflare backend stores/returns for the account's device id; dump the +> `HTTPCache` entries decoded (not plaintext); trace who sets the field the POST body reads from. +- **Working client-side workaround:** force `CheckForDUIDMismatch` → false (skips the migration path + entirely). Config: `Suppress DUID Mismatch = true`. +- **Proper root-cause fix:** server-side — make the endpoint stop reporting a stale `old` id (so + `old == new`, no mismatch) or return whatever the client needs to proceed. + +### Diagnostic knobs (all in `[Advanced]`) + +`Suppress DUID Mismatch` defaults **true** (it's the shipped fix). Everything else defaults **false** — +those are investigation tools, not normal config. See the patch files for details. + +| Config key | Patch file | What it does | +| --- | --- | --- | +| `Suppress DUID Mismatch` | `DUIDMismatchPatch.cs` | **The fix (default true).** Force `CheckForDUIDMismatch` → false. | +| `Simulate DUID Mismatch` | `DUIDMismatchPatch.cs` | Force it → true. Reproduce the hang without a corrupt value. | +| `Corrupt Stored DUID` | `CorruptDUIDPatch.cs` | One-shot: write a truncated id via `WriteDUIDs` (spoofing `SystemInfo.deviceUniqueIdentifier`) to create a *genuinely* corrupt stored value. | +| `Restore Stored DUID` | `CorruptDUIDPatch.cs` | One-shot undo: `WriteDUIDs` with the real id. | +| `DeviceId Response Override` / `Status` | `DeviceIdResponsePatch.cs` | Rewrite the `deviceId` response body/status in-flight to probe what shape the client will accept. | +| (probe) | `DUIDProbePatch.cs` | Logs every PlayerPrefs get/set and the `WriteDUIDs`/`ClearDUIDs` calls — `WriteDUIDs() called` is the "client accepted the response" signal. | + +`DUIDMismatchPatch` has three modes: `Simulate` → force true, `Suppress` → force false, neither → +pass through to the real check (needed to observe a genuinely corrupt stored value). + +**The diagnostic patches should not ship in a release build** — strip them (or at least confirm their +knobs default false: `Simulate`, `Corrupt`, `Restore`, `DeviceId Response Override`) before cutting a +release. `Suppress DUID Mismatch` is the real fix and defaults **true**, so it stays on. diff --git a/Patches/CorruptDUIDPatch.cs b/Patches/CorruptDUIDPatch.cs new file mode 100644 index 0000000..baef361 --- /dev/null +++ b/Patches/CorruptDUIDPatch.cs @@ -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() ?? cheatMgrGo.GetComponentInChildren(); + 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() ?? cheatMgrGo.GetComponentInChildren(); + 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; + } +} diff --git a/Patches/DUIDMismatchPatch.cs b/Patches/DUIDMismatchPatch.cs new file mode 100644 index 0000000..e806545 --- /dev/null +++ b/Patches/DUIDMismatchPatch.cs @@ -0,0 +1,45 @@ +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")] + private static bool Prefix(ref string ALOMDLLNIMD, ref bool __result) + { + if (Plugin.SimulateDUIDMismatch.Value) + { + ALOMDLLNIMD = SimulatedStoredDeviceId; + __result = true; + Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}"); + return false; + } + + if (Plugin.SuppressDUIDMismatch.Value) + { + ALOMDLLNIMD = string.Empty; + __result = false; + Plugin.Log.LogInfo("[DUID] mismatch check forced to false (suppressed)"); + return false; + } + + // Pass through to the real check. + return true; + } +} diff --git a/Patches/DUIDProbePatch.cs b/Patches/DUIDProbePatch.cs new file mode 100644 index 0000000..42670a8 --- /dev/null +++ b/Patches/DUIDProbePatch.cs @@ -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 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, ""); + + [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"); +} diff --git a/Patches/DeviceIdResponsePatch.cs b/Patches/DeviceIdResponsePatch.cs new file mode 100644 index 0000000..aefd503 --- /dev/null +++ b/Patches/DeviceIdResponsePatch.cs @@ -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( + (Action)((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(Encoding.UTF8.GetBytes(body)); + resp.dataAsText = body; + resp.StatusCode = status; + Plugin.Log.LogWarning($"[DEVICEID] response overridden -> {status} {body}"); + } + + original?.Invoke(req, resp); + })); + } +} diff --git a/Patches/SendRequestPatch.cs b/Patches/SendRequestPatch.cs index c02dfc5..5d6ec23 100644 --- a/Patches/SendRequestPatch.cs +++ b/Patches/SendRequestPatch.cs @@ -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) { diff --git a/Plugin.cs b/Plugin.cs index f017b2f..f806755 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -22,6 +22,14 @@ public class Plugin : BasePlugin public static ConfigEntry PhotonHostname { get; private set; } public static ConfigEntry PhotonPort { get; private set; } public static ConfigEntry Debug { get; private set; } + public static ConfigEntry SimulateDUIDMismatch { get; private set; } + public static ConfigEntry SuppressDUIDMismatch { get; private set; } + public static ConfigEntry CorruptStoredDUID { get; private set; } + public static ConfigEntry RestoreStoredDUID { get; private set; } + public static ConfigEntry DeviceIdResponseOverride { get; private set; } + public static ConfigEntry DeviceIdResponseStatus { get; private set; } + + private static bool _corruptDone; public override void Load() { @@ -35,6 +43,12 @@ 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."); Harmony.CreateAndPatchAll(typeof(Plugin).Assembly); @@ -51,10 +65,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) - { - cheatMgr.SetActive(false); - Log.LogInfo("cheatmanager deactivated"); - } + 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"); } } diff --git a/README.md b/README.md index 1fcf43c..e8baa6e 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,36 @@ 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. | +| TLS bypass | `Patches/DisableTLSPinning.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. | | 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. | + +## 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 @@ -76,7 +103,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/`. @@ -100,13 +127,21 @@ Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed: - `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, 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` |