Files
Radicade/docs/2023-AUGUST-BUILD.md
T
2026-10-16 23:33:13 -07:00

230 KiB

August 2023 client migration — local work, not yet playable

Target: C:\RecRoom\2023-08-29T06-53-33Z. The version stored in this client's globalgamemanagers is 20230825, and its Unity version is 2020.3.33f1.

Implemented

  • Server default game version is 20230825 (earlier supported versions remain).
  • The metadata patcher recognizes this release's shuffled 256-byte header using a SHA-256 fingerprint, excluding only the two fields it intentionally updates. Unknown headers still fail closed.
  • Literal table offset/size fields are at 0x2C/0xBC; literal data offset/size fields are at 0xC8/0xC0. All 52,189 literal records fit the original blob.
  • Rewrite verification checks unchanged literal contents and all original bytes outside the literal records and the two edited header fields.
  • The patch marker records 20230825 rather than the old hardcoded March version.
  • Local development exposes HTTPS on localhost:3030 and HTTP on 127.0.0.1:3031, and advertises HTTPS service URLs.

Original client metadata and boot configuration are preserved beside the files as .orig. No March BepInEx files or generated interop assemblies were copied into the August client. No Git push or remote server deployment was performed.

Verification and remaining blocker

The patcher's real-file verification passes, including a second dry run with zero changes. Server startup succeeds and the version endpoint returns ValidVersion=0 / VersionStatus=0. The 38 targeted VersionCheck, Nameserver and SplitTestCompatibility tests pass.

Initial startup failures (before the August plugin):

  • HTTP: the client's request constructor throws Exception: Invalid URI scheme.
  • Direct desktop launch with HTTPS: embedded BestHTTP/BouncyCastle rejects the local certificate with user_canceled(90) / nameserver error summer.
  • EAC bootstrapper launch: native crash near Connecting to RecNet.

The new 2023Patch/RadicadeAugustClient project builds against August interop and replaces the March plugin locally. It installs embedded TLS, nameserver, image signature key, hub authentication, and August EAC challenge hooks without March-only UI patches. The old DLL is preserved as BepInEx/plugins/RadicadeEacBypass.dll.march-backup; the old configuration is preserved as com.radicade.eacbypass.cfg.before-august.

Live verification: August Client 2.0.0 loads without a plugin-load exception, TLS accepts the configured localhost endpoint, the game fetches the nameserver, /api/gameconfigs/v1/all and /api/config/v1/amplitude successfully, and logs the Steam profile. It then reports Launch validation failed. Is Rec Room installed correctly? Login and room joining remain unverified. No broad launch-validation or error-suppression patch was installed.

Launch-validation root cause and bypass

Native analysis with the tools/AugustInterop disassembler traced the error:

  • NDIIPBKGGIG::CBNCPLACNIE(15) is the only call site producing the error screen; value 15 is JKBPKDAGEHN.LAUNCH_VALIDATION_FAILED.
  • Its sole caller chain is NFPDGJNDFOF::GBLLEJBCFGI -> NFPDGJNDFOF::GBAMNAKGEGD.
  • GBAMNAKGEGD is the boot validator invoked from BootSequence::DFCEILLEFOK via ADJOODNBCKG::BEFDPLAELIH. It resolves an obfuscated static-data file reference, opens the referenced file, parses its PE headers, and computes a SHA-256 over selected sections in 0x400-byte chunks on a background thread, then hard-rejects (CBNCPLACNIE(15)) when the hash does not match the expected value baked into GameAssembly.dll's static data.
  • The check is not satisfiable in a patched installation (BepInEx loader metadata redirect and localhost server wiring), so the plugin now installs AugustLaunchValidatorPatch, a prefix on ADJOODNBCKG.NFPDGJNDFOF::GBAMNAKGEGD that resolves the boot completion promise (PFDOLDLEIBA.OLKGFACPIPC, with a direct handler-list fallback) and skips the native body. It must be registered via harmony.PatchAll in the plugin Load method — an earlier attempt defined the patch class but never registered it, which is why the error screen still appeared once.

Rebuild and reinstall RadicadeAugustClient.dll after this change; the log should show Bypassing native launch validator before boot proceeds.

Build validation: August plugin builds with zero warnings/errors. The March plugin cannot be compiled unchanged against August's renamed types.

The final client target is https://localhost:3030. Start the server with dotnet run --project Server/Server.csproj. For diagnostics, direct desktop launch uses RecRoom.exe +forcemode:screen; it now passes TLS and encounters the launch-validation blocker described above.

BepInEx interop generation fixed

The installed BepInEx 6.0.0-be.697 initially failed Cpp2IL's magic-number check. The original .orig metadata has the same shuffled header: this is the release's format, not evidence of download corruption or a URL-patch error.

tools/AugustInterop/normalize.cjs creates a separate tooling copy with a standard v27 header. It also repairs the 305 synthetic <Module> records, whose name indices and generic-container indices otherwise break assembly generation. Only verified module records are repaired. This copy must NEVER replace the game's global-metadata.dat; the native runtime expects the custom format.

The installed loader reads global-metadata.interop.dat for Cpp2IL. Its original is backed up at BepInEx/core/BepInEx.Unity.IL2CPP.dll.before-august-interop. The loader modification changes only RunCpp2Il's metadata filename literal.

Verified with the actual August GameAssembly.dll: 42,537 types and 283,224 methods parsed, native registrations resolved, interop generation completed, 335 files written including the hash and caches, and the live loader logged Chainloader startup complete. Interop generation was first verified with no plugins; the August connection plugin was then built and installed separately.

See tools/AugustInterop/README.md for reproduction and rollback.

August login "code: block" root cause and fix

Symptom: title screen showed Cached/Manual Login failed with: Unable to login (code: block) right after a POST /matchmaking/player/login log line whose [Net] line prints when the request is queued, not when the response lands.

Server-side truth (server-console.log + PlayerDataDebug): the login POST actually returned 200 both times. The real 405 came from POST /api/PlayerReporting/v1/moderationBlockDetails, which the server only mapped as GET; Kestrel answered the POST with 405 Method Not Allowed. The August login flow runs its moderation-block check inside the login promise, and a failed check maps to JKBPKDAGEHN.LOGIN_CODE_BLOCK - hence "code: block", which reads like a moderation block but was a route mismatch.

Fix: added [HttpPost("v1/moderationBlockDetails"), Authorize] GetBlockDetailsPost() in Server/API/Moderation/PlayerReportingAPI.cs, delegating to the existing GET action so both verbs serve the identical body. Verified live: POST now returns 401 without a token (auth layer reached, no more 405) and the GET shape is unchanged for older clients. Server rebuilt and restarted on 3030/3031.

Remaining non-fatal August 404s (client continues without them): GET /matchmaking/player/qos, GET /matchmaking/player/connection-info, GET /chat/thread/party, GET /cdn/room/. RudderStack analytics cannot resolve its host and self-disables after 15 failures.

Verified in-game after the fix: POST /matchmaking/player/login -> 200, POST /matchmaking/player/exclusivelogin -> 200, POST /api/PlayerReporting/v1/moderationBlockDetails -> 200 (86-byte empty block-details body), hub WebSocket connected, and the avatar spawned in the Dorm. The August 2023 build boots fully into the Radicade server. The RSA neutralizer correctly passes through verification calls signed with non-p1 keys (observed on a cdn/sigs/... fetch).

Remaining August 404s fixed

The four 404s the August client hit after login were implemented, using the recflare reference server's response shapes where documented:

  • GET /matchmaking/player/qos - unauthenticated BARE ARRAY of QoS probe regions (id + host:port address); the client pings these and reports back through PUT /matchmaking/player/photonregionpings (already a no-op ack).
  • GET /matchmaking/player/connection-info - authed { success, value, error } envelope carrying a freshly minted HS256 photonAuthToken (sub/rn.platid/ rn.plat/rn.deviceclass/rn.env claims, 1h TTL, signed with the server JWT key), the configured Photon app ids, photonRegion, and the photonRoomId of the caller's current room instance (presence first, roomInstanceId query param as fallback). The experiments block mirrors the reference verbatim; shouldUseGameServerNetworking stays false.
  • GET /chat/thread/party - empty object (recflare's documented stub: parses as "no party" without failing the client's deserializer).
  • GET /platformnotifications/crm/me/config - empty object (same stub convention; the 404 here raised one console error per login).

Photon app ids and region are configurable (Radicade:Photon:RealtimeAppId, VoiceAppId, ChatAppId, Region in appsettings) and default to empty ids / us region, exactly as recflare behaves with no Photon configured: the client is told empty ids and connects to nothing. A deployment that runs a Photon server sets the three ids and the client connects normally.

Corrections after the first live run with those routes (all observed in BepInEx LogOutput.log):

  • chat/thread/party: the August client deserializes this as a LIST - the empty-object stub made it throw expected:'[', actual:'{'. Answers a bare empty array now.
  • platformnotifications/crm/me/config: the empty-object stub made the manager throw Deserialization returned null. The client parses a counted envelope here (same as config/categories), so it answers { Results: [], TotalResults: 0 } now.
  • connection-info with empty Photon app ids is WORSE than a 404: the client's GetRegions call gets Empty application id (ReturnCode -2) and Photon drops the session with InvalidAuthentication. The ids must be the ones the client actually uses - they live in the game's BepInEx/config/com.radicade.eacbypass.cfg under [Photon] and are now set in appsettings (22dda1bb-... realtime, 82766593-... voice, e8ea33f3-... chat), matching the AppIdOverride the March plugin was already forcing.

2026-09-27: the same empty-app-id trap, hit again on a second host

Moving the client to the public server reproduced the connection-info failure above, from the other direction. The loader reported GOING TO ^DormRoom 0% PREPARING.. and sat there; the server looked healthy throughout (presence, matchmaking and the room routes all 200), and the client's own log showed the real story:

[Network.Photon.LoadBalancingClient] Queued Network action failed due to exception: NullReferenceException
[RoomLoad.RoomManager.PresenceUpdates] Error joining room instance RoomInstance - ID:21516 DORM_ROOM:25/32
JOKAIBMCFDA::Failed to connect to region "us"
  IKDHNNILPMJ::Unexpected Photon Disconnect due to cause: InvalidAuthentication

with Empty application id in Player.log exactly once and InvalidAuthentication eight times, retrying roughly every 7 s. The cause was config, not code: the ids were set in appsettings.Development.json and not in appsettings.Production.json, so a host running the Production environment kept the DBConfig defaults ("") and answered connection-info with an empty photonRealtimeAppId. Photon's GetRegions then fails with ReturnCode -2 and drops the session, which is the documented behaviour quoted above - connection-info with empty ids is worse than a 404.

Two changes so the next environment does not have to be diagnosed from a loading screen:

  • Server/appsettings.Production.json carries the Radicade:Photon block (same app as the dev config and the plugin's AppIdOverride).
  • Server/Program.cs prints either Photon: realtime app id configured, region 'us' (voice set, chat set). or a full WARNING naming the four keys to set, at startup. The warning path is verified by booting with Radicade__Photon__RealtimeAppId= set to empty.

Note the failure is silent by design from the server's point of view: nothing 500s and no route errors. Anything that shows up as "the room load hangs at 0%" should have the client's Photon state checked first, because the REST surface will look perfect.

Run-3 hard crash root cause: RSA passthrough access violation (fixed)

The fully-green-server run (20:25) still crashed the client on the loading screen with the infamous DILGBLCELMP: Exception of type 'DILGBLCELMP' was thrown. at DLHKINDAIHK+MICFNEHOAMC.MoveNext() right after GET /platformnotifications/crm/me/config (200) and [ImageSig] RSA verify called #1 with non-p1 key - passing through.

That obfuscated exception was a red herring: the real fatal is in BepInEx ErrorLog.log:

Fatal error. System.AccessViolationException
  at Il2CppInterop.Runtime.IL2CPP.il2cpp_runtime_invoke(...)
  at DynamicClass.DMD<RSACryptoServiceProvider::VerifyHash>(...)

Chain of events:

  1. After exclusivelogin, the client downloads the file-integrity manifest GET /cdn/sigs/6fae9a40-edf5-44c5-a263-36d7b5b4a91f (served since an earlier blocker) and starts verifying the listed game files' signatures.
  2. It verifies with the game's OWN embedded RSA key (not p1.rec.net), which reached RsaVerifyNeutralizerPatches.VerifyPrefix's deliberate "non-p1 -> pass through to original" path.
  3. Invoking the il2cpp original through the Harmony DMD re-implementation access-violates (interop cannot safely re-enter that native method). Unity surfaces the AV as the unmappable DILGBLCELMP il2cpp exception inside the sigs-check async chain (DLHKINDAIHK+MICFNEHOAMC awaited the RecNet HTTP task, then died during verify) - hence the crash after the crm 200 line even though crm/party responses were parsed fine.

Fix (client plugin, RadicadeAugustClient.Plugin.cs): the RSA verify prefix now forces true and skips the original for EVERY intercepted verify, p1 key or not - the passthrough path is never taken because it is un-invokable. Rebuilt (dotnet build 2023Patch/RadicadeAugustClient/... -c Release, 0 errors) and deployed to BepInEx/plugins/RadicadeAugustClient.dll (2023-09-23 21:07).

Lesson: on this build, any Harmony-prefixed il2cpp method whose prefix returns true (executes the original) risks the DMD AV. Neutralizer-style patches must always skip the original when the original's failure is the thing being patched.

Run-4 follow-up: file check fails gracefully -> manifest parser bypassed (fixed)

After the RSA-forcing fix, the client survived the sigs manifest (RSA verify forced true, DILGBLCELMP now a caught/logged error) but the checker (KKHDHILFOGO) threw while PARSING the placeholder manifest: IndexOutOfRangeException in KKHDHILFOGO.PFNKMGPPFGP -> "File check failed KKHDHILFOGO+LPIKGKOLBJH: Error validating file Failed to process verification file due to internal exception" -> POST /api/PlayerReporting/v1/hile -> PreparingToExit -> clean shutdown. Not a crash, but still an exit.

The real manifest format encodes per-file hashes inside the RSA-signed blobs, so no manifest we can serve without Rec Room's private key parses.

Fix (client plugin): new FileCheckManifestParserPatch prefixes KKHDHILFOGO.PFNKMGPPFGP and returns an empty file->hash dictionary - with no files listed the per-file verify loop completes as success. Rebuilt and deployed (2023-09-23 ~21:20). Look for [FileCheck] manifest parser bypassed in the next BepInEx log.

Run-5: manifest parser patch is NOT viable -> sigs route reverted to 404 (final)

The FileCheckManifestParserPatch attempt failed differently: HarmonyX's DMD cannot compile the detour for KKHDHILFOGO.PFNKMGPPFGP's out-byref generic signature (Dictionary<string, Il2CppStructArray<byte>>& out) and throws InvalidProgramException: Common Language Runtime detected an invalid program at (il2cpp -> managed) PFNKMGPPFGP detour time - the checker then fails with NullReferenceException and the same hile report + PreparingToExit follows.

Empirical summary across runs (all 2023-09-23):

cdn/sigs response client outcome
404 (run 1) reaches the Dorm (known-good)
200 placeholder (run 4) IndexOutOfRange in parser -> hile -> exit
200 + parser patch (run 5) InvalidProgramException (DMD) -> hile -> exit

Conclusion: the check TOLERATES a 404 and fails hard on any 200 we can produce. The parser patch was removed from the plugin and SigsCDN.cs now deliberately answers 404 (with the full analysis in its header comment). Plugin rebuilt and redeployed; server rebuilt, restarted (PID 4768), verified: sigs 404, gameconfigs/qos 200. The RSA verify neutralizer stays for p1-keyed image signatures; the DMD passthrough AV lesson from run 3 still applies to any new patch that would execute an il2cpp original.

Run-6 correction: 404 is NOT tolerated; final sigs/RSA combination

Run 6 disproved the "client tolerates a 404" theory from run 5's writeup: OBHEMJEJNMK: HTTP Error 404 -> "File check failed ... Dowload failed" -> hile report -> PreparingToExit. Reconciling ALL six runs gives the real rules of the file check (KKHDHILFOGO):

  1. Missing manifest (404) -> fatal (run 6)
  2. Manifest present, non-p1 verify PASSES through -> DMD access violation to il2cpp original (run 3, flaky)
  3. Manifest present, non-p1 verify forced TRUE -> parser decodes the fake 16-byte blobs as a hash table -> IndexOutOfRange (run 4)
  4. Manifest present, non-p1 verify forced FALSE -> parser skips blob decoding, check completes -> known-good (run 1, Dorm reached)
  5. A client-side parser prefix is impossible (DMD InvalidProgramException, run 5) - the out-byref generic signature is un-detourable.

FINAL combination (all live 2023-09-23 ~21:35): SigsCDN.cs serves the placeholder manifest again (with byte-range support) AND RsaVerifyNeutralizerPatches now answers __result = isP1 - TRUE only for the embedded p1.rec.net key (image signatures, boot-critical), FALSE for every other key (the file check, which must fail cleanly to skip blob decoding). Never passthrough (AV), never unconditional TRUE (IndexOutOfRange). Plugin rebuilt/deployed, server rebuilt/restarted (PID 42708), verified: sigs 200 placeholder, gameconfigs/qos 200. Watch for [ImageSig] RSA verify #N (game key) forced false in BepInEx.

Run 11 outcome (22:36) + Run 12: exit-state veto (deployed 22:55)

Run 11: suppression armed, file-check bypass fired, Dorm load started at 11.13s - then the process DIED mid-scene-load at 16.26s with no exception, no hile POST, no exit sequence. The IJHOPKJFKBI prefix crashed the process inside the DMD stub BEFORE the prefix body ran: the scanner thread is a native, unattached thread (CallerCount(1), no managed callers), and il2cpp detours on such threads access-violate - the run-3 VerifyHash AV class. winhttp.dll cannot be dodged at the source: it is the EAC-bypass door DLL (the only non-stock DLL in the game root) and loads BepInEx itself.

Run 12 therefore ALLOWS the hile report to happen (HileAPI logs it at info) and vetoes the MANAGED exit state machine instead: CCKNJCHHBLL.KALEFEDAHCC (AppExitState setter, enum Running/PreparingToExit/ ReadyForExit) is prefixed; transitions OUT of Running are blocked for 120s after load, then pass through. The observed shutdown always begins with the managed "AppExitState changed: Running => PreparingToExit" line, so this cuts the shutdown off at its head from attached managed-thread territory where detours are proven safe. Alt+F4 still works (native path).

Run 12 markers: [ExitVeto] blocked AppExitState -> PreparingToExit at ~16s in BepInEx, hile POST visible server-side but session SURVIVES, room load completes, player spawns in the Dorm. If the game still exits: the scan thread exits natively without the managed transition - next lever is thread-freezing or attacking the scan itself.

Run 10 outcome (22:31): hile suppression DID NOT FIRE - registration bug, fixed

Run 10 replayed run 9's ending exactly: file-check bypass fired, Dorm room load ran 6.1s, hile POST at ~18.3s, PreparingToExit 160ms later, room load cancelled. No [Hile] suppressed line, no armed startup line. Cause: HileReportSuppressionPatch was added as a class but never registered - this plugin patches per-class (harmony.PatchAll(typeof(...))) and the new class was missing from the Awake chain. Fixed + startup log line "[Hile] report suppression armed" added; deployed 22:34:43.

Server-side wins confirmed in run 10: gameserverregionpings PUT no longer 404s ("Failed to upload cluster pings" gone); curatedlists 404 gone but the client now logs non-fatal "Deserialization returned null" + "No such list" for GET /lists/curatedlists/bulk?id=17859340 - the [] shape is tolerated but wrong; may want a Results/TotalResults envelope or a list object later (NOT the current blocker).

Run 11 markers: [Hile] report suppression armed at startup, then [Hile] suppressed report 0 (Obscured) message='winhttp.dll' at ~18s, NO POST .../v1/hile, no PreparingToExit, player spawns in the Dorm.

Run 9 outcome (22:16): file check DEAD, new blocker = native module scan (winhttp.dll)

The both-entries bypass WORKED: [FileCheck] entry bypassed fired after exclusivelogin, no GET /cdn/sigs, no game-key RSA verify, no "Signatures don't match!". Boot went deeper than ever: matchmake/dorm -> room 25/32 instance 20830 -> Photon connect -> LoadScene(dormroom2) -> Initial Room Load '@TestPlayer's Dorm' completed its 5.3s flow - then the session died: hile POST at 20.48s, PreparingToExit at 20.64s, room load cancelled.

The hile body decoded server-side: Type=Obcured Message=[0:winhttp.dll]

  • the native module scanner flags our EAC-bypass proxy DLL (game root, 26KB, deployed 15:18) as an obscured module. The hile enum (MEHDFJHKLBK. JBLJIENBBHK) decodes [0:...] = (Obscured, "winhttp.dll"). Same pattern in the short 22:15:40 session.

Also fixed (server, 22:29): GET /lists/curatedlists/bulk -> [] (was 404, caused one "Deserialization returned null") and PUT /matchmaking/player/gameserverregionpings -> {} (was 404, "Failed to upload cluster pings").

Run 10: hile funnel suppression (deployed 22:29)

Client: HileReportSuppressionPatch - prefix on KDJDKJNPELI.IJHOPKJFKBI(MEHDFJHKLBK.JBLJIENBBHK, string) (CallerCount(1) funnel for EVERY hile report; enum Obscured=0/Time=1/Inject=2/.../ AppData_Boot_InvalidSignature=310/...) returns false and logs the report; finalizer on ModerationManager.BJGDALDIOGI is insurance for paths skipping the static funnel. No hile report is legitimate in a Radicade session. Server: HileAPI log downgraded warning -> info.

Success markers: [Hile] suppressed report 0 (Obscured) message='winhttp.dll' in BepInEx, no POST .../v1/hile in server log, no PreparingToExit, room load 25/32 completes and the player spawns in the Dorm. If a DIFFERENT hile type appears later, the suppression log will name it.

Run 9: entry patch v2 - both entries (deployed 22:08)

Run 8 (21:54) failed exactly like run 7: no [FileCheck] entry bypassed line, GET /cdn/sigs/... still ran, RSA verify #1 (game key) forced false, then "Signatures don't match!". The public wrapper GGIFLDPHCIP is never invoked. The decomp explains it: GGIFLDPHCIP is marked CallerCount(0) (dead public wrapper) while the private twin GOCCLPJDJIC (same signature) is CallerCount(3); the no-arg NPIILCCIPNF is also CallerCount(0), so it needs no patch. FileCheckEntryPatch now uses TargetMethods() to prefix BOTH entries and returns the already-resolved success promise (PFDOLDLEIBA.PLJINLOCHGM(KKHDHILFOGO.BJGODLEIDGE.NOMPJGOIAEB(...))). No download, no parsing, no RSA verify; the consumer sees a resolved MCFFIFIPNKE<BJGODLEIDGE> exactly like a passing check produces. Success markers for run 9: [FileCheck] entry bypassed - resolved success promise returned in BepInEx, and NO GET /cdn/sigs / no RSA verify / no POST .../v1/hile; boot should continue past the file check into the Dorm.

Run-7/8: forced-FALSE also fails; final bypass = resolved success promise (deployed)

Run 7 (__result = isP1, non-p1 -> false, manifest 200) failed with an explicit validation error: File check failed KKHDHILFOGO+LPIKGKOLBJH: Error validating file verification.sig: Signatures don't match! at KKHDHILFOGO.AFHNPNGLCML called from the parser. The complete matrix now (six live client outcomes, one theory):

  • 404 manifest -> "Dowload failed" (run 6)
  • verify false -> "Signatures don't match!" (run 7)
  • verify true -> blob decode IndexOutOfRange (run 4)
  • verify passthrough -> flaky DMD access violation (run 3)
  • parser prefix -> impossible, DMD InvalidProgramException (run 5)
  • run-1 "Dorm reached" -> actually a race: the flaky AV path returned native false before the AV could trigger; the run-1 theory of "false skips blob decoding" is dead.

Conclusion: NO server response + verify-result combination passes this check. DILGBLCELMP is the checker's own exception class (KGABAOLHEHL-derived, string ctor), logged caught in every run - it is a symptom, never the cause.

Run-8 hypothesis before the decomp check: patch the public entry KKHDHILFOGO.GGIFLDPHCIP(string, byte[], byte[], long, string) (DMD-safe parameter profile) and return an already-resolved success promise. Run 8 disproved it (see Run 9 above); the fix now covers both entries.

The RSA neutralizer keeps forcing TRUE only for the p1 key (image signatures); with the entry bypass active the file check never reaches RSA verify at all.

Run 12: AppExitState veto - process survives but zombie (deployed 22:55)

Client: ExitStateVetoPatch - prefix on private setter CCKNJCHHBLL.KALEFEDAHCC (nested enum CCFIKLBABNB), blocks any transition out of Running for 120s after load, then allows. Safe to detour from managed threads (fired dozens of times, no crash). HileReportSuppressionPatch fully removed (run-11 lesson stands: NEVER detour KDJDKJNPELI.IJHOPKJFKBI).

Outcome: the veto WORKS as a patch ([ExitVeto] blocked ... PreparingToExit right after the hile POST at 17.6s) but is INSUFFICIENT. The shutdown path does not run through AppExitState: immediately at hile time the client also cancelled the room load ("Room load to 20833 cancelled" AFTER the 6s load flow completed), tore down EAC ("[EAC] Tearing down and releasing runtime"), POSTed logout + reportjoinresult, and threw OBHEMJEJNMK: Aborted. Then an infinite ~1.35s loop: NRE + "An error occurred" + EAC teardown + veto-blocked PreparingToExit AND ReadyForExit, from 17.6s to 65.6s+. VoiceClient timed out after 30s; Backtrace "Cannot resolve destination host". Result: alive-but-unusable zombie. The setter's CallerCount(3) matches the three state drives visible in the loop.

Conclusion: the exit has at least two arms - the AppExitState machine AND a parallel shutdown initiator (EAC teardown / room-load cancel / Photon disconnect). Vetoing the state machine alone wedges the process.

Run 13: server-side hile-response experiment (pending)

Untested hypothesis: the native exit fires in response to the hile POST RESPONSE (exit began ~160ms after the POST completed in runs 9/10/12). HileAPI.PostHile is now env-selectable via RADICADE_HILE_BODY: ok-true (default, unchanged shape) | ok-false | nocontent (204) | servererror (500). Test pins ok-* to OkObjectResult+bool; 204/500 are diagnostic-only.

Procedure: kill Server.exe (PID 20224 at 23:00), dotnet build, start with RADICADE_HILE_BODY=ok-false, relaunch client, compare:

  • Exit GONE -> the native scanner waits on the server verdict; tune the response (then try nocontent/servererror to map the contract).
  • Exit STILL at ~17.6s -> trigger is client-local; next step is decomp of the managed exit initiators (CCKNJCHHBLL.BEFDPLAELIH/GIEEBIOOFML/ GBGMBMGDGGD, all run on attached threads) and prefixing them, or native handling of the scanner thread (run-11 lesson: do NOT detour the funnel).

Run 13: hile-response experiment - exit is CLIENT-LOCAL (23:11)

Server returned Ok(false) for the winhttp.dll hile report (verified in server-console.log: "Hile experiment: returning Ok(false)"). Result: the client STILL exited - same signature as run 12 (veto blocks, EAC teardown loop, OBHEMJEJNMK Aborted, ~94s death). The server response does NOT drive the exit.

Two new facts:

  1. The IMMEDIATE post-report exit was skipped. Run 12: exit began 160ms after the hile POST and killed the room load. Run 13: room load COMPLETED (FinishRoomLoad 21.5s), spawn completed ~23s, and the player had ~30s of real gameplay before the first PreparingToExit attempt at 52.5s. First-time NRE + "InvalidOperationException: Cannot error an already completed promise" at 32s looked suspicious but did not kill.
  2. The 52.5s exit is the same driver as the 17.6s exit (veto + EAC + Aborted loop). Interpretation: the native scanner re-scans on its own schedule; with Ok(false) the first post-report exit did not fire, but a later scan still flagged the module and restarted the shutdown.

Conclusion: stop DETECTION, not the shutdown. Next lever: make winhttp.dll invisible to the module scan itself.

Run 14: PEB module hiding - hide winhttp.dll from the scanner (deployed 23:27)

Client: PebModuleHider.HideModule("winhttp.dll") at plugin Load (before any Harmony patching). Walks PEB->Ldr->InLoadOrderModuleList, matches BaseDllName (x64 offset 0x58 from the InLoadOrder node; 0x60 was wrong - FullDllName at 0x48 is a 16-byte UNICODE_STRING), unlinks the entry from all three public lists (InLoadOrder / InMemoryOrder / InInitializationOrder). Pure user-mode pointer writes on the main thread - no detours, no scanner-thread contact, no DMD. Module ENUMERATION (Toolhelp, EnumProcessModules, PEB-walks) no longer sees the DLL; module RESOLUTION (LoadLibrary/GetModuleHandle) is untouched (loader hash tables), so the BepInEx door keeps working. AllowUnsafeBlocks enabled.

Server: hile restored to canonical Ok(true) (ok-true, default env) so run 14 tests exactly one variable. Server PID 34420.

Success markers: [ModuleHide] winhttp.dll unlinked from PEB module lists in BepInEx at startup, NO POST /api/PlayerReporting/v1/hile in the whole session (the scanner finds nothing to report), no PreparingToExit, room load completes, spawn, stable gameplay past 2min. If hile STILL appears, the scanner is not PEB-based (try hiding via handle-table wipe / ImagePath alternate next). Keep ExitStateVetoPatch armed as belt-and-suspenders.

PebModuleHider.HideModule("winhttp.dll") fired successfully ("[ModuleHide] winhttp.dll unlinked from PEB module lists"). BaseDllName offset correction: 0x58 on x64, not 0x60 (FullDllName at 0x48 is 16 bytes). Outcome: the hile POST STILL arrived at 16.3s with Message=[0:winhttp.dll], immediate exit followed 150ms later (veto loop as before). Server log confirms exactly one fresh report at 23:32:58.

CONCLUSION: the scanner does not enumerate via PEB module lists. It walks committed image regions (NtQueryVirtualMemory-style) and classifies each region by parsing PE headers - unlinking the lists hides the NAME but the region still self-identifies via its MZ/PE signatures.

Note the response correlation across runs: ok-true -> immediate exit (12, 14); ok-false -> deferred exit (13). The exit is native local state, the POST is only telemetry.

Run 15: PE signature wipe - declassify the mapped image (deployed 23:42, FAILED)

PeHeaderWiper.WipeSignatures("winhttp.dll") at plugin Load (after the PEB unlink, before Harmony). Zeroes the two structural signature tokens: "MZ" at base+0 and "PE\0\0" at base+e_lfanew (6 bytes total, VirtualProtect RW for the write then restore). A region-walking scanner that classifies by parsing headers can no longer name this region; GetProcAddress is unaffected (it follows e_lfanew into the intact optional/export headers without validating the signature bytes) and winhttp IATs bound at process start.

Success markers: both [ModuleHide] lines OK, NO POST /v1/hile in the whole session, no PreparingToExit, load completes, spawn, stable past 2min. Failure modes to distinguish in the log:

  • hile still appears -> scanner hashes raw region bytes against the module's on-disk image (need bytes-level rework of the mapped image or targeted section wiping) -> next run.
  • no hile but exit at ~16-18s -> an independent native watchdog (e.g. door DLL fingerprint check) -> revisit native path.

Run 15 outcome (14:28 Sep 24, FAILED)

Both hides active in the boot log ("[ModuleHide] winhttp.dll unlinked" AND "PE signatures wiped"). The hile POST fired anyway at 19.3s session time (client [Net] log: Post .../api/PlayerReporting/v1/hile), immediate exit 150ms later: veto blocked PreparingToExit, EAC teardown, room load cancelled, OBHEMJEJNMK: Aborted, NRE/"An error occurred" loop ~1.3s - identical to runs 12/14.

Server-side note: server-console.log stopped receiving entries after 23:33 Sep 23 even though the server process survived (its console redirection died with the previous agent session), so run-15 POSTs are evidenced from the client log only. Server was alive and answering gameconfigs 200 during the whole session.

CONCLUSION after runs 14+15: the scanner neither enumerates PEB module lists nor classifies regions by PE signature tokens. It still flags winhttp.dll as Type=Obcured (note the misspelling - the flag is "obscured", i.e. it is reporting our tampering itself, not a name it got from the headers). Remaining hypotheses: (a) hashes raw mapped bytes against the on-disk image, (b) fingerprints the door DLL through the loader record / LoadLibrary bookkeeping, (c) an independent native watchdog.

DECISIVE observation for run 16: across ok-true runs the exit fires ~150ms after the POST, while run 13's ok-false (response that never satisfied the handler) deferred the exit until the next scan. The exit is triggered by the report's RESPONSE arriving, not by the scan itself. So: drop the report request at the HTTP layer before it is ever sent.

Run 16: HileGate - drop the hile POST at the HTTP layer (deployed 14:50 Sep 24, FAILED - prefix never skipped the original)

HileGate (in NameserverConnectionPatches.cs): a prefix check inside the existing BestHTTP HTTPManager.SendRequest(HTTPRequest) patch - the same managed, main-flow funnel the [Net] logger uses, proven detour-safe (unlike the scanner-thread funnel KDJDKJNPELI.IJHOPKJFKBI, run 11 fatal). When the request URI path ends with /api/PlayerReporting/v1/hile, the prefix returns before any processing: the request is never queued, never sent, and no completion callback can fire, so the response-gated exit path never runs. The scanner cannot observe the drop - it only sees that its report never completes. Opt-in via HileGate.Enabled (armed in the August plugin Load; the March plugin source stays inert). The ExitStateVetoPatch stays armed as a second layer.

Success markers: [HileGate] dropped anti-cheat report POST around 16-20s, NO hile POST reaching the server, NO PreparingToExit, load completes, spawn, stable past 2 min.

Failure modes to distinguish in the log:

  • No [HileGate] line but exit at ~16-20s -> the scanner sends through a different stack (native winhttp directly, not BestHTTP) -> shim the door DLL's winhttp exports or block at the socket layer -> next run.
  • [HileGate] line fires, no POST, still exit -> trigger independent of report completion (native watchdog) -> revisit native exit initiators (CCKNJCHHBLL.BEFDPLAELIH / GIEEBIOOFML, tokens 100667097/098).
  • Periodic re-scan attempts every ~30s should each be dropped silently; a rising [HileGate] count with stable gameplay is a WIN state.

Run 16 outcome (14:53 Sep 24, FAILED - implementation bug, model intact)

The gate FIRED ([HileGate] dropped anti-cheat report POST at 16.9s) but the POST went out anyway: server-console-run16.log line 12835 shows POST /api/PlayerReporting/v1/hile -> 200 at 14:53:55.431, exit machinery started ~150ms later, veto loop as usual. ROOT CAUSE: the run-16 prefix was VOID - in Harmony an early return from a void prefix does not skip the original; only a bool prefix returning false does. The drop was a no-op, so this run is evidence-wise just another ok-true repetition (12/14/15).

FIX (deployed 14:58): the prefix is now bool Prefix(HTTPRequest request, ref HTTPRequest __result); on a hile report it logs, sets __result = request (the overload's fluent return value - decomp confirms SendRequest_Public_Static_HTTPRequest_HTTPRequest_0) and returns false so the original never queues the request. Success markers unchanged; next run must show the [HileGate] line AND no POST /v1/hile on the server, then no PreparingToExit at all.

Run 16 RETRY (boot 14:59:23 Sep 24) - SUCCESS

All success markers met for the first time in 16 runs:

  1. [HileGate] dropped anti-cheat report POST fired at 16.9s session time (LogOutput.log line 174) - exactly where the scanner always reported.
  2. ZERO POST /api/PlayerReporting/v1/hile reached the server for this boot (server-console-run16.log: the only hile entry is the buggy 14:53 run's). The drop now actually skips the original.
  3. ZERO [EAC] Tearing down, ZERO OBHEMJEJNMK: Aborted, ZERO NullReferenceException/"An error occurred" - the post-report zombie loop never started.
  4. Room load COMPLETED: joinresult 200 at 14:59:43 (instance 20845), RunFor.Authority handlers finished, phases advanced through DESERIALIZE_CONNECTABLES -> POST_DESERIALIZE_OBJECTS (attached log tail at 20s), avatar/keepsake/invention fetches all flowed.
  5. Session end (14:59:52 logout, 16 veto blocks 0-10s, process left ~15:00:03) was a USER-INITIATED quit ~9s after spawn, NOT an anti-cheat exit: no EAC teardown, no hile around the logout, and the native quit path bypasses the veto by design (documented run-12 behavior). The 120s veto window had not expired; the native path took the process down cleanly.

VERDICT: the anti-cheat kill chain is broken at its response gate. The report is dropped client-side, the response never arrives, the exit handler never fires, and the game survives into the Dorm.

PENDING VERIFICATION (next soak run): the scanner's ~30s periodic re-scan. Prediction: every subsequent report is dropped identically (rising [HileGate] count, stable gameplay). If a later scan instead switches to a different report channel (native winhttp direct), the first symptom is a server-side hile POST - watch for it in a 5+ minute soak.

Note: keep the ExitStateVetoPatch armed. With reports dropped it should never fire again except for genuine user quits inside the 120s window; if it starts blocking repeatedly WITHOUT a [HileGate] drop preceding it, a second exit trigger exists and the native initiators (CCKNJCHHBLL.BEFDPLAELIH / GIEEBIOOFML) become the next patch target.

Run 17: second exit trigger + veto removed by a parallel edit (Sep 24

afternoon)

Three more boots with the fixed gate (14:59, 15:07, 15:13) reshaped the evidence:

  • 14:59 boot: gate dropped the report at 16.9s, room load COMPLETED (joinresult 14:59:43), then 16 veto blocks (PreparingToExit x8, ReadyForExit x8) at 14:59:52-15:00:03 - the exit re-request came ~9s AFTER load finished, and the process left once the veto window ended. No hile, no EAC teardown, no crash dump, no Windows fault.
  • 15:07 boot: gate dropped reports TWICE (17s, room-change), survived 7 minutes, then died through the managed shutdown cascade at 15:08:42 - exactly when the run-12 veto (120s from 15:07:40) expired. Same clean shape: the exit kept being re-requested and the veto kept absorbing it until the window closed.
  • 15:13 boot: a PARALLEL EDIT (Plugin.cs modified + rebuilt at 15:11, not by this thread) REMOVED ExitStateVetoPatch entirely. That boot had [HileGate] armed but no [ExitVeto] armed, died ~40s in with nothing absorbing the exit. This confirms the veto was doing real work.

MODEL REVISION: the exit is NOT purely response-gated. The scanner keeps RE-REQUESTING exit on a cadence (runs show 10-40s intervals), plausibly a report-completion TIMEOUT on the dropped report (run 13's ok-false exit at ~30s fits: no satisfying response within the deadline = failure = exit). Dropping the request cannot win alone; something must absorb the re-requests forever. The veto did that until its window expired; a parallel edit removed it.

Run 17 change: ExitInitiatorTracerPatch prefixes the managed exit initiators CCKNJCHHBLL.BEFDPLAELIH (100667097), GIEEBIOOFML (100667098) and FNOEHNDLHHG (100667101), logs the FULL managed stack of whoever calls them, and blocks the call. These are attached-thread, detour-safe targets (run-14 prep). Deployed 15:42. Success markers: [ExitInit] stack traces identifying the second trigger's caller, and sessions that no longer die at 10-40s. If sessions still die with all three blocked, the trigger is native-only (TerminateProcess-class) and the blockades prove it by absence.

Run 18: "crash on boot" was a disabled Doorstop proxy, not the tracer

Symptom: the user reported a crash on boot immediately after the 15:42 run-17 deploy.

Evidence chain:

  • BepInEx/LogOutput.log still had mtime 15:14, i.e. no plugin log was written for any boot after the 15:13 session. No boot has ever printed [ExitInit] armed.
  • The Unity log (AppData/LocalLow/Against Gravity/Rec Room/Player.log and Player-prev.log, mtime 15:59:47 / 15:59:33) contains ZERO BepInEx/Doorstop/Radicade lines: two VANILLA boots of ~14 KB each. Player.log shows "Could not resolve host 'ns.rec.net'" at 3.6s, then AppExitState Running => PreparingToExit at 3.9s, then "[EAC] Tearing down and releasing runtime", process gone by ~7s. EAC was live because the Doorstop/EAC-bypass door never loaded, and the game was still pointed at the real nameservers.
  • Cause: winhttp.dll (26,112 b, Doorstop 4.3.0 proxy, md5 b2a4d0cb...) had been renamed to winhttp.dll.baseline-disabled at 15:17:15 by a parallel action, which also created baseline-backup-20260924-151633/ containing a copy of winhttp.dll, Player-before-baseline.log (the 15:13 session log), the pristine 173 b RecRoom_Data/boot.config and the pristine 38,654,794 b il2cpp_data/Metadata/global-metadata.dat.
  • No new WER 1001 event and no new CrashDumps entry: the game exits through its orderly quit path, it does not fault. That is why the crash left no crash report and why only the Unity log has evidence.
  • Fix: the snapshot's winhttp.dll was copied back to the game root (md5 identical). The rest of the door chain was verified intact: doorstop_config.ini enabled=true, .doorstop_version 4.3.0, BepInEx/core/BepInEx.Unity.IL2CPP.dll, dotnet/coreclr.dll, 335 interop assemblies, 64 unity-libs.

Consequences:

  • Run 17 has NEVER booted. It was deployed at 15:42, 25 minutes after the door was disabled, so ExitInitiatorTracerPatch has zero runtime data and its block/trace behaviour is still unproven.
  • The "baseline" experiment cannot work as designed. With the door removed there is no patch stack at all, so the game dies at ns.rec.net DNS plus EAC long before any comparison is possible. A vanilla control run needs the door present and the patch groups switched off from inside the plugin, never a removed proxy DLL.
  • Parallel edits remain the biggest source of false signal: ExitStateVeto Patch was deleted from the working tree at 15:11 and the door was disabled at 15:17. Both were uncommitted, so neither the veto nor any boot data exists in the run-17 build.

The next boot is run 17's first real run. Expected markers: [ModuleHide] x2, [HileGate] armed, [ExitInit] armed, a hile drop ~17s in, and [ExitInit] stack traces naming the second exit trigger's caller.

Run 17's first real boot (16:10): the exit block broke the room load

The door restore worked and the boot reached the Dorm, but the player saw a BLACK SCREEN: the debug overlay showed Room: 'DormRoom: Home', RoomId 2, SubroomId 2883, Players 1 (2 TestPlayer, Loading), FPS 910 (nothing being drawn). Run 17's own markers were all present: [ExitInit] armed, ONE blocked initiator call, and four [HileGate] drops.

The one block is the problem. It fired ~0.05s after Chainloader startup complete, before any networking, and its caller trace is:

   at System.Environment.get_StackTrace()
   at ExitInitiatorTracerPatch.TraceAndBlock()
   at DMD<CCKNJCHHBLL::BEFDPLAELIH>()
   at (il2cpp -> managed) BEFDPLAELIH(Il2CppMethodInfo* )

Read that trace correctly: in an IL2CPP build ALL game code is native, so "no managed caller above the trampoline" does NOT mean an outside native component. It only means the caller is another il2cpp method - ordinary game code. A boot-time call to an exit initiator is therefore most likely part of normal startup, and blocking it is not free.

What followed, compared against the last good session (15:13, pre-run-17, preserved as baseline-backup-20260924-151633/Player-before-baseline.log):

  • 15:13: load of 25/32 runs 14.08s -> COMPLETE at 19.77s (FinishRoomLoad). FetchDeserializationDataTask.Run('25.32') finishes in 1ms: DownloadAsset Bundle, then DownloadAndParse of MKMOLMPLENB, JGEHMKKALNE and OBHEFDFCAIC all return in 2-7ms, then the phases march through DATA_DOWNLOADED, INIT_OBJECTMODEL ... COMPLETE.
  • 16:10 (run 17): the SAME room, the SAME sequence - and START: DownloadAndParse('OBHEFDFCAIC)') never returns. The load sits in DOWNLOADING_DATA until the client is closed 64s later. Everything after the load never happens: no rooms/25/experience/player, no keepsakes/rooms/25, no roomkeys?roomId=25, and no GET /rooms/photon_access_token, so the dorm's Photon join is never even attempted and the save can never arrive. That is the black screen.
  • Server side is exonerated: GET /rooms/rooms/25?include=5421... returned byte-identical payloads (len=2174) in every session including 16:10, and the server log shows no [RoomUtils] self-heal firing. The only client-side delta between the 15:13 build and the 16:10 build is run 17's tracer (the veto was already gone at 15:13). Four consecutive pre-run-17 boots (14:53, 14:59, 15:07, 15:13) all completed the dorm load.

Run 18 change: ExitInitiatorTracerPatch still traces every call, but it now ALLOWS the call (returns true) until HileGate.DroppedCount > 0, i.e. until the scanner has actually filed a report, and only blocks from then on. The exit that matters is the one the dropped report triggers; a call that arrives before any report exists is startup traffic. HileGate grew the DroppedCount counter (NameserverConnectionPatches.cs, shared into the August assembly), the log line is now "[ExitInit] armed - ... traced; blocking switches on after the first dropped hile report", and an allowed call logs "[ExitInit] allowed managed exit initiator (no report filed yet)".

Deployed 16:16 (34304 b). Success markers for the next boot: an [ExitInit] allowed ... line during startup instead of a block, the dorm load reaching COMPLETE / FinishRoomLoad, a visible Dorm, then the first hile drop and - from that point - [ExitInit] blocked ... traces.

Run 18 gating result (16:17 boot): the hang is gone, the load still fails

The gate did what it was built to do:

  • [ExitInit] allowed managed exit initiator (no report filed yet) at startup (the boot-time BEFDPLAELIH call now passes through),
  • four [HileGate] dropped anti-cheat report POST lines, and
  • one [ExitInit] blocked managed exit initiator ... at DMD<CCKNJCHHBLL:: FNOEHNDLHHG>() after the drops - the gated block works.

The infinite stall is gone too: DownloadAndParse('OBHEFDFCAIC)') returned after 22.7s (16:17:44.4479 -> 16:18:07.1543) instead of never, and OperationContext.FinishRoomLoad followed at 39.1551.

But it returned a FAILURE, and the session died with it:

[16:18:07] [39.1509] AppExitState changed: Running => PreparingToExit
[16:18:07] [39.1566] [Error | RoomOperation] Error during operation
            InitialRoomLoad-a820aca0-...: Error downloading save data!
            Try again later or on a better connection.
[16:18:08] [40.2863] [Unity] [EAC] Tearing down and releasing runtime
[16:18:09] [41.6710] [Unity] PhotonHandler.OnDestroy

So the exit that ends these sessions is at least partly a CONSEQUENCE of the failed room load - the documented DORM-BLOB-FIX chain - not purely the scanner. Note also that the block was armed and STILL did not hold the process: a [ExitInit] blocked line fired and the client left anyway, so the recurring exit is not solely the managed initiators.

WHAT IS MISSING THIS TIME: GET /rooms/photon_access_token. Every pre-run-17 boot fetched it during the dorm load (14:59:39.570, 15:07:54.917, 15:13:41.243 and again at 15:13:52.159) and the good run shows [GetSubRoomDataSave()]: Total: [5397.72ms 2618frames] - the subroom save download succeeding after ~5.4s. Neither run-17-lineage boot (16:10, 16:17) ever requested a Photon access token, so the Photon room join is never set up, GetSubRoomDataSave() has nothing to resolve against, and the load fails with "Error downloading save data!" instead - after a 22.7s timeout in 16:17, and forever in 16:10.

Everything server-side is byte-identical across good and bad sessions: /matchmaking/player/connection-info ~1180 b, /matchmaking/matchmake/dorm 572 b, /rooms/rooms/25?include=5421... 2174 b, /cdn/room/ 22 b. The tracer is the only client-side delta, so the tracer is the A/B variable.

Run 18a (deployed 16:19, 34304 b): the tracer registration is commented out (the class stays in source with its findings recorded - a boot-time BEFDPLAELIH call arriving from il2cpp with no managed frames, and the exit leaving through FNOEHNDLHHG). Expected on the next boot: `[ExitInit] disabled

  • tracer removed for the room-load A/B, then GET /rooms/photon_access_tokenin the client log and in the server log,GetSubRoomDataSave()` resolving in seconds, the phases running through to COMPLETE, and a visible Dorm. If all of that happens, the tracer - or one of its three detours - is confirmed as the room-load breaker and it comes back only one target at a time.

Run 18a result and the REAL regression: the baseline experiment restored the

unpatched metadata (root cause of the black screen and the forced exit)

Run 18a booted at 16:20 with [ExitInit] disabled (LogOutput.log line 33) and the four [HileGate] dropped lines, and it failed EXACTLY like run 18 did: no GET /rooms/rooms/25/experience/player, no playerdata/me, no keepsakes/rooms/25, no roomkeys?roomId=25, no GET /rooms/photon_access_token, then Error downloading save data!, FailedToSpawnPlayer (Code 8715) and the forced exit. The exit tracer is therefore NOT the room-load breaker - that hypothesis is dead, and the server payloads were byte-identical the whole time.

What the tracer-off boot did NOT rule out - and what actually broke every boot after 15:16 - is the rest of the parallel "baseline" experiment. It did not only rename winhttp.dll away at 15:17:15; it also put back two files from the pristine (pre-Radicade) Sep 23 14:21-14:23 state, using mtime-preserving copies, so nothing looked "new" afterwards:

file good state (the 14:53-15:13 boots) what every failing boot ran
RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat 38,654,794 b, md5 cc9c32014b5fc3dd1a2a8fb12376be15 32,039,456 b, md5 825f34f1dd046a0ae764cd5a97dba5e6
RecRoom_Data/boot.config 173 b, md5 dc2e5f7af7d5df736a4cc3477b4b1a1f 104 b, md5 5694f1046c94cb6b9bbcf6854ed8eaae

The 104 b boot.config is byte-identical to RecRoom_Data/boot.config.orig (the patcher's own backup), which is the fingerprint of a restore, not of an edit. Proof that the 38 MB metadata - and only it - carries the Radicade rewrite:

  • grep -a -c 'localhost:3030': 38 MB file = 3, live 32 MB file = 0. 127.0.0.1 = 9 vs 1, rec.net = 20 vs 4.
  • RecRoom_Data/radicade-patch.json records patchedAtUtc 2026-09-23T22:08:42Z (15:08:42 local, changedLiterals: 7, serverUrl https://localhost:3030), and that timestamp is exactly the mtime of the 38 MB file captured in the snapshot: the file the baseline experiment replaced was the Radicade-patched one that had been live for all four good boots.
  • Both files keep the release's custom shuffled header (magic d4370000...), so the patched metadata is still the native format the runtime expects. The standard-header global-metadata.interop.dat (magic af1bb1fa) is only the tooling copy and was never touched.

Mechanism: with the pristine metadata the client's hardcoded bootstrap literals point at the official hosts again. The plugin's runtime redirects only cover the nameserver host, the websocket host and TLS, so most traffic still reaches the server through GET https://ns.rec.net/ns - which is why the boot looks healthy, logs in, joins Photon and renders the Dorm - but the room-save path is built from a literal that nothing rewrites at runtime, so GetSubRoomDataSave() fails and the session is torn down by the room-load failure handler.

Fixed: both files copied back from baseline-backup-20260924-151633/RecRoom_Data/ (cp -p, provenance kept). Live again now: metadata md5 cc9c3201... (38,654,794 b, 3x localhost:3030), boot.config 173 b (vr-enabled=0, player-connection-mode=Listen, player-connection-debug=0). winhttp.dll had already been restored, a scan for any other 2026-09-24 write outside BepInEx found nothing, and the only disabled sidecars are winhttp.dll.baseline-disabled (dormant) plus the BepInEx config/loader backups - so the baseline undo is now complete.

Caveat for future restores: the patcher's own input (38,654,604 b) no longer exists anywhere, because the experiment also restored global-metadata.dat.orig to the 32,039,456 b Sep 23 14:23 file. The canonical restore sources are therefore the snapshot directory and global-metadata.interop.dat - never .orig.

Deployed build for the next boot stays run 18a (34304 b, md5 b5cdc49cf2da66e306ee20f4e60a515c, tracer off), so the metadata fix is a clean A/B. Success markers: GET /rooms/rooms/25/experience/player (404), then playerdata/me, keepsakes/rooms/25 and roomkeys?roomId=25 in the server log, GET /rooms/photon_access_token, [GetSubRoomDataSave()] resolving in seconds, FinishRoomLoad COMPLETE and a visible Dorm. The gated ExitInitiatorTracerPatch and a never-expiring exit veto come back only after that, one target at a time, with the metadata state now treated as a first-class tracked variable (md5 above) rather than an assumed constant.

Run 19 (16:54 boot): the Dorm LOADS - the next killer is the Play menu's

missing list routes

The metadata restore worked. The 16:54:57 boot completed the dorm load for the first time since run 16:

16:55:10  phase DOWNLOADING_DATA, DownloadAndParse('OBHEFDFCAIC)') 16.2212 -> 16.2296  (8 ms, was 19-22 s or never)
16:55:11  GET /rooms/photon_access_token
16:55:13  [10976][19.4330] OperationContext.FinishRoomLoad(e4909cf1-...)
16:55:13  Moved to phase COMPLETE  (SCENE_LOADED -> SETUP_ROOM_OBJECTS -> RUN_MIGRATIONS
          -> DESERIALIZE_OBJECTS -> DESERIALIZE_CONNECTABLES -> COMPLETE, SpawnLocalPlayer at ~19.5s)

So GetSubRoomDataSave() was never the broken thing - the unpatched metadata was. Player spawn works too (the only spawn-time error is a benign Toxmod config warning).

The new failure: the Play menu's discovery rows 404

At 27.7 s the client opened the room list and its rows failed:

16:55:22  GET /lists/algorithmiclists/HotList?type=1 -> 404
          [Error] Error refreshing rooms from source Hot: An error occurred   (RoomListModel)
16:56:05  GET /rooms/rooms/carousel/rising?skip=0&take=20 -> 404
          [Error] Error refreshing rooms from source CarouselEndpoint: An error occurred
16:56:05  [22050][70.8179] Error refreshing rooms from source Hot: An error occurred
16:56:05  [22051][70.8330] AppExitState changed: Running => PreparingToExit
          NullReferenceException at RRUI.Data.RoomListModel.DOPOJKBDDEL()
            <- UnityEngine.Object.Instantiate <- RRUI.StaggeredPrefabSpawner.EPABHKKCNIF()
          [EAC] Tearing down; Uploading Crash Report

A failed row is not a skipped row: it NREs the model's prefab spawner and ends the session. Same death in the 15:13 reference run (Error refreshing rooms from source Hot at 15:13:59, exit at 15:14:06), which is why that run ended seconds after the player reached the RecCenter. The curated-lists 404/[] note from run 10 was never the whole story - lists/algorithmiclists/* and rooms/rooms/carousel/* were simply never implemented.

Fix (server, 17:36)

  • NEW Server/Lists/AlgorithmicListsAPI.cs: GET /lists/algorithmiclists/{list}?type=1 -> { Type, Entities: [ { Id, Context } ] } (Id a string, Context null), the shape and field order the reference server answers with, because the client's obfuscated DTOs are read positionally. type is echoed (defaulting to 1 = Rooms). Every row key answers 200: HotList and the other ranking rows are ordered by RoomStats.VisitCount desc, new by PublishedAt desc, both over public active non-dorm rooms, 20 per row.
  • Server/RoomServer/RoomServerAPI.cs: GET rooms/carousel/{*source} (next to the other carousel feeds) -> bare array of RoomDetail, name/tag filtered when the source names something, falling back to every public room so a row is never empty. Shape follows rooms/hot_rooms (see docs/2023-WATCH-HOME-FIX.md); if a row still renders empty, the envelope (RoomSearchResult) is the thing to change.
  • Not changed: lists/curatedlists/bulk still answers []. Its shape is a guess either way (the client logs "Exception: No such list" for the empty array), so it stays as the documented, non-fatal behaviour until the fatal rows are confirmed fixed - changing a DTO the positional codec reads without a verified field order is how a non-fatal error becomes a fatal one.

Verified live over the plain-HTTP listener (server restarted 17:37, now server-console-run19.log, dotnet run --no-build from Server/):

/lists/algorithmiclists/HotList?type=1            -> 200  {"Type":1,"Entities":[{"Id":"32",...},{"Id":"3"},...]}
/lists/algorithmiclists/new?type=1                -> 200  {"Type":1,"Entities":[{"Id":"35"},...]}
/rooms/rooms/carousel/rising?skip=0&take=20       -> 200  [ {RoomId:2,...}, ... ]  (20289 b)
/lists/curatedlists/bulk?id=17859340              -> 200  []  (unchanged, see above)

Server build clean (0 errors, 389 pre-existing warnings). Client build unchanged (run 18a, tracer still off), so the next boot A/Bs exactly one thing: the rows. Expected markers: no "Error refreshing rooms from source Hot/CarouselEndpoint" lines, no RoomListModel NullReferenceException, the Play menu carousels populated with real rooms, and a session that survives the hot row refresh.

Run 20 (17:43 boot): the row fix holds - the room-list killer is gone

The 17:43:25 boot ran against that rebuild, and every marker above landed:

  • GET /lists/algorithmiclists/HotList?type=1 was requested twice and answered 200 (1171 b). Player.log has no "Error refreshing rooms from source Hot/CarouselEndpoint" and no RRUI.Data.RoomListModel NullReferenceException.
  • The rows resolved into real rooms instead of failing: right after HotList the client asked rooms/rooms/bulk?id=2&id=4&id=7&...&id=19 and drew the carousels (rooms/hot?tag=rro 200 / 27163 b, with RecRally/Crescendo/Park/DodgeballVR images following).
  • The Dorm was unaffected: Moved to phase COMPLETE at [10504][20.13], OperationContext.FinishRoomLoad, SpawnLocalPlayer total 10278 ms - the same numbers as the 16:54 reference boot. The metadata precondition was re-checked at boot: global-metadata.dat 38654794 b md5 cc9c32014b5fc3dd1a2a8fb12376be15, boot.config 173 b md5 dc2e5f7af7d5df736a4cc3477b4b1a1f.

The session still ended, but differently from every previous failure: the log stops on a clean quit path (POST /matchmaking/player/logout -> PreparingToExit => ReadyForExit -> EAC teardown -> PhotonHandler.OnDestroy), with no exception in the 3.7 s before it - where the 16:54 run had the RoomListModel NRE 40 ms before PreparingToExit. A silent 3.7 s gap plus a graceful logout is the shape of a hand quit, not of the crash path, so run 20 cannot be called a crash without knowing what the screen did.

GET /rooms/rooms/hot?tag=community&skip=0&take=20 answered 200 with 43 bytes - no room in Rooms carries the tag community (the column holds rro,2|quest,0 style pairs), so the Play menu's COMMUNITY carousel was empty. GetHotRooms's default branch now falls back to the whole public non-dorm catalogue when the tag matches nothing, the same way rooms/carousel/{*source} does; it answers 27163 b / 25 rooms now.

Server rebuilt (0 errors, 389 pre-existing warnings) and restarted as server-console-run20.log, PID 26088, both listeners up:

/rooms/rooms/hot?tag=community&skip=0&take=20    -> 200  27163 b  25 rooms
/rooms/rooms/hot?tag=rro&skip=0&take=20          -> 200  27163 b
/lists/algorithmiclists/HotList?type=1            -> 200  {"Type":1,"Entities":[...]}
/rooms/rooms/carousel/rising?skip=0&take=20       -> 200  20289 b

Known non-fatal noise (do not chase these first)

All of these are present in both the 16:54 run and the 15:13 reference run, so they are background, not regressions:

  • Failed to load Type "RecRoom.Analytics.Statsig.StatsigDebugOverrides" - RuntimeInitializationHelper:OIDNKEGDPPF(Dictionary\2)->MBJKIOBDOAM()->SubsystemRegistration(). It is **vanilla noise**, not a symptom: the 15:13 baseline boot - loader renamed away, pristine 32,039,456 b metadata, i.e. no patch stack at all - logs the same line (artifacts/baseline-20260924/Player-first-baseline.log, line 24), and so does every working boot since, including the bug-report upload at Server/RadicadeServer-Config/BugReports/20260925-044419-Player.log. The type is a debug-only Statsig helper the retail build does not carry; the subsystem registration logs the miss and continues. The August plugin never mentions the string (grep -a -c StatsigDebugOverrides RadicadeAugustClient.dll` = 0). Do not chase it.

  • Malformed Response: '[]' (2 in run 20, 5 in the 15:13 run) - ODIGMKMGFCF: expected:'Number Token', actual:'[', at offset:0, i.e. the client deserialised a bare array where it wanted an object. The two in run 20 land within ~1 s of api/inventions/v2/mine and api/inventions/v1/toptoday, both of which answer [] because the inventions tables are empty; the 15:13 run survived five of them, so their shape is wrong but not fatal.

  • Could not enable featured rooms display... no images are available - featuredrooms/current deliberately 404s.

  • 404s: /rooms/rooms/25/experience/player, /rooms/rooms/25/experience, /econ/econ/roomGiftDropShops/room/25, /data/commonQuery/userSegment, /econ/api/influencerpartnerprogram/{influencer,myinfluencer}.

  • Exception: No such list / Deserialization returned null for lists/curatedlists/bulk (documented above).

Run 21 setup: the tracer is re-armed (it was never the room-load regression)

Run 20's exit was confirmed by the user: it closed on its own, with the screen looking fine - no black screen, no error on screen, no crash dialog. That is a silent self-exit through the graceful path, i.e. the same class of shutdown the run-17 ExitInitiatorTracerPatch was built to name.

The run-18a reasoning that parked the tracer is now known to be wrong. Its two boots (16:10, 16:17) never fetched /rooms/photon_access_token and died with "Error downloading save data!", so the tracer was blamed - but those boots were running the pristine global-metadata.dat + boot.config restored by the baseline experiment at 15:16, which is exactly what starves DownloadAndParse('OBHEFDFCAIC'). With the patched metadata back, run 20 completed the load in the usual 8ms and 10278ms and the tracer was off, so the tracer is not implicated in the room-load regression.

Re-armed in Plugin.cs (harmony.PatchAll(typeof(ExitInitiatorTracerPatch))), rebuilt (dotnet build 2023Patch/RadicadeAugustClient/RadicadeAugustClient.csproj -c Release, 0 errors) and deployed to BepInEx/plugins/RadicadeAugustClient.dll (34304 b, md5 7fd2f6c698f9568ebc6019bd8ca01e2f; the previous run-18a build is kept as RadicadeAugustClient.dll.before-run20-tracer, md5 b5cdc49cf2da66e306ee20f4e60a515c). The old "disabled" log line is gone from the binary, the arm message is present.

What it does: prefixes CCKNJCHHBLL.BEFDPLAELIH / GIEEBIOOFML / FNOEHNDLHHG (the managed funnel every shutdown passes through). While HileGate.DroppedCount == 0 the call is allowed and logged (that is normal boot traffic); once a report has been dropped the call is blocked and the full managed caller stack is logged as [ExitInit] blocked managed exit initiator. The gate dropped a report at 17:43:38 in run 20 and the exit fired at 17:43:59, so a repeat boot will be armed well before the exit.

Expected in BepInEx/LogOutput.log on the next boot: one [ExitInit] allowed managed exit initiator early (boot traffic, before the drop), then either an [ExitInit] blocked managed exit initiator stack at ~37s - which names the trigger and should also stop the shutdown - or no ExitInit line at all, which would prove the trigger is native-only (TerminateProcess-class) rather than managed. Either outcome is a result; the metadata/boot.config md5 pair above is the precondition to re-check first.

Run 21 (19:34 boot, tracer armed): the trigger is NATIVE, and the tail is managed

Both expected lines appeared, and they settle the model:

line 36  [ExitInit] allowed managed exit initiator  -> DMD<CCKNJCHHBLL::BEFDPLAELIH>() -> (il2cpp -> managed)
line 183 [HileGate] dropped anti-cheat report POST
line 276 [ExitInit] blocked managed exit initiator  -> DMD<CCKNJCHHBLL::FNOEHNDLHHG>() -> (il2cpp -> managed)

Two things follow from the traces. First, no managed caller ever appears: the frame above each initiator is (il2cpp -> managed), i.e. native il2cpp code invoking the managed method directly, so whatever decides to leave is not managed code and no managed veto can see it. Second, the blocked initiator fires at the very end of the shutdown, after PreparingToExit (19:34:36.77) and after ReadyForExit (19:34:38.47) - the [ExitInit] block sits within the last ~9 log lines, next to the logout / PUT playersettings / final analytics flush. So FNOEHNDLHHG is the last step of a shutdown, not its trigger: blocking it held the process ~13s longer than the previous boot but the native side still finished.

What the boot looked like from the outside:

  • Dorm + spawn fine again: Moved to phase COMPLETE [14020][26.5664], SpawnLocalPlayer 10629.92 ms.
  • Play menu fine: HotList?type=1 200 (1171 b) then rooms/bulk?id=2&id=4&id=7... (21460 b) with the carousel art.
  • Only three 404s, all long-known: rooms/25/experience/player, econ/econ/roomGiftDropShops/room/25, data/commonQuery/userSegment.
  • The client stayed interactive until the last second: Applied KEYBOARD_MOUSE control scheme LEGACY (27.1s, automatic), ProUX (32.42s), LEGACY (41.98s) - i.e. the settings menu was being used - then POST /matchmaking/player/logout at 19:34:36.676, 100ms before AppExitState Running => PreparingToExit.
  • No OS-level crash: Windows Application log has no Application Error / Application Hang entry for RecRoom.exe at 19:34; the game's own Crash Dump Report: New: 0 Previously Sent: 0. This is a clean exit, not a crash, however it looks on screen.

So the open question is no longer how the exit happens (native, at the tail) but what decides it, and the only always-present candidate left is the same one as every prior run: the dropped anti-cheat report. The two runs with a known cause of death now stand 37s (17:43, tracer off) and 50s (19:34, same shape) apart with no other delta, so the next boot should be a hands-off soak: launch it, touch nothing, and see whether it exits on its own at all.

The user confirmed it was not a quit: "I was only toggling settings, never pressed Quit". Ruled out in the same pass: no [NotifyHub] -> <connection> send at all in run 20 (the only hub lines are connect/register/disconnect, the last one at the logout instant), so no server push ends the session; a 404 in the notify socket is not it either; and the report's own response body is a dead end (RADICADE_HILE_BODY tried ok-true/ok-false/nocontent/servererror back in run 13 - ok-false only DEFERRED the exit).

Run 22: the exit veto is back (rebuilt), on the decision point this time

The old ExitStateVetoPatch was the only mitigation that ever absorbed these exits (16 blocks in runs 12/13/16) and it was deleted from the tree at 15:11 by a parallel edit, never committed - the whole 2023Patch/RadicadeAugustClient/ directory is untracked (git status --porcelain -> ??), so there is no history to recover it from. It has been rewritten against the decompilation instead of guesswork: .external/aug-decomp/acs/CCKNJCHHBLL.cs gives enum CCFIKLBABNB { Running = 0, PreparingToExit = 1, ReadyForExit = 2 }, the private static setter KALEFEDAHCC(CCFIKLBABNB) (token 100667095, CallerCount 3) and its interop wrapper as the setter of property CCKNJCHHBLL.GLNIKJNLCIB (getter JABDIMNNBCC, token 100667094). Every AppExitState changed: Running => PreparingToExit line in Player.log is that setter.

ExitStateVetoPatch now patches BOTH the wrapper setter and the private method (resolved by name, whatever exists), allows only Running, and blocks every other transition with no expiry window - run 17 showed the re-requests continue long after the old 120s window closed and the process leaves the moment it opens. Blocks log the first three, then every 25th. Escape hatch for a real quit: RADICADE_EXIT_VETO=0 before launch (with the veto armed, quitting from the menu is refused too - end the process from Task Manager instead). Registration is wrapped in try/catch so a veto that will not bind can never take the boot down with it.

A trap worth remembering: the first build of this patch was a silent no-op. The log line was LogInfo(Enabled ? "armed ..." : "disabled ...") over a static readonly bool Enabled, and string-searching the built dll showed the true-branch literal was absent while the false-branch one was present - i.e. the compiler had decided the condition. Enabled is now a property (=> !_disabled) and the log line is built at runtime, so the dll contains [ExitVeto] patches applied; blockExitState enabled= and the value can be read straight off the next boot instead of being assumed. Verify patches by searching the SHIPPED dll for their strings; do not trust the arm message alone.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 36352 b, md5 4a672a6b0ba59f73ca785cb318a6b824 (previous builds kept as RadicadeAugustClient.dll.tracer-run21, md5 34304 b / 19:32, and RadicadeAugustClient.dll.before-run20-tracer, md5 b5cdc49cf2da66e306ee20f4e60a515c). Build clean (0 errors). Server untouched and up (server-console-run20.log, PID 26088). Expected on the next boot: [ExitVeto] ... enabled=True, the early [ExitInit] allowed line, the [HileGate] dropped line, and then - instead of the silent shutdown at 37-50s - [ExitVeto] blocked AppExitState -> PreparingToExit while the session keeps running. If the session instead dies with no [ExitVeto] line, the veto never bound and the [ExitVeto] failed to apply error text will say why.

Run 23: the exit's chain is named, and the last wall is Unity's own quit callback

Two questions were open after run 22: what starts the shutdown, and whether any frame of it is still reachable from managed code. The first is now answered from evidence that was already on disk; the second is patched.

The chain

  1. The client's own scanner finds a planted module and files the anti-cheat report. That report body was decoded back in run 9 and is still in the server logs: Hile report received: Type=Obcured Message=[0:winhttp.dll] - the BepInEx Doorstop proxy winhttp.dll in the game root, classified Obscured (hile enum MEHDFJHKLBK.JBLJIENBBHK, Obscured = 0). The classification is inherent: run 9 logged the identical body at 22:16, hours before the run-14 PEB unlink (23:27) and the run-15 PE-signature wipe (23:42) existed, so hiding never caused it and un-hiding will not remove it.
  2. It re-scans on a fixed schedule. server-console.log holds two reports at 23:32:28.141 and 23:32:58.405 - 30.264s apart. That schedule, not a boot timer, is why the same build dies at 36.8s on one boot and 55.3s on the next: the exit lands on the first tick after the boot completes.
  3. The report is not the trigger. Dropping it at the HTTP layer (HileGate, run 16) only defers the exit, and run 13 proved the response body is irrelevant. The scanner's local verdict starts the shutdown; the report is a side effect.
  4. Where it ends (run 22 Player.log): UnityEngine.Application:Internal_ApplicationWantsToQuit() -> System.Func\1[TResult]:Invoke()->CCKNJCHHBLL:GBGMBMGDGGD(System.Boolean)->MGLDPDAEODB:EMDNIBPNEGH()->PFDOLDLEIBA:CKIEGOCLGFD(Action`1), and then [EAC] Tearing down and releasing runtime` three times in the last 3.3s.

The 15:13 reference run self-exits too. Player-before-baseline.log - the boot that loaded the Dorm and was taken as good - ends with [15:14:05] AppExitState changed: Running => PreparingToExit at 41.81s, followed by the same EAC teardown. Its Could not enable featured rooms display warnings start at 23.1s and repeat (run 22: 23.2s, 30.2s, 37.2s, 44.2s, 51.2s - every 7.0s), so they are periodic RRUI noise and not the precursor they looked like. Nothing in the recent client work causes this exit; it is inherent to running this build with a Doorstop module in the game root.

Patched in this build

  • ExitSequenceTracePatch - a Priority.First prefix on BestHTTP.HTTPManager.SendRequest(HTTPRequest) (the same managed funnel NameserverConnectionPatches logs every request in) that dumps Environment.StackTrace the first four times the shutdown's own requests go out: /matchmaking/player/logout and /playersettings/playersettings. The shutdown sequence's first observable network act is the logout POST (server log run 22: 19:45:38.785, right after the notify-hub reconnect). Whoever calls it is the owner of the shutdown, and the stack names them - no inference needed.
  • ExitStateVetoPatch now also dumps the stack on its first block, which names the caller of the Running => PreparingToExit transition (run 22 blocked three times with no caller recorded).
  • UnityQuitVetoPatch - the last wall. Prefixes both CCKNJCHHBLL.GBGMBMGDGGD overloads (decompilation tokens 100667099 / 100667100; the stack trace prints the bool one, so both are bound rather than guessing) and forces __result = false, which is "the application does not want to quit" and makes Unity cancel the quit. It skips the original instead of running it and rewriting the result: run 22's trace shows the original throwing (NullReferenceException out of StatsigUnity.EventLogger.StopCoroutine), and a throwing original returns before any postfix can override the value Unity reads - a postfix-only veto would be silent exactly when it matters. Escape hatch: RADICADE_QUIT_VETO=0, and Enabled is a property (=> !_disabled) with a runtime-built log line, the run-22 folding trap avoided.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 39424 b, md5 71df28734d88fd3f9eca70f202e517bb; the run-22 build is kept as RadicadeAugustClient.dll.before-run23-quitveto (36352 b, md5 4a672a6b0ba59f73ca785cb318a6b824). Build clean (0 errors, 0 warnings). Preconditions re-checked at deploy time: global-metadata.dat 38654794 b md5 cc9c32014b5fc3dd1a2a8fb12376be15, boot.config 173 b md5 dc2e5f7af7d5df736a4cc3477b4b1a1f.

What the next boot has to say

  • [ExitTrace] shutdown-request stack dump armed; [QuitVeto] wantsToQuit veto enabled=True at load, then [ExitVeto] ... enabled=True, the early [ExitInit] allowed, and the [HileGate] dropped line at the first 30s tick.
  • At the exit: [ExitTrace] shutdown request ... with the stack that ordered it, and [QuitVeto] wantsToQuit answered false when the quit is refused.
  • If the process is still alive minutes after that the plant works and the remaining problem is cosmetic (whatever state the half-run shutdown left the UI in). If it dies anyway, the kill is outside Unity's quit path (TerminateProcess-class, or EAC's own runtime) and the next lever is to remove the module the scanner objects to instead of fighting the reaction - i.e. bootstrap BepInEx without a winhttp.dll proxy in the game root, which means a loader-side injection (manual mapping or a launcher) rather than Doorstop.

Run 23b: neutralise the EAC runtime itself (ported from the supplied patcher)

C:\RecRoom\Downloads\patch is a decompiled copy of RecRoomil2cppPatcher (WaeBoi69, loaded by Reloaded-II). Its EacPatcher.cs is the piece this project needed: a proven recipe for neutralising EasyAntiCheat on Rec Room. Five hooks into EasyAntiCheat.Client.dll:

Method Replacement
Hydra.Runtime:IsActive return true
Hydra.Runtime:Initialize no-op
Hydra.NativeClient:Initialize no-op
Hydra.NativeClient:IsStartedWithEasyAntiCheat return true
Hydra.Runtime:get_Initialized return true

The pattern is the point: the initialisers are stubbed while the queries lie and report a healthy, active anti-cheat - which is what the game's own checks read.

Our build exposes the same surface. BepInEx/interop/EasyAntiCheat.Client.dll contains EasyAntiCheat.Client.Hydra, .Hydra.Runtime, .Hydra.NativeClient, .Hydra.NetProtect and exactly those members (ilspycmd -o ... EasyAntiCheat.Client.dll; output kept in .external/interop-decomp/EasyAntiCheat.Client/EasyAntiCheat.Client.decompiled.cs). Why this is the root-cause lever and not another wall: the report is Obscured + winhttp.dll, i.e. a native module scan, and NativeClient.Initialize is where that native client is brought up.

Deployed in our plugin (RADICADE_EAC_NEUTRALIZE=0 disables; armed by default):

  • EacNativeClientStubPatch - every static Initialize on Hydra.NativeClient is skipped, so EAC's native client (home of the module scanner) never starts.
  • EacHealthQueryLiePatch - Hydra.NativeClient.IsStartedWithEasyAntiCheat, Hydra.Runtime.IsActive and Hydra.Runtime.get_Initialized answer true.
  • Hydra.Runtime.Initialize(...) is deliberately left running: it does the game-facing bookkeeping (event wiring RecRoom.AntiCheat.EACManager subscribes to via JMAPOMLPFMI / HINJPEHPJGO / AOFHJPLDLBC) and its native step is the stubbed NativeClient.Initialize. Stubbing it too is stage B if this is not enough.
  • Type discovery is name-based with a hard-reference fallback (typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly in its own method, so the JIT only resolves it if the soft lookups came up empty) - nothing else in the plugin touches that assembly, so at Load time AccessTools.TypeByName can legitimately see nothing.

The game's EAC readiness door is already propped open (AugustChallengePatches fakes EACManager.GenerateChallengeResponse and forces EACManager.MGEAHKJIIMO true), so stopping the runtime does not leave one of the game's checks waiting on it.

Success marker, and it is unambiguous: the [HileGate] dropped anti-cheat report POST line must no longer appear at the 30s tick. No report means the scanner never reached its verdict, which means no shutdown. The run-23 exit walls stay armed as the backstop, and their [ExitTrace] / [QuitVeto] / [ExitVeto] lines say whether they were needed.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 43520 b, md5 434972bf54968f1287643d8be61de8f7 (the run-23 plain build is kept as RadicadeAugustClient.dll.before-run23b-eacstub, 39424 b). Build clean (0 errors).

Verification method correction

The run-22 "constant folding" conclusion came from a hand-rolled check - decode the shipped dll as UTF-16 and String.Contains the literal - and that method is not reliable: against this build it reports EasyAntiCheat.Client.Hydra.NativeClient and [EacStub] EasyAntiCheat Hydra runtime neutralisation enabled= as MISSING, and the decompiled IL shows both present. Do not use it. Use the decompiler on our own output:

ilspycmd -o .external/selfcheck 2023Patch/RadicadeAugustClient/bin/Release/net6.0/RadicadeAugustClient.dll
grep -c "EacStub" .external/selfcheck/*.cs

The run-22 lesson's shape is kept anyway (Enabled is a property over a static readonly _disabled, log lines built at runtime) - it costs nothing, and it is the only version that cannot be folded if the compiler ever does decide to.

Run 23b outcome (20:44 boot): three negative results, each of them useful

Session: 20:44:21 -> 20:45:11 (50s). Boot was healthy and identical to the best boots - Moved to phase COMPLETE at 20.24s, SpawnLocalPlayer 10568.89ms.

  1. EAC's managed runtime is not the scanner. [EacStub] ... enabled=True and Hydra.Runtime.IsActive answered true appear, but there is no skipped ... Hydra.NativeClient.Initialize line anywhere in the boot: the game never calls it, i.e. EAC's native client was never started in this setup - our own EACManager patches had already seen to that. The winhttp.dll verdict still arrived. The scanner is RecRoom's own, not Hydra's. Auena's recipe is still worth having (it is what keeps a modded client's EAC surface quiet), but it is not the fix for this.
  2. Unity's quit is not the kill. [QuitVeto] wantsToQuit answered false (block #1) fired and the game kept running for ~18s afterwards - it went on loading images, PUTting settings and fetching avatar items - so the veto did its job on the request it saw. But Player.log shows Trying to send photon room updates to server while application is quitting - this should not be happening from 36.1s, i.e. Unity was already in its quit state before the veto fired: the first quit request took the native path (Application.Quit() from native code does not consult wantsToQuit), and the request we blocked was a later one. The process ended at 52.85s. Windows' Application log has no Application Error/Hang for it, and this run's shutdown again begins with [EAC] Tearing down and releasing runtime at 34.89s.
  3. The absence of ExitVeto/ExitInit lines is itself the result: this run's shutdown never touched the managed exit state machine at all (no AppExitState changed, no blocked transition, no initiator call). So the veto and the tracer are aimed at a path this build does not use when the anti-cheat verdict starts the shutdown; the verdict path is native end to end.

The stack dumps did name the boundary honestly: [ExitTrace] shows DMD<BestHTTP.HTTPManager::SendRequest> followed by (il2cpp -> managed) SendRequest(IntPtr, Il2CppMethodInfo*) - the logout and the settings PUT are issued by native code calling into managed BestHTTP, so no managed caller exists to name. Same shape as run 21's finding for the initiators.

Run 23c: capture the verdict, stop obscuring the module, trace CheatManager

Given the above, the only remaining fix is to stop the verdict instead of the reaction. Three changes, all in one build, all observable in one launch:

  1. The run-14/15 module hiding is now OFF by default (RADICADE_MODULE_HIDE=1 re-arms it). Runs 14/15 proved the scanner cannot be blinded - unlink and wipe both applied mechanically and the report fired anyway - while "a loaded module that the loader lists do not know about, with its MZ/PE headers zeroed" is the most literal description of an obscured module there is, and Obscured is the exact verdict the client files against winhttp.dll. It bought nothing and may have been feeding the classifier; the plugin now logs what a Toolhelp module walk
    • the same view a scanner gets - actually sees ([ModuleList] ...).
  2. HileGate is now a capture gate, not a drop gate: ShouldDrop dumps the report's whole wire form (request.MethodType, the URI and request.RawData as UTF-8, with GetEntityBody() as the fallback) and then lets it through, so both the client log and the server log carry what the client believes it found. The server's HileAPI keeps only Type and Message; the body may carry more. RADICADE_HILE_GATE=drop restores run 16.
  3. CheatManagerTracePatch prefixes every declared method of CheatManager and its nested types and logs name + elapsed ms (first two calls of each, hard line cap). CheatManager (Assembly-CSharp, global namespace, a SingletonMonoBehaviour) is the one managed class in this build that plainly does local anti-cheat work - OnTimeCheatDetected, OnObscuredTypeCheatDetected, OnAdvancedMovementCheatDetected, IPEMGBLAFIJ(string), BFNMCBEDOLF(string), CheckForDUIDMismatch, and two nested helpers carrying appPath/filenames. Nothing is skipped: observing only, because the one detour this project must never take is the scanner's own funnel (run 11). Whatever runs a few hundred ms before the [HileGate] captured line is the verdict, and its name is the next patch target.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 48128 b, md5 018b991ab951196a76e4ed7f7f3eac4b (run-23b build kept as RadicadeAugustClient.dll.before-run23c-capture, 43520 b). Build clean (0 errors), verified by decompiling the built dll (ilspycmd -o .external/selfcheck), not by string search. The run-23 exit walls stay armed as the backstop.

Run 23c outcome (21:02 boot): the trace named the producer

The CheatManager trace paid for itself on its first run:

+15113ms CheatManager.OnApplicationFocus
+15366ms CheatManager.MJLAGDJLOBC
+15367ms AALEGIEIKEL.LLPNCADGDNI
+15368ms AALEGIEIKEL.LLPNCADGDNI
+15368ms [HileGate] anti-cheat report captured (full wire form)

CheatManager.MJLAGDJLOBC() - a private no-argument method on the anti-cheat MonoBehaviour - runs 2ms before the report, with AALEGIEIKEL.LLPNCADGDNI(string) (a helper whose class carries an appPath field) called twice on the way. A pass over files in the game directory is exactly what would decide that a proxy DLL in the game root is Obscured, which is the verdict [0:winhttp.dll] reports.

Two things the capture taught us about the report itself: RawData is empty and the fields live in the entity body (36 bytes = Type=Obcured&Message=[0:winhttp.dll], matching run 9's decode exactly), and the body text was not printed by the first version of the dumper - it printed the byte count only. Now it prints both.

Also confirmed: [ModuleList] winhttp.dll is enumerated by a Toolhelp module walk: C:\RecRoom\2023-08-29T06-53-33Z\WINHTTP.dll - run 14's PEB unlink and run 15's PE wipe really are off now, and the module is back in the list a scanner reads.

What the exit walls actually buy, now measured on screen

The 21:02 boot lost its session to the same shutdown, but this time the process survived (the Unity quit veto answered false and the game kept running). What the player sees in that state is the run-12 prediction, reproduced on demand:

Error joining room instance RoomInstance - ID:20871 DORM_ROOM:25/32
NullReferenceException: Object reference not set to an instance of an object.
Attempting to go to dorm
Not logged in to matchmaking service
Not logged in to matchmaking! not letting me in no more

The server log agrees with the order: the report arrived for the first time in these boots (21:02:44.255 POST /api/PlayerReporting/v1/hile -> 200 body=true, because the gate is now in capture mode) and the client logged out 156ms later (21:02:44.411 POST /matchmaking/player/logout). So the walls convert a silent exit into a live-but-logged-out client: better for diagnosis, useless as a game.

Server-side note for future probing: localhost:3030 speaks TLS. A plain-HTTP probe answers 000 in ~5ms and looks like a hung server; curl -sk https://127.0.0.1:3030/... answers 200 in ~11ms. The server was healthy throughout.

Run 23d: suppress the scan instead of the reaction

CheatScanSuppressPatch skips CheatManager.MJLAGDJLOBC() (env RADICADE_CHEAT_SCAN=0 leaves it alone). Given the trace, that method is the producer of the verdict, so skipping it means no verdict, no report and no shutdown - the only stopping point that leaves a playable client, since the reaction is native end to end and the walls only trade an exit for a zombie.

Supporting changes in the same build:

  • CheatTrace per-method limit 2 -> 4, so if the skip is not enough, the third and fourth calls of every candidate are still on the record and a miss stays diagnosable.
  • [HileGate] armed now states which mode it is in (capture vs RADICADE_HILE_GATE=drop) instead of always claiming the drop.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 50176 b, md5 b96b1b8926a1ec172e2b84ea0a85d179 (run-23c build kept as RadicadeAugustClient.dll.before-run23d-scan, 48128 b). Build clean, verified by decompiling the built dll.

Expected on the next boot: the session stays logged in past the 15-40s window it has never survived, with no [HileGate] captured line and a [CheatScan] skipped CheatManager.MJLAGDJLOBC() (#1) where the verdict used to appear. If instead the process dies exactly at the skip, that pass runs on a scanner thread and joins the run-11 forbidden list (KDJDKJNPELI.IJHOPKJFKBI) - also a result, and the lever after it is to remove the module the scan objects to rather than the scan itself.

Run 24 (2026-09-24 21:0x) - the first real crash of the project, and how to name it

The skip worked. [CheatScan] skipped CheatManager.MJLAGDJLOBC() fired twice, [HileGate] captured never appears, the server log has no /api/PlayerReporting/v1/hile POST for the whole session, and there is no matchmaking/player/logout - the anti-cheat chain that ended every previous run is genuinely gone. With it gone the client did something it had never done: it entered RecCenter.

One second later it died. This time it is not a graceful shutdown:

Application Error 09-24 21:11:58
  Faulting application: RecRoom.exe, 2020.3.33.23162
  Faulting module:      GameAssembly.dll
  Exception code:       0xc0000005   (access violation)
  Fault offset:         0x00000000029a79ec

Application Hang has nothing, and the previous day's 18 RecRoom faults are a different signature ({coreclr.dll +0x1d1fdd}, {GameAssembly.dll +0x7567f0 / 0x80000003 breakpoint}, {unknown / 0xffffffffffffffff}); 09-24 had no RecRoom fault at all until this one. So this is new code territory, reached only now that the room load is not being cut short at 15-40s.

Reading the dump without a debugger

WER left %LOCALAPPDATA%\CrashDumps\RecRoom.exe.8396.dmp (61 MB). No cdb/windbg/dotnet-dump is installed, so tools/minidump_stack.py reads the minidump directly: exception stream, module list, thread context (amd64 Rsp/Rbp/Rip at 0x98/0xA0/0xF8) and the stack pages, then prints every stack slot that points into a module as module+0xrva. --map resolves those through the RVA table the client writes (below), so no debugger is needed at any point.

It reproduces the WER record exactly, and adds the two things WER does not say:

fault: ACCESS_VIOLATION (0xc0000005) at 0x7ffc16f679ec
       read of address 0x93
       in GameAssembly.dll+0x29a79ec
       rsp 0x41fb78e6d0 rbp 0x24447df8250 rip 0x7ffc16f679ec

A read of 0x93 means a null object with a field at offset 0x93 was dereferenced, and il2cpp left the check off - which is how pointer-arithmetic paths (fixed/Unsafe/native arrays) fail, not ordinary managed field access (that raises NullReferenceException). The stack around it (innermost first) is ~20 GameAssembly frames, with GameAssembly.dll+0x2881f73 the closest return address and coreclr.dll+0x11f66a (BepInEx's .NET runtime) and UnityPlayer.dll frames behind them:

rsp+0x28   GameAssembly+0x2881f73    rsp+0x108  GameAssembly+0x11f2eae
rsp+0x98   GameAssembly+0x59adea8    rsp+0x1a8  GameAssembly+0x1201e67
rsp+0xa8   GameAssembly+0x7764a5     rsp+0x218  coreclr+0x11f66a
rsp+0xd8   GameAssembly+0x49dcd3f    rsp+0x2d8  GameAssembly+0x1202175

What the last requests were (the "last API was the heartbeat" question)

21:11:50.944  POST /matchmaking/player/heartbeat   200  14.5 ms  len=631
21:11:53-57   POST /datacollection/data/event      200  x3
21:11:55.395  GET  /clubs/club/home/me             200
21:11:55.433  GET  /data/commonQuery/userSegment   404
21:11:55.648  GET  /lists/algorithmiclists/HotList 200  1171 b
21:11:55.675  GET  /rooms/rooms/bulk?id=2..19      200  21460 b
21:11:56.113  GET  /rooms/rooms/3/interactionby/me 200  47 b
21:11:56.124  GET  /rooms/rooms/3/experience       404
21:11:56.170  GET  /images/RecCenter?width=1024    200  71351 b
21:11:57.213  POST /datacollection/data/heartbeat   200
<crash> 21:11:58, dump written 21:12, notify-hub SSE closes 21:12:04

The request just before the crash is the data-collection heartbeat, and the matchmaking heartbeat 7 s earlier is the only call in the session with an unusual duration (14.5 ms vs sub-millisecond everywhere else, 631 b response), so both are on the list. But the crash is 1 s after the RecCenter art fetch and is a null dereference inside the game's own compiled code, so the room-3 load is the stronger reading. rooms/rooms/3/experience returning 404 is not obviously the cause: the reference server has no such route either.

The new instrument: rva -> method

An offset from a module base is worthless until it is a method name, and IL2CPP will hand the name over: the MethodInfo that il2cpp_class_get_methods returns begins with the compiled method's native entry point. 2023Patch/RadicadeAugustClient/CrashAddressMap.cs walks il2cpp_domain_get() -> assemblies -> images -> classes -> methods (all raw entry points are public in Il2CppInterop.Runtime.IL2CPP), writes rva<TAB>image<TAB>Class::Method to BepInEx/radicade-addr-map.txt, and logs the methods bracketing the target RVA. It is a background thread started 5 s into the boot, it never blocks the login, and it does not need the crash reproduced - the table is static. RADICADE_ADDR_MAP=0 disables it and RADICADE_ADDR_TARGET=0x... re-aims it at the next WER offset, so no code change is needed for the next crash.

Deployed BepInEx/plugins/RadicadeAugustClient.dll, 55296 b, md5 129e9b5ef7e55820f05d8440b9d5b900 (run-23d build kept as RadicadeAugustClient.dll.before-run24-addrmap, 50176 b). Build clean (0 warnings), and the shipped IL was verified by decompiling the built dll (CrashAddressMap.Start() is called from Load, and the enumeration + .txt path are present).

Next: one boot, then sort -k1 BepInEx/radicade-addr-map.txt offline and resolve 0x29a79ec plus the stack RVAs above through it - which names the method that dereferenced null, and the frames that led there.

Run 25 (2026-09-24 21:27) - RecCenter loads, then a second, different crash

This is the run that made the room load irrelevant: the room finished loading. The 2023 build's own load profiler ends with [Run Success Callbacks], SpawnLocalPlayer total 9857 ms, MoveToPhase(COMPLETE), and the server served everything in order (matchmaking/matchmake/room/3 -> instance 20876, room 3 metadata, photon token, reportjoinresult, verifyRole). The anti-cheat chain stayed off (CheatScan skipped MJLAGDJLOBC twice, no [HileGate] captured, no hile POST, no logout).

Then, ~10 s after the spawn, the client died again - at a different address:

Application Error 09-24 21:28  RecRoom.exe / GameAssembly.dll
  Exception code 0xc0000005   Fault offset 0x0000000003dc0c57

0x3dc0c57 is inside Unity.Entities.ChunkDataUtility::GetComponentDataWithTypeRO (exact entry point match), and the faulting instruction is mov rbx, [rdx+0x88] with rdx = 0xFFFFFFFFFFFFFF77 - a read of address 0xFFFFFFFFFFFFFFFF. So the game handed an ECS chunk accessor a garbage pointer, and il2cpp had no null check to catch it.

Compared with the 21:11 crash, that is two different fault sites (0x29a79ec is a 26-byte unnamed function; read of address 0x93) but the same shape: a pointer-arithmetic dereference inside the game's own compiled code, roughly 1-3 s after a periodic RRUI "Could not enable featured rooms display" warning. That warning is not the trigger - it also fires every ~7 s in the dorm and nothing happens. Likewise the 35 RecCenter_Chair ... has an RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component errors are pre-existing noise: they appear 35 times in both the crashing run and in the 21:02 run, dorm included.

Tooling that made this readable

  • BepInEx/radicade-addr-map.txt - 254 171 methods from 305 images, written in 141 ms (18.5 MB, benign: a background thread, 5 s into boot).
  • tools/pe_unwind.py - parses .pdata RUNTIME_FUNCTIONs and UNWIND_INFO (PUSH_NONVOL, ALLOC_SMALL/LARGE, SET_FPREG, SAVE_NONVOL(_FAR), PUSH_MACHFRAME, chained info) and replays a prologue to recover the caller - a real x64 unwinder. Two bugs found and fixed while writing it: ALLOC_SMALL carries its size in the op-info nibble (a zero operand advanced RSP by 8 instead of 0x28, which produced one bogus frame), and a past-the-end lookup used to reuse the previous function's unwind info and manufacture frames - a leaf function with no entry is now unwound from [rsp] instead, as a debugger would.
  • tools/minidump_stack.py --unwind - walks the dump's faulting thread; resolves the WER offset against the dump ("matches the WER fault offset") before using either.
  • tools/resolve_rva.py - function boundaries from .pdata, names from the boot-time map, and it says UNNAMED when the nearest name is not the function's own entry point.

The verified chain for the 21:28 crash (innermost first, exact-name frames only):

Unity.Entities.ChunkDataUtility::GetComponentDataWithTypeRO      <- crash
  (unnamed generic-instantiation frames, no metadata entry point)
IHDIFFAKHOM::JEONIAELMNM
  (unnamed)
EHEHGHCKFII::System.Collections.IEnumerable.GetEnumerator
Tool::CCFENLNCIOG -> Tool::LPAOCFPMJFD -> Tool::RpcOnReset
  -> Tool::AuthorityResetToDefault -> ToolCleanup::BFFGEGKNGMM
RecRoom.Core.Updates.StaggeredUpdateManager::DGHHCHACHOM
  -> (unnamed) -> UnityPlayer.dll main loop

Every frame's own .pdata entry was used (no leaf guessing except where flagged), and the walk terminates in UnityPlayer's scheduler, so this is a call chain rather than a stack picture.

Run 25b: the ObjectModel A/B (server-side, reversible)

Both crashes are inside Unity.Entities code reached from a staggered update, and the dorm has never crashed. ObjectModel_EnableForRoom_2022_07_01 is exactly the split test that gates Rec Room's ECS-backed object model for rooms, and this server shipped it as Allowed, so the 2023 client turns that model on whenever it enters a room. It is now Disallowed:

  • Server/RecNet/Config/SplitTestConfigs.cs - HardOverrides (fresh-DB seed),
  • Server/DB/DBConfig.cs - PlaceholderConfig (same value, same reason),
  • the live GameConfig row splitTestHardOverrides, updated in place with REPLACE(...,'ObjectModel_EnableForRoom_2022_07_01=Allowed','...=Disallowed').

The third one is the only edit that actually reaches this client: UpsertGameConfigValue and EnsureSplitTestHardOverrides only ever add what a row is missing, and RepairSplitTestConfigs documents the stored row as admin-managed ("never clobber a value that is already set"), so the DB row is the source of truth. Pinning the key in GameConfigAPI.Local2023Overrides was tried first and reverted: it contradicts that design and RecNetShapeConformanceTests.GameConfigs_SuppressInvalid2023SplitTestConfigs, which asserts the DB row is filtered and passed through rather than replaced.

Verified through the endpoint the client actually calls:

GET /api/gameconfigs/v1/all  ->  ObjectModel_EnableForRoom_2022_07_01=Disallowed

Server restarted (PID 11884, 3030/3031), build clean, SplitTest/GameConfigs_ tests 6/6.

Also eliminated while checking: room save data. Every subroom's DataBlob is empty - the working dorm room 25 included - and SubRoomHistory is empty, so "RecCenter's data is missing" is not a difference between a room that survives and one that does not. The client never requests a room blob at all (GET /cdn/room/ is only the CDN index, {"status":"ok"}, 22 b). The frames the map cannot name are generic instantiations: IL2CPP gives each one its own code but they share a single metadata entry point, so il2cpp_class_get_methods never sees them. Naming those needs the runtime instantiation table, not this map.

Run 25c: crash 3's own fault header, and the prop-binding difference

Crash 3's fault: block had not been printed before (an earlier sed filter cut it off). It is the same fault as crash 2, one instruction shape away:

fault: ACCESS_VIOLATION (0xc0000005) at 0x7ffc18cc728d
       read of address 0xffffffffffffffff
       in GameAssembly.dll+0x470728d     (matches the WER fault offset)
       rsp 0xc42b34ea20  rbp 0x261c24784a0

resolved (crash 3, exact names only):

Unity.Entities.EntityComponentStore::GetComponentArraySize region  <- crash, +0x140 = UNNAMED
  (unnamed generic instantiations)
IHDIFFAKHOM::JEONIAELMNM
  (unnamed)
EHEHGHCKFII::System.Collections.IEnumerable.GetEnumerator
  (unnamed) -> Tool::CCFENLNCIOG -> Tool::ResetToDefaultCollisionLayer
  -> Tool::LPAOCFPMJFD -> Tool::RpcOnReset -> Tool::AuthorityResetToDefault
  -> (unnamed) -> ToolCleanup::BFFGEGKNGMM -> ToolCleanup::CJGILKPKDOL
RecRoom.Core.Updates.StaggeredUpdateManager::DGHHCHACHOM
  -> (unnamed) -> (unnamed) -> UnityPlayer.dll scheduler

A read at 0xffffffffffffffff is an index that came out as -1: some TypeIndex/component lookup in the tool's component set has no answer, the code indexes with it anyway, and the base pointer lands one byte past the end of the address space.

The /similar lead is dead. The route does exist - Server/RoomServer/V2_RoomsAPI.cs ([Route("rooms/rooms/")], GET {id:long}/similar, our own local addition) - and it did answer 200 len=10166 for room 3, followed 2.5 s later by Error refreshing rooms from source RoomSimilarity: An error occurred. But run 24 (crash 2) never requested /similar and never logged that error, and it died with the identical chain, 12 s after the same room reported COMPLETE. Two runs, same crash, one with the carousel and one without: the carousel is a real but separate bug (our payload fails the client's RoomSimilarity parse), not the cause of the AV.

Timing is deterministic. Room load COMPLETE -> crash:

run COMPLETE crash delta props spawned props -> crash
24 (21:28) 32.50 s 21:28:33 ~12 s 28.41 s ~16.6 s
25 (21:44) 100.50 s 21:44:55 ~13 s 96.31 s ~16.7 s

So it is a timer that fires ~12 s after the room finishes loading, not something the player does: in run 24 the client sat in the room on heartbeats, in run 25 the watch menu was open.

The one thing that actually differs between the room that crashes and the room that does not is prop binding. RecCenter logs, once, at scene-setup time:

[RecCenter_Chair] / [Chair_White_Stationary] / [RecCenter_Couch] (N)
  has an RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component

Exactly 35 in both crashing runs; zero anywhere in the dorm (which shares the same log). Those objects exist in the Unity scene with a RigidbodyEx and are missing the ECS companions the ObjectModel needs. Tool::ResetToDefaultCollisionLayer iterates exactly that state - a tool's collision-layer component set - which is why it is the code that dies.

Run 25b's A/B aimed at the wrong key. ObjectModel_EnableForRoom_2022_07_01 never reaches the client's ObjectModel path - it does not appear in Player.log at all. The gate the client actually reads is the GameConfig family, served from the DB and logged at boot:

GameConfig 'UGC.ObjectModel.Allowed' = True          (DB row: UGC.ObjectModel.Allowed = true)
GameConfig 'UGC.ObjectModel.AllowedInRoomV1' = True
GameConfig 'UGC.ObjectModel.ScalabilityAllowed' = true, .R2Allowed = true, .DormAllowed = false

and the load log shows the path being taken, for the dorm as well as for RecCenter:

[DATA_DOWNLOADED] Fetched Deserialization Data...
START: UnloadSceneToEnableObjectModelTask
  Checking for Object Model Enabled flag...
[Moving to phase INIT_OBJECTMODEL...] -> [MoveToPhase(INIT_OBJECTMODEL)]

Disallowed therefore proved nothing about ObjectModel: the model ran anyway. Note the seeded UGC.ObjectModel.* values (incl. DormAllowed = false) are the reference's, so ObjectModel-on-for-rooms is the authentic August-2023 configuration, not drift of ours.

Where the room data should have come from. The load's [Downloading Data] report for RecCenter:

[AssetBundle_Download] 0.01ms | [SubRoom_Download] 0.01ms
[SuperRoom_Download]   0.00ms | [PlayerSave_Download] 4.00ms

SubRoom_Download is a no-op, consistent with every subroom DataBlob being empty. If the prop -> ECS bindings normally arrive with the sub-room save, an empty save is precisely what leaves scene props with a RigidbodyEx and no EmbodiedObject/RbexEntity/TransformEntity.

Open, in order of expected value:

  1. Serve a real RecCenter sub-room save (or otherwise complete the prop bindings) and see whether both the 35 bind errors and the AV disappear - this tests the cause directly.
  2. A/B UGC.ObjectModel.Allowed=false (+ AllowedInRoomV1) in the DB row: cheap, reversible, and it removes the whole ECS object path. Divergence from the reference, so treat it as a probe, not a fix.
  3. Stopgap only: make Tool::ResetToDefaultCollisionLayer (0x1201e32..0x1201e90) a no-op from the client plugin. Removes the crash without fixing the corrupt entity, so the tool simply never gets its collision layer restored.
  4. Separate bug: fix our /rooms/rooms/{id}/similar payload shape so the RRUI carousel stops failing with Error refreshing rooms from source RoomSimilarity.

Run 27 (2026-09-24 22:22, tool guard deployed): no crash - and the join NRE named

Player.log 22:22:34 -> 22:36:23 (~832 s, no crash), LogOutput.log carries 9 [ToolGuard] skips, all of them game-role nodes and shape containers ([GameRoleNode]_INJHAOJMPFF_*, [StandaloneShapeContainer](Clone)) rather than the RecCenter chairs, and zero has an RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component lines - every run that dies has exactly 35. Three joins:

time target result
22:22:42 dorm 25/32, instance 20886 COMPLETE
22:29:51 room 54/57, instance 20887 COMPLETE
22:30:17 dorm 25/32, instance 20888 FailedToSpawnPlayer (8718)

The third is the room bug the guard did not cover: the load walks every phase to SCENE_LOADED, then dies inside SETUP_ROOM_OBJECTS.

[471.4358] Starting RunFor.All handler 729481d6-1503-47c8-b401-f98f736a13f2 (1 handlers)
[471.4486] [Error | RoomOperation] Error during operation InitialRoomLoad-a820aca0-...:
           Object reference not set to an instance of an object.
[471.4830] Reporting result FailedToSpawnPlayer / (Code 8718) to RecNet Matchmaking ...

Stack (managed frame bottom of the throw):

EMKMMPEIGLD.FACCFIGFBIP(IEnumerable<T>)
EMKMMPEIGLD.HLLNBBPLCAH(Tuple<T1,T2,T3>)
KOGBHMMNAJA1.EIAKBDPCFOD(Action1)  <- broadcaster
EMKMMPEIGLD.KOIPGJONEBE(LPPEEGCNOPD, CancellationToken)
IGOEMHGCPBC2+DGLCCMDLGNI.Invoke(request, ct)
AGCMEKDMOEG+PAIKPNCMAPO.<<InvokeMethodsInParallel>b__2>d.MoveNext()
AGCMEKDMOEG.FFAFPFAGKDH(Guid, List, IEJHLHGIEFP, LPPEEGCNOPD, CancellationToken)
<<LoadRoomLocal>b__0>d.MoveNext()

Two frames above the NRE:

[22:30:15] Operation SaveRoom sent. Waiting...
[22:30:15] Unobserved exception throw ... BDADBLBKLJN.OABJEHOGBIM(GKENAAINMOP)
[22:30:17] Error during operation RoomSave-6ad9514c-...: Object reference not set to an
           instance of an object.

Both sites resolve by name without a crash dump: grep -la EMKMMPEIGLD BepInEx/interop/*.dll answers RecRoom.Keepsakes.Runtime.dll, and BDADBLBKLJN is RecNet.Runtime.dll. So the handler that faults a room load is the keepsake runtime's:

  • It runs in SETUP_ROOM_OBJECTS, again in RUN_MIGRATIONS, again in POST_DESTROY_OLD_OBJECTS - one 729481d6 per keepsake pass - and the identical NRE is thrown on the loads that survive (22:22:49.5, 22:29:59.0) as an unobserved task exception. Chronic, and fatal only when it lands inside the phase run.
  • Its fetches line up exactly: GET /api/keepsakes/rooms/25 + categories + globalconfig at 22:22:48.988, GET /api/keepsakes/rooms/54 at 22:29:58.388. The failed load at 22:30 makes no keepsake request at all - it dies before/inside that pass.

What we were serving, and what the reference serves (apps/api/src/routes/gameplay.ts):

route ours (run 27) reference
GET /api/keepsakes/rooms/{id} 200 {FIOFPPHNMHM:[],EBKHAGMFFEK:[],OFNJMGKKKJP:[]} (69 b) 204, no body - "the client treats a body here as data"
GET /api/keepsakes/categories counted set with one fabricated keepsake (IIILIMOMKDK...) {Results:[],TotalResults:0}
GET /api/keepsakes/globalconfig {NPCHGMEHAPA:true,NEFKKONMGJI:100,PJMJGIKKGJB:true} {KeepsakeFeatureEnabled:true,KeepsakeRoomLimit:10,SocialXpBoostEnabled:false}

The reference's note on categories is the same failure mode one layer down: the client parses it as an object and "an array fails it outright - expected:'{', actual:'[', at offset:0, logged as 'Failed to get keepsake categories' - which takes the keepsake load down with it". Fabricated entries with guessed obfuscated field names are that same class of input: a keepsake the client resolves that exists nowhere.

Fix (server, this run). Server/API/Keepsakes/KeepsakesAPI.cs now answers exactly what the reference does - 204 with no body for a room's keepsakes, an empty counted catalog, and the client's own field names on the config. Tests pin all three shapes (StartupApiCompatibilityTests: empty catalog, NoContentResult, config names).

Fix (server, same session). The save that preceded the failure also faulted, and the session's only subroom save (POST /rooms/rooms/54/subrooms/57/data, 200, 5718 b) is the call it followed. The reference is explicit that this response is the one envelope the client reads differently:

value is { room, subRoomDataSave }, and error is NULL rather than "". The subRoomDataSave is camelCase with a different field set from the PascalCase CurrentSave embedded in the room ... Don't unify the two projections.

We were serving the room's CurrentSave there - PascalCase, with none of unityAsset, unityAssetHash or the echoed dataBlobHash. RoomUtils.BuildSaveResponse now serves the documented projection (and dataBlobHash is read off the save body and echoed), while the room's own CurrentSave is untouched. The other documented trap in this path is fixed too: a subroom carries CreatorAccountId, null until its first save and the saver's id after - "the client NREs on a null one" - which we did not send at all.

Tests: 560 pass, including SaveAcceptsTheJsonEnvelopeThe2023ClientPosts (now asserting the camelCase save + a non-null subroom creator), SaveResponseEchoesTheBlobHashItWasGiven, and AnUnsavedSubRoomReportsNoCreator.

Server relaunched (development environment, cwd Server/, server-console-run28.log), and the new shapes verified live: rooms/25 -> 204, categories -> {Results:[],TotalResults:0}, globalconfig -> KeepsakeFeatureEnabled/RoomLimit 10/SocialXpBoost.

How to read the next run. The join is fixed if 729481d6 completes on the dorm load and the post-load keepsake pass stops throwing:

grep -c "Unobserved exception" Player.log
grep -n "729481d6" Player.log
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:3030/api/keepsakes/rooms/25   # 204

Still open: the missing SignalR UnsubscribeFrom the client calls on every transition (autoloc.job, HubException: Method does not exist), and real keepsake storage - the emptied catalog means no keepsake can be placed until it exists.

Run 28 (2026-09-24 23:25, live): the watch page's missing buttons are two missing routes

The watch's "This Room" tab makes exactly three requests as it opens, and the middle one was the only failure:

[23:27:18.014] GET /rooms/rooms/25/interactionby/me -> 200 len=47
[23:27:18.031] GET /rooms/rooms/25/experience     -> 404 len=0
[23:27:18.097] GET /images/default.png?width=1024 -> 200 len=80001

The tab renders with SETTINGS alone - no LOAD, no SAVE ROOM, no ROOM STATS, no subroom (room list) button. The client logs each failure as a crash-report line (OBHEMJEJNMK: HTTP Error 404 through GFIBIELMKBM+<>c__271[TResponse].NOFENLOLAIH), 10 of them in the session against 12 rooms/rooms/25/experience` 404s, and it keeps re-asking rather than settling.

Not permissions: the room payload already reports the viewer's role (Roles: [{"AccountId":2,"Role":255,...}], 255 = Creator) and CreatorAccountId: 2 - which is also how the subroom CreatorAccountId added in run 27 shows up on the wire.

Neither rooms/{id}/experience nor rooms/{id}/experience/player existed on our server at all. The reference's note on the player half is the cost of that: nothing tracks per-room progress, so it serves an empty list - "which the client reads as 'no progress in this room', where a 404 would stall the room load" - and the settings half is a bare two-key object, Enabled: false with DailyLimit: 1000, "the cap that WOULD apply ... the client reads both keys whatever Enabled says". Both added to V2_RoomsAPI (GetRoomExperience, GetRoomExperienceForPlayer).

The other 404 on the same page is econ/econ/roomGiftDropShops/room/{id}, asked for on every run (run 16 through run 28, 1-5 times each) and never once served. The reference answers an empty list - "Empty stub so the client doesn't 404" - so RoomGiftDropShopAPI now does. It is the only room-economy route this build asks for: roomInventory, roomOffer, roomEconConfig and ugcPurchasables are never requested (checked across all six server logs), so no stubs for them were added.

Tests: 563 pass, adding RoomExperienceAnswersTheTwoKeysTheClientReads, RoomExperienceForPlayerAnswersAnEmptyList and RoomGiftDropShopsAnswerAnEmptyList. Server relaunched (server-console-run29.log, cwd Server/), and every route verified live:

rooms/rooms/25/experience           -> 200 {"Enabled":false,"DailyLimit":1000}
rooms/rooms/25/experience/player    -> 200 []
econ/econ/roomGiftDropShops/room/25 -> 200 []
api/keepsakes/rooms/25              -> 204 (empty)

Run 29 (2026-09-24 23:50, live): MakeItToMidnight imported, and what the create route dropped

C:\RecRoom\Downloads\MakeItToMidnight is a rec.net archive of the August 2023 RRO MakeItToMidnight: room_data.json (the room, its one subroom "Home", and 100 revisions of that subroom's save), blobs/*.room (137 MB, one scene blob per revision) and image/*.png (the thumbnail). There is no Unity asset bundle in it and none is needed - a room's objects and prefabs are shipped in the game build and a save only names them - so the subroom is imported with an empty UnityAssetId.

Two things decide which revisions may be imported, and both belong to the client that has to open them:

  • PersistenceVersion. The revisions from this client's own build week (2023-08-21 through 08-28) are all 61 - one v63 on 08-21 aside - which is the schema the build writes; the 67 later ones climb 63, 64, 66, 68, 70, 72, 77, 87, 88, 103, 128 and 138 (the last from 2024-07) and a newer save than the client can read is a room that opens broken. The taco importer's gate was "38 or lower" because its archive was 2020-2022: the number is a property of the archive's era, not a constant.
  • CreatedAt, as the second net. 33 revisions pass (2023-08-21T20:20 through 2023-08-28T21:00, 44 970 347 bytes).

The newest of those is the LIVE save and every other one is posted through the game's own save route, so each lands in the subroom's history exactly as an ordinary save does - and each POST pushes the revision it replaces into that history, which is what makes the whole list restorable. The room is created on the OLDEST revision and walked forward, because whichever revision is posted LAST is the one that stays live: creating it on the newest and then posting the older ones leaves the room serving the second-newest save, which is what the first attempt did.

tools/import_room_dump.mjs runs it in resumable phases (plan|upload|register|verify|redate), recording what it uploaded, created and posted in artifacts/room-dump-import/<room>.json. Blobs keep the archive's own names (5k1vmec9n5jf4dymy0zkjligj.room), so a save in CDN storage stays traceable to the dump it came from, and the redate phase re-stamps the revisions with the dates the archive recorded - the save route stamps everything it records with the moment of the POST, so the restore list would otherwise read "just now" 33 times and be useless to pick from.

Result: room 55 / subroom 58, owner 2, Public, MaxPlayers 5, WarningMask Scary (1), tagged import, thumbnail 798fe98e05b944999ab8c76181f45367.png, scene a75f7547-79eb-47c6-8986-6767abcb4f92 (Maker Room), and 33 saves carrying the archive's dates.

What the import exposed in admin/rooms/create/room

The first create answered Created as 55! and the row was wrong: Accessibility 0 (Private), WarningMask 0, Tags empty. The route reads its body through JsonExtension.Get<T>, which could only read scalars:

  • An enum is not a type JsonValue.GetValue<T>() knows, so it threw and the caller took its fallback - every room created through this route came out Private with no warning mask, and nothing in the response said a field had been dropped.
  • A collection never got that far: GetValue<object>() on a JsonArray throws before the scalar read, so the tag list fell back to empty. The taco importer's Tags never landed either.
  • The bool path ("1" and "true" both mean true) was already there, but read its node through GetValue<object>(), which hands back a JsonElement for a body parsed from JSON rather than an int or a string - the check could not see either shape.

Get<T> now reads the node's own JsonValueKind, converts an enum from its number or its name, deserializes a JsonArray/JsonObject whole, and keeps the bool spellings. Room 55's three fields were corrected directly in SQL, because the running server could not be rebuilt mid-session: the live client holds Server/bin/Debug/net9.0/Server.dll, so the verification build goes to .external/verify-build/bin/ (-p:BaseOutputPath=...) and the apphost exe is skipped entirely.

A second, separate bug in the same block: loadScreen is NOT NULL with a CLR default of [], and a payload without LoadScreens wrote null over that default - the insert failed with a required-property error, a 500 for any import that did not happen to send an empty list.

Tests: 569 pass, adding AdminRoomCreateTests - enum binding by number and by name, the bool spellings, the tag list with its tag types, the defaults an absent field still gets (including loadScreen), the subroom's scene and blob, and the Maker Room fallback for a scene the client does not know.

Verified in the client

The live session had already found the room by the time the import finished:

[23:57:23] GET /images/798fe98e05b944999ab8c76181f45367.png                        -> 200
[23:57:25] GET /rooms/rooms/55?include=5421&unityAssetTarget=0&unityAssetVersion=1 -> 200
[23:57:35] GET /cdn/room/5k1vmec9n5jf4dymy0zkjligj.room                            -> 200 len=1373747
Player joined ^MakeItToMidnight in scene "Maker Room" with playerCount 1
[RoomLoad.RoomManager] -- Initial Room Load '^MakeItToMidnight' (55.58) [instance 20907] --
[23:57:49] Moved to phase COMPLETE

The load walks every phase through SETUP_ROOM_OBJECTS, RUN_MIGRATIONS and DestroyAndRebuildObjects, with 0 occurrences of the no EmbodiedObject/RbexEntity/TransformEntity component prop-bind error that ran at 35 for the RecCenter, and 0 FailedToSpawnPlayer. The only errors after the join are the room's own circuits (CircuitsV2Errors, "Studio Object is invalid!") alongside the guard's 25 ToolGuard skips.

One note for the next restart: the fixed Server.dll is not in Server/bin/Debug/net9.0/ yet, because the running server holds it. A plain dotnet build ServerTests/Server.Tests.csproj -c Debug once the client closes picks it up, and nothing about room 55 depends on it.

Run 30 (2026-09-25 00:00, live): the portfolio "crash" is the anti-cheat's dorm drop

Clicking your own portfolio closed the game. It is not a crash: Windows recorded no RecRoom.exe fault either side of it (the last one is 22:09:19, the pre-ToolGuard GameAssembly.dll+0x470728d), and the process ran the game's own shutdown instead - [EAC] Tearing down and releasing runtime, then Trying to send photon room updates to server while application is quitting. What Player.log has at 00:01:05 is the answer:

[00:01:05] [Warning] [Default | Warning] Dropping to dormroom due to error [385120]
[00:01:06] [Log] [Unity] [EAC] Tearing down and releasing runtime

and BepInEx/LogOutput.log has the chain, one millisecond wide:

[CheatTrace] +244681ms CheatManager.OnObscuredTypeCheatDetected
[CheatTrace] +244682ms CheatManager.EFLCHFDDPJP
[HileGate] anti-cheat report captured (full wire form): body: Message=&Type=0
[Net] Post https://localhost:3030/api/PlayerReporting/v1/hile      -> 200 body=true
[QuitVeto] wantsToQuit answered false (block #1) - this is the anti-cheat shutdown

CheatManager.EFLCHFDDPJP(string reason = "Dropping to dormroom due to error [385120]") is the whole of the message, and that string is the PARAMETER'S DEFAULT VALUE - 385120 is not an error code, it names nothing, which is why every drop in every session reports the same number. Its caller is the obscured-type handler, and what the page does to trip it is in the server log: the profile renders the caller's own custom avatar items, and GET /api/customAvatarItems/v2/fromCreator/2 is fetched 7 times in 10 ms immediately before the detection (00:01:03.921-00:01:05.699).

The server side of that page is faithful and stays as it is: /accounts/account/2/bio 200, /api/customAvatarItems/v2/fromCreator/2 200 with the reference's empty page, /accounts/accountprivacysettings/2 200, /rooms/rooms/ownedby/2 200, and 404 with an empty body on both influencer routes - which is the reference's own answer for "not an influencer", not a gap.

So the verdict is removed at its source, exactly as the file scan was in run 23d, and the result is already deployed (BepInEx/plugins/RadicadeAugustClient.dll, 62464 b, md5 8718a45dd42d7cb72b3e46e04b744d91, backup .before-run30b-cheatdrop):

  • CheatDetectionSuppressPatch - skips all three verdict handlers (OnObscuredTypeCheatDetected, OnTimeCheatDetected, OnAdvancedMovementCheatDetected), not just the one that fired. Skipping only the obscured one would leave the other two live for whatever a clock-skewed or laggy machine trips next. A handler that does not resolve is logged and skipped rather than thrown on - the throw would take the whole patch block (exit veto included) down with it - and the startup line prints verdict handlers guarded=3 (or names the unguarded one). RADICADE_CHEAT_TYPE=0 restores the detections.
  • CheatDormDropGuardPatch - holds the drop itself back, narrowly: only when the reason contains 385120, the anti-cheat's own default. Any other reason still drops the player, so a genuine room failure behaves as before. RADICADE_CHEAT_DROP=0 lets every drop run.

Both are registered in their own try/catch, so a resolution failure on a different build cannot undo the exit veto, the exit tracer or the hile gate applied above them.

Run 30, second half: rooms/rooms/{id}/similar answered a bare array

The "Similar Rooms" rail is a paged DTO in this client, and the route answered Array.Empty<object>() - Malformed Response: '[]' in Player.log, one of 9 in the session (and 5 in the one before). A room that did not exist was a 404 on top of that, which stalls the screen the rail sits on rather than emptying one row. GetSimilarRooms now returns RoomSearchResult (scored by shared tags, same creator as the fallback for untagged rooms, visibility checked per candidate, unknown room = empty page) and pages skip/take. ServerTests/SimilarRoomsTests.cs pins the envelope, the empty page, the paging and the visibility rule. 573 tests pass.

/econ/econ/roomGiftDropShops/room/{id} ([]), /rooms/rooms/{id}/experience/player ([]) and /econ/api/roomkeys/v1/room ([]) were each checked against the reference while chasing the remaining Malformed Response: '[]' lines: all three are documented there as empty lists, so the chronic parse errors are not from them and are left alone for now - they are logged, not fatal.

The restart, and the one trap in it

The server was rebuilt and relaunched for the similar fix (server-console-run30.log, cwd Server, PID 33272). Launching the exe directly from Server/ picks up appsettings.Production.json

  • Hosting environment: Production, a different JwtKey, no HTTPS listener on 3030 and no Photon app ids, which would break the client at the room join. The launch must carry the environment:
$env:ASPNETCORE_ENVIRONMENT='Development'
Start-Process ...\Server\bin\Debug\net9.0\Server.exe -WorkingDirectory ...\radicade-server\Server

and the log has to read Now listening on: https://localhost:3030 and Now listening on: http://127.0.0.1:3031 and Hosting environment: Development. Live after the restart:

rooms/rooms/55/similar        -> 200 {"TotalResults":0,"Results":[]}
rooms/rooms/99999/similar     -> 200 {"TotalResults":0,"Results":[]}   (was 404)
rooms/rooms/ownedby/2         -> 200 len=5950
api/customAvatarItems/v2/fromCreator/2 -> 200 {"Results":[],"TotalResults":0}

(Authorization: Bearer needs a token signed with the Development JwtKey; ?access_token= is only honoured on notify-hub paths, which is why a copied client token reads as 401 here.)

Run 31: the room's Unity asset bundles, uploaded and bound

The August build's MakeItToMidnight import had no UnityAssetId because the room dump (C:\RecRoom\Downloads\MakeItToMidnight) carries no bundle - the importer's note is right, and tools/import_room_dump.mjs says the same thing: the save only names the content and the bytes live elsewhere. The bytes turned up separately, in C:\RecRoom\Downloads\AssetBundles: 87 *.assetbundle files plus one 546 KB .room, no manifest, no index.

What the archive actually is

The files are named by their CDN blob name and carry the CDN's own timestamps, and those two facts recover the whole structure. Group by upload time and each group is one publish event: Rec Room uploads one bundle per target, seconds apart, and every bundle in a group carries the same container path, which names the room it was built for.

2023-08-21 13:10  n=7   333.3 MB   Assets/RoomSource/Assets/Rooms/MakeItToMidnight/MakeItToMidnight-Home.unity
2023-08-22 10:34  n=7   333.4 MB   ...
2023-08-23 12:48  n=7   333.3 MB   ...
2023-09-12 18:49  n=6   308.2 MB   ...

Two rooms are in there (MakeItToMidnight, 12 batches, and KOdByDawn, 3), across Unity revisions 2020.3.33f1 (the August 2023 build - the only one this client can load), 2021.3.29f1 and 6000.0.27f1. SerializedFile.target_platform separates the targets inside a batch, and the texture formats confirm what the numbers mean: 9 and 13 carry ASTC (iOS and Android - twice, a phone and a Quest build both report Android), while 5, 31, 43 and 44 carry BC/DXT (StandaloneWindows and the consoles). Each publish costs ~333 MB across targets; 5 alone is ~85 MB.

The bundle is not optional decoration. The 08-23 Android bundle alone holds 8080 objects - 1490 GameObjects, 1366 meshes, 1255 MonoBehaviours, 291 textures, 171 AudioClips - and the names in it (Survivor 1, [PerkIcon], PhotoBooth_Curtain*_LOD*) are the names the room save builds its object tree from. A subroom with an empty UnityAssetId loads the save against whatever the build itself ships, which is exactly why the room opened as a Maker Room with its content missing.

Which bundle belongs to the live save

The live revision is the newest v61 save, 5k1vmec9n5jf4dymy0zkjligj.room (2023-08-28T21:00Z), and the dump records the asset id it advertised: 32cafb11-c51b-485c-8f1f-73b73159feff. The dump is the only place that id appears, so the batch it names was pinned by time, and the pinning is exact once the archive's clock is accounted for - its zip entry times are the CDN's local wall clock, exactly 7 h behind the UTC CreatedAt the saves carry:

unityAssetId                           first saved              publish batch       delta
bd362d85-6cc5-4b6f-9d3b-33774e3938aa   2023-08-21T19:59:45      2023-08-21 13:10     +11 m
14c842e7-01f0-4b81-9d03-19f39b7393d0   2023-08-21T20:20:28      2023-08-21 13:10     -10 m
20e15a8f-faca-41dc-8025-dd7da5c3cd0c   2023-08-22T17:25:32      2023-08-22 10:34      +9 m
3fb814c1-271e-4e54-960e-d6298947baaa   2023-08-22T17:27:21      2023-08-22 10:34      +7 m
32cafb11-c51b-485c-8f1f-73b73159feff   2023-08-23T19:39:06      2023-08-23 12:48     +10 m

So 32cafb11 is the 2023-08-23 publish, and in that batch the desktop bundle is d7ftni8391e3nrdts2drs4qlb.assetbundle - StandaloneWindows, 2020.3.33f1 (the client's own revision), 85,232,592 bytes, md5 31b4faa66491de6106ce748a457f9905. Two saves share that id because the later revisions (2023-08-24..28) changed the room without republishing content, so one bundle serves them all - which is also why the earlier ids matter only for history restores.

Uploaded and bound

The client resolves a bundle in two steps - GET /rooms/unity_assets/{assetId}/{target}/{version} returns {UnityAssetId, Target, Version, Filename}, and the bytes come from /data/{Filename} - and UnityAssetsCDN.ResolveFilename looks for a file named exactly the id (or the id plus .assetbundle) in the CDN storage directory. So the file is stored under the id the save advertises, and the room's subroom points at that id:

Server/RadicadeServer-Data/CDN/Storage/32cafb11-c51b-485c-8f1f-73b73159feff.assetbundle   85232592 b
Server/RadicadeServer-Data/CDN/Storage/32cafb11-c51b-485c-8f1f-73b73159feff             hard link
UPDATE SubRooms SET UnityAssetId='32cafb11-c51b-485c-8f1f-73b73159feff' WHERE SubRoomId=58;

Both spellings are stored because the loader reaches for the bytes two ways: the metadata route hands the client a Filename that it fetches from /data/{Filename}, and the CDN path for a bundle is /unity/{id} - the id with no extension. ResolveFilename checks the bare id first, so the extensionless name is what the metadata reports; the hard link costs no disk and covers the route that asks by id.

Verified against the running server (cwd Server, PID 33272 - the storage directory the server reads is Server/RadicadeServer-Data/CDN/Storage, not the one at the repo root):

GET /rooms/unity_assets/32cafb11-.../0/1  -> 200 {"UnityAssetId":"32cafb11-...","Target":0,
                                                  "Version":1,"Filename":"32cafb11-..."}
GET /data/32cafb11-...                    -> 200 len=85232592  Accept-Ranges: bytes
GET /data/32cafb11-... (Range)            -> 206 Content-Range: bytes 0-15/85232592
GET /rooms/rooms/55?include=5421&unityAssetTarget=0&unityAssetVersion=1
                                          -> CurrentSave.unityAssetId = 32cafb11-c51b-485c-8f1f-73b73159feff

The unityAssetTarget=0 the client sends is RecNet's own target list, not the SerializedFile platform id the bundle carries: the server answers the id with one file, so the target in the query does not have to be matched to the target in the bundle.

The tool, so the next room is one command

tools/import_room_bundles.py does the three steps above and is what produced the tables in this section:

python tools/import_room_bundles.py inventory --source ".../AssetBundles" --zip ".../AssetBundles.zip"
python tools/import_room_bundles.py plan --source ... --zip ... --dump ".../MakeItToMidnight/room_data.json" \
       --room MakeItToMidnight
python tools/import_room_bundles.py bind --asset-id 32cafb11-... --batch 2023-08-23 \
       --source ... --zip ... --subroom 58 --apply

inventory probes every bundle (platform, room container, Unity revision, object count) and groups by publish batch - 2m37s for all 87, written to artifacts/asset-bundles.json. plan pairs a room dump's asset ids with batches using the clock alignment above, and flags a pairing an hour or more out as DISTANT rather than pretending to know it. bind copies the batch's StandaloneWindows bundle into CDN storage under the id, points the subroom at it, and then runs the client's own two-step resolution against the live server to prove it answers. The dump's own file name is deliberately not the stored name - the id is what the client asks for - so the inventory JSON is the record of which real blob each id was bound to.

KOdByDawn is in the same archive with three batches and is bound the same way if it is ever imported. The remaining older ids can be bound too, for history restores that keep their own content: 14c842e7 and bd362d85 are the 2023-08-21 batch (a4al16vq0235oom5aplev2omu.assetbundle), and 20e15a8f / 3fb814c1 are 2023-08-22 (esnrn95jqftokte3hmy8m1ffr.assetbundle).

Run 32 - the beta tag on room 55, and a room that is developer-owned when the maker is a dev

Room 55 does not carry beta

Rooms.Tags for room 55 is import,0 - one entry, import at TagType.General. There is no beta entry, so the room does not advertise itself as beta-enabled the way the imported Showdown room does (rro,2|games,0|beta,1, TagType.Auto).

Two things worth keeping, because they explain the value rather than just reporting it:

  • The importer wrote it. tools/import_room_dump.mjs:228 stamps every imported room with a hardcoded [{ Tag: 'import', Type: 0 }], so whatever the real room carried was overwritten at import time - this is a latent bug for every room imported that way, not a fact about MakeItToMidnight.
  • The archives cannot tell us what the original tags were. All 48 captured room JSONs in rooms.zip (more rr rooms/*/room_*.json) carry no room-level Tags field at all - the exporter drops it - and the MakeItToMidnight/room_data.json dump only has save-level Tags: [], which is unrelated.

Nothing server-side branches on the room-level tag: grep '"beta"' Server/**.cs finds only the comment in Program.cs:564, and the tag is pure pass-through (FullRoom.CopyTo copies Tags straight into the room payload) with the client acting on it. Beta creative-tools creation is already enabled globally by EnableBetaCreativeToolsConfigs (UGC.AllowBetaInvertedTubeCreation, UGC.AllowNonBetaInvertedTubeCreation, UGC.EnableInventionsInBetaRooms, UGC.Room2Enabled), so the missing tag is not what keeps beta content from loading. To match the real room anyway:

UPDATE Rooms SET Tags = 'import,0|beta,1' WHERE RoomId = 55;

Left unapplied - the user only asked whether it was there.

IsDeveloperOwned was never in the room payload

The 2023 client's room parser has the field (it is in the August metadata dump, and in the captured RecNet responses: "IsDeveloperOwned": true on a developer's room), but FullRoom did not declare it at all, so every room on this server read as not-developer-owned and the client's Dev Rooms row stayed empty however the rooms were made.

Where a room is made matters: there is no create-room route. A player creates a room by POST /rooms/rooms/{id}/clone (RoomServerAPI.CloneRoom -> RoomUtils.CloneRoom), which is the same method GetOrCreateDorm uses to make a dorm - so the flag has to come from the maker's roles and has to exclude dorms.

What changed:

  • RoomData.IsDeveloperOwned (bool, default false) - a column on Rooms, bit NOT NULL DEFAULT 0.
  • Room.IsDeveloperOwned - declared on the response base, so it is emitted for room lists and room details alike, as IsDeveloperOwned (Program.cs pins the serializer to no naming policy, the same PascalCase the real API answers with).
  • RoomUtils.CloneRoom stamps it: !room.IsDorm && await ctx.IsDeveloper(newCreator).
  • RoomUtils.ConvertToFull and FullRoom.CopyTo carry it to the payload.
  • AdminRoomAPI.CreateRoom (the import route) honours an explicit IsDeveloperOwned in the body - a captured RRO room is developer-owned on the real service even when it is recreated here under a local owner - and otherwise derives it from the owner it is being created for.
  • EnsureRoomDeveloperOwnedColumn on boot, same self-heal pattern as UnityAssetId and the join-gift columns: EF maps the property onto every Rooms query, so the column has to exist before any startup step reads a room.

Verified live against the restarted server (PID 38436, server-console-run32.log, Development, 3030 + 3031). The column is in place and the payload carries the field:

GET /rooms/rooms/55?include=5421...  -> IsDeveloperOwned: false
GET /rooms/rooms/23                  -> IsDeveloperOwned: false
GET /rooms/rooms/25                  -> IsDorm: true, IsDeveloperOwned: false

Then both directions of the rule, through the client's own clone route, with tokens minted for the two accounts on this server (2 TestPlayer = developer,influencer; 1 Coach = influencer):

POST /rooms/rooms/23/clone (name=DevOwnedProbe, account 2) -> RoomId 56, IsDeveloperOwned: true
POST /rooms/rooms/23/clone (name=PlainOwnedProbe, account 1) -> RoomId 57, IsDeveloperOwned: false

Both probe rooms were deleted again (RoomRoles, RoomStats, SubRooms, Rooms), leaving the 51 rooms that were there before. dotnet test ServerTests/Server.Tests.csproj -> 581 passed (573 before, +8 in ServerTests/RoomDeveloperOwnedTests.cs: a dev's clone, a plain player's clone, a dev's dorm, the flag on the room that is read back, the explicit false in the payload, and the three admin-import cases).

Every room that predates the column started false. The backfill was then applied, over the same rule the create path uses (the maker's role, dorms excluded) - 5 rooms flagged, 46 left false:

UPDATE Rooms SET IsDeveloperOwned = 1
WHERE IsDorm = 0 AND CreatorAccountId IN (SELECT accountId FROM Profiles WHERE role LIKE '%developer%');
31 TestPlayers_MakerRoom_3528  52 studio-room-SdjyyctVdc  53 studio-room-NRyzI1_njb
54 TestPlayers_MakerRoom_1603  55 MakeItToMidnight         (25 DormRoom - dorm, left false)

Room 55 is account 2's, so MakeItToMidnight became developer-owned along with the four the user built - which is also what the real service says about that room. It is a data-only change, so the running server picked it up without a restart; the detail route and the bulk list route the client's own room lists read both answer it:

GET /rooms/rooms/55?include=5421...   -> IsRRO: true,  IsDeveloperOwned: true
GET /rooms/rooms/bulk?id=31..55       -> IsDeveloperOwned: true  (x5)
GET /rooms/rooms/bulk?...&id=25       -> IsDorm: true, IsDeveloperOwned: false

Run 33 - Showdown reimported, and the four-character bug that was skipping it

The request was that Showdown was "a little broken", and to delete and reimport it from a fresh export at C:\RecRoom\Downloads\Showdown. The import ran, but the interesting part is why the previous one had not: the boot importer was looking in a directory that does not exist here.

What the export actually is

The folder is a RecNet export, not a captured room dump: room.json plus 22 per-subroom directories, each carrying room_data.json and Saves/<saveId>/<blobId>.room. Showdown.rar (46 MB, RAR5) holds the same tree. The export describes the real room 52815862 - CreatorAccountId 1, Accessibility 1, IsRRO true, IsDeveloperOwned true, WarningMask 0, MaxPlayers 6, CloningAllowed false, PersistenceVersion 150, CreatedAt 2022-03-03, image 65hs5cb56byqs05lu16kr3e8r.png, and the description about Deputies and Bandits. 22 subrooms is not 22 live subrooms: they are archive snapshots, all sharing the one scene a75f7547-79eb-47c6-8986-6767abcb4f92, and they are named accordingly - SD_Archive_001, SD_Archive_082322, SD_Archive_11_29_2022_STABLE, SD_Archive_13_061824, and so on. The single subroom the server actually needs is the one every other room here has: one Home row pointing at the scene and the save.

The root cause: room-imports vs RoomImports

EnsureShowdownRoom looked for its files in ..\room-imports\Showdown and room-imports\Showdown

  • always lowercase. This checkout spells the directory RoomImports, and Test-Path answers False for the lowercase spelling even though the tree is not case-sensitive as a whole. So the lookup found nothing, the importer printed [Showdown] Skipped import - missing files ... and returned on every single boot, and the room kept whatever an older import had left behind. A silent skip is the worst kind of green: the log line is there, but nothing above it looks like a failure.

The fix keeps the spelled-correctly path first and the old lowercase spellings after it (so a tree where they do resolve, or a deployment that still ships that name, keeps working):

var candidates = new[]
{
    Path.GetFullPath(Path.Combine("..", "RoomImports", "Showdown")),
    Path.GetFullPath(Path.Combine("..", "room-imports", "Showdown")),
    Path.GetFullPath(Path.Combine("RoomImports", "Showdown")),
    Path.GetFullPath(Path.Combine("room-imports", "Showdown")),
};
var sourceDir = candidates.FirstOrDefault(Directory.Exists) ?? candidates[0];

Which save to install, and why the newest one is the wrong one

A room here is a bundle plus a save blob, and the two have to be the same revision or the client loads a scene it has no data for. The bundle was already identified by hash: RoomImports/Showdown/Showdown_0_1.assetbundle is 76,719,774 bytes, md5 220891bc78b852df1c7ba8e1af0e9d1b, and matches 24 metadata entries - among them asset 58784cb3-204c-47e0-9cf3-23a76b94ae90 as Unity_2020_3_33f1, Target 0, Version 1. That asset revision is the one the August client can load, and the save that goes with it is 8u07n4iwzt4xbqkww7zun0jam.room (2023-06-02, 1,222,161 bytes), the newest save whose revision is still 2020.3.33f1.

The newest save in the export looks tempting and is a trap: the live Showdown subroom (8785941127880372366) was last saved 2024-10-02 as d39glazjteu2zztjuuulribr7.room under asset d03c4c99-4814-412b-9594-f61851d32225, and that asset exists only as 2021.3.29f1 / 6000.0.27f1 - nothing a 2020.3.33f1 client can load. The previously installed blob was e45vok0ppp89hda6n1xrm4z16.room (2022-10-18, the SD_Archive_11_29_2022_STABLE save), which is why the room was stale.

The blob really is Showdown

Raw string overlap between the blob and the bundle is zero, so that proves nothing either way. But the .room blob is protobuf, and walking its wire format for printable runs pulls out the room's own content verbatim - _SDLog_LocalPlayerSeesRedTeamValid, _SDLog_LocalPlayerLooksForValidTeam, _Equipping gun index {0} to self, _Not enough guns to give to self, _Tie Game!, Event Sender/Event Receiver/To Teams. That is Showdown's team-and-guns gameplay, and the SD_ prefix on its own script log ids is the signature. artifacts/verify_showdown_blob.py re-runs that walk against any blob.

The swap: room 32 out, room 58 in

The old room was deleted (with its RoomRoles, RoomStats, RoomInstances, RoomInteractions, SubRoomHistory and SubRooms rows - 51 rooms became 50), the era-matched save was put in place, and the metadata was patched to match the export:

field old room 32 new room 58
Description Showdown the Deputies/Bandits text
ImageName default.png 65hs5cb56byqs05lu16kr3e8r.png
MaxPlayers 16 6
CloningAllowed 1 0
IsDeveloperOwned backfilled true true
CreatedAt 2026-09-09 2022-03-03

Room 58's Tags were already rro,2|games,0|beta,1 (the importer sets them) and its subroom 61 is the one Home row: UnitySceneId a75f7547-79eb-47c6-8986-6767abcb4f92, DataBlob Showdown.room, UnityAssetId Showdown_0_1.assetbundle, MaxPlayers 6, Accessibility 1. Nothing else in the database still points at room 32 - every table that carries a RoomId was checked and all twelve count zero rows for it.

It survives a restart, which is the part that could have bitten back

The importer's else branch (room already exists) re-applies IsRRO, Tags, subRoom.DataBlob, subRoom.UnityAssetId and subRoom.UnitySceneId on every boot, so those have to be checked against what a restart would write. They are all already correct, and the scene is the subtle one: SceneLocations.MakerRoom is a75f7547-79eb-47c6-8986-6767abcb4f92, the Showdown scene, so the overwrite is a no-op. The fields that a restart would not touch - MaxPlayers, Description, ImageName, WarningMask, CloningAllowed, IsDeveloperOwned, CreatedAt - are the ones the patch above changed, so the importer's non-idempotent half does not undo any of it.

Verified live (server PID 36096, run 33, Development)

GET /rooms/rooms/58                          -> 200  Name=Showdown  MaxPlayers=6  Accessibility=1
                                                      IsRRO=true  IsDeveloperOwned=true  IsDorm=false
                                                      CloningAllowed=false  WarningMask=0
                                                      ImageName=65hs5cb56byqs05lu16kr3e8r.png
                                                      CreatedAt=2022-03-03T16:41:31
                                                      Tags=[rro,2][games,0][beta,1]  SubRooms=1
GET /cdn/data/Showdown.room                  -> 200  1,222,161 bytes  (the 2023-06-02 save)
GET /data/Showdown.room                      -> 200  1,222,161 bytes  (range-capable alias)
GET /cdn/data/Showdown_0_1.assetbundle       -> 206  range request, 100 bytes of 76,719,774
GET /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 200
    {"UnityAssetId":"Showdown_0_1.assetbundle","Target":0,"Version":1,
     "Filename":"Showdown_0_1.assetbundle"}
GET /images/img/65hs5cb56byqs05lu16kr3e8r.png -> 200  2,777,168 bytes

The room thumbnail needs no Images table row: room art is served straight from DBConfig.CDNImages by name (/images/img/{name}), which is why the built-in rooms' ImageName values are bare names like Dodgeball. Both HTTPS (3030, the port the client talks to) and HTTP (3031) answer for the room and the asset metadata.

Backups kept: RoomImports/Showdown/Showdown.room.before-run33 is the previous blob, and artifacts/showdown-room32-before-run33.txt is the row-level snapshot of the deleted room.

Handing the client patch to another machine

The August client patch is a stack of six pieces, not one DLL, and two of them are produced by machine-local patching rather than shipped. A player who receives only BepInEx/plugins/RadicadeAugustClient.dll gets a game that still talks to the official hosts, and the only red line in his Player.log is the vanilla Statsig noise above - which is why that line is the most common wrong lead. The reference install (C:\RecRoom\2023-08-29T06-53-33Z) hashes as follows; anything that differs is the piece to replace:

piece expected
GameAssembly.dll 167,390,208 b, md5 11e14eb35671e2725bd0c78a6a91bb48 - the build identity; every address map and the metadata patch are tied to it
Recroom_Data/il2cpp_data/Metadata/global-metadata.dat 38,654,794 b, md5 cc9c32014b5fc3dd1a2a8fb12376be15, 28 x localhost:3030, 50 x rec.net
Recroom_Data/il2cpp_data/Metadata/global-metadata.interop.dat 38,654,794 b, md5 4a1a28078f957af470f3b31951b32648 (normalized tooling copy)
Recroom_Data/boot.config 173 b, md5 dc2e5f7af7d5df736a4cc3477b4b1a1f (vr-enabled=0, player-connection-mode=Listen)
Recroom_Data/radicade-patch.json provenance stub: serverUrl https://localhost:3030, 7 changed literals, gameVersion 20230825
winhttp.dll 26,112 b, md5 b2a4d0cb04bf8f5a27ccab237ecc2586 (Doorstop 4.3.0 proxy)
doorstop_config.ini, .doorstop_version enabled = true, target BepInEx\core\BepInEx.Unity.IL2CPP.dll, version 4.3.0
BepInEx/core/BepInEx.Unity.IL2CPP.dll 60,928 b, md5 9430bb84349d52b753a181788a86c5e2 - patched to read global-metadata.interop.dat; the original is kept as .before-august-interop (75,776 b, md5 b8b3e4da1dbf171f3d405102d3c9d2e2)
BepInEx/interop/ 335 assemblies
BepInEx/plugins/RadicadeAugustClient.dll 62,464 b, md5 8718a45dd42d7cb72b3e46e04b744d91
Radicade server https://localhost:3030 + http://127.0.0.1:3031, Hosting environment: Development, on the player's own machine

The last row is the one no file copy can satisfy: the patched metadata rewrites the client's bootstrap literals to https://localhost:3030, so the server has to run where the game runs (a remote server needs a different metadata patch and a certificate the client accepts).

The three failure modes seen so far, each with its own fingerprint:

  • No loader. winhttp.dll renamed to winhttp.dll.baseline-disabled (or enabled = false) - Player.log dies at Could not resolve host 'ns.rec.net', there is no BepInEx/LogOutput.log for that boot. This is the 15:42 run-17 state.
  • Unpatched metadata. global-metadata.dat is the 32,039,456 b / md5 825f34f1dd046a0ae764cd5a97dba5e6 original. The plugin loads and the boot looks healthy (login, Photon, Dorm), then the room-save path fails: GetSubRoomDataSave() never resolves, the client prints Error downloading save data! / FailedToSpawnPlayer Code 8715 and tears the session down. This is the 2026-09-24 15:16 baseline-experiment state.
  • No local server. Everything else is in place but nothing listens on 3030 - the client's requests fail and the screen shows the launch-validation/login failure.

tools/august-client-triage.ps1 reports all of the above in one pass (read-only; it also opens two TCP sockets to 127.0.0.1:3030 and :3031 and writes august-client-report.txt next to itself):

powershell -ExecutionPolicy Bypass -File august-client-triage.ps1
powershell -ExecutionPolicy Bypass -File august-client-triage.ps1 -GameDir "D:\Games\2023-08-29T06-53-33Z"

On the reference install with the server stopped it reports exactly one [MISSING] (port 3030) and one [WARN] (port 3031); with the server up, none. Every [MISSING] line names a piece of the table above that did not arrive, and the two log tails at the end of the report carry the rest.

tools/make-august-client-pack.ps1 assembles the handover itself: it verifies every piece against the table (fail-closed, so it cannot ship an unpatched metadata or a foreign build), copies the loader pieces, the patched data files and the whole BepInEx tree - minus logs, previous-build sidecars and the 18 MB radicade-addr-map.txt diagnostic - into <OutDir>\RadicadeAugustClient, then writes README-FRIEND.txt and drops the triage script in alongside. The reference bundle is 302 MB in 448 files, of which BepInEx/interop (335 assemblies) is the bulk:

powershell -ExecutionPolicy Bypass -File make-august-client-pack.ps1 -OutDir "D:\radicade-pack" -Zip

For the September build (20230831, folder 2023-09-06T06-53-47Z) the same pair exists as tools/september-client-triage.ps1 and tools/make-september-client-pack.ps1, carrying that build's reference hashes (332 interop assemblies, 294.8 MB bundle) and one extra check the August scripts do not need: the metadata header magic, because a stock BepInEx core fails on this game's scrambled global-metadata.dat with Invalid or corrupt metadata (magic number check failed). See docs/2023-0831-CLIENT-SWITCH.md.

Rec Room Studio on Radicade

C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate is the Rec Room Studio template, and it lines up with the client we already patch: Unity 2020.3.33f1 (the August build's engine) and packages built from features/rec-room-studio/releases/20230831-211740 (git 0c23610a, recorded in RecRoomStudioPackages/VersionInfo.json). Those packages carry the entire game runtime (com.recroom.studio, .studio.build, .studio.api, networking, circuitsv2, objectmodel, ...), which is what lets the studio run the real game code inside the editor.

Everything routes through one string

A sweep of every com.recroom.* assembly for both ASCII and UTF-16 string heaps (plain grep finds nothing - .NET literals live in the #US heap as UTF-16) turns up exactly one baked-in service host:

Where Literal
Library/PackageCache/com.recroom.studio.build@*/Editor/RecRoom.Studio.Editor.Core.dll https://deluxe-backend-test.oldrecroom.com/nameserver
.../com.recroom.studio/.../UI/RecRoomStudioWindow/SignInPanel.uxml https://auth.rec.net/device (label text only)
.../RecRoom.Studio.Editor.Core.dll https://events.statsigapi.net/v1/ (telemetry)

No config file, environment variable or command-line switch redirects it: the editor GETs that nameserver URL and derives every other base (Auth, API, CDN, Images, Studio, ...) from the JSON, so aiming the whole editor at a private server is a one-string patch. The -environment argument belongs to the batchmode pipeline (-roomId, -outputPath, -rebuildMode, -RECNET_CLIENTSECRET, ...), not to the window.

Sign-in is an RFC 8628 device flow

RecRoom.Studio.Editor.Core.dll carries connect/deviceauthorization and connect/studiotoken under the nameserver's Auth base, together with the matching model: device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, scope, access_token, refresh_token, authorization_pending, tokenError, and the grant URN urn:ietf:params:oauth:grant-type:device_code. Nothing in the studio window works before that succeeds - there is no offline path, and "Play Local" (which launches the game client with -RecRoomStudioPort:N and drives it over http://localhost:{port}/recroomstudio/checkavailability and /loadroom) stays disabled until a room is open, which itself comes from RecNet.

What the server now provides

Server/Auth/StudioAuthAPI.cs implements both endpoints off the nameserver's Auth base ({ServerUrl}/auth/connect/...). Real RecNet has the user approve the code at auth.rec.net/device; a private server has no such page, so the code is auto-approved and the tokens are minted by AuthAPI.GenerateToken - the same claims (userId, tver, roles, rn.ver) the game client gets, which is what [Authorize] and token revocation actually check. The flow is refused for non-loopback callers unless Radicade:Studio:AllowRemoteDeviceAuth is true, and the account is Radicade:Studio:AutoApproveAccountId, else whichever account the caller names, else the lowest account holding the developer role. Requests are read as form or JSON and logged in full (bar secrets), because the exact wire shape is not documented anywhere - the first real sign-in attempt is what will tell us.

Verified live against the dev server:

POST /auth/connect/deviceauthorization      -> device_code, user_code 8JXV-8W4D, expires_in 900, interval 2
POST /auth/connect/studiotoken (device_code) -> access_token (JWT roles gameClient+developer+influencer, userId 2, tver 0)
GET  /auth/account/me/haspassword + bearer   -> true
POST /auth/connect/studiotoken replying the device_code -> invalid_grant (single use)

The editor-side patch

tools/patch-rrs-nameserver.ps1 rewrites that one literal in place, as a .NET string-heap edit: the entry keeps its 108-byte footprint (1-byte compressed length + 106 bytes of UTF-16 + terminator), the freed tail is zero-filled, and because the heap is addressed by offset and the footprint is unchanged no other string moves. One-time backup to RecRoom.Studio.Editor.Core.dll.radicade-orig, idempotent, and after writing it re-decodes the entry and asserts that nothing outside the slot changed (-Restore puts the original back).

powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1                     # -> https://localhost:3030/nameserver
powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1 -Url http://127.0.0.1:3031/nameserver
powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1 -Restore

Two blockers before any of this can be exercised

  1. The project tree is write-protected. Creating a file anywhere under C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate fails (ERROR_FILE_NOT_FOUND for a new file, Access denied when overwriting an existing one, and a hang via cmd) from Python, PowerShell and .NET, while reads work and writes elsewhere on the same volume succeed - OneDrive\ itself is writable, OneDrive\Documents is not, and no OneDrive process is running. Defender's Controlled Folder Access is enabled (EnableControlledFolderAccess = 1) and its implicit protected "Documents" resolves to the OneDrive one. Unity cannot open a project it cannot write Library/ and Temp/ into, so this has to be settled first.
  2. TLS. The editor validates certificates normally - the game client does not, because our plugin patches that. The certificate Kestrel serves on 3030 (localhost-rsa-aes-devcert.pfx, thumbprint D2275346BC059E9B62466F116F146F63CB89D5C4, notAfter 2031-09-05) sits in CurrentUser\My, not in CurrentUser\Root, so the editor's https://localhost:3030/... calls will be rejected until that cert is trusted (or the editor is aimed at the plain-HTTP listener on http://127.0.0.1:3031, in which case Radicade:ServerUrl has to match too, since the nameserver response is where every other base URL comes from).

After that the remaining surface is the studio's own room data: the window lists rooms and saves from RecNet (RoomTargetPanel, SaveListItem) and uploads with git -c http.extraheader="AUTHORIZATION: bearer <token>" to a RecNet-hosted remote - none of which exists here yet (Server/API/Studio is an empty placeholder directory).

The compile wall was Defender, not the packages (2026-09-26)

The writable copy opened in Unity 2020.3.33f1 and immediately refused to compile anything:

Asset Packages/com.recroom.studio.build/Editor/RecRoom.Studio.Editor.Core.dll.radicade-orig has no
  meta file, but it's in an immutable folder. The asset will be ignored.
NullReferenceException ... UnityEditor.Utils.Program.GetStandardOutput()   (twice)
Assembly 'Packages/com.recroom.creation/Runtime/RecRoom.Creation.Runtime.dll' will not be loaded
  due to errors: Unable to resolve reference 'Unity.Mathematics'
... the same for ~15 com.recroom.* assemblies, plus 'Unity.Collections', 'Unity.Settings.Editor',
  'Unity.RenderPipelines.Core.Editor', 'RecRoom.Studio.Common.Runtime'

with Library/ScriptAssemblies holding only BuiltinAssemblies.stamp. The first guess - that the manifest's file:../RecRoomStudioPackages/*.tgz sibling folder had been left behind by the robocopy - was wrong, and worth recording so it is not chased again: Unity resolves a file: path in Packages/manifest.json relative to the Packages folder, so ../RecRoomStudioPackages is <project>\RecRoomStudioPackages. That folder is inside ProjectTemplate and always was, holding all 55 tarballs plus VersionInfo.json. An integrity scan of the whole set (tar entry list vs extracted files, for every tarball) found zero missing files.

The actual failure is one line of Editor.log:

Starting external compile of Unity.Jobs.CodeGen.dll:
  ...\Library\PackageCache\com.unity.roslyn@0.2.2-preview\Compiler~\win64\csc.exe /noconfig @Temp/... /shared
Exception when running external compiler on Unity.Jobs.CodeGen.dll:
  System.ComponentModel.Win32Exception (0x80004005): ApplicationName='...\win64\csc.exe', ...
  Native error= The system cannot find the file specified.

Unity 2020.3 compiles C# with an external compiler, and because this project installs com.unity.roslyn (a transitive dependency of com.unity.entities@0.51.1-preview.21) that compiler is the package's own Roslyn 3.8. csc.exe was gone - from Library\PackageCache and from the shared %LOCALAPPDATA%\Unity\cache\packages copy - so the first compile died, nothing was written to ScriptAssemblies, and every later reference resolution failed against an empty assembly set. The wall of "Unable to resolve reference" is a symptom of "the compiler never ran".

It was not shipped broken: the registry tarball (https://cdn.packages.unity.com/tarballs/com.unity.roslyn/com.unity.roslyn-0.2.2-preview/8b5b337db92cbdec37b54235ec8b826c92c6ddef.tgz) contains package/Compiler~/win64/csc.exe (md5 8c4361cc37113f8467feb1d22b4e83da), as it does for mac/csc and linux/csc. Something deleted it locally.

That something is Windows Defender's PUA protection, which has been quarantining unsigned executables out of Unity package folders on this machine. Get-MpThreatDetection lists com.unity.burst@1.8.4\.Runtime\hostwin\llvm-lipo.exe at 2026-09-26 08:35 (ThreatID 2147972435), the older dsymutil.exe in burst 1.8.12/1.8.17/1.8.21 in the shared cache, and the same llvm-lipo.exe in the OneDrive tree at 00:34 - com.unity.burst\.Runtime\hostwin now contains only vswhere.exe. Burst's bcl.exe survived and the lost tools only matter for Apple targets, so the one casualty that blocks everything is the script compiler.

Repair: tools/repair-unity-script-compiler.ps1 restores Compiler~\win64\csc.exe and proves it works by running the compiler's own -version probe. Sources, in order: -FromPath, else the Unity install's Editor\Data\Tools\Roslyn\csc.exe. The launcher is interchangeable - it is a stock .NET Core apphost that loads the csc.dll beside it, and Unity's own copy drove the package's newer csc.dll through a clean test compile (empty output, valid 3 KB assembly). Use -FromPath with the extracted tarball member to get the authentic binary (it differs: md5 5f0947da... for Unity's build).

Cure (needs an elevated shell):

Add-MpPreference -ExclusionPath 'C:\RecRoom\Documents\EggFiles\ProjectTemplate'
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\Unity\cache"

and re-run the repair with -IncludeGlobalCache so a package re-resolve cannot copy the damaged file back out of the shared cache.

Smallest reproducer for the whole thing, without Unity: run csc.exe -version in the package's Compiler~\win64; if the file is missing you get the Win32Exception above, and a test compile with -noconfig -nostdlib -target:library -r:<dir>\System.Private.CoreLib.dll produces a real assembly.

Verified: the compile wall is down (batch-mode run)

The editor can be driven headlessly, which makes the whole state checkable without any clicking:

Unity.exe -batchmode -nographics -quit -projectPath <copy> -logFile <log>

(it exits 1 on a purely cosmetic Timeout after 300 seconds while waiting async operations to finish). Before and after the csc.exe repair, on the same project copy:

before after
Starting external compile ... 3, then Win32Exception ... cannot find the file 205, 0 failures
Unable to resolve reference 'Unity.Mathematics' / will not be loaded due to errors ~15 assemblies 0
Library/ScriptAssemblies/*.dll none, only BuiltinAssemblies.stamp 105, incl. Unity.Mathematics, Unity.Collections, Unity.Serialization, RecRoom.Studio.Common.Runtime

The first repair attempt also showed why this has to be repaired in both caches: csc.exe compiled 189 assemblies and then vanished mid-run, which the Defender event log does not account for (Get-WinEvent ... /Operational ids 1116/1117/1121 only ever name burst\hostwin\llvm-lipo.exe, and never csc/roslyn). With the shared %LOCALAPPDATA%\Unity\cache copy repaired too, the next run kept the file and logged no failure at all - and com.unity.roslyn@0.2.2-preview's own directory mtime stayed put, so this was not a whole-package re-extraction. Treat the project copy alone as fragile; repair both.

Two things the log still reports, neither of them blocking:

  • Assets/**/*.cs is empty, so there is correctly no Assembly-CSharp in the output - all of this project's code lives in packages. Do not go looking for that assembly.
  • The 11 remaining error CS lines are [Script Updater] advisories from com.unity.serialization@1.7.0-preview.1 against Unity.Collections 1.4.0 (FixedString128 renamed, Rune overloads). It still compiles, and nothing in the project references it.

Still eaten by the same Defender rule: com.unity.burst@1.8.4\.Runtime\burst-lld-14-hostwin, the LLD the Burst AOT job links with. It is missing from the project copy and from the shared cache, so no re-extraction can bring it back - the batch log ends with Error while executing command: ...burst-lld-14-hostwin ... Native error: The system cannot find the file specified. hostwin\llvm-lipo.exe is quarantined on sight (twice on 2026-09-26 alone). Both are Burst build/AOT tools: they break a player build, not the C# compilation the editor needs. Recover them by re-downloading com.unity.burst@1.8.4 from the registry - its burst-lld-14-hostlin and hostmac siblings are intact, which is how we know the Windows one was deleted rather than never shipped (1.8.17 in the same cache has three hostwin entries, 1.8.4 now has one).

Burst repaired, and the studio auth path re-verified (2026-09-26)

Re-downloading com.unity.burst@1.8.4 settled both the linker and its naming: the package ships package/.Runtime/burst-lld-14-hostwin.exe with the extension (the log prints the extensionless name because that is what CreateProcess is given, and Windows appends .exe itself), while 1.8.17/1.8.21 carry only burst-lld-16/18-hostwin-arm64.exe. A full file-set diff of that tarball (480 files) against the extracted package found exactly two casualties and no extras: .Runtime/burst-lld-14-hostwin.exe and .Runtime/hostwin/dsymutil.exe (40.8 MB). Both are restored and the package now diffs clean, as does the whole RecRoomStudioPackages set.

With the compiler, the certificate and the server all in one piece, the studio device flow was re-run over strict TLS (the localhost dev cert is now in CurrentUser\Root, so curl without -k succeeds):

POST /auth/connect/deviceauthorization    -> device_code, user_code 9C3C-UXUF,
                                             verification_uri https://localhost:3030/device
POST /auth/connect/studiotoken            -> 400 grant_type is required        (the full RFC 8628
   grant_type=urn:...:device_code             urn:ietf:params:oauth:grant-type:device_code is NOT
                                              accepted)
POST /auth/connect/studiotoken  grant_type=device_code&device_code=...
                                          -> access_token (JWT roles gameClient+developer+influencer,
                                             userId 2, tver 0, rn.ver 20230825, expires_in 604800)
GET  /auth/account/me/haspassword + bearer -> true
POST studiotoken replying the device_code -> invalid_grant (single use)

grant_type=device_code is the value the handler actually branches on; a request naming any other grant type is answered with unsupported_grant_type and logged. What is left is unchanged: rooms/saves, and the git-over-HTTPS upload remote - Server/API/Studio is still an empty placeholder directory.

First real Sign in: two server-side gaps (2026-09-26)

The first click of Sign in in the studio window opened the browser at https://localhost:3030/device?user_code=EYJ4-T9DF and got a 404. The browser is not where the authority on this is: %LOCALAPPDATA%\Unity\Editor\Editor.log records the editor's own verdicts, and it had two of them.

1. Device authorization failed: unsupported_grant_type. The editor polls studiotoken with the full RFC 8628 grant type - urn:ietf:params:oauth:grant-type:device_code - while the handler only branched on the bare device_code, so it answered unsupported_grant_type and the editor abandoned the sign-in. Matching is now on the token rather than one exact string (dashes normalised, so any spelling is accepted), and refresh_token got the same treatment. Re-verified against the live server: that exact request now returns an access_token, and the short form still does.

2. There was no /device page. verification_uri and verification_uri_complete were being advertised while nothing served the path, so the browser step could only ever 404. New Server/Auth/DeviceApprovalAPI.cs ([Route("/")] + [HttpGet("device")], the same root-level pattern NameSever uses, and the same HTML styling as AccountsAPI.VerificationPage) renders "Approved - account 2" for a live code, and explains an unknown or missing one. It deliberately approves nothing: the code is already issued approved, so the page's job is to tell the user what they are holding.

An earlier attempt had also logged Failed to initiate device authorization: Failed to obtain RecNet Service URIs: NullReferenceException inside KJGMKNABDIJ.EMOFOGPOOLJ(String, Char) - a parse of the nameserver response in editor code, not a server fault. It stopped occurring once the client got past that stage, and it is worth knowing because it looks like a server problem and is not.

Unrelated noise now identified and safe to ignore: Rec Room Studio unable to record last project path / skipped checking for update / skipped checking for local update to install, all FileNotFoundException for ProjectSettings\Packages\com.recroom.studio\BranchId.txt, plus EditorUpdateCheck: Failed The requested URL returned error: 404. Those only disable the studio's self-update check.

The server now leaves a readable log

PID 448 had no stdout redirect, so the editor's actual request bodies from that first attempt are gone. It has been restarted (PID 39184) from Server\ with ASPNETCORE_ENVIRONMENT=Development and stdout captured to .tmp-studio-auth.log in the repo root. ReadRequestFields already logs the method, path, client IP, content-type, user-agent and every field of every studio request, so the next Sign in attempt is fully diagnosable:

Studio POST /auth/connect/studiotoken from ::1, content-type=application/x-www-form-urlencoded,
  user-agent=curl/8.17.0: grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code=..., client_id=recroom-studio
Studio token issued: account=2.

Restart pattern (the working directory matters - DBConfig resolves RadicadeServer-Data/ and RadicadeServer-Config/ relative to it, so starting from anywhere else silently uses a different account database):

Stop-Process -Id <pid> -Force          # dotnet build cannot write bin/ while it runs
cd Server; $env:ASPNETCORE_ENVIRONMENT='Development'
Start-Process -FilePath "bin\Debug\net9.0\Server.exe" -WorkingDirectory (Get-Location) `
  -RedirectStandardOutput "..\.tmp-studio-auth.log" -RedirectStandardError "..\.tmp-studio-auth.err.log"

It takes 90-150 s to start listening on https://localhost:3030 and http://127.0.0.1:3031, and the log's Content root path line confirms which working directory actually won. (Starting it from a script: do not capture the launcher's stdout - the server inherits the pipe and the shell blocks forever. It starts fine, the wrapper just never returns.)

Sign-in works; the studio API surface (2026-09-26)

Sign in now completes - the studio window shows the account, and a Sign out button. What it does afterwards is visible because this server already logs every request:

[09:22:08] GET /studio/analytics/rudderstack-api-key                -> 404 (x20)
[09:22:08] GET /rooms/rooms/ownedby/me                              -> 200
[09:22:08] GET /rooms/rooms/25?include=1037                         -> 200
[09:22:13] GET /rooms/rooms/25/subrooms/32/saves?skip=0&take=30     -> 200
[09:22:13] GET /studio/cloud-builds/for-room?roomId=25&subRoomId=32&skip=0&take=30 -> 404

cloud-builds/for-room is the wall: with no route at all the panel showed "Failed to open room - Could not download room details. 404 (Not Found)", and the editor retried it on a loop (three HttpRequestException: 404 in the console).

The complete route list fell straight out of the same DLL that held the nameserver literal - the client's UTF-16 #US heap keeps its route templates back to back, in Packages/com.recroom.studio.build/Editor/RecRoom.Studio.Editor.Core.dll at offset ~296k:

cloud-builds
cloud-builds/for-room
cloud-builds/create                (+ query keys autoPublish, buildBranch, cloudBuildId)
cloud-builds/custom-avatar-item/{0}
cloud-builds/custom-avatar-item/{0}/save/{1}    (+ customAvatarItemSaveId)
cloud-builds/cancel
remote-run/push-to-device
collaboration/owners-in-room
account/{0}

and, in the same block, the auth client's own vocabulary - which closes the loop on the two fixes above:

connect/studiotoken, client_id=recroom.studiopipeline, client_secret=54oc5C4BH7jpqp42KhChkNZOLP0U3yTx,
  grant_type=client_credentials          <- a second, non-interactive login path
recroom.studio, refresh_token            <- and a refresh path
connect/deviceauthorization, urn:ietf:params:oauth:grant-type:device_code, device_code,
  authorization_pending                  <- the editor DOES send the urn form, and it does poll for
                                            authorization_pending; both are now handled
account/me, rrs.pushtostudio, RecRoom.Studio.SessionId

Server/API/Studio/StudioAPI.cs (the folder had been an empty placeholder since the studio work started) now answers the three read-only ones: cloud-builds/for-room and collaboration/owners-in-room with an empty list, analytics/rudderstack-api-key with an empty key (that route is polled twenty-odd times a session and the 404s were noise). Verified 200 with the exact query string the editor uses.

Empty lists, not 404s, on purpose: this server has no cloud build to hand out. Producing one is the other half of the feature - cloud-builds/create plus the git-over-HTTPS remote the studio pushes the scene to - so the useful next state is the editor saying "this room has no builds" and showing what it asks for after that, instead of failing on transport.

Still unexplained but harmless: three NullReferenceExceptions in KJGMKNABDIJ.EMOFOGPOOLJ(System.String, System.Char) fire a second after a successful sign-in. That is the studio's own URI parsing helper (the same one behind the earlier Failed to obtain RecNet Service URIs), so if the studio later complains about a missing service it is the first place to look - the candidate is a nameserver key we do not emit that it expects to split.

Explained below ("Opening a room"). Decompiling the class settles it: EMOFOGPOOLJ is url.EndsWith('/') ? url : url + '/', it NREs on a null nameserver field, and the null one is StudioCDN. The three exceptions were the same missing key each session, not a cosmetic curiosity - every URI before it resolved, which is why the session carried on.

for-room wants a page, not an array (2026-09-26)

Answering an empty list moved the failure from transport to shape, which is what makes the next step legible - the studio window stopped reporting a 404 and started naming the type it wanted:

Could not download room details.
The JSON value could not be converted to
  RecRoom.Core.Studio.RecNet.PagedResultsDTO`1[RecRoom.Core.Studio.RecNet.CloudBuildDTO].
Path: $ | LineNumber: 0 | BytePositionInLine: 1.

Byte 1 of [ means the studio's list DTO is an object. This repo had already learned that for the game client - V2_RoomsAPI.EmptyPage carries the note "The empty page every paged list here answers with { TotalResults, Results } rather than a bare []. The client's list DTOs are objects (PagedResultsDTO<RoomDTO>)" - so StudioAPI.CloudBuildsForRoom now mirrors that shape: { Results: [], TotalResults: 0, TotalCount: 0 }.

collaboration/owners-in-room is the exception and stays a bare array. The editor requested all three studio routes within the same second (09:23:55 in .tmp-studio-auth.log) and accepted [] there without a deserialization error, so the two routes are not the same DTO family. Reading the console to see which route threw, instead of assuming one uniform shape for every studio list, is what separated them.

Wrong, and corrected below. The editor did reject [] on that route - it just logged the Result<List<int>> deserialization error a second later, next to the room-open failure, where it read as part of that failure rather than as its own. "The two routes are not the same family" was the right conclusion for the wrong reason: owners-in-room is wrapped (Result<T>), not paged.

Opening a room still cannot succeed: for-room is only the lookup, and this server has no CloudBuildDTO to return because nothing has ever been uploaded. The next milestone is producing one - cloud-builds/create plus wherever the studio pushes the scene - not another shape fix here.

Two patcher details settled the same day

  • The one-time backup now lives outside the package folder, at <ProjectRoot>\radicade-backups\<pkg-id>\...\X.dll.radicade-orig, because Unity treats everything under Library\PackageCache as immutable and logs the has no meta file, but it's in an immutable folder warning that opened this whole trail (-Restore still understands a legacy in-package backup). Relocate an existing one with a plain move; restore -> patch -> re-run was verified round-trip, leaving the slot at offset 296749 with 40 bytes of zero padding and the file size unchanged.

Opening a room: the studio's DTOs, and why "" is not null (2026-09-26)

The screenshot that prompted this round: the room resolves (TestPlayers_MakerRoom_3528/Home), sign-in holds, the panel offers Build on Rec.net and Download - and Open Room fails with two stack traces that have nothing to do with transport, because the server log for that whole session has no non-200 in it:

[09:33:33] NullReferenceException: Object reference not set to an instance of an object
             KJGMKNABDIJ.EMOFOGPOOLJ(System.String, System.Char)
[09:33:34] ArgumentException: The string argument 'filename' cannot be empty. Parameter name: filename
             at CDLPCLJLLLG.DHGPNOHKPNB(...)      ->  MAMHCJFJOGI+OMPAKMPBPNM.MoveNext()
             OGINOCKCDO+ICFNAE5EAJ.MoveNext()  <- "Could not download subroom data"
[09:33:34] JsonException: The JSON value could not be converted to
             RecRoom.Core.Studio.RecNet.Result`1[System.Collections.Generic.List`1[System.Int32]].
             Path: $ | LineNumber: 0 | BytePositionInLine: 1.

The editor's own log keeps the full traces and names the methods, which is what makes the shapes readable - %LOCALAPPDATA%\Unity\Editor\Editor.log, and the interesting lines are the ones between the exception and the UnityEngine.DebugLogHandler:Internal_LogException marker.

Decompiling the studio is one command, and it is already installed

ilspycmd (8.2.0) is a global dotnet tool on this machine, and the editor's assemblies are ordinary managed DLLs - the obfuscator renamed types and methods, not the DTOs, because the DTOs are the wire contract:

ilspycmd -l "RecNet"  ".../Library/PackageCache/com.recroom.studio.build@e443aa011f13/Editor/RecRoom.Studio.Editor.Core.dll"
ilspycmd -t 'RecRoom.Core.Studio.RecNet.Result`1' "<that dll>"
ilspycmd -o .tmp-rrs-decomp    "<that dll>"          # 7,725 lines, whole assembly
ilspycmd -o .tmp-rrs-authoring ".../com.recroom.studio@6492b87ce18e/Editor/Authoring/RecRoom.Studio.Editor.Authoring.dll"   # 28,779 lines

Both dumps are kept in the repo working tree (gitignored, like the .tmp-*.log files) - the loader lives in Authoring and the API client in Core, so a question about a route is usually a grep across both.

The reply shapes, verbatim

[Serializable] public class Result        { public bool Success { get; set; } public string Error { get; set; } }
[Serializable] public class Result<T> : Result { public T Value { get; set; } }
[Serializable] public class PagedResultsDTO<T>  { public List<T> Results { get; set; } public long TotalResults { get; set; } }

public class RoomDetailsDTO : RoomDTO {
    public string DataBlob; public string DataBlobHash;
    public IReadOnlyList<SubRoomDTO> SubRooms; public IReadOnlyList<TagDTO> Tags;
    public IReadOnlyList<AccountRoleDTO> Roles; public IReadOnlyList<int> CoOwnerAccountIds; }
public class SubRoomDTO {
    public long SubRoomId; public string Name; public string UnitySceneId; public int MaxPlayers;
    public SubRoomDataSaveDTO CurrentSave; }
public class SubRoomDataSaveDTO {
    public long SubRoomDataSaveId; public long SubRoomId; public string UnityAssetId; public string UnityAsset;
    public string UnityAssetHash; public string DataBlob; public string DataBlobHash;
    public int PersistenceVersion; public byte OMVersion;                    // <- a BYTE, not an int
    public int? SavedByAccountId; public PICFJDPCODO? SavedOnPlatform;
    public ELMJICAIAAA? SavedOnDeviceClass; public string Description; public DateTime CreatedAt; }

Result is what catches the eye: every reply the studio mutates with is wrapped, and the wrapper's EEKDNHEOFFD() ("assert result") is Debug.Assert(Success == (Error == null)) followed by if (!Success) throw new Exception(Error). So a wrapper with Success:false is not a soft failure - it throws out of whatever the editor was doing. owners-in-room is declared Task<List<int>> over Result<List<int>>, which is exactly the type in the JsonException: a bare [] was never accepted there, it just failed a second after the room-open error and read like part of it. StudioAPI.OwnersInRoom now answers { Success: true, Error: null, Value: [] } (the value is the room's owner account ids, which the editor resolves one by one for its collaborator list).

Every nameserver URL is required, and StudioCDN was missing

KJGMKNABDIJ in the same DLL is the nameserver client: a NameServerResponse with 27 string fields, a IEBKFBLKOKM that walks them one at a time into CNHJLHELLLL[key] = new Uri(EMOFOGPOOLJ(url, '/')), and EMOFOGPOOLJ is url.EndsWith('/') ? url : url + '/' - it dereferences the string, so a key the response does not carry is a NullReferenceException mid-walk, wrapped as Failed to obtain RecNet Service URIs: Object reference not set to an instance of an object. The dictionary is assigned before the walk, so everything before the missing key stays usable: the session looks healthy and three NREs are logged.

RecNetStatus Auth API Notifications Images CDN Commerce Matchmaking Storage Chat Leaderboard
Accounts Link RoomComments Clubs Rooms PlatformNotifications Moderation DataCollection
BugReporting Discovery PlayerSettings Studio GameLogs Strings WWW Econ StudioCDN

GET /nameserver on this server had all of them but two: StudioCDN (mapped, so this is the null that NREs) and RecNetStatus (declared by the editor but never mapped, which is why it was never the culprit). RecNetStatus stays unwritten on purpose - adding a URL that 404s to a field the 2023 client may also read is a worse trade than leaving a field the studio never touches absent - and StudioCDN is now {ServerUrl}/studiocdn, the base for the editor's roomunityasset/{filename} (source-project manifest) and unityasset/{filename} (the baked bundle). Neither route exists yet; the point is that the editor can build a Uri and get an honest 404 instead of an NRE, and the fix is one line in DBConfig.NameServer next to a comment saying all 27 are load-bearing.

A blob name of "" is fatal; null is the documented "nothing to download"

The room loader (OGINOCKCDO in Authoring) does this, in order:

  1. room details, wrapped in PFJIHBLICIE(..., "Could not download room details");
  2. pick the save: room.LNLADOGFALG?.IDNMEAFLKOF?.FirstOrDefault() ?? room.DJACJOEJMOD?.CurrentSave - the saves list first (the /saves page), then the room payload's currentSave;
  3. download the subroom blob from save.DataBlob via BCEDBPKMONJ.OGLFKNHIMHG(name) - failure text "Could not download subroom data";
  4. download the room-level blob from RoomDetailsDTO.DataBlob via BCEDBPKMONJ.FKDIJAIOAPJ(name) - "Could not download room data";
  5. both go to MAMHCJFJOGI.GMIJOHGNLFI(filename), which first calls CDLPCLJLLLG.DHGPNOHKPNB(filename, "filename") - the empty-string guard from the trace - and then GET {CDN}/room/{filename};
  6. and both are skipped entirely when the name is null, succeeding with an empty payload (new PLGEIAPHMDM(null) / new GDEHOACHGIF(null)), which the validators then step over (if (payload != null && payload.Length != 0)).

So "" is the one value that cannot be handled, and this server was emitting it in two places:

  • RoomUtils.ConvertToFull hardcoded DataBlob = "" - the room level field. Nothing on this server has ever produced a room-level blob (a save carries RoomData.Filename and SubRoomData.Filename; RoomServerAPI.UploadRoomData only ever stored the subroom one), so the honest value is null. This is the failure the first attempt hit: the subroom download had succeeded (the /cdn/room/7dc6589a-... 200 in the log) and the room blob killed it.
  • subRoom.CurrentSave = BuildCurrentSave(subRoom) ran unconditionally, so a never-saved subroom shipped a save object with DataBlob: "" instead of currentSave: null. That is the second attempt: room 31 simply has no save, and the studio read the placeholder save and died on the guard.

Both are fixed, and both fixes are safe for the game client - checked two ways. grep -rli currentsave over the 2023 client's decompilation under .external/aug-decomp finds nothing: the client never reads currentSave at all (it loads saves from the /saves route, whose blob-less entries this server already omits). And the reference implementation under .external/recflare-server documents the same contract - SubRoomDto.CurrentSave: z.nullable().describe('Null until first save'), no room-level DataBlob field on its room DTO at all, and if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename on the save - so a null here is what the real backend does, not a workaround.

Verified against the running server

Rebuilt, restarted, and read back with a token minted through the studio's own device flow (the client_credentials path in .tmp-studio-auth.log works too, without a browser):

nameserver StudioCDN = 'https://localhost:3030/studiocdn'

--- room 31 (TestPlayers_MakerRoom_3528) roomDataBlob=None
   subRoom 34 'Home' flatDataBlob='' currentSave=null
--- room 54 (TestPlayers_MakerRoom_1603) roomDataBlob=None
   subRoom 57 'Home' flatDataBlob='7dc6589a-f613-4575-afc2-21efaf9347ac'
   currentSave={ "SubRoomDataSaveId": 1, "SubRoomId": 57, "DataBlob": "7dc6589a-...", ... }

owners-in-room     -> {"Success": true, "Error": null, "Value": []}
cloud-builds/for-room -> {"Results": [], "TotalResults": 0, "TotalCount": 0}

Room 31 now opens against an empty scene (nothing was ever saved) and room 54 against the save it has; the room-level blob being absent means the scene opens without room-level data, which is what a room with no room data is, and a studio save is what would create it.

dotnet build Server/Server.csproj is clean, and 185 tests pass across RoomSave, SubRoom, RoomRole, AdminRoom, RoomDeveloperOwned, PlayerWarning, CdnRange and RecNetShape - the last one is the conformance suite that compares payloads against the reference shapes, so the CurrentSave/DataBlob change is covered in the direction that matters (a subroom that HAS a save still reports it). ServerTests still cannot build as-is - its csproj compiles two 2023Patch/RadicadeEacBypass/*.cs files that live in the uncommitted stash - so this run used a temporary project file beside it with those two Compile Includes dropped and the three test files that need them (HubRealtimeTests, ImageSignatureRequestTests, NameserverRedirectTests) removed. Do not "fix" the csproj: the missing sources are somebody's work in progress.

What is left before a room can actually be built in Studio

  • cloud-builds/create (+ buildBranch, autoPublish, cloudBuildId, cloud-builds/cancel) - the studio's Build & Upload button, and the only way a CloudBuildDTO will ever exist to list;
  • remote-run/push-to-device ({ SessionId, RoomId, SubRoomId, RoomData, SubRoomData, BakedUnityAssets } of UploadFileResponseDTOs);
  • {StudioCDN}/roomunityasset/{filename} and /unityasset/{filename}, plus {Storage}/upload and /upload/unityassets - where the studio puts the baked Unity assets it needs back;
  • the room-level DataBlob: accept RoomData.Filename on the save route and store it, so a room saved from Studio comes back with room data instead of null;
  • collaboration/owners-in-room with the room's real owner ids, which needs the collaboration layer this server does not have yet.

A room opens in Studio: the illegal-text check, and the realtime channels (2026-09-26)

The three shape fixes above landed the room. DormRoom/Home opened with its real Maker Pen scene in the viewport (Plants, Basketball, Welcome Mat, the whole dorm object list), the panel stopped saying "Failed to open room", and Build on Rec.net filled in with the subroom's save history (#1, #39, #38, #37 - those numbers are the SubRoomDataSaveIds the /saves route mints).

Two errors were left, and both were missing server surface rather than shape:

[10:52:53] HubException: Failed to invoke 'SubscribeTo' due to an error on the server.
             HubException: Method does not exist
[10:54:41] NullReferenceException  KOMDFENMFBG+EADNODIDNG.MoveNext()
             -> dialog: "An exception occurred while checking for illegal text in components.
                Trying again may fix the error. Exception: 'Object reference not set ...'"

The purity check: POST /api/sanitize/v2/isAllowed/bulk

The studio runs every string in the scene through KOMDFENMFBG.DOLJODEKDLI, which calls Result<AreStringsPureResponseDTO>, i.e. POST {API}/api/sanitize/v2/isAllowed/bulk with Values, and then does:

Result<AreStringsPureResponseDTO> newStringAllowances = await NLMHGDMIMAP(toBeChecked);
for (int i = 0; i < toBeChecked.Count; i++)
    HINAIGHAGOI.TryAdd(toBeChecked[i], newStringAllowances.Value.ArePure[i]);

Value is dereferenced and ArePure is indexed with the sender's own index, so the reply has to be the Result wrapper with one bool per string, in order - which is the NRE, and the reason the dialog says "trying again may fix the error" rather than naming the string it disliked. The route did not exist at all (404, len=0), and the NRE is what the editor made of the empty body.

One detail decides how the route reads its input. The editor's request builder (OCOOLHLEDHD.MFBDEFCEDGN) appends its parameter list to the URL only for GET; for every other verb it wraps the same list in FormUrlEncodedContent. So this arrives as a form body of repeated Values fields, and the log line looks like the studio called it with no arguments at all - there is nothing after bulk:

[09:54:59] POST /api/sanitize/v2/isAllowed/bulk -> 404 (0.2 ms) len=0 ct= body=

SanitizeAPI.IsAllowedBulk now answers { success: true, error: null, value: { ArePure: [...] } } from the same StringTools.IsPure every other route here applies to room names and inventions, with the form spelling and the query spelling both accepted (so it can be exercised by hand). Verified:

$ curl -X POST https://localhost:3030/api/sanitize/v2/isAllowed/bulk -d 'Values=Plant (Dracaena)&Values=faggot&Values='
{"success":true,"error":null,"value":{"ArePure":[true,false,true]}}    # aligned, in order

(The Result names are lowercase because that is how Success declares them for the game client; the editor binds them anyway - it is JsonSerializerDefaults.Web, i.e. case-insensitive. That is also why the owners-in-room wrapper from the section above needed no second pass.)

The realtime channels: SubscribeTo / UnsubscribeFrom on the hub

The editor's realtime client (LKDEHPKLIGJ) connects to {Notifications}/hub/v1 - NotifyHub, which already serves the game client at that path - sends { Channels: [...] } to those two methods, and reads Notification frames back off the connection. The channel it subscribes to is RRS:<RecRoom.Studio.SessionId>, which is exactly where a rrs.pushtostudio message would come from (remote-run/push-to-device, still unimplemented). The hub had SubscribeToPlayers but not SubscribeTo, and SignalR answers an unknown method with HubException: Method does not exist - an error the editor logs and retries on every reconnect instead of ignoring.

Both methods now exist, take the raw object? the way the neighbouring hub methods do, pull the channel names out of the JsonElement by name (SignalR serializes camelCase, so the lookup is case-insensitive), and log the subscription. Nothing publishes to them yet, which is stated in the log line rather than silently swallowed:

[NotifyHub] U9-Bl4o4KZFIUWR5eMi9IA subscribed to RRS:test-session (nothing publishes to these yet).
[NotifyHub] U9-Bl4o4KZFIUWR5eMi9IA unsubscribed from RRS:test-session (nothing publishes to these yet).

Verified without a SignalR client package, by driving the hub's long-polling transport with curl - worth keeping, because it is the only way this repo can poke the hub by hand:

TOK=$(curl -s -X POST 'https://localhost:3030/notify/hub/v1/negotiate?negotiateVersion=1' | jq -r .connectionToken)
curl -s -X POST "https://localhost:3030/notify/hub/v1?id=$TOK" -H 'Content-Type: text/plain;charset=UTF-8' \
     --data-binary $'{"protocol":"json","version":1}\x1e'                      # handshake
curl -s -X POST "https://localhost:3030/notify/hub/v1?id=$TOK" -H 'Content-Type: text/plain;charset=UTF-8' \
     --data-binary $'{"type":1,"target":"SubscribeTo","arguments":[{"channels":["RRS:test-session"]}],"invocationId":"1"}\x1e'

The \x1e record separator is part of the protocol, and the response body stays empty on the POST - the server-side log line is the proof of dispatch (a missing method answers with a HubException in the next poll instead).

The panel's remaining warnings are the honest ones: "This scene has never been uploaded from Rec Room Studio" and "Never save available on rec.net" are the empty cloud-builds/for-room page, and they go away when cloud-builds/create and the build store exist.

Keepsakes: what the reference server holds, and the store we built

There is a second reference in play now - a downloaded fork at rec-room-2023-server-main/rec-room-2023-server/asdfasdf - so it is worth stating exactly what it carries, because it is less than it looks. Five files mention keepsakes and only one of them holds anything the client can use:

file contents
keepsakes_categories.json the real catalog: 5 categories in a counted result set
APIs/API.cs:186,527,539 the same three GETs we serve: categories (reads the JSON), globalconfig, rooms/{roomId} -> Results.NoContent()
KeepsakesConfig.cs empty stub classes, no code
APIs/Auth.cs:227 "keepsake" in a role array - inert
ProgressionEvent.cs:15 KeepsakeRoomLists, never assigned

So the fork's keepsake endpoints are already byte-for-byte ours - the same 204 for a room, the same config trio - except that its catalog is populated and ours was empty. That one file is the delta, and it is trustworthy: each entry's field order matches the client's own KeepsakeCategoryConfigDTO(enum, string, int, int, string, string) constructor, and its ids (0 explore, 2 presents, 4/5/6 science_core_*) are the Science Core event this build's own GameConfig text describes.

What the client itself says it wants

Straight out of global-metadata.dat rather than inferred: the route literals are api/keepsakes, api/keepsakes/categories, api/keepsakes/events, api/keepsakes/globalconfig; the feature's error strings (Failed to add keepsake, Failed to collect keepsake {0}, Failed to delete keepsake {0}, Waiting for Keepsake Instance Data download..., Cannot place additional keepsakes in this room!) and the notification names KeepsakeInstanceAdded / KeepsakeInstanceRemoved are all present. Our own log already showed the three GETs firing on every room load (rooms/25 -> 204, categories -> 200 len=43, globalconfig -> 200 len=100).

The one write-side payload we can read comes from the recflare reference's notify package, which documents the captured shape:

/** `KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved`. */
interface KeepsakeInstancePayload {
    KeepsakeInstanceId: string        // the GUID the room save carries
    KeepsakeCategoryConfigId: number
    PlacedByAccountId: number
    RoomId: number
    SubRoomId: number | null
}

Two things follow from that, and they drive the design. The instance id is a GUID the room save already contains - the client writes it when an author places a keepsake - so the server must store the id it is told, never mint its own; and a room whose save holds a keepsake with no row behind it is precisely the state the keepsake runtime null-references.

What shipped this session

  • KeepsakeInstances and KeepsakeCollectionRecords (hand-written guarded migration 20260927120000_AddKeepsakes, the same style as the neighbouring ones).
  • GET api/keepsakes/categories now serves the five real categories (RADICADE_KEEPSAKE_CATALOG=empty restores the empty set; RADICADE_KEEPSAKE_ICON=<name> swaps both icon names, which is how we find out which name the client actually reads).
  • GET api/keepsakes/rooms/{id} is unchanged when nothing is placed (204) and serves {KeepsakeInstances, KeepsakeCollectionRecords, KeepsakeProgressionEventIds} once something is - the instances this caller has not collected, then their records.
  • POST api/keepsakes/rooms/{id} places one, keeping the caller's GUID when it sends one; POST api/keepsakes/rooms/{id}/collect records the pickup, once per player (a repeat keeps the first timestamp).
  • Anything else under api/keepsakes lands on a probe that names the call in the console and answers 404, never 204: a write that looks successful while the room save holds an id nothing backs is the exact failure the empty catalog was protecting against.

The two open unknowns are both answered by that probe rather than by more guesswork: the exact spelling of the collect/delete routes (the literals only prove categories, events, globalconfig, api/keepsakes) and the JSON key the client reads for a placed instance. The next in-game attempt prints them.

The room that already has a keepsake in it

RoomImports/Showdown/Showdown.room - imported as an RRO at boot - contains a keepsake component with a GUID beside it:

offset 585176: ... "keepsake" ... $a75f7547-79eb-47c6-8986-6767abcb4f92 ...
offset 591174: ... keepsake ... (the room's component-type table)

Nothing registers it yet, and that is deliberate: registering a guessed id is exactly the kind of invented keepsake that faults a load. The right way in is the probe - join Showdown, watch which ids the client reports, and store those.

Keepsake rooms: how a room comes to be allowed to hold keepsakes

The banner this leaves behind - "This room does not have keepsake permissions." - is not a room role. The client's room-permission enum is CAN_EDIT_ROOM_ROLES ... CAN_USE_MAKER_PEN ... CAN_USE_PLAY_GIZMOS_TOGGLE with no keepsake entry in it, the room DTO has no keepsake field either, and the fork's ProgressionEvent.KeepsakeRoomLists is never assigned. The only per-room keepsake payload the client asks for while a room loads is GET /api/keepsakes/rooms/{id}, and the keys that response may carry - out of the client's own metadata - are KeepsakeProgressionEventIds, KeepsakeRoomId, KeepsakeRoomLimit, KeepsakeRoomListId, KeepsakeRoomLists, KeepsakeRooms. So the permission arrives with the room's keepsakes or not at all, which is why every room answered NO CONTENT and every placement answered with that banner.

A room is advertised as a keepsake room when it is listed in Server/RadicadeServer-Config/keepsake-rooms.json ({ "AllRooms": false, "RoomIds": [3, 25], "RoomListId": 1, "RoomLimit": 100 }) or when RADICADE_KEEPSAKE_ROOMS names it (ids, or * for every room, with RADICADE_KEEPSAKE_ROOM_LIST_ID / RADICADE_KEEPSAKE_ROOM_LIMIT beside it). Those rooms answer 200 with the keys above beside the three instance lists - and those lists stay honest, so an empty keepsake room still carries nothing for the keepsake loader to resolve - and the server logs [Keepsakes] room {id} answered as a keepsake room: list ..., limit ..., N room(s) advertised, M placed. Every other room is unchanged: NO CONTENT, the old behaviour. The config that ships enables every room (AllRooms: true): the banner is the symptom this exists to clear, so a checkout that shipped it switched off would look exactly like the bug. Narrowing it back is an operator edit - AllRooms: false plus the ids in RoomIds.

The file is re-read on every room load, so an edit applies to the next room a player enters with no restart. That is verifiable without the game: with the running server on 3030, curl -sk https://localhost:3030/api/keepsakes/rooms/25 answered 204 0b while AllRooms was false and 200 381b with KeepsakeRoomId/KeepsakeRoomLimit/KeepsakeRoomListId/KeepsakeRooms/ KeepsakeRoomLists the moment the file said true - same process, no relaunch.

Confirmed in game. With AllRooms: true, walking into room 25 clears the banner: those five room keys ARE the permission, and the client asks for them every room load ([Net] Get .../api/keepsakes/rooms/25 in the 21:09 log, the first run that got far enough to make the call).

Permission is not the whole gate: the keepsake comes from a progression event

Permission cleared the banner but left the MakerPen's keepsake tile reading x0 - a room can be allowed to hold keepsakes and still offer nothing to place. The second half is the progression event:

  • The room payload's KeepsakeProgressionEventIds names the event the room's keepsakes belong to (the client's metadata spells the field run EmptyKeepsakeProgressionEventIdsKeepsakeRoomId…, i.e. "empty" is its own state).
  • GET /api/progressionEvents/active answers the active event's ID - a bare number, CONFIRMED: with the id served, the client's log has no malformed line for this route at all, and the very next calls are GET /api/progressionEvents/event/1 and GET /api/progressionEvents/record/1 (the record asked four times in one session), i.e. ProgressionEventDTO and ProgressionEventRecordDTO by id. Both were 404s here, so the id alone changed nothing on screen; the record, its KeepsakeRoomLists and the palette's keepsake allowance all hang off them.The client's progression-events client has exactly two no-argument calls, KIJDNIPGGDC() -> MFBKDKPGMNP<long?> and a private one returning a record, and the id is what the model caches first: ABIEHIAHKJP(Nullable<long>) is that field's setter and BJKPLIJHAJK(long, bool) is the id-taking record fetch. A nullable long deserializes from none of the shapes this endpoint answered before ([], {}, and a full record), each logged by the client as Malformed Response: '<body>'.

Both by-id payloads carry an _route marker ("event" / "record"). An unknown member is ignored by the deserializer, so it costs nothing, and it is what makes a Malformed Response: '<body>' line in the client's log name WHICH of the two the parser rejected - the log shows a body, never a URL.

  • RoomListModel.ApplyKeepsakeOverrides(ref eventRecord, RoomModel, long, int, long) is the client applying that record's KeepsakeRoomLists to a room, and the sibling members IsActiveEventKeepsakeEvent / GetKeepsakeRoomListIdFromRoomId / GetNextKeepsakeRoomUnlockTime are all event-shaped.

The record's field names come from the 2023 fork's own ProgressionEvent model (Downloads/rec-room-2023-server-main/.../ProgressionEvent.cs) and each one is present in this build's global-metadata.dat: ProgressionEventId, Name, IsEnabled, UsesBoost, BoostXpMultiplier (the fork spells it BoosXpMultiplier - a typo), BoostDailyGameplayMinutesLimit, ActiveExperiment, StartTime, EndTime, CollectionEndTime, EventInfoImageName, ChallengesIconImageName, RewardsPipImageName, PurchasableXpBoostId, Rewards, KeepsakeRoomLists. Two fork fields are NOT in this build and are left out: EventLeaderboardId and InitialCurrencyGrant. The event is windowed around the current time (start -30d, end +1y) rather than pinned to a date, so a machine whose clock is in any year still sees it running.

The event's KeepsakeRoomLists carry the same one-list entry the room payload carries (from KeepsakeRoomConfig.RoomListEntries), because the client's copy of the event OVERRIDES the room's: a room the event's list does not mention would be overridden straight back to no permission. Under AllRooms the event's copy carries no member rooms - they cannot be enumerated - and the room's own copy is what names it.

The event payloads were rejected, and the client's own log says so. The game writes every session to BepInEx/LogOutput.log, which the plugin's [LogGate] feeds: the room's keepsake answer never appears in a Malformed Response line, but the event bodies do, verbatim - the whole record (Malformed Response: '{ "ProgressionEventId": 1, "Name": "Science Core", ...') and, after it was narrowed to identity + window + keepsake lists, the narrowed one too (the log's truncation lands on "StartTime" right after "IsEnabled", which is how the two payloads are told apart). Both come from EFMPNEEHJFJ.CMKDNAPFCOI[T]. Two things follow:

  • Unknown members are ignored, so a name that is merely absent costs nothing - which is why the room's answer parses even though it carries six keys KeepsakeRoomInstancesDTO does not declare (that is the answer that cleared the banner).
  • A name whose TYPE is wrong fails the whole response, so the fix is to send FEWER names, not more: every field that belongs to a neighbouring DTO rather than to this record is now out - the XP-boost family (BoostXpMultiplier, BoostDailyGameplayMinutesLimit, UsesBoost, PurchasableXpBoostId, all of which live on the boost DTOs) and the image-name fields (which may be typed image references rather than strings). What is left is the event's identity (ProgressionEventId, Name, IsEnabled), its window, Rewards, and KeepsakeRoomLists.

Two knobs stay for the next narrow. RADICADE_KEEPSAKE_EVENT_LISTS=empty sends KeepsakeRoomLists: [] in the record instead - an empty array deserializes for a list of anything, so if the malformed line disappears with it set and returns without it, the entry shape is what the record rejects. RADICADE_PROGRESSION_ACTIVE=record serves the record AT /api/progressionEvents/active instead of the id, for the case where that endpoint turns out to be the client's other no-argument call. The server console names which it served: [ProgressionEvent] active -> 1 or [ProgressionEvent] record 1 served (all keepsake list(s), list 1, limit 100).

How to read the next run. Walk into a room and place a keepsake; the server console names both halves of the answer:

grep -n "Keepsakes]" server-console-runNN.log   # the room's answer, and any placement

A POST /api/keepsakes/rooms/{id} answered accepted means the tile had something to place. If the tile still reads x0, the next lever is the event's contents rather than the room's permission - and any keepsake call this server does not implement is still named by the [Keepsakes] unclaimed METHOD path body=… probe (which answers 404 on purpose). GET /api/keepsakes/events is the one keepsake route still stubbed to []; the client's own route table (the literal run api/keepsakes api/keepsakes/categories api/keepsakes/events api/keepsakes/globalconfig in global-metadata.dat) shows those four are the whole keepsake surface, with the room route built beside them.

Saving an outfit: the route the client posts to, and the 404 it met

The client's POST /econ/api/avatar/v4/saved/set answered 404: this server had the saved-outfit routes on v3 only (GET /api/avatar/v3/saved, GET /api/avatar/v3/saved/{accountId}, POST /api/avatar/v3/saved/set), while the 2023 build reads its list from v3 and writes through v4. The client's own route table in global-metadata.dat names the three avatar routes it knows:

api/avatar/v4/items   api/avatar/v4/saved/name   api/avatar/v4/saved/set

so the missing pair was saved/set and saved/name (items already existed). Only saved/set is implemented here; nothing in this build has been seen calling saved/name, and a route invented without knowing the shape it must answer is worse than a 404 that names itself.

v3 and v4 share one slot table. Both post the same payload and both expect "save over this outfit" to mean re-save an existing slot, and the client reads the result back through v3. Two tables would let a player save an outfit that their own outfit list cannot find, so the slot bookkeeping lives in one place (RecNet/Avatar/SavedOutfits) and each version is only a shape: v3 echoes the outfit back, v4 answers the lean acknowledgement the client expects - { Success, Slot } - which is what the reference servers do too.

Two things behind the 404 were wrong and had gone unnoticed because the route that used them was never reached by this client:

  • Outfit.Slot was a string; the client posts an integer (Slot: 0..n). Deserializing the client's body into the old DTO could not have bound, so even a v3-shaped route would have failed on the body. It is int? now, and the payload's other two members (OutfitSelectionsV2, CustomAvatarItems) are declared so they survive the round trip instead of being dropped on save.
  • A body with no slot was accepted and stored as a literal null entry - the [null] an earlier empty-body post left on account 2 is exactly that. A missing slot is now a 400, matching the reference server: defaulting it would overwrite slot 0, the one outfit the player did not ask to replace. Stored blobs are also filtered on read, so that legacy [null] is dropped rather than handed to the client as an entry it cannot parse.

Verified live against the running server: no token is a 401 (the route exists), a body without a slot is a 400, a real save is 200 {"Success": true, "Slot": 0}, and GET /econ/api/avatar/v3/saved serves that outfit back with its selections verbatim. The writes were made on a throwaway slot and account 2's stored blob was restored afterwards.

Loading a room: what the second-per-second actually goes to

A room join is slow - minutes for a Rec Room Studio room, and uncomfortably long even for a Dorm whose save is 1.8 KB - and the log's own numbers say why. Measured against live from the client's machine:

Measurement Result
Fresh HTTPS request to the API 0.57 - 0.86 s (dns 0.28 + tcp 0.19 + tls 0.2 + server)
Second request on the same connection 0.19 s
Server-side work for a trivial route (/api/version) the same 0.19 s as any other route
CDN asset bundle, one stream 10 - 12 MB/s (85 MB ⇒ ~8 s)
TLS session resumption already working (Reused, TLSv1.3)

0.19 s for every route including a no-op one is ~2 RTT to the VPS, which is 100 ms away - the server is not spending that time, the wire is. So a room load costs what it costs because the client makes dozens of calls and opens a fresh TLS connection for each one (the [TLS] Accepting server certificate ... line is printed again before every single request in the log). Nothing server-side removes that; two things were making it worse:

  • GET /econ/api/roomcurrencies/v1/getBalance did not exist. The client asks it immediately after getAllBalances on every room join and retries an answer it cannot use; a 404 is an exception plus another round trip per retry. Nothing here tracks room-currency balances, so it now answers 200 with a zero balance that echoes the currency and account asked for (RoomCurrencyAPI.GetBalance).
  • The CDN sent no Cache-Control at all. Accept-Ranges, Content-Length and Last-Modified were there, but nothing told the client it may reuse bytes it already has - and for a Studio room the bytes in question are the entire asset bundle. Blob and asset routes now send public, max-age=31536000, immutable for .assetbundle names (the id is minted per upload, so a different bundle is a different URL) and public, max-age=86400 for everything else, because an admin repair can replace a room save under its existing name (VPS/fix-dorm-blob.sh).

Two things in the log that are not what they look like

Malformed Response: '[]', '{}' and '[ {...rooms...} ]' are thrown by the client's obfuscated DTO reader, which reads members positionally - the same property AlgorithmicListsAPI documents for lists/algorithmiclists. An empty body is therefore not a valid "nothing here" answer, and the client retries instead of moving on. Several routes still answer that way by design (keepsakes off, progressionEvents/active, roomkeys/v1/room); the retries are visible as 4x photon_access_token and 5x experience/player per join.

Exception: Can't parse '' as a hex number! fires ~112 times per session and is not PhotonAccessToken being empty, despite that field being "" in GET /rooms/photon_access_token: the client fetches that route 7 times per session, and both reference servers return "" there too (config/v1's UseNewPhotonAuthToken is false, so the token is not used for the join). Whatever parses hex 112 times is elsewhere, and changing the token to guess would risk the joins that currently work.

The failure at the end of a slow load

Unexpected Photon Disconnect due to cause: ServerTimeout is what the client reports as "Failed to load room due to disconnect during load (Code 8706-3)": the load outlives the room's Photon connection. The room-load stage timeouts are ours to set and are generous (RoomLoad.DownloadAssetBundleTimeoutSeconds = 900, PreDownloadTimeoutSeconds = 120, PostDownloadTimeoutSeconds = 240); the Photon peer timeout is not in that table, so the lever there is client-side, not a config key.

Verified locally against a dev server, then the server was stopped:

GET /econ/api/roomcurrencies/v1/getBalance?currencyId=...&accountId=2 -> 200 {"CurrencyId":...,"AccountId":2,"RoomId":0,"Balance":0}
GET /cdn/data/{blob}.room                -> 200 Cache-Control: public, max-age=86400
GET /cdn/data/{id}.assetbundle           -> 200 Cache-Control: public, max-age=31536000, immutable
GET /cdn/data/{id}.assetbundle (-r 0-1)  -> 206, Content-Range: bytes 0-0/1, same directive

Tests: the two CDN routes and getBalance are pinned in CdnRangeRequestTests and RoomEconomyPersistenceTests (8/8), and the full suite is 688 passed / 1 failed - the pre-existing AvatarCatalogSourceTests.ShippedLookupCoversTheCatalogServedToClients.

One landed batch from a single player report. Each item is the client's own URL, taken from BepInEx/LogOutput.log / Player.log in the deployed client rather than from the templates.

AmbiguousMatchException (HTTP 500) on the club member actions

ClubsAPI carries [Route("clubs/club")] (plus the api/clubs/v{1,2}/club copies), so an action with BOTH the class-relative template {clubId}/minlevel and an absolute ~/clubs/club/{clubId}/minlevel registers the same path twice. ASP.NET reports that as ambiguity, so every request to it threw before any handler ran - PUT minlevel, POST|DELETE members/remove, PUT members/invite and the accept/decline actions were all dead. The absolute ~/clubs/club/... copies are removed (the ~/club/... aliases are a different path and stay); the same duplication in ClubAnnouncementsAPI and api/playerevents/v1/report (EventsAPI owns v1, ContentReportAPI keeps v2) went with them, and PATCH admin/panel/{table}/{id} vs {id:long} is now one action that parses the id. ServerTests/RouteAmbiguityTests.cs reflects over every controller and asserts no (method, path) pair has two owners, so this cannot come back silently.

POST clubs/club/{id}/members/acceptinvite -> 405

The string literals (and the log) show the client accepts an invitation at .../members/acceptinvite; the server only ever registered .../members/accept, so the invite notification led nowhere. Both verbs are registered now, with declineinvite and changetype beside them, and invitemembers joins invite. The invite reader also accepts the field spellings the reference DTO allows (accountId/accountIds/playerId(s)/targetPlayerIds, single or repeated) instead of only accountId, which is what silently invited nobody.

POST econ/api/influencerpartnerprogram/remove -> 415

The economy client does not post JSON, and [FromBody] only accepts JSON content types. The handler reads the fields from the query string, a urlencoded form or a JSON body (content type or not), the same way redeem does.

DELETE api/customAvatarItems/v1/{guid} -> 405 and POST .../report -> 404

The item's editor addresses it by id: PUT (metadata edit) and DELETE (remove) are registered alongside the existing GET, both owner-gated, and the item report route resolves the item's creator as the reported player before funnelling into the shared SubmitReport.

GET matchmaking/room/{roomId}/instances -> 204

The instance browser is the watch menu every player opens for the room they are in. It required RoleType.Moderator and answered null to everyone else - null serialises to 204 No Content, which the client renders as "Failed to get room instance browser: Response was empty". It now answers an array (empty when the room has no live instance) to any signed-in caller.

"Clubs are appearing in every single category"

The Clubs screen asks clubs/club/categoryTags for its categories and then fetches each row on its own: GET clubs/club/search?sort=0&category=Social&count=32, one call per tag. The server ignored category and returned the whole catalogue, so the single club was drawn in every row. ClubStore.CategoryFilter/MatchesCategory now filter on primaryTag or customTags (case-insensitive, #tag tolerated) for search, mostactivetoday, mostactivenow and search/mostactivenow; a request with no category still gets everything.

Error refreshing rooms from source CarouselEndpoint

Still one per Play-menu open, and now explained: the client's template for the row is rooms/carousel/{0}&skip={1}&take={2} (a paged read), and the client logged our answer as Malformed Response: '[ { "RoomId": 167, "IsDorm": false, ... } ]' - a bare array where it reads { Results, TotalResults }, the envelope every sibling *_rooms/{source}&skip=&take= feed already answers with. rooms/carousel/{*source} answers that envelope now. The earlier "a bare array works" conclusion came from a run whose Play menu was fed by lists/algorithmiclists + rooms/bulk instead, so the route had never actually been read by the client.

Tests: RouteAmbiguityTests, ClubCategoryDiscoveryTests (9), CarouselFeedShapeTests (3) and three new instance-browser cases in MatchmakeInstanceRouteTests; full suite 798 passed / 9 failed, all nine the pre-existing catalog set (AvatarCatalogSourceTests, DorkNetCatalogTests, EquipmentInventoryTests, StorefrontRewardConfigTests).

Run 34 (2026-10-15, live): every room's bundle answered 400, so no room loaded its content

What the player saw, and what it was not

"Error downloading room assets! Try again later or on a better connection." on every room, for every player. In Player.log the exception surfaces inside RoomLoad.RoomManager and the client reports FailedToSpawnPlayer / (Code 8715) to matchmaking; the Zenject InvalidOperationException: You should not try to retrieve bindings from a disposed container! that follows is that failed load task being torn down, not a second fault. Nothing in the client's own log names the request, which is what made this look like a connection problem.

The request the client was actually making

BepInEx/LogOutput.log logs every call with its status, and it has the answer:

[HttpStatus] #322 Get /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 400 bytes=39 reason=Bad Request
[HttpStatus] #543 Get /rooms/unity_assets/2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle/0/1 -> 400 bytes=39 reason=Bad Request

Room 247 is the MAKER_ROOM:247/313 in the error: subroom 313 advertises UnityAssetId: 2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle, and step two of the download is healthy -

GET /data/2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle  -> 200  68,673,320 b
GET /rooms/rooms/247?include=5421&unityAssetTarget=0&unityAssetVersion=1
                                               -> 200  SubRooms[0].UnityAssetId = 2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle
  • so step one, the metadata call, was the only thing broken, and it was broken for every id of that shape on the server rather than for one room.

The regression, in one line

b33d5b3 ("Checkpoint the in-flight work before merging origin/2023") reworked UnityAssetsCDN for the Studio pipeline and put this in front of the resolution:

if (!StudioUnityAssetRegistry.IsValidAssetId(assetId) || target < 0 || ...) return BadRequest();

IsValidAssetId is value is { Length: 40 } && value.All(char.IsAsciiLetterOrDigit) - the shape of a Studio manifest id, and of nothing else that is stored as a bundle. Every legacy id fails it: Showdown_0_1.assetbundle (23 characters, _ and .), the 24-character RecNet ids (2n8bbqwnk6zs6k2blzgcdhcnc, 8aas7b5ivtaggn9swhe3wkgdj), the 36-character dashed id the importer binds (32cafb11-c51b-485c-8f1f-73b73159feff), and the built-in rooms' *_0_1.assetbundle names. ResolveLegacyFilename was still underneath, unreachable for exactly the ids it exists for, so every room whose subroom points at a bundle - which is every room with custom content, for every player - answered 400 {"success":false,"error":"bad request"}. The pre-Studio file had no such gate: ResolveFilename(assetId), the id with no extension first, then the id plus .assetbundle.

What changed

Server/CDN/UnityAssetsCDN.cs - 40 alphanumerics with a manifest behind them is still the Studio path, and a target/version its metadata does not name is a 404 (the file stored under a manifest id is the manifest JSON, so it must never be handed over as a bundle). Everything else resolves as a stored file: the name as given first, then <name>.assetbundle.

Server/CDN/CdnStorageNames.cs (new) holds the two read-side rules the blob routes share - one bare file name in that one directory (no separator, no ..), never a name ending in the admin upload's .uploading suffix, and <id> resolving to <id>.assetbundle - and DataCDN, RoomsCDN (both the /cdn/room + /unity blob route and the raw {assetId}/{target}/{version} route) and InventionCDN all go through it. Two things fall out of that: /data/<name>.uploading no longer serves a half-imported bundle by name, and the bare /unity/{id} alias now answers for a bundle stored only as <id>.assetbundle - the hard link per bundle that Run 31 needed is no longer required.

Verified

ServerTests/UnityAssetMetadataTests.cs (21 cases: both spellings of three id shapes, the Studio manifest with and without a bake, the manifest never served as a bundle, an id that only looks like a manifest, unknown names, separators and .uploading, out-of-contract target/version, and the blob routes) - 21/21. CdnRangeRequestTests, StudioUploadTests, AdminRoomImportTests, AdminRoomPartUploadTests, AdminBundleBindingTests together with it: 59/59.

Live on a local instance (Development, cwd Server, http://127.0.0.1:3031, storage Server/RadicadeServer-Data/CDN/Storage), the URL from the player's log:

GET /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 200
    {"UnityAssetId":"Showdown_0_1.assetbundle","Target":0,"Version":1,
     "Filename":"Showdown_0_1.assetbundle","RejectedDependenciesFilename":null}
GET /rooms/unity_assets/Showdown_0_1/0/1            -> 200  Filename="Showdown_0_1.assetbundle"
GET /rooms/unity_assets/Showdown_0_1.assetbundle     -> 200   (the short form)
GET /rooms/unity_assets/not-on-disk.assetbundle/0/1  -> 404  {"success":false,"error":"not found"}
GET /data/Showdown_0_1.assetbundle (Range 0-15)      -> 206  16 b, Accept-Ranges: bytes
GET /unity/Showdown_0_1                              -> 200  76,719,774 b   (was 404 without the hard link)
GET /data/Showdown.room                              -> 200  1,222,161 b

and the refusals, with the file present on disk so the rule is what refuses:

GET /data/probe-upload.assetbundle.uploading                        -> 400  (was 200, a truncated bundle)
GET /rooms/unity_assets/probe-upload.assetbundle.uploading/0/1      -> 400
GET /rooms/unity_assets/..%2F..%2Fappsettings.Development.json/0/1  -> 400
GET /data/../appsettings.Development.json                           -> 404

The probe file was removed afterwards and the instance stopped; nothing left listening on 3030/3031.

Full suite: 1556 passed / 12 failed. Eight of the twelve fail identically with this change stashed out (the catalog set the Run 30 note records: AdminGiftSkinTests, ConnectionRecoveryTests, DorkNetCatalogTests x2, EquipmentInventoryTests x2, StorefrontRewardConfigTests, WeeklyChallengeTests), and the other four (GameRewardFlowTests x2, RecNetShapeConformanceTests x2) pass when their classes run alone and mutate the same two statics - DBConfig.DefaultAvatarCatalog / DefaultAvatarUnlocks - that AvatarCatalogSourceTests, AvatarWireFormatTests and EquipmentInventoryTests write from parallel collections. That race is pre-existing and is not this change's.

Not verified: nothing was deployed, so production still answers the 400 until the server is rebuilt and restarted, and the client has to rejoin room 247 to confirm the load.

Run 35 (2026-10-15, live): the join box had no sender, so the client had no name to put on it

The box already carried a sender - no room could set one

RecNet's Gift.FromPlayerId is the field the client reads to name a box's sender, and the panel's own Gift Player page already lands there (AdminGiftFromTests). A room's join gift built its Gift in RoomJoinGiftUtils.TryDeliverAsync and left FromPlayerId at its default of zero, so every join box arrived from nobody, and no room setting could change that.

What changed

  • RoomData.JoinGiftFromPlayerId (bigint, 0 = no sender), added both by 20261015120000_AddRoomJoinGiftFromPlayerId and by EnsureRoomJoinGiftColumns for a database whose migration history is already past that point.
  • RoomJoinGiftUtils.TryDeliverAsync puts it on the box it hands out on join.
  • The admin panel's Room Details modal carries a FromPlayerId inside the Gift Box On Join block (assets/js/rooms.js in Radicade-Admin), and AdminPanelAPI.UpdateRoom reads join_gift_from_player_id, refusing anything that is not digits rather than dropping it quietly.

The id travels as digits in a string, never as a JSON number: an account id is eighteen digits, a JSON number is read back as a double, and its last digits are rounded away - so the panel would save a different account than the one it was shown. The room row the panel reads and the value it posts are both text, the same way the Gift Player page already posts ids.

Only a sender that actually changes bumps JoinGiftVersion, so a save that leaves the field alone does not hand every player a fresh box.

Verified

ServerTests/RoomJoinGiftFromTests.cs, 8/8, driving the real routes and then reading the box the way a client does (GET api/avatar/v2/gifts, asserted on the wire JSON): the saved sender becomes the box's sender; an eighteen-digit id survives the panel both ways; a changed sender re-issues the box while a save that leaves it alone does not; a blank clears it back to no sender; a payload without the field keeps the stored sender; a non-digit value is a 400; and a room that names no sender still hands out its box.

Live on a local instance (Development, cwd Server, http://127.0.0.1:3031, SQL Server .\SQLEXPRESS) the migration applied on startup and the route behaved:

Applying migration '20261015120000_AddRoomJoinGiftFromPlayerId'.
GET  /admin/panel/rooms                                       -> 200  all 45 rooms carry
                                                                    "join_gift_from_player_id": "0"
PATCH /admin/panel/rooms/58  {"join_gift_from_player_id":"114903603133502227"}
                                                             -> 200  {"success":true}
GET  /admin/panel/rooms/58                                    -> 200  "join_gift_from_player_id": "114903603133502227"
                                                                    "join_gift_version": 2   (was 1)
PATCH /admin/panel/rooms/58  {"join_gift_from_player_id":"not-a-player"}
                                                             -> 400  'join_gift_from_player_id' must be
                                                                    an account id in digits, or blank
                                                                    for no sender.  (room untouched)
PATCH /admin/panel/rooms/58  {"join_gift_from_player_id":"","join_gift_version":1}
                                                             -> 200  "join_gift_from_player_id": "0"
                                                                    "join_gift_version": 1

Room 58 was left exactly as it started (no sender, version 1) and the instance was stopped; nothing left listening on 3031.

Not verified: the modal itself was not clicked in a browser - the panel sits behind SSO, so the served page redirects before the room list loads. rooms.js was parsed as a classic script in the browser (new Function over the served file, no SyntaxError) and its field markup and save handler read; nothing was deployed, so production needs a rebuild and a restart, at which point the column arrives with the rest of the startup migrations.

Full suite: 1566 passed / 10 failed / 1 skipped, and every failure is one the Run 34 note already records as pre-existing: AdminGiftSkinTests (x1), ConnectionRecoveryTests (x1), DorkNetCatalogTests (x2), EquipmentInventoryTests (x2), StorefrontRewardConfigTests (x1), WeeklyChallengeTests (x1), and the two order-dependent RecNetShapeConformanceTests avatar-catalog cases that mutate DBConfig.DefaultAvatarCatalog / DefaultAvatarUnlocks from parallel collections. The two GameRewardFlowTests scheduled into that same race passed this run.