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

1681 lines
111 KiB
Markdown

# Switching the game client to 20230831 (September 2023)
Target: `C:\RecRoom\2023-09-06T06-53-47Z` — a Steam depot download whose
*name* is only the download timestamp. The build's own version literal (inside
`RecRoom_Data/globalgamemanagers`) is **20230831**, Unity **2020.3.33f1**, Steam
manifest `471711_1957069142347218099`.
Why this build: the RRS packages in `Documents/EggFiles/ProjectTemplate` are the
**2023-08-31** set (`com.recroom.versioning@3ec42268d40e`, whose
`APOBPPFCFHK()` returns enum 34 -> PersistenceVersion 64). The August client
(20230825) writes 61 and accepts up to 63, which is exactly why the studio's room
was refused with "You are playing on an older version of Rec Room than this room
was saved with". 20230831 is the release that owns the studio's revision, so it is
the client to run.
## The release's metadata is shuffled differently
Rec Room ships `global-metadata.dat` with a 256-byte header whose 31
(offset,size) field *positions* are permuted per release, and the section tables
are laid out in a per-release order. The August map is known from
`tools/AugustInterop/normalize.cjs`; the September one was derived structurally in
`.tmp-newbuild/`:
- `find_layout.py` — locates a string-literal table (records `{u4 length, i4
dataIndex}`, monotonic indices) plus the data blob they point into
(printable UTF-8). On the August file it reproduces the documented fields
(table 0x2C/0xBC, data 0xC8/0xC0) and for September gives **table 0x74/0x24
= 0x17BDD78, 52,309 records; data 0xA0/0x3C = 0xE11A10, 1,657,800 bytes**.
- `chain.py` — the tables are laid out back to back from 0x100 with *zero gaps*
(verified on August: every section starts where the previous one ends). Walking
that chain with the header's own numbers yields a **unique** 29-step layout that
ends exactly at EOF, with only two unused header values, which classifies every
slot as an offset, a size or unused.
- Section sizes then match August's within ~0.2-0.5%, and the resulting
standard-v27 header copy loads cleanly in LibCpp2IL:
```
dotnet run --project tools/AugustInterop -- <august BepInEx core> artifacts/20230831-normalized-metadata.dat
Reading type definitions...42636 OK
Metadata parsed successfully.
imageDefinitions: 305 typeDefs: 42636 methodDefs: 283919 fieldDefs: 171027
```
(August for comparison: 305 images, 42,537 types, 283,224 methods.)
## Installed into the new client
- `Recroom_Data/il2cpp_data/Metadata/global-metadata.dat` — host literals rewritten
to `https://localhost:3030` by `tools/patch-0831-client-metadata.py` (9 literals,
the same set the August patch produced: `ns.rec.net`, `auth.rec.net`,
`cdn.rec.net`, bare `rec.net`, the `/shop` and `/{0}` forms, the `.rec.net`
tail -> `.radicade.internal`). The rebuilt literal blob is relaid at EOF and only
the four header fields that describe it are repointed. Original kept as
`global-metadata.dat.orig`; marker in `Recroom_Data/radicade-patch.json`.
- `Recroom_Data/il2cpp_data/Metadata/global-metadata.interop.dat` — the normalized
tooling copy (`artifacts/20230831-normalized-metadata.dat`) with the 305
synthetic `<Module>` rows repaired, for Cpp2IL/Il2CppInterop. This file must never
replace the game's `global-metadata.dat`.
- Doorstop (`winhttp.dll`, `doorstop_config.ini`, `.doorstop_version`) and
`BepInEx/{core,config,patchers,unity-libs}` from the August install, including the
patched loader (`BepInEx.Unity.IL2CPP.dll` md5 `9430bb84...`, reads
`global-metadata.interop.dat`). August's `BepInEx/interop` and its build-specific
plugin were deliberately **not** copied.
- `Recroom_Data/boot.config` — the desktop variant (`vr-enabled=0`,
`player-connection-mode=Listen`); the vanilla file is kept as `boot.config.orig`.
Server: `20230831` added to `DBConfig.SupportedGameVersions`.
## Launch results (2026-09-26)
First launch crashed without any BepInEx log: Doorstop's runtime folder (`dotnet/`,
67 MB, `coreclr.dll` plus corlib; `doorstop_config.ini` points at it as
`coreclr_path = dotnet\coreclr.dll`) had not been copied from the August install.
With `dotnet/` in place BepInEx starts and writes `BepInEx/LogOutput.log`.
Second launch reached Cpp2IL and failed inside interop generation:
```
Failed to generate Il2Cpp interop assemblies: System.IndexOutOfRangeException
at LibCpp2IL.Metadata.Il2CppTypeDefinition.get_NestedTypes()
```
Cause: two sections were swapped. LibCpp2IL treats `nestedTypes` as a flat array
of *type-definition* indices (`nestedTypeIndices.Skip(NestedTypesStart).Take(NestedTypeCount)`
then `typeDefs[idx]`), while `interfaces` indexes the binary's type table and its
values legitimately exceed the type-definition count. Of the two ~70 KB blocks,
**nestedTypes** is the 71,356-byte one at 0x1826FC8 (fields 0xA8/0xE0; every value
below 42,636) and **interfaces** is the 70,556-byte one at 0x1873AB8 (fields
0x68/0x08; values up to 134,062). With that fixed the copy re-validates through
`LibCpp2IlMain.LoadFromFile` against the new `GameAssembly.dll`:
`Binary + metadata initialization: True`, 283,919 method pointers mapped.
Third launch: interop generation succeeded — 335 assemblies plus
`BepInEx/interop/assembly-hash.txt`, log ends with `Il2CppInteropGen Done!` and
`Chainloader startup complete`. The game then boots about 19 s and dies the way the
August client did before its plugin existed:
```
BestHTTP...BouncyCastle.Crypto.Tls.TlsFatalAlert: user_canceled(90)
RecNet name server query failed (error code: summer): user_canceled(90)
```
The literal patch is working — the client really is querying the local server — and
the remaining blocker is the client's embedded certificate validation, which is one
of the hooks the August plugin installed.
## Remaining work
1. ~~Launch once with no plugins to let Il2CppInterop generate this build's
interop assemblies.~~ Done: `BepInEx/interop` holds 335 assemblies.
2. ~~Port the client plugin.~~ Done - see the port section below.
3. **Verify**: sign-in plus loading the studio room, which is also the first hard
evidence of the schema ceiling this build accepts. Sign-in, session stability and
the Dorm load are now green; entering the studio-authored room itself is the part
still waiting on a click in-game.
## Plugin port (`2023Patch/RadicadeSeptemberClient`)
`RadicadeAugustClient.dll` is built against August interop and patches August's
obfuscated identifiers, so it cannot load in this client. The port is a source
rebuild (`RadicadeSeptemberClient`, plugin id `com.radicade.eacbypass`, version
3.0.0, version gate `Application.version != "20230831"`) sharing the same four
sources plus TLS/hub/websocket/address-map files from `RadicadeEacBypass`.
Il2CppInterop's **method tokens are stable between builds** (deltas 0-4; only the
obfuscated identifiers rotate) and its type spellings keep their structure, so the
rename map was derived mechanically instead of by hand:
- `tools/sept-map.py {show,find,map,match,verify,types}` indexes the decompiled
stubs in `.external/{aug,sep}-decomp/` and matches `(normalized signature, token
+/- window)`. Every ported symbol was confirmed by token **and** shape.
- Examples: launch validator `ADJOODNBCKG/NFPDGJNDFOF/GBAMNAKGEGD` ->
`IDLGLOLKLEB/MKGADIPJDLK/IDDOELEGIFB`; exit state `CCKNJCHHBLL` ->
`KJAMNFCDBIO` (setter `KALEFEDAHCC` -> `EHPDJDJLPKM`); cheat manager
`MJLAGDJLOBC`/`EFLCHFDDPJP` -> `EKGOMPDGNEK`/`FOKOIBODNAD`; promise type
`PFDOLDLEIBA` -> `NOKBHGDKGNF`. `EACManager.GenerateChallengeResponse`,
`Tool.ResetToDefaultCollisionLayer` and the `CheatManager` scan entry kept their
names.
- `.gitignore` was blocking the edit tools for the port tree (rule
`/2023Patch/*`); the new tree and its four shared sources are now un-ignored.
Build is clean (0 errors/0 warnings) and the DLL replaces the August one in
`BepInEx/plugins`. Boot markers seen live: embedded-TLS acceptance, launch-validator
bypass, `[FileCheck] entry bypassed`, `[ExitVeto]`/`[QuitVeto]`, cheat-scan
suppression, `[AddrMap] mapped 254799 methods`, `[ToolGuard]`, and
`[CheatDrop] dorm drop (FOKOIBODNAD) held back`.
## Image signature wall: the key id came from the wrong metadata file (fixed)
The wall: the client requested images with `sig=p1`, every response carried
`Content-Signature`, and the client still filed an anti-cheat report
`Type=6 Message=Signature malformed (default.png?sig=p1)` - no image ever rendered
and the featured-rooms UI looped. Zero `[RsaVerify]` calls appeared in the log, so
the failure happened **before** any RSA verify.
Which is exactly how this client reports a **key-id mismatch** - the conformance
notes in `ServerTests/RecNetShapeConformanceTests.cs` record that a wrong key id
makes the build drop the image as "malformed" before it reaches RSA - and the
mismatch was self-inflicted:
- the metadata patch rewrites the literal `.rec.net` to `.radicade.internal`, so the
running game pins `KEY:RSA:p1.radicade.internal` (verified: record #2311 of
`global-metadata.dat` reads `.radicade.internal`);
- the plugin read its key id from `global-metadata.interop.dat`, the *normalized
tooling copy* made before the literal patch, which still says `.rec.net`; it
therefore stated `KEY:RSA:p1.rec.net` in `rcsig`, the server echoed the stock id
back, and the client rejected its own answer.
Fix: `ImageSignatureRequest.DetectKeyIdFromGameMetadata` reads the metadata the
runtime actually loads. That file's 256-byte header is **shuffled per release**, so
the literal table is discovered instead of hardcoded: every 32-bit slot in the first
0x100 bytes is a candidate field value, a (table,data) layout is accepted only when
its records fit the data segment, sample as strict UTF-8, and are **packed in
order** (`index[n] == index[n-1] + length[n-1]`, the rule that makes the answer
unambiguous - a wrong table cannot pass it), and the key id itself must still be the
single tail literal the file carries. The plugin now reads the game metadata first
and keeps the interop copy as a fallback, logging
`[ImageSig] image key id: ... (game metadata: ..., interop fallback: ...)`; image
requests log `[ImageSig] stated key id on <url>`.
Live result (2026-09-26 14:11 boot, `RADICADE_HILE_GATE=drop`):
- `[ImageSig] image key id: KEY:RSA:p1.radicade.internal (game metadata: ...)`,
52 image requests, each carrying `rcsig=KEY%3ARSA%3Ap1.radicade.internal`;
- **no** `PlayerReporting/v1/hile` report and no `Signature malformed` anywhere -
the key-id wall is gone and signatures are no longer the reason images fail;
- `Initial Room Load '@TestPlayer's Dorm' (25.32) [instance 20957]` completes,
`SpawnLocalPlayer` finishes at 11.1 s, and the process is still alive at 400 s+
(the August client self-exited ~40 s in through EAC teardown).
The periodic `Could not enable featured rooms display... no images are available`
warning is the known August noise (`featuredrooms/current` is not served), not a
signature failure.
Tooling added while chasing this: `tools/sep-literals.py` (literal table inspector:
`find`, `around`, `token`) and `tools/sep-scan.py` (PE byte scan for metadata-usage
tokens/pointers, naming the enclosing method from the runtime address map). The scan
did **not** locate the verifier: at metadata v27 the code reaches a literal through
a `.data` table of 8-byte encoded usage tokens, and the bare 4-byte token pattern is
too common in 30 MB of code to be conclusive.
`.tmp-newbuild/` holds the derivation scripts (`find_layout.py`, `chain.py`,
`make_interop_metadata.py`, `solve_map.py`, `pin_map.py`) and `map_20230831.json`.
## 2026-09-26 15:05 - per-frame exception flood gated at the game's log handler
Reported symptom: after a studio test the log fills with
InvalidOperationException: You should not try to retrieve bindings from a disposed container!
NullReferenceException: Object reference not set to an instance of an object.
Trigger (both sessions): the presence fallback cancels the in-progress load
(`Room load to <id> cancelled` -> `Attempting to go to dorm`; the 15:04 boot also
logged `Local Player's presence room instance is invalid`). The room scope's UJect
container (`HOKFDAHMIPF` in `UJect.Runtime`, it has `Dispose`) is disposed while
systems built from it keep updating, so each of them resolves through the disposed
container every frame and the guard `LIFBDDODLCA.KILJNHIAFPI` throws:
- `RecRoom.Core.Creation.CircuitsV1.CircuitVisibilityBehaviour.ShouldBeVisible`
via `HHLGMMPJKPK` -> `HOKFDAHMIPF.BBDOIOJLDPI<T>` -> `LIFBDDODLCA.KILJNHIAFPI`;
- `RecRoom.CircuitsV1Manager.Update`;
- `RecRoom.Audio.AudioManager.Update` -> font-texture-rebuild callback chain;
- `RecRoomSceneManager.Update` -> `SceneMoodSettings.FPAMNPDBNJC` (the NRE the
same broken resolve produces once it fails instead of throwing).
Unity logs every throw with its full managed stack: 8016 exception entries / 12.5 MB
Player.log (14:22 session), 7151 / 6 MB (14:45). None of it is actionable - the
systems are stale, the container is gone - and it buries everything else.
Fix: `2023Patch/RadicadeSeptemberClient/LogFloodGate.cs` patches
`ODGGOALOLEG.LogException(Il2CppSystem.Exception, UnityEngine.Object)`, the game's
`ILogHandler` in RecRoom.Debugging.Runtime and therefore the single managed funnel
every `UnityEngine.Debug.LogException` goes through. The first occurrence of each
distinct exception (type + message + top stack frame) logs in full; identical
repeats collapse to one per 30 s window, with `[LogGate]` count reports. Different
exceptions are untouched, and `RADICADE_LOG_GATE=0` disables the gate entirely.
`RADICADE_LOG_GATE_SELFTEST=1` proves the gate in one boot: the first exception that
reaches the game handler makes it fire five identical ones and log PASS/FAIL. (It
cannot run at plugin load - Unity is still on its own `DebugLogHandler` then, which is
why the first attempt reported FAIL with five uncollapsed lines in Player.log.)
Verified (2026-09-26 15:04 boot, `RADICADE_HILE_GATE=drop`,
`RADICADE_LOG_GATE_SELFTEST=1`):
- `[LogGate] armed on ODGGOALOLEG.LogException ...`;
- `[LogGate] self-test: fired 5 identical exceptions through the live game handler;
4 were collapsed, 1 reached the log (PASS)` - Player.log has exactly one;
- the same boot collapsed real floods with the exact signatures the user pasted
(disposed container, `SceneMoodSettings` NRE) plus two others it exposed:
`Malformed Response: '{}'/'[]'` (`EFMPNEEHJFJ.CMKDNAPFCOI`) and
`Local Player's presence room instance is invalid` (`CPEIFKPBJND.ICPBBDFEHBO`);
- session log 2 exception lines / 764 KB, against 7151 / 6 MB the run before.
The gate only skips the log write, so it cannot cause behaviour changes; the boot that
verified it still ended ~46 s in through the client's own
`shutdown request /matchmaking/player/logout`, a boot-scene restart
(`CheatManager.OnDestroy` -> `Awake` -> `Start`) and then silence - no dump, no WER,
no `[ExitVeto]` block. That is the pre-existing silent-exit class, and its trigger is
the same presence bounce the cancelled-load flood points at.
## 2026-09-27 02:00 - chat: the conversation pane was blank because the message list was empty
Reported symptom: the Chat screen shows `Party Chat - 0 party people!` on the left and
a right-hand conversation titled `Chat` with a completely empty body, even though
`POST /chat/thread/withmembers`, `GET /chat/thread/1?messageCount=50` and
`POST /chat/thread/1` all answered 200.
Client-side truth, from Player.log plus the plugin's `[Net]` lines around 02:00:29:
[Net] Post .../chat/thread/withmembers (creates thread 1 for accounts 2 and 3)
[Net] Get .../chat/thread/1?messageCount=50
Exception: Index was out of range. Must be non-negative and less than the size of the collection.
at System.Collections.Generic.List`1[T].get_Item (System.Int32 index)
at MMBGIGIFEMJ.AJONACNDODH (ELKGEHBGLCO HEJJEIECPNK, System.Int32 FJLPCOJGEJP)
at RRUI.Data.ChatThreadModel.FetchOlderMessages (System.Int32 FJLPCOJGEJP)
at RRUI.Data.ChatThreadModelController+FetchOlderMessagesScrollRectImpl.IHJJMFLAKAE (System.Double)
at ... OSA`2.ChangeItemsCount -> OSA`2.Refresh -> RRUI.RecyclingListScrollRect.Clear
at RRUI.RecyclingListScrollRect.SpawnLoadingPlaceholders (GameObject)
[LogGate] repeats of this exception are now suppressed for 30s at a time
[Net] Post .../chat/thread/1 (the message the user then typed)
The thread the client had just created had **zero** messages, and `FetchOlderMessages`
pages history from the OLDEST message the model already holds - `MMBGIGIFEMJ` is the
chat API client (`MessageJson{Type,Version,Data}`, `DMMNEMENNED{Latest,NewerThan,
OlderThan}`), and `ELKGEHBGLCO` is the client's thread with a `List<ChatMessage>` on it.
With that list empty the index is out of range. The throw comes out of the recycling
list's own refresh, so it fires once per frame, the list never completes a build, and
the pane stays blank forever - the user could still type, which is why a message did get
posted afterwards.
Real threads never hit this because Rec Room opens every conversation with a system
notice from the pseudo-player `-5`. Captured real traffic (`chat.rec.net`, 2022, in
`.external/recflare-server/research/RecNet API.postman_collection.json`) shows it as the
oldest message of every thread that has one:
"senderPlayerId": -5,
"contents": "{\"Type\":0,\"Version\":1,\"Data\":\"Player <@U29565301> started a chat\"}"
The same capture fixes three more shapes we were guessing at, and it is what
`Server/Chat/Thread.cs` now serves:
- `GET /chat/thread?maxCount=16&mode=0` is an ARRAY of threads carrying
`latestMessage` (not `lastMessage`/`messages`), `chatThreadId`, `playerIds`,
`lastReadMessageId`, `chatThreadName`, `snoozedUntil`, `isFavorited`. DMs carry
`chatThreadName: null` (we send `""`) - the client renders the other member's display
name itself, so a `"Chat"` placeholder here was wrong too.
- `GET /chat/thread/1?messageCount=50` is an OBJECT with a `messages` page (newest
first); `GET /chat/thread/1/message?MessageCount=16&Mode=0` is a BARE ARRAY of the same
messages. The second route did not exist on this server at all - it is the spelling
`FetchOlderMessages` calls, and the client rejects a thread object there.
- `POST /chat/thread/1` answers the whole thread with its messages, and a blank
`messageContents` reports `chatResult` 1 with the thread still attached. The client
posts exactly that blank call right after `/thread/withmembers` to "open" a thread, and
we were answering 400.
Also fixed while in there: a thread with no messages was dropped from the thread list
(`if (messages.Count == 0) continue;`), which is why the sidebar showed nothing at all;
`lastMessage` was the OLDEST message of the page rather than the newest; and thread
membership was not checked on the send route.
Verified: `dotnet test ServerTests/Server.Tests.csproj` - **608 passed** (9 new cases in
`ServerTests/ChatThreadTests.cs` pin the notice, the list/open/messages shapes, backwards
paging and the blank-contents result), and all three routes were replayed live with a
real client token against the rebuilt server.
### Retest 02:16 - the routes the first pass never saw
Rerunning with the client exposed two things the 02:00 evidence could not:
[Error] PINDLLOAFFA: HTTP Error 404
[Error] Failed to ReadMessage: An error occurred
...
[Net] Get /chat/thread/1/message?messageCount=8&mode=2&referenceMessageId=1
[Net] Post /chat/thread/1/message/2/read -> 404 (server-side)
The 404 was `POST /chat/thread/{id}/message/{messageId}/read` - the client's "ReadMessage",
which it calls for the newest message once a conversation has rendered. The messages
paging call also spelled its anchor `referenceMessageId`, not `MessageId`, so the anchor was
being ignored: every "older than message 1" page was answering the NEWEST messages instead
of the ones before the anchor, and the client kept re-requesting (the query repeats in the
log even after the pane filled).
Both fixed, plus the result shape the read route has to use. `MMBGIGIFEMJ`'s method table
shows what the client awaits, and the read family returns
`MFBKDKPGMNP<BBPILCKAECN>` - a Task of the result enum, not of a thread or a wrapper:
public enum BBPILCKAECN // RecNet, the client's ChatResult
{ Success, InvalidArguments, ThreadNotFound, MembershipNotFound, PlayerAlreadyOnThread,
CannotMessagePlayer, InvalidCharacters, RecentlyLeftThread, ThreadTooLarge,
InsufficientPermission, TooManyAffiliationThreads, UnderModeration, MessageNotFound,
InvalidThreadJoinType }
The body is therefore a BARE NUMBER. `ChatResult(int)` in `Server/Chat/Thread.cs` now returns
the integer itself rather than a `{ ChatResult, chatResult }` envelope, which is what the
read, leave and rename routes answer; an envelope deserializes into nothing and surfaces as
`Failed to ReadMessage` even at HTTP 200. A non-member on the read route is a 200 with
`MembershipNotFound` (3), not a 404 - the status is what the client turns into a transport
error, the code is what it reports.
Verified: `dotnet test` - **610 passed** (2 more cases), and live: the read route answers
`200` + `0`, and `?messageCount=8&mode=2&referenceMessageId=1` now answers `[]` instead of
the whole thread.
Still not done: `GET /chat/thread/party` is the `[]` stub from the August port, so the left
column keeps reading `0 party people!`; `favorite`, `snooze` and `chatPrivacySetting` have no
route at all (so the star on the conversation header 404s); `lastReadMessageId` is one value
per thread rather than per member, so unread badges are wrong once two accounts read the same
conversation at different times; and every message is served with the full ~40-key alias
shotgun, which is why one 4-message thread answers a `POST` with 80 KB.
## 2026-09-27 02:35 - chat, the other half: the push id has to be a NAME, not the enum's number
A message now persisted and rendered from a refetch, but the conversation still only updated
for whoever asked: the live push never reached a handler. That half is realtime, and it is now
settled from the client itself rather than inferred.
`RealtimeDiagnostics` (shared source, compiled into the September plugin) grew a probe that
prints the notification ids this client actually accepts. It hooks the dispatcher's own
registration calls - every void method on it taking `(string|enum, callback)` plus the
one-argument channel calls - and logs the first argument as each registration happens. The
result, at login, is the contract in one place:
[Realtime] watching 6 dispatcher method(s) on HCACKLIOLEM (5 registration, 2 generic
overload(s) skipped but funnel through the core, rest channels): every notification id
this client accepts will be printed as it registers.
[Realtime] the client registered a notification handler under "ChatMessageReceived"
(HCACKLIOLEM.LFOCBEMNPDP) - a notification whose Id EQUALS "ChatMessageReceived" is
handled, anything else is dropped.
[Realtime] chat push key check: the server sends Id="90" and the client registered NO
handler for "90"; the chat handler it registered is under "ChatMessageReceived".
Only an exact match is delivered.
Two things had to be learned to get there, and both are in the file's comments now:
- **Reading the handler map kills the client.** The handlers live in an IL2CPP static field,
which Il2CppInterop surfaces as a property whose getter is a native
`il2cpp_field_static_get_value` call. Invoking it through reflection terminated the process
with no managed exception at all - Player.log simply stopped at 2 KB and nothing reached the
error log - while the dispatcher's own registration methods are ordinary managed methods that
patch safely. The map dump survives only as a managed-fields-only fallback.
- **`Il2CppSystem.Action` is not `System.Delegate`.** The dispatcher's overloads take
`Il2CppSystem.Action` / `Il2CppSystem.Action<T>` / its own delegate type, and those are IL2CPP
proxies: `Il2CppSystem.Object` is not `System.Object`, so `IsAssignableFrom(typeof(Delegate))`
matches none of them and the first run hooked zero methods while reporting the dispatcher
found. The check walks the base chain for a Delegate-shaped type by name instead.
The server change is one line in `Server/Chat/Thread.cs`. `Notification.Id`'s getter converts
any enum to its numeric value, and an id the client has no handler for is dropped without a
trace - which is exactly what a new message used to do. It is now spelled out as
`nameof(NotificationType.ChatMessageReceived)`, the way `PresenceUpdate`, `AccountUpdate` and
`SelfAccountUpdate` already were. The per-type rule is real and worth keeping in mind: this
client registers `ModerationKick` by its NUMBER (`"22"`, see `ConnectionRecoveryTests`) and
`ChatMessageReceived` by the NAME, so neither form is universally right.
Verified: `dotnet test` - **611 passed** (a new case drives the send route with a recording hub
client and asserts the pushed envelope carries `"Id":"ChatMessageReceived"` and not
`"Id":"90"`), and live against the running client:
[NotifyHub] -> Ye_BABskiLsHFK5zFYwTEg: Id="ChatMessageReceived"
[Realtime] Notification #16 Id=ChatMessageReceived IdToken=String Msg={..."id":14...}
[Net] Post /chat/thread/1 (the message, right after the push)
[Net] Post /chat/thread/1/message/14/read -> 200 0
[Net] Get /chat/thread/1?messageCount=50 -> 200
The client now reacts to its own and the other account's messages: it marks the newest message
read and refetches the thread, which it never did before because the frame landed on no
handler. Thread 1 answers `len=108749` for a 50-message page, all of it alias shotgun.
## 2026-09-27 03:15 - pointing the client at the public server (ns.radicade.app)
The client now talks to `https://ns.radicade.app` (a netcup VPS behind Caddy ->
Kestrel) and nothing to the dev box. Two independent things decide that address, and moving
only one of them produces a client that boots against one server and then lives on another:
1. **`Connection:ServerUrl`** in `BepInEx/config/com.radicade.eacbypass.cfg`, whose code
default is now `https://ns.radicade.app` (`RadicadeSeptemberClient/Plugin.cs`).
Everything the plugin rewrites - every `*.rec.net` service host, the hub socket, and the
`ns.radicade.app` host itself - is redirected to this base. `TLS:AllowedHosts`
follows it: `AlignAllowedHostsWithServer()` appends the host and saves the config, so a new
domain needs no second edit.
2. **The bootstrap host baked into the client metadata** by
`tools/patch-0831-client-metadata.py` (rule `https://ns.rec.net` -> the server base, plus
`api`/`auth`/`images`/`cdn` literals). The client fetches its nameserver document from that
baked host and then uses whatever absolute URLs THAT server returns, so a stale baked host
silently governs the whole session.
That second point is the trap. The tool rewrites the file as it stands and does not reseed, so
re-running it on an install already patched for another address finds nothing to rewrite and
reports success with zero changes. Restore first:
cp RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat.orig \
RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat
py -3 tools/patch-0831-client-metadata.py <game-root> --server https://ns.radicade.app --apply
The plugin warns about exactly this mismatch at boot (`[Meta] this install's metadata was
patched for 'X' but Connection:ServerUrl is 'Y'`), which is how the localhost-baked install was
caught: it had been reading `/ns` from `localhost:3030`, and that server's own
`Radicade:ServerUrl` (the dev box's `https://<dev-host>:3030`) is what the whole session then
dialled - 41 requests to the IP, none to the configured domain.
Verified on the running client: 134 requests to `https://ns.radicade.app/`, **zero** to
`<dev-host>` or `localhost`; the notify hub socket upgraded and the handshake was accepted;
`[TLS] Accepting server certificate for allowed Radicade host 'ns.radicade.app'`; the
login token was captured and 27 notification frames arrived.
Still localhost by default, and deliberately left alone for now: `RadicadeAugustClient` (the
20230803 build, not in use) and `RadicadePatch` (the older C# host patcher, whose README
documents a plain-HTTP local workflow).
One more thing learned the hard way: **stopping the server kills the running client.** The
02:36:52 run ended the moment the server was killed for a rebuild - the last lines are repeated
`/notify/hub/v1/negotiate` attempts and `Exception: Connection refused`. Every rebuild+restart
therefore costs a client relaunch; plan the retest around that instead of reading the death as
the old silent-exit bug.
## 2026-09-27 11:55 - a friend's install died in interop, and the metadata header is the whole reason
A second machine (game folder the same `2023-09-06T06-53-47Z`, but data folder renamed
`Radicade_Data`, BepInEx reported as **6.0.0-be.788**) failed before it ever reached the plugin:
[Error :InteropManager] Failed to generate Il2Cpp interop assemblies:
---> System.FormatException: Invalid or corrupt metadata (magic number check failed)
[Fatal : BepInEx] Unable to execute IL2CPP chainloader, no plugins will be loaded
[Error : BepInEx] Could not load file or assembly 'UnityEngine.CoreModule'
That is not a broken file. Rec Room's `global-metadata.dat` does not start with the standard
IL2CPP magic; it ships a per-release scrambled 256-byte header. Ours begins `e0 eb 02 00`,
the normalized copy begins `af 1b b1 fa` (0xFAB11BAF - the value `Il2CppMetadata.ReadFrom`
checks). The working install exists because of exactly this: a normalized copy
(`global-metadata.interop.dat`) plus a loader that reads the copy. The friend's install has
neither, and three details in the log say so:
- The failing line names `.../il2cpp_data/Metadata/global-metadata.dat`. Our patched loader
prints `global-metadata.interop.dat` there - `tools/AugustInterop --patch-loader` rewrites
that single `ldstr` - so theirs is a stock loader.
- `be.788` with no `GlobalMetadataPath` in its config. That setting does not exist in be.697
at all (our working `BepInEx.cfg` has no such key under `[IL2CPP]`), which is why the fix
was a binary patch here. Newer BepInEx builds expose the same redirect as configuration.
- No `Detected outdated interop assemblies, will regenerate them now` line, and no `Preloaded
N interop assemblies`. `CheckIfGenerationRequired()` returns true silently when
`BepInEx/interop` is **absent**, so the folder was never there: a plain BepInEx was installed
on top of this game. `Downloading unity base libraries` agrees - `unity-libs` was missing too.
So the rule for a summer 2023 Rec Room install is: **the BepInEx layer travels as one unit**.
The be.697 core with the patched loader, `global-metadata.interop.dat`, the generated
`BepInEx/interop` (332 assemblies here) and `assembly-hash.txt` have to arrive together; any
one of them alone is worse than nothing, and a newer core replaces all four.
Two ways back, in order of least pain:
1. Move the friend's `BepInEx` folder aside and copy the working one in, together with
`winhttp.dll`, `doorstop_config.ini`, `boot.config` and both `*.dat` files.
2. Keep the newer core and add this to `BepInEx/config/BepInEx.cfg` (then let interop
regenerate once, which takes minutes and needs `unity-libs`):
[IL2CPP]
GlobalMetadataPath = {GameDataPath}/il2cpp_data/Metadata/global-metadata.interop.dat
That only works if `global-metadata.interop.dat` is present in the metadata folder - the
normalized file is the piece a stock install never has.
Both are now scripted, because hand-assembling this a third time is how the be.788 install
happened in the first place:
- `tools/september-client-triage.ps1` - read-only, finds the data folder whatever it is called,
and prints one `OK`/`WARN`/`MISSING` line per piece: build identity, both metadata files and
their magic bytes, loader patched-vs-stock, the `GlobalMetadataPath` override, interop count
and hash, plugin/winhttp/doorstop, then the markers in the last `LogOutput.log`. It writes
`september-client-report.txt` and exits non-zero when anything is missing. Confirmed against
this install (8 OK, 0 warnings) and against a fixture that reproduces the friend's log
(5 MISSING, including `the last run died in interop generation`).
- `tools/make-september-client-pack.ps1` - builds the handover bundle: verifies the source
against the reference hashes and the two header magics, then copies the whole BepInEx tree
(minus logs, `*.before-*` backups and the 18 MB `radicade-addr-map.txt`), the data-folder
pieces, and a `README-FRIEND.txt` whose first instruction is *move your existing BepInEx
aside, do not merge*. `-VerifyOnly` checks the source and copies nothing. Run here: all ten
pieces `ok`, bundle 294.8 MB, and the bundled triage script reports the source install clean.
(It carries eleven checked pieces since 2026-09-27: the marker `radicade-patch.json` was added
to the bundle, since the plugin reads it to confirm the patched server - see the last section.)
One thing the reference hashes pin down: this is the public-server install, so the friend needs
no local server. The shipped plugin config already reads
`ServerUrl = https://ns.radicade.app`, matching the domain baked into `global-metadata.dat`.
## 2026-09-27 13:20 - the friend's second log: the stack boots, and the client asks a *different* private server for its config
The be.697 stack is fine now (`BepInEx 6.0.0-be.697`, `1 plugin to load`, `[Radicade September
Client 3.0.0]`, `Chainloader startup complete`, every notification handler registered,
`[Sept] Connection patches loaded`). What the log shows instead is that the *client data* in that
folder is not this bundle's:
[Warning:Radicade September Client] [Meta] this install's metadata was patched for
'https://localhost:3030' but Connection:ServerUrl is 'https://ns.radicade.app'
[Info :Radicade September Client] [Net] Get https://localhost:3030/
[Info :Radicade September Client] [Net] Get https://api.recflare.net/api/gameconfigs/v1/all
[Info :Radicade September Client] [Net] Post https://datacollection.recflare.net/data/event
[Info :Radicade September Client] [TLS] NotifyServerCertificate: host=api.recflare.net, ...
[Error :Il2CppInterop] ... TlsFatalAlert: user_canceled(90)
[Warning:Radicade September Client] [QuitVeto] wantsToQuit answered false (block #1)
[Warning:Radicade September Client] [LogGate] repeats of this exception are now suppressed ... Exception: Aborted
### `recflare` is another project, and this install has never contained the string
`recflare.net` is RecFlare's public server - the reference implementation we studied lives in
`.external/recflare-server`, and its services are exactly `api.recflare.net`,
`datacollection.recflare.net` and friends. On this working install the string appears **zero**
times in every piece that could carry a host:
global-metadata.dat / .orig / .interop.dat / GameAssembly.dll -> 0 x recflare
So those URLs are in *his* client data, and two things put them there: a second patch in the same
tree (his `global-metadata.dat` still carries our `.radicade.internal` image-key tail, so a
Radicade patch did run on it - over a client that already had `api.recflare.net` baked in by
something else), and/or the per Windows user folder replaying a config an earlier RecFlare
session cached there. That folder is shared by every install on the PC:
`%LOCALAPPDATA%Low\Against Gravity\Rec Room` holds one `Library`/`HTTPCache`/`Player.log` for all
of them. This machine's own `Library` still lists `https://rr2019-images.viverec.com/...` and
`https://<dev-host>:3030/...` from servers it has talked to before - the same mechanism, milder.
### Why a foreign host is fatal rather than cosmetic
`NameserverRedirect` rewrites only the exact hosts it knows (`ns.rec.net`, `api.rec.net`,
`auth.rec.net`, `img.rec.net`, ...) plus the configured server host, and the embedded TLS patch
accepts a certificate only for hosts on `TLS:AllowedHosts` (`localhost, ns.radicade.app`).
A host the client resolves itself that is in neither list is neither redirected nor trusted, so
`LegacyTlsAuthentication.NotifyServerCertificate` throws `user_canceled(90)`, no gameconfigs ever
arrive, and the anti-cheat path takes the session down: `CheatManager` wants to quit, `QuitVeto`
refuses, `LogGate` collapses the `Exception: Aborted` repeats. The `QuitVeto`/`[EacStub]`/`[AddrMap]`
noise at the end is a symptom of a boot that never got its configs.
Everything else in that log is expected: the HarmonyX `TypeLoadException` walls on `Il2Cppmscorlib`
and `Assembly-CSharp` (`IStream`, `TabsModel`/`ObscuredEnum` constraint errors are
assembly-shape facts of the decompiled types), Il2CppInterop's "field accessor, it can't be
patched", the `GetTypesFromAssembly` warnings, `[ModuleList] winhttp.dll`, and the
`www.google.com/generate_204` probe.
### What the triage script grew, and the bug it uncovered
A byte search cannot answer "which server does this client use": the patch keeps the original
literal blob verbatim and appends the rewritten strings, so the *dead* copy of every host is
still in the file - the reference metadata still contains `ns.rec.net` as dead bytes, which is
why the old `Test-Literal 'localhost:3030'` check could never be conclusive. The script now reads
the **literal table** itself (0x74 table offset, 0x24 table size, 0xA0 data offset, 0x3C data
size - the same four fields `tools/patch-0831-client-metadata.py` rewrites) and prints the hosts
the runtime actually resolves:
== IL2CPP metadata ==
global-metadata.dat: 33774004 b md5 ce5097af... magic e0 eb 02 00
live service hosts this client resolves (from the literal table, dead copies ignored):
40x www.root-motion.com
22x www.w3.org
9x www.youtube.com
7x ns.radicade.app
5x recroom.com
4x recroom-dataplane.rudderstack.com
...
radicade-patch.json: serverUrl https://ns.radicade.app
New verdicts: a live host belonging to another project (`recflare|viverec|trollroom|meownet`) is
`MISSING` - *no plugin setting can redirect a host the client resolves itself*; a **ported**
loopback host means the install was baked for a local server (the bare `localhost` literal is this
build's own, so it must not raise anything); and the marker `RecRoom_Data/radicade-patch.json` -
the URL the patch tool wrote, the same file the plugin compares against `Connection:ServerUrl` at
boot - is printed. A third section lists the per-user folder: sizes, cache entry counts, every
host it has seen, and a note when it still names another private server. Log markers were added
for `TlsFatalAlert`, `user_canceled`, `wantsToQuit answered false`, `Exception: Aborted`,
`metadata was patched for`, plus a hard `MISSING` when the last run dialled another private server.
Adding that parser uncovered a latent bug in the script's own `Get-Bytes` helper: `return
[IO.File]::ReadAllBytes($path)` unrolls the array into the pipeline, so the caller received one
boxed object per byte - on the 33 MB metadata that is 33 million objects. A full run now takes
**1.0 s** end to end; before the fix the same run took minutes, and the new metadata read hung
outright (no output after 300 s).
Verified both ways: this install -> `missing: 0 warnings: 0` plus the two new informational
sections, and a fixture made from this install's own metadata with every `ns.radicade.app`
literal rebound to `api.recflare.zzzzzzzzz` (same length, so the table's length fields stay valid)
and a `radicade-patch.json` naming `https://localhost:3030` -> `missing: 10 warnings: 4`,
including `this client's own literals resolve api.recflare.zzzzzzzzz - that is another private
server's patch` and `the last run dialled another private server (recflare.net)`. Recipe for the
fixture (delete it afterwards):
py -3 -c "import pathlib; s=pathlib.Path(r'<working install>\RecRoom_Data\il2cpp_data\Metadata\global-metadata.dat').read_bytes(); pathlib.Path(r'<fixture>\RecRoom_Data\il2cpp_data\Metadata\global-metadata.dat').write_bytes(s.replace(b'ns.radicade.app', b'api.recflare.zzzzzzzzz'))"
### What the friend has to do
He is launching a copy nested inside a copy (`...\2023-09-06T06-53-47Z (2)\2023-09-06T06-53-47Z`),
which is how two patches end up in one tree - the bundle was unpacked next to the game instead of
into it. One clean install, then:
1. move the existing `BepInEx` aside and copy this bundle's `BepInEx` in (never merge), plus
`winhttp.dll`, `doorstop_config.ini`, `.doorstop_version`;
2. copy this bundle's `RecRoom_Data\` contents over his data folder - `boot.config`, **both `.dat`
files** and now also `radicade-patch.json` (`il2cpp_data\Metadata\global-metadata.dat` is the
piece his log proves he does not have: it is the file whose literals decide the bootstrap host);
3. rename `%LOCALAPPDATA%Low\Against Gravity\Rec Room` so no cached config from another server can
be replayed;
4. launch and look for `[Meta] metadata patched for https://ns.radicade.app; matches
Connection:ServerUrl.` - then run the triage script and send `september-client-report.txt``
## 2026-09-30 17:50 - chat and account: the members the client reads POSITIONALLY
Two client logs came back, and both are about the same failure class rather than about missing
features: an obfuscated DTO whose members the client binds by POSITION, so a response is rejected
whole (`EFMPNEEHJFJ.CMKDNAPFCOI[T]` -> `Exception: Malformed Response: '<body>'`) whenever the
member order or an alias differs from the reference. Unknown members are ignored; wrong ones are
not. `docs/2023-AUGUST-BUILD.md` documents the reader; this is what it cost on the two screens the
player was actually looking at.
### Chat: the thread list, and the shotgun that broke it
[Warning] [LogGate] repeats of this exception are now suppressed for 30s at a time:
Exception: Malformed Response: '[ { "id": 5, "Id": 5, "chatThreadId": 5,
"ChatThreadId": 5, "chatThreadName": "", "Ch...
@ at EFMPNEEHJFJ.CMKDNAPFCOI[T] (System.String IFIGOIHCFPF)
That body is this server's `GET /chat/thread?maxCount=50&mode=0` - the sidebar list - and the
`id`/`Id`/`chatThreadId`/`ChatThreadId`/`name` prefixes are the alias shotgun each response used to
carry ahead of the real members. Every member after the first was bound one position off, so the
list threw and the conversation pane never rendered. `Server/Chat/Thread.cs` now serves the
reference names in the reference order, and nothing else:
- list item: `latestMessage`, `chatThreadId`, `playerIds`, `lastReadMessageId`, `chatThreadName`,
`chatThreadType`, `snoozedUntil`, `isFavorited`;
- opened thread: `chatThreadId`, `playerIds`, `lastReadMessageId`, `chatThreadName`,
`chatThreadType`, `snoozedUntil`, `isFavorited`, `messages`;
- message: `chatMessageId`, `chatThreadId`, `senderPlayerId`, `timeSent`, `contents`,
`moderationState`. The ~40-key alias shotgun is gone, so a 4-message thread no longer answers
`POST /chat/thread/1` with 80 KB of aliases.
(Those two were still wrong on the first pass - `chatThreadName` as `null`, `chatThreadType` dropped
as a shotgun alias, `latestMessage` repeated on the opened thread. See the 18:14 retest below, which
is the shape that stands.)
A new case pins the ORDER, the presence and the nullability of those members rather than their
values (`ChatThreadTests.WireShapes_BindPositionally_InTheReferenceOrder`): the next person to
"clean up" a key order there breaks the client, and that is now a failing test rather than a log
line.
### Account: what two profiles were rejected for
Same log, same reader, different screens. Both live answers were replayed against
`https://ns.radicade.app` before the fix, and both are exactly what the client threw on:
$ curl .../econ/api/influencerpartnerprogram/influencer?accountId=2
{"AccountId": 2, "Code": "COACH"} -> Malformed Response: '{ "AccountId": 2, "Code": "COACH" }'
$ curl .../econ/api/influencerpartnerprogram/myinfluencer
{} -> Malformed Response: '{}'
The first is a shape we invented: the captured real answer to that route is a BARE NUMBER
(`28848275`, "Influencer Support" in `research/RecNet API.postman_collection.json`), it is served
as one now, and `404` still means "not an influencer". The second is the recurring lesson of this
file in its purest form - `{}` is not a valid "nothing here": the client's DTO for it has one int
member (`influencerAccountId`), so an empty object has nothing to bind and the route is refetched
in a loop (the log fetches `myinfluencer` and `customAvatarItems/v2/fromCreator/2` a dozen times
over, each round ending in another Malformed). It now answers `{"influencerAccountId": 0}` (or the
caller's own id when they are one of the configured test influencers), which is the same value
with the member present.
### Account, the other half: a refused sign-up must not look like a login
The second log is a machine with no account on this server at all
(`GET /auth/cachedlogin/forplatformid/0/76561198695373725` -> `[]`, verified live), and its boot
loops every ~20 s with NO request after the login:
[Onboarding] exit get_IsInAccountCreationState => false
[Net] Get /auth/cachedlogin/forplatformid/0/76561198695373725
[Net] Get /auth/eac/challenge
[Net] Post /auth/connect/token
[Realtime] Login token captured (value omitted)
... nothing. +12.7s / +35.8s / +53.4s: CheatManager.OnDestroy -> Awake -> Start, and again
`Login token captured` is printed by `HubSocketAuth.CaptureFromLoginResponse` when the
response is HTTP 200 and HAS an `access_token` property - and `AuthResponse.access_token` is a
plain `string`, so a REFUSAL (`new AuthResponse("Account creation failed.", ...)`, no token)
serializes as `"access_token": null` and satisfies both checks. Every refusal this server could
answer a sign-up with - the per-IP account-creation cap (15/hour), an IP or hardware ban, the
five-accounts-per-device limit, a missing device id - was therefore delivered as a 200 that the
client read as a finished login, leaving it holding no token and dying before its next request,
while the plugin's own trace called it a successful login. That is the loop in that log.
`ConnectWithToken` now answers those two provisioning refusals (the `cached_login` grant that tried
to auto-provision, and the `create_account` grant) as HTTP **400** carrying `error` and
`error_description` - the same shape the unlinked-identity refusal already uses and the one the
client acts on - and logs the reason server-side. A refusal the player can see beats a login that
silently goes nowhere, and the next log from that machine will say which refusal it was: the body
is no longer a 200, so it can no longer be read as a captured token.
The plugin half of that lie is fixed too: `HubSocketAuth.CaptureFromLoginResponse` now requires
`access_token` to be a real non-empty STRING before it reports a captured token, and a 200 without
one logs `"connect/token answered HTTP 200 without an access_token - that is a login REFUSAL"`
together with the body's `error`/`error_description`. That single line is what this whole
investigation had to reconstruct from server-side evidence; the next run on that machine will just
print the reason.
Still open on that machine, and worth knowing before the next trip: the plugin's `[AuthHttp]`
status trace is armed and logged its arming line, then never fires a single `begin`/status line in
either log, so no onboarding HTTP status is on the record. `AuthRequestStatusTrace` patches
`HTTPManager.SendRequest(HTTPRequest)` and the `HTTPResponse` constructor, which this client's
request path evidently does not go through; patching the request's completion callback (the way
`HubSocketAuth.CaptureFromLoginResponse` does for `connect/token`) is the shape that works.
Verified: `dotnet test ServerTests/Server.Tests.csproj` - **776 passed, 2 failed**, and the two
failures are the storefront/equipment capture counts (`StorefrontRewardConfigTests`
`EveryGameStorefrontHasACapture` 44 vs 45, `AdminGiftSkinTests` 9 vs 11) that track the unrelated
in-flight storefront catalog work on this branch - no chat, auth, account or influencer test
touches them. New cases: the order, presence and nullability of all three chat shapes (a DM's empty
name, `chatThreadType` as an int, an opened thread carrying `messages` and not `latestMessage`), the
bare-number influencer answer with the one-member `myinfluencer` object, and a refused sign-up being
a 400 and not a tokenless 200.
Also still open, seen in the same log and not touched here: `GET /api/progressionEvents/active`
answers `[]` where the client wants an object (same class of rejection, one route over), and the
`/chat/thread/party` stub, the per-member `lastReadMessageId` and the missing favorite/snooze
routes from 2026-09-27 remain as they were. The reference also has two routes this server does not:
`GET`/`PUT /chat/thread/chatPrivacySetting` (enum by number on read, by NAME on write) and
`GET /chat/thread/checkCanSendDirectMessageWithPrivacySetting` (a bare ChatResult int, whose real
refusals are 15/16).
### Retest 18:14 - the shapes above broke `GetChatBetweenPlayers`, and the reference says why
Shipping them made the conversation itself fail, which the list fix had hidden:
[Error] ArgumentException: JSON parse error: Invalid value.
[Error] [18:14:34] Failed to GetChatBetweenPlayers: An error occurred
`GetChatBetweenPlayers` is `POST /chat/thread/withmembers` - the OPENED-thread object - and two of
the three members the 17:50 rewrite changed are load-bearing in ways the 2022 capture cannot show:
- **`chatThreadName` is DEREFERENCED without a null check.** Serving an unnamed thread as `null`
(what the capture shows, and what 17:50 switched to) takes the whole call down; the client wants
`""` and falls back to naming the members itself. Unnamed is an empty STRING, never null.
- **`chatThreadType` cannot be dropped.** It deserializes into a NON-NULLABLE int, and the client
discards the entire response when the member is absent - so removing it as one more shotgun alias
was a regression, not a cleanup. Every thread carries `chatThreadType: 0`, the only type the
reference ever serves.
- `latestMessage` is NOT repeated on the opened thread: the client is sent `messages` or
`latestMessage`, never both. `latestMessage` is also the one NULLABLE member of the list item
("null only for a thread with no messages yet") - `lastReadMessageId` is a non-nullable int too.
All of that is recorded in the working reference, with the client's own throw text, in
`.external/recflare-server/apps/chat/src/thread-db.ts` - "The client deserializes this into a
non-nullable int and fails the whole response on a null (\"expected 'Number Token', actual
'null'\")" and "The client dereferences this name without a null check (a null
NullReferenceExceptions its way out of GetChatBetweenPlayers)". The final shapes, in the
reference's own serializer order:
list item latestMessage, chatThreadId, playerIds, lastReadMessageId, chatThreadName,
chatThreadType, snoozedUntil, isFavorited
opened thread chatThreadId, playerIds, lastReadMessageId, chatThreadName, chatThreadType,
snoozedUntil, isFavorited, messages
message chatMessageId, chatThreadId, senderPlayerId, timeSent, contents, moderationState
The lesson, for the second time in a day: a payload is not "the real server's" or "ours" - it is
the one THIS client build reads. The 2022 capture (which spells a DM's name `null`), the September
DTO and the reference implementation disagree, and where they do, the reference wins: it is the one
whose comments cite each throw the September build makes, route by route.
### 2026-09-30 19:40 - a wiped database could not start the server at all (EF identity conflict)
Dropping `RadicadeServer` and starting the server is meant to be the easy way back to a clean
world. On the live host it died on the way up, twelve lines into the seeding pass:
Unhandled exception. System.InvalidOperationException: The instance of entity type 'ConfigVal'
cannot be tracked because another instance with the same key value for {'Key'} is already being
tracked. When attaching existing entities, ensure that only one entity instance with a given key
value is attached.
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.IdentityMap`1.Add(InternalEntityEntry entry)
at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.AddAsync[TEntity](TEntity entity, ...)
at Program.<<Main>$>g__EnsureSplitTestHardOverrides|0_32(RecDBContext dbContext) in Program.cs:line 1162
at Program.<<Main>$>g__EnsureRequiredGameConfigs|0_31(RecDBContext dbContext) in Program.cs:line 1137
at Program.<Main>$(String[] args) in Program.cs:line 427
The mechanism is a single rule of EF Core being ignored in four places: **a row this DbContext has
added is not in the database yet, so a query for its key answers null.**
1. `EnsureRequiredGameConfigs` upserts `splitTestHardOverrides` - `UpsertGameConfigValue` queries,
sees nothing (empty database), and *adds* a `ConfigVal`. Tracked, unsaved.
2. Two lines later `EnsureSplitTestHardOverrides` queries for the same key, again sees nothing
against the database, and adds a second instance of it. EF refuses the moment the duplicate is
tracked - and the process dies before the `SaveChangesAsync` at the end of the method that would
have made the first row visible.
That last detail is what made it unrecoverable rather than a one-off: nothing was persisted, so
**every** subsequent start walked into the same conflict. Three more keys were armed the same way
behind it - `splitTestsAvailable` and `splitTestSegmentProbabilities` (`RepairSplitTestConfigs`,
line 430) and the `UGC.Room2*` batch (`EnableBetaCreativeToolsConfigs`).
The fix is `Server/Utils/GameConfigSeeder.cs`: one lookup that checks `dbContext.GameConfig.Local`
(the tracked rows, including unsaved adds) before the database, plus the upsert built on it. All
four find-or-add sites go through it now, with the tracked-aware trick `EnsureReferenceGameConfigs`
already used for its own keys. `ServerTests/GameConfigSeederTests.cs` pins it, including the
conflict itself as a case: add a row, query for it, watch the query answer null, then watch the
second add throw. Nothing else was wrong - the wipe regenerated the schema, the Coach profile, the
config, the default rooms and the dorm, and only this seeding step was unpassable.
Anyone stuck on a build without the fix can seed the three keys by hand and get up:
```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<object>()`), and the array is the sidebar list's item shape and nothing else.
### A message's `contents` is a JSON envelope, not a sentence
This is the one that has been hiding behind the thread-shape work all along. The thread item we serve
is byte-for-byte the working reference's item (`latestMessage`, `chatThreadId`, `playerIds`,
`lastReadMessageId`, `chatThreadName`, `chatThreadType`, `snoozedUntil`, `isFavorited` - all eight
present, in that order, `chatThreadName` as `""`), and the reference's own integration test asserts
exactly that JSON - yet the client rejects ours. The difference is not the thread at all, it is the
message inside it:
[Net] Get .../chat/thread?maxCount=50&mode=0
-> Malformed Response: '[ { "latestMessage": { "chatMessageId": 1, …
"contents": "Player <@U2> started a chat", "moderationState": 0 } ]'
The client's message model does not read `contents` as display text: it parses it, and the text lives
inside a small envelope. Real traffic spells it
`{"Type":0,"Version":1,"Data":"Player <@U29565301> started a chat"}` (2022 capture, quoted in this
file's 18:14 section), the working reference stores and serves precisely that - its chat test data is
`contents: '{"Type":0,"Version":1,"Data":"hi"}'` - and this server was writing bare sentences into
it for every thread notice it generates itself. That is also the *other* unexplained throw from the
18:14 retest:
[Error] ArgumentException: JSON parse error: Invalid value.
[Error] Failed to GetChatBetweenPlayers: An error occurred
`JSON parse error: Invalid value` is what a JSON reader says about a bare sentence, and the 18:14
fix (empty `chatThreadName`, `chatThreadType` present) did not address it - the run after it never
opened a conversation, so nothing disproved it either. `ChatStartedNotice`, the leave notice and
`ToMessageResponse` now all go through `ThreadAPI.MessageEnvelope`, which
* stores this server's own notices (`Player <@U4> started a chat`, `Player <@U4> left`) as the
envelope, and
* wraps anything else that is not already a JSON object on the way out, so a message row written
before this change (bare text, from an earlier build or a hand-written request) is still readable
instead of failing the thread it appears in. A value that already parses as an object is passed
through untouched - the client posts the envelope, and re-wrapping it would nest it.
`<`/`>` are deliberately left unescaped (`UnsafeRelaxedJsonEscaping`): the capture spells the mention
as `Player <@U29565301> started a chat`, the client parses mentions out of `Data`, and the default
encoder's `\u003C` is not the same bytes as the traffic this shape was taken from.
### If the thread list still misbehaves, the element order is the next lever
The list item's element type is readable straight out of the client's interop assembly, and it is
worth writing down because the reference's member order is the 2022 capture's, not necessarily this
build's: `RecNet.Runtime.dll` declares a static API wrapper returning
`MFBKDKPGMNP<List<ELKGEHBGLCO>>` (`NativeMethodInfoPtr_EKLFFAGHACF_Public_Static_…_List_1_ELKGEHBGLCO_0`)
- so the body is a bare array whose element is `ELKGEHBGLCO`, which the interop decompiles to
(`ilspycmd -t ELKGEHBGLCO BepInEx/interop/RecNet.Runtime.dll`) 12 backing fields in this order:
long, OBLNNOGIKLC, long, List<ChatMessage>, ChatMessage, List<int>, bool, long, string,
Nullable<DateTime>, bool, Nullable<long>
`OBLNNOGIKLC` is `enum { Player, Club, Party }` - the thread type, in slot 2 - and the key literals in
`global-metadata.interop.dat` (`chatThreadId`, `chatThreadType`, `lastReadMessageId`, `messages`,
`latestMessage`, `playerIds`, `isFavorited`, `chatThreadName`, `snoozedUntil`, `otherPlayerId`) all
exist as string literals (`tools/sep-literals.py find …`), which is where the slot names come from.
`otherPlayerId` is a `Nullable<long>` and lands on slot 12 (`isMuted`/`lastMessageId`, by contrast, are
**not** literals in this build). The reference's order does not match that list at all, so if the
envelope fix above does not clear the list line, the next thing to serve is the twelve members in the
DTO's own order - and the payload the client prints is then the evidence either way.
### `progressionEvents/active` and `seasons/current`: `null`, and 404
* `GET /api/progressionEvents/active` answers `long?` (`KIJDNIPGGDC() -> MFBKDKPGMNP<long?>`), and with
no keepsake event configured it now answers the JSON literal `null` at HTTP 200 - the one body that
type deserializes from as "no event". The empty array it used to answer is a parse failure
(`'[]'`, both the 17:54 and 21:37 runs), and a 404 is an empty body, which the client reports as a
failed call it retries. `Ok(null)` would have been a 204 with no body at all, so the `null` is
written explicitly (`Content("null", "application/json")`).
* `GET /econ/api/subscriptionseasons/v1/seasons/current` answered `{}`, and the client's DTO here has
six members (`Guid`, `string`, `string`, `DateTime`, `DateTime?`, `List<…>` - the 2023-09-12
reference's `ALGDPMHOPGG`), so an empty object has nothing to bind. It answers `404` now: writing
six invented member names would put a season in front of the player that does not exist.
Verified: `dotnet build Server/Server.csproj` 0 errors, `dotnet test ServerTests` **798 passed / 9
failed**, the same nine catalog/storefront/equipment capture-count failures this branch has carried all
week. The chat and startup-compatibility cases were updated for the envelope and the `null` body
respectively.
## 2026-10-02 17:00 - chat photos: the `<Image:N>` reference, and the bulk lookup that 404'd
A photo message is not a text message with a special payload - it is the same envelope with `Type` 2
and a `Data` that is one reference:
{"Type":2,"Version":1,"Data":"<Image:0>"}
Both literals are in the client's own metadata, verbatim and adjacent
(`tools/sep-literals.py find "Image:"`): `"<Image:(.+)>"` (the parse) and `"<Image:{0}>"` (the
compose). The capture is `(.+)`, not `(\d+)`, so the value is whatever `string.Format` was handed.
Two facts pin down what N is meant to be:
* The client's image vocabulary is built on `SavedImageId` - the picker helper's own promise type is
`Task<long>` (`PKMFHFKFFAE.PGJDJGBGNFP(List<HNGNAJKMCGJ>, …) -> MFBKDKPGMNP<long>`), and the metadata
DTO's first member is the id.
* The client's **only** by-id metadata route is `api/images/v5/bulk`, in its own route table: the
contiguous literals at `sep-literals.py around 39485` are `api/images/`, `…/v1/cheer`,
`…/v1/deletesaved`, `…/v1/listsaved`, `…/v1/modifyaccessibility`, `…/v1/sendlink`, `…/v1/slideshow`,
`…/v2/named`, `…/v4/uploadsaved`, `…/v5/bulk`, `…/v5/cheered/bulk`, `…/v6`. Every other photo
route the client uses is path-built off `api/images/`.
What the 2026-10-02 16:16 session did, in order (client-side `[Net]` lines, which are a prefix on
`HTTPManager.SendRequest` and therefore log EVERY request, in send order):
Get /chat/thread/1/message?messageCount=8&mode=2&referenceMessageId=1
Get /api/images/v5/player/2?sort=0 -> the sender's own photo roll
Get /api/images/v5/bulk?ids=-1 -> 404: the route does not exist here
[ShareCamera](Clone) -> photo taken
Post /api/images/v4/uploadsaved -> {ImageName:f91f6f3b-….png}
Get /api/images/v6?name=f91f6f3b-….png -> the metadata, SavedImageId 17
Get /chat/thread/1?messageCount=50 (x2)
Post /chat/thread/1 -> {"Type":2,"Version":1,"Data":"<Image:0>"}
So the client uploads the photo, asks for its metadata, and then posts a message whose reference is
`0` anyway - every photo message this server has ever received says `<Image:0>` (96 occurrences in
`Persistent/Logs/player-data-debug.log` are 8 stored messages read back through the log's field
aliases). `0` is the default of a `long`, not an id: it resolves to nothing, both clients paint the
missing-texture placeholder, and the v6 response that carries the real id is only used for the photo
roll's own UI.
### The two repairs
* `GET /api/images/v5/bulk?ids=…` now exists (`ImagesAPI.GetBulkImageMetadata`). `ids` is repeatable
and each value may be a comma-separated list, matching the reference server's spelling. An id with
no record, or one that is not public, is **absent** from the answer rather than an error, so the
list can be shorter than the request (the reference documents exactly this). The member order is
the reference's live capture (`Id`, `Type`, then the rest raw) with the alias spellings
(`SavedImageId`, `SavedImageType`, `ClubId`) APPENDED rather than interleaved: a positional reader
stops before them, a name-looking reader finds every name it asks for. With the route missing,
every such call was a 404 and a correct id could not be turned into the `ImageName` the photo is
fetched by.
* `ThreadAPI.AddMessageToThread` now reseats an unresolvable reference to the image the sender was
attaching (`ChatPhotoMessages` + `ResolvePhotoPlaceholder`). The rewrite happens only when the
envelope's type marker is 2 and the reference is `0`/negative/empty. A live id is copied through
byte for byte, a text message is never touched, and a placeholder with nothing attributed behind
it is stored as sent - a wrong photo would be worse than none.
### 17:41 - a photo PICKED out of the roll was still grey, and why
The first retest picked an existing photo instead of taking one. The stored contents were still
`<Image:0>`, and the running local server already had everything above (`v5/bulk?ids=7` answered
200): the rewrite only looked at the sender's **newest upload inside 15 minutes**, and a picked
photo was uploaded on 2026-09-08. The picker path never uploads anything, so it had nothing to
resolve to - and it turns out the client posts `<Image:0>` on that path too.
What the picker path leaves behind is a different trace, one request earlier in the log:
Get /api/images/v5/player/2?sort=0 -> the roll
… thumbnails …
Get /api/images/v6?name=08f5465c-….png -> the PICKED image's metadata (SavedImageId 7)
Post /chat/thread/1 -> {"Type":2,…,"Data":"<Image:0>"}
That `v6`-by-name lookup is the last images request before the message on BOTH paths (a fresh
capture asks for the new name right after `uploadsaved`), and it names exactly the image being
attached. So the server now records it: `RecentImageReferences` keeps one `(account, image id,
timestamp)` per account, written by `v6` and by `uploadsaved`, read by the chat post, with a
10-minute window. The chat rewrite prefers that attribution and keeps the 15-minute upload window
only as a fallback for a session where the metadata call did not arrive.
Both sides of the conversation read the same stored row, so the repair reaches the sender (whose
client re-reads the thread and the `ChatMessageReceived` push straight after posting) and the
receiver (who resolves the stored id through `v5/bulk` -> `ImageName` -> `/images/{name}`). Note
what the 17:41 log also shows: for `<Image:0>` the client asks for NOTHING - the placeholder is
painted without a request - so a stored `0` can never resolve, and the id has to be right in the
row.
### Retest
1. STOP the running server first - it holds `Server/bin/Debug/net9.0` open, so a rebuild cannot land
under it. Then `dotnet run --no-launch-profile -c Debug` from `Server/` (or build, then start the
new `Server.exe`).
2. Send a photo, either picked from the roll or freshly captured - both paths are covered now.
3. The console prints `[ChatPhoto] sender=2 posted a placeholder photo reference; resolved it to
saved image N from …`; the stored contents are `<Image:N>`.
4. The message should render the photo in the sender's client on its next thread refresh, and in the
other account's client when it opens the thread.
5. In the client log, a `Get /api/images/v5/bulk?ids=N` (or a by-id fetch) should answer 200 - it
404'd before. If the photo still shows a placeholder, what the client requests for the stored id
is the next thing to read: `[Net]` lines carry the request, and a non-public image is absent from
the answer by design.
Confirmed 2026-10-02 17:54, local server, photo picked out of the roll: the client asked
`v6?name=08f5465c-….png`, posted `{"Type":2,"Version":1,"Data":"<Image:0>"}`, and the
`ChatMessageReceived` push it got back carried
`{"Type":2,"Version":1,"Data":"<Image:7>"}` - `08f5465c-….png` is image 7. The photo rendered in
the sender's client. What has NOT been exercised yet is the receiving side: the sender's client can
paint from the thumbnail it already fetched, so only the other account proves the stored id resolves
end to end.
Verified: `dotnet test ServerTests/Server.Tests.csproj -o artifacts/chat-image-run` **816 passed / 4
failed, 820 total** - the four are the in-flight catalog/storefront/equipment capture-count failures
(`StorefrontRewardConfigTests`, `DorkNetCatalogTests` x2, `AdminGiftSkinTests`), none of them chat.
The photo repair is verified by hand rather than pinned in the suite: the 17:54 client run above is
the evidence, and no photo test class ships (there was never one that earned its place). The routes
were also curled against a live local server: `v5/bulk?ids=14` -> the metadata, `v5/bulk?ids=-1` ->
`[]` at 200, `v5/bulk?ids=14,13&ids=999` -> request order with the unknown omitted.
Note the server has to be STOPPED before a rebuild: a running `Server.exe` holds
`Server/bin/Debug/net9.0` open and every rebuild after that silently keeps the old bytes there (the
2026-10-02 17:41 retest ran against a binary built at 17:35, which had `v5/bulk` but not the
attribution).
## 2026-10-03 - invites: the spelling the client actually posts, and accepting them
The game invite - pressing Invite on a friend, "come to where I am" - is in the client's own route
table, in the contiguous `matchmake/*` block (literals `#46558`-`#46568`):
matchmake/chatinvite/{0}/{1} matchmake/club/{0} matchmake/code/{0}/{1}
matchmake/dorm matchmake/event/{0} matchmake/instance/{0}
matchmake/invite/{0} matchmake/none matchmake/player/{0}
matchmake/room/{0} matchmake/room/{0}/{1}
Those are paths on the matchmaking host, which this server fakes as a `/matchmaking` prefix
(`/matchmake/dorm` is served at `/matchmaking/matchmake/dorm` - visible in every client log).
**The spelling the client actually posts is the bare form one.** The 2026-10-02 client log
(`BepInEx/LogOutput.log`, lines 1356 and 1463 - one per Invite press) carries exactly:
[Net] Post https://<dev-host>:3030/matchmaking/invite
with a form body. No path-shaped send appears anywhere in the log, so the earlier guess that the
client posts `matchmake/invite/{playerId}` was wrong.
That matters because the `matchmake/invite/{0}` in the table above is NOT the send spelling. It sits
in the middle of the contiguous JOIN family (`matchmake/instance/{0}` before it,
`matchmake/player/{0}` after it) and carries one path parameter like its neighbours, so it is the
accept-by-invite-id spelling: "join the instance the invite with this id names". Serving it as a
send would invite whatever account happened to hold the same number as an invite's message id -
someone unrelated. The send surface is therefore exactly:
POST /matchmaking/invite (form; the one provably posted)
POST /matchmaking/invite/{playerId} (path form, for a build that carries the target there)
`POST /matchmaking/matchmake/invite/{playerId}` was registered as a send in the previous session and
has been REMOVED: that path is now the accept route below.
Two more things were fixed on the way, both from the reference's own documentation of the same
route: `roomInstanceId` is OPTIONAL there (its schema says "the caller's room instance... resolves
the invite's RoomId"), while this server's binding made a missing instance a refusal before the
invite was even attempted. It now falls back to the caller's OWN presence row - the same row
`InvitePlayer` already insists the inviter be live in, so the fallback can only name an instance the
caller is really standing in - and refuses by name only when the caller has no instance at all. The
fields are also read the way this codebase already reads club invites: path value, form field, JSON
body or query string, under `playerId` or `invitedPlayerId`, because the client's exact body is not
observable from the server.
Every invite now prints one line naming exactly what arrived, and that line is the thing to read on
the next client run - it pins the spelling this build really uses:
[Invite] POST /matchmaking/invite from account 5: target=2, instance=20790
(roomInstanceId sent as <N>); fields read: <every key the client sent>.
Two neighbouring routes from the same literal table were missing as well and are now served:
* `settings/partyinvite` -> `GET /api/settings/partyinvite`, answered as the BARE single-key object
the reference serves (`{"InviteLinkLifetimeInMinutes":60}`) - nothing here stores invite links, so
the link lives in the client and this only reports its lifetime;
* `api/offlineinvite/v1/send` -> `POST /api/offlineinvite/v1/send`. An offline invite is the same
stored game invite: `Notify.SendMessage` stores the row either way and an offline target simply
gets no hub frame, reading the message when it next signs in.
### Accepting: both join spellings are served now
The client's accept does not reach the network yet (live finding below), but both spellings it could
use are registered and behave as joins authored by the invite:
* `POST /matchmaking/matchmake/invite/{inviteId}` -> `AcceptInviteByMessageId`. `inviteId` is the
stored `GameInvite` message the hub pushed; the row must be addressed to the caller and its `Data`
must name a live instance. The sender must still be ONLINE in that exact instance - an invite is
not a blanket key - so: not addressed to the caller -> `RoomInviteExpired`, the caller's own row
-> `ChatMessageNotAnInvite`, the sender signed off -> `PlayerNotOnline`, the sender moved on ->
`RoomInviteExpired`, the instance is gone -> `RoomIsNotActive`.
* `POST /matchmaking/matchmake/chatinvite/{threadId}/{messageId}` -> `MatchmakeChatInvite`, the chat
party acceptance. A stored message in the caller's thread resolves the inviter from its sender
(the caller's own message -> `ChatMessageNotAnInvite`; older than `InviteLinkLifetimeInMinutes` =
60 -> `RoomInviteExpired`). An id this server never stored falls back to the thread's single other
member. A thread the caller is not in, or one with no unique other member ->
`ChatPartyInviteNotFound`.
Both funnel into `AcceptInviteIntoInstanceAsync`: bans outrank the invite (`Banned`,
`BannedFromRoom`), a pending-warning bounce is honoured, the caller's presence row is moved into the
inviter's instance and a `MatchResponse` with `errorCode: Success` and the room instance is
returned. Each step logs `[Invite] accept:` / `[Invite] accepted:` / `[Invite] accept refused:` so
the next client run shows exactly which spelling arrived and what it resolved to.
### Live findings - the two invite surfaces block in different places
After the invite above was pushed and rendered (Notification #111, `handler failures: 0`), the server
saw NO accept request of any spelling - no `matchmake/instance/20790`, no `matchmake/invite/...`,
nothing. The Unity log (`Player.log`) shows why: `ArgumentOutOfRangeException: Index was out of
range` thrown from `WatchNotificationsModel.KHNPLDJLMEA(HashSet\`1, CKPNHJPJAGB+KINNNKACEEA)` - the
watch notification LIST build (twice) - and `[Error] Failed to perform action. : Spam Alert!` with
the 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<T>.CPOPMFILHCF(Task<T>)` -> `FIKNOBAKBJH<T>.BEJNCICKNOM(T)` ->
`ANHPEPEMBNM+<>c__3<T>.DHFNHECJGBH(HttpResult<T>)` -> `LLDACAIIDPN.EGMGFIBBBOM<T>(string)` ->
`FIKNOBAKBJH<T>.NEPFPOCMJPJ(string)`: the client's response continuation for a request this server
answered **200**, taking its error branch and building the exception from a null message. Every
success envelope serialized `"error": null`; the reference spells `"error": ""`. `Success` now
defaults `error` to `string.Empty` (a refusal still carries its reason), pinned by
`RecNetShapeConformanceTests.Success_SpellsItsAbsentErrorAsAnEmptyString`, with the `RoomSaveTests`
envelope assertion moved to the reference shape.
What an invite looks like client-side, from this build's metadata (`.external/sep-decomp`, the
interop stubs carry every name even where the bodies do not): the outer notification `Id` `"2"` is
`HCACKLIOLEM.PHKAIMJMIML.MessageReceived`; the inner `Msg.Type` is `MOHGFNBDKJI` (`GameInvite = 0`,
`FriendStatusOnline = 20` - exactly the server's `MessageType`); and the notification's action
dispatcher `ILFPMMHFHOE` has one payload struct per action of `DBANOMFCFBF` (`GoToRoom`,
`ShowRoomDetails`, `ShowAccountDetails`, `ShowMessage`, `AcceptGameInvite`, ...) with
`AcceptGameInviteData { FromAccountId }`. A game invite is therefore a button action whose payload
names the inviter, which is the next thing to check if a run with the room now visible still posts
no accept.
### The invitee's client, in its own log: the invite arrives, the press posts nothing (the 22:21 run)
The 22:21 run finally came with the **invitee's** client logs. `Player.log` under
`AppData/LocalLow/Against Gravity/Rec Room/` (mtime 22:22, engine path
`Downloads/2023-09-06T06-53-47Z/RecRoom_Data`, an RTX 4060 - `Initial Room Load
'@TestPlayer's Dorm' (99.109) [instance 20802]` at 22:21:08) is account 2's install, and so is the
`BepInEx/LogOutput.log` beside it. The two attached AMD-Radeon logs are the *other* install
(`Downloads/2023-09-06T06-53-47Z (2)`, the inviter): its `[Net]` lines carry both
`Post .../matchmaking/invite` calls, which is what the server logged as account 5 both times.
Condensed, in order, everything the invitee's client sent around the invite:
| Step | Request |
| --- | --- |
| card renders | `GET /images/5cea039d-....png?width=128` (the inviter's avatar), `POST /datacollection/data/heartbeat` |
| the room it names | `GET /api/inventions/v1/room?id=101` -> 200 `[]` |
| then | `GET .../images/5cea039d-....png?width=256&cropSquare=1`, `PUT /playersettings/playersettings` x2 |
| end of the attempt | `POST /api/messages/v3/delete` - the notification is dropped |
No `rooms/rooms/101`, no `matchmake/*`, no accept: the press produced no request at all, and the
second invite (message 15, 22:22:02) repeated the same sequence. Both installs also log
`[Meta] this install's metadata was patched for 'https://localhost:3030' but Connection:ServerUrl is
'https://<dev-host>:3030' ...` at startup. That line is **cosmetic**: the plugin rewrites the
address before it leaves the client (`[Connection] Redirected nameserver request to the configured
Radicade server.`), and every `[Net]` URL in both logs reads `<dev-host>:3030`, so it is not the
reason a request is missing.
### The crash behind the press is ours, and a null `error` was only half of it
The invitee's Player.log logs `ArgumentException: Error message cannot be null` at 22:21:11 - after
this server had been restarted with the success-envelope fix - from
`ANHPEPEMBNM+<>c__3<T>.DHFNHECJGBH(OAFAKLEKFGI<T>)` -> `LLDACAIIDPN.EGMGFIBBBOM<T>(string)` ->
`FIKNOBAKBJH<T>.NEPFPOCMJPJ(string)`: its response continuation builds the exception from the body's
`error` field. A null `error` on a 200 was one way to feed it a null, but not the only one - a
response with **no body at all** hands the same path an empty string and gets the same crash, inside
the caller's task continuation. That is why the caller does not fail visibly: it stops. Every 4xx
this server served during that window was bodyless:
| Request (from either client, 22:20-22:22) | Answer that run |
| --- | --- |
| `GET /clubs/club/home/me` (no home club, a deliberate 404) | 404, `len=0` |
| `GET /econ/api/subscriptionseasons/v1/seasons/current` (deliberate 404) | 404, `len=0` |
| `GET /rooms/rooms/101?include=5421...` (private dorm, answered to a caller with no live invite) | 404, `len=0` |
| `GET /rooms/rooms/99?include=5421...` (the same rule, the other dorm) | 404, `len=0` |
The reference never answers a failure without a body: its route failures are
`{ success: false, error: 'No such room' }` and its global handlers spell the status
(`{ success: false, error: { message: 'not found' } }`). The 2023 client's `error` is a string - the
same DTO as the success envelope - so this server now answers with the shape this build can bind.
### Every failure carries the envelope now (fix)
`RecNet/Responses/Failure.cs` is the failure twin of `Success`: `success: false` plus a **non-null,
non-empty** `error` string (even `Failure("")` spells *request failed*).
`Middleware/FailureEnvelopeMiddleware.cs` fills it in for the whole pipeline: any 4xx/5xx that
reached the client with `Content-Length: 0` and no content type gets
`{"success":false,"error":"<why>"}` under its own status, with the wording spelled per status
(`404 -> "not found"`, `401 -> "unauthorized"`, `500 -> "internal server error"`, ...). Answers that
already describe themselves (a written body, a content type, a streamed response), redirects, the
101 upgrade the notify hub takes over and every non-error status are left exactly as they are.
It is registered just before `UseAuthentication()`, so it wraps authentication, authorization and
every controller: a bare `NotFound()` from any handler, a `[Authorize]` 401 and a path no route
matches are all covered. It also sits *inside* the request logger, so the console line that used to
read `len=0` now prints the envelope it counted. Live, on the restarted server (PID 35348, log
`artifacts/server-failure-envelope-run.log`):
```
GET /econ/api/subscriptionseasons/v1/seasons/current -> 404 {"success":false,"error":"not found"}
GET /definitely/not/here -> 404 {"success":false,"error":"not found"}
POST /matchmaking/matchmake/instance/20800 -> 401 {"success":false,"error":"unauthorized"}
GET /api/settings/partyinvite -> 200 {"InviteLinkLifetimeInMinutes":60}
```
`FailureEnvelopeTests` (13 cases) pins the envelope, the per-status wording and the pass-through
rules (`AnAnswerThatAlreadyHasABodyIsLeftAlone`, `SuccessfulAndEmptyAnswersAreLeftAlone`,
`UpgradesAndRedirectsAreLeftAlone`). Whole suite after the change: **838 passed / 9 failed, 847
total** - the invite and shape suites are green, and the nine failures are the in-flight
catalog/storefront/equipment counts being edited (`EquipmentInventoryTests` expects 87 and saw 57,
and its neighbours), none of them touching this pipeline.
What this does not claim: the press may still post nothing. In this run the invitee's client never
even asked for the room (`rooms/rooms/101`), so if `[Invite] accept:` is still absent after the
envelope fix, the next lever is the client's own button path rather than another server shape - the
plugin already watches the notification channels, and the action dispatcher noted above
(`AcceptGameInviteData { FromAccountId }`) is where a press would have to appear.
### Retest
1. The local server was restarted on this build after the fixes above (PID 35348, log
`artifacts/server-failure-envelope-run.log`), so the routes are live; stop and start it again
after any further rebuild (a running `Server.exe` serves the old bytes).
2. A 4xx in that log now prints the answer the client received (`len=37` and a JSON body) where it
used to print `len=0`. If a 4xx there still reads `len=0`, a response escaped the pipeline -
that is the one to fix next, whatever the client then does.
3. In the client's watch, press Invite on a friend who is in another account's client. The server
console should print `[Invite] POST /matchmaking/invite from account ...` - the bare-spelling
proof, again. The invitee's client should follow with `GET /rooms/rooms/{roomId}?include=5421...`
answering **200** (it was 404 for a private room before), with `"error": ""` on its successes.
4. The friend's client shows the join prompt (watch) or the party-invite card (chat) and presses
it. Read the server console for `[Invite] accept:` - that line is the accept spelling this build
really posts. The party-invite card needs the `chat/settings/partyinvite` fix above; without it
its model throws on the missing lifetime and the press never becomes a request.
5. If no `[Invite] accept:` line appears, the block is client-side, and the logs now say which side
of it: the invitee's `Player.log` for a fresh `ArgumentException: Error message cannot be null`
(a bodyless 4xx that escaped step 2) or the client's own button path - as observed 2026-10-02
(`Spam Alert!`, `WatchNotificationsModel` throw, and the 22:21 run where a press produced
`PUT /playersettings/playersettings` twice and `POST /api/messages/v3/delete`, nothing else).
That is the thing to chase, not a server 404.
6. If a line does appear, `[Invite] accepted:` or `[Invite] accept refused: ... (code)` is the whole
outcome: refusals come as client-readable JoinStatus codes, and a refusal naming an invite id the
caller does not own means the client's id and the stored message diverged (the `[Invite]` send
line and the notification push carry the ids to compare).
## 2026-10-08 19:50 - announcements ride the name the client registers, and the new-update sound hack is gone
Two threads, one root cause.
**"Sending an announcement does nothing."** The panel's announcement branch pushed the numeric enum
`AnnouncementText` (26). The client's dispatcher delivers a frame only when its Id EQUALS a registered
key, and the registration probe (this file's own dump, taken at login) shows the announcement handlers
registered under the literal names - `"AnnouncementUpdate"` and `"AnnouncementDelete"` - with no
numeric id for either. `AnnouncementText` (26) is the phantom id this doc already named for the game
update: the frame parses, matches nothing, and is dropped, so a saved announcement only ever appeared
on the next login through the `api/announcement/v1/get` fetch. The push now goes out under
`"AnnouncementUpdate"`, the way the community board (`"CommunityBoardUpdate"`) and the progression
frame already do. Server-side, `AnnouncementBroadcast.Update(...)` holds the literal and
`AdminPanelAPI.UpdateAnnouncements` calls it; `AnnouncementWireTests` pins both the id and the payload.
**The new-update alert is removed from the client.** `AnnouncementSoundAlert.cs`, its
`Assets/AnnounceNewUpdate.wav` (1.3 MB, embedded) and the `<EmbeddedResource>` that shipped it are
gone, and the call in `Plugin.cs` with them - so the plugin no longer watches the hub for the
game-update frame or carries a sound at all. The DLL drops from ~1.7 MB to 140 KB. The panel still
sends the game-update frame id-only; nothing on the client answers it yet. That is the next piece of
work, and the removed hack was in the way of doing it properly - it played a stand-in sound off the
same frame rather than letting the game show its own update prompt.
Retest: send a regular announcement and look for `[Realtime] Notification #N Id="AnnouncementUpdate"`
in the client log, followed by the announcement appearing without a relog. The client DLL in
`BepInEx/plugins` is rebuilt and deployed; the previous one is kept beside it as
`RadicadeSeptemberClient.dll.bak-announce-wav`.
## 2026-10-08 20:15 - the game-update prompt is the version check, not a notification (21 is not it)
The 2026-10-01 entry guessed the alert was a client-side sound the server could only ever *request*.
That was half right and the wrong half: the client does have a game-update path, it just is not a
notification.
`ModerationUpdateRequired` (21) is **not** the id. The dispatcher probe's login dump lists every key
the client registers a handler under, and the numeric set is exactly
```
1 2 3 5 6 20 22 23 25 30 31 32 50 60 61 62 70 71 80 81 82 83 84 96 100 110 120 121
```
- no 21 (and no 24, 26, 27, 40, 85, 90, 95). A frame under 21 parses, matches nothing and is dropped,
exactly like 26 and 27. The only game-update-relevant key in that table is the **string** name
`"AppVersionUpdate"`, and a string key is only ever matched by an identical string.
What actually drives the prompt is REST. Every boot the client asks
```
[Net] Get https://ns.radicade.app/api/versioncheck/v4?v=20230831&p=0&pid=4
```
and the answer carries `VersionStatus` plus `UpdateNotificationStage`. The server was answering
`ValidForPlay` / stage `0` unconditionally, which is why no build ever showed an update prompt - and
why a client-side sound was the only thing that could ever be made to happen. The client's own string
table has exactly the status and stage members this implies:
```
GFBJICOODHE.ValidForPlay.UpdateRequired.CHPBKJBGLIB.Warn.Prompt.Require.FHCAEIBGBHF
```
- `ValidForPlay` / `UpdateRequired` (and a third member, matching the three-value `VersionCheckStatus`
the server already models as `ValidForPlay = 0, UpdateRequired = 1, ValidForMenu = 2`), then the
escalation stage `Warn / Prompt / Require` and one more member beyond it. The client also carries
the string `Update Required` in its UI string table.
So a Game Update is now the announcement row plus the answer it produces:
* `GameUpdateVersionCheck` (`Server/RecNet/VersionCheck/`) reads the panel's `Announcement` config for
a live, unexpired `"Game Update"` row. Same expiry rule as the announcement list - a row with no
parsable `expires_at` never expires, so the prompt is taken down by deleting the announcement.
* `api/versioncheck/v4` answers `UpdateRequired` (+ stage `1`) while one is live, `ValidForPlay`
otherwise. The `ValidVersion`/key set is untouched - it stays exactly the five keys the client's
strict lookups need. Deliberately stage `1`, not `0`: `0` is what a healthy boot already gets, so
it is the one value that may also mean "nothing to show".
* The panel's Game Update send now pushes the id-only frame under `"AppVersionUpdate"` instead of
`AnnouncementGameUpdate` (27) - the re-check request for a client that is already connected, so an
announcement lands without a relog.
Why 21 was a reasonable guess worth checking: `ModerationUpdateRequired` *sounds* like "the moderation
state changed, go re-read it", which is the same shape of request. It is still not registered - which
also means the two places the server pushes 21 today (`ReleaseModerationHoldAsync`,
`DeliverWarningAsync`) are reaching nothing. The warning prompt survives on the 22 that follows it.
Still unknown, and the next launch answers it: what payload the `"AppVersionUpdate"` handler binds.
The probe now prints the bound type and its members for that key (it already did for progressions), so
one login log settles whether the empty object is enough or the frame has to carry the version. If the
prompt does not appear but the version check *is* answering `UpdateRequired`, that is the thing to
read. If it appears but is too soft or too harsh, the lever is `GameUpdateVersionCheck.UpdateStage`
(climb the stage) or `VersionStatus` (`ValidForMenu` instead of `UpdateRequired`).
Verified: `dotnet test ServerTests/Server.Tests.csproj` on the affected filter (GameUpdate,
VersionCheck, Announcement, CommunityBoard, WatchWire) **54 passed / 0 failed**, including the new
`GameUpdateVersionCheckTests` and the untouched `VersionCheck_AllowsPlay_WhenNoGameUpdateIsPublished`
shape test. Client DLL rebuilt (140 KB, still no embedded sound) and deployed.
## 2026-10-16 - the named services: one host each, and the rewrite that keeps their routes
The nameserver no longer hands the client one host with a path per service
(`https://ns.radicade.app/auth`). Every service is now advertised as a host of its own -
`https://auth.radicade.app`, `https://images.radicade.app`, `https://thorn.radicade.app`, 38 in
all - and the host label is the route the server has always served that service under, so
`auth.radicade.app/<rest>` is exactly what `ns.radicade.app/auth/<rest>` was.
`DBConfig.ServiceDomain` + `DBConfig.ServiceUrl(name)` build the names;
`2023Patch/RadicadeEacBypass/NameserverRedirect.cs` (`ServicePrefixes`) turns each host back into
that prefix, in the funnel, before any socket is opened.
Two names are deliberately not their own prefix. `api` stays the bare base, because the client
sends unprefixed calls to it (`api/versioncheck/v4`) and a segment would break every one. `cdn`
keeps `/cdn`, the segment its advertised base has always carried. The stock `cdn.rec.net` entry
sitting above them stays bare, and that is not an inconsistency: the *stock* client asks for bare
blob paths while this one appends to a base that already ended in `/cdn`.
Everything else is unchanged on purpose. `ns.radicade.app` is still the bootstrap host and still
the base every rewritten request lands on - `Connection:ServerUrl`, the baked metadata bootstrap,
`TLS:AllowedHosts` - so the client never resolves or trusts a `*.radicade.app` service host at
all: the redirect happens in the funnel, before DNS. The URLs the server generates itself
(`DBConfig.ServerUrl` in `Admin*`, `StudioAuthAPI`, `CommunityBoardV2`) keep working for the same
reason. Of the three payload fields that *are* built from `DBConfig.NameServer`, `CDN` and `Images`
still land on the routes they named before - with one wrinkle worth knowing: the `{Images}/img/{name}`
URLs the server puts in photo and webhook payloads collapse to the bare `/images/{name}` route,
because the redirect's `/img/` de-doubling applies to every redirected host, not just `img.rec.net`.
That is the same picture either way: both routes serve the file, and both take the key id from the
`rcsig` the client states, so the signature the anti-cheat reads is unchanged.
The third field, `WWW`, is the one endpoint here that a *browser* consumes rather than the client
(`ConfigAPI`/`FirstTimeUtils` hand it out as `ShareBaseUrl`, and the server serves those share pages
at `/www/{0}`). The funnel cannot rewrite a browser, so a share link built as
`https://www.radicade.app/{0}` opens the share page only if that host resolves to this server *and*
the server answers it at the root - `www.radicade.app` has to be routed by Host header, or that one
base has to keep the `/www` path it used to carry. Everything else in the list is reached only
through the rewritten client.
What this costs is any client with no plugin to do the rewrite. The Quest build is
metadata-patched only (`tools/patch-quest-client-metadata.py`, no BepInEx/Harmony port), so it
will now resolve `auth.radicade.app` and the rest itself: it needs DNS for those names, a
certificate they are served under, and a server that routes by Host header before it works again.
Windows is unaffected, and this is the one thing to weigh before deploying.
The names are one mapping in two places, so the test that guards it reads both. `dotnet test
ServerTests/Server.Tests.csproj`: 1597 tests, 8 failed - and all 8 fail on pristine `10c9035` as
well (catalog/storefront/challenge data shapes, none of them reaching the nameserver), while the
suite went 1577 -> 1597 with the 20 new cases
green. The new `EveryAdvertisedServiceHostLandsOnAServiceRootTheServerServes` takes the advertised
names, runs them through `NameserverRedirect` and checks the segment that comes out against the
server's own route templates, so a name misspelled on either side - `roomieintergrations`, say -
fails here instead of in a client that half-works. `HubRealtimeTests` now derives each advertised
service from its host label and asserts there is no path; `RecNetShapeConformanceTests` asserts
the same four bases it did before, by name.
Not deployed: the running server keeps answering with the path form until this is pushed and
restarted. Ship the plugin first, then the server - the order is not symmetric. A client running
the new plugin against the *old* `/ns` is fine (its requests stay on `ns.radicade.app`, which the
redirect leaves alone), but an old plugin against the *new* `/ns` resolves `auth.radicade.app`
itself - the failure mode already written up above under "Why a foreign host is fatal": no
rewrite, no certificate on `TLS:AllowedHosts`, and the anti-cheat path takes the session down at
boot.
## 2026-10-16 - the notify service is advertised under its full name
The notify service is no longer named `notify.radicade.app`. The nameserver advertises it as
`notifications.radicade.app`, and the *route* did not move: the client's redirect still lands it on
`/notify`, so the hub socket is still `wss://ns.radicade.app/notify/hub/v1?...`, the health root is
still `/notify`, and `NotifyHubRoutes` is unchanged. This renames the advertised name only, which is
why nothing in the request path moved.
It is the first name in the set that is deliberately not its own route. Every other host label is
the segment the server serves that service under, and
`EveryAdvertisedServiceHostLandsOnAServiceRootTheServerServes` enforced that as an equality
(`Assert.Equal(label, segment)`); that assertion now reads `DBConfig.ServiceRoute(label)`, and
`DBConfig.ServiceRouteOverrides` holds the one entry - `notifications` -> `notify` - so the
divergence is a fact the server, the redirect and the test all read, rather than a hole in the
check. It is not weaker for it: pointing the redirect entry at `/notifications` instead of `/notify`
fails the test (verified by doing it), and so does advertising a label the server has no route for.
`notify.radicade.app` was retired, not aliased - the redirect map has no entry for it any more, so a
request still naming that host is left exactly as it is, which `NameserverRedirectTests` asserts.
The consequence is worth stating plainly: a client carrying this plugin against a server that still
advertises the old name sends its hub socket to a host with no DNS record, which is the same stall
this service's plugin half was written for. Ship the server and the plugin together; there is no
window in which a new plugin and an old `/ns` both work.
Nothing infra changed, and nothing needs to. `notify.radicade.app` never had a DNS record either -
public DNS answers only for `ns.radicade.app` - because the redirect happens in
the funnel, before DNS: the client never resolves a service host at all. So this rename needs no
Cloudflare record, no Caddy site and no certificate. `www` remains the one name that has to be
routed by Host header, because a browser consumes it.
Server: `Server/DB/DBConfig.cs` (`Notifications = ServiceUrl("notifications")`, plus
`ServiceRouteOverrides`/`ServiceRoute`). Client: `2023Patch/RadicadeEacBypass/NameserverRedirect.cs`,
one map entry. Tests: `HubRealtimeTests` (both nameserver-set tests now compare through
`ServiceRoute`), `NameserverRedirectTests` (the socket case and the service-host case name
`notifications.radicade.app`, and the retired host is asserted to be left untouched).
`dotnet test ServerTests/Server.Tests.csproj`: 1599 tests, 8 failed - all 8 also fail on pristine
`10c9035` in a clean `git worktree`, and that set is a strict subset of the baseline's 10, the suite
being order-dependent in a couple of unrelated places. The affected classes alone are 190/190.