Files
Radicade/docs/2023-0831-CLIENT-SWITCH.md
T
2026-10-16 23:33:13 -07:00

111 KiB

Switching the game client to 20230831 (September 2023)

Target: C:\RecRoom\2023-09-06T06-53-47Z — a Steam depot download whose name is only the download timestamp. The build's own version literal (inside RecRoom_Data/globalgamemanagers) is 20230831, Unity 2020.3.33f1, Steam manifest 471711_1957069142347218099.

Why this build: the RRS packages in Documents/EggFiles/ProjectTemplate are the 2023-08-31 set (com.recroom.versioning@3ec42268d40e, whose APOBPPFCFHK() returns enum 34 -> PersistenceVersion 64). The August client (20230825) writes 61 and accepts up to 63, which is exactly why the studio's room was refused with "You are playing on an older version of Rec Room than this room was saved with". 20230831 is the release that owns the studio's revision, so it is the client to run.

The release's metadata is shuffled differently

Rec Room ships global-metadata.dat with a 256-byte header whose 31 (offset,size) field positions are permuted per release, and the section tables are laid out in a per-release order. The August map is known from tools/AugustInterop/normalize.cjs; the September one was derived structurally in .tmp-newbuild/:

  • find_layout.py — locates a string-literal table (records {u4 length, i4 dataIndex}, monotonic indices) plus the data blob they point into (printable UTF-8). On the August file it reproduces the documented fields (table 0x2C/0xBC, data 0xC8/0xC0) and for September gives table 0x74/0x24 = 0x17BDD78, 52,309 records; data 0xA0/0x3C = 0xE11A10, 1,657,800 bytes.
  • chain.py — the tables are laid out back to back from 0x100 with zero gaps (verified on August: every section starts where the previous one ends). Walking that chain with the header's own numbers yields a unique 29-step layout that ends exactly at EOF, with only two unused header values, which classifies every slot as an offset, a size or unused.
  • Section sizes then match August's within ~0.2-0.5%, and the resulting standard-v27 header copy loads cleanly in LibCpp2IL:
dotnet run --project tools/AugustInterop -- <august BepInEx core> artifacts/20230831-normalized-metadata.dat
Reading type definitions...42636 OK
Metadata parsed successfully.
imageDefinitions: 305   typeDefs: 42636   methodDefs: 283919   fieldDefs: 171027

(August for comparison: 305 images, 42,537 types, 283,224 methods.)

Installed into the new client

  • Recroom_Data/il2cpp_data/Metadata/global-metadata.dat — host literals rewritten to https://localhost:3030 by tools/patch-0831-client-metadata.py (9 literals, the same set the August patch produced: ns.rec.net, auth.rec.net, cdn.rec.net, bare rec.net, the /shop and /{0} forms, the .rec.net tail -> .radicade.internal). The rebuilt literal blob is relaid at EOF and only the four header fields that describe it are repointed. Original kept as global-metadata.dat.orig; marker in Recroom_Data/radicade-patch.json.
  • Recroom_Data/il2cpp_data/Metadata/global-metadata.interop.dat — the normalized tooling copy (artifacts/20230831-normalized-metadata.dat) with the 305 synthetic <Module> rows repaired, for Cpp2IL/Il2CppInterop. This file must never replace the game's global-metadata.dat.
  • Doorstop (winhttp.dll, doorstop_config.ini, .doorstop_version) and BepInEx/{core,config,patchers,unity-libs} from the August install, including the patched loader (BepInEx.Unity.IL2CPP.dll md5 9430bb84..., reads global-metadata.interop.dat). August's BepInEx/interop and its build-specific plugin were deliberately not copied.
  • Recroom_Data/boot.config — the desktop variant (vr-enabled=0, player-connection-mode=Listen); the vanilla file is kept as boot.config.orig.

Server: 20230831 added to DBConfig.SupportedGameVersions.

Launch results (2026-09-26)

First launch crashed without any BepInEx log: Doorstop's runtime folder (dotnet/, 67 MB, coreclr.dll plus corlib; doorstop_config.ini points at it as coreclr_path = dotnet\coreclr.dll) had not been copied from the August install. With dotnet/ in place BepInEx starts and writes BepInEx/LogOutput.log.

Second launch reached Cpp2IL and failed inside interop generation:

Failed to generate Il2Cpp interop assemblies: System.IndexOutOfRangeException
   at LibCpp2IL.Metadata.Il2CppTypeDefinition.get_NestedTypes()

Cause: two sections were swapped. LibCpp2IL treats nestedTypes as a flat array of type-definition indices (nestedTypeIndices.Skip(NestedTypesStart).Take(NestedTypeCount) then typeDefs[idx]), while interfaces indexes the binary's type table and its values legitimately exceed the type-definition count. Of the two ~70 KB blocks, nestedTypes is the 71,356-byte one at 0x1826FC8 (fields 0xA8/0xE0; every value below 42,636) and interfaces is the 70,556-byte one at 0x1873AB8 (fields 0x68/0x08; values up to 134,062). With that fixed the copy re-validates through LibCpp2IlMain.LoadFromFile against the new GameAssembly.dll: Binary + metadata initialization: True, 283,919 method pointers mapped.

Third launch: interop generation succeeded — 335 assemblies plus BepInEx/interop/assembly-hash.txt, log ends with Il2CppInteropGen Done! and Chainloader startup complete. The game then boots about 19 s and dies the way the August client did before its plugin existed:

BestHTTP...BouncyCastle.Crypto.Tls.TlsFatalAlert: user_canceled(90)
RecNet name server query failed (error code: summer): user_canceled(90)

The literal patch is working — the client really is querying the local server — and the remaining blocker is the client's embedded certificate validation, which is one of the hooks the August plugin installed.

Remaining work

  1. Launch once with no plugins to let Il2CppInterop generate this build's interop assemblies. Done: BepInEx/interop holds 335 assemblies.
  2. Port the client plugin. Done - see the port section below.
  3. Verify: sign-in plus loading the studio room, which is also the first hard evidence of the schema ceiling this build accepts. Sign-in, session stability and the Dorm load are now green; entering the studio-authored room itself is the part still waiting on a click in-game.

Plugin port (2023Patch/RadicadeSeptemberClient)

RadicadeAugustClient.dll is built against August interop and patches August's obfuscated identifiers, so it cannot load in this client. The port is a source rebuild (RadicadeSeptemberClient, plugin id com.radicade.eacbypass, version 3.0.0, version gate Application.version != "20230831") sharing the same four sources plus TLS/hub/websocket/address-map files from RadicadeEacBypass.

Il2CppInterop's method tokens are stable between builds (deltas 0-4; only the obfuscated identifiers rotate) and its type spellings keep their structure, so the rename map was derived mechanically instead of by hand:

  • tools/sept-map.py {show,find,map,match,verify,types} indexes the decompiled stubs in .external/{aug,sep}-decomp/ and matches (normalized signature, token +/- window). Every ported symbol was confirmed by token and shape.
  • Examples: launch validator ADJOODNBCKG/NFPDGJNDFOF/GBAMNAKGEGD -> IDLGLOLKLEB/MKGADIPJDLK/IDDOELEGIFB; exit state CCKNJCHHBLL -> KJAMNFCDBIO (setter KALEFEDAHCC -> EHPDJDJLPKM); cheat manager MJLAGDJLOBC/EFLCHFDDPJP -> EKGOMPDGNEK/FOKOIBODNAD; promise type PFDOLDLEIBA -> NOKBHGDKGNF. EACManager.GenerateChallengeResponse, Tool.ResetToDefaultCollisionLayer and the CheatManager scan entry kept their names.
  • .gitignore was blocking the edit tools for the port tree (rule /2023Patch/*); the new tree and its four shared sources are now un-ignored.

Build is clean (0 errors/0 warnings) and the DLL replaces the August one in BepInEx/plugins. Boot markers seen live: embedded-TLS acceptance, launch-validator bypass, [FileCheck] entry bypassed, [ExitVeto]/[QuitVeto], cheat-scan suppression, [AddrMap] mapped 254799 methods, [ToolGuard], and [CheatDrop] dorm drop (FOKOIBODNAD) held back.

Image signature wall: the key id came from the wrong metadata file (fixed)

The wall: the client requested images with sig=p1, every response carried Content-Signature, and the client still filed an anti-cheat report Type=6 Message=Signature malformed (default.png?sig=p1) - no image ever rendered and the featured-rooms UI looped. Zero [RsaVerify] calls appeared in the log, so the failure happened before any RSA verify.

Which is exactly how this client reports a key-id mismatch - the conformance notes in ServerTests/RecNetShapeConformanceTests.cs record that a wrong key id makes the build drop the image as "malformed" before it reaches RSA - and the mismatch was self-inflicted:

  • the metadata patch rewrites the literal .rec.net to .radicade.internal, so the running game pins KEY:RSA:p1.radicade.internal (verified: record #2311 of global-metadata.dat reads .radicade.internal);
  • the plugin read its key id from global-metadata.interop.dat, the normalized tooling copy made before the literal patch, which still says .rec.net; it therefore stated KEY:RSA:p1.rec.net in rcsig, the server echoed the stock id back, and the client rejected its own answer.

Fix: ImageSignatureRequest.DetectKeyIdFromGameMetadata reads the metadata the runtime actually loads. That file's 256-byte header is shuffled per release, so the literal table is discovered instead of hardcoded: every 32-bit slot in the first 0x100 bytes is a candidate field value, a (table,data) layout is accepted only when its records fit the data segment, sample as strict UTF-8, and are packed in order (index[n] == index[n-1] + length[n-1], the rule that makes the answer unambiguous - a wrong table cannot pass it), and the key id itself must still be the single tail literal the file carries. The plugin now reads the game metadata first and keeps the interop copy as a fallback, logging [ImageSig] image key id: ... (game metadata: ..., interop fallback: ...); image requests log [ImageSig] stated key id on <url>.

Live result (2026-09-26 14:11 boot, RADICADE_HILE_GATE=drop):

  • [ImageSig] image key id: KEY:RSA:p1.radicade.internal (game metadata: ...), 52 image requests, each carrying rcsig=KEY%3ARSA%3Ap1.radicade.internal;
  • no PlayerReporting/v1/hile report and no Signature malformed anywhere - the key-id wall is gone and signatures are no longer the reason images fail;
  • Initial Room Load '@TestPlayer's Dorm' (25.32) [instance 20957] completes, SpawnLocalPlayer finishes at 11.1 s, and the process is still alive at 400 s+ (the August client self-exited ~40 s in through EAC teardown).

The periodic Could not enable featured rooms display... no images are available warning is the known August noise (featuredrooms/current is not served), not a signature failure.

Tooling added while chasing this: tools/sep-literals.py (literal table inspector: find, around, token) and tools/sep-scan.py (PE byte scan for metadata-usage tokens/pointers, naming the enclosing method from the runtime address map). The scan did not locate the verifier: at metadata v27 the code reaches a literal through a .data table of 8-byte encoded usage tokens, and the bare 4-byte token pattern is too common in 30 MB of code to be conclusive.

.tmp-newbuild/ holds the derivation scripts (find_layout.py, chain.py, make_interop_metadata.py, solve_map.py, pin_map.py) and map_20230831.json.

2026-09-26 15:05 - per-frame exception flood gated at the game's log handler

Reported symptom: after a studio test the log fills with

InvalidOperationException: You should not try to retrieve bindings from a disposed container!
NullReferenceException: Object reference not set to an instance of an object.

Trigger (both sessions): the presence fallback cancels the in-progress load (Room load to <id> cancelled -> Attempting to go to dorm; the 15:04 boot also logged Local Player's presence room instance is invalid). The room scope's UJect container (HOKFDAHMIPF in UJect.Runtime, it has Dispose) is disposed while systems built from it keep updating, so each of them resolves through the disposed container every frame and the guard LIFBDDODLCA.KILJNHIAFPI throws:

  • RecRoom.Core.Creation.CircuitsV1.CircuitVisibilityBehaviour.ShouldBeVisible via HHLGMMPJKPK -> HOKFDAHMIPF.BBDOIOJLDPI<T> -> LIFBDDODLCA.KILJNHIAFPI;
  • RecRoom.CircuitsV1Manager.Update;
  • RecRoom.Audio.AudioManager.Update -> font-texture-rebuild callback chain;
  • RecRoomSceneManager.Update -> SceneMoodSettings.FPAMNPDBNJC (the NRE the same broken resolve produces once it fails instead of throwing).

Unity logs every throw with its full managed stack: 8016 exception entries / 12.5 MB Player.log (14:22 session), 7151 / 6 MB (14:45). None of it is actionable - the systems are stale, the container is gone - and it buries everything else.

Fix: 2023Patch/RadicadeSeptemberClient/LogFloodGate.cs patches ODGGOALOLEG.LogException(Il2CppSystem.Exception, UnityEngine.Object), the game's ILogHandler in RecRoom.Debugging.Runtime and therefore the single managed funnel every UnityEngine.Debug.LogException goes through. The first occurrence of each distinct exception (type + message + top stack frame) logs in full; identical repeats collapse to one per 30 s window, with [LogGate] count reports. Different exceptions are untouched, and RADICADE_LOG_GATE=0 disables the gate entirely.

RADICADE_LOG_GATE_SELFTEST=1 proves the gate in one boot: the first exception that reaches the game handler makes it fire five identical ones and log PASS/FAIL. (It cannot run at plugin load - Unity is still on its own DebugLogHandler then, which is why the first attempt reported FAIL with five uncollapsed lines in Player.log.)

Verified (2026-09-26 15:04 boot, RADICADE_HILE_GATE=drop, RADICADE_LOG_GATE_SELFTEST=1):

  • [LogGate] armed on ODGGOALOLEG.LogException ...;
  • [LogGate] self-test: fired 5 identical exceptions through the live game handler; 4 were collapsed, 1 reached the log (PASS) - Player.log has exactly one;
  • the same boot collapsed real floods with the exact signatures the user pasted (disposed container, SceneMoodSettings NRE) plus two others it exposed: Malformed Response: '{}'/'[]' (EFMPNEEHJFJ.CMKDNAPFCOI) and Local Player's presence room instance is invalid (CPEIFKPBJND.ICPBBDFEHBO);
  • session log 2 exception lines / 764 KB, against 7151 / 6 MB the run before.

The gate only skips the log write, so it cannot cause behaviour changes; the boot that verified it still ended ~46 s in through the client's own shutdown request /matchmaking/player/logout, a boot-scene restart (CheatManager.OnDestroy -> Awake -> Start) and then silence - no dump, no WER, no [ExitVeto] block. That is the pre-existing silent-exit class, and its trigger is the same presence bounce the cancelled-load flood points at.

2026-09-27 02:00 - chat: the conversation pane was blank because the message list was empty

Reported symptom: the Chat screen shows Party Chat - 0 party people! on the left and a right-hand conversation titled Chat with a completely empty body, even though POST /chat/thread/withmembers, GET /chat/thread/1?messageCount=50 and POST /chat/thread/1 all answered 200.

Client-side truth, from Player.log plus the plugin's [Net] lines around 02:00:29:

[Net] Post .../chat/thread/withmembers        (creates thread 1 for accounts 2 and 3)
[Net] Get  .../chat/thread/1?messageCount=50
Exception: Index was out of range. Must be non-negative and less than the size of the collection.
  at System.Collections.Generic.List`1[T].get_Item (System.Int32 index)
  at MMBGIGIFEMJ.AJONACNDODH (ELKGEHBGLCO HEJJEIECPNK, System.Int32 FJLPCOJGEJP)
  at RRUI.Data.ChatThreadModel.FetchOlderMessages (System.Int32 FJLPCOJGEJP)
  at RRUI.Data.ChatThreadModelController+FetchOlderMessagesScrollRectImpl.IHJJMFLAKAE (System.Double)
  at ... OSA`2.ChangeItemsCount -> OSA`2.Refresh -> RRUI.RecyclingListScrollRect.Clear
  at RRUI.RecyclingListScrollRect.SpawnLoadingPlaceholders (GameObject)
[LogGate] repeats of this exception are now suppressed for 30s at a time
[Net] Post .../chat/thread/1                 (the message the user then typed)

The thread the client had just created had zero messages, and FetchOlderMessages pages history from the OLDEST message the model already holds - MMBGIGIFEMJ is the chat API client (MessageJson{Type,Version,Data}, DMMNEMENNED{Latest,NewerThan, OlderThan}), and ELKGEHBGLCO is the client's thread with a List<ChatMessage> on it. With that list empty the index is out of range. The throw comes out of the recycling list's own refresh, so it fires once per frame, the list never completes a build, and the pane stays blank forever - the user could still type, which is why a message did get posted afterwards.

Real threads never hit this because Rec Room opens every conversation with a system notice from the pseudo-player -5. Captured real traffic (chat.rec.net, 2022, in .external/recflare-server/research/RecNet API.postman_collection.json) shows it as the oldest message of every thread that has one:

"senderPlayerId": -5,
"contents": "{\"Type\":0,\"Version\":1,\"Data\":\"Player <@U29565301> started a chat\"}"

The same capture fixes three more shapes we were guessing at, and it is what Server/Chat/Thread.cs now serves:

  • GET /chat/thread?maxCount=16&mode=0 is an ARRAY of threads carrying latestMessage (not lastMessage/messages), chatThreadId, playerIds, lastReadMessageId, chatThreadName, snoozedUntil, isFavorited. DMs carry chatThreadName: null (we send "") - the client renders the other member's display name itself, so a "Chat" placeholder here was wrong too.
  • GET /chat/thread/1?messageCount=50 is an OBJECT with a messages page (newest first); GET /chat/thread/1/message?MessageCount=16&Mode=0 is a BARE ARRAY of the same messages. The second route did not exist on this server at all - it is the spelling FetchOlderMessages calls, and the client rejects a thread object there.
  • POST /chat/thread/1 answers the whole thread with its messages, and a blank messageContents reports chatResult 1 with the thread still attached. The client posts exactly that blank call right after /thread/withmembers to "open" a thread, and we were answering 400.

Also fixed while in there: a thread with no messages was dropped from the thread list (if (messages.Count == 0) continue;), which is why the sidebar showed nothing at all; lastMessage was the OLDEST message of the page rather than the newest; and thread membership was not checked on the send route.

Verified: dotnet test ServerTests/Server.Tests.csproj - 608 passed (9 new cases in ServerTests/ChatThreadTests.cs pin the notice, the list/open/messages shapes, backwards paging and the blank-contents result), and all three routes were replayed live with a real client token against the rebuilt server.

Retest 02:16 - the routes the first pass never saw

Rerunning with the client exposed two things the 02:00 evidence could not:

[Error] PINDLLOAFFA: HTTP Error 404
[Error] Failed to ReadMessage: An error occurred
...
[Net] Get  /chat/thread/1/message?messageCount=8&mode=2&referenceMessageId=1
[Net] Post /chat/thread/1/message/2/read      -> 404 (server-side)

The 404 was POST /chat/thread/{id}/message/{messageId}/read - the client's "ReadMessage", which it calls for the newest message once a conversation has rendered. The messages paging call also spelled its anchor referenceMessageId, not MessageId, so the anchor was being ignored: every "older than message 1" page was answering the NEWEST messages instead of the ones before the anchor, and the client kept re-requesting (the query repeats in the log even after the pane filled).

Both fixed, plus the result shape the read route has to use. MMBGIGIFEMJ's method table shows what the client awaits, and the read family returns MFBKDKPGMNP<BBPILCKAECN> - a Task of the result enum, not of a thread or a wrapper:

public enum BBPILCKAECN   // RecNet, the client's ChatResult
{ Success, InvalidArguments, ThreadNotFound, MembershipNotFound, PlayerAlreadyOnThread,
  CannotMessagePlayer, InvalidCharacters, RecentlyLeftThread, ThreadTooLarge,
  InsufficientPermission, TooManyAffiliationThreads, UnderModeration, MessageNotFound,
  InvalidThreadJoinType }

The body is therefore a BARE NUMBER. ChatResult(int) in Server/Chat/Thread.cs now returns the integer itself rather than a { ChatResult, chatResult } envelope, which is what the read, leave and rename routes answer; an envelope deserializes into nothing and surfaces as Failed to ReadMessage even at HTTP 200. A non-member on the read route is a 200 with MembershipNotFound (3), not a 404 - the status is what the client turns into a transport error, the code is what it reports.

Verified: dotnet test - 610 passed (2 more cases), and live: the read route answers 200 + 0, and ?messageCount=8&mode=2&referenceMessageId=1 now answers [] instead of the whole thread.

Still not done: GET /chat/thread/party is the [] stub from the August port, so the left column keeps reading 0 party people!; favorite, snooze and chatPrivacySetting have no route at all (so the star on the conversation header 404s); lastReadMessageId is one value per thread rather than per member, so unread badges are wrong once two accounts read the same conversation at different times; and every message is served with the full ~40-key alias shotgun, which is why one 4-message thread answers a POST with 80 KB.

2026-09-27 02:35 - chat, the other half: the push id has to be a NAME, not the enum's number

A message now persisted and rendered from a refetch, but the conversation still only updated for whoever asked: the live push never reached a handler. That half is realtime, and it is now settled from the client itself rather than inferred.

RealtimeDiagnostics (shared source, compiled into the September plugin) grew a probe that prints the notification ids this client actually accepts. It hooks the dispatcher's own registration calls - every void method on it taking (string|enum, callback) plus the one-argument channel calls - and logs the first argument as each registration happens. The result, at login, is the contract in one place:

[Realtime] watching 6 dispatcher method(s) on HCACKLIOLEM (5 registration, 2 generic
  overload(s) skipped but funnel through the core, rest channels): every notification id
  this client accepts will be printed as it registers.
[Realtime] the client registered a notification handler under "ChatMessageReceived"
  (HCACKLIOLEM.LFOCBEMNPDP) - a notification whose Id EQUALS "ChatMessageReceived" is
  handled, anything else is dropped.
[Realtime] chat push key check: the server sends Id="90" and the client registered NO
  handler for "90"; the chat handler it registered is under "ChatMessageReceived".
  Only an exact match is delivered.

Two things had to be learned to get there, and both are in the file's comments now:

  • Reading the handler map kills the client. The handlers live in an IL2CPP static field, which Il2CppInterop surfaces as a property whose getter is a native il2cpp_field_static_get_value call. Invoking it through reflection terminated the process with no managed exception at all - Player.log simply stopped at 2 KB and nothing reached the error log - while the dispatcher's own registration methods are ordinary managed methods that patch safely. The map dump survives only as a managed-fields-only fallback.
  • Il2CppSystem.Action is not System.Delegate. The dispatcher's overloads take Il2CppSystem.Action / Il2CppSystem.Action<T> / its own delegate type, and those are IL2CPP proxies: Il2CppSystem.Object is not System.Object, so IsAssignableFrom(typeof(Delegate)) matches none of them and the first run hooked zero methods while reporting the dispatcher found. The check walks the base chain for a Delegate-shaped type by name instead.

The server change is one line in Server/Chat/Thread.cs. Notification.Id's getter converts any enum to its numeric value, and an id the client has no handler for is dropped without a trace - which is exactly what a new message used to do. It is now spelled out as nameof(NotificationType.ChatMessageReceived), the way PresenceUpdate, AccountUpdate and SelfAccountUpdate already were. The per-type rule is real and worth keeping in mind: this client registers ModerationKick by its NUMBER ("22", see ConnectionRecoveryTests) and ChatMessageReceived by the NAME, so neither form is universally right.

Verified: dotnet test - 611 passed (a new case drives the send route with a recording hub client and asserts the pushed envelope carries "Id":"ChatMessageReceived" and not "Id":"90"), and live against the running client:

[NotifyHub] -> Ye_BABskiLsHFK5zFYwTEg: Id="ChatMessageReceived"
[Realtime] Notification #16 Id=ChatMessageReceived IdToken=String Msg={..."id":14...}
[Net] Post /chat/thread/1                    (the message, right after the push)
[Net] Post /chat/thread/1/message/14/read    -> 200 0
[Net] Get  /chat/thread/1?messageCount=50    -> 200

The client now reacts to its own and the other account's messages: it marks the newest message read and refetches the thread, which it never did before because the frame landed on no handler. Thread 1 answers len=108749 for a 50-message page, all of it alias shotgun.

2026-09-27 03:15 - pointing the client at the public server (ns.radicade.app)

The client now talks to https://ns.radicade.app (a netcup VPS behind Caddy -> Kestrel) and nothing to the dev box. Two independent things decide that address, and moving only one of them produces a client that boots against one server and then lives on another:

  1. Connection:ServerUrl in BepInEx/config/com.radicade.eacbypass.cfg, whose code default is now https://ns.radicade.app (RadicadeSeptemberClient/Plugin.cs). Everything the plugin rewrites - every *.rec.net service host, the hub socket, and the ns.radicade.app host itself - is redirected to this base. TLS:AllowedHosts follows it: AlignAllowedHostsWithServer() appends the host and saves the config, so a new domain needs no second edit.
  2. The bootstrap host baked into the client metadata by tools/patch-0831-client-metadata.py (rule https://ns.rec.net -> the server base, plus api/auth/images/cdn literals). The client fetches its nameserver document from that baked host and then uses whatever absolute URLs THAT server returns, so a stale baked host silently governs the whole session.

That second point is the trap. The tool rewrites the file as it stands and does not reseed, so re-running it on an install already patched for another address finds nothing to rewrite and reports success with zero changes. Restore first:

cp RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat.orig \
   RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat
py -3 tools/patch-0831-client-metadata.py <game-root> --server https://ns.radicade.app --apply

The plugin warns about exactly this mismatch at boot ([Meta] this install's metadata was patched for 'X' but Connection:ServerUrl is 'Y'), which is how the localhost-baked install was caught: it had been reading /ns from localhost:3030, and that server's own Radicade:ServerUrl (the dev box's https://<dev-host>:3030) is what the whole session then dialled - 41 requests to the IP, none to the configured domain.

Verified on the running client: 134 requests to https://ns.radicade.app/, zero to <dev-host> or localhost; the notify hub socket upgraded and the handshake was accepted; [TLS] Accepting server certificate for allowed Radicade host 'ns.radicade.app'; the login token was captured and 27 notification frames arrived.

Still localhost by default, and deliberately left alone for now: RadicadeAugustClient (the 20230803 build, not in use) and RadicadePatch (the older C# host patcher, whose README documents a plain-HTTP local workflow).

One more thing learned the hard way: stopping the server kills the running client. The 02:36:52 run ended the moment the server was killed for a rebuild - the last lines are repeated /notify/hub/v1/negotiate attempts and Exception: Connection refused. Every rebuild+restart therefore costs a client relaunch; plan the retest around that instead of reading the death as the old silent-exit bug.

2026-09-27 11:55 - a friend's install died in interop, and the metadata header is the whole reason

A second machine (game folder the same 2023-09-06T06-53-47Z, but data folder renamed Radicade_Data, BepInEx reported as 6.0.0-be.788) failed before it ever reached the plugin:

[Error  :InteropManager] Failed to generate Il2Cpp interop assemblies:
 ---> System.FormatException: Invalid or corrupt metadata (magic number check failed)
[Fatal  :   BepInEx] Unable to execute IL2CPP chainloader, no plugins will be loaded
[Error  :   BepInEx] Could not load file or assembly 'UnityEngine.CoreModule'

That is not a broken file. Rec Room's global-metadata.dat does not start with the standard IL2CPP magic; it ships a per-release scrambled 256-byte header. Ours begins e0 eb 02 00, the normalized copy begins af 1b b1 fa (0xFAB11BAF - the value Il2CppMetadata.ReadFrom checks). The working install exists because of exactly this: a normalized copy (global-metadata.interop.dat) plus a loader that reads the copy. The friend's install has neither, and three details in the log say so:

  • The failing line names .../il2cpp_data/Metadata/global-metadata.dat. Our patched loader prints global-metadata.interop.dat there - tools/AugustInterop --patch-loader rewrites that single ldstr - so theirs is a stock loader.
  • be.788 with no GlobalMetadataPath in its config. That setting does not exist in be.697 at all (our working BepInEx.cfg has no such key under [IL2CPP]), which is why the fix was a binary patch here. Newer BepInEx builds expose the same redirect as configuration.
  • No Detected outdated interop assemblies, will regenerate them now line, and no Preloaded N interop assemblies. CheckIfGenerationRequired() returns true silently when BepInEx/interop is absent, so the folder was never there: a plain BepInEx was installed on top of this game. Downloading unity base libraries agrees - unity-libs was missing too.

So the rule for a summer 2023 Rec Room install is: the BepInEx layer travels as one unit. The be.697 core with the patched loader, global-metadata.interop.dat, the generated BepInEx/interop (332 assemblies here) and assembly-hash.txt have to arrive together; any one of them alone is worse than nothing, and a newer core replaces all four.

Two ways back, in order of least pain:

  1. Move the friend's BepInEx folder aside and copy the working one in, together with winhttp.dll, doorstop_config.ini, boot.config and both *.dat files.

  2. Keep the newer core and add this to BepInEx/config/BepInEx.cfg (then let interop regenerate once, which takes minutes and needs unity-libs):

    [IL2CPP]
    GlobalMetadataPath = {GameDataPath}/il2cpp_data/Metadata/global-metadata.interop.dat
    

    That only works if global-metadata.interop.dat is present in the metadata folder - the normalized file is the piece a stock install never has.

Both are now scripted, because hand-assembling this a third time is how the be.788 install happened in the first place:

  • tools/september-client-triage.ps1 - read-only, finds the data folder whatever it is called, and prints one OK/WARN/MISSING line per piece: build identity, both metadata files and their magic bytes, loader patched-vs-stock, the GlobalMetadataPath override, interop count and hash, plugin/winhttp/doorstop, then the markers in the last LogOutput.log. It writes september-client-report.txt and exits non-zero when anything is missing. Confirmed against this install (8 OK, 0 warnings) and against a fixture that reproduces the friend's log (5 MISSING, including the last run died in interop generation).
  • tools/make-september-client-pack.ps1 - builds the handover bundle: verifies the source against the reference hashes and the two header magics, then copies the whole BepInEx tree (minus logs, *.before-* backups and the 18 MB radicade-addr-map.txt), the data-folder pieces, and a README-FRIEND.txt whose first instruction is move your existing BepInEx aside, do not merge. -VerifyOnly checks the source and copies nothing. Run here: all ten pieces ok, bundle 294.8 MB, and the bundled triage script reports the source install clean. (It carries eleven checked pieces since 2026-09-27: the marker radicade-patch.json was added to the bundle, since the plugin reads it to confirm the patched server - see the last section.)

One thing the reference hashes pin down: this is the public-server install, so the friend needs no local server. The shipped plugin config already reads ServerUrl = https://ns.radicade.app, matching the domain baked into global-metadata.dat.

2026-09-27 13:20 - the friend's second log: the stack boots, and the client asks a different private server for its config

The be.697 stack is fine now (BepInEx 6.0.0-be.697, 1 plugin to load, [Radicade September Client 3.0.0], Chainloader startup complete, every notification handler registered, [Sept] Connection patches loaded). What the log shows instead is that the client data in that folder is not this bundle's:

[Warning:Radicade September Client] [Meta] this install's metadata was patched for
  'https://localhost:3030' but Connection:ServerUrl is 'https://ns.radicade.app'
[Info   :Radicade September Client] [Net] Get https://localhost:3030/
[Info   :Radicade September Client] [Net] Get https://api.recflare.net/api/gameconfigs/v1/all
[Info   :Radicade September Client] [Net] Post https://datacollection.recflare.net/data/event
[Info   :Radicade September Client] [TLS] NotifyServerCertificate: host=api.recflare.net, ...
[Error  :Il2CppInterop] ... TlsFatalAlert: user_canceled(90)
[Warning:Radicade September Client] [QuitVeto] wantsToQuit answered false (block #1)
[Warning:Radicade September Client] [LogGate] repeats of this exception are now suppressed ... Exception: Aborted

recflare is another project, and this install has never contained the string

recflare.net is RecFlare's public server - the reference implementation we studied lives in .external/recflare-server, and its services are exactly api.recflare.net, datacollection.recflare.net and friends. On this working install the string appears zero times in every piece that could carry a host:

global-metadata.dat / .orig / .interop.dat / GameAssembly.dll  ->  0 x recflare

So those URLs are in his client data, and two things put them there: a second patch in the same tree (his global-metadata.dat still carries our .radicade.internal image-key tail, so a Radicade patch did run on it - over a client that already had api.recflare.net baked in by something else), and/or the per Windows user folder replaying a config an earlier RecFlare session cached there. That folder is shared by every install on the PC: %LOCALAPPDATA%Low\Against Gravity\Rec Room holds one Library/HTTPCache/Player.log for all of them. This machine's own Library still lists https://rr2019-images.viverec.com/... and https://<dev-host>:3030/... from servers it has talked to before - the same mechanism, milder.

Why a foreign host is fatal rather than cosmetic

NameserverRedirect rewrites only the exact hosts it knows (ns.rec.net, api.rec.net, auth.rec.net, img.rec.net, ...) plus the configured server host, and the embedded TLS patch accepts a certificate only for hosts on TLS:AllowedHosts (localhost, ns.radicade.app). A host the client resolves itself that is in neither list is neither redirected nor trusted, so LegacyTlsAuthentication.NotifyServerCertificate throws user_canceled(90), no gameconfigs ever arrive, and the anti-cheat path takes the session down: CheatManager wants to quit, QuitVeto refuses, LogGate collapses the Exception: Aborted repeats. The QuitVeto/[EacStub]/[AddrMap] noise at the end is a symptom of a boot that never got its configs.

Everything else in that log is expected: the HarmonyX TypeLoadException walls on Il2Cppmscorlib and Assembly-CSharp (IStream, TabsModel/ObscuredEnum constraint errors are assembly-shape facts of the decompiled types), Il2CppInterop's "field accessor, it can't be patched", the GetTypesFromAssembly warnings, [ModuleList] winhttp.dll, and the www.google.com/generate_204 probe.

What the triage script grew, and the bug it uncovered

A byte search cannot answer "which server does this client use": the patch keeps the original literal blob verbatim and appends the rewritten strings, so the dead copy of every host is still in the file - the reference metadata still contains ns.rec.net as dead bytes, which is why the old Test-Literal 'localhost:3030' check could never be conclusive. The script now reads the literal table itself (0x74 table offset, 0x24 table size, 0xA0 data offset, 0x3C data size - the same four fields tools/patch-0831-client-metadata.py rewrites) and prints the hosts the runtime actually resolves:

== IL2CPP metadata ==
global-metadata.dat: 33774004 b  md5 ce5097af...  magic e0 eb 02 00
  live service hosts this client resolves (from the literal table, dead copies ignored):
      40x  www.root-motion.com
      22x  www.w3.org
       9x  www.youtube.com
       7x  ns.radicade.app
       5x  recroom.com
       4x  recroom-dataplane.rudderstack.com
      ...
radicade-patch.json: serverUrl https://ns.radicade.app

New verdicts: a live host belonging to another project (recflare|viverec|trollroom|meownet) is MISSING - no plugin setting can redirect a host the client resolves itself; a ported loopback host means the install was baked for a local server (the bare localhost literal is this build's own, so it must not raise anything); and the marker RecRoom_Data/radicade-patch.json - the URL the patch tool wrote, the same file the plugin compares against Connection:ServerUrl at boot - is printed. A third section lists the per-user folder: sizes, cache entry counts, every host it has seen, and a note when it still names another private server. Log markers were added for TlsFatalAlert, user_canceled, wantsToQuit answered false, Exception: Aborted, metadata was patched for, plus a hard MISSING when the last run dialled another private server.

Adding that parser uncovered a latent bug in the script's own Get-Bytes helper: return [IO.File]::ReadAllBytes($path) unrolls the array into the pipeline, so the caller received one boxed object per byte - on the 33 MB metadata that is 33 million objects. A full run now takes 1.0 s end to end; before the fix the same run took minutes, and the new metadata read hung outright (no output after 300 s).

Verified both ways: this install -> missing: 0 warnings: 0 plus the two new informational sections, and a fixture made from this install's own metadata with every ns.radicade.app literal rebound to api.recflare.zzzzzzzzz (same length, so the table's length fields stay valid) and a radicade-patch.json naming https://localhost:3030 -> missing: 10 warnings: 4, including this client's own literals resolve api.recflare.zzzzzzzzz - that is another private server's patch and the last run dialled another private server (recflare.net). Recipe for the fixture (delete it afterwards):

py -3 -c "import pathlib; s=pathlib.Path(r'<working install>\RecRoom_Data\il2cpp_data\Metadata\global-metadata.dat').read_bytes(); pathlib.Path(r'<fixture>\RecRoom_Data\il2cpp_data\Metadata\global-metadata.dat').write_bytes(s.replace(b'ns.radicade.app', b'api.recflare.zzzzzzzzz'))"

What the friend has to do

He is launching a copy nested inside a copy (...\2023-09-06T06-53-47Z (2)\2023-09-06T06-53-47Z), which is how two patches end up in one tree - the bundle was unpacked next to the game instead of into it. One clean install, then:

  1. move the existing BepInEx aside and copy this bundle's BepInEx in (never merge), plus winhttp.dll, doorstop_config.ini, .doorstop_version;
  2. copy this bundle's RecRoom_Data\ contents over his data folder - boot.config, both .dat files and now also radicade-patch.json (il2cpp_data\Metadata\global-metadata.dat is the piece his log proves he does not have: it is the file whose literals decide the bootstrap host);
  3. rename %LOCALAPPDATA%Low\Against Gravity\Rec Room so no cached config from another server can be replayed;
  4. launch and look for [Meta] metadata patched for https://ns.radicade.app; matches Connection:ServerUrl. - then run the triage script and send `september-client-report.txt``

2026-09-30 17:50 - chat and account: the members the client reads POSITIONALLY

Two client logs came back, and both are about the same failure class rather than about missing features: an obfuscated DTO whose members the client binds by POSITION, so a response is rejected whole (EFMPNEEHJFJ.CMKDNAPFCOI[T] -> Exception: Malformed Response: '<body>') whenever the member order or an alias differs from the reference. Unknown members are ignored; wrong ones are not. docs/2023-AUGUST-BUILD.md documents the reader; this is what it cost on the two screens the player was actually looking at.

Chat: the thread list, and the shotgun that broke it

[Warning] [LogGate] repeats of this exception are now suppressed for 30s at a time:
  Exception: Malformed Response: '[    {      "id": 5,      "Id": 5,      "chatThreadId": 5,
  "ChatThreadId": 5, "chatThreadName": "", "Ch...
  @ at EFMPNEEHJFJ.CMKDNAPFCOI[T] (System.String IFIGOIHCFPF)

That body is this server's GET /chat/thread?maxCount=50&mode=0 - the sidebar list - and the id/Id/chatThreadId/ChatThreadId/name prefixes are the alias shotgun each response used to carry ahead of the real members. Every member after the first was bound one position off, so the list threw and the conversation pane never rendered. Server/Chat/Thread.cs now serves the reference names in the reference order, and nothing else:

  • list item: latestMessage, chatThreadId, playerIds, lastReadMessageId, chatThreadName, chatThreadType, snoozedUntil, isFavorited;
  • opened thread: chatThreadId, playerIds, lastReadMessageId, chatThreadName, chatThreadType, snoozedUntil, isFavorited, messages;
  • message: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents, moderationState. The ~40-key alias shotgun is gone, so a 4-message thread no longer answers POST /chat/thread/1 with 80 KB of aliases.

(Those two were still wrong on the first pass - chatThreadName as null, chatThreadType dropped as a shotgun alias, latestMessage repeated on the opened thread. See the 18:14 retest below, which is the shape that stands.)

A new case pins the ORDER, the presence and the nullability of those members rather than their values (ChatThreadTests.WireShapes_BindPositionally_InTheReferenceOrder): the next person to "clean up" a key order there breaks the client, and that is now a failing test rather than a log line.

Account: what two profiles were rejected for

Same log, same reader, different screens. Both live answers were replayed against https://ns.radicade.app before the fix, and both are exactly what the client threw on:

$ curl .../econ/api/influencerpartnerprogram/influencer?accountId=2
{"AccountId": 2, "Code": "COACH"}         -> Malformed Response: '{ "AccountId": 2, "Code": "COACH" }'
$ curl .../econ/api/influencerpartnerprogram/myinfluencer
{}                                          -> Malformed Response: '{}'

The first is a shape we invented: the captured real answer to that route is a BARE NUMBER (28848275, "Influencer Support" in research/RecNet API.postman_collection.json), it is served as one now, and 404 still means "not an influencer". The second is the recurring lesson of this file in its purest form - {} is not a valid "nothing here": the client's DTO for it has one int member (influencerAccountId), so an empty object has nothing to bind and the route is refetched in a loop (the log fetches myinfluencer and customAvatarItems/v2/fromCreator/2 a dozen times over, each round ending in another Malformed). It now answers {"influencerAccountId": 0} (or the caller's own id when they are one of the configured test influencers), which is the same value with the member present.

Account, the other half: a refused sign-up must not look like a login

The second log is a machine with no account on this server at all (GET /auth/cachedlogin/forplatformid/0/76561198695373725 -> [], verified live), and its boot loops every ~20 s with NO request after the login:

[Onboarding] exit get_IsInAccountCreationState => false
[Net] Get /auth/cachedlogin/forplatformid/0/76561198695373725
[Net] Get /auth/eac/challenge
[Net] Post /auth/connect/token
[Realtime] Login token captured (value omitted)
... nothing. +12.7s / +35.8s / +53.4s: CheatManager.OnDestroy -> Awake -> Start, and again

Login token captured is printed by HubSocketAuth.CaptureFromLoginResponse when the response is HTTP 200 and HAS an access_token property - and AuthResponse.access_token is a plain string, so a REFUSAL (new AuthResponse("Account creation failed.", ...), no token) serializes as "access_token": null and satisfies both checks. Every refusal this server could answer a sign-up with - the per-IP account-creation cap (15/hour), an IP or hardware ban, the five-accounts-per-device limit, a missing device id - was therefore delivered as a 200 that the client read as a finished login, leaving it holding no token and dying before its next request, while the plugin's own trace called it a successful login. That is the loop in that log.

ConnectWithToken now answers those two provisioning refusals (the cached_login grant that tried to auto-provision, and the create_account grant) as HTTP 400 carrying error and error_description - the same shape the unlinked-identity refusal already uses and the one the client acts on - and logs the reason server-side. A refusal the player can see beats a login that silently goes nowhere, and the next log from that machine will say which refusal it was: the body is no longer a 200, so it can no longer be read as a captured token.

The plugin half of that lie is fixed too: HubSocketAuth.CaptureFromLoginResponse now requires access_token to be a real non-empty STRING before it reports a captured token, and a 200 without one logs "connect/token answered HTTP 200 without an access_token - that is a login REFUSAL" together with the body's error/error_description. That single line is what this whole investigation had to reconstruct from server-side evidence; the next run on that machine will just print the reason.

Still open on that machine, and worth knowing before the next trip: the plugin's [AuthHttp] status trace is armed and logged its arming line, then never fires a single begin/status line in either log, so no onboarding HTTP status is on the record. AuthRequestStatusTrace patches HTTPManager.SendRequest(HTTPRequest) and the HTTPResponse constructor, which this client's request path evidently does not go through; patching the request's completion callback (the way HubSocketAuth.CaptureFromLoginResponse does for connect/token) is the shape that works.

Verified: dotnet test ServerTests/Server.Tests.csproj - 776 passed, 2 failed, and the two failures are the storefront/equipment capture counts (StorefrontRewardConfigTests EveryGameStorefrontHasACapture 44 vs 45, AdminGiftSkinTests 9 vs 11) that track the unrelated in-flight storefront catalog work on this branch - no chat, auth, account or influencer test touches them. New cases: the order, presence and nullability of all three chat shapes (a DM's empty name, chatThreadType as an int, an opened thread carrying messages and not latestMessage), the bare-number influencer answer with the one-member myinfluencer object, and a refused sign-up being a 400 and not a tokenless 200.

Also still open, seen in the same log and not touched here: GET /api/progressionEvents/active answers [] where the client wants an object (same class of rejection, one route over), and the /chat/thread/party stub, the per-member lastReadMessageId and the missing favorite/snooze routes from 2026-09-27 remain as they were. The reference also has two routes this server does not: GET/PUT /chat/thread/chatPrivacySetting (enum by number on read, by NAME on write) and GET /chat/thread/checkCanSendDirectMessageWithPrivacySetting (a bare ChatResult int, whose real refusals are 15/16).

Retest 18:14 - the shapes above broke GetChatBetweenPlayers, and the reference says why

Shipping them made the conversation itself fail, which the list fix had hidden:

[Error] ArgumentException: JSON parse error: Invalid value.
[Error] [18:14:34] Failed to GetChatBetweenPlayers: An error occurred

GetChatBetweenPlayers is POST /chat/thread/withmembers - the OPENED-thread object - and two of the three members the 17:50 rewrite changed are load-bearing in ways the 2022 capture cannot show:

  • chatThreadName is DEREFERENCED without a null check. Serving an unnamed thread as null (what the capture shows, and what 17:50 switched to) takes the whole call down; the client wants "" and falls back to naming the members itself. Unnamed is an empty STRING, never null.
  • chatThreadType cannot be dropped. It deserializes into a NON-NULLABLE int, and the client discards the entire response when the member is absent - so removing it as one more shotgun alias was a regression, not a cleanup. Every thread carries chatThreadType: 0, the only type the reference ever serves.
  • latestMessage is NOT repeated on the opened thread: the client is sent messages or latestMessage, never both. latestMessage is also the one NULLABLE member of the list item ("null only for a thread with no messages yet") - lastReadMessageId is a non-nullable int too.

All of that is recorded in the working reference, with the client's own throw text, in .external/recflare-server/apps/chat/src/thread-db.ts - "The client deserializes this into a non-nullable int and fails the whole response on a null ("expected 'Number Token', actual 'null'")" and "The client dereferences this name without a null check (a null NullReferenceExceptions its way out of GetChatBetweenPlayers)". The final shapes, in the reference's own serializer order:

list item     latestMessage, chatThreadId, playerIds, lastReadMessageId, chatThreadName,
              chatThreadType, snoozedUntil, isFavorited
opened thread chatThreadId, playerIds, lastReadMessageId, chatThreadName, chatThreadType,
              snoozedUntil, isFavorited, messages
message       chatMessageId, chatThreadId, senderPlayerId, timeSent, contents, moderationState

The lesson, for the second time in a day: a payload is not "the real server's" or "ours" - it is the one THIS client build reads. The 2022 capture (which spells a DM's name null), the September DTO and the reference implementation disagree, and where they do, the reference wins: it is the one whose comments cite each throw the September build makes, route by route.

2026-09-30 19:40 - a wiped database could not start the server at all (EF identity conflict)

Dropping RadicadeServer and starting the server is meant to be the easy way back to a clean world. On the live host it died on the way up, twelve lines into the seeding pass:

Unhandled exception. System.InvalidOperationException: The instance of entity type 'ConfigVal'
cannot be tracked because another instance with the same key value for {'Key'} is already being
tracked. When attaching existing entities, ensure that only one entity instance with a given key
value is attached.
  at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.IdentityMap`1.Add(InternalEntityEntry entry)
  at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.AddAsync[TEntity](TEntity entity, ...)
  at Program.<<Main>$>g__EnsureSplitTestHardOverrides|0_32(RecDBContext dbContext) in Program.cs:line 1162
  at Program.<<Main>$>g__EnsureRequiredGameConfigs|0_31(RecDBContext dbContext) in Program.cs:line 1137
  at Program.<Main>$(String[] args) in Program.cs:line 427

The mechanism is a single rule of EF Core being ignored in four places: a row this DbContext has added is not in the database yet, so a query for its key answers null.

  1. EnsureRequiredGameConfigs upserts splitTestHardOverrides - UpsertGameConfigValue queries, sees nothing (empty database), and adds a ConfigVal. Tracked, unsaved.
  2. Two lines later EnsureSplitTestHardOverrides queries for the same key, again sees nothing against the database, and adds a second instance of it. EF refuses the moment the duplicate is tracked - and the process dies before the SaveChangesAsync at the end of the method that would have made the first row visible.

That last detail is what made it unrecoverable rather than a one-off: nothing was persisted, so every subsequent start walked into the same conflict. Three more keys were armed the same way behind it - splitTestsAvailable and splitTestSegmentProbabilities (RepairSplitTestConfigs, line 430) and the UGC.Room2* batch (EnableBetaCreativeToolsConfigs).

The fix is Server/Utils/GameConfigSeeder.cs: one lookup that checks dbContext.GameConfig.Local (the tracked rows, including unsaved adds) before the database, plus the upsert built on it. All four find-or-add sites go through it now, with the tracked-aware trick EnsureReferenceGameConfigs already used for its own keys. ServerTests/GameConfigSeederTests.cs pins it, including the conflict itself as a case: add a row, query for it, watch the query answer null, then watch the second add throw. Nothing else was wrong - the wipe regenerated the schema, the Coach profile, the config, the default rooms and the dorm, and only this seeding step was unpassable.

Anyone stuck on a build without the fix can seed the three keys by hand and get up:

USE RadicadeServer;
INSERT INTO GameConfig ([Key],[Value]) VALUES
 ('splitTestHardOverrides',''), ('splitTestsAvailable',''), ('splitTestSegmentProbabilities','');

Both lookups then find a row in the database and neither adds anything; the blank values are filled in by the upsert and RepairSplitTestConfigs on the same start.

2026-10-01 18:30 - the game-update alert has no notification to send, so the client plays it

Sending a Game Update from the panel stored a row, pushed a frame, and made no sound. The hub log is specific about how far it got:

[Realtime] Notification #39 Id=27 IdToken=String Msg={}      <- arrived, parsed
[Realtime]   27 envelope: {"Id":"27","Msg":{}}              <- the frame, echoed
[Realtime] the client called IPCPDNFCGPA("{...}")            <- no handler registered under "27"

IdToken=String rules out the old parse failure (expected:'String Begin Token'), and the third line is the client falling past every registered handler into its channel/lookup path. The id was never going to match, for a reason worth writing down: this client has no notification for a game update at all. Its enum, read out of RecNet.Runtime.dll metadata (40 members), runs 24 ModerationRoomBan, 25 ServerMaintenance, 30 GiftPackageReceived - there is no 26, no 27, and AnnouncementGameUpdate appears nowhere in that assembly as a string, while ServerMaintenance, ModerationKick, ChatMessageReceived and PresenceHeartbeatResponse all do. So neither spelling can work: the number matches nothing, and the name has no handler to register. AnnouncementText (26) is equally phantom, which is the standing explanation for why regular announcements only ever reach a player through the REST fetch and never through a push.

That matches how Rec Room's own alert works: the sound is a client-side asset with nothing behind it on the wire, and the server can only ever request it. So the request is the frame the panel already produces, and the playing is now the client's own job - 2023Patch/RadicadeSeptemberClient/AnnouncementSoundAlert.cs:

  • it hooks WebSocketTransport.OnMessage (the same raw-frame hook RealtimeDiagnostics already uses) and reads the record properly - target == Notification, the envelope out of arguments[0], then its Id - rather than by substring, so a payload that merely mentions an id cannot set it off;
  • ids "27", "AnnounceNewUpdate" and "RadicadeAnnounceNewUpdate" all mean "play it", so moving the server to an explicit marker later needs no new client build;
  • the sound is embedded in the DLL (Assets/AnnounceNewUpdate.wav, 16-bit stereo 44.1kHz, ~7.6s) and played through a 2D AudioSource on a DontDestroyOnLoad object - 2D because the alert is not a position in the world and must not be attenuated by wherever the player is standing;
  • Unity audio is main-thread only and a notify frame can arrive on BestHTTP's receive thread, so the play is handed to the captured main-thread synchronisation context and refuses to run (loudly) anywhere else rather than asserting inside the game;
  • Alert.TestOnStartup is off by default and exists only to prove the DLL can play a sound with no server involved: turned on, it plays the alert once, by itself, a few seconds after the game's audio is up. A client must never make a noise on its own when somebody just launches the game - which is exactly what the first build did, three times over. The loop only checked after posting, so ticks that queued while the game was still booting each played (log: play #1, #2, #3 on one launch). The play is now claimed by the first probe that finds an audio listener, so the extra ticks do nothing. Alert.SoundFile swaps in another wav; Alert.Volume and Alert.Enabled do what they say.
  • the clip is held on the source (AudioSource.clip) as well as played with PlayOneShot, because an AudioClip nothing references natively is unloaded with the next scene's assets - observed as alert clip ready logging a second time (the clip being rebuilt mid-session) after the boot plays.

What to look for on a launch: [Alert] watching the notify hub for the game-update notification, then [Alert] alert clip ready from ... the first time the alert is about to play, then either [Alert] playing the new-update alert (the startup test (Alert.TestOnStartup)) if that switch is on, or - in a normal install - [Alert] the server pushed the game-update notification (Id "27") followed by a play line naming that id. A launch that plays nothing at all is the expected case.

No server change was needed for any of this: the deployed server already sends Id "27" with an empty payload, so the alert works as soon as the new client DLL is installed - no rebuild, no redeploy. The comment at that branch in AdminPanelAPI now records that the frame is a cue rather than an announcement, and that it has to stay id-only.

2026-10-01 22:00 - the last three Malformed Response lines, and the envelope a message rides in

The 21:37 BepInEx/LogOutput.log is the first run against a server carrying the club-route, filter and carousel work, and it is down to three parse failures from the dozen-per-boot of the earlier runs. LogGate prints each distinct one once, so three lines is three routes:

Body the client printed Route Why
'[ { "latestMessage": … } ]' GET /chat/thread?maxCount=50&mode=0 the message's contents
'{}' GET /econ/api/subscriptionseasons/v1/seasons/current an empty object is not a season
'[]' GET /api/progressionEvents/active an array is not a long?

The routes are identifiable from the printed body and the request log around it: those two {} and [] routes are the only ones in that window that answer those bodies (subscriptionseasons was the branch's one remaining return Ok(new { }), and progressionEvents/active answered Array.Empty<object>()), and the array is the sidebar list's item shape and nothing else.

A message's contents is a JSON envelope, not a sentence

This is the one that has been hiding behind the thread-shape work all along. The thread item we serve is byte-for-byte the working reference's item (latestMessage, chatThreadId, playerIds, lastReadMessageId, chatThreadName, chatThreadType, snoozedUntil, isFavorited - all eight present, in that order, chatThreadName as ""), and the reference's own integration test asserts exactly that JSON - yet the client rejects ours. The difference is not the thread at all, it is the message inside it:

[Net] Get .../chat/thread?maxCount=50&mode=0
  -> Malformed Response: '[ { "latestMessage": { "chatMessageId": 1, …
       "contents": "Player <@U2> started a chat", "moderationState": 0 } ]'

The client's message model does not read contents as display text: it parses it, and the text lives inside a small envelope. Real traffic spells it {"Type":0,"Version":1,"Data":"Player <@U29565301> started a chat"} (2022 capture, quoted in this file's 18:14 section), the working reference stores and serves precisely that - its chat test data is contents: '{"Type":0,"Version":1,"Data":"hi"}' - and this server was writing bare sentences into it for every thread notice it generates itself. That is also the other unexplained throw from the 18:14 retest:

[Error] ArgumentException: JSON parse error: Invalid value.
[Error] Failed to GetChatBetweenPlayers: An error occurred

JSON parse error: Invalid value is what a JSON reader says about a bare sentence, and the 18:14 fix (empty chatThreadName, chatThreadType present) did not address it - the run after it never opened a conversation, so nothing disproved it either. ChatStartedNotice, the leave notice and ToMessageResponse now all go through ThreadAPI.MessageEnvelope, which

  • stores this server's own notices (Player <@U4> started a chat, Player <@U4> left) as the envelope, and
  • wraps anything else that is not already a JSON object on the way out, so a message row written before this change (bare text, from an earlier build or a hand-written request) is still readable instead of failing the thread it appears in. A value that already parses as an object is passed through untouched - the client posts the envelope, and re-wrapping it would nest it.

</> are deliberately left unescaped (UnsafeRelaxedJsonEscaping): the capture spells the mention as Player <@U29565301> started a chat, the client parses mentions out of Data, and the default encoder's \u003C is not the same bytes as the traffic this shape was taken from.

If the thread list still misbehaves, the element order is the next lever

The list item's element type is readable straight out of the client's interop assembly, and it is worth writing down because the reference's member order is the 2022 capture's, not necessarily this build's: RecNet.Runtime.dll declares a static API wrapper returning MFBKDKPGMNP<List<ELKGEHBGLCO>> (NativeMethodInfoPtr_EKLFFAGHACF_Public_Static_…_List_1_ELKGEHBGLCO_0)

  • so the body is a bare array whose element is ELKGEHBGLCO, which the interop decompiles to (ilspycmd -t ELKGEHBGLCO BepInEx/interop/RecNet.Runtime.dll) 12 backing fields in this order:

    long, OBLNNOGIKLC, long, List, ChatMessage, List, bool, long, string, Nullable, bool, Nullable

OBLNNOGIKLC is enum { Player, Club, Party } - the thread type, in slot 2 - and the key literals in global-metadata.interop.dat (chatThreadId, chatThreadType, lastReadMessageId, messages, latestMessage, playerIds, isFavorited, chatThreadName, snoozedUntil, otherPlayerId) all exist as string literals (tools/sep-literals.py find …), which is where the slot names come from. otherPlayerId is a Nullable<long> and lands on slot 12 (isMuted/lastMessageId, by contrast, are not literals in this build). The reference's order does not match that list at all, so if the envelope fix above does not clear the list line, the next thing to serve is the twelve members in the DTO's own order - and the payload the client prints is then the evidence either way.

progressionEvents/active and seasons/current: null, and 404

  • GET /api/progressionEvents/active answers long? (KIJDNIPGGDC() -> MFBKDKPGMNP<long?>), and with no keepsake event configured it now answers the JSON literal null at HTTP 200 - the one body that type deserializes from as "no event". The empty array it used to answer is a parse failure ('[]', both the 17:54 and 21:37 runs), and a 404 is an empty body, which the client reports as a failed call it retries. Ok(null) would have been a 204 with no body at all, so the null is written explicitly (Content("null", "application/json")).
  • GET /econ/api/subscriptionseasons/v1/seasons/current answered {}, and the client's DTO here has six members (Guid, string, string, DateTime, DateTime?, List<…> - the 2023-09-12 reference's ALGDPMHOPGG), so an empty object has nothing to bind. It answers 404 now: writing six invented member names would put a season in front of the player that does not exist.

Verified: dotnet build Server/Server.csproj 0 errors, dotnet test ServerTests 798 passed / 9 failed, the same nine catalog/storefront/equipment capture-count failures this branch has carried all week. The chat and startup-compatibility cases were updated for the envelope and the null body respectively.

2026-10-02 17:00 - chat photos: the <Image:N> reference, and the bulk lookup that 404'd

A photo message is not a text message with a special payload - it is the same envelope with Type 2 and a Data that is one reference:

{"Type":2,"Version":1,"Data":"<Image:0>"}

Both literals are in the client's own metadata, verbatim and adjacent (tools/sep-literals.py find "Image:"): "<Image:(.+)>" (the parse) and "<Image:{0}>" (the compose). The capture is (.+), not (\d+), so the value is whatever string.Format was handed.

Two facts pin down what N is meant to be:

  • The client's image vocabulary is built on SavedImageId - the picker helper's own promise type is Task<long> (PKMFHFKFFAE.PGJDJGBGNFP(List<HNGNAJKMCGJ>, …) -> MFBKDKPGMNP<long>), and the metadata DTO's first member is the id.
  • The client's only by-id metadata route is api/images/v5/bulk, in its own route table: the contiguous literals at sep-literals.py around 39485 are api/images/, …/v1/cheer, …/v1/deletesaved, …/v1/listsaved, …/v1/modifyaccessibility, …/v1/sendlink, …/v1/slideshow, …/v2/named, …/v4/uploadsaved, …/v5/bulk, …/v5/cheered/bulk, …/v6. Every other photo route the client uses is path-built off api/images/.

What the 2026-10-02 16:16 session did, in order (client-side [Net] lines, which are a prefix on HTTPManager.SendRequest and therefore log EVERY request, in send order):

Get /chat/thread/1/message?messageCount=8&mode=2&referenceMessageId=1
Get /api/images/v5/player/2?sort=0                -> the sender's own photo roll
Get /api/images/v5/bulk?ids=-1                    -> 404: the route does not exist here
[ShareCamera](Clone)                              -> photo taken
Post /api/images/v4/uploadsaved                   -> {ImageName:f91f6f3b-….png}
Get /api/images/v6?name=f91f6f3b-….png            -> the metadata, SavedImageId 17
Get /chat/thread/1?messageCount=50 (x2)
Post /chat/thread/1                               -> {"Type":2,"Version":1,"Data":"<Image:0>"}

So the client uploads the photo, asks for its metadata, and then posts a message whose reference is 0 anyway - every photo message this server has ever received says <Image:0> (96 occurrences in Persistent/Logs/player-data-debug.log are 8 stored messages read back through the log's field aliases). 0 is the default of a long, not an id: it resolves to nothing, both clients paint the missing-texture placeholder, and the v6 response that carries the real id is only used for the photo roll's own UI.

The two repairs

  • GET /api/images/v5/bulk?ids=… now exists (ImagesAPI.GetBulkImageMetadata). ids is repeatable and each value may be a comma-separated list, matching the reference server's spelling. An id with no record, or one that is not public, is absent from the answer rather than an error, so the list can be shorter than the request (the reference documents exactly this). The member order is the reference's live capture (Id, Type, then the rest raw) with the alias spellings (SavedImageId, SavedImageType, ClubId) APPENDED rather than interleaved: a positional reader stops before them, a name-looking reader finds every name it asks for. With the route missing, every such call was a 404 and a correct id could not be turned into the ImageName the photo is fetched by.
  • ThreadAPI.AddMessageToThread now reseats an unresolvable reference to the image the sender was attaching (ChatPhotoMessages + ResolvePhotoPlaceholder). The rewrite happens only when the envelope's type marker is 2 and the reference is 0/negative/empty. A live id is copied through byte for byte, a text message is never touched, and a placeholder with nothing attributed behind it is stored as sent - a wrong photo would be worse than none.

17:41 - a photo PICKED out of the roll was still grey, and why

The first retest picked an existing photo instead of taking one. The stored contents were still <Image:0>, and the running local server already had everything above (v5/bulk?ids=7 answered 200): the rewrite only looked at the sender's newest upload inside 15 minutes, and a picked photo was uploaded on 2026-09-08. The picker path never uploads anything, so it had nothing to resolve to - and it turns out the client posts <Image:0> on that path too.

What the picker path leaves behind is a different trace, one request earlier in the log:

Get /api/images/v5/player/2?sort=0                  -> the roll
… thumbnails …
Get /api/images/v6?name=08f5465c-….png              -> the PICKED image's metadata (SavedImageId 7)
Post /chat/thread/1                                 -> {"Type":2,…,"Data":"<Image:0>"}

That v6-by-name lookup is the last images request before the message on BOTH paths (a fresh capture asks for the new name right after uploadsaved), and it names exactly the image being attached. So the server now records it: RecentImageReferences keeps one (account, image id, timestamp) per account, written by v6 and by uploadsaved, read by the chat post, with a 10-minute window. The chat rewrite prefers that attribution and keeps the 15-minute upload window only as a fallback for a session where the metadata call did not arrive.

Both sides of the conversation read the same stored row, so the repair reaches the sender (whose client re-reads the thread and the ChatMessageReceived push straight after posting) and the receiver (who resolves the stored id through v5/bulk -> ImageName -> /images/{name}). Note what the 17:41 log also shows: for <Image:0> the client asks for NOTHING - the placeholder is painted without a request - so a stored 0 can never resolve, and the id has to be right in the row.

Retest

  1. STOP the running server first - it holds Server/bin/Debug/net9.0 open, so a rebuild cannot land under it. Then dotnet run --no-launch-profile -c Debug from Server/ (or build, then start the new Server.exe).
  2. Send a photo, either picked from the roll or freshly captured - both paths are covered now.
  3. The console prints [ChatPhoto] sender=2 posted a placeholder photo reference; resolved it to saved image N from …; the stored contents are <Image:N>.
  4. The message should render the photo in the sender's client on its next thread refresh, and in the other account's client when it opens the thread.
  5. In the client log, a Get /api/images/v5/bulk?ids=N (or a by-id fetch) should answer 200 - it 404'd before. If the photo still shows a placeholder, what the client requests for the stored id is the next thing to read: [Net] lines carry the request, and a non-public image is absent from the answer by design.

Confirmed 2026-10-02 17:54, local server, photo picked out of the roll: the client asked v6?name=08f5465c-….png, posted {"Type":2,"Version":1,"Data":"<Image:0>"}, and the ChatMessageReceived push it got back carried {"Type":2,"Version":1,"Data":"<Image:7>"} - 08f5465c-….png is image 7. The photo rendered in the sender's client. What has NOT been exercised yet is the receiving side: the sender's client can paint from the thumbnail it already fetched, so only the other account proves the stored id resolves end to end.

Verified: dotnet test ServerTests/Server.Tests.csproj -o artifacts/chat-image-run 816 passed / 4 failed, 820 total - the four are the in-flight catalog/storefront/equipment capture-count failures (StorefrontRewardConfigTests, DorkNetCatalogTests x2, AdminGiftSkinTests), none of them chat. The photo repair is verified by hand rather than pinned in the suite: the 17:54 client run above is the evidence, and no photo test class ships (there was never one that earned its place). The routes were also curled against a live local server: v5/bulk?ids=14 -> the metadata, v5/bulk?ids=-1 -> [] at 200, v5/bulk?ids=14,13&ids=999 -> request order with the unknown omitted.

Note the server has to be STOPPED before a rebuild: a running Server.exe holds Server/bin/Debug/net9.0 open and every rebuild after that silently keeps the old bytes there (the 2026-10-02 17:41 retest ran against a binary built at 17:35, which had v5/bulk but not the attribution).

2026-10-03 - invites: the spelling the client actually posts, and accepting them

The game invite - pressing Invite on a friend, "come to where I am" - is in the client's own route table, in the contiguous matchmake/* block (literals #46558-#46568):

matchmake/chatinvite/{0}/{1}      matchmake/club/{0}       matchmake/code/{0}/{1}
matchmake/dorm                    matchmake/event/{0}      matchmake/instance/{0}
matchmake/invite/{0}              matchmake/none           matchmake/player/{0}
matchmake/room/{0}                matchmake/room/{0}/{1}

Those are paths on the matchmaking host, which this server fakes as a /matchmaking prefix (/matchmake/dorm is served at /matchmaking/matchmake/dorm - visible in every client log).

The spelling the client actually posts is the bare form one. The 2026-10-02 client log (BepInEx/LogOutput.log, lines 1356 and 1463 - one per Invite press) carries exactly:

[Net] Post https://<dev-host>:3030/matchmaking/invite

with a form body. No path-shaped send appears anywhere in the log, so the earlier guess that the client posts matchmake/invite/{playerId} was wrong.

That matters because the matchmake/invite/{0} in the table above is NOT the send spelling. It sits in the middle of the contiguous JOIN family (matchmake/instance/{0} before it, matchmake/player/{0} after it) and carries one path parameter like its neighbours, so it is the accept-by-invite-id spelling: "join the instance the invite with this id names". Serving it as a send would invite whatever account happened to hold the same number as an invite's message id - someone unrelated. The send surface is therefore exactly:

POST /matchmaking/invite                  (form; the one provably posted)
POST /matchmaking/invite/{playerId}       (path form, for a build that carries the target there)

POST /matchmaking/matchmake/invite/{playerId} was registered as a send in the previous session and has been REMOVED: that path is now the accept route below.

Two more things were fixed on the way, both from the reference's own documentation of the same route: roomInstanceId is OPTIONAL there (its schema says "the caller's room instance... resolves the invite's RoomId"), while this server's binding made a missing instance a refusal before the invite was even attempted. It now falls back to the caller's OWN presence row - the same row InvitePlayer already insists the inviter be live in, so the fallback can only name an instance the caller is really standing in - and refuses by name only when the caller has no instance at all. The fields are also read the way this codebase already reads club invites: path value, form field, JSON body or query string, under playerId or invitedPlayerId, because the client's exact body is not observable from the server.

Every invite now prints one line naming exactly what arrived, and that line is the thing to read on the next client run - it pins the spelling this build really uses:

[Invite] POST /matchmaking/invite from account 5: target=2, instance=20790
         (roomInstanceId sent as <N>); fields read: <every key the client sent>.

Two neighbouring routes from the same literal table were missing as well and are now served:

  • settings/partyinvite -> GET /api/settings/partyinvite, answered as the BARE single-key object the reference serves ({"InviteLinkLifetimeInMinutes":60}) - nothing here stores invite links, so the link lives in the client and this only reports its lifetime;
  • api/offlineinvite/v1/send -> POST /api/offlineinvite/v1/send. An offline invite is the same stored game invite: Notify.SendMessage stores the row either way and an offline target simply gets no hub frame, reading the message when it next signs in.

Accepting: both join spellings are served now

The client's accept does not reach the network yet (live finding below), but both spellings it could use are registered and behave as joins authored by the invite:

  • POST /matchmaking/matchmake/invite/{inviteId} -> AcceptInviteByMessageId. inviteId is the stored GameInvite message the hub pushed; the row must be addressed to the caller and its Data must name a live instance. The sender must still be ONLINE in that exact instance - an invite is not a blanket key - so: not addressed to the caller -> RoomInviteExpired, the caller's own row -> ChatMessageNotAnInvite, the sender signed off -> PlayerNotOnline, the sender moved on -> RoomInviteExpired, the instance is gone -> RoomIsNotActive.
  • POST /matchmaking/matchmake/chatinvite/{threadId}/{messageId} -> MatchmakeChatInvite, the chat party acceptance. A stored message in the caller's thread resolves the inviter from its sender (the caller's own message -> ChatMessageNotAnInvite; older than InviteLinkLifetimeInMinutes = 60 -> RoomInviteExpired). An id this server never stored falls back to the thread's single other member. A thread the caller is not in, or one with no unique other member -> ChatPartyInviteNotFound.

Both funnel into AcceptInviteIntoInstanceAsync: bans outrank the invite (Banned, BannedFromRoom), a pending-warning bounce is honoured, the caller's presence row is moved into the inviter's instance and a MatchResponse with errorCode: Success and the room instance is returned. Each step logs [Invite] accept: / [Invite] accepted: / [Invite] accept refused: so the next client run shows exactly which spelling arrived and what it resolved to.

Live findings - the two invite surfaces block in different places

After the invite above was pushed and rendered (Notification #111, handler failures: 0), the server saw NO accept request of any spelling - no matchmake/instance/20790, no matchmake/invite/..., nothing. The Unity log (Player.log) shows why: ArgumentOutOfRangeException: Index was out of range thrown from WatchNotificationsModel.KHNPLDJLMEA(HashSet\1, CKPNHJPJAGB+KINNNKACEEA)- the watch notification LIST build (twice) - and[Error] Failed to perform action. : Spam Alert!with the stackWatchNotificationCardModel.OnPrimaryButtonClicked()->OHIKJCLKIID:JDJCONMMAHM(String)`. So the button press is refused inside the client's notification model; the server routes above are ready for whenever a client run finally posts an accept.

The 2026-10-03 20:15 run pinned down a second, different surface: the chat party invite. The sender's client posted the chat message {"Type":1,"Version":1,"Data":"SmartCheetah985... sent a party invite!"} (Type 1 = party invite), and the server forwarded it verbatim (Notification #25 Id=ChatMessageReceived). The receiver's client then fetched GET /chat/settings/partyinvite - TWICE, at 20:15:45 and 20:15:49 - and the server answered 404, because only the /api/settings/partyinvite prefix was registered. Right after that, PartyInviteChatMessageModel.HasPartyInviteExpired() threw An error occurred in a task continuation (MMBGIGIFEMJ:DMNKANIOLMG), every time the card model recomputed - so the party invite card could not be acted on. The sender-side flow reads the same setting from the other prefix (GET /api/settings/partyinvite -> 200 at 20:10:03), which is why only the receiver broke.

That chat-host spelling is now served ([HttpGet("~/chat/settings/partyinvite")] beside the api one, same bare single-key object), so the card's own expiry model has the lifetime it asks for; the test pins both templates. In the same run the game invite itself was delivered and then deleted/dismissed by the client (POST /api/messages/v3/delete 2 s later) with no join request, so the watch path still needs a run where accept is actually pressed while the server console is watched.

The 2026-10-03 20:22-20:31 run (both accounts connected at once) added the next missing piece: the OTHER account's client posted POST /api/messages/v1/sendMultiple while an invite was being sorted out and read a 404 - the only non-200 that client hit all run. Account 2 sent both invites (game invite message #10 and the Type-1 party invite chat message #5) and the server pushed each to account 5 ([Notify] message 10 (type GameInvite) pushed to account 5 on 1 connection(s), Chat notify: thread=2, message=5, sender=2, recipients=[2,5]), but no accept request of any spelling arrived from either client. sendMultiple is the reference's bulk message send - real JSON ({"ToPlayerIds":[...],"Type":n,"Data":"..."}), one stored row and one MessageReceived frame per unique recipient - and is now served (MessagesFanoutTests pins the template, the per-recipient rows and the empty-id refusal).

Client disassembly (global-metadata.interop.dat + GameAssembly through tools/AugustInterop, which reads this build's metadata once pointed at it) shows the party-invite button is gated client side in RRUI.Data.PartyInviteChatMessageModel: CanAcceptPartyInvite is !HasPartyInviteExpired() AND a flag off the sender's BaseAccountModel presence, and get_CanJoinParty additionally requires BaseAccountModel::get_IsJoinableFromChatPartyInvite() - which compares the sender's presence entry against Matchmaking::AGDPIAALDDJ() (the local player's own matchmaking value). Those read the account/presence models, not an invite route, so a missing or mismatched presence value on the sender is enough to grey the card out before any request is made.

Verified 2026-10-03 (server restarted on the new build; all requests over https://127.0.0.1:3030):

Request Result
POST /matchmaking/matchmake/invite/8 (no token) 401 - the accept route exists
POST /matchmaking/matchmake/chatinvite/1/2 (no token) 401
POST /matchmaking/matchmake/notaroute (no token) 404 - unknown sibling, so the 401s mean registration
POST /matchmaking/matchmake/instance/20800 (no token) 401 - the instance join the accept is expected to reach
POST /matchmaking/invite / invite/43 (no token) 401
POST /api/offlineinvite/v1/send (no token) 401
GET /api/settings/partyinvite 200, {"InviteLinkLifetimeInMinutes":60}
GET /chat/settings/partyinvite 200, same object - it 404'd in the 20:15 run and the receiver's card then threw
POST /api/messages/v1/sendMultiple (no token) 401 - the route exists (a sibling spelling answers 404)

What the tests pin: InviteFlowTests (now 18 cases) covers every registered spelling by reflection (the matchmake/* path asserted as ACCEPT, not send), the path-shaped invite resolving the caller's own instance into a GameInvite message, the refusal when there is no instance anywhere, the offline variant storing the same message for a target with no presence, the party-invite settings shape, accept-by-message-id success moving the caller's presence into the inviter's instance, each invite-by-id refusal by name (foreign, self-sent, sender offline, sender moved, instance gone), the chat accept resolving the inviter from a stored message, its fallback to the thread's other member, and the expired/non-member refusals. MessagesFanoutTests (3 cases) pins the bulk sendMultiple route, its one-row-per-unique-recipient storage and its empty-id refusal. Whole suite: 830 passed / 4 failed, 834 total - the same four in-flight catalog/storefront/equipment failures, none of them invites.

The invitee's client fetches the room the invite names - and the room answered 404 (the 21:04 run)

The 21:04 run (the one with friend-side logs in Downloads/friend-logs) added the missing half of the invite. Both accounts were connected; the friend (account 5) opened account 2's profile - its client fetched accounts/account/2/bio, rooms/rooms/ownedby/2, api/images/v5/player/2, api/customAvatarItems/v2/fromCreator/2 and accounts/accountprivacysettings/2 in one second - and then posted the bare-spelling invite:

[Invite] POST /matchmaking/invite from account 5: target=2, instance=20800 (roomInstanceId sent as 20800); fields read: action=Invite; controller=MatchmakingAPI; playerId=2; roomInstanceId=20800; joinRoomInviteMode=None.
[Notify] message 12 (type GameInvite) pushed to account 2 on 1 connection(s).

The invitee's client reacted 80 ms later, and what it asked for is the room the notification names (Msg.RoomId: 101, Msg.Data: "20800"):

Request as account 2, right after the invite Answer
GET /rooms/rooms/101?include=5421&unityAssetTarget=0&unityAssetVersion=1 404
GET /api/inventions/v1/room?id=101 200 [] (correct - the dorm has no inventions)
GET /rooms/rooms/bulk?id=101 200 []
GET /images/5cea039d-...png?width=256&cropSquare=1 (the inviter's avatar) 200

Room 101 is the friend's private dorm (IsDorm, Accessibility = Private, creator 5), and the old rule showed a private room to its creator and its co-owners only - so the one player who had just been invited into it could not fetch it, and the join prompt had no room card to build from. Everything that client sent afterwards was heartbeats and carousel fetches, its only deliberate call being POST /api/messages/v3/delete at 21:05:12; no accept of any spelling was posted - the same dead end as every earlier run, now with a concrete reason for the prompt to have nothing to act on.

The rule now follows the invite: RoomUtils.CanSeeRoom also answers a private room to an account holding a live game invite into it (RoomUtils.HasLiveGameInvite - a stored MessageType.GameInvite message addressed to them whose sender is online in an instance of that room), so rooms/rooms/{id} and rooms/rooms/bulk?id= both serve it to the invitee. A stranger's answer is unchanged, and an inviter who has left stops granting it (nothing could be placed there anyway - AcceptInviteIntoInstanceAsync refuses the same way). InviteFlowTests.InviteAlsoMakesThePrivateRoomFetchableForTheInviteeOnly pins all three states through the real handlers.

The friend's side of the run carried two more server-shape failures, neither attributed to a route yet: Exception: Deserialization returned null -> Malformed Response: '{}' at 21:05:01, and GAOFLKNLIAI: expected:'{', actual:'[', at offset:0 -> Malformed Response: '[]' at 21:05:09 while that client was in its DOWNLOADING_DETAILS room-load phase (the [] bodies that run are the empty list answers - inventions, inbox, carousel lists - and the {} bodies are the datacollection acks).

And the ArgumentException: Error message cannot be null the friend's client logged at 21:05:18 is ours. It is an unobserved-task exception whose stack runs FPNLMMCEKLO+NHCIKGEPNNL<T>.CPOPMFILHCF(Task<T>) -> FIKNOBAKBJH<T>.BEJNCICKNOM(T) -> ANHPEPEMBNM+<>c__3<T>.DHFNHECJGBH(HttpResult<T>) -> LLDACAIIDPN.EGMGFIBBBOM<T>(string) -> FIKNOBAKBJH<T>.NEPFPOCMJPJ(string): the client's response continuation for a request this server answered 200, taking its error branch and building the exception from a null message. Every success envelope serialized "error": null; the reference spells "error": "". Success now defaults error to string.Empty (a refusal still carries its reason), pinned by RecNetShapeConformanceTests.Success_SpellsItsAbsentErrorAsAnEmptyString, with the RoomSaveTests envelope assertion moved to the reference shape.

What an invite looks like client-side, from this build's metadata (.external/sep-decomp, the interop stubs carry every name even where the bodies do not): the outer notification Id "2" is HCACKLIOLEM.PHKAIMJMIML.MessageReceived; the inner Msg.Type is MOHGFNBDKJI (GameInvite = 0, FriendStatusOnline = 20 - exactly the server's MessageType); and the notification's action dispatcher ILFPMMHFHOE has one payload struct per action of DBANOMFCFBF (GoToRoom, ShowRoomDetails, ShowAccountDetails, ShowMessage, AcceptGameInvite, ...) with AcceptGameInviteData { FromAccountId }. A game invite is therefore a button action whose payload names the inviter, which is the next thing to check if a run with the room now visible still posts no accept.

The invitee's client, in its own log: the invite arrives, the press posts nothing (the 22:21 run)

The 22:21 run finally came with the invitee's client logs. Player.log under AppData/LocalLow/Against Gravity/Rec Room/ (mtime 22:22, engine path Downloads/2023-09-06T06-53-47Z/RecRoom_Data, an RTX 4060 - Initial Room Load '@TestPlayer's Dorm' (99.109) [instance 20802] at 22:21:08) is account 2's install, and so is the BepInEx/LogOutput.log beside it. The two attached AMD-Radeon logs are the other install (Downloads/2023-09-06T06-53-47Z (2), the inviter): its [Net] lines carry both Post .../matchmaking/invite calls, which is what the server logged as account 5 both times.

Condensed, in order, everything the invitee's client sent around the invite:

Step Request
card renders GET /images/5cea039d-....png?width=128 (the inviter's avatar), POST /datacollection/data/heartbeat
the room it names GET /api/inventions/v1/room?id=101 -> 200 []
then GET .../images/5cea039d-....png?width=256&cropSquare=1, PUT /playersettings/playersettings x2
end of the attempt POST /api/messages/v3/delete - the notification is dropped

No rooms/rooms/101, no matchmake/*, no accept: the press produced no request at all, and the second invite (message 15, 22:22:02) repeated the same sequence. Both installs also log [Meta] this install's metadata was patched for 'https://localhost:3030' but Connection:ServerUrl is 'https://<dev-host>:3030' ... at startup. That line is cosmetic: the plugin rewrites the address before it leaves the client ([Connection] Redirected nameserver request to the configured Radicade server.), and every [Net] URL in both logs reads <dev-host>:3030, so it is not the reason a request is missing.

The crash behind the press is ours, and a null error was only half of it

The invitee's Player.log logs ArgumentException: Error message cannot be null at 22:21:11 - after this server had been restarted with the success-envelope fix - from ANHPEPEMBNM+<>c__3<T>.DHFNHECJGBH(OAFAKLEKFGI<T>) -> LLDACAIIDPN.EGMGFIBBBOM<T>(string) -> FIKNOBAKBJH<T>.NEPFPOCMJPJ(string): its response continuation builds the exception from the body's error field. A null error on a 200 was one way to feed it a null, but not the only one - a response with no body at all hands the same path an empty string and gets the same crash, inside the caller's task continuation. That is why the caller does not fail visibly: it stops. Every 4xx this server served during that window was bodyless:

Request (from either client, 22:20-22:22) Answer that run
GET /clubs/club/home/me (no home club, a deliberate 404) 404, len=0
GET /econ/api/subscriptionseasons/v1/seasons/current (deliberate 404) 404, len=0
GET /rooms/rooms/101?include=5421... (private dorm, answered to a caller with no live invite) 404, len=0
GET /rooms/rooms/99?include=5421... (the same rule, the other dorm) 404, len=0

The reference never answers a failure without a body: its route failures are { success: false, error: 'No such room' } and its global handlers spell the status ({ success: false, error: { message: 'not found' } }). The 2023 client's error is a string - the same DTO as the success envelope - so this server now answers with the shape this build can bind.

Every failure carries the envelope now (fix)

RecNet/Responses/Failure.cs is the failure twin of Success: success: false plus a non-null, non-empty error string (even Failure("") spells request failed). Middleware/FailureEnvelopeMiddleware.cs fills it in for the whole pipeline: any 4xx/5xx that reached the client with Content-Length: 0 and no content type gets {"success":false,"error":"<why>"} under its own status, with the wording spelled per status (404 -> "not found", 401 -> "unauthorized", 500 -> "internal server error", ...). Answers that already describe themselves (a written body, a content type, a streamed response), redirects, the 101 upgrade the notify hub takes over and every non-error status are left exactly as they are.

It is registered just before UseAuthentication(), so it wraps authentication, authorization and every controller: a bare NotFound() from any handler, a [Authorize] 401 and a path no route matches are all covered. It also sits inside the request logger, so the console line that used to read len=0 now prints the envelope it counted. Live, on the restarted server (PID 35348, log artifacts/server-failure-envelope-run.log):

GET  /econ/api/subscriptionseasons/v1/seasons/current -> 404 {"success":false,"error":"not found"}
GET  /definitely/not/here                             -> 404 {"success":false,"error":"not found"}
POST /matchmaking/matchmake/instance/20800            -> 401 {"success":false,"error":"unauthorized"}
GET  /api/settings/partyinvite                        -> 200 {"InviteLinkLifetimeInMinutes":60}

FailureEnvelopeTests (13 cases) pins the envelope, the per-status wording and the pass-through rules (AnAnswerThatAlreadyHasABodyIsLeftAlone, SuccessfulAndEmptyAnswersAreLeftAlone, UpgradesAndRedirectsAreLeftAlone). Whole suite after the change: 838 passed / 9 failed, 847 total - the invite and shape suites are green, and the nine failures are the in-flight catalog/storefront/equipment counts being edited (EquipmentInventoryTests expects 87 and saw 57, and its neighbours), none of them touching this pipeline.

What this does not claim: the press may still post nothing. In this run the invitee's client never even asked for the room (rooms/rooms/101), so if [Invite] accept: is still absent after the envelope fix, the next lever is the client's own button path rather than another server shape - the plugin already watches the notification channels, and the action dispatcher noted above (AcceptGameInviteData { FromAccountId }) is where a press would have to appear.

Retest

  1. The local server was restarted on this build after the fixes above (PID 35348, log artifacts/server-failure-envelope-run.log), so the routes are live; stop and start it again after any further rebuild (a running Server.exe serves the old bytes).
  2. A 4xx in that log now prints the answer the client received (len=37 and a JSON body) where it used to print len=0. If a 4xx there still reads len=0, a response escaped the pipeline - that is the one to fix next, whatever the client then does.
  3. In the client's watch, press Invite on a friend who is in another account's client. The server console should print [Invite] POST /matchmaking/invite from account ... - the bare-spelling proof, again. The invitee's client should follow with GET /rooms/rooms/{roomId}?include=5421... answering 200 (it was 404 for a private room before), with "error": "" on its successes.
  4. The friend's client shows the join prompt (watch) or the party-invite card (chat) and presses it. Read the server console for [Invite] accept: - that line is the accept spelling this build really posts. The party-invite card needs the chat/settings/partyinvite fix above; without it its model throws on the missing lifetime and the press never becomes a request.
  5. If no [Invite] accept: line appears, the block is client-side, and the logs now say which side of it: the invitee's Player.log for a fresh ArgumentException: Error message cannot be null (a bodyless 4xx that escaped step 2) or the client's own button path - as observed 2026-10-02 (Spam Alert!, WatchNotificationsModel throw, and the 22:21 run where a press produced PUT /playersettings/playersettings twice and POST /api/messages/v3/delete, nothing else). That is the thing to chase, not a server 404.
  6. If a line does appear, [Invite] accepted: or [Invite] accept refused: ... (code) is the whole outcome: refusals come as client-readable JoinStatus codes, and a refusal naming an invite id the caller does not own means the client's id and the stored message diverged (the [Invite] send line and the notification push carry the ids to compare).

2026-10-08 19:50 - announcements ride the name the client registers, and the new-update sound hack is gone

Two threads, one root cause.

"Sending an announcement does nothing." The panel's announcement branch pushed the numeric enum AnnouncementText (26). The client's dispatcher delivers a frame only when its Id EQUALS a registered key, and the registration probe (this file's own dump, taken at login) shows the announcement handlers registered under the literal names - "AnnouncementUpdate" and "AnnouncementDelete" - with no numeric id for either. AnnouncementText (26) is the phantom id this doc already named for the game update: the frame parses, matches nothing, and is dropped, so a saved announcement only ever appeared on the next login through the api/announcement/v1/get fetch. The push now goes out under "AnnouncementUpdate", the way the community board ("CommunityBoardUpdate") and the progression frame already do. Server-side, AnnouncementBroadcast.Update(...) holds the literal and AdminPanelAPI.UpdateAnnouncements calls it; AnnouncementWireTests pins both the id and the payload.

The new-update alert is removed from the client. AnnouncementSoundAlert.cs, its Assets/AnnounceNewUpdate.wav (1.3 MB, embedded) and the <EmbeddedResource> that shipped it are gone, and the call in Plugin.cs with them - so the plugin no longer watches the hub for the game-update frame or carries a sound at all. The DLL drops from ~1.7 MB to 140 KB. The panel still sends the game-update frame id-only; nothing on the client answers it yet. That is the next piece of work, and the removed hack was in the way of doing it properly - it played a stand-in sound off the same frame rather than letting the game show its own update prompt.

Retest: send a regular announcement and look for [Realtime] Notification #N Id="AnnouncementUpdate" in the client log, followed by the announcement appearing without a relog. The client DLL in BepInEx/plugins is rebuilt and deployed; the previous one is kept beside it as RadicadeSeptemberClient.dll.bak-announce-wav.

2026-10-08 20:15 - the game-update prompt is the version check, not a notification (21 is not it)

The 2026-10-01 entry guessed the alert was a client-side sound the server could only ever request. That was half right and the wrong half: the client does have a game-update path, it just is not a notification.

ModerationUpdateRequired (21) is not the id. The dispatcher probe's login dump lists every key the client registers a handler under, and the numeric set is exactly

1 2 3 5 6 20 22 23 25 30 31 32 50 60 61 62 70 71 80 81 82 83 84 96 100 110 120 121
  • no 21 (and no 24, 26, 27, 40, 85, 90, 95). A frame under 21 parses, matches nothing and is dropped, exactly like 26 and 27. The only game-update-relevant key in that table is the string name "AppVersionUpdate", and a string key is only ever matched by an identical string.

What actually drives the prompt is REST. Every boot the client asks

[Net] Get https://ns.radicade.app/api/versioncheck/v4?v=20230831&p=0&pid=4

and the answer carries VersionStatus plus UpdateNotificationStage. The server was answering ValidForPlay / stage 0 unconditionally, which is why no build ever showed an update prompt - and why a client-side sound was the only thing that could ever be made to happen. The client's own string table has exactly the status and stage members this implies:

GFBJICOODHE.ValidForPlay.UpdateRequired.CHPBKJBGLIB.Warn.Prompt.Require.FHCAEIBGBHF
  • ValidForPlay / UpdateRequired (and a third member, matching the three-value VersionCheckStatus the server already models as ValidForPlay = 0, UpdateRequired = 1, ValidForMenu = 2), then the escalation stage Warn / Prompt / Require and one more member beyond it. The client also carries the string Update Required in its UI string table.

So a Game Update is now the announcement row plus the answer it produces:

  • GameUpdateVersionCheck (Server/RecNet/VersionCheck/) reads the panel's Announcement config for a live, unexpired "Game Update" row. Same expiry rule as the announcement list - a row with no parsable expires_at never expires, so the prompt is taken down by deleting the announcement.
  • api/versioncheck/v4 answers UpdateRequired (+ stage 1) while one is live, ValidForPlay otherwise. The ValidVersion/key set is untouched - it stays exactly the five keys the client's strict lookups need. Deliberately stage 1, not 0: 0 is what a healthy boot already gets, so it is the one value that may also mean "nothing to show".
  • The panel's Game Update send now pushes the id-only frame under "AppVersionUpdate" instead of AnnouncementGameUpdate (27) - the re-check request for a client that is already connected, so an announcement lands without a relog.

Why 21 was a reasonable guess worth checking: ModerationUpdateRequired sounds like "the moderation state changed, go re-read it", which is the same shape of request. It is still not registered - which also means the two places the server pushes 21 today (ReleaseModerationHoldAsync, DeliverWarningAsync) are reaching nothing. The warning prompt survives on the 22 that follows it.

Still unknown, and the next launch answers it: what payload the "AppVersionUpdate" handler binds. The probe now prints the bound type and its members for that key (it already did for progressions), so one login log settles whether the empty object is enough or the frame has to carry the version. If the prompt does not appear but the version check is answering UpdateRequired, that is the thing to read. If it appears but is too soft or too harsh, the lever is GameUpdateVersionCheck.UpdateStage (climb the stage) or VersionStatus (ValidForMenu instead of UpdateRequired).

Verified: dotnet test ServerTests/Server.Tests.csproj on the affected filter (GameUpdate, VersionCheck, Announcement, CommunityBoard, WatchWire) 54 passed / 0 failed, including the new GameUpdateVersionCheckTests and the untouched VersionCheck_AllowsPlay_WhenNoGameUpdateIsPublished shape test. Client DLL rebuilt (140 KB, still no embedded sound) and deployed.

2026-10-16 - the named services: one host each, and the rewrite that keeps their routes

The nameserver no longer hands the client one host with a path per service (https://ns.radicade.app/auth). Every service is now advertised as a host of its own - https://auth.radicade.app, https://images.radicade.app, https://thorn.radicade.app, 38 in all - and the host label is the route the server has always served that service under, so auth.radicade.app/<rest> is exactly what ns.radicade.app/auth/<rest> was. DBConfig.ServiceDomain + DBConfig.ServiceUrl(name) build the names; 2023Patch/RadicadeEacBypass/NameserverRedirect.cs (ServicePrefixes) turns each host back into that prefix, in the funnel, before any socket is opened.

Two names are deliberately not their own prefix. api stays the bare base, because the client sends unprefixed calls to it (api/versioncheck/v4) and a segment would break every one. cdn keeps /cdn, the segment its advertised base has always carried. The stock cdn.rec.net entry sitting above them stays bare, and that is not an inconsistency: the stock client asks for bare blob paths while this one appends to a base that already ended in /cdn.

Everything else is unchanged on purpose. ns.radicade.app is still the bootstrap host and still the base every rewritten request lands on - Connection:ServerUrl, the baked metadata bootstrap, TLS:AllowedHosts - so the client never resolves or trusts a *.radicade.app service host at all: the redirect happens in the funnel, before DNS. The URLs the server generates itself (DBConfig.ServerUrl in Admin*, StudioAuthAPI, CommunityBoardV2) keep working for the same reason. Of the three payload fields that are built from DBConfig.NameServer, CDN and Images still land on the routes they named before - with one wrinkle worth knowing: the {Images}/img/{name} URLs the server puts in photo and webhook payloads collapse to the bare /images/{name} route, because the redirect's /img/ de-doubling applies to every redirected host, not just img.rec.net. That is the same picture either way: both routes serve the file, and both take the key id from the rcsig the client states, so the signature the anti-cheat reads is unchanged.

The third field, WWW, is the one endpoint here that a browser consumes rather than the client (ConfigAPI/FirstTimeUtils hand it out as ShareBaseUrl, and the server serves those share pages at /www/{0}). The funnel cannot rewrite a browser, so a share link built as https://www.radicade.app/{0} opens the share page only if that host resolves to this server and the server answers it at the root - www.radicade.app has to be routed by Host header, or that one base has to keep the /www path it used to carry. Everything else in the list is reached only through the rewritten client.

What this costs is any client with no plugin to do the rewrite. The Quest build is metadata-patched only (tools/patch-quest-client-metadata.py, no BepInEx/Harmony port), so it will now resolve auth.radicade.app and the rest itself: it needs DNS for those names, a certificate they are served under, and a server that routes by Host header before it works again. Windows is unaffected, and this is the one thing to weigh before deploying.

The names are one mapping in two places, so the test that guards it reads both. dotnet test ServerTests/Server.Tests.csproj: 1597 tests, 8 failed - and all 8 fail on pristine 10c9035 as well (catalog/storefront/challenge data shapes, none of them reaching the nameserver), while the suite went 1577 -> 1597 with the 20 new cases green. The new EveryAdvertisedServiceHostLandsOnAServiceRootTheServerServes takes the advertised names, runs them through NameserverRedirect and checks the segment that comes out against the server's own route templates, so a name misspelled on either side - roomieintergrations, say - fails here instead of in a client that half-works. HubRealtimeTests now derives each advertised service from its host label and asserts there is no path; RecNetShapeConformanceTests asserts the same four bases it did before, by name.

Not deployed: the running server keeps answering with the path form until this is pushed and restarted. Ship the plugin first, then the server - the order is not symmetric. A client running the new plugin against the old /ns is fine (its requests stay on ns.radicade.app, which the redirect leaves alone), but an old plugin against the new /ns resolves auth.radicade.app itself - the failure mode already written up above under "Why a foreign host is fatal": no rewrite, no certificate on TLS:AllowedHosts, and the anti-cheat path takes the session down at boot.

2026-10-16 - the notify service is advertised under its full name

The notify service is no longer named notify.radicade.app. The nameserver advertises it as notifications.radicade.app, and the route did not move: the client's redirect still lands it on /notify, so the hub socket is still wss://ns.radicade.app/notify/hub/v1?..., the health root is still /notify, and NotifyHubRoutes is unchanged. This renames the advertised name only, which is why nothing in the request path moved.

It is the first name in the set that is deliberately not its own route. Every other host label is the segment the server serves that service under, and EveryAdvertisedServiceHostLandsOnAServiceRootTheServerServes enforced that as an equality (Assert.Equal(label, segment)); that assertion now reads DBConfig.ServiceRoute(label), and DBConfig.ServiceRouteOverrides holds the one entry - notifications -> notify - so the divergence is a fact the server, the redirect and the test all read, rather than a hole in the check. It is not weaker for it: pointing the redirect entry at /notifications instead of /notify fails the test (verified by doing it), and so does advertising a label the server has no route for.

notify.radicade.app was retired, not aliased - the redirect map has no entry for it any more, so a request still naming that host is left exactly as it is, which NameserverRedirectTests asserts. The consequence is worth stating plainly: a client carrying this plugin against a server that still advertises the old name sends its hub socket to a host with no DNS record, which is the same stall this service's plugin half was written for. Ship the server and the plugin together; there is no window in which a new plugin and an old /ns both work.

Nothing infra changed, and nothing needs to. notify.radicade.app never had a DNS record either - public DNS answers only for ns.radicade.app - because the redirect happens in the funnel, before DNS: the client never resolves a service host at all. So this rename needs no Cloudflare record, no Caddy site and no certificate. www remains the one name that has to be routed by Host header, because a browser consumes it.

Server: Server/DB/DBConfig.cs (Notifications = ServiceUrl("notifications"), plus ServiceRouteOverrides/ServiceRoute). Client: 2023Patch/RadicadeEacBypass/NameserverRedirect.cs, one map entry. Tests: HubRealtimeTests (both nameserver-set tests now compare through ServiceRoute), NameserverRedirectTests (the socket case and the service-host case name notifications.radicade.app, and the retired host is asserted to be left untouched). dotnet test ServerTests/Server.Tests.csproj: 1599 tests, 8 failed - all 8 also fail on pristine 10c9035 in a clean git worktree, and that set is a strict subset of the baseline's 10, the suite being order-dependent in a couple of unrelated places. The affected classes alone are 190/190.