# 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 -- 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 `` 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 `. 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 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` -> `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` 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` - 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` / 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 --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://: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 `` 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://: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'\RecRoom_Data\il2cpp_data\Metadata\global-metadata.dat').read_bytes(); pathlib.Path(r'\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: ''`) 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.<
$>g__EnsureSplitTestHardOverrides|0_32(RecDBContext dbContext) in Program.cs:line 1162 at Program.<
$>g__EnsureRequiredGameConfigs|0_31(RecDBContext dbContext) in Program.cs:line 1137 at Program.
$(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: ```sql 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()`), 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>` (`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` 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`), 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 `` 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":""} Both literals are in the client's own metadata, verbatim and adjacent (`tools/sep-literals.py find "Image:"`): `""` (the parse) and `""` (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` (`PKMFHFKFFAE.PGJDJGBGNFP(List, …) -> MFBKDKPGMNP`), 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":""} 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 `` (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 ``, 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 `` 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":""} 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 `` 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 ``. 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":""}`, and the `ChatMessageReceived` push it got back carried `{"Type":2,"Version":1,"Data":""}` - `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://: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 ); fields read: . 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 stack `WatchNotificationCardModel.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.CPOPMFILHCF(Task)` -> `FIKNOBAKBJH.BEJNCICKNOM(T)` -> `ANHPEPEMBNM+<>c__3.DHFNHECJGBH(HttpResult)` -> `LLDACAIIDPN.EGMGFIBBBOM(string)` -> `FIKNOBAKBJH.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://: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 `: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.DHFNHECJGBH(OAFAKLEKFGI)` -> `LLDACAIIDPN.EGMGFIBBBOM(string)` -> `FIKNOBAKBJH.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":""}` 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 `` 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/` is exactly what `ns.radicade.app/auth/` 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.