mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1681 lines
111 KiB
Markdown
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.
|