Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+1680
View File
@@ -0,0 +1,1680 @@
# 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.
+3944
View File
@@ -0,0 +1,3944 @@
# August 2023 client migration — local work, not yet playable
Target: `C:\RecRoom\2023-08-29T06-53-33Z`.
The version stored in this client's `globalgamemanagers` is **20230825**,
and its Unity version is 2020.3.33f1.
## Implemented
- Server default game version is 20230825 (earlier supported versions remain).
- The metadata patcher recognizes this release's shuffled 256-byte header using
a SHA-256 fingerprint, excluding only the two fields it intentionally updates.
Unknown headers still fail closed.
- Literal table offset/size fields are at 0x2C/0xBC; literal data offset/size
fields are at 0xC8/0xC0. All 52,189 literal records fit the original blob.
- Rewrite verification checks unchanged literal contents and all original bytes
outside the literal records and the two edited header fields.
- The patch marker records 20230825 rather than the old hardcoded March version.
- Local development exposes HTTPS on localhost:3030 and HTTP on 127.0.0.1:3031,
and advertises HTTPS service URLs.
Original client metadata and boot configuration are preserved beside the files
as `.orig`. No March BepInEx files or generated interop assemblies were copied
into the August client. No Git push or remote server deployment was performed.
## Verification and remaining blocker
The patcher's real-file verification passes, including a second dry run with
zero changes. Server startup succeeds and the version endpoint returns
ValidVersion=0 / VersionStatus=0. The 38 targeted VersionCheck, Nameserver and
SplitTestCompatibility tests pass.
Initial startup failures (before the August plugin):
- HTTP: the client's request constructor throws `Exception: Invalid URI scheme`.
- Direct desktop launch with HTTPS: embedded BestHTTP/BouncyCastle rejects the
local certificate with `user_canceled(90)` / nameserver error `summer`.
- EAC bootstrapper launch: native crash near `Connecting to RecNet`.
The new `2023Patch/RadicadeAugustClient` project builds against August interop
and replaces the March plugin locally. It installs embedded TLS, nameserver,
image signature key, hub authentication, and August EAC challenge hooks without
March-only UI patches. The old DLL is preserved as
`BepInEx/plugins/RadicadeEacBypass.dll.march-backup`; the old configuration is
preserved as `com.radicade.eacbypass.cfg.before-august`.
Live verification: August Client 2.0.0 loads without a plugin-load exception,
TLS accepts the configured localhost endpoint, the game fetches the nameserver,
`/api/gameconfigs/v1/all` and `/api/config/v1/amplitude` successfully, and logs
the Steam profile. It then reports **Launch validation failed. Is Rec Room
installed correctly?** Login and room joining remain unverified. No broad
launch-validation or error-suppression patch was installed.
## Launch-validation root cause and bypass
Native analysis with the `tools/AugustInterop` disassembler traced the error:
- `NDIIPBKGGIG::CBNCPLACNIE(15)` is the only call site producing the error
screen; value 15 is `JKBPKDAGEHN.LAUNCH_VALIDATION_FAILED`.
- Its sole caller chain is `NFPDGJNDFOF::GBLLEJBCFGI -> NFPDGJNDFOF::GBAMNAKGEGD`.
- `GBAMNAKGEGD` is the boot validator invoked from `BootSequence::DFCEILLEFOK`
via `ADJOODNBCKG::BEFDPLAELIH`. It resolves an obfuscated static-data file
reference, opens the referenced file, parses its PE headers, and computes a
SHA-256 over selected sections in 0x400-byte chunks on a background thread,
then hard-rejects (`CBNCPLACNIE(15)`) when the hash does not match the
expected value baked into `GameAssembly.dll`'s static data.
- The check is not satisfiable in a patched installation (BepInEx loader
metadata redirect and localhost server wiring), so the plugin now installs
`AugustLaunchValidatorPatch`, a prefix on `ADJOODNBCKG.NFPDGJNDFOF::GBAMNAKGEGD`
that resolves the boot completion promise (`PFDOLDLEIBA.OLKGFACPIPC`, with a
direct handler-list fallback) and skips the native body. It must be
registered via `harmony.PatchAll` in the plugin `Load` method — an earlier
attempt defined the patch class but never registered it, which is why the
error screen still appeared once.
Rebuild and reinstall `RadicadeAugustClient.dll` after this change; the log
should show `Bypassing native launch validator` before boot proceeds.
Build validation: August plugin builds with zero warnings/errors. The March
plugin cannot be compiled unchanged against August's renamed types.
The final client target is `https://localhost:3030`. Start the server with
`dotnet run --project Server/Server.csproj`. For diagnostics, direct desktop
launch uses `RecRoom.exe +forcemode:screen`; it now passes TLS and encounters
the launch-validation blocker described above.
## BepInEx interop generation fixed
The installed BepInEx 6.0.0-be.697 initially failed Cpp2IL's magic-number check.
The original `.orig` metadata has the same shuffled header: this is the release's
format, not evidence of download corruption or a URL-patch error.
`tools/AugustInterop/normalize.cjs` creates a **separate tooling copy** with a
standard v27 header. It also repairs the 305 synthetic `<Module>` records, whose
name indices and generic-container indices otherwise break assembly generation.
Only verified module records are repaired. This copy must NEVER replace the
game's `global-metadata.dat`; the native runtime expects the custom format.
The installed loader reads `global-metadata.interop.dat` for Cpp2IL. Its original
is backed up at `BepInEx/core/BepInEx.Unity.IL2CPP.dll.before-august-interop`.
The loader modification changes only RunCpp2Il's metadata filename literal.
Verified with the actual August GameAssembly.dll: 42,537 types and 283,224
methods parsed, native registrations resolved, interop generation completed,
335 files written including the hash and caches, and the live loader logged
`Chainloader startup complete`. Interop generation was first verified with no
plugins; the August connection plugin was then built and installed separately.
See `tools/AugustInterop/README.md` for reproduction and rollback.
## August login "code: block" root cause and fix
Symptom: title screen showed `Cached/Manual Login failed with: Unable to login
(code: block)` right after a `POST /matchmaking/player/login` log line whose
`[Net]` line prints when the request is queued, not when the response lands.
Server-side truth (server-console.log + PlayerDataDebug): the login POST
actually returned **200** both times. The real 405 came from
`POST /api/PlayerReporting/v1/moderationBlockDetails`, which the server only
mapped as GET; Kestrel answered the POST with 405 Method Not Allowed. The
August login flow runs its moderation-block check inside the login promise, and
a failed check maps to `JKBPKDAGEHN.LOGIN_CODE_BLOCK` - hence "code: block",
which reads like a moderation block but was a route mismatch.
Fix: added `[HttpPost("v1/moderationBlockDetails"), Authorize] GetBlockDetailsPost()`
in `Server/API/Moderation/PlayerReportingAPI.cs`, delegating to the existing GET
action so both verbs serve the identical body. Verified live: POST now returns
401 without a token (auth layer reached, no more 405) and the GET shape is
unchanged for older clients. Server rebuilt and restarted on 3030/3031.
Remaining non-fatal August 404s (client continues without them):
`GET /matchmaking/player/qos`, `GET /matchmaking/player/connection-info`,
`GET /chat/thread/party`, `GET /cdn/room/`. RudderStack analytics cannot
resolve its host and self-disables after 15 failures.
Verified in-game after the fix: `POST /matchmaking/player/login` -> 200,
`POST /matchmaking/player/exclusivelogin` -> 200,
`POST /api/PlayerReporting/v1/moderationBlockDetails` -> 200 (86-byte empty
block-details body), hub WebSocket connected, and the avatar spawned in the
Dorm. The August 2023 build boots fully into the Radicade server. The RSA
neutralizer correctly passes through verification calls signed with non-p1
keys (observed on a `cdn/sigs/...` fetch).
## Remaining August 404s fixed
The four 404s the August client hit after login were implemented, using the
recflare reference server's response shapes where documented:
- `GET /matchmaking/player/qos` - unauthenticated BARE ARRAY of QoS probe
regions (id + host:port address); the client pings these and reports back
through `PUT /matchmaking/player/photonregionpings` (already a no-op ack).
- `GET /matchmaking/player/connection-info` - authed `{ success, value, error }`
envelope carrying a freshly minted HS256 `photonAuthToken` (sub/rn.platid/
rn.plat/rn.deviceclass/rn.env claims, 1h TTL, signed with the server JWT key),
the configured Photon app ids, `photonRegion`, and the `photonRoomId` of the
caller's current room instance (presence first, `roomInstanceId` query param
as fallback). The `experiments` block mirrors the reference verbatim;
`shouldUseGameServerNetworking` stays false.
- `GET /chat/thread/party` - empty object (recflare's documented stub: parses as
"no party" without failing the client's deserializer).
- `GET /platformnotifications/crm/me/config` - empty object (same stub
convention; the 404 here raised one console error per login).
Photon app ids and region are configurable (`Radicade:Photon:RealtimeAppId`,
`VoiceAppId`, `ChatAppId`, `Region` in appsettings) and default to empty ids /
`us` region, exactly as recflare behaves with no Photon configured: the client
is told empty ids and connects to nothing. A deployment that runs a Photon
server sets the three ids and the client connects normally.
Corrections after the first live run with those routes (all observed in
BepInEx LogOutput.log):
- `chat/thread/party`: the August client deserializes this as a LIST - the
empty-object stub made it throw `expected:'[', actual:'{'`. Answers a bare
empty array now.
- `platformnotifications/crm/me/config`: the empty-object stub made the manager
throw `Deserialization returned null`. The client parses a counted envelope
here (same as `config/categories`), so it answers
`{ Results: [], TotalResults: 0 }` now.
- `connection-info` with empty Photon app ids is WORSE than a 404: the client's
GetRegions call gets `Empty application id` (ReturnCode -2) and Photon drops
the session with InvalidAuthentication. The ids must be the ones the client
actually uses - they live in the game's
`BepInEx/config/com.radicade.eacbypass.cfg` under [Photon] and are now set in
appsettings (`22dda1bb-...` realtime, `82766593-...` voice, `e8ea33f3-...`
chat), matching the AppIdOverride the March plugin was already forcing.
### 2026-09-27: the same empty-app-id trap, hit again on a second host
Moving the client to the public server reproduced the `connection-info` failure above,
from the other direction. The loader reported `GOING TO ^DormRoom 0% PREPARING..` and
sat there; the server looked healthy throughout (presence, matchmaking and the room
routes all 200), and the client's own log showed the real story:
[Network.Photon.LoadBalancingClient] Queued Network action failed due to exception: NullReferenceException
[RoomLoad.RoomManager.PresenceUpdates] Error joining room instance RoomInstance - ID:21516 DORM_ROOM:25/32
JOKAIBMCFDA::Failed to connect to region "us"
IKDHNNILPMJ::Unexpected Photon Disconnect due to cause: InvalidAuthentication
with `Empty application id` in Player.log exactly once and `InvalidAuthentication` eight
times, retrying roughly every 7 s. The cause was config, not code: the ids were set in
`appsettings.Development.json` and **not** in `appsettings.Production.json`, so a host
running the Production environment kept the `DBConfig` defaults (`""`) and answered
`connection-info` with an empty `photonRealtimeAppId`. Photon's GetRegions then fails
with ReturnCode -2 and drops the session, which is the documented behaviour quoted
above - `connection-info` with empty ids is worse than a 404.
Two changes so the next environment does not have to be diagnosed from a loading screen:
- `Server/appsettings.Production.json` carries the `Radicade:Photon` block (same app as the
dev config and the plugin's `AppIdOverride`).
- `Server/Program.cs` prints either `Photon: realtime app id configured, region 'us'
(voice set, chat set).` or a full WARNING naming the four keys to set, at startup. The
warning path is verified by booting with `Radicade__Photon__RealtimeAppId=` set to empty.
Note the failure is silent by design from the server's point of view: nothing 500s and no
route errors. Anything that shows up as "the room load hangs at 0%" should have the
client's Photon state checked first, because the REST surface will look perfect.
## Run-3 hard crash root cause: RSA passthrough access violation (fixed)
The fully-green-server run (20:25) still crashed the client on the loading
screen with the infamous `DILGBLCELMP: Exception of type 'DILGBLCELMP' was
thrown.` at `DLHKINDAIHK+MICFNEHOAMC.MoveNext()` right after
`GET /platformnotifications/crm/me/config` (200) and
`[ImageSig] RSA verify called #1 with non-p1 key - passing through`.
That obfuscated exception was a red herring: the *real* fatal is in BepInEx
`ErrorLog.log`:
```
Fatal error. System.AccessViolationException
at Il2CppInterop.Runtime.IL2CPP.il2cpp_runtime_invoke(...)
at DynamicClass.DMD<RSACryptoServiceProvider::VerifyHash>(...)
```
Chain of events:
1. After `exclusivelogin`, the client downloads the file-integrity manifest
`GET /cdn/sigs/6fae9a40-edf5-44c5-a263-36d7b5b4a91f` (served since an earlier
blocker) and starts verifying the listed game files' signatures.
2. It verifies with the game's OWN embedded RSA key (not p1.rec.net), which
reached `RsaVerifyNeutralizerPatches.VerifyPrefix`'s deliberate
"non-p1 -> pass through to original" path.
3. Invoking the il2cpp original through the Harmony DMD re-implementation
access-violates (interop cannot safely re-enter that native method). Unity
surfaces the AV as the unmappable `DILGBLCELMP` il2cpp exception inside the
sigs-check async chain (`DLHKINDAIHK+MICFNEHOAMC` awaited the RecNet HTTP
task, then died during verify) - hence the crash *after* the crm 200 line
even though crm/party responses were parsed fine.
Fix (client plugin, `RadicadeAugustClient.Plugin.cs`): the RSA verify prefix now
forces `true` and skips the original for EVERY intercepted verify, p1 key or
not - the passthrough path is never taken because it is un-invokable. Rebuilt
(`dotnet build 2023Patch/RadicadeAugustClient/... -c Release`, 0 errors) and
deployed to `BepInEx/plugins/RadicadeAugustClient.dll` (2023-09-23 21:07).
Lesson: on this build, any Harmony-prefixed il2cpp method whose prefix returns
true (executes the original) risks the DMD AV. Neutralizer-style patches must
always skip the original when the original's failure is the thing being patched.
## Run-4 follow-up: file check fails gracefully -> manifest parser bypassed (fixed)
After the RSA-forcing fix, the client survived the sigs manifest (RSA verify
forced true, DILGBLCELMP now a caught/logged error) but the checker
(KKHDHILFOGO) threw while PARSING the placeholder manifest:
`IndexOutOfRangeException in KKHDHILFOGO.PFNKMGPPFGP` -> "File check failed
KKHDHILFOGO+LPIKGKOLBJH: Error validating file Failed to process verification
file due to internal exception" -> POST /api/PlayerReporting/v1/hile ->
PreparingToExit -> clean shutdown. Not a crash, but still an exit.
The real manifest format encodes per-file hashes inside the RSA-signed blobs,
so no manifest we can serve without Rec Room's private key parses.
Fix (client plugin): new `FileCheckManifestParserPatch` prefixes
`KKHDHILFOGO.PFNKMGPPFGP` and returns an empty file->hash dictionary - with no
files listed the per-file verify loop completes as success. Rebuilt and
deployed (2023-09-23 ~21:20). Look for `[FileCheck] manifest parser bypassed`
in the next BepInEx log.
## Run-5: manifest parser patch is NOT viable -> sigs route reverted to 404 (final)
The `FileCheckManifestParserPatch` attempt failed differently: HarmonyX's DMD
cannot compile the detour for `KKHDHILFOGO.PFNKMGPPFGP`'s out-byref generic
signature (`Dictionary<string, Il2CppStructArray<byte>>& out`) and throws
`InvalidProgramException: Common Language Runtime detected an invalid program`
at `(il2cpp -> managed) PFNKMGPPFGP` detour time - the checker then fails with
NullReferenceException and the same `hile` report + PreparingToExit follows.
Empirical summary across runs (all 2023-09-23):
| cdn/sigs response | client outcome |
|------------------------------|--------------------------------------------------|
| 404 (run 1) | reaches the Dorm (known-good) |
| 200 placeholder (run 4) | IndexOutOfRange in parser -> hile -> exit |
| 200 + parser patch (run 5) | InvalidProgramException (DMD) -> hile -> exit |
Conclusion: the check TOLERATES a 404 and fails hard on any 200 we can produce.
The parser patch was removed from the plugin and `SigsCDN.cs` now deliberately
answers 404 (with the full analysis in its header comment). Plugin rebuilt and
redeployed; server rebuilt, restarted (PID 4768), verified: sigs 404,
gameconfigs/qos 200. The RSA verify neutralizer stays for p1-keyed image
signatures; the DMD passthrough AV lesson from run 3 still applies to any new
patch that would execute an il2cpp original.
## Run-6 correction: 404 is NOT tolerated; final sigs/RSA combination
Run 6 disproved the "client tolerates a 404" theory from run 5's writeup:
`OBHEMJEJNMK: HTTP Error 404` -> "File check failed ... Dowload failed" ->
hile report -> PreparingToExit. Reconciling ALL six runs gives the real
rules of the file check (KKHDHILFOGO):
1. Missing manifest (404) -> fatal (run 6)
2. Manifest present, non-p1 verify PASSES through -> DMD access violation
to il2cpp original (run 3, flaky)
3. Manifest present, non-p1 verify forced TRUE -> parser decodes the
fake 16-byte blobs as a hash table -> IndexOutOfRange (run 4)
4. Manifest present, non-p1 verify forced FALSE -> parser skips blob
decoding, check completes -> known-good (run 1, Dorm reached)
5. A client-side parser prefix is impossible (DMD InvalidProgramException,
run 5) - the out-byref generic signature is un-detourable.
FINAL combination (all live 2023-09-23 ~21:35): SigsCDN.cs serves the
placeholder manifest again (with byte-range support) AND
RsaVerifyNeutralizerPatches now answers `__result = isP1` - TRUE only for the
embedded p1.rec.net key (image signatures, boot-critical), FALSE for every
other key (the file check, which must fail cleanly to skip blob decoding).
Never passthrough (AV), never unconditional TRUE (IndexOutOfRange).
Plugin rebuilt/deployed, server rebuilt/restarted (PID 42708), verified:
sigs 200 placeholder, gameconfigs/qos 200. Watch for
`[ImageSig] RSA verify #N (game key) forced false` in BepInEx.
## Run 11 outcome (22:36) + Run 12: exit-state veto (deployed 22:55)
Run 11: suppression armed, file-check bypass fired, Dorm load started at
11.13s - then the process DIED mid-scene-load at 16.26s with no exception,
no hile POST, no exit sequence. The IJHOPKJFKBI prefix crashed the process
inside the DMD stub BEFORE the prefix body ran: the scanner thread is a
native, unattached thread (CallerCount(1), no managed callers), and il2cpp
detours on such threads access-violate - the run-3 VerifyHash AV class.
winhttp.dll cannot be dodged at the source: it is the EAC-bypass door DLL
(the only non-stock DLL in the game root) and loads BepInEx itself.
Run 12 therefore ALLOWS the hile report to happen (HileAPI logs it at info)
and vetoes the MANAGED exit state machine instead:
`CCKNJCHHBLL.KALEFEDAHCC` (AppExitState setter, enum Running/PreparingToExit/
ReadyForExit) is prefixed; transitions OUT of Running are blocked for 120s
after load, then pass through. The observed shutdown always begins with the
managed "AppExitState changed: Running => PreparingToExit" line, so this
cuts the shutdown off at its head from attached managed-thread territory
where detours are proven safe. Alt+F4 still works (native path).
Run 12 markers: `[ExitVeto] blocked AppExitState -> PreparingToExit at ~16s`
in BepInEx, hile POST visible server-side but session SURVIVES, room load
completes, player spawns in the Dorm. If the game still exits: the scan
thread exits natively without the managed transition - next lever is
thread-freezing or attacking the scan itself.
## Run 10 outcome (22:31): hile suppression DID NOT FIRE - registration bug, fixed
Run 10 replayed run 9's ending exactly: file-check bypass fired, Dorm room
load ran 6.1s, hile POST at ~18.3s, PreparingToExit 160ms later, room load
cancelled. No `[Hile] suppressed` line, no `armed` startup line. Cause:
`HileReportSuppressionPatch` was added as a class but never registered -
this plugin patches per-class (`harmony.PatchAll(typeof(...))`) and the new
class was missing from the Awake chain. Fixed + startup log line
"[Hile] report suppression armed" added; deployed 22:34:43.
Server-side wins confirmed in run 10: gameserverregionpings PUT no longer
404s ("Failed to upload cluster pings" gone); curatedlists 404 gone but
the client now logs non-fatal "Deserialization returned null" + "No such
list" for `GET /lists/curatedlists/bulk?id=17859340` - the `[]` shape is
tolerated but wrong; may want a `Results/TotalResults` envelope or a list
object later (NOT the current blocker).
Run 11 markers: `[Hile] report suppression armed` at startup, then
`[Hile] suppressed report 0 (Obscured) message='winhttp.dll'` at ~18s,
NO `POST .../v1/hile`, no PreparingToExit, player spawns in the Dorm.
## Run 9 outcome (22:16): file check DEAD, new blocker = native module scan (winhttp.dll)
The both-entries bypass WORKED: `[FileCheck] entry bypassed` fired after
exclusivelogin, no `GET /cdn/sigs`, no game-key RSA verify, no
"Signatures don't match!". Boot went deeper than ever: matchmake/dorm ->
room 25/32 instance 20830 -> Photon connect -> LoadScene(dormroom2) ->
Initial Room Load '@TestPlayer's Dorm' completed its 5.3s flow - then the
session died: hile POST at 20.48s, PreparingToExit at 20.64s, room load
cancelled.
The hile body decoded server-side: `Type=Obcured Message=[0:winhttp.dll]`
- the native module scanner flags our EAC-bypass proxy DLL (game root,
26KB, deployed 15:18) as an obscured module. The hile enum (MEHDFJHKLBK.
JBLJIENBBHK) decodes [0:...] = (Obscured, "winhttp.dll"). Same pattern in
the short 22:15:40 session.
Also fixed (server, 22:29): GET /lists/curatedlists/bulk -> [] (was 404,
caused one "Deserialization returned null") and PUT
/matchmaking/player/gameserverregionpings -> {} (was 404, "Failed to
upload cluster pings").
## Run 10: hile funnel suppression (deployed 22:29)
Client: `HileReportSuppressionPatch` - prefix on
`KDJDKJNPELI.IJHOPKJFKBI(MEHDFJHKLBK.JBLJIENBBHK, string)` (CallerCount(1)
funnel for EVERY hile report; enum Obscured=0/Time=1/Inject=2/.../
AppData_Boot_InvalidSignature=310/...) returns false and logs the report;
finalizer on `ModerationManager.BJGDALDIOGI` is insurance for paths
skipping the static funnel. No hile report is legitimate in a Radicade
session. Server: HileAPI log downgraded warning -> info.
Success markers: `[Hile] suppressed report 0 (Obscured) message='winhttp.dll'`
in BepInEx, no `POST .../v1/hile` in server log, no PreparingToExit, room
load 25/32 completes and the player spawns in the Dorm. If a DIFFERENT
hile type appears later, the suppression log will name it.
## Run 9: entry patch v2 - both entries (deployed 22:08)
Run 8 (21:54) failed exactly like run 7: no `[FileCheck] entry bypassed` line,
`GET /cdn/sigs/...` still ran, `RSA verify #1 (game key) forced false`, then
"Signatures don't match!". The public wrapper `GGIFLDPHCIP` is never invoked.
The decomp explains it: `GGIFLDPHCIP` is marked `CallerCount(0)` (dead public
wrapper) while the private twin `GOCCLPJDJIC` (same signature) is
`CallerCount(3)`; the no-arg `NPIILCCIPNF` is also `CallerCount(0)`, so it
needs no patch. `FileCheckEntryPatch` now uses `TargetMethods()` to prefix
BOTH entries and returns the already-resolved success promise
(`PFDOLDLEIBA.PLJINLOCHGM(KKHDHILFOGO.BJGODLEIDGE.NOMPJGOIAEB(...))`).
No download, no parsing, no RSA verify; the consumer sees a resolved
`MCFFIFIPNKE<BJGODLEIDGE>` exactly like a passing check produces. Success
markers for run 9: `[FileCheck] entry bypassed - resolved success promise
returned` in BepInEx, and NO `GET /cdn/sigs` / no RSA verify / no
`POST .../v1/hile`; boot should continue past the file check into the Dorm.
## Run-7/8: forced-FALSE also fails; final bypass = resolved success promise (deployed)
Run 7 (`__result = isP1`, non-p1 -> false, manifest 200) failed with an
explicit validation error: `File check failed KKHDHILFOGO+LPIKGKOLBJH:
Error validating file verification.sig: Signatures don't match!` at
`KKHDHILFOGO.AFHNPNGLCML` called from the parser. The complete matrix now
(six live client outcomes, one theory):
- 404 manifest -> "Dowload failed" (run 6)
- verify false -> "Signatures don't match!" (run 7)
- verify true -> blob decode IndexOutOfRange (run 4)
- verify passthrough -> flaky DMD access violation (run 3)
- parser prefix -> impossible, DMD InvalidProgramException (run 5)
- run-1 "Dorm reached" -> actually a race: the flaky AV path returned
native false before the AV could trigger; the run-1 theory of "false skips
blob decoding" is dead.
Conclusion: NO server response + verify-result combination passes this check.
`DILGBLCELMP` is the checker's own exception class (KGABAOLHEHL-derived,
string ctor), logged caught in every run - it is a symptom, never the cause.
Run-8 hypothesis before the decomp check: patch the public entry
`KKHDHILFOGO.GGIFLDPHCIP(string, byte[], byte[], long, string)` (DMD-safe
parameter profile) and return an already-resolved success promise. Run 8
disproved it (see Run 9 above); the fix now covers both entries.
The RSA neutralizer keeps forcing TRUE only for the p1 key (image
signatures); with the entry bypass active the file check never reaches RSA
verify at all.
## Run 12: AppExitState veto - process survives but zombie (deployed 22:55)
Client: `ExitStateVetoPatch` - prefix on private setter
`CCKNJCHHBLL.KALEFEDAHCC` (nested enum CCFIKLBABNB), blocks any transition
out of Running for 120s after load, then allows. Safe to detour from managed
threads (fired dozens of times, no crash). HileReportSuppressionPatch fully
removed (run-11 lesson stands: NEVER detour KDJDKJNPELI.IJHOPKJFKBI).
Outcome: the veto WORKS as a patch (`[ExitVeto] blocked ... PreparingToExit`
right after the hile POST at 17.6s) but is INSUFFICIENT. The shutdown path
does not run through AppExitState: immediately at hile time the client also
cancelled the room load ("Room load to 20833 cancelled" AFTER the 6s load
flow completed), tore down EAC ("[EAC] Tearing down and releasing runtime"),
POSTed logout + reportjoinresult, and threw OBHEMJEJNMK: Aborted. Then an
infinite ~1.35s loop: NRE + "An error occurred" + EAC teardown +
veto-blocked PreparingToExit AND ReadyForExit, from 17.6s to 65.6s+.
VoiceClient timed out after 30s; Backtrace "Cannot resolve destination
host". Result: alive-but-unusable zombie. The setter's CallerCount(3)
matches the three state drives visible in the loop.
Conclusion: the exit has at least two arms - the AppExitState machine AND a
parallel shutdown initiator (EAC teardown / room-load cancel / Photon
disconnect). Vetoing the state machine alone wedges the process.
## Run 13: server-side hile-response experiment (pending)
Untested hypothesis: the native exit fires in response to the hile POST
RESPONSE (exit began ~160ms after the POST completed in runs 9/10/12).
HileAPI.PostHile is now env-selectable via `RADICADE_HILE_BODY`:
`ok-true` (default, unchanged shape) | `ok-false` | `nocontent` (204) |
`servererror` (500). Test pins ok-* to OkObjectResult+bool; 204/500 are
diagnostic-only.
Procedure: kill Server.exe (PID 20224 at 23:00), `dotnet build`, start with
`RADICADE_HILE_BODY=ok-false`, relaunch client, compare:
- Exit GONE -> the native scanner waits on the server verdict; tune the
response (then try nocontent/servererror to map the contract).
- Exit STILL at ~17.6s -> trigger is client-local; next step is decomp of
the managed exit initiators (CCKNJCHHBLL.BEFDPLAELIH/GIEEBIOOFML/
GBGMBMGDGGD, all run on attached threads) and prefixing them, or native
handling of the scanner thread (run-11 lesson: do NOT detour the funnel).
## Run 13: hile-response experiment - exit is CLIENT-LOCAL (23:11)
Server returned `Ok(false)` for the winhttp.dll hile report (verified in
server-console.log: "Hile experiment: returning Ok(false)"). Result: the
client STILL exited - same signature as run 12 (veto blocks, EAC teardown
loop, OBHEMJEJNMK Aborted, ~94s death). The server response does NOT drive
the exit.
Two new facts:
1. The IMMEDIATE post-report exit was skipped. Run 12: exit began 160ms
after the hile POST and killed the room load. Run 13: room load
COMPLETED (FinishRoomLoad 21.5s), spawn completed ~23s, and the player
had ~30s of real gameplay before the first PreparingToExit attempt at
52.5s. First-time NRE + "InvalidOperationException: Cannot error an
already completed promise" at 32s looked suspicious but did not kill.
2. The 52.5s exit is the same driver as the 17.6s exit (veto + EAC + Aborted
loop). Interpretation: the native scanner re-scans on its own schedule;
with Ok(false) the first post-report exit did not fire, but a later scan
still flagged the module and restarted the shutdown.
Conclusion: stop DETECTION, not the shutdown. Next lever: make
winhttp.dll invisible to the module scan itself.
## Run 14: PEB module hiding - hide winhttp.dll from the scanner (deployed 23:27)
Client: `PebModuleHider.HideModule("winhttp.dll")` at plugin Load (before
any Harmony patching). Walks PEB->Ldr->InLoadOrderModuleList, matches
BaseDllName (x64 offset 0x58 from the InLoadOrder node; 0x60 was wrong -
FullDllName at 0x48 is a 16-byte UNICODE_STRING), unlinks the entry from
all three public lists (InLoadOrder / InMemoryOrder /
InInitializationOrder). Pure user-mode pointer writes on the main thread -
no detours, no scanner-thread contact, no DMD. Module ENUMERATION
(Toolhelp, EnumProcessModules, PEB-walks) no longer sees the DLL; module
RESOLUTION (LoadLibrary/GetModuleHandle) is untouched (loader hash tables),
so the BepInEx door keeps working. AllowUnsafeBlocks enabled.
Server: hile restored to canonical Ok(true) (ok-true, default env) so run 14
tests exactly one variable. Server PID 34420.
Success markers: `[ModuleHide] winhttp.dll unlinked from PEB module lists`
in BepInEx at startup, NO `POST /api/PlayerReporting/v1/hile` in the whole
session (the scanner finds nothing to report), no PreparingToExit, room
load completes, spawn, stable gameplay past 2min. If hile STILL appears,
the scanner is not PEB-based (try hiding via handle-table wipe / ImagePath
alternate next). Keep ExitStateVetoPatch armed as belt-and-suspenders.
## Run 14: PEB unlink - scanner does NOT use module lists (deployed 23:27, failed)
`PebModuleHider.HideModule("winhttp.dll")` fired successfully
("[ModuleHide] winhttp.dll unlinked from PEB module lists"). BaseDllName
offset correction: 0x58 on x64, not 0x60 (FullDllName at 0x48 is 16 bytes).
Outcome: the hile POST STILL arrived at 16.3s with
Message=[0:winhttp.dll], immediate exit followed 150ms later (veto loop as
before). Server log confirms exactly one fresh report at 23:32:58.
CONCLUSION: the scanner does not enumerate via PEB module lists. It walks
committed image regions (NtQueryVirtualMemory-style) and classifies each
region by parsing PE headers - unlinking the lists hides the NAME but the
region still self-identifies via its MZ/PE signatures.
Note the response correlation across runs: ok-true -> immediate exit (12,
14); ok-false -> deferred exit (13). The exit is native local state, the
POST is only telemetry.
## Run 15: PE signature wipe - declassify the mapped image (deployed 23:42, FAILED)
`PeHeaderWiper.WipeSignatures("winhttp.dll")` at plugin Load (after the PEB
unlink, before Harmony). Zeroes the two structural signature tokens:
"MZ" at base+0 and "PE\0\0" at base+e_lfanew (6 bytes total, VirtualProtect
RW for the write then restore). A region-walking scanner that classifies by
parsing headers can no longer name this region; GetProcAddress is unaffected
(it follows e_lfanew into the intact optional/export headers without
validating the signature bytes) and winhttp IATs bound at process start.
Success markers: both [ModuleHide] lines OK, NO POST /v1/hile in the whole
session, no PreparingToExit, load completes, spawn, stable past 2min.
Failure modes to distinguish in the log:
- hile still appears -> scanner hashes raw region bytes against the
module's on-disk image (need bytes-level rework of the mapped image or
targeted section wiping) -> next run.
- no hile but exit at ~16-18s -> an independent native watchdog (e.g. door
DLL fingerprint check) -> revisit native path.
### Run 15 outcome (14:28 Sep 24, FAILED)
Both hides active in the boot log ("[ModuleHide] winhttp.dll unlinked" AND
"PE signatures wiped"). The hile POST fired anyway at 19.3s session time
(client [Net] log: `Post .../api/PlayerReporting/v1/hile`), immediate exit
150ms later: veto blocked PreparingToExit, EAC teardown, room load
cancelled, `OBHEMJEJNMK: Aborted`, NRE/"An error occurred" loop ~1.3s -
identical to runs 12/14.
Server-side note: server-console.log stopped receiving entries after
23:33 Sep 23 even though the server process survived (its console
redirection died with the previous agent session), so run-15 POSTs are
evidenced from the client log only. Server was alive and answering
gameconfigs 200 during the whole session.
CONCLUSION after runs 14+15: the scanner neither enumerates PEB module
lists nor classifies regions by PE signature tokens. It still flags
winhttp.dll as `Type=Obcured` (note the misspelling - the flag is
"obscured", i.e. it is reporting our tampering itself, not a name it
got from the headers). Remaining hypotheses: (a) hashes raw mapped
bytes against the on-disk image, (b) fingerprints the door DLL through
the loader record / LoadLibrary bookkeeping, (c) an independent native
watchdog.
DECISIVE observation for run 16: across ok-true runs the exit fires
~150ms after the POST, while run 13's ok-false (response that never
satisfied the handler) deferred the exit until the next scan. The exit
is triggered by the report's RESPONSE arriving, not by the scan itself.
So: drop the report request at the HTTP layer before it is ever sent.
## Run 16: HileGate - drop the hile POST at the HTTP layer (deployed 14:50 Sep 24, FAILED - prefix never skipped the original)
`HileGate` (in NameserverConnectionPatches.cs): a prefix check inside the
existing BestHTTP `HTTPManager.SendRequest(HTTPRequest)` patch - the same
managed, main-flow funnel the [Net] logger uses, proven detour-safe
(unlike the scanner-thread funnel KDJDKJNPELI.IJHOPKJFKBI, run 11 fatal).
When the request URI path ends with `/api/PlayerReporting/v1/hile`, the
prefix returns before any processing: the request is never queued, never
sent, and no completion callback can fire, so the response-gated exit
path never runs. The scanner cannot observe the drop - it only sees that
its report never completes. Opt-in via `HileGate.Enabled` (armed in the
August plugin Load; the March plugin source stays inert). The
ExitStateVetoPatch stays armed as a second layer.
Success markers: `[HileGate] dropped anti-cheat report POST` around 16-20s,
NO hile POST reaching the server, NO PreparingToExit, load completes,
spawn, stable past 2 min.
Failure modes to distinguish in the log:
- No [HileGate] line but exit at ~16-20s -> the scanner sends through a
different stack (native winhttp directly, not BestHTTP) -> shim the
door DLL's winhttp exports or block at the socket layer -> next run.
- [HileGate] line fires, no POST, still exit -> trigger independent of
report completion (native watchdog) -> revisit native exit initiators
(CCKNJCHHBLL.BEFDPLAELIH / GIEEBIOOFML, tokens 100667097/098).
- Periodic re-scan attempts every ~30s should each be dropped silently;
a rising [HileGate] count with stable gameplay is a WIN state.
### Run 16 outcome (14:53 Sep 24, FAILED - implementation bug, model intact)
The gate FIRED (`[HileGate] dropped anti-cheat report POST` at 16.9s) but
the POST went out anyway: server-console-run16.log line 12835 shows
`POST /api/PlayerReporting/v1/hile -> 200` at 14:53:55.431, exit machinery
started ~150ms later, veto loop as usual. ROOT CAUSE: the run-16 prefix was
VOID - in Harmony an early return from a void prefix does not skip the
original; only a bool prefix returning false does. The drop was a no-op, so
this run is evidence-wise just another ok-true repetition (12/14/15).
FIX (deployed 14:58): the prefix is now `bool Prefix(HTTPRequest request,
ref HTTPRequest __result)`; on a hile report it logs, sets
`__result = request` (the overload's fluent return value - decomp confirms
SendRequest_Public_Static_HTTPRequest_HTTPRequest_0) and returns false so
the original never queues the request. Success markers unchanged; next run
must show the [HileGate] line AND no POST /v1/hile on the server, then no
PreparingToExit at all.
### Run 16 RETRY (boot 14:59:23 Sep 24) - SUCCESS
All success markers met for the first time in 16 runs:
1. `[HileGate] dropped anti-cheat report POST` fired at 16.9s session time
(LogOutput.log line 174) - exactly where the scanner always reported.
2. ZERO `POST /api/PlayerReporting/v1/hile` reached the server for this
boot (server-console-run16.log: the only hile entry is the buggy 14:53
run's). The drop now actually skips the original.
3. ZERO `[EAC] Tearing down`, ZERO `OBHEMJEJNMK: Aborted`, ZERO
NullReferenceException/"An error occurred" - the post-report zombie loop
never started.
4. Room load COMPLETED: joinresult 200 at 14:59:43 (instance 20845),
RunFor.Authority handlers finished, phases advanced through
DESERIALIZE_CONNECTABLES -> POST_DESERIALIZE_OBJECTS (attached log tail
at 20s), avatar/keepsake/invention fetches all flowed.
5. Session end (14:59:52 logout, 16 veto blocks 0-10s, process left
~15:00:03) was a USER-INITIATED quit ~9s after spawn, NOT an
anti-cheat exit: no EAC teardown, no hile around the logout, and the
native quit path bypasses the veto by design (documented run-12
behavior). The 120s veto window had not expired; the native path took
the process down cleanly.
VERDICT: the anti-cheat kill chain is broken at its response gate. The
report is dropped client-side, the response never arrives, the exit
handler never fires, and the game survives into the Dorm.
PENDING VERIFICATION (next soak run): the scanner's ~30s periodic re-scan.
Prediction: every subsequent report is dropped identically (rising
[HileGate] count, stable gameplay). If a later scan instead switches to a
different report channel (native winhttp direct), the first symptom is a
server-side hile POST - watch for it in a 5+ minute soak.
Note: keep the ExitStateVetoPatch armed. With reports dropped it should
never fire again except for genuine user quits inside the 120s window; if
it starts blocking repeatedly WITHOUT a [HileGate] drop preceding it, a
second exit trigger exists and the native initiators
(CCKNJCHHBLL.BEFDPLAELIH / GIEEBIOOFML) become the next patch target.
## Run 17: second exit trigger + veto removed by a parallel edit (Sep 24
afternoon)
Three more boots with the fixed gate (14:59, 15:07, 15:13) reshaped the
evidence:
- 14:59 boot: gate dropped the report at 16.9s, room load COMPLETED
(joinresult 14:59:43), then 16 veto blocks (PreparingToExit x8,
ReadyForExit x8) at 14:59:52-15:00:03 - the exit re-request came ~9s
AFTER load finished, and the process left once the veto window ended.
No hile, no EAC teardown, no crash dump, no Windows fault.
- 15:07 boot: gate dropped reports TWICE (17s, room-change), survived 7
minutes, then died through the managed shutdown cascade at 15:08:42 -
exactly when the run-12 veto (120s from 15:07:40) expired. Same clean
shape: the exit kept being re-requested and the veto kept absorbing it
until the window closed.
- 15:13 boot: a PARALLEL EDIT (Plugin.cs modified + rebuilt at 15:11,
not by this thread) REMOVED ExitStateVetoPatch entirely. That boot had
[HileGate] armed but no [ExitVeto] armed, died ~40s in with nothing
absorbing the exit. This confirms the veto was doing real work.
MODEL REVISION: the exit is NOT purely response-gated. The scanner keeps
RE-REQUESTING exit on a cadence (runs show 10-40s intervals), plausibly a
report-completion TIMEOUT on the dropped report (run 13's ok-false exit
at ~30s fits: no satisfying response within the deadline = failure =
exit). Dropping the request cannot win alone; something must absorb the
re-requests forever. The veto did that until its window expired; a
parallel edit removed it.
Run 17 change: ExitInitiatorTracerPatch prefixes the managed exit
initiators CCKNJCHHBLL.BEFDPLAELIH (100667097), GIEEBIOOFML (100667098)
and FNOEHNDLHHG (100667101), logs the FULL managed stack of whoever
calls them, and blocks the call. These are attached-thread, detour-safe
targets (run-14 prep). Deployed 15:42. Success markers: [ExitInit]
stack traces identifying the second trigger's caller, and sessions that
no longer die at 10-40s. If sessions still die with all three blocked,
the trigger is native-only (TerminateProcess-class) and the blockades
prove it by absence.
Run 18: "crash on boot" was a disabled Doorstop proxy, not the tracer
Symptom: the user reported a crash on boot immediately after the 15:42
run-17 deploy.
Evidence chain:
- BepInEx/LogOutput.log still had mtime 15:14, i.e. no plugin log was
written for any boot after the 15:13 session. No boot has ever printed
[ExitInit] armed.
- The Unity log (AppData/LocalLow/Against Gravity/Rec Room/Player.log and
Player-prev.log, mtime 15:59:47 / 15:59:33) contains ZERO
BepInEx/Doorstop/Radicade lines: two VANILLA boots of ~14 KB each.
Player.log shows "Could not resolve host 'ns.rec.net'" at 3.6s, then
AppExitState Running => PreparingToExit at 3.9s, then
"[EAC] Tearing down and releasing runtime", process gone by ~7s. EAC
was live because the Doorstop/EAC-bypass door never loaded, and the
game was still pointed at the real nameservers.
- Cause: winhttp.dll (26,112 b, Doorstop 4.3.0 proxy, md5 b2a4d0cb...) had
been renamed to winhttp.dll.baseline-disabled at 15:17:15 by a parallel
action, which also created baseline-backup-20260924-151633/ containing a
copy of winhttp.dll, Player-before-baseline.log (the 15:13 session log),
the pristine 173 b RecRoom_Data/boot.config and the pristine 38,654,794 b
il2cpp_data/Metadata/global-metadata.dat.
- No new WER 1001 event and no new CrashDumps entry: the game exits
through its orderly quit path, it does not fault. That is why the crash
left no crash report and why only the Unity log has evidence.
- Fix: the snapshot's winhttp.dll was copied back to the game root (md5
identical). The rest of the door chain was verified intact:
doorstop_config.ini enabled=true, .doorstop_version 4.3.0,
BepInEx/core/BepInEx.Unity.IL2CPP.dll, dotnet/coreclr.dll, 335 interop
assemblies, 64 unity-libs.
Consequences:
- Run 17 has NEVER booted. It was deployed at 15:42, 25 minutes after the
door was disabled, so ExitInitiatorTracerPatch has zero runtime data and
its block/trace behaviour is still unproven.
- The "baseline" experiment cannot work as designed. With the door removed
there is no patch stack at all, so the game dies at ns.rec.net DNS plus
EAC long before any comparison is possible. A vanilla control run needs
the door present and the patch groups switched off from inside the
plugin, never a removed proxy DLL.
- Parallel edits remain the biggest source of false signal: ExitStateVeto
Patch was deleted from the working tree at 15:11 and the door was
disabled at 15:17. Both were uncommitted, so neither the veto nor any
boot data exists in the run-17 build.
The next boot is run 17's first real run. Expected markers: [ModuleHide]
x2, [HileGate] armed, [ExitInit] armed, a hile drop ~17s in, and [ExitInit]
stack traces naming the second exit trigger's caller.
## Run 17's first real boot (16:10): the exit block broke the room load
The door restore worked and the boot reached the Dorm, but the player saw a
BLACK SCREEN: the debug overlay showed `Room: 'DormRoom: Home', RoomId 2,
SubroomId 2883, Players 1 (2 TestPlayer, Loading)`, FPS 910 (nothing being
drawn). Run 17's own markers were all present: [ExitInit] armed, ONE blocked
initiator call, and four [HileGate] drops.
The one block is the problem. It fired ~0.05s after `Chainloader startup
complete`, before any networking, and its caller trace is:
```
at System.Environment.get_StackTrace()
at ExitInitiatorTracerPatch.TraceAndBlock()
at DMD<CCKNJCHHBLL::BEFDPLAELIH>()
at (il2cpp -> managed) BEFDPLAELIH(Il2CppMethodInfo* )
```
Read that trace correctly: in an IL2CPP build ALL game code is native, so
"no managed caller above the trampoline" does NOT mean an outside native
component. It only means the caller is another il2cpp method - ordinary game
code. A boot-time call to an exit initiator is therefore most likely part of
normal startup, and blocking it is not free.
What followed, compared against the last good session (15:13, pre-run-17,
preserved as baseline-backup-20260924-151633/Player-before-baseline.log):
- 15:13: load of 25/32 runs 14.08s -> COMPLETE at 19.77s (FinishRoomLoad).
FetchDeserializationDataTask.Run('25.32') finishes in 1ms: DownloadAsset
Bundle, then DownloadAndParse of MKMOLMPLENB, JGEHMKKALNE and OBHEFDFCAIC
all return in 2-7ms, then the phases march through DATA_DOWNLOADED,
INIT_OBJECTMODEL ... COMPLETE.
- 16:10 (run 17): the SAME room, the SAME sequence - and
`START: DownloadAndParse('OBHEFDFCAIC)')` never returns. The load sits in
DOWNLOADING_DATA until the client is closed 64s later. Everything after the
load never happens: no `rooms/25/experience/player`, no
`keepsakes/rooms/25`, no `roomkeys?roomId=25`, and no
`GET /rooms/photon_access_token`, so the dorm's Photon join is never even
attempted and the save can never arrive. That is the black screen.
- Server side is exonerated: `GET /rooms/rooms/25?include=5421...` returned
byte-identical payloads (len=2174) in every session including 16:10, and
the server log shows no [RoomUtils] self-heal firing. The only client-side
delta between the 15:13 build and the 16:10 build is run 17's tracer (the
veto was already gone at 15:13). Four consecutive pre-run-17 boots
(14:53, 14:59, 15:07, 15:13) all completed the dorm load.
Run 18 change: `ExitInitiatorTracerPatch` still traces every call, but it now
ALLOWS the call (returns true) until `HileGate.DroppedCount > 0`, i.e. until
the scanner has actually filed a report, and only blocks from then on. The
exit that matters is the one the dropped report triggers; a call that arrives
before any report exists is startup traffic. HileGate grew the DroppedCount
counter (NameserverConnectionPatches.cs, shared into the August assembly), the
log line is now "[ExitInit] armed - ... traced; blocking switches on after the
first dropped hile report", and an allowed call logs
"[ExitInit] allowed managed exit initiator (no report filed yet)".
Deployed 16:16 (34304 b). Success markers for the next boot: an `[ExitInit]
allowed ...` line during startup instead of a block, the dorm load reaching
COMPLETE / FinishRoomLoad, a visible Dorm, then the first hile drop and - from
that point - `[ExitInit] blocked ...` traces.
## Run 18 gating result (16:17 boot): the hang is gone, the load still fails
The gate did what it was built to do:
- `[ExitInit] allowed managed exit initiator (no report filed yet)` at startup
(the boot-time BEFDPLAELIH call now passes through),
- four `[HileGate] dropped anti-cheat report POST` lines, and
- one `[ExitInit] blocked managed exit initiator ... at DMD<CCKNJCHHBLL::
FNOEHNDLHHG>()` after the drops - the gated block works.
The infinite stall is gone too: `DownloadAndParse('OBHEFDFCAIC)')` returned
after 22.7s (16:17:44.4479 -> 16:18:07.1543) instead of never, and
`OperationContext.FinishRoomLoad` followed at 39.1551.
But it returned a FAILURE, and the session died with it:
```
[16:18:07] [39.1509] AppExitState changed: Running => PreparingToExit
[16:18:07] [39.1566] [Error | RoomOperation] Error during operation
InitialRoomLoad-a820aca0-...: Error downloading save data!
Try again later or on a better connection.
[16:18:08] [40.2863] [Unity] [EAC] Tearing down and releasing runtime
[16:18:09] [41.6710] [Unity] PhotonHandler.OnDestroy
```
So the exit that ends these sessions is at least partly a CONSEQUENCE of the
failed room load - the documented DORM-BLOB-FIX chain - not purely the scanner.
Note also that the block was armed and STILL did not hold the process: a
`[ExitInit] blocked` line fired and the client left anyway, so the recurring
exit is not solely the managed initiators.
WHAT IS MISSING THIS TIME: `GET /rooms/photon_access_token`. Every pre-run-17
boot fetched it during the dorm load (14:59:39.570, 15:07:54.917,
15:13:41.243 and again at 15:13:52.159) and the good run shows
`[GetSubRoomDataSave()]: Total: [5397.72ms 2618frames]` - the subroom save
download succeeding after ~5.4s. Neither run-17-lineage boot (16:10, 16:17)
ever requested a Photon access token, so the Photon room join is never set up,
`GetSubRoomDataSave()` has nothing to resolve against, and the load fails with
"Error downloading save data!" instead - after a 22.7s timeout in 16:17, and
forever in 16:10.
Everything server-side is byte-identical across good and bad sessions:
`/matchmaking/player/connection-info` ~1180 b, `/matchmaking/matchmake/dorm`
572 b, `/rooms/rooms/25?include=5421...` 2174 b, `/cdn/room/` 22 b. The tracer
is the only client-side delta, so the tracer is the A/B variable.
Run 18a (deployed 16:19, 34304 b): the tracer registration is commented out
(the class stays in source with its findings recorded - a boot-time
BEFDPLAELIH call arriving from il2cpp with no managed frames, and the exit
leaving through FNOEHNDLHHG). Expected on the next boot: `[ExitInit] disabled
- tracer removed for the room-load A/B`, then `GET /rooms/photon_access_token`
in the client log and in the server log, `GetSubRoomDataSave()` resolving in
seconds, the phases running through to COMPLETE, and a visible Dorm. If all of
that happens, the tracer - or one of its three detours - is confirmed as the
room-load breaker and it comes back only one target at a time.
## Run 18a result and the REAL regression: the baseline experiment restored the
## unpatched metadata (root cause of the black screen and the forced exit)
Run 18a booted at 16:20 with `[ExitInit] disabled` (LogOutput.log line 33) and
the four `[HileGate] dropped` lines, and it failed EXACTLY like run 18 did:
no `GET /rooms/rooms/25/experience/player`, no `playerdata/me`, no
`keepsakes/rooms/25`, no `roomkeys?roomId=25`, no
`GET /rooms/photon_access_token`, then `Error downloading save data!`,
FailedToSpawnPlayer (Code 8715) and the forced exit. The exit tracer is
therefore NOT the room-load breaker - that hypothesis is dead, and the server
payloads were byte-identical the whole time.
What the tracer-off boot did NOT rule out - and what actually broke every boot
after 15:16 - is the rest of the parallel "baseline" experiment. It did not
only rename winhttp.dll away at 15:17:15; it also put back two files from the
pristine (pre-Radicade) Sep 23 14:21-14:23 state, using mtime-preserving
copies, so nothing looked "new" afterwards:
| file | good state (the 14:53-15:13 boots) | what every failing boot ran |
| --- | --- | --- |
| `RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat` | 38,654,794 b, md5 `cc9c32014b5fc3dd1a2a8fb12376be15` | 32,039,456 b, md5 `825f34f1dd046a0ae764cd5a97dba5e6` |
| `RecRoom_Data/boot.config` | 173 b, md5 `dc2e5f7af7d5df736a4cc3477b4b1a1f` | 104 b, md5 `5694f1046c94cb6b9bbcf6854ed8eaae` |
The 104 b boot.config is byte-identical to `RecRoom_Data/boot.config.orig`
(the patcher's own backup), which is the fingerprint of a restore, not of an
edit. Proof that the 38 MB metadata - and only it - carries the Radicade
rewrite:
- `grep -a -c 'localhost:3030'`: 38 MB file = 3, live 32 MB file = 0.
`127.0.0.1` = 9 vs 1, `rec.net` = 20 vs 4.
- `RecRoom_Data/radicade-patch.json` records `patchedAtUtc
2026-09-23T22:08:42Z` (15:08:42 local, `changedLiterals: 7`, serverUrl
`https://localhost:3030`), and that timestamp is exactly the mtime of the
38 MB file captured in the snapshot: the file the baseline experiment
replaced was the Radicade-patched one that had been live for all four good
boots.
- Both files keep the release's custom shuffled header (magic `d4370000...`),
so the patched metadata is still the native format the runtime expects. The
standard-header `global-metadata.interop.dat` (magic `af1bb1fa`) is only the
tooling copy and was never touched.
Mechanism: with the pristine metadata the client's hardcoded bootstrap
literals point at the official hosts again. The plugin's runtime redirects only
cover the nameserver host, the websocket host and TLS, so most traffic still
reaches the server through `GET https://ns.rec.net/ns` - which is why the boot
looks healthy, logs in, joins Photon and renders the Dorm - but the room-save
path is built from a literal that nothing rewrites at runtime, so
`GetSubRoomDataSave()` fails and the session is torn down by the room-load
failure handler.
Fixed: both files copied back from
`baseline-backup-20260924-151633/RecRoom_Data/` (`cp -p`, provenance kept).
Live again now: metadata md5 `cc9c3201...` (38,654,794 b, 3x localhost:3030),
boot.config 173 b (`vr-enabled=0`, `player-connection-mode=Listen`,
`player-connection-debug=0`). winhttp.dll had already been restored, a scan for
any other 2026-09-24 write outside BepInEx found nothing, and the only disabled
sidecars are `winhttp.dll.baseline-disabled` (dormant) plus the BepInEx
config/loader backups - so the baseline undo is now complete.
Caveat for future restores: the patcher's own input (38,654,604 b) no longer
exists anywhere, because the experiment also restored
`global-metadata.dat.orig` to the 32,039,456 b Sep 23 14:23 file. The canonical
restore sources are therefore the snapshot directory and
`global-metadata.interop.dat` - never `.orig`.
Deployed build for the next boot stays run 18a (34304 b, md5
`b5cdc49cf2da66e306ee20f4e60a515c`, tracer off), so the metadata fix is a clean
A/B. Success markers: `GET /rooms/rooms/25/experience/player` (404), then
`playerdata/me`, `keepsakes/rooms/25` and `roomkeys?roomId=25` in the server
log, `GET /rooms/photon_access_token`, `[GetSubRoomDataSave()]` resolving in
seconds, FinishRoomLoad COMPLETE and a visible Dorm. The gated
ExitInitiatorTracerPatch and a never-expiring exit veto come back only after
that, one target at a time, with the metadata state now treated as a
first-class tracked variable (md5 above) rather than an assumed constant.
## Run 19 (16:54 boot): the Dorm LOADS - the next killer is the Play menu's
## missing list routes
The metadata restore worked. The 16:54:57 boot completed the dorm load for the
first time since run 16:
```
16:55:10 phase DOWNLOADING_DATA, DownloadAndParse('OBHEFDFCAIC)') 16.2212 -> 16.2296 (8 ms, was 19-22 s or never)
16:55:11 GET /rooms/photon_access_token
16:55:13 [10976][19.4330] OperationContext.FinishRoomLoad(e4909cf1-...)
16:55:13 Moved to phase COMPLETE (SCENE_LOADED -> SETUP_ROOM_OBJECTS -> RUN_MIGRATIONS
-> DESERIALIZE_OBJECTS -> DESERIALIZE_CONNECTABLES -> COMPLETE, SpawnLocalPlayer at ~19.5s)
```
So `GetSubRoomDataSave()` was never the broken thing - the unpatched metadata
was. Player spawn works too (the only spawn-time error is a benign Toxmod
config warning).
### The new failure: the Play menu's discovery rows 404
At 27.7 s the client opened the room list and its rows failed:
```
16:55:22 GET /lists/algorithmiclists/HotList?type=1 -> 404
[Error] Error refreshing rooms from source Hot: An error occurred (RoomListModel)
16:56:05 GET /rooms/rooms/carousel/rising?skip=0&take=20 -> 404
[Error] Error refreshing rooms from source CarouselEndpoint: An error occurred
16:56:05 [22050][70.8179] Error refreshing rooms from source Hot: An error occurred
16:56:05 [22051][70.8330] AppExitState changed: Running => PreparingToExit
NullReferenceException at RRUI.Data.RoomListModel.DOPOJKBDDEL()
<- UnityEngine.Object.Instantiate <- RRUI.StaggeredPrefabSpawner.EPABHKKCNIF()
[EAC] Tearing down; Uploading Crash Report
```
A failed row is not a skipped row: it NREs the model's prefab spawner and ends
the session. Same death in the 15:13 reference run (`Error refreshing rooms
from source Hot` at 15:13:59, exit at 15:14:06), which is why that run ended
seconds after the player reached the RecCenter. The curated-lists 404/`[]`
note from run 10 was never the whole story - `lists/algorithmiclists/*` and
`rooms/rooms/carousel/*` were simply never implemented.
### Fix (server, 17:36)
- NEW `Server/Lists/AlgorithmicListsAPI.cs`:
`GET /lists/algorithmiclists/{list}?type=1` -> `{ Type, Entities: [ { Id, Context } ] }`
(`Id` a string, `Context` null), the shape and field order the reference
server answers with, because the client's obfuscated DTOs are read
positionally. `type` is echoed (defaulting to 1 = Rooms). Every row key
answers 200: `HotList` and the other ranking rows are ordered by
`RoomStats.VisitCount` desc, `new` by `PublishedAt` desc, both over public
active non-dorm rooms, 20 per row.
- `Server/RoomServer/RoomServerAPI.cs`: `GET rooms/carousel/{*source}` (next to
the other carousel feeds) -> bare array of RoomDetail, name/tag filtered when
the source names something, falling back to every public room so a row is
never empty. Shape follows `rooms/hot_rooms` (see
docs/2023-WATCH-HOME-FIX.md); if a row still renders empty, the envelope
(RoomSearchResult) is the thing to change.
- Not changed: `lists/curatedlists/bulk` still answers `[]`. Its shape is a
guess either way (the client logs "Exception: No such list" for the empty
array), so it stays as the documented, non-fatal behaviour until the fatal
rows are confirmed fixed - changing a DTO the positional codec reads without
a verified field order is how a non-fatal error becomes a fatal one.
Verified live over the plain-HTTP listener (server restarted 17:37, now
`server-console-run19.log`, `dotnet run --no-build` from `Server/`):
```
/lists/algorithmiclists/HotList?type=1 -> 200 {"Type":1,"Entities":[{"Id":"32",...},{"Id":"3"},...]}
/lists/algorithmiclists/new?type=1 -> 200 {"Type":1,"Entities":[{"Id":"35"},...]}
/rooms/rooms/carousel/rising?skip=0&take=20 -> 200 [ {RoomId:2,...}, ... ] (20289 b)
/lists/curatedlists/bulk?id=17859340 -> 200 [] (unchanged, see above)
```
Server build clean (0 errors, 389 pre-existing warnings). Client build unchanged
(run 18a, tracer still off), so the next boot A/Bs exactly one thing: the rows.
Expected markers: no "Error refreshing rooms from source Hot/CarouselEndpoint"
lines, no RoomListModel NullReferenceException, the Play menu carousels
populated with real rooms, and a session that survives the `hot` row refresh.
## Run 20 (17:43 boot): the row fix holds - the room-list killer is gone
The 17:43:25 boot ran against that rebuild, and every marker above landed:
- `GET /lists/algorithmiclists/HotList?type=1` was requested twice and answered
200 (1171 b). `Player.log` has **no** "Error refreshing rooms from source
Hot/CarouselEndpoint" and **no** `RRUI.Data.RoomListModel` NullReferenceException.
- The rows resolved into real rooms instead of failing: right after HotList the
client asked `rooms/rooms/bulk?id=2&id=4&id=7&...&id=19` and drew the carousels
(`rooms/hot?tag=rro` 200 / 27163 b, with RecRally/Crescendo/Park/DodgeballVR
images following).
- The Dorm was unaffected: `Moved to phase COMPLETE` at `[10504][20.13]`,
`OperationContext.FinishRoomLoad`, `SpawnLocalPlayer` total 10278 ms - the same
numbers as the 16:54 reference boot. The metadata precondition was re-checked
at boot: `global-metadata.dat` 38654794 b md5 `cc9c32014b5fc3dd1a2a8fb12376be15`,
`boot.config` 173 b md5 `dc2e5f7af7d5df736a4cc3477b4b1a1f`.
The session still ended, but *differently* from every previous failure: the log
stops on a clean quit path (`POST /matchmaking/player/logout` -> `PreparingToExit
=> ReadyForExit` -> EAC teardown -> `PhotonHandler.OnDestroy`), with **no
exception in the 3.7 s before it** - where the 16:54 run had the RoomListModel NRE
40 ms before `PreparingToExit`. A silent 3.7 s gap plus a graceful logout is the
shape of a hand quit, not of the crash path, so run 20 cannot be called a crash
without knowing what the screen did.
## Run 20 fix: an empty COMMUNITY carousel
`GET /rooms/rooms/hot?tag=community&skip=0&take=20` answered 200 with
**43 bytes** - no room in `Rooms` carries the tag `community` (the column holds
`rro,2|quest,0` style pairs), so the Play menu's COMMUNITY carousel was empty.
`GetHotRooms`'s default branch now falls back to the whole public non-dorm
catalogue when the tag matches nothing, the same way `rooms/carousel/{*source}`
does; it answers 27163 b / 25 rooms now.
Server rebuilt (0 errors, 389 pre-existing warnings) and restarted as
`server-console-run20.log`, PID 26088, both listeners up:
```
/rooms/rooms/hot?tag=community&skip=0&take=20 -> 200 27163 b 25 rooms
/rooms/rooms/hot?tag=rro&skip=0&take=20 -> 200 27163 b
/lists/algorithmiclists/HotList?type=1 -> 200 {"Type":1,"Entities":[...]}
/rooms/rooms/carousel/rising?skip=0&take=20 -> 200 20289 b
```
## Known non-fatal noise (do not chase these first)
All of these are present in both the 16:54 run and the 15:13 reference run, so
they are background, not regressions:
- `Failed to load Type "RecRoom.Analytics.Statsig.StatsigDebugOverrides"` -
`RuntimeInitializationHelper:OIDNKEGDPPF(Dictionary\`2)` -> `MBJKIOBDOAM()` ->
`SubsystemRegistration()`. It is **vanilla noise**, not a symptom: the
15:13 baseline boot - loader renamed away, pristine 32,039,456 b metadata,
i.e. no patch stack at all - logs the same line
(`artifacts/baseline-20260924/Player-first-baseline.log`, line 24), and so
does every working boot since, including the bug-report upload at
`Server/RadicadeServer-Config/BugReports/20260925-044419-Player.log`. The
type is a debug-only Statsig helper the retail build does not carry; the
subsystem registration logs the miss and continues. The August plugin never
mentions the string (`grep -a -c StatsigDebugOverrides
RadicadeAugustClient.dll` = 0). Do not chase it.
- `Malformed Response: '[]'` (2 in run 20, 5 in the 15:13 run) - `ODIGMKMGFCF:
expected:'Number Token', actual:'[', at offset:0`, i.e. the client deserialised a
bare array where it wanted an object. The two in run 20 land within ~1 s of
`api/inventions/v2/mine` and `api/inventions/v1/toptoday`, both of which answer
`[]` because the inventions tables are empty; the 15:13 run survived five of
them, so their shape is wrong but not fatal.
- `Could not enable featured rooms display... no images are available` -
`featuredrooms/current` deliberately 404s.
- 404s: `/rooms/rooms/25/experience/player`, `/rooms/rooms/25/experience`,
`/econ/econ/roomGiftDropShops/room/25`, `/data/commonQuery/userSegment`,
`/econ/api/influencerpartnerprogram/{influencer,myinfluencer}`.
- `Exception: No such list` / `Deserialization returned null` for
`lists/curatedlists/bulk` (documented above).
## Run 21 setup: the tracer is re-armed (it was never the room-load regression)
Run 20's exit was confirmed by the user: **it closed on its own, with the screen
looking fine** - no black screen, no error on screen, no crash dialog. That is a
silent self-exit through the graceful path, i.e. the same class of shutdown the
run-17 `ExitInitiatorTracerPatch` was built to name.
The run-18a reasoning that parked the tracer is now known to be wrong. Its two
boots (16:10, 16:17) never fetched `/rooms/photon_access_token` and died with
"Error downloading save data!", so the tracer was blamed - but those boots were
running the *pristine* `global-metadata.dat` + `boot.config` restored by the
baseline experiment at 15:16, which is exactly what starves
`DownloadAndParse('OBHEFDFCAIC')`. With the patched metadata back, run 20
completed the load in the usual 8ms and 10278ms and the tracer was off, so the
tracer is not implicated in the room-load regression.
Re-armed in `Plugin.cs` (`harmony.PatchAll(typeof(ExitInitiatorTracerPatch))`),
rebuilt (`dotnet build 2023Patch/RadicadeAugustClient/RadicadeAugustClient.csproj
-c Release`, 0 errors) and deployed to `BepInEx/plugins/RadicadeAugustClient.dll`
(34304 b, md5 `7fd2f6c698f9568ebc6019bd8ca01e2f`; the previous run-18a build is
kept as `RadicadeAugustClient.dll.before-run20-tracer`, md5
`b5cdc49cf2da66e306ee20f4e60a515c`). The old "disabled" log line is gone from the
binary, the arm message is present.
What it does: prefixes `CCKNJCHHBLL.BEFDPLAELIH` / `GIEEBIOOFML` / `FNOEHNDLHHG`
(the managed funnel every shutdown passes through). While
`HileGate.DroppedCount == 0` the call is allowed and logged (that is normal boot
traffic); once a report has been dropped the call is **blocked** and the full
managed caller stack is logged as `[ExitInit] blocked managed exit initiator`.
The gate dropped a report at 17:43:38 in run 20 and the exit fired at 17:43:59,
so a repeat boot will be armed well before the exit.
Expected in `BepInEx/LogOutput.log` on the next boot: one `[ExitInit] allowed
managed exit initiator` early (boot traffic, before the drop), then either an
`[ExitInit] blocked managed exit initiator` stack at ~37s - which names the
trigger and should also stop the shutdown - or **no** ExitInit line at all, which
would prove the trigger is native-only (TerminateProcess-class) rather than
managed. Either outcome is a result; the metadata/boot.config md5 pair above is
the precondition to re-check first.
## Run 21 (19:34 boot, tracer armed): the trigger is NATIVE, and the tail is managed
Both expected lines appeared, and they settle the model:
```
line 36 [ExitInit] allowed managed exit initiator -> DMD<CCKNJCHHBLL::BEFDPLAELIH>() -> (il2cpp -> managed)
line 183 [HileGate] dropped anti-cheat report POST
line 276 [ExitInit] blocked managed exit initiator -> DMD<CCKNJCHHBLL::FNOEHNDLHHG>() -> (il2cpp -> managed)
```
Two things follow from the traces. First, **no managed caller ever appears**: the
frame above each initiator is `(il2cpp -> managed)`, i.e. native il2cpp code
invoking the managed method directly, so whatever decides to leave is not managed
code and no managed veto can see it. Second, the blocked initiator fires at the
**very end** of the shutdown, after `PreparingToExit` (19:34:36.77) and after
`ReadyForExit` (19:34:38.47) - the `[ExitInit]` block sits within the last ~9 log
lines, next to the `logout` / `PUT playersettings` / final analytics flush. So
`FNOEHNDLHHG` is the *last* step of a shutdown, not its trigger: blocking it held
the process ~13s longer than the previous boot but the native side still finished.
What the boot looked like from the outside:
- Dorm + spawn fine again: `Moved to phase COMPLETE` `[14020][26.5664]`,
`SpawnLocalPlayer` 10629.92 ms.
- Play menu fine: `HotList?type=1` 200 (1171 b) then `rooms/bulk?id=2&id=4&id=7...`
(21460 b) with the carousel art.
- Only three 404s, all long-known: `rooms/25/experience/player`,
`econ/econ/roomGiftDropShops/room/25`, `data/commonQuery/userSegment`.
- The client stayed **interactive until the last second**:
`Applied KEYBOARD_MOUSE control scheme LEGACY` (27.1s, automatic), `ProUX`
(32.42s), `LEGACY` (41.98s) - i.e. the settings menu was being used - then
`POST /matchmaking/player/logout` at 19:34:36.676, 100ms before
`AppExitState Running => PreparingToExit`.
- **No OS-level crash**: Windows Application log has no `Application Error` /
`Application Hang` entry for RecRoom.exe at 19:34; the game's own
`Crash Dump Report: New: 0 Previously Sent: 0`. This is a clean exit, not a
crash, however it looks on screen.
So the open question is no longer *how* the exit happens (native, at the tail) but
*what decides it*, and the only always-present candidate left is the same one as
every prior run: the dropped anti-cheat report. The two runs with a known cause of
death now stand 37s (17:43, tracer off) and 50s (19:34, same shape) apart with no
other delta, so the next boot should be a **hands-off soak**: launch it, touch
nothing, and see whether it exits on its own at all.
The user confirmed it was **not** a quit: "I was only toggling settings, never
pressed Quit". Ruled out in the same pass: no `[NotifyHub] -> <connection>` send at
all in run 20 (the only hub lines are connect/register/disconnect, the last one at
the logout instant), so no server push ends the session; a 404 in the notify socket
is not it either; and the report's own response body is a dead end (`RADICADE_HILE_BODY`
tried `ok-true`/`ok-false`/`nocontent`/`servererror` back in run 13 - ok-false only
DEFERRED the exit).
## Run 22: the exit veto is back (rebuilt), on the decision point this time
The old `ExitStateVetoPatch` was the only mitigation that ever *absorbed* these
exits (16 blocks in runs 12/13/16) and it was deleted from the tree at 15:11 by a
parallel edit, never committed - the whole `2023Patch/RadicadeAugustClient/`
directory is untracked (`git status --porcelain` -> `??`), so there is no history to
recover it from. It has been rewritten against the decompilation instead of guesswork:
`.external/aug-decomp/acs/CCKNJCHHBLL.cs` gives
`enum CCFIKLBABNB { Running = 0, PreparingToExit = 1, ReadyForExit = 2 }`,
the private static setter `KALEFEDAHCC(CCFIKLBABNB)` (token 100667095, CallerCount
3) and its interop wrapper as the setter of property `CCKNJCHHBLL.GLNIKJNLCIB`
(getter `JABDIMNNBCC`, token 100667094). Every `AppExitState changed: Running =>
PreparingToExit` line in `Player.log` is that setter.
`ExitStateVetoPatch` now patches BOTH the wrapper setter and the private method
(resolved by name, whatever exists), allows only `Running`, and blocks every other
transition **with no expiry window** - run 17 showed the re-requests continue long
after the old 120s window closed and the process leaves the moment it opens. Blocks
log the first three, then every 25th. Escape hatch for a real quit:
`RADICADE_EXIT_VETO=0` before launch (with the veto armed, quitting from the menu is
refused too - end the process from Task Manager instead). Registration is wrapped in
try/catch so a veto that will not bind can never take the boot down with it.
**A trap worth remembering:** the first build of this patch was a silent no-op. The
log line was `LogInfo(Enabled ? "armed ..." : "disabled ...")` over a
`static readonly bool Enabled`, and string-searching the built dll showed the
true-branch literal was *absent* while the false-branch one was present - i.e. the
compiler had decided the condition. `Enabled` is now a property
(`=> !_disabled`) and the log line is built at runtime, so the dll contains
`[ExitVeto] patches applied; blockExitState enabled=` and the value can be read
straight off the next boot instead of being assumed. Verify patches by searching
the SHIPPED dll for their strings; do not trust the arm message alone.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 36352 b, md5
`4a672a6b0ba59f73ca785cb318a6b824` (previous builds kept as
`RadicadeAugustClient.dll.tracer-run21`, md5 `34304 b / 19:32`, and
`RadicadeAugustClient.dll.before-run20-tracer`, md5 `b5cdc49cf2da66e306ee20f4e60a515c`).
Build clean (0 errors). Server untouched and up (`server-console-run20.log`, PID
26088). Expected on the next boot: `[ExitVeto] ... enabled=True`, the early
`[ExitInit] allowed` line, the `[HileGate] dropped` line, and then - instead of the
silent shutdown at 37-50s - `[ExitVeto] blocked AppExitState -> PreparingToExit`
while the session keeps running. If the session instead dies with no `[ExitVeto]`
line, the veto never bound and the `[ExitVeto] failed to apply` error text will say
why.
## Run 23: the exit's chain is named, and the last wall is Unity's own quit callback
Two questions were open after run 22: what *starts* the shutdown, and whether any
frame of it is still reachable from managed code. The first is now answered from
evidence that was already on disk; the second is patched.
### The chain
1. The client's own scanner finds a planted module and files the anti-cheat report.
That report body was decoded back in run 9 and is still in the server logs:
`Hile report received: Type=Obcured Message=[0:winhttp.dll]` - the BepInEx
Doorstop proxy `winhttp.dll` in the game root, classified `Obscured` (hile enum
`MEHDFJHKLBK.JBLJIENBBHK`, Obscured = 0). The classification is **inherent**: run 9
logged the identical body at 22:16, hours before the run-14 PEB unlink (23:27) and
the run-15 PE-signature wipe (23:42) existed, so hiding never caused it and
un-hiding will not remove it.
2. It re-scans on a fixed schedule. `server-console.log` holds two reports at
`23:32:28.141` and `23:32:58.405` - **30.264s apart**. That schedule, not a boot
timer, is why the same build dies at 36.8s on one boot and 55.3s on the next: the
exit lands on the first tick after the boot completes.
3. The report is not the trigger. Dropping it at the HTTP layer (`HileGate`, run 16)
only defers the exit, and run 13 proved the response body is irrelevant. The
scanner's local verdict starts the shutdown; the report is a side effect.
4. Where it ends (run 22 `Player.log`):
`UnityEngine.Application:Internal_ApplicationWantsToQuit()` ->
`System.Func\`1[TResult]:Invoke()` -> `CCKNJCHHBLL:GBGMBMGDGGD(System.Boolean)` ->
`MGLDPDAEODB:EMDNIBPNEGH()` -> `PFDOLDLEIBA:CKIEGOCLGFD(Action\`1)`, and then
`[EAC] Tearing down and releasing runtime` three times in the last 3.3s.
**The 15:13 reference run self-exits too.** `Player-before-baseline.log` - the boot
that loaded the Dorm and was taken as good - ends with
`[15:14:05] AppExitState changed: Running => PreparingToExit` at 41.81s, followed by
the same EAC teardown. Its `Could not enable featured rooms display` warnings start at
23.1s and repeat (run 22: 23.2s, 30.2s, 37.2s, 44.2s, 51.2s - every 7.0s), so they are
periodic RRUI noise and not the precursor they looked like. Nothing in the recent
client work causes this exit; it is inherent to running this build with a Doorstop
module in the game root.
### Patched in this build
- `ExitSequenceTracePatch` - a `Priority.First` prefix on
`BestHTTP.HTTPManager.SendRequest(HTTPRequest)` (the same managed funnel
`NameserverConnectionPatches` logs every request in) that dumps
`Environment.StackTrace` the first four times the **shutdown's own requests** go out:
`/matchmaking/player/logout` and `/playersettings/playersettings`. The shutdown
sequence's first observable network act is the logout POST (server log run 22:
`19:45:38.785`, right after the notify-hub reconnect). Whoever calls it is the owner
of the shutdown, and the stack names them - no inference needed.
- `ExitStateVetoPatch` now also dumps the stack on its **first** block, which names the
caller of the `Running => PreparingToExit` transition (run 22 blocked three times with
no caller recorded).
- `UnityQuitVetoPatch` - the last wall. Prefixes **both** `CCKNJCHHBLL.GBGMBMGDGGD`
overloads (decompilation tokens 100667099 / 100667100; the stack trace prints the
`bool` one, so both are bound rather than guessing) and forces `__result = false`,
which is "the application does not want to quit" and makes Unity cancel the quit.
It **skips** the original instead of running it and rewriting the result: run 22's
trace shows the original *throwing* (`NullReferenceException` out of
`StatsigUnity.EventLogger.StopCoroutine`), and a throwing original returns before any
postfix can override the value Unity reads - a postfix-only veto would be silent
exactly when it matters. Escape hatch: `RADICADE_QUIT_VETO=0`, and `Enabled` is a
property (`=> !_disabled`) with a runtime-built log line, the run-22 folding trap
avoided.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 39424 b, md5
`71df28734d88fd3f9eca70f202e517bb`; the run-22 build is kept as
`RadicadeAugustClient.dll.before-run23-quitveto` (36352 b, md5
`4a672a6b0ba59f73ca785cb318a6b824`). Build clean (0 errors, 0 warnings).
Preconditions re-checked at deploy time: `global-metadata.dat` 38654794 b md5
`cc9c32014b5fc3dd1a2a8fb12376be15`, `boot.config` 173 b md5
`dc2e5f7af7d5df736a4cc3477b4b1a1f`.
### What the next boot has to say
- `[ExitTrace] shutdown-request stack dump armed; [QuitVeto] wantsToQuit veto enabled=True`
at load, then `[ExitVeto] ... enabled=True`, the early `[ExitInit] allowed`, and the
`[HileGate] dropped` line at the first 30s tick.
- At the exit: `[ExitTrace] shutdown request ...` with the stack that ordered it, and
`[QuitVeto] wantsToQuit answered false` when the quit is refused.
- **If the process is still alive minutes after that** the plant works and the remaining
problem is cosmetic (whatever state the half-run shutdown left the UI in). If it dies
anyway, the kill is outside Unity's quit path (`TerminateProcess`-class, or EAC's own
runtime) and the next lever is to remove the module the scanner objects to instead of
fighting the reaction - i.e. bootstrap BepInEx without a `winhttp.dll` proxy in the
game root, which means a loader-side injection (manual mapping or a launcher) rather
than Doorstop.
## Run 23b: neutralise the EAC runtime itself (ported from the supplied patcher)
`C:\RecRoom\Downloads\patch` is a decompiled copy of **`RecRoomil2cppPatcher`**
(WaeBoi69, loaded by Reloaded-II). Its `EacPatcher.cs` is the piece this project needed:
a proven recipe for neutralising EasyAntiCheat on Rec Room. Five hooks into
`EasyAntiCheat.Client.dll`:
| Method | Replacement |
| --- | --- |
| `Hydra.Runtime:IsActive` | `return true` |
| `Hydra.Runtime:Initialize` | no-op |
| `Hydra.NativeClient:Initialize` | no-op |
| `Hydra.NativeClient:IsStartedWithEasyAntiCheat` | `return true` |
| `Hydra.Runtime:get_Initialized` | `return true` |
The pattern is the point: the *initialisers* are stubbed while the *queries* lie and report
a healthy, active anti-cheat - which is what the game's own checks read.
Our build exposes the same surface. `BepInEx/interop/EasyAntiCheat.Client.dll` contains
`EasyAntiCheat.Client.Hydra`, `.Hydra.Runtime`, `.Hydra.NativeClient`, `.Hydra.NetProtect`
and exactly those members (`ilspycmd -o ... EasyAntiCheat.Client.dll`; output kept in
`.external/interop-decomp/EasyAntiCheat.Client/EasyAntiCheat.Client.decompiled.cs`).
Why this is the root-cause lever and not another wall: the report is `Obscured` +
`winhttp.dll`, i.e. a *native module scan*, and `NativeClient.Initialize` is where that
native client is brought up.
Deployed in our plugin (`RADICADE_EAC_NEUTRALIZE=0` disables; armed by default):
- `EacNativeClientStubPatch` - every static `Initialize` on `Hydra.NativeClient` is
skipped, so EAC's native client (home of the module scanner) never starts.
- `EacHealthQueryLiePatch` - `Hydra.NativeClient.IsStartedWithEasyAntiCheat`,
`Hydra.Runtime.IsActive` and `Hydra.Runtime.get_Initialized` answer `true`.
- `Hydra.Runtime.Initialize(...)` is deliberately **left running**: it does the game-facing
bookkeeping (event wiring `RecRoom.AntiCheat.EACManager` subscribes to via `JMAPOMLPFMI`
/ `HINJPEHPJGO` / `AOFHJPLDLBC`) and its native step is the stubbed
`NativeClient.Initialize`. Stubbing it too is stage B if this is not enough.
- Type discovery is name-based with a hard-reference fallback
(`typeof(EasyAntiCheat.Client.Hydra.Runtime).Assembly` in its own method, so the JIT only
resolves it if the soft lookups came up empty) - nothing else in the plugin touches that
assembly, so at Load time `AccessTools.TypeByName` can legitimately see nothing.
The game's EAC readiness door is already propped open (`AugustChallengePatches` fakes
`EACManager.GenerateChallengeResponse` and forces `EACManager.MGEAHKJIIMO` true), so stopping
the runtime does not leave one of the game's checks waiting on it.
**Success marker, and it is unambiguous:** the `[HileGate] dropped anti-cheat report POST`
line must no longer appear at the 30s tick. No report means the scanner never reached its
verdict, which means no shutdown. The run-23 exit walls stay armed as the backstop, and their
`[ExitTrace]` / `[QuitVeto]` / `[ExitVeto]` lines say whether they were needed.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 43520 b, md5
`434972bf54968f1287643d8be61de8f7` (the run-23 plain build is kept as
`RadicadeAugustClient.dll.before-run23b-eacstub`, 39424 b). Build clean (0 errors).
### Verification method correction
The run-22 "constant folding" conclusion came from a hand-rolled check - decode the shipped
dll as UTF-16 and `String.Contains` the literal - and that method is **not reliable**:
against this build it reports `EasyAntiCheat.Client.Hydra.NativeClient` and
`[EacStub] EasyAntiCheat Hydra runtime neutralisation enabled=` as MISSING, and the
decompiled IL shows both present. Do not use it. Use the decompiler on our own output:
```bash
ilspycmd -o .external/selfcheck 2023Patch/RadicadeAugustClient/bin/Release/net6.0/RadicadeAugustClient.dll
grep -c "EacStub" .external/selfcheck/*.cs
```
The run-22 lesson's *shape* is kept anyway (`Enabled` is a property over a
`static readonly _disabled`, log lines built at runtime) - it costs nothing, and it is the
only version that cannot be folded if the compiler ever does decide to.
### Run 23b outcome (20:44 boot): three negative results, each of them useful
Session: 20:44:21 -> 20:45:11 (50s). Boot was healthy and identical to the best
boots - `Moved to phase COMPLETE` at 20.24s, `SpawnLocalPlayer` 10568.89ms.
1. **EAC's managed runtime is not the scanner.** `[EacStub] ... enabled=True` and
`Hydra.Runtime.IsActive answered true` appear, but there is **no**
`skipped ... Hydra.NativeClient.Initialize` line anywhere in the boot: the game
never calls it, i.e. EAC's native client was never started in this setup - our
own `EACManager` patches had already seen to that. The `winhttp.dll` verdict
still arrived. **The scanner is RecRoom's own**, not Hydra's. Auena's recipe is
still worth having (it is what keeps a modded client's EAC surface quiet), but it
is not the fix for this.
2. **Unity's quit is not the kill.** `[QuitVeto] wantsToQuit answered false (block
#1)` fired and the game **kept running for ~18s afterwards** - it went on loading
images, PUTting settings and fetching avatar items - so the veto did its job on
the request it saw. But `Player.log` shows `Trying to send photon room updates to
server while application is quitting - this should not be happening` from 36.1s,
i.e. Unity was already in its quit state *before* the veto fired: the first quit
request took the native path (`Application.Quit()` from native code does not
consult `wantsToQuit`), and the request we blocked was a later one. The process
ended at 52.85s. Windows' Application log has no `Application Error`/`Hang` for
it, and this run's shutdown again begins with `[EAC] Tearing down and releasing
runtime` at 34.89s.
3. **The absence of `ExitVeto`/`ExitInit` lines is itself the result**: this run's
shutdown never touched the managed exit state machine *at all* (no
`AppExitState changed`, no blocked transition, no initiator call). So the veto
and the tracer are aimed at a path this build does not use when the anti-cheat
verdict starts the shutdown; the verdict path is native end to end.
The stack dumps did name the boundary honestly: `[ExitTrace]` shows
`DMD<BestHTTP.HTTPManager::SendRequest>` followed by
`(il2cpp -> managed) SendRequest(IntPtr, Il2CppMethodInfo*)` - the logout and the
settings PUT are issued by **native code calling into managed** BestHTTP, so no
managed caller exists to name. Same shape as run 21's finding for the initiators.
## Run 23c: capture the verdict, stop obscuring the module, trace `CheatManager`
Given the above, the only remaining fix is to stop the verdict instead of the
reaction. Three changes, all in one build, all observable in one launch:
1. **The run-14/15 module hiding is now OFF by default** (`RADICADE_MODULE_HIDE=1`
re-arms it). Runs 14/15 proved the scanner cannot be blinded - unlink and wipe
both applied mechanically and the report fired anyway - while "a loaded module
that the loader lists do not know about, with its MZ/PE headers zeroed" is the
most literal description of an *obscured* module there is, and `Obscured` is the
exact verdict the client files against `winhttp.dll`. It bought nothing and may
have been feeding the classifier; the plugin now logs what a Toolhelp module walk
- the same view a scanner gets - actually sees (`[ModuleList] ...`).
2. **`HileGate` is now a capture gate**, not a drop gate: `ShouldDrop` dumps the
report's whole wire form (`request.MethodType`, the URI and `request.RawData` as
UTF-8, with `GetEntityBody()` as the fallback) and then lets it through, so both
the client log and the server log carry what the client believes it found. The
server's `HileAPI` keeps only `Type` and `Message`; the body may carry more.
`RADICADE_HILE_GATE=drop` restores run 16.
3. **`CheatManagerTracePatch`** prefixes every declared method of `CheatManager`
and its nested types and logs name + elapsed ms (first two calls of each, hard
line cap). `CheatManager` (Assembly-CSharp, global namespace, a
`SingletonMonoBehaviour`) is the one managed class in this build that plainly
does local anti-cheat work - `OnTimeCheatDetected`,
`OnObscuredTypeCheatDetected`, `OnAdvancedMovementCheatDetected`,
`IPEMGBLAFIJ(string)`, `BFNMCBEDOLF(string)`, `CheckForDUIDMismatch`, and two
nested helpers carrying `appPath`/`filenames`. Nothing is skipped: observing only,
because the one detour this project must never take is the scanner's own funnel
(run 11). Whatever runs a few hundred ms before the `[HileGate] captured` line is
the verdict, and its name is the next patch target.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 48128 b, md5
`018b991ab951196a76e4ed7f7f3eac4b` (run-23b build kept as
`RadicadeAugustClient.dll.before-run23c-capture`, 43520 b). Build clean (0 errors),
verified by decompiling the built dll (`ilspycmd -o .external/selfcheck`), not by
string search. The run-23 exit walls stay armed as the backstop.
### Run 23c outcome (21:02 boot): the trace named the producer
The `CheatManager` trace paid for itself on its first run:
```
+15113ms CheatManager.OnApplicationFocus
+15366ms CheatManager.MJLAGDJLOBC
+15367ms AALEGIEIKEL.LLPNCADGDNI
+15368ms AALEGIEIKEL.LLPNCADGDNI
+15368ms [HileGate] anti-cheat report captured (full wire form)
```
`CheatManager.MJLAGDJLOBC()` - a private no-argument method on the anti-cheat
MonoBehaviour - runs 2ms before the report, with `AALEGIEIKEL.LLPNCADGDNI(string)`
(a helper whose class carries an `appPath` field) called twice on the way. A pass
over files in the game directory is exactly what would decide that a proxy DLL in
the game root is `Obscured`, which is the verdict `[0:winhttp.dll]` reports.
Two things the capture taught us about the report itself: `RawData` is **empty** and
the fields live in the entity body (36 bytes = `Type=Obcured&Message=[0:winhttp.dll]`,
matching run 9's decode exactly), and the body text was not printed by the first
version of the dumper - it printed the byte count only. Now it prints both.
Also confirmed: `[ModuleList] winhttp.dll is enumerated by a Toolhelp module walk:
C:\RecRoom\2023-08-29T06-53-33Z\WINHTTP.dll` - run 14's PEB unlink and
run 15's PE wipe really are off now, and the module is back in the list a scanner reads.
### What the exit walls actually buy, now measured on screen
The 21:02 boot lost its session to the same shutdown, but this time the process
survived (the Unity quit veto answered `false` and the game kept running). What the
player sees in that state is the run-12 prediction, reproduced on demand:
```
Error joining room instance RoomInstance - ID:20871 DORM_ROOM:25/32
NullReferenceException: Object reference not set to an instance of an object.
Attempting to go to dorm
Not logged in to matchmaking service
Not logged in to matchmaking! not letting me in no more
```
The server log agrees with the order: the report arrived for the first time in these
boots (`21:02:44.255 POST /api/PlayerReporting/v1/hile -> 200 body=true`, because the
gate is now in capture mode) and the client logged out 156ms later
(`21:02:44.411 POST /matchmaking/player/logout`). So the walls convert a silent exit
into a live-but-logged-out client: better for diagnosis, useless as a game.
Server-side note for future probing: `localhost:3030` speaks **TLS**. A plain-HTTP
probe answers `000` in ~5ms and looks like a hung server; `curl -sk
https://127.0.0.1:3030/...` answers `200` in ~11ms. The server was healthy throughout.
## Run 23d: suppress the scan instead of the reaction
`CheatScanSuppressPatch` skips `CheatManager.MJLAGDJLOBC()` (env `RADICADE_CHEAT_SCAN=0`
leaves it alone). Given the trace, that method is the producer of the verdict, so
skipping it means no verdict, no report and no shutdown - the only stopping point that
leaves a playable client, since the reaction is native end to end and the walls only
trade an exit for a zombie.
Supporting changes in the same build:
- `CheatTrace` per-method limit 2 -> 4, so if the skip is not enough, the third and
fourth calls of every candidate are still on the record and a miss stays diagnosable.
- `[HileGate] armed` now states which mode it is in (capture vs `RADICADE_HILE_GATE=drop`)
instead of always claiming the drop.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 50176 b, md5
`b96b1b8926a1ec172e2b84ea0a85d179` (run-23c build kept as
`RadicadeAugustClient.dll.before-run23d-scan`, 48128 b). Build clean, verified by
decompiling the built dll.
Expected on the next boot: the session stays logged in past the 15-40s window it has
never survived, with **no** `[HileGate] captured` line and a `[CheatScan] skipped
CheatManager.MJLAGDJLOBC() (#1)` where the verdict used to appear. If instead the
process dies exactly at the skip, that pass runs on a scanner thread and joins the
run-11 forbidden list (`KDJDKJNPELI.IJHOPKJFKBI`) - also a result, and the lever after
it is to remove the module the scan objects to rather than the scan itself.
## Run 24 (2026-09-24 21:0x) - the first real crash of the project, and how to name it
The skip worked. `[CheatScan] skipped CheatManager.MJLAGDJLOBC()` fired twice, `[HileGate]
captured` never appears, the server log has no `/api/PlayerReporting/v1/hile` POST for the
whole session, and there is no `matchmaking/player/logout` - the anti-cheat chain that ended
every previous run is genuinely gone. With it gone the client did something it had never done:
it **entered RecCenter**.
One second later it died. This time it is not a graceful shutdown:
```
Application Error 09-24 21:11:58
Faulting application: RecRoom.exe, 2020.3.33.23162
Faulting module: GameAssembly.dll
Exception code: 0xc0000005 (access violation)
Fault offset: 0x00000000029a79ec
```
`Application Hang` has nothing, and the previous day's 18 RecRoom faults are a *different*
signature ({coreclr.dll +0x1d1fdd}, {GameAssembly.dll +0x7567f0 / 0x80000003 breakpoint},
{unknown / 0xffffffffffffffff}); 09-24 had no RecRoom fault at all until this one. So this is
new code territory, reached only now that the room load is not being cut short at 15-40s.
### Reading the dump without a debugger
WER left `%LOCALAPPDATA%\CrashDumps\RecRoom.exe.8396.dmp` (61 MB). No cdb/windbg/dotnet-dump
is installed, so `tools/minidump_stack.py` reads the minidump directly: exception stream,
module list, thread context (amd64 Rsp/Rbp/Rip at 0x98/0xA0/0xF8) and the stack pages, then
prints every stack slot that points into a module as `module+0xrva`. `--map` resolves those
through the RVA table the client writes (below), so no debugger is needed at any point.
It reproduces the WER record exactly, and adds the two things WER does not say:
```
fault: ACCESS_VIOLATION (0xc0000005) at 0x7ffc16f679ec
read of address 0x93
in GameAssembly.dll+0x29a79ec
rsp 0x41fb78e6d0 rbp 0x24447df8250 rip 0x7ffc16f679ec
```
A read of **0x93** means a null object with a field at offset 0x93 was dereferenced, and
il2cpp left the check off - which is how pointer-arithmetic paths (`fixed`/`Unsafe`/native
arrays) fail, not ordinary managed field access (that raises NullReferenceException). The
stack around it (innermost first) is ~20 GameAssembly frames, with
`GameAssembly.dll+0x2881f73` the closest return address and `coreclr.dll+0x11f66a`
(BepInEx's .NET runtime) and `UnityPlayer.dll` frames behind them:
```
rsp+0x28 GameAssembly+0x2881f73 rsp+0x108 GameAssembly+0x11f2eae
rsp+0x98 GameAssembly+0x59adea8 rsp+0x1a8 GameAssembly+0x1201e67
rsp+0xa8 GameAssembly+0x7764a5 rsp+0x218 coreclr+0x11f66a
rsp+0xd8 GameAssembly+0x49dcd3f rsp+0x2d8 GameAssembly+0x1202175
```
### What the last requests were (the "last API was the heartbeat" question)
```
21:11:50.944 POST /matchmaking/player/heartbeat 200 14.5 ms len=631
21:11:53-57 POST /datacollection/data/event 200 x3
21:11:55.395 GET /clubs/club/home/me 200
21:11:55.433 GET /data/commonQuery/userSegment 404
21:11:55.648 GET /lists/algorithmiclists/HotList 200 1171 b
21:11:55.675 GET /rooms/rooms/bulk?id=2..19 200 21460 b
21:11:56.113 GET /rooms/rooms/3/interactionby/me 200 47 b
21:11:56.124 GET /rooms/rooms/3/experience 404
21:11:56.170 GET /images/RecCenter?width=1024 200 71351 b
21:11:57.213 POST /datacollection/data/heartbeat 200
<crash> 21:11:58, dump written 21:12, notify-hub SSE closes 21:12:04
```
The request just before the crash is the data-collection heartbeat, and the matchmaking
heartbeat 7 s earlier is the only call in the session with an unusual duration (14.5 ms vs
sub-millisecond everywhere else, 631 b response), so both are on the list. But the crash is
1 s after the RecCenter art fetch and is a null dereference inside the game's own compiled
code, so the room-3 load is the stronger reading. `rooms/rooms/3/experience` returning 404 is
not obviously the cause: the reference server has no such route either.
### The new instrument: rva -> method
An offset from a module base is worthless until it is a method name, and IL2CPP will hand the
name over: the MethodInfo that `il2cpp_class_get_methods` returns begins with the compiled
method's native entry point. `2023Patch/RadicadeAugustClient/CrashAddressMap.cs` walks
`il2cpp_domain_get()` -> assemblies -> images -> classes -> methods (all raw entry points are
public in `Il2CppInterop.Runtime.IL2CPP`), writes `rva<TAB>image<TAB>Class::Method` to
`BepInEx/radicade-addr-map.txt`, and logs the methods bracketing the target RVA. It is a
background thread started 5 s into the boot, it never blocks the login, and it does not need
the crash reproduced - the table is static. `RADICADE_ADDR_MAP=0` disables it and
`RADICADE_ADDR_TARGET=0x...` re-aims it at the next WER offset, so no code change is needed
for the next crash.
Deployed `BepInEx/plugins/RadicadeAugustClient.dll`, 55296 b, md5
`129e9b5ef7e55820f05d8440b9d5b900` (run-23d build kept as
`RadicadeAugustClient.dll.before-run24-addrmap`, 50176 b). Build clean (0 warnings), and the
shipped IL was verified by decompiling the built dll (`CrashAddressMap.Start()` is called from
`Load`, and the enumeration + `.txt` path are present).
Next: one boot, then `sort -k1 BepInEx/radicade-addr-map.txt` offline and resolve
`0x29a79ec` plus the stack RVAs above through it - which names the method that dereferenced
null, and the frames that led there.
## Run 25 (2026-09-24 21:27) - RecCenter loads, then a second, different crash
This is the run that made the room load irrelevant: **the room finished loading**. The
2023 build's own load profiler ends with `[Run Success Callbacks]`, `SpawnLocalPlayer`
total 9857 ms, `MoveToPhase(COMPLETE)`, and the server served everything in order
(`matchmaking/matchmake/room/3` -> instance 20876, room 3 metadata, photon token,
`reportjoinresult`, `verifyRole`). The anti-cheat chain stayed off (`CheatScan` skipped
`MJLAGDJLOBC` twice, no `[HileGate] captured`, no hile POST, no logout).
Then, ~10 s after the spawn, the client died again - at a **different address**:
```
Application Error 09-24 21:28 RecRoom.exe / GameAssembly.dll
Exception code 0xc0000005 Fault offset 0x0000000003dc0c57
```
`0x3dc0c57` is inside `Unity.Entities.ChunkDataUtility::GetComponentDataWithTypeRO`
(exact entry point match), and the faulting instruction is `mov rbx, [rdx+0x88]` with
`rdx = 0xFFFFFFFFFFFFFF77` - a read of address 0xFFFFFFFFFFFFFFFF. So the game handed an
ECS chunk accessor a garbage pointer, and il2cpp had no null check to catch it.
Compared with the 21:11 crash, that is two different fault sites (`0x29a79ec` is a 26-byte
unnamed function; read of address 0x93) but the same shape: a pointer-arithmetic dereference
inside the game's own compiled code, roughly 1-3 s after a periodic RRUI
"Could not enable featured rooms display" warning. That warning is not the trigger - it also
fires every ~7 s in the dorm and nothing happens. Likewise the 35 `RecCenter_Chair ... has an
RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component` errors are pre-existing
noise: they appear 35 times in both the crashing run and in the 21:02 run, dorm included.
### Tooling that made this readable
- `BepInEx/radicade-addr-map.txt` - 254 171 methods from 305 images, written in **141 ms**
(18.5 MB, benign: a background thread, 5 s into boot).
- `tools/pe_unwind.py` - parses `.pdata` RUNTIME_FUNCTIONs and UNWIND_INFO (PUSH_NONVOL,
ALLOC_SMALL/LARGE, SET_FPREG, SAVE_NONVOL(_FAR), PUSH_MACHFRAME, chained info) and replays
a prologue to recover the caller - a real x64 unwinder. Two bugs found and fixed while
writing it: `ALLOC_SMALL` carries its size in the op-info nibble (a zero operand advanced
RSP by 8 instead of 0x28, which produced one bogus frame), and a past-the-end lookup used
to reuse the previous function's unwind info and manufacture frames - a leaf function with
no entry is now unwound from `[rsp]` instead, as a debugger would.
- `tools/minidump_stack.py --unwind` - walks the dump's faulting thread; resolves the WER
offset against the dump ("matches the WER fault offset") before using either.
- `tools/resolve_rva.py` - function boundaries from `.pdata`, names from the boot-time map,
and it says `UNNAMED` when the nearest name is not the function's own entry point.
The verified chain for the 21:28 crash (innermost first, exact-name frames only):
```
Unity.Entities.ChunkDataUtility::GetComponentDataWithTypeRO <- crash
(unnamed generic-instantiation frames, no metadata entry point)
IHDIFFAKHOM::JEONIAELMNM
(unnamed)
EHEHGHCKFII::System.Collections.IEnumerable.GetEnumerator
Tool::CCFENLNCIOG -> Tool::LPAOCFPMJFD -> Tool::RpcOnReset
-> Tool::AuthorityResetToDefault -> ToolCleanup::BFFGEGKNGMM
RecRoom.Core.Updates.StaggeredUpdateManager::DGHHCHACHOM
-> (unnamed) -> UnityPlayer.dll main loop
```
Every frame's own `.pdata` entry was used (no leaf guessing except where flagged), and the
walk terminates in UnityPlayer's scheduler, so this is a call chain rather than a stack
picture.
### Run 25b: the ObjectModel A/B (server-side, reversible)
Both crashes are inside `Unity.Entities` code reached from a staggered update, and the dorm
has never crashed. `ObjectModel_EnableForRoom_2022_07_01` is exactly the split test that
gates Rec Room's ECS-backed object model for rooms, and this server shipped it as
`Allowed`, so the 2023 client turns that model on whenever it enters a room. It is now
`Disallowed`:
- `Server/RecNet/Config/SplitTestConfigs.cs` - `HardOverrides` (fresh-DB seed),
- `Server/DB/DBConfig.cs` - `PlaceholderConfig` (same value, same reason),
- the live `GameConfig` row `splitTestHardOverrides`, updated in place with
`REPLACE(...,'ObjectModel_EnableForRoom_2022_07_01=Allowed','...=Disallowed')`.
The third one is the only edit that actually reaches this client: `UpsertGameConfigValue`
and `EnsureSplitTestHardOverrides` only ever *add* what a row is missing, and
`RepairSplitTestConfigs` documents the stored row as admin-managed ("never clobber a value
that is already set"), so the DB row is the source of truth. Pinning the key in
`GameConfigAPI.Local2023Overrides` was tried first and reverted: it contradicts that design
*and* `RecNetShapeConformanceTests.GameConfigs_SuppressInvalid2023SplitTestConfigs`, which
asserts the DB row is filtered and passed through rather than replaced.
Verified through the endpoint the client actually calls:
```
GET /api/gameconfigs/v1/all -> ObjectModel_EnableForRoom_2022_07_01=Disallowed
```
Server restarted (PID 11884, 3030/3031), build clean, `SplitTest`/`GameConfigs_` tests 6/6.
Also eliminated while checking: room save data. Every subroom's `DataBlob` is empty - the
working dorm room 25 included - and `SubRoomHistory` is empty, so "RecCenter's data is
missing" is not a difference between a room that survives and one that does not. The client
never requests a room blob at all (`GET /cdn/room/` is only the CDN index, `{"status":"ok"}`,
22 b). The frames the map cannot name are generic instantiations: IL2CPP gives each one its
own code but they share a single metadata entry point, so `il2cpp_class_get_methods` never
sees them. Naming those needs the runtime instantiation table, not this map.
### Run 25c: crash 3's own fault header, and the prop-binding difference
Crash 3's `fault:` block had not been printed before (an earlier `sed` filter cut it off). It is
the same fault as crash 2, one instruction shape away:
```
fault: ACCESS_VIOLATION (0xc0000005) at 0x7ffc18cc728d
read of address 0xffffffffffffffff
in GameAssembly.dll+0x470728d (matches the WER fault offset)
rsp 0xc42b34ea20 rbp 0x261c24784a0
```
resolved (crash 3, exact names only):
```
Unity.Entities.EntityComponentStore::GetComponentArraySize region <- crash, +0x140 = UNNAMED
(unnamed generic instantiations)
IHDIFFAKHOM::JEONIAELMNM
(unnamed)
EHEHGHCKFII::System.Collections.IEnumerable.GetEnumerator
(unnamed) -> Tool::CCFENLNCIOG -> Tool::ResetToDefaultCollisionLayer
-> Tool::LPAOCFPMJFD -> Tool::RpcOnReset -> Tool::AuthorityResetToDefault
-> (unnamed) -> ToolCleanup::BFFGEGKNGMM -> ToolCleanup::CJGILKPKDOL
RecRoom.Core.Updates.StaggeredUpdateManager::DGHHCHACHOM
-> (unnamed) -> (unnamed) -> UnityPlayer.dll scheduler
```
A read at `0xffffffffffffffff` is an index that came out as `-1`: some `TypeIndex`/component
lookup in the tool's component set has no answer, the code indexes with it anyway, and the
base pointer lands one byte past the end of the address space.
**The `/similar` lead is dead.** The route does exist - `Server/RoomServer/V2_RoomsAPI.cs`
(`[Route("rooms/rooms/")]`, `GET {id:long}/similar`, our own local addition) - and it did
answer `200 len=10166` for room 3, followed 2.5 s later by
`Error refreshing rooms from source RoomSimilarity: An error occurred`. But run 24 (crash 2)
never requested `/similar` and never logged that error, and it died with the identical chain,
12 s after the same room reported COMPLETE. Two runs, same crash, one with the carousel and
one without: the carousel is a real but separate bug (our payload fails the client's
RoomSimilarity parse), not the cause of the AV.
**Timing is deterministic.** Room load COMPLETE -> crash:
| run | COMPLETE | crash | delta | props spawned | props -> crash |
|---|---|---|---|---|---|
| 24 (21:28) | 32.50 s | 21:28:33 | ~12 s | 28.41 s | ~16.6 s |
| 25 (21:44) | 100.50 s | 21:44:55 | ~13 s | 96.31 s | ~16.7 s |
So it is a timer that fires ~12 s after the room finishes loading, not something the player
does: in run 24 the client sat in the room on heartbeats, in run 25 the watch menu was open.
**The one thing that actually differs between the room that crashes and the room that does
not is prop binding.** RecCenter logs, once, at scene-setup time:
```
[RecCenter_Chair] / [Chair_White_Stationary] / [RecCenter_Couch] (N)
has an RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component
```
Exactly 35 in both crashing runs; **zero** anywhere in the dorm (which shares the same log).
Those objects exist in the Unity scene with a `RigidbodyEx` and are missing the ECS
companions the ObjectModel needs. `Tool::ResetToDefaultCollisionLayer` iterates exactly that
state - a tool's collision-layer component set - which is why it is the code that dies.
**Run 25b's A/B aimed at the wrong key.** `ObjectModel_EnableForRoom_2022_07_01` never
reaches the client's ObjectModel path - it does not appear in `Player.log` at all. The gate
the client actually reads is the GameConfig family, served from the DB and logged at boot:
```
GameConfig 'UGC.ObjectModel.Allowed' = True (DB row: UGC.ObjectModel.Allowed = true)
GameConfig 'UGC.ObjectModel.AllowedInRoomV1' = True
GameConfig 'UGC.ObjectModel.ScalabilityAllowed' = true, .R2Allowed = true, .DormAllowed = false
```
and the load log shows the path being taken, for the dorm as well as for RecCenter:
```
[DATA_DOWNLOADED] Fetched Deserialization Data...
START: UnloadSceneToEnableObjectModelTask
Checking for Object Model Enabled flag...
[Moving to phase INIT_OBJECTMODEL...] -> [MoveToPhase(INIT_OBJECTMODEL)]
```
`Disallowed` therefore proved nothing about ObjectModel: the model ran anyway. Note the
seeded `UGC.ObjectModel.*` values (incl. `DormAllowed = false`) are the reference's, so
ObjectModel-on-for-rooms is the authentic August-2023 configuration, not drift of ours.
**Where the room data should have come from.** The load's `[Downloading Data]` report for
RecCenter:
```
[AssetBundle_Download] 0.01ms | [SubRoom_Download] 0.01ms
[SuperRoom_Download] 0.00ms | [PlayerSave_Download] 4.00ms
```
`SubRoom_Download` is a no-op, consistent with every subroom `DataBlob` being empty. If the
prop -> ECS bindings normally arrive with the sub-room save, an empty save is precisely what
leaves scene props with a `RigidbodyEx` and no `EmbodiedObject`/`RbexEntity`/`TransformEntity`.
Open, in order of expected value:
1. Serve a real RecCenter sub-room save (or otherwise complete the prop bindings) and see
whether both the 35 bind errors and the AV disappear - this tests the cause directly.
2. A/B `UGC.ObjectModel.Allowed=false` (+ `AllowedInRoomV1`) in the DB row: cheap,
reversible, and it removes the whole ECS object path. Divergence from the reference, so
treat it as a probe, not a fix.
3. Stopgap only: make `Tool::ResetToDefaultCollisionLayer` (0x1201e32..0x1201e90) a no-op
from the client plugin. Removes the crash without fixing the corrupt entity, so the tool
simply never gets its collision layer restored.
4. Separate bug: fix our `/rooms/rooms/{id}/similar` payload shape so the RRUI carousel
stops failing with `Error refreshing rooms from source RoomSimilarity`.
## Run 27 (2026-09-24 22:22, tool guard deployed): no crash - and the join NRE named
Player.log 22:22:34 -> 22:36:23 (~832 s, no crash), `LogOutput.log` carries 9 `[ToolGuard]`
skips, all of them game-role nodes and shape containers (`[GameRoleNode]_INJHAOJMPFF_*`,
`[StandaloneShapeContainer](Clone)`) rather than the RecCenter chairs, and **zero**
`has an RigidbodyEx but no EmbodiedObject/RbexEntity/TransformEntity component` lines - every
run that dies has exactly 35. Three joins:
| time | target | result |
|---|---|---|
| 22:22:42 | dorm 25/32, instance 20886 | COMPLETE |
| 22:29:51 | room 54/57, instance 20887 | COMPLETE |
| 22:30:17 | dorm 25/32, instance 20888 | **FailedToSpawnPlayer (8718)** |
The third is the room bug the guard did not cover: the load walks every phase to
`SCENE_LOADED`, then dies inside `SETUP_ROOM_OBJECTS`.
```
[471.4358] Starting RunFor.All handler 729481d6-1503-47c8-b401-f98f736a13f2 (1 handlers)
[471.4486] [Error | RoomOperation] Error during operation InitialRoomLoad-a820aca0-...:
Object reference not set to an instance of an object.
[471.4830] Reporting result FailedToSpawnPlayer / (Code 8718) to RecNet Matchmaking ...
```
Stack (managed frame bottom of the throw):
```
EMKMMPEIGLD.FACCFIGFBIP(IEnumerable<T>)
EMKMMPEIGLD.HLLNBBPLCAH(Tuple<T1,T2,T3>)
KOGBHMMNAJA1.EIAKBDPCFOD(Action1) <- broadcaster
EMKMMPEIGLD.KOIPGJONEBE(LPPEEGCNOPD, CancellationToken)
IGOEMHGCPBC2+DGLCCMDLGNI.Invoke(request, ct)
AGCMEKDMOEG+PAIKPNCMAPO.<<InvokeMethodsInParallel>b__2>d.MoveNext()
AGCMEKDMOEG.FFAFPFAGKDH(Guid, List, IEJHLHGIEFP, LPPEEGCNOPD, CancellationToken)
<<LoadRoomLocal>b__0>d.MoveNext()
```
Two frames above the NRE:
```
[22:30:15] Operation SaveRoom sent. Waiting...
[22:30:15] Unobserved exception throw ... BDADBLBKLJN.OABJEHOGBIM(GKENAAINMOP)
[22:30:17] Error during operation RoomSave-6ad9514c-...: Object reference not set to an
instance of an object.
```
Both sites resolve by name without a crash dump: `grep -la EMKMMPEIGLD
BepInEx/interop/*.dll` answers **`RecRoom.Keepsakes.Runtime.dll`**, and `BDADBLBKLJN` is
`RecNet.Runtime.dll`. So the handler that faults a room load is the **keepsake runtime's**:
* It runs in `SETUP_ROOM_OBJECTS`, again in `RUN_MIGRATIONS`, again in
`POST_DESTROY_OLD_OBJECTS` - one `729481d6` per keepsake pass - and the identical NRE is
thrown on the loads that *survive* (22:22:49.5, 22:29:59.0) as an unobserved task exception.
Chronic, and fatal only when it lands inside the phase run.
* Its fetches line up exactly: `GET /api/keepsakes/rooms/25` + `categories` + `globalconfig` at
22:22:48.988, `GET /api/keepsakes/rooms/54` at 22:29:58.388. The failed load at 22:30 makes
**no keepsake request at all** - it dies before/inside that pass.
What we were serving, and what the reference serves (`apps/api/src/routes/gameplay.ts`):
| route | ours (run 27) | reference |
|---|---|---|
| `GET /api/keepsakes/rooms/{id}` | 200 `{FIOFPPHNMHM:[],EBKHAGMFFEK:[],OFNJMGKKKJP:[]}` (69 b) | **204, no body** - "the client treats a body here as data" |
| `GET /api/keepsakes/categories` | counted set with one fabricated keepsake (`IIILIMOMKDK`...) | `{Results:[],TotalResults:0}` |
| `GET /api/keepsakes/globalconfig` | `{NPCHGMEHAPA:true,NEFKKONMGJI:100,PJMJGIKKGJB:true}` | `{KeepsakeFeatureEnabled:true,KeepsakeRoomLimit:10,SocialXpBoostEnabled:false}` |
The reference's note on `categories` is the same failure mode one layer down: the client parses
it as an object and "an array fails it outright - expected:'{', actual:'[', at offset:0,
logged as 'Failed to get keepsake categories' - **which takes the keepsake load down with
it**". Fabricated entries with guessed obfuscated field names are that same class of input: a
keepsake the client resolves that exists nowhere.
**Fix (server, this run).** `Server/API/Keepsakes/KeepsakesAPI.cs` now answers exactly what the
reference does - 204 with no body for a room's keepsakes, an empty counted catalog, and the
client's own field names on the config. Tests pin all three shapes
(`StartupApiCompatibilityTests`: empty catalog, `NoContentResult`, config names).
**Fix (server, same session).** The save that preceded the failure also faulted, and the
session's only subroom save (`POST /rooms/rooms/54/subrooms/57/data`, 200, 5718 b) is the call
it followed. The reference is explicit that this response is the one envelope the client reads
differently:
> `value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
> `subRoomDataSave` is camelCase with a different field set from the PascalCase `CurrentSave`
> embedded in the room ... Don't unify the two projections.
We were serving the room's `CurrentSave` there - PascalCase, with none of `unityAsset`,
`unityAssetHash` or the echoed `dataBlobHash`. `RoomUtils.BuildSaveResponse` now serves the
documented projection (and `dataBlobHash` is read off the save body and echoed), while the
room's own `CurrentSave` is untouched. The other documented trap in this path is fixed too: a
subroom carries `CreatorAccountId`, null until its first save and the saver's id after - "the
client NREs on a null one" - which we did not send at all.
Tests: 560 pass, including `SaveAcceptsTheJsonEnvelopeThe2023ClientPosts` (now asserting the
camelCase save + a non-null subroom creator), `SaveResponseEchoesTheBlobHashItWasGiven`, and
`AnUnsavedSubRoomReportsNoCreator`.
Server relaunched (development environment, cwd `Server/`, `server-console-run28.log`), and the
new shapes verified live: `rooms/25 -> 204`, `categories -> {Results:[],TotalResults:0}`,
`globalconfig -> KeepsakeFeatureEnabled/RoomLimit 10/SocialXpBoost`.
**How to read the next run.** The join is fixed if `729481d6` completes on the dorm load and the
post-load keepsake pass stops throwing:
```bash
grep -c "Unobserved exception" Player.log
grep -n "729481d6" Player.log
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:3030/api/keepsakes/rooms/25 # 204
```
Still open: the missing SignalR `UnsubscribeFrom` the client calls on every transition
(`autoloc.job`, `HubException: Method does not exist`), and real keepsake storage - the emptied
catalog means no keepsake can be placed until it exists.
## Run 28 (2026-09-24 23:25, live): the watch page's missing buttons are two missing routes
The watch's "This Room" tab makes exactly three requests as it opens, and the middle one was the
only failure:
```
[23:27:18.014] GET /rooms/rooms/25/interactionby/me -> 200 len=47
[23:27:18.031] GET /rooms/rooms/25/experience -> 404 len=0
[23:27:18.097] GET /images/default.png?width=1024 -> 200 len=80001
```
The tab renders with SETTINGS alone - no LOAD, no SAVE ROOM, no ROOM STATS, no subroom (room
list) button. The client logs each failure as a crash-report line
(`OBHEMJEJNMK: HTTP Error 404` through `GFIBIELMKBM+<>c__27`1[TResponse].NOFENLOLAIH`), 10 of
them in the session against 12 `rooms/rooms/25/experience` 404s, and it keeps re-asking rather
than settling.
Not permissions: the room payload already reports the viewer's role
(`Roles: [{"AccountId":2,"Role":255,...}]`, 255 = Creator) and `CreatorAccountId: 2` - which
is also how the subroom `CreatorAccountId` added in run 27 shows up on the wire.
Neither `rooms/{id}/experience` nor `rooms/{id}/experience/player` existed on our server at all.
The reference's note on the player half is the cost of that: nothing tracks per-room progress, so
it serves an empty list - "**which the client reads as 'no progress in this room', where a 404
would stall the room load**" - and the settings half is a bare two-key object, `Enabled: false`
with `DailyLimit: 1000`, "the cap that WOULD apply ... the client reads both keys whatever
`Enabled` says". Both added to `V2_RoomsAPI` (`GetRoomExperience`, `GetRoomExperienceForPlayer`).
The other 404 on the same page is `econ/econ/roomGiftDropShops/room/{id}`, asked for on every
run (run 16 through run 28, 1-5 times each) and never once served. The reference answers an empty
list - "Empty stub so the client doesn't 404" - so `RoomGiftDropShopAPI` now does. It is the only
room-economy route this build asks for: `roomInventory`, `roomOffer`, `roomEconConfig` and
`ugcPurchasables` are never requested (checked across all six server logs), so no stubs for them
were added.
Tests: 563 pass, adding `RoomExperienceAnswersTheTwoKeysTheClientReads`,
`RoomExperienceForPlayerAnswersAnEmptyList` and `RoomGiftDropShopsAnswerAnEmptyList`. Server
relaunched (`server-console-run29.log`, cwd `Server/`), and every route verified live:
```
rooms/rooms/25/experience -> 200 {"Enabled":false,"DailyLimit":1000}
rooms/rooms/25/experience/player -> 200 []
econ/econ/roomGiftDropShops/room/25 -> 200 []
api/keepsakes/rooms/25 -> 204 (empty)
```
## Run 29 (2026-09-24 23:50, live): MakeItToMidnight imported, and what the create route dropped
`C:\RecRoom\Downloads\MakeItToMidnight` is a rec.net archive of the August 2023 RRO
MakeItToMidnight: `room_data.json` (the room, its one subroom "Home", and **100** revisions of that
subroom's save), `blobs/*.room` (137 MB, one scene blob per revision) and `image/*.png` (the
thumbnail). There is no Unity asset bundle in it and none is needed - a room's objects and prefabs
are shipped in the game build and a save only names them - so the subroom is imported with an empty
`UnityAssetId`.
Two things decide which revisions may be imported, and both belong to the client that has to open
them:
* `PersistenceVersion`. The revisions from this client's own build week (2023-08-21 through
08-28) are all **61** - one v63 on 08-21 aside - which is the schema the build writes; the 67
later ones climb 63, 64, 66, 68, 70, 72, 77, 87, 88, 103, 128 and 138 (the last from 2024-07) and
a newer save than the client can read is a room that opens broken. The taco importer's gate was
"38 or lower" because its archive was 2020-2022: the number is a property of the archive's era,
not a constant.
* `CreatedAt`, as the second net. **33 revisions pass** (2023-08-21T20:20 through 2023-08-28T21:00,
44 970 347 bytes).
The newest of those is the LIVE save and every other one is posted through the game's own save
route, so each lands in the subroom's history exactly as an ordinary save does - and each POST
pushes the revision it replaces into that history, which is what makes the whole list restorable.
The room is created on the OLDEST revision and walked forward, because whichever revision is posted
LAST is the one that stays live: creating it on the newest and then posting the older ones leaves
the room serving the second-newest save, which is what the first attempt did.
`tools/import_room_dump.mjs` runs it in resumable phases (`plan|upload|register|verify|redate`),
recording what it uploaded, created and posted in `artifacts/room-dump-import/<room>.json`. Blobs
keep the archive's own names (`5k1vmec9n5jf4dymy0zkjligj.room`), so a save in CDN storage stays
traceable to the dump it came from, and the `redate` phase re-stamps the revisions with the dates
the archive recorded - the save route stamps everything it records with the moment of the POST, so
the restore list would otherwise read "just now" 33 times and be useless to pick from.
Result: **room 55 / subroom 58**, owner 2, Public, `MaxPlayers` 5, `WarningMask` Scary (1), tagged
`import`, thumbnail `798fe98e05b944999ab8c76181f45367.png`, scene
`a75f7547-79eb-47c6-8986-6767abcb4f92` (Maker Room), and 33 saves carrying the archive's dates.
### What the import exposed in `admin/rooms/create/room`
The first create answered `Created as 55!` and the row was wrong: `Accessibility` 0 (Private),
`WarningMask` 0, `Tags` empty. The route reads its body through `JsonExtension.Get<T>`, which could
only read scalars:
* An **enum** is not a type `JsonValue.GetValue<T>()` knows, so it threw and the caller took its
fallback - every room created through this route came out Private with no warning mask, and
nothing in the response said a field had been dropped.
* A **collection** never got that far: `GetValue<object>()` on a JsonArray throws before the scalar
read, so the tag list fell back to empty. The taco importer's `Tags` never landed either.
* The bool path ("1" and "true" both mean true) was already there, but read its node through
`GetValue<object>()`, which hands back a `JsonElement` for a body parsed from JSON rather than an
`int` or a `string` - the check could not see either shape.
`Get<T>` now reads the node's own `JsonValueKind`, converts an enum from its number or its name,
deserializes a JsonArray/JsonObject whole, and keeps the bool spellings. Room 55's three fields were
corrected directly in SQL, because the running server could not be rebuilt mid-session: the live
client holds `Server/bin/Debug/net9.0/Server.dll`, so the verification build goes to
`.external/verify-build/bin/` (`-p:BaseOutputPath=...`) and the apphost exe is skipped entirely.
A second, separate bug in the same block: `loadScreen` is `NOT NULL` with a CLR default of `[]`, and
a payload without `LoadScreens` wrote **null** over that default - the insert failed with a
required-property error, a 500 for any import that did not happen to send an empty list.
Tests: **569 pass**, adding `AdminRoomCreateTests` - enum binding by number and by name, the bool
spellings, the tag list with its tag types, the defaults an absent field still gets (including
`loadScreen`), the subroom's scene and blob, and the Maker Room fallback for a scene the client does
not know.
### Verified in the client
The live session had already found the room by the time the import finished:
```
[23:57:23] GET /images/798fe98e05b944999ab8c76181f45367.png -> 200
[23:57:25] GET /rooms/rooms/55?include=5421&unityAssetTarget=0&unityAssetVersion=1 -> 200
[23:57:35] GET /cdn/room/5k1vmec9n5jf4dymy0zkjligj.room -> 200 len=1373747
Player joined ^MakeItToMidnight in scene "Maker Room" with playerCount 1
[RoomLoad.RoomManager] -- Initial Room Load '^MakeItToMidnight' (55.58) [instance 20907] --
[23:57:49] Moved to phase COMPLETE
```
The load walks every phase through `SETUP_ROOM_OBJECTS`, `RUN_MIGRATIONS` and
`DestroyAndRebuildObjects`, with **0** occurrences of the
`no EmbodiedObject/RbexEntity/TransformEntity component` prop-bind error that ran at 35 for the
RecCenter, and 0 `FailedToSpawnPlayer`. The only errors after the join are the room's own circuits
(`CircuitsV2Errors`, "Studio Object is invalid!") alongside the guard's 25 `ToolGuard` skips.
One note for the next restart: the fixed `Server.dll` is **not** in `Server/bin/Debug/net9.0/` yet,
because the running server holds it. A plain `dotnet build ServerTests/Server.Tests.csproj -c Debug`
once the client closes picks it up, and nothing about room 55 depends on it.
## Run 30 (2026-09-25 00:00, live): the portfolio "crash" is the anti-cheat's dorm drop
Clicking your own portfolio closed the game. It is not a crash: Windows recorded **no**
`RecRoom.exe` fault either side of it (the last one is 22:09:19, the pre-ToolGuard
`GameAssembly.dll+0x470728d`), and the process ran the game's own shutdown instead -
`[EAC] Tearing down and releasing runtime`, then `Trying to send photon room updates to server
while application is quitting`. What Player.log has at 00:01:05 is the answer:
```
[00:01:05] [Warning] [Default | Warning] Dropping to dormroom due to error [385120]
[00:01:06] [Log] [Unity] [EAC] Tearing down and releasing runtime
```
and `BepInEx/LogOutput.log` has the chain, one millisecond wide:
```
[CheatTrace] +244681ms CheatManager.OnObscuredTypeCheatDetected
[CheatTrace] +244682ms CheatManager.EFLCHFDDPJP
[HileGate] anti-cheat report captured (full wire form): body: Message=&Type=0
[Net] Post https://localhost:3030/api/PlayerReporting/v1/hile -> 200 body=true
[QuitVeto] wantsToQuit answered false (block #1) - this is the anti-cheat shutdown
```
`CheatManager.EFLCHFDDPJP(string reason = "Dropping to dormroom due to error [385120]")` is the
whole of the message, and that string is the PARAMETER'S DEFAULT VALUE - `385120` is not an error
code, it names nothing, which is why every drop in every session reports the same number. Its
caller is the obscured-type handler, and what the page does to trip it is in the server log: the
profile renders the caller's own custom avatar items, and
`GET /api/customAvatarItems/v2/fromCreator/2` is fetched 7 times in 10 ms immediately before the
detection (`00:01:03.921`-`00:01:05.699`).
The server side of that page is faithful and stays as it is: `/accounts/account/2/bio` 200,
`/api/customAvatarItems/v2/fromCreator/2` 200 with the reference's empty page,
`/accounts/accountprivacysettings/2` 200, `/rooms/rooms/ownedby/2` 200, and 404 with an empty
body on both influencer routes - which is the reference's own answer for "not an influencer",
not a gap.
So the verdict is removed at its source, exactly as the file scan was in run 23d, and the result
is already deployed (`BepInEx/plugins/RadicadeAugustClient.dll`, 62464 b, md5
`8718a45dd42d7cb72b3e46e04b744d91`, backup `.before-run30b-cheatdrop`):
* `CheatDetectionSuppressPatch` - skips all three verdict handlers
(`OnObscuredTypeCheatDetected`, `OnTimeCheatDetected`, `OnAdvancedMovementCheatDetected`), not
just the one that fired. Skipping only the obscured one would leave the other two live for
whatever a clock-skewed or laggy machine trips next. A handler that does not resolve is logged
and skipped rather than thrown on - the throw would take the whole patch block (exit veto
included) down with it - and the startup line prints `verdict handlers guarded=3` (or names the
unguarded one). `RADICADE_CHEAT_TYPE=0` restores the detections.
* `CheatDormDropGuardPatch` - holds the drop itself back, narrowly: only when the reason contains
`385120`, the anti-cheat's own default. Any other reason still drops the player, so a genuine
room failure behaves as before. `RADICADE_CHEAT_DROP=0` lets every drop run.
Both are registered in their own try/catch, so a resolution failure on a different build cannot
undo the exit veto, the exit tracer or the hile gate applied above them.
## Run 30, second half: `rooms/rooms/{id}/similar` answered a bare array
The "Similar Rooms" rail is a paged DTO in this client, and the route answered
`Array.Empty<object>()` - `Malformed Response: '[]'` in Player.log, one of 9 in the session
(and 5 in the one before). A room that did not exist was a 404 on top of that, which stalls the
screen the rail sits on rather than emptying one row. `GetSimilarRooms` now returns
`RoomSearchResult` (scored by shared tags, same creator as the fallback for untagged rooms,
visibility checked per candidate, unknown room = empty page) and pages `skip`/`take`.
`ServerTests/SimilarRoomsTests.cs` pins the envelope, the empty page, the paging and the
visibility rule. **573 tests pass.**
`/econ/econ/roomGiftDropShops/room/{id}` (`[]`), `/rooms/rooms/{id}/experience/player` (`[]`) and
`/econ/api/roomkeys/v1/room` (`[]`) were each checked against the reference while chasing the
remaining `Malformed Response: '[]'` lines: all three are documented there as empty lists, so the
chronic parse errors are not from them and are left alone for now - they are logged, not fatal.
### The restart, and the one trap in it
The server was rebuilt and relaunched for the `similar` fix (`server-console-run30.log`, cwd
`Server`, PID 33272). Launching the exe directly from `Server/` picks up **`appsettings.Production.json`**
- `Hosting environment: Production`, a different `JwtKey`, no HTTPS listener on 3030 and no Photon
app ids, which would break the client at the room join. The launch must carry the environment:
```
$env:ASPNETCORE_ENVIRONMENT='Development'
Start-Process ...\Server\bin\Debug\net9.0\Server.exe -WorkingDirectory ...\radicade-server\Server
```
and the log has to read `Now listening on: https://localhost:3030` **and**
`Now listening on: http://127.0.0.1:3031` **and** `Hosting environment: Development`. Live
after the restart:
```
rooms/rooms/55/similar -> 200 {"TotalResults":0,"Results":[]}
rooms/rooms/99999/similar -> 200 {"TotalResults":0,"Results":[]} (was 404)
rooms/rooms/ownedby/2 -> 200 len=5950
api/customAvatarItems/v2/fromCreator/2 -> 200 {"Results":[],"TotalResults":0}
```
(`Authorization: Bearer` needs a token signed with the Development `JwtKey`; `?access_token=` is
only honoured on notify-hub paths, which is why a copied client token reads as 401 here.)
## Run 31: the room's Unity asset bundles, uploaded and bound
The August build's `MakeItToMidnight` import had no `UnityAssetId` because the room dump
(`C:\RecRoom\Downloads\MakeItToMidnight`) carries no bundle - the importer's note is right, and
`tools/import_room_dump.mjs` says the same thing: the save only *names* the content and the bytes
live elsewhere. The bytes turned up separately, in `C:\RecRoom\Downloads\AssetBundles`: 87
`*.assetbundle` files plus one 546 KB `.room`, no manifest, no index.
### What the archive actually is
The files are named by their CDN blob name and carry the CDN's own timestamps, and those two facts
recover the whole structure. Group by upload time and each group is **one publish event**: Rec Room
uploads one bundle per target, seconds apart, and every bundle in a group carries the same
`container` path, which names the room it was built for.
```
2023-08-21 13:10 n=7 333.3 MB Assets/RoomSource/Assets/Rooms/MakeItToMidnight/MakeItToMidnight-Home.unity
2023-08-22 10:34 n=7 333.4 MB ...
2023-08-23 12:48 n=7 333.3 MB ...
2023-09-12 18:49 n=6 308.2 MB ...
```
Two rooms are in there (`MakeItToMidnight`, 12 batches, and `KOdByDawn`, 3), across Unity revisions
`2020.3.33f1` (the August 2023 build - the only one this client can load), `2021.3.29f1` and
`6000.0.27f1`. `SerializedFile.target_platform` separates the targets inside a batch, and the
texture formats confirm what the numbers mean: `9` and `13` carry ASTC (iOS and Android - twice, a
phone and a Quest build both report Android), while `5`, `31`, `43` and `44` carry BC/DXT
(`StandaloneWindows` and the consoles). Each publish costs ~333 MB across targets; `5` alone is
~85 MB.
The bundle is not optional decoration. The 08-23 Android bundle alone holds 8080 objects - 1490
GameObjects, 1366 meshes, 1255 MonoBehaviours, 291 textures, 171 AudioClips - and the names in it
(`Survivor 1`, `[PerkIcon]`, `PhotoBooth_Curtain*_LOD*`) are the names the room save builds its
object tree from. A subroom with an empty `UnityAssetId` loads the save against whatever the build
itself ships, which is exactly why the room opened as a Maker Room with its content missing.
### Which bundle belongs to the live save
The live revision is the newest v61 save, `5k1vmec9n5jf4dymy0zkjligj.room` (2023-08-28T21:00Z), and
the dump records the asset id it advertised: `32cafb11-c51b-485c-8f1f-73b73159feff`. The dump is the
only place that id appears, so the batch it names was pinned by time, and the pinning is exact once
the archive's clock is accounted for - its zip entry times are the CDN's **local** wall clock,
exactly 7 h behind the UTC `CreatedAt` the saves carry:
```
unityAssetId first saved publish batch delta
bd362d85-6cc5-4b6f-9d3b-33774e3938aa 2023-08-21T19:59:45 2023-08-21 13:10 +11 m
14c842e7-01f0-4b81-9d03-19f39b7393d0 2023-08-21T20:20:28 2023-08-21 13:10 -10 m
20e15a8f-faca-41dc-8025-dd7da5c3cd0c 2023-08-22T17:25:32 2023-08-22 10:34 +9 m
3fb814c1-271e-4e54-960e-d6298947baaa 2023-08-22T17:27:21 2023-08-22 10:34 +7 m
32cafb11-c51b-485c-8f1f-73b73159feff 2023-08-23T19:39:06 2023-08-23 12:48 +10 m
```
So `32cafb11` is the 2023-08-23 publish, and in that batch the desktop bundle is
`d7ftni8391e3nrdts2drs4qlb.assetbundle` - `StandaloneWindows`, `2020.3.33f1` (the client's own
revision), 85,232,592 bytes, md5 `31b4faa66491de6106ce748a457f9905`. Two saves share that id
because the later revisions (2023-08-24..28) changed the room without republishing content, so one
bundle serves them all - which is also why the earlier ids matter only for history restores.
### Uploaded and bound
The client resolves a bundle in two steps - `GET /rooms/unity_assets/{assetId}/{target}/{version}`
returns `{UnityAssetId, Target, Version, Filename}`, and the bytes come from `/data/{Filename}` -
and `UnityAssetsCDN.ResolveFilename` looks for a file named exactly the id (or the id plus
`.assetbundle`) in the CDN storage directory. So the file is stored under the id the save
advertises, and the room's subroom points at that id:
```
Server/RadicadeServer-Data/CDN/Storage/32cafb11-c51b-485c-8f1f-73b73159feff.assetbundle 85232592 b
Server/RadicadeServer-Data/CDN/Storage/32cafb11-c51b-485c-8f1f-73b73159feff hard link
UPDATE SubRooms SET UnityAssetId='32cafb11-c51b-485c-8f1f-73b73159feff' WHERE SubRoomId=58;
```
Both spellings are stored because the loader reaches for the bytes two ways: the metadata route
hands the client a `Filename` that it fetches from `/data/{Filename}`, and the CDN path for a
bundle is `/unity/{id}` - the id with no extension. `ResolveFilename` checks the bare id first, so
the extensionless name is what the metadata reports; the hard link costs no disk and covers the
route that asks by id.
Verified against the running server (cwd `Server`, PID 33272 - the storage directory the server
reads is `Server/RadicadeServer-Data/CDN/Storage`, not the one at the repo root):
```
GET /rooms/unity_assets/32cafb11-.../0/1 -> 200 {"UnityAssetId":"32cafb11-...","Target":0,
"Version":1,"Filename":"32cafb11-..."}
GET /data/32cafb11-... -> 200 len=85232592 Accept-Ranges: bytes
GET /data/32cafb11-... (Range) -> 206 Content-Range: bytes 0-15/85232592
GET /rooms/rooms/55?include=5421&unityAssetTarget=0&unityAssetVersion=1
-> CurrentSave.unityAssetId = 32cafb11-c51b-485c-8f1f-73b73159feff
```
The `unityAssetTarget=0` the client sends is RecNet's own target list, not the SerializedFile
platform id the bundle carries: the server answers the id with one file, so the target in the query
does not have to be matched to the target in the bundle.
### The tool, so the next room is one command
`tools/import_room_bundles.py` does the three steps above and is what produced the tables in this
section:
```
python tools/import_room_bundles.py inventory --source ".../AssetBundles" --zip ".../AssetBundles.zip"
python tools/import_room_bundles.py plan --source ... --zip ... --dump ".../MakeItToMidnight/room_data.json" \
--room MakeItToMidnight
python tools/import_room_bundles.py bind --asset-id 32cafb11-... --batch 2023-08-23 \
--source ... --zip ... --subroom 58 --apply
```
`inventory` probes every bundle (platform, room container, Unity revision, object count) and groups
by publish batch - 2m37s for all 87, written to `artifacts/asset-bundles.json`. `plan` pairs a room
dump's asset ids with batches using the clock alignment above, and flags a pairing an hour or more
out as `DISTANT` rather than pretending to know it. `bind` copies the batch's `StandaloneWindows`
bundle into CDN storage under the id, points the subroom at it, and then runs the client's own
two-step resolution against the live server to prove it answers. The dump's own file name is
deliberately not the stored name - the id is what the client asks for - so the inventory JSON is the
record of which real blob each id was bound to.
`KOdByDawn` is in the same archive with three batches and is bound the same way if it is ever
imported. The remaining older ids can be bound too, for history restores that keep their own
content: `14c842e7` and `bd362d85` are the 2023-08-21 batch
(`a4al16vq0235oom5aplev2omu.assetbundle`), and `20e15a8f` / `3fb814c1` are 2023-08-22
(`esnrn95jqftokte3hmy8m1ffr.assetbundle`).
## Run 32 - the beta tag on room 55, and a room that is developer-owned when the maker is a dev
### Room 55 does not carry `beta`
`Rooms.Tags` for room 55 is `import,0` - one entry, `import` at `TagType.General`. There is no
`beta` entry, so the room does not advertise itself as beta-enabled the way the imported Showdown
room does (`rro,2|games,0|beta,1`, `TagType.Auto`).
Two things worth keeping, because they explain the value rather than just reporting it:
- The importer wrote it. `tools/import_room_dump.mjs:228` stamps every imported room with a
hardcoded `[{ Tag: 'import', Type: 0 }]`, so whatever the real room carried was overwritten at
import time - this is a latent bug for every room imported that way, not a fact about
MakeItToMidnight.
- The archives cannot tell us what the original tags were. All 48 captured room JSONs in
`rooms.zip` (`more rr rooms/*/room_*.json`) carry **no room-level `Tags` field at all** - the
exporter drops it - and the `MakeItToMidnight/room_data.json` dump only has save-level
`Tags: []`, which is unrelated.
Nothing server-side branches on the room-level tag: `grep '"beta"' Server/**.cs` finds only the
comment in `Program.cs:564`, and the tag is pure pass-through (`FullRoom.CopyTo` copies `Tags`
straight into the room payload) with the client acting on it. Beta creative-tools *creation* is
already enabled globally by `EnableBetaCreativeToolsConfigs` (`UGC.AllowBetaInvertedTubeCreation`,
`UGC.AllowNonBetaInvertedTubeCreation`, `UGC.EnableInventionsInBetaRooms`, `UGC.Room2Enabled`), so
the missing tag is not what keeps beta content from loading. To match the real room anyway:
```sql
UPDATE Rooms SET Tags = 'import,0|beta,1' WHERE RoomId = 55;
```
Left unapplied - the user only asked whether it was there.
### `IsDeveloperOwned` was never in the room payload
The 2023 client's room parser has the field (it is in the August metadata dump, and in the captured
RecNet responses: `"IsDeveloperOwned": true` on a developer's room), but `FullRoom` did not declare
it at all, so **every** room on this server read as not-developer-owned and the client's Dev Rooms
row stayed empty however the rooms were made.
Where a room is made matters: there is no create-room route. A player creates a room by
`POST /rooms/rooms/{id}/clone` (`RoomServerAPI.CloneRoom` -> `RoomUtils.CloneRoom`), which is the
same method `GetOrCreateDorm` uses to make a dorm - so the flag has to come from the maker's roles
and has to exclude dorms.
What changed:
- `RoomData.IsDeveloperOwned` (bool, default false) - a column on `Rooms`, `bit NOT NULL DEFAULT 0`.
- `Room.IsDeveloperOwned` - declared on the response base, so it is emitted for room lists and room
details alike, as `IsDeveloperOwned` (Program.cs pins the serializer to no naming policy, the
same PascalCase the real API answers with).
- `RoomUtils.CloneRoom` stamps it: `!room.IsDorm && await ctx.IsDeveloper(newCreator)`.
- `RoomUtils.ConvertToFull` and `FullRoom.CopyTo` carry it to the payload.
- `AdminRoomAPI.CreateRoom` (the import route) honours an explicit `IsDeveloperOwned` in the body -
a captured RRO room is developer-owned on the real service even when it is recreated here under a
local owner - and otherwise derives it from the owner it is being created for.
- `EnsureRoomDeveloperOwnedColumn` on boot, same self-heal pattern as `UnityAssetId` and the
join-gift columns: EF maps the property onto every `Rooms` query, so the column has to exist
before any startup step reads a room.
Verified live against the restarted server (PID 38436, `server-console-run32.log`, Development,
3030 + 3031). The column is in place and the payload carries the field:
```
GET /rooms/rooms/55?include=5421... -> IsDeveloperOwned: false
GET /rooms/rooms/23 -> IsDeveloperOwned: false
GET /rooms/rooms/25 -> IsDorm: true, IsDeveloperOwned: false
```
Then both directions of the rule, through the client's own clone route, with tokens minted for the
two accounts on this server (2 `TestPlayer` = `developer,influencer`; 1 `Coach` = `influencer`):
```
POST /rooms/rooms/23/clone (name=DevOwnedProbe, account 2) -> RoomId 56, IsDeveloperOwned: true
POST /rooms/rooms/23/clone (name=PlainOwnedProbe, account 1) -> RoomId 57, IsDeveloperOwned: false
```
Both probe rooms were deleted again (`RoomRoles`, `RoomStats`, `SubRooms`, `Rooms`), leaving the 51
rooms that were there before. `dotnet test ServerTests/Server.Tests.csproj` -> **581 passed**
(573 before, +8 in `ServerTests/RoomDeveloperOwnedTests.cs`: a dev's clone, a plain player's clone,
a dev's dorm, the flag on the room that is read back, the explicit false in the payload, and the
three admin-import cases).
Every room that predates the column started false. The backfill was then applied, over the same
rule the create path uses (the maker's role, dorms excluded) - 5 rooms flagged, 46 left false:
```sql
UPDATE Rooms SET IsDeveloperOwned = 1
WHERE IsDorm = 0 AND CreatorAccountId IN (SELECT accountId FROM Profiles WHERE role LIKE '%developer%');
```
```
31 TestPlayers_MakerRoom_3528 52 studio-room-SdjyyctVdc 53 studio-room-NRyzI1_njb
54 TestPlayers_MakerRoom_1603 55 MakeItToMidnight (25 DormRoom - dorm, left false)
```
Room 55 is account 2's, so MakeItToMidnight became developer-owned along with the four the user
built - which is also what the real service says about that room. It is a data-only change, so the
running server picked it up without a restart; the detail route and the bulk list route the
client's own room lists read both answer it:
```
GET /rooms/rooms/55?include=5421... -> IsRRO: true, IsDeveloperOwned: true
GET /rooms/rooms/bulk?id=31..55 -> IsDeveloperOwned: true (x5)
GET /rooms/rooms/bulk?...&id=25 -> IsDorm: true, IsDeveloperOwned: false
```
## Run 33 - Showdown reimported, and the four-character bug that was skipping it
The request was that Showdown was "a little broken", and to delete and reimport it from a fresh
export at `C:\RecRoom\Downloads\Showdown`. The import ran, but the interesting part is *why*
the previous one had not: the boot importer was looking in a directory that does not exist here.
### What the export actually is
The folder is a RecNet export, not a captured room dump: `room.json` plus 22 per-subroom
directories, each carrying `room_data.json` and `Saves/<saveId>/<blobId>.room`. `Showdown.rar`
(46 MB, RAR5) holds the same tree. The export describes the *real* room 52815862 - `CreatorAccountId
1`, `Accessibility 1`, `IsRRO true`, `IsDeveloperOwned true`, `WarningMask 0`, `MaxPlayers 6`,
`CloningAllowed false`, `PersistenceVersion 150`, `CreatedAt 2022-03-03`, image
`65hs5cb56byqs05lu16kr3e8r.png`, and the description about Deputies and Bandits. 22 subrooms is
not 22 live subrooms: they are archive snapshots, all sharing the one scene
`a75f7547-79eb-47c6-8986-6767abcb4f92`, and they are named accordingly - `SD_Archive_001`,
`SD_Archive_082322`, `SD_Archive_11_29_2022_STABLE`, `SD_Archive_13_061824`, and so on. The single
subroom the server actually needs is the one every other room here has: one `Home` row pointing at
the scene and the save.
### The root cause: `room-imports` vs `RoomImports`
`EnsureShowdownRoom` looked for its files in `..\room-imports\Showdown` and `room-imports\Showdown`
- always lowercase. This checkout spells the directory `RoomImports`, and `Test-Path` answers
`False` for the lowercase spelling even though the tree is not case-sensitive as a whole. So the
lookup found nothing, the importer printed
`[Showdown] Skipped import - missing files ...` and returned on every single boot, and the room kept
whatever an older import had left behind. A silent skip is the worst kind of green: the log line is
there, but nothing above it looks like a failure.
The fix keeps the spelled-correctly path first and the old lowercase spellings after it (so a tree
where they do resolve, or a deployment that still ships that name, keeps working):
```csharp
var candidates = new[]
{
Path.GetFullPath(Path.Combine("..", "RoomImports", "Showdown")),
Path.GetFullPath(Path.Combine("..", "room-imports", "Showdown")),
Path.GetFullPath(Path.Combine("RoomImports", "Showdown")),
Path.GetFullPath(Path.Combine("room-imports", "Showdown")),
};
var sourceDir = candidates.FirstOrDefault(Directory.Exists) ?? candidates[0];
```
### Which save to install, and why the newest one is the wrong one
A room here is a bundle plus a save blob, and the two have to be the same revision or the client
loads a scene it has no data for. The bundle was already identified by hash:
`RoomImports/Showdown/Showdown_0_1.assetbundle` is 76,719,774 bytes, md5
`220891bc78b852df1c7ba8e1af0e9d1b`, and matches 24 metadata entries - among them asset
`58784cb3-204c-47e0-9cf3-23a76b94ae90` as **Unity_2020_3_33f1, Target 0, Version 1**. That asset
revision is the one the August client can load, and the save that goes with it is
`8u07n4iwzt4xbqkww7zun0jam.room` (2023-06-02, 1,222,161 bytes), the newest save whose revision is
still 2020.3.33f1.
The newest save in the export looks tempting and is a trap: the live `Showdown` subroom
(8785941127880372366) was last saved 2024-10-02 as `d39glazjteu2zztjuuulribr7.room` under asset
`d03c4c99-4814-412b-9594-f61851d32225`, and that asset exists only as 2021.3.29f1 / 6000.0.27f1 -
nothing a 2020.3.33f1 client can load. The previously installed blob was `e45vok0ppp89hda6n1xrm4z16.room`
(2022-10-18, the `SD_Archive_11_29_2022_STABLE` save), which is why the room was stale.
### The blob really is Showdown
Raw string overlap between the blob and the bundle is zero, so that proves nothing either way. But
the `.room` blob is protobuf, and walking its wire format for printable runs pulls out the room's
own content verbatim - `_SDLog_LocalPlayerSeesRedTeamValid`,
`_SDLog_LocalPlayerLooksForValidTeam`, `_Equipping gun index {0} to self`,
`_Not enough guns to give to self`, `_Tie Game!`, `Event Sender`/`Event Receiver`/`To Teams`. That is
Showdown's team-and-guns gameplay, and the `SD_` prefix on its own script log ids is the signature.
`artifacts/verify_showdown_blob.py` re-runs that walk against any blob.
### The swap: room 32 out, room 58 in
The old room was deleted (with its `RoomRoles`, `RoomStats`, `RoomInstances`, `RoomInteractions`,
`SubRoomHistory` and `SubRooms` rows - 51 rooms became 50), the era-matched save was put in place,
and the metadata was patched to match the export:
| field | old room 32 | new room 58 |
| --- | --- | --- |
| `Description` | `Showdown` | the Deputies/Bandits text |
| `ImageName` | `default.png` | `65hs5cb56byqs05lu16kr3e8r.png` |
| `MaxPlayers` | 16 | 6 |
| `CloningAllowed` | 1 | 0 |
| `IsDeveloperOwned` | backfilled true | true |
| `CreatedAt` | 2026-09-09 | 2022-03-03 |
Room 58's `Tags` were already `rro,2|games,0|beta,1` (the importer sets them) and its subroom 61 is
the one `Home` row: `UnitySceneId a75f7547-79eb-47c6-8986-6767abcb4f92`, `DataBlob Showdown.room`,
`UnityAssetId Showdown_0_1.assetbundle`, `MaxPlayers 6`, `Accessibility 1`. Nothing else in the
database still points at room 32 - every table that carries a `RoomId` was checked and all twelve
count zero rows for it.
### It survives a restart, which is the part that could have bitten back
The importer's `else` branch (room already exists) re-applies `IsRRO`, `Tags`, `subRoom.DataBlob`,
`subRoom.UnityAssetId` and `subRoom.UnitySceneId` on every boot, so those have to be checked against
what a restart would write. They are all already correct, and the scene is the subtle one:
`SceneLocations.MakerRoom` **is** `a75f7547-79eb-47c6-8986-6767abcb4f92`, the Showdown scene, so the
overwrite is a no-op. The fields that a restart would *not* touch - `MaxPlayers`, `Description`,
`ImageName`, `WarningMask`, `CloningAllowed`, `IsDeveloperOwned`, `CreatedAt` - are the ones the
patch above changed, so the importer's non-idempotent half does not undo any of it.
### Verified live (server PID 36096, run 33, Development)
```
GET /rooms/rooms/58 -> 200 Name=Showdown MaxPlayers=6 Accessibility=1
IsRRO=true IsDeveloperOwned=true IsDorm=false
CloningAllowed=false WarningMask=0
ImageName=65hs5cb56byqs05lu16kr3e8r.png
CreatedAt=2022-03-03T16:41:31
Tags=[rro,2][games,0][beta,1] SubRooms=1
GET /cdn/data/Showdown.room -> 200 1,222,161 bytes (the 2023-06-02 save)
GET /data/Showdown.room -> 200 1,222,161 bytes (range-capable alias)
GET /cdn/data/Showdown_0_1.assetbundle -> 206 range request, 100 bytes of 76,719,774
GET /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 200
{"UnityAssetId":"Showdown_0_1.assetbundle","Target":0,"Version":1,
"Filename":"Showdown_0_1.assetbundle"}
GET /images/img/65hs5cb56byqs05lu16kr3e8r.png -> 200 2,777,168 bytes
```
The room thumbnail needs no `Images` table row: room art is served straight from
`DBConfig.CDNImages` by name (`/images/img/{name}`), which is why the built-in rooms' `ImageName`
values are bare names like `Dodgeball`. Both HTTPS (3030, the port the client talks to) and HTTP
(3031) answer for the room and the asset metadata.
Backups kept: `RoomImports/Showdown/Showdown.room.before-run33` is the previous blob, and
`artifacts/showdown-room32-before-run33.txt` is the row-level snapshot of the deleted room.
## Handing the client patch to another machine
The August client patch is a **stack of six pieces**, not one DLL, and two of them are produced
by machine-local patching rather than shipped. A player who receives only
`BepInEx/plugins/RadicadeAugustClient.dll` gets a game that still talks to the official hosts, and
the only red line in his `Player.log` is the vanilla Statsig noise above - which is why that line is
the most common wrong lead. The reference install (`C:\RecRoom\2023-08-29T06-53-33Z`)
hashes as follows; anything that differs is the piece to replace:
| piece | expected |
| --- | --- |
| `GameAssembly.dll` | 167,390,208 b, md5 `11e14eb35671e2725bd0c78a6a91bb48` - the build identity; every address map and the metadata patch are tied to it |
| `Recroom_Data/il2cpp_data/Metadata/global-metadata.dat` | 38,654,794 b, md5 `cc9c32014b5fc3dd1a2a8fb12376be15`, 28 x `localhost:3030`, 50 x `rec.net` |
| `Recroom_Data/il2cpp_data/Metadata/global-metadata.interop.dat` | 38,654,794 b, md5 `4a1a28078f957af470f3b31951b32648` (normalized tooling copy) |
| `Recroom_Data/boot.config` | 173 b, md5 `dc2e5f7af7d5df736a4cc3477b4b1a1f` (`vr-enabled=0`, `player-connection-mode=Listen`) |
| `Recroom_Data/radicade-patch.json` | provenance stub: `serverUrl https://localhost:3030`, 7 changed literals, `gameVersion 20230825` |
| `winhttp.dll` | 26,112 b, md5 `b2a4d0cb04bf8f5a27ccab237ecc2586` (Doorstop 4.3.0 proxy) |
| `doorstop_config.ini`, `.doorstop_version` | `enabled = true`, target `BepInEx\core\BepInEx.Unity.IL2CPP.dll`, version 4.3.0 |
| `BepInEx/core/BepInEx.Unity.IL2CPP.dll` | 60,928 b, md5 `9430bb84349d52b753a181788a86c5e2` - patched to read `global-metadata.interop.dat`; the original is kept as `.before-august-interop` (75,776 b, md5 `b8b3e4da1dbf171f3d405102d3c9d2e2`) |
| `BepInEx/interop/` | 335 assemblies |
| `BepInEx/plugins/RadicadeAugustClient.dll` | 62,464 b, md5 `8718a45dd42d7cb72b3e46e04b744d91` |
| Radicade server | `https://localhost:3030` + `http://127.0.0.1:3031`, `Hosting environment: Development`, **on the player's own machine** |
The last row is the one no file copy can satisfy: the patched metadata rewrites the client's
bootstrap literals to `https://localhost:3030`, so the server has to run where the game runs (a
remote server needs a different metadata patch and a certificate the client accepts).
The three failure modes seen so far, each with its own fingerprint:
- **No loader.** `winhttp.dll` renamed to `winhttp.dll.baseline-disabled` (or `enabled = false`) -
`Player.log` dies at `Could not resolve host 'ns.rec.net'`, there is no `BepInEx/LogOutput.log`
for that boot. This is the 15:42 run-17 state.
- **Unpatched metadata.** `global-metadata.dat` is the 32,039,456 b / md5
`825f34f1dd046a0ae764cd5a97dba5e6` original. The plugin loads and the boot looks healthy (login,
Photon, Dorm), then the room-save path fails: `GetSubRoomDataSave()` never resolves, the client
prints `Error downloading save data!` / FailedToSpawnPlayer `Code 8715` and tears the session
down. This is the 2026-09-24 15:16 baseline-experiment state.
- **No local server.** Everything else is in place but nothing listens on 3030 - the client's
requests fail and the screen shows the launch-validation/login failure.
`tools/august-client-triage.ps1` reports all of the above in one pass (read-only; it also opens two
TCP sockets to 127.0.0.1:3030 and :3031 and writes `august-client-report.txt` next to itself):
```
powershell -ExecutionPolicy Bypass -File august-client-triage.ps1
powershell -ExecutionPolicy Bypass -File august-client-triage.ps1 -GameDir "D:\Games\2023-08-29T06-53-33Z"
```
On the reference install with the server stopped it reports exactly one `[MISSING]` (port 3030) and
one `[WARN]` (port 3031); with the server up, none. Every `[MISSING]` line names a piece of the
table above that did not arrive, and the two log tails at the end of the report carry the rest.
`tools/make-august-client-pack.ps1` assembles the handover itself: it verifies every piece against
the table (fail-closed, so it cannot ship an unpatched metadata or a foreign build), copies the
loader pieces, the patched data files and the whole `BepInEx` tree - minus logs, previous-build
sidecars and the 18 MB `radicade-addr-map.txt` diagnostic - into
`<OutDir>\RadicadeAugustClient`, then writes `README-FRIEND.txt` and drops the triage script in
alongside. The reference bundle is 302 MB in 448 files, of which `BepInEx/interop` (335 assemblies)
is the bulk:
```
powershell -ExecutionPolicy Bypass -File make-august-client-pack.ps1 -OutDir "D:\radicade-pack" -Zip
```
For the September build (20230831, folder `2023-09-06T06-53-47Z`) the same pair exists as
`tools/september-client-triage.ps1` and `tools/make-september-client-pack.ps1`, carrying that
build's reference hashes (332 interop assemblies, 294.8 MB bundle) and one extra check the August
scripts do not need: the metadata header magic, because a stock BepInEx core fails on this game's
scrambled `global-metadata.dat` with `Invalid or corrupt metadata (magic number check failed)`.
See `docs/2023-0831-CLIENT-SWITCH.md`.
## Rec Room Studio on Radicade
`C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate` is the Rec Room Studio template, and
it lines up with the client we already patch: Unity **2020.3.33f1** (the August build's engine) and
packages built from `features/rec-room-studio/releases/20230831-211740` (git `0c23610a`, recorded in
`RecRoomStudioPackages/VersionInfo.json`). Those packages carry the entire game runtime
(`com.recroom.studio`, `.studio.build`, `.studio.api`, `networking`, `circuitsv2`, `objectmodel`, ...),
which is what lets the studio run the real game code inside the editor.
### Everything routes through one string
A sweep of every `com.recroom.*` assembly for both ASCII **and** UTF-16 string heaps (plain `grep`
finds nothing - .NET literals live in the `#US` heap as UTF-16) turns up exactly one baked-in
service host:
| Where | Literal |
| --- | --- |
| `Library/PackageCache/com.recroom.studio.build@*/Editor/RecRoom.Studio.Editor.Core.dll` | `https://deluxe-backend-test.oldrecroom.com/nameserver` |
| `.../com.recroom.studio/.../UI/RecRoomStudioWindow/SignInPanel.uxml` | `https://auth.rec.net/device` (label text only) |
| `.../RecRoom.Studio.Editor.Core.dll` | `https://events.statsigapi.net/v1/` (telemetry) |
No config file, environment variable or command-line switch redirects it: the editor `GET`s that
nameserver URL and derives *every* other base (`Auth`, `API`, `CDN`, `Images`, `Studio`, ...) from the
JSON, so aiming the whole editor at a private server is a one-string patch. The `-environment`
argument belongs to the batchmode pipeline (`-roomId`, `-outputPath`, `-rebuildMode`,
`-RECNET_CLIENTSECRET`, ...), not to the window.
### Sign-in is an RFC 8628 device flow
`RecRoom.Studio.Editor.Core.dll` carries `connect/deviceauthorization` and `connect/studiotoken`
under the nameserver's `Auth` base, together with the matching model: `device_code`, `user_code`,
`verification_uri`, `verification_uri_complete`, `expires_in`, `interval`, `scope`, `access_token`,
`refresh_token`, `authorization_pending`, `tokenError`, and the grant URN
`urn:ietf:params:oauth:grant-type:device_code`. Nothing in the studio window works before that
succeeds - there is no offline path, and `"Play Local"` (which launches the game client with
`-RecRoomStudioPort:N` and drives it over `http://localhost:{port}/recroomstudio/checkavailability`
and `/loadroom`) stays disabled until a room is open, which itself comes from RecNet.
### What the server now provides
`Server/Auth/StudioAuthAPI.cs` implements both endpoints off the nameserver's `Auth` base
(`{ServerUrl}/auth/connect/...`). Real RecNet has the user approve the code at `auth.rec.net/device`;
a private server has no such page, so the code is auto-approved and the tokens are minted by
`AuthAPI.GenerateToken` - the same claims (`userId`, `tver`, roles, `rn.ver`) the game client gets,
which is what `[Authorize]` and token revocation actually check. The flow is refused for non-loopback
callers unless `Radicade:Studio:AllowRemoteDeviceAuth` is true, and the account is
`Radicade:Studio:AutoApproveAccountId`, else whichever account the caller names, else the lowest
account holding the `developer` role. Requests are read as form **or** JSON and logged in full (bar
secrets), because the exact wire shape is not documented anywhere - the first real sign-in attempt is
what will tell us.
Verified live against the dev server:
```
POST /auth/connect/deviceauthorization -> device_code, user_code 8JXV-8W4D, expires_in 900, interval 2
POST /auth/connect/studiotoken (device_code) -> access_token (JWT roles gameClient+developer+influencer, userId 2, tver 0)
GET /auth/account/me/haspassword + bearer -> true
POST /auth/connect/studiotoken replying the device_code -> invalid_grant (single use)
```
### The editor-side patch
`tools/patch-rrs-nameserver.ps1` rewrites that one literal in place, as a `.NET` string-heap edit: the
entry keeps its 108-byte footprint (1-byte compressed length + 106 bytes of UTF-16 + terminator), the
freed tail is zero-filled, and because the heap is addressed by offset and the footprint is unchanged
no other string moves. One-time backup to `RecRoom.Studio.Editor.Core.dll.radicade-orig`, idempotent,
and after writing it re-decodes the entry and asserts that **nothing outside the slot changed**
(`-Restore` puts the original back).
```powershell
powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1 # -> https://localhost:3030/nameserver
powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1 -Url http://127.0.0.1:3031/nameserver
powershell -ExecutionPolicy Bypass -File tools/patch-rrs-nameserver.ps1 -Restore
```
### Two blockers before any of this can be exercised
1. **The project tree is write-protected.** Creating a file anywhere under
`C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate` fails (`ERROR_FILE_NOT_FOUND` for a
new file, `Access denied` when overwriting an existing one, and a hang via `cmd`) from Python,
PowerShell and .NET, while reads work and writes elsewhere on the same volume succeed -
`OneDrive\` itself is writable, `OneDrive\Documents` is not, and no `OneDrive` process is running.
Defender's Controlled Folder Access is enabled (`EnableControlledFolderAccess = 1`) and its
implicit protected "Documents" resolves to the OneDrive one. Unity cannot open a project it cannot
write `Library/` and `Temp/` into, so this has to be settled first.
2. **TLS.** The editor validates certificates normally - the game client does not, because our plugin
patches that. The certificate Kestrel serves on 3030 (`localhost-rsa-aes-devcert.pfx`, thumbprint
`D2275346BC059E9B62466F116F146F63CB89D5C4`, notAfter 2031-09-05) sits in `CurrentUser\My`, **not** in
`CurrentUser\Root`, so the editor's `https://localhost:3030/...` calls will be rejected until that
cert is trusted (or the editor is aimed at the plain-HTTP listener on `http://127.0.0.1:3031`, in
which case `Radicade:ServerUrl` has to match too, since the nameserver response is where every other
base URL comes from).
After that the remaining surface is the studio's own room data: the window lists rooms and saves from
RecNet (`RoomTargetPanel`, `SaveListItem`) and uploads with
`git -c http.extraheader="AUTHORIZATION: bearer <token>"` to a RecNet-hosted remote - none of which
exists here yet (`Server/API/Studio` is an empty placeholder directory).
### The compile wall was Defender, not the packages (2026-09-26)
The writable copy opened in Unity 2020.3.33f1 and immediately refused to compile anything:
```
Asset Packages/com.recroom.studio.build/Editor/RecRoom.Studio.Editor.Core.dll.radicade-orig has no
meta file, but it's in an immutable folder. The asset will be ignored.
NullReferenceException ... UnityEditor.Utils.Program.GetStandardOutput() (twice)
Assembly 'Packages/com.recroom.creation/Runtime/RecRoom.Creation.Runtime.dll' will not be loaded
due to errors: Unable to resolve reference 'Unity.Mathematics'
... the same for ~15 com.recroom.* assemblies, plus 'Unity.Collections', 'Unity.Settings.Editor',
'Unity.RenderPipelines.Core.Editor', 'RecRoom.Studio.Common.Runtime'
```
with `Library/ScriptAssemblies` holding only `BuiltinAssemblies.stamp`. The first guess - that the
manifest's `file:../RecRoomStudioPackages/*.tgz` sibling folder had been left behind by the robocopy -
was **wrong**, and worth recording so it is not chased again: Unity resolves a `file:` path in
`Packages/manifest.json` relative to the **Packages folder**, so `../RecRoomStudioPackages` is
`<project>\RecRoomStudioPackages`. That folder is inside `ProjectTemplate` and always was, holding all
55 tarballs plus `VersionInfo.json`. An integrity scan of the whole set (tar entry list vs extracted
files, for every tarball) found **zero** missing files.
The actual failure is one line of `Editor.log`:
```
Starting external compile of Unity.Jobs.CodeGen.dll:
...\Library\PackageCache\com.unity.roslyn@0.2.2-preview\Compiler~\win64\csc.exe /noconfig @Temp/... /shared
Exception when running external compiler on Unity.Jobs.CodeGen.dll:
System.ComponentModel.Win32Exception (0x80004005): ApplicationName='...\win64\csc.exe', ...
Native error= The system cannot find the file specified.
```
Unity 2020.3 compiles C# with an external compiler, and because this project installs
`com.unity.roslyn` (a transitive dependency of `com.unity.entities@0.51.1-preview.21`) that compiler
is the package's own Roslyn 3.8. **`csc.exe` was gone** - from `Library\PackageCache` *and* from the
shared `%LOCALAPPDATA%\Unity\cache\packages` copy - so the first compile died, nothing was written to
`ScriptAssemblies`, and every later reference resolution failed against an empty assembly set. The wall
of "Unable to resolve reference" is a symptom of "the compiler never ran".
It was not shipped broken: the registry tarball
(`https://cdn.packages.unity.com/tarballs/com.unity.roslyn/com.unity.roslyn-0.2.2-preview/8b5b337db92cbdec37b54235ec8b826c92c6ddef.tgz`)
contains `package/Compiler~/win64/csc.exe` (md5 `8c4361cc37113f8467feb1d22b4e83da`), as it does for
`mac/csc` and `linux/csc`. Something deleted it locally.
That something is **Windows Defender's PUA protection**, which has been quarantining unsigned
executables out of Unity package folders on this machine. `Get-MpThreatDetection` lists
`com.unity.burst@1.8.4\.Runtime\hostwin\llvm-lipo.exe` at 2026-09-26 08:35 (ThreatID 2147972435),
the older `dsymutil.exe` in burst 1.8.12/1.8.17/1.8.21 in the shared cache, and the same
`llvm-lipo.exe` in the OneDrive tree at 00:34 - `com.unity.burst\.Runtime\hostwin` now contains only
`vswhere.exe`. Burst's `bcl.exe` survived and the lost tools only matter for Apple targets, so the
one casualty that blocks everything is the script compiler.
**Repair:** `tools/repair-unity-script-compiler.ps1` restores `Compiler~\win64\csc.exe` and proves it
works by running the compiler's own `-version` probe. Sources, in order: `-FromPath`, else the Unity
install's `Editor\Data\Tools\Roslyn\csc.exe`. The launcher is interchangeable - it is a stock .NET
Core apphost that loads the `csc.dll` beside it, and Unity's own copy drove the package's newer
`csc.dll` through a clean test compile (empty output, valid 3 KB assembly). Use `-FromPath` with the
extracted tarball member to get the authentic binary (it differs: md5 `5f0947da...` for Unity's
build).
**Cure (needs an elevated shell):**
```powershell
Add-MpPreference -ExclusionPath 'C:\RecRoom\Documents\EggFiles\ProjectTemplate'
Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\Unity\cache"
```
and re-run the repair with `-IncludeGlobalCache` so a package re-resolve cannot copy the damaged file
back out of the shared cache.
Smallest reproducer for the whole thing, without Unity: run `csc.exe -version` in the package's
`Compiler~\win64`; if the file is missing you get the Win32Exception above, and a test compile with
`-noconfig -nostdlib -target:library -r:<dir>\System.Private.CoreLib.dll` produces a real assembly.
### Verified: the compile wall is down (batch-mode run)
The editor can be driven headlessly, which makes the whole state checkable without any clicking:
```powershell
Unity.exe -batchmode -nographics -quit -projectPath <copy> -logFile <log>
```
(it exits 1 on a purely cosmetic `Timeout after 300 seconds while waiting async operations to finish`).
Before and after the `csc.exe` repair, on the same project copy:
| | before | after |
|---|---|---|
| `Starting external compile ...` | 3, then `Win32Exception ... cannot find the file` | 205, **0** failures |
| `Unable to resolve reference 'Unity.Mathematics'` / `will not be loaded due to errors` | ~15 assemblies | **0** |
| `Library/ScriptAssemblies/*.dll` | none, only `BuiltinAssemblies.stamp` | 105, incl. `Unity.Mathematics`, `Unity.Collections`, `Unity.Serialization`, `RecRoom.Studio.Common.Runtime` |
The first repair attempt also showed why this has to be repaired in *both* caches: `csc.exe` compiled
189 assemblies and then vanished mid-run, which the Defender event log does **not** account for
(`Get-WinEvent ... /Operational` ids 1116/1117/1121 only ever name `burst\hostwin\llvm-lipo.exe`, and
never `csc`/`roslyn`). With the shared `%LOCALAPPDATA%\Unity\cache` copy repaired too, the next run
kept the file and logged no failure at all - and `com.unity.roslyn@0.2.2-preview`'s own directory
mtime stayed put, so this was not a whole-package re-extraction. Treat the project copy alone as
fragile; repair both.
Two things the log still reports, neither of them blocking:
* `Assets/**/*.cs` is empty, so there is correctly no `Assembly-CSharp` in the output - all of this
project's code lives in packages. Do not go looking for that assembly.
* The 11 remaining `error CS` lines are `[Script Updater]` advisories from
`com.unity.serialization@1.7.0-preview.1` against `Unity.Collections` 1.4.0 (`FixedString128`
renamed, `Rune` overloads). It still compiles, and nothing in the project references it.
**Still eaten by the same Defender rule:** `com.unity.burst@1.8.4\.Runtime\burst-lld-14-hostwin`, the
LLD the Burst AOT job links with. It is missing from the project copy *and* from the shared cache, so
no re-extraction can bring it back - the batch log ends with
`Error while executing command: ...burst-lld-14-hostwin ... Native error: The system cannot find the
file specified`. `hostwin\llvm-lipo.exe` is quarantined on sight (twice on 2026-09-26 alone). Both are
Burst build/AOT tools: they break a player build, not the C# compilation the editor needs. Recover
them by re-downloading `com.unity.burst@1.8.4` from the registry - its `burst-lld-14-hostlin` and
`hostmac` siblings are intact, which is how we know the Windows one was deleted rather than never
shipped (1.8.17 in the same cache has three `hostwin` entries, 1.8.4 now has one).
### Burst repaired, and the studio auth path re-verified (2026-09-26)
Re-downloading `com.unity.burst@1.8.4` settled both the linker and its naming: the package ships
`package/.Runtime/burst-lld-14-hostwin.exe` **with** the extension (the log prints the extensionless
name because that is what `CreateProcess` is given, and Windows appends `.exe` itself), while
1.8.17/1.8.21 carry only `burst-lld-16/18-hostwin-arm64.exe`. A full file-set diff of that tarball
(480 files) against the extracted package found exactly two casualties and no extras:
`.Runtime/burst-lld-14-hostwin.exe` and `.Runtime/hostwin/dsymutil.exe` (40.8 MB). Both are restored
and the package now diffs clean, as does the whole `RecRoomStudioPackages` set.
With the compiler, the certificate and the server all in one piece, the studio device flow was re-run
over **strict** TLS (the `localhost` dev cert is now in `CurrentUser\Root`, so `curl` without `-k`
succeeds):
```
POST /auth/connect/deviceauthorization -> device_code, user_code 9C3C-UXUF,
verification_uri https://localhost:3030/device
POST /auth/connect/studiotoken -> 400 grant_type is required (the full RFC 8628
grant_type=urn:...:device_code urn:ietf:params:oauth:grant-type:device_code is NOT
accepted)
POST /auth/connect/studiotoken grant_type=device_code&device_code=...
-> access_token (JWT roles gameClient+developer+influencer,
userId 2, tver 0, rn.ver 20230825, expires_in 604800)
GET /auth/account/me/haspassword + bearer -> true
POST studiotoken replying the device_code -> invalid_grant (single use)
```
`grant_type=device_code` is the value the handler actually branches on; a request naming any other
grant type is answered with `unsupported_grant_type` and logged. What is left is unchanged:
rooms/saves, and the `git`-over-HTTPS upload remote - `Server/API/Studio` is still an empty
placeholder directory.
### First real Sign in: two server-side gaps (2026-09-26)
The first click of **Sign in** in the studio window opened the browser at
`https://localhost:3030/device?user_code=EYJ4-T9DF` and got a 404. The browser is not where the
authority on this is: `%LOCALAPPDATA%\Unity\Editor\Editor.log` records the editor's own verdicts, and
it had two of them.
**1. `Device authorization failed: unsupported_grant_type`.** The editor polls `studiotoken` with the
full RFC 8628 grant type - `urn:ietf:params:oauth:grant-type:device_code` - while the handler only
branched on the bare `device_code`, so it answered `unsupported_grant_type` and the editor abandoned
the sign-in. Matching is now on the token rather than one exact string (dashes normalised, so any
spelling is accepted), and `refresh_token` got the same treatment. Re-verified against the live
server: that exact request now returns an `access_token`, and the short form still does.
**2. There was no `/device` page.** `verification_uri` and `verification_uri_complete` were being
advertised while nothing served the path, so the browser step could only ever 404. New
`Server/Auth/DeviceApprovalAPI.cs` (`[Route("/")]` + `[HttpGet("device")]`, the same root-level
pattern `NameSever` uses, and the same HTML styling as `AccountsAPI.VerificationPage`) renders
"Approved - account 2" for a live code, and explains an unknown or missing one. It deliberately
approves nothing: the code is already issued approved, so the page's job is to tell the user what
they are holding.
An earlier attempt had also logged
`Failed to initiate device authorization: Failed to obtain RecNet Service URIs: NullReferenceException`
inside `KJGMKNABDIJ.EMOFOGPOOLJ(String, Char)` - a parse of the nameserver response in *editor* code,
not a server fault. It stopped occurring once the client got past that stage, and it is worth knowing
because it looks like a server problem and is not.
Unrelated noise now identified and safe to ignore: `Rec Room Studio unable to record last project
path` / `skipped checking for update` / `skipped checking for local update to install`, all
`FileNotFoundException` for `ProjectSettings\Packages\com.recroom.studio\BranchId.txt`, plus
`EditorUpdateCheck: Failed The requested URL returned error: 404`. Those only disable the studio's
self-update check.
### The server now leaves a readable log
PID 448 had no stdout redirect, so the editor's actual request bodies from that first attempt are
gone. It has been restarted (PID 39184) from `Server\` with `ASPNETCORE_ENVIRONMENT=Development` and
stdout captured to `.tmp-studio-auth.log` in the repo root. `ReadRequestFields` already logs the
method, path, client IP, content-type, user-agent and every field of every studio request, so the
next Sign in attempt is fully diagnosable:
```
Studio POST /auth/connect/studiotoken from ::1, content-type=application/x-www-form-urlencoded,
user-agent=curl/8.17.0: grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code=..., client_id=recroom-studio
Studio token issued: account=2.
```
Restart pattern (the working directory matters - `DBConfig` resolves `RadicadeServer-Data/` and
`RadicadeServer-Config/` relative to it, so starting from anywhere else silently uses a different
account database):
```powershell
Stop-Process -Id <pid> -Force # dotnet build cannot write bin/ while it runs
cd Server; $env:ASPNETCORE_ENVIRONMENT='Development'
Start-Process -FilePath "bin\Debug\net9.0\Server.exe" -WorkingDirectory (Get-Location) `
-RedirectStandardOutput "..\.tmp-studio-auth.log" -RedirectStandardError "..\.tmp-studio-auth.err.log"
```
It takes 90-150 s to start listening on `https://localhost:3030` and `http://127.0.0.1:3031`, and the
log's `Content root path` line confirms which working directory actually won. (Starting it from a
script: do not capture the launcher's stdout - the server inherits the pipe and the shell blocks
forever. It starts fine, the wrapper just never returns.)
### Sign-in works; the studio API surface (2026-09-26)
Sign in now completes - the studio window shows the account, and a **Sign out** button. What it does
afterwards is visible because this server already logs every request:
```
[09:22:08] GET /studio/analytics/rudderstack-api-key -> 404 (x20)
[09:22:08] GET /rooms/rooms/ownedby/me -> 200
[09:22:08] GET /rooms/rooms/25?include=1037 -> 200
[09:22:13] GET /rooms/rooms/25/subrooms/32/saves?skip=0&take=30 -> 200
[09:22:13] GET /studio/cloud-builds/for-room?roomId=25&subRoomId=32&skip=0&take=30 -> 404
```
`cloud-builds/for-room` is the wall: with no route at all the panel showed
"Failed to open room - Could not download room details. 404 (Not Found)", and the editor retried it
on a loop (three `HttpRequestException: 404` in the console).
The complete route list fell straight out of the same DLL that held the nameserver literal - the
client's UTF-16 #US heap keeps its route templates back to back, in
`Packages/com.recroom.studio.build/Editor/RecRoom.Studio.Editor.Core.dll` at offset ~296k:
```
cloud-builds
cloud-builds/for-room
cloud-builds/create (+ query keys autoPublish, buildBranch, cloudBuildId)
cloud-builds/custom-avatar-item/{0}
cloud-builds/custom-avatar-item/{0}/save/{1} (+ customAvatarItemSaveId)
cloud-builds/cancel
remote-run/push-to-device
collaboration/owners-in-room
account/{0}
```
and, in the same block, the auth client's own vocabulary - which closes the loop on the two fixes
above:
```
connect/studiotoken, client_id=recroom.studiopipeline, client_secret=54oc5C4BH7jpqp42KhChkNZOLP0U3yTx,
grant_type=client_credentials <- a second, non-interactive login path
recroom.studio, refresh_token <- and a refresh path
connect/deviceauthorization, urn:ietf:params:oauth:grant-type:device_code, device_code,
authorization_pending <- the editor DOES send the urn form, and it does poll for
authorization_pending; both are now handled
account/me, rrs.pushtostudio, RecRoom.Studio.SessionId
```
`Server/API/Studio/StudioAPI.cs` (the folder had been an empty placeholder since the studio work
started) now answers the three read-only ones: `cloud-builds/for-room` and
`collaboration/owners-in-room` with an empty list, `analytics/rudderstack-api-key` with an empty key
(that route is polled twenty-odd times a session and the 404s were noise). Verified 200 with the
exact query string the editor uses.
Empty lists, not 404s, on purpose: this server has no cloud build to hand out. Producing one is the
other half of the feature - `cloud-builds/create` plus the git-over-HTTPS remote the studio pushes
the scene to - so the useful next state is the editor saying "this room has no builds" and showing
what it asks for after that, instead of failing on transport.
Still unexplained but harmless: three `NullReferenceException`s in
`KJGMKNABDIJ.EMOFOGPOOLJ(System.String, System.Char)` fire a second after a successful sign-in. That
is the studio's own URI parsing helper (the same one behind the earlier
`Failed to obtain RecNet Service URIs`), so if the studio later complains about a missing service it
is the first place to look - the candidate is a nameserver key we do not emit that it expects to
split.
> **Explained below** ("Opening a room"). Decompiling the class settles it: `EMOFOGPOOLJ` is
> `url.EndsWith('/') ? url : url + '/'`, it NREs on a **null** nameserver field, and the null one is
> `StudioCDN`. The three exceptions were the same missing key each session, not a cosmetic
> curiosity - every URI before it resolved, which is why the session carried on.
### for-room wants a page, not an array (2026-09-26)
Answering an empty list moved the failure from transport to **shape**, which is what makes the next
step legible - the studio window stopped reporting a 404 and started naming the type it wanted:
```
Could not download room details.
The JSON value could not be converted to
RecRoom.Core.Studio.RecNet.PagedResultsDTO`1[RecRoom.Core.Studio.RecNet.CloudBuildDTO].
Path: $ | LineNumber: 0 | BytePositionInLine: 1.
```
Byte 1 of `[` means the studio's list DTO is an **object**. This repo had already learned that for
the game client - `V2_RoomsAPI.EmptyPage` carries the note "The empty page every paged list here
answers with `{ TotalResults, Results }` rather than a bare `[]`. The client's list DTOs are objects
(`PagedResultsDTO<RoomDTO>`)" - so `StudioAPI.CloudBuildsForRoom` now mirrors that shape:
`{ Results: [], TotalResults: 0, TotalCount: 0 }`.
`collaboration/owners-in-room` is the exception and stays a bare array. The editor requested all
three studio routes within the same second (`09:23:55` in `.tmp-studio-auth.log`) and accepted `[]`
there without a deserialization error, so the two routes are not the same DTO family. Reading the
console to see *which* route threw, instead of assuming one uniform shape for every studio list, is
what separated them.
> **Wrong, and corrected below.** The editor *did* reject `[]` on that route - it just logged the
> `Result<List<int>>` deserialization error a second later, next to the room-open failure, where it
> read as part of that failure rather than as its own. "The two routes are not the same family" was
> the right conclusion for the wrong reason: `owners-in-room` is wrapped (`Result<T>`), not paged.
Opening a room still cannot succeed: `for-room` is only the lookup, and this server has no
`CloudBuildDTO` to return because nothing has ever been uploaded. The next milestone is producing
one - `cloud-builds/create` plus wherever the studio pushes the scene - not another shape fix here.
### Two patcher details settled the same day
* The one-time backup now lives **outside** the package folder, at
`<ProjectRoot>\radicade-backups\<pkg-id>\...\X.dll.radicade-orig`, because Unity treats everything
under `Library\PackageCache` as immutable and logs the `has no meta file, but it's in an immutable
folder` warning that opened this whole trail (`-Restore` still understands a legacy in-package
backup). Relocate an existing one with a plain `move`; restore -> patch -> re-run was verified
round-trip, leaving the slot at offset 296749 with 40 bytes of zero padding and the file size
unchanged.
### Opening a room: the studio's DTOs, and why `""` is not `null` (2026-09-26)
The screenshot that prompted this round: the room resolves (`TestPlayers_MakerRoom_3528/Home`), sign-in
holds, the panel offers **Build on Rec.net** and **Download** - and **Open Room** fails with two
stack traces that have nothing to do with transport, because the server log for that whole session
has no non-200 in it:
```
[09:33:33] NullReferenceException: Object reference not set to an instance of an object
KJGMKNABDIJ.EMOFOGPOOLJ(System.String, System.Char)
[09:33:34] ArgumentException: The string argument 'filename' cannot be empty. Parameter name: filename
at CDLPCLJLLLG.DHGPNOHKPNB(...) -> MAMHCJFJOGI+OMPAKMPBPNM.MoveNext()
OGINOCKCDO+ICFNAE5EAJ.MoveNext() <- "Could not download subroom data"
[09:33:34] JsonException: The JSON value could not be converted to
RecRoom.Core.Studio.RecNet.Result`1[System.Collections.Generic.List`1[System.Int32]].
Path: $ | LineNumber: 0 | BytePositionInLine: 1.
```
The editor's own log keeps the full traces and names the methods, which is what makes the shapes
readable - `%LOCALAPPDATA%\Unity\Editor\Editor.log`, and the interesting lines are the ones between
the exception and the `UnityEngine.DebugLogHandler:Internal_LogException` marker.
#### Decompiling the studio is one command, and it is already installed
`ilspycmd` (8.2.0) is a global dotnet tool on this machine, and the editor's assemblies are ordinary
managed DLLs - the obfuscator renamed types and methods, not the DTOs, because the DTOs are the
wire contract:
```
ilspycmd -l "RecNet" ".../Library/PackageCache/com.recroom.studio.build@e443aa011f13/Editor/RecRoom.Studio.Editor.Core.dll"
ilspycmd -t 'RecRoom.Core.Studio.RecNet.Result`1' "<that dll>"
ilspycmd -o .tmp-rrs-decomp "<that dll>" # 7,725 lines, whole assembly
ilspycmd -o .tmp-rrs-authoring ".../com.recroom.studio@6492b87ce18e/Editor/Authoring/RecRoom.Studio.Editor.Authoring.dll" # 28,779 lines
```
Both dumps are kept in the repo working tree (gitignored, like the `.tmp-*.log` files) - the loader
lives in **Authoring** and the API client in **Core**, so a question about a route is usually a
grep across both.
#### The reply shapes, verbatim
```csharp
[Serializable] public class Result { public bool Success { get; set; } public string Error { get; set; } }
[Serializable] public class Result<T> : Result { public T Value { get; set; } }
[Serializable] public class PagedResultsDTO<T> { public List<T> Results { get; set; } public long TotalResults { get; set; } }
public class RoomDetailsDTO : RoomDTO {
public string DataBlob; public string DataBlobHash;
public IReadOnlyList<SubRoomDTO> SubRooms; public IReadOnlyList<TagDTO> Tags;
public IReadOnlyList<AccountRoleDTO> Roles; public IReadOnlyList<int> CoOwnerAccountIds; }
public class SubRoomDTO {
public long SubRoomId; public string Name; public string UnitySceneId; public int MaxPlayers;
public SubRoomDataSaveDTO CurrentSave; }
public class SubRoomDataSaveDTO {
public long SubRoomDataSaveId; public long SubRoomId; public string UnityAssetId; public string UnityAsset;
public string UnityAssetHash; public string DataBlob; public string DataBlobHash;
public int PersistenceVersion; public byte OMVersion; // <- a BYTE, not an int
public int? SavedByAccountId; public PICFJDPCODO? SavedOnPlatform;
public ELMJICAIAAA? SavedOnDeviceClass; public string Description; public DateTime CreatedAt; }
```
`Result` is what catches the eye: every reply the studio *mutates* with is wrapped, and the wrapper's
`EEKDNHEOFFD()` ("assert result") is `Debug.Assert(Success == (Error == null))` followed by
`if (!Success) throw new Exception(Error)`. So a wrapper with `Success:false` is not a soft failure -
it throws out of whatever the editor was doing. `owners-in-room` is declared
`Task<List<int>>` over `Result<List<int>>`, which is exactly the type in the `JsonException`: a bare
`[]` was never accepted there, it just failed a second after the room-open error and read like part
of it. `StudioAPI.OwnersInRoom` now answers `{ Success: true, Error: null, Value: [] }` (the value is
the room's owner account ids, which the editor resolves one by one for its collaborator list).
#### Every nameserver URL is required, and `StudioCDN` was missing
`KJGMKNABDIJ` in the same DLL is the nameserver client: a `NameServerResponse` with 27 string fields,
a `IEBKFBLKOKM` that walks them one at a time into `CNHJLHELLLL[key] = new Uri(EMOFOGPOOLJ(url, '/'))`,
and `EMOFOGPOOLJ` is `url.EndsWith('/') ? url : url + '/'` - **it dereferences the string**, so a key
the response does not carry is a `NullReferenceException` mid-walk, wrapped as
`Failed to obtain RecNet Service URIs: Object reference not set to an instance of an object.` The
dictionary is assigned *before* the walk, so everything before the missing key stays usable: the
session looks healthy and three NREs are logged.
```
RecNetStatus Auth API Notifications Images CDN Commerce Matchmaking Storage Chat Leaderboard
Accounts Link RoomComments Clubs Rooms PlatformNotifications Moderation DataCollection
BugReporting Discovery PlayerSettings Studio GameLogs Strings WWW Econ StudioCDN
```
`GET /nameserver` on this server had all of them but **two**: `StudioCDN` (mapped, so this is the
null that NREs) and `RecNetStatus` (declared by the editor but never mapped, which is why it was
never the culprit). `RecNetStatus` stays unwritten on purpose - adding a URL that 404s to a field the
2023 client may also read is a worse trade than leaving a field the studio never touches absent - and
`StudioCDN` is now `{ServerUrl}/studiocdn`, the base for the editor's `roomunityasset/{filename}`
(source-project manifest) and `unityasset/{filename}` (the baked bundle). Neither route exists yet;
the point is that the editor can build a `Uri` and get an honest 404 instead of an NRE, and the fix
is one line in `DBConfig.NameServer` next to a comment saying all 27 are load-bearing.
#### A blob name of `""` is fatal; `null` is the documented "nothing to download"
The room loader (`OGINOCKCDO` in Authoring) does this, in order:
1. room details, wrapped in `PFJIHBLICIE(..., "Could not download room details")`;
2. pick the save: `room.LNLADOGFALG?.IDNMEAFLKOF?.FirstOrDefault() ?? room.DJACJOEJMOD?.CurrentSave` -
the **saves list first** (the `/saves` page), then the room payload's `currentSave`;
3. download the subroom blob from `save.DataBlob` via
`BCEDBPKMONJ.OGLFKNHIMHG(name)` - failure text `"Could not download subroom data"`;
4. download the room-level blob from `RoomDetailsDTO.DataBlob` via
`BCEDBPKMONJ.FKDIJAIOAPJ(name)` - `"Could not download room data"`;
5. both go to `MAMHCJFJOGI.GMIJOHGNLFI(filename)`, which *first* calls
`CDLPCLJLLLG.DHGPNOHKPNB(filename, "filename")` - the empty-string guard from the trace - and then
`GET {CDN}/room/{filename}`;
6. and both are **skipped entirely when the name is null**, succeeding with an empty payload
(`new PLGEIAPHMDM(null)` / `new GDEHOACHGIF(null)`), which the validators then step over
(`if (payload != null && payload.Length != 0)`).
So `""` is the one value that cannot be handled, and this server was emitting it in two places:
* `RoomUtils.ConvertToFull` hardcoded `DataBlob = ""` - the room *level* field. Nothing on this server
has ever produced a room-level blob (a save carries `RoomData.Filename` **and**
`SubRoomData.Filename`; `RoomServerAPI.UploadRoomData` only ever stored the subroom one), so the
honest value is `null`. This is the failure the first attempt hit: the subroom download had
succeeded (the `/cdn/room/7dc6589a-...` 200 in the log) and the room blob killed it.
* `subRoom.CurrentSave = BuildCurrentSave(subRoom)` ran unconditionally, so a never-saved subroom
shipped a save object with `DataBlob: ""` instead of `currentSave: null`. That is the second
attempt: room 31 simply has no save, and the studio read the placeholder save and died on the guard.
Both are fixed, and both fixes are safe for the game client - checked two ways. `grep -rli currentsave`
over the 2023 client's decompilation under `.external/aug-decomp` finds **nothing**: the client never
reads `currentSave` at all (it loads saves from the `/saves` route, whose blob-less entries this
server already omits). And the reference implementation under `.external/recflare-server` documents
the same contract - `SubRoomDto.CurrentSave: z.nullable().describe('Null until first save')`, no
room-level `DataBlob` field on its room DTO at all, and `if (input.roomDataFilename) sub.RoomDataBlob
= input.roomDataFilename` on the save - so a null here is what the real backend does, not a
workaround.
#### Verified against the running server
Rebuilt, restarted, and read back with a token minted through the studio's own device flow (the
`client_credentials` path in `.tmp-studio-auth.log` works too, without a browser):
```
nameserver StudioCDN = 'https://localhost:3030/studiocdn'
--- room 31 (TestPlayers_MakerRoom_3528) roomDataBlob=None
subRoom 34 'Home' flatDataBlob='' currentSave=null
--- room 54 (TestPlayers_MakerRoom_1603) roomDataBlob=None
subRoom 57 'Home' flatDataBlob='7dc6589a-f613-4575-afc2-21efaf9347ac'
currentSave={ "SubRoomDataSaveId": 1, "SubRoomId": 57, "DataBlob": "7dc6589a-...", ... }
owners-in-room -> {"Success": true, "Error": null, "Value": []}
cloud-builds/for-room -> {"Results": [], "TotalResults": 0, "TotalCount": 0}
```
Room 31 now opens against an empty scene (nothing was ever saved) and room 54 against the save it has;
the room-level blob being absent means the scene opens without room-level data, which is what a room
with no room data is, and a studio save is what would create it.
`dotnet build Server/Server.csproj` is clean, and **185 tests pass** across `RoomSave`, `SubRoom`,
`RoomRole`, `AdminRoom`, `RoomDeveloperOwned`, `PlayerWarning`, `CdnRange` and `RecNetShape` - the
last one is the conformance suite that compares payloads against the reference shapes, so the
`CurrentSave`/`DataBlob` change is covered in the direction that matters (a subroom that HAS a save
still reports it). `ServerTests` still cannot build as-is - its csproj compiles two
`2023Patch/RadicadeEacBypass/*.cs` files that live in the uncommitted stash - so this run used a
temporary project file beside it with those two `Compile Include`s dropped and the three test files
that need them (`HubRealtimeTests`, `ImageSignatureRequestTests`, `NameserverRedirectTests`)
removed. Do not "fix" the csproj: the missing sources are somebody's work in progress.
#### What is left before a room can actually be built in Studio
* `cloud-builds/create` (+ `buildBranch`, `autoPublish`, `cloudBuildId`, `cloud-builds/cancel`) - the
studio's `Build & Upload` button, and the only way a `CloudBuildDTO` will ever exist to list;
* `remote-run/push-to-device` (`{ SessionId, RoomId, SubRoomId, RoomData, SubRoomData,
BakedUnityAssets }` of `UploadFileResponseDTO`s);
* {StudioCDN}/`roomunityasset/{filename}` and `/unityasset/{filename}`, plus `{Storage}/upload` and
`/upload/unityassets` - where the studio puts the baked Unity assets it needs back;
* the **room**-level `DataBlob`: accept `RoomData.Filename` on the save route and store it, so a room
saved *from Studio* comes back with room data instead of `null`;
* `collaboration/owners-in-room` with the room's real owner ids, which needs the collaboration layer
this server does not have yet.
### A room opens in Studio: the illegal-text check, and the realtime channels (2026-09-26)
The three shape fixes above landed the room. `DormRoom/Home` opened with its real Maker Pen scene in
the viewport (Plants, Basketball, Welcome Mat, the whole dorm object list), the panel stopped saying
"Failed to open room", and **Build on Rec.net** filled in with the subroom's save history (`#1`, `#39`,
`#38`, `#37` - those numbers are the `SubRoomDataSaveId`s the `/saves` route mints).
Two errors were left, and both were missing server surface rather than shape:
```
[10:52:53] HubException: Failed to invoke 'SubscribeTo' due to an error on the server.
HubException: Method does not exist
[10:54:41] NullReferenceException KOMDFENMFBG+EADNODIDNG.MoveNext()
-> dialog: "An exception occurred while checking for illegal text in components.
Trying again may fix the error. Exception: 'Object reference not set ...'"
```
#### The purity check: `POST /api/sanitize/v2/isAllowed/bulk`
The studio runs every string in the scene through `KOMDFENMFBG.DOLJODEKDLI`, which calls
`Result<AreStringsPureResponseDTO>`, i.e. `POST {API}/api/sanitize/v2/isAllowed/bulk` with `Values`,
and then does:
```csharp
Result<AreStringsPureResponseDTO> newStringAllowances = await NLMHGDMIMAP(toBeChecked);
for (int i = 0; i < toBeChecked.Count; i++)
HINAIGHAGOI.TryAdd(toBeChecked[i], newStringAllowances.Value.ArePure[i]);
```
`Value` is dereferenced and `ArePure` is indexed with the sender's own index, so the reply has to be
the `Result` wrapper with **one bool per string, in order** - which is the NRE, and the reason the
dialog says "trying again may fix the error" rather than naming the string it disliked. The route did
not exist at all (404, `len=0`), and the NRE is what the editor made of the empty body.
One detail decides how the route reads its input. The editor's request builder
(`OCOOLHLEDHD.MFBDEFCEDGN`) appends its parameter list to the URL **only for GET**; for every other
verb it wraps the same list in `FormUrlEncodedContent`. So this arrives as a form body of repeated
`Values` fields, and the log line looks like the studio called it with no arguments at all - there is
nothing after `bulk`:
```
[09:54:59] POST /api/sanitize/v2/isAllowed/bulk -> 404 (0.2 ms) len=0 ct= body=
```
`SanitizeAPI.IsAllowedBulk` now answers `{ success: true, error: null, value: { ArePure: [...] } }`
from the same `StringTools.IsPure` every other route here applies to room names and inventions, with
the form spelling and the query spelling both accepted (so it can be exercised by hand). Verified:
```
$ curl -X POST https://localhost:3030/api/sanitize/v2/isAllowed/bulk -d 'Values=Plant (Dracaena)&Values=faggot&Values='
{"success":true,"error":null,"value":{"ArePure":[true,false,true]}} # aligned, in order
```
(The `Result` names are lowercase because that is how `Success` declares them for the game client;
the editor binds them anyway - it is `JsonSerializerDefaults.Web`, i.e. case-insensitive. That is also
why the `owners-in-room` wrapper from the section above needed no second pass.)
#### The realtime channels: `SubscribeTo` / `UnsubscribeFrom` on the hub
The editor's realtime client (`LKDEHPKLIGJ`) connects to `{Notifications}/hub/v1` - `NotifyHub`, which
already serves the game client at that path - sends `{ Channels: [...] }` to those two methods, and
reads `Notification` frames back off the connection. The channel it subscribes to is
`RRS:<RecRoom.Studio.SessionId>`, which is exactly where a `rrs.pushtostudio` message would come from
(`remote-run/push-to-device`, still unimplemented). The hub had `SubscribeToPlayers` but not
`SubscribeTo`, and SignalR answers an unknown method with `HubException: Method does not exist` - an
error the editor logs and retries on every reconnect instead of ignoring.
Both methods now exist, take the raw `object?` the way the neighbouring hub methods do, pull the
channel names out of the `JsonElement` by name (SignalR serializes camelCase, so the lookup is
case-insensitive), and log the subscription. Nothing publishes to them yet, which is stated in the
log line rather than silently swallowed:
```
[NotifyHub] U9-Bl4o4KZFIUWR5eMi9IA subscribed to RRS:test-session (nothing publishes to these yet).
[NotifyHub] U9-Bl4o4KZFIUWR5eMi9IA unsubscribed from RRS:test-session (nothing publishes to these yet).
```
Verified without a SignalR client package, by driving the hub's **long-polling** transport with curl -
worth keeping, because it is the only way this repo can poke the hub by hand:
```
TOK=$(curl -s -X POST 'https://localhost:3030/notify/hub/v1/negotiate?negotiateVersion=1' | jq -r .connectionToken)
curl -s -X POST "https://localhost:3030/notify/hub/v1?id=$TOK" -H 'Content-Type: text/plain;charset=UTF-8' \
--data-binary $'{"protocol":"json","version":1}\x1e' # handshake
curl -s -X POST "https://localhost:3030/notify/hub/v1?id=$TOK" -H 'Content-Type: text/plain;charset=UTF-8' \
--data-binary $'{"type":1,"target":"SubscribeTo","arguments":[{"channels":["RRS:test-session"]}],"invocationId":"1"}\x1e'
```
The `\x1e` record separator is part of the protocol, and the response body stays empty on the POST -
the server-side log line is the proof of dispatch (a missing method answers with a `HubException` in
the next poll instead).
The panel's remaining warnings are the honest ones: "This scene has never been uploaded from Rec Room
Studio" and "Never save available on rec.net" are the empty `cloud-builds/for-room` page, and they go
away when `cloud-builds/create` and the build store exist.
## Keepsakes: what the reference server holds, and the store we built
There is a second reference in play now - a downloaded fork at
`rec-room-2023-server-main/rec-room-2023-server/asdfasdf` - so it is worth stating exactly what it
carries, because it is less than it looks. Five files mention keepsakes and only one of them holds
anything the client can use:
| file | contents |
|---|---|
| `keepsakes_categories.json` | the **real** catalog: 5 categories in a counted result set |
| `APIs/API.cs:186,527,539` | the same three GETs we serve: `categories` (reads the JSON), `globalconfig`, `rooms/{roomId}` -> `Results.NoContent()` |
| `KeepsakesConfig.cs` | empty stub classes, no code |
| `APIs/Auth.cs:227` | `"keepsake"` in a role array - inert |
| `ProgressionEvent.cs:15` | `KeepsakeRoomLists`, never assigned |
So the fork's keepsake endpoints are already byte-for-byte ours - the same 204 for a room, the same
config trio - **except that its catalog is populated and ours was empty.** That one file is the delta,
and it is trustworthy: each entry's field order matches the client's own
`KeepsakeCategoryConfigDTO(enum, string, int, int, string, string)` constructor, and its ids
(0 `explore`, 2 `presents`, 4/5/6 `science_core_*`) are the Science Core event this build's own
GameConfig text describes.
### What the client itself says it wants
Straight out of `global-metadata.dat` rather than inferred: the route literals are
`api/keepsakes`, `api/keepsakes/categories`, `api/keepsakes/events`, `api/keepsakes/globalconfig`;
the feature's error strings (`Failed to add keepsake`, `Failed to collect keepsake {0}`,
`Failed to delete keepsake {0}`, `Waiting for Keepsake Instance Data download...`,
`Cannot place additional keepsakes in this room!`) and the notification names
`KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved` are all present. Our own log already showed the
three GETs firing on every room load (`rooms/25 -> 204`, `categories -> 200 len=43`,
`globalconfig -> 200 len=100`).
The one write-side payload we can read comes from the recflare reference's notify package, which
documents the captured shape:
```ts
/** `KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved`. */
interface KeepsakeInstancePayload {
KeepsakeInstanceId: string // the GUID the room save carries
KeepsakeCategoryConfigId: number
PlacedByAccountId: number
RoomId: number
SubRoomId: number | null
}
```
Two things follow from that, and they drive the design. The instance id is a **GUID the room save
already contains** - the client writes it when an author places a keepsake - so the server must store
the id it is told, never mint its own; and a room whose save holds a keepsake with no row behind it is
precisely the state the keepsake runtime null-references.
### What shipped this session
* `KeepsakeInstances` and `KeepsakeCollectionRecords` (hand-written guarded migration
`20260927120000_AddKeepsakes`, the same style as the neighbouring ones).
* `GET api/keepsakes/categories` now serves the five real categories (`RADICADE_KEEPSAKE_CATALOG=empty`
restores the empty set; `RADICADE_KEEPSAKE_ICON=<name>` swaps both icon names, which is how we find
out which name the client actually reads).
* `GET api/keepsakes/rooms/{id}` is unchanged when nothing is placed (204) and serves
`{KeepsakeInstances, KeepsakeCollectionRecords, KeepsakeProgressionEventIds}` once something is -
the instances this caller has *not* collected, then their records.
* `POST api/keepsakes/rooms/{id}` places one, keeping the caller's GUID when it sends one;
`POST api/keepsakes/rooms/{id}/collect` records the pickup, once per player (a repeat keeps the
first timestamp).
* Anything else under `api/keepsakes` lands on a probe that names the call in the console and answers
**404, never 204**: a write that looks successful while the room save holds an id nothing backs is
the exact failure the empty catalog was protecting against.
The two open unknowns are both answered by that probe rather than by more guesswork: the exact
spelling of the collect/delete routes (the literals only prove `categories`, `events`, `globalconfig`,
`api/keepsakes`) and the JSON key the client reads for a placed instance. The next in-game attempt
prints them.
### The room that already has a keepsake in it
`RoomImports/Showdown/Showdown.room` - imported as an RRO at boot - contains a `keepsake` component
with a GUID beside it:
```
offset 585176: ... "keepsake" ... $a75f7547-79eb-47c6-8986-6767abcb4f92 ...
offset 591174: ... keepsake ... (the room's component-type table)
```
Nothing registers it yet, and that is deliberate: registering a *guessed* id is exactly the kind of
invented keepsake that faults a load. The right way in is the probe - join Showdown, watch which ids
the client reports, and store those.
### Keepsake rooms: how a room comes to be allowed to hold keepsakes
The banner this leaves behind - **"This room does not have keepsake permissions."** - is not a room
role. The client's room-permission enum is `CAN_EDIT_ROOM_ROLES ... CAN_USE_MAKER_PEN ...
CAN_USE_PLAY_GIZMOS_TOGGLE` with no keepsake entry in it, the room DTO has no keepsake field either,
and the fork's `ProgressionEvent.KeepsakeRoomLists` is never assigned. The only per-room keepsake
payload the client asks for while a room loads is `GET /api/keepsakes/rooms/{id}`, and the keys that
response may carry - out of the client's own metadata - are `KeepsakeProgressionEventIds`,
`KeepsakeRoomId`, `KeepsakeRoomLimit`, `KeepsakeRoomListId`, `KeepsakeRoomLists`, `KeepsakeRooms`.
So the permission arrives with the room's keepsakes or not at all, which is why every room answered
NO CONTENT and every placement answered with that banner.
A room is advertised as a keepsake room when it is listed in
`Server/RadicadeServer-Config/keepsake-rooms.json` (`{ "AllRooms": false, "RoomIds": [3, 25],
"RoomListId": 1, "RoomLimit": 100 }`) or when `RADICADE_KEEPSAKE_ROOMS` names it (ids, or `*` for
every room, with `RADICADE_KEEPSAKE_ROOM_LIST_ID` / `RADICADE_KEEPSAKE_ROOM_LIMIT` beside it). Those
rooms answer 200 with the keys above beside the three instance lists - and those lists stay honest,
so an empty keepsake room still carries nothing for the keepsake loader to resolve - and the server
logs `[Keepsakes] room {id} answered as a keepsake room: list ..., limit ..., N room(s) advertised,
M placed.` Every other room is unchanged: NO CONTENT, the old behaviour. **The config that ships
enables every room** (`AllRooms: true`): the banner is the symptom this exists to clear, so a
checkout that shipped it switched off would look exactly like the bug. Narrowing it back is an
operator edit - `AllRooms: false` plus the ids in `RoomIds`.
The file is re-read on every room load, so an edit applies to the next room a player enters with no
restart. That is verifiable without the game: with the running server on 3030, `curl -sk
https://localhost:3030/api/keepsakes/rooms/25` answered `204 0b` while `AllRooms` was false and
`200 381b` with `KeepsakeRoomId`/`KeepsakeRoomLimit`/`KeepsakeRoomListId`/`KeepsakeRooms`/
`KeepsakeRoomLists` the moment the file said `true` - same process, no relaunch.
**Confirmed in game.** With `AllRooms: true`, walking into room 25 clears the banner: those five
room keys ARE the permission, and the client asks for them every room load (`[Net] Get
.../api/keepsakes/rooms/25` in the 21:09 log, the first run that got far enough to make the call).
### Permission is not the whole gate: the keepsake comes from a progression event
Permission cleared the banner but left the MakerPen's keepsake tile reading **`x0`** - a room can be
allowed to hold keepsakes and still offer nothing to place. The second half is the progression
event:
* The room payload's `KeepsakeProgressionEventIds` names the event the room's keepsakes belong to
(the client's metadata spells the field run `EmptyKeepsakeProgressionEventIdsKeepsakeRoomId…`,
i.e. "empty" is its own state).
* `GET /api/progressionEvents/active` answers the active event's **ID** - a bare number, CONFIRMED:
with the id served, the client's log has no malformed line for this route at all, and the very
next calls are `GET /api/progressionEvents/event/1` and `GET /api/progressionEvents/record/1`
(the record asked four times in one session), i.e. `ProgressionEventDTO` and
`ProgressionEventRecordDTO` by id. Both were 404s here, so the id alone changed nothing on
screen; the record, its `KeepsakeRoomLists` and the palette's keepsake allowance all hang off
them.The client's progression-events client has exactly two no-argument calls, `KIJDNIPGGDC() ->
MFBKDKPGMNP<long?>` and a private one returning a record, and the id is what the model caches
first: `ABIEHIAHKJP(Nullable<long>)` is that field's setter and `BJKPLIJHAJK(long, bool)` is the
id-taking record fetch. A nullable long deserializes from none of the shapes this endpoint
answered before (`[]`, `{}`, and a full record), each logged by the client as `Malformed
Response: '<body>'`.
Both by-id payloads carry an `_route` marker (`"event"` / `"record"`). An unknown member is ignored
by the deserializer, so it costs nothing, and it is what makes a `Malformed Response: '<body>'` line
in the client's log name WHICH of the two the parser rejected - the log shows a body, never a URL.
* `RoomListModel.ApplyKeepsakeOverrides(ref eventRecord, RoomModel, long, int, long)` is the client
applying that record's `KeepsakeRoomLists` to a room, and the sibling members
`IsActiveEventKeepsakeEvent` / `GetKeepsakeRoomListIdFromRoomId` / `GetNextKeepsakeRoomUnlockTime`
are all event-shaped.
The record's field names come from the 2023 fork's own `ProgressionEvent` model
(`Downloads/rec-room-2023-server-main/.../ProgressionEvent.cs`) and each one is **present in this
build's `global-metadata.dat`**: `ProgressionEventId, Name, IsEnabled, UsesBoost, BoostXpMultiplier`
(the fork spells it `BoosXpMultiplier` - a typo), `BoostDailyGameplayMinutesLimit,
ActiveExperiment, StartTime, EndTime, CollectionEndTime, EventInfoImageName,
ChallengesIconImageName, RewardsPipImageName, PurchasableXpBoostId, Rewards, KeepsakeRoomLists`.
Two fork fields are NOT in this build and are left out: `EventLeaderboardId` and
`InitialCurrencyGrant`. The event is windowed around the current time (start -30d, end +1y) rather
than pinned to a date, so a machine whose clock is in any year still sees it running.
The event's `KeepsakeRoomLists` carry the same one-list entry the room payload carries (from
`KeepsakeRoomConfig.RoomListEntries`), because the client's copy of the event OVERRIDES the room's:
a room the event's list does not mention would be overridden straight back to no permission. Under
`AllRooms` the event's copy carries no member rooms - they cannot be enumerated - and the room's own
copy is what names it.
**The event payloads were rejected, and the client's own log says so.** The game writes every
session to `BepInEx/LogOutput.log`, which the plugin's `[LogGate]` feeds: the room's keepsake answer
never appears in a `Malformed Response` line, but the event bodies do, verbatim - the whole record
(`Malformed Response: '{ "ProgressionEventId": 1, "Name": "Science Core", ...'`) and, after it was
narrowed to identity + window + keepsake lists, the narrowed one too (the log's truncation lands on
`"StartTime"` right after `"IsEnabled"`, which is how the two payloads are told apart). Both come
from `EFMPNEEHJFJ.CMKDNAPFCOI[T]`. Two things follow:
* **Unknown members are ignored**, so a name that is merely absent costs nothing - which is why the
room's answer parses even though it carries six keys `KeepsakeRoomInstancesDTO` does not declare
(that is the answer that cleared the banner).
* A name whose TYPE is wrong fails the *whole* response, so the fix is to send FEWER names, not
more: every field that belongs to a neighbouring DTO rather than to this record is now out - the
XP-boost family (`BoostXpMultiplier`, `BoostDailyGameplayMinutesLimit`, `UsesBoost`,
`PurchasableXpBoostId`, all of which live on the boost DTOs) and the image-name fields (which may
be typed image references rather than strings). What is left is the event's identity
(`ProgressionEventId`, `Name`, `IsEnabled`), its window, `Rewards`, and `KeepsakeRoomLists`.
Two knobs stay for the next narrow. `RADICADE_KEEPSAKE_EVENT_LISTS=empty` sends `KeepsakeRoomLists:
[]` in the record instead - an empty array deserializes for a list of anything, so if the malformed
line disappears with it set and returns without it, the entry shape is what the record rejects.
`RADICADE_PROGRESSION_ACTIVE=record` serves the record AT `/api/progressionEvents/active` instead of
the id, for the case where that endpoint turns out to be the client's other no-argument call. The
server console names which it served: `[ProgressionEvent] active -> 1` or `[ProgressionEvent] record
1 served (all keepsake list(s), list 1, limit 100).`
**How to read the next run.** Walk into a room and place a keepsake; the server console names both
halves of the answer:
```bash
grep -n "Keepsakes]" server-console-runNN.log # the room's answer, and any placement
```
A `POST /api/keepsakes/rooms/{id}` answered `accepted` means the tile had something to place. If the
tile still reads `x0`, the next lever is the event's contents rather than the room's permission -
and any keepsake call this server does not implement is still named by the `[Keepsakes] unclaimed
METHOD path body=…` probe (which answers 404 on purpose). `GET /api/keepsakes/events` is the one
keepsake route still stubbed to `[]`; the client's own route table (the literal run
`api/keepsakes api/keepsakes/categories api/keepsakes/events api/keepsakes/globalconfig` in
`global-metadata.dat`) shows those four are the whole keepsake surface, with the room route built
beside them.
## Saving an outfit: the route the client posts to, and the 404 it met
The client's `POST /econ/api/avatar/v4/saved/set` answered 404: this server had the saved-outfit
routes on **v3 only** (`GET /api/avatar/v3/saved`, `GET /api/avatar/v3/saved/{accountId}`,
`POST /api/avatar/v3/saved/set`), while the 2023 build reads its list from v3 and *writes* through
v4. The client's own route table in `global-metadata.dat` names the three avatar routes it knows:
```
api/avatar/v4/items api/avatar/v4/saved/name api/avatar/v4/saved/set
```
so the missing pair was `saved/set` and `saved/name` (`items` already existed). Only `saved/set` is
implemented here; nothing in this build has been seen calling `saved/name`, and a route invented
without knowing the shape it must answer is worse than a 404 that names itself.
**v3 and v4 share one slot table.** Both post the same payload and both expect "save over this
outfit" to mean *re-save an existing slot*, and the client reads the result back through v3. Two
tables would let a player save an outfit that their own outfit list cannot find, so the slot
bookkeeping lives in one place (`RecNet/Avatar/SavedOutfits`) and each version is only a shape:
v3 echoes the outfit back, v4 answers the lean acknowledgement the client expects - `{ Success,
Slot }` - which is what the reference servers do too.
Two things behind the 404 were wrong and had gone unnoticed because the route that used them was
never reached by this client:
* `Outfit.Slot` was a **string**; the client posts an **integer** (`Slot: 0..n`). Deserializing the
client's body into the old DTO could not have bound, so even a v3-shaped route would have failed
on the body. It is `int?` now, and the payload's other two members (`OutfitSelectionsV2`,
`CustomAvatarItems`) are declared so they survive the round trip instead of being dropped on save.
* A body with **no slot** was accepted and stored as a literal `null` entry - the `[null]` an
earlier empty-body post left on account 2 is exactly that. A missing slot is now a 400, matching
the reference server: defaulting it would overwrite slot 0, the one outfit the player did not ask
to replace. Stored blobs are also filtered on read, so that legacy `[null]` is dropped rather than
handed to the client as an entry it cannot parse.
Verified live against the running server: no token is a `401` (the route exists), a body without a
slot is a `400`, a real save is `200 {"Success": true, "Slot": 0}`, and
`GET /econ/api/avatar/v3/saved` serves that outfit back with its selections verbatim. The writes
were made on a throwaway slot and account 2's stored blob was restored afterwards.
## Loading a room: what the second-per-second actually goes to
A room join is slow - minutes for a Rec Room Studio room, and uncomfortably long even for a Dorm
whose save is 1.8 KB - and the log's own numbers say why. Measured against live from the client's
machine:
| Measurement | Result |
| --- | --- |
| Fresh HTTPS request to the API | 0.57 - 0.86 s (`dns 0.28` + `tcp 0.19` + `tls 0.2` + server) |
| Second request on the same connection | **0.19 s** |
| Server-side work for a trivial route (`/api/version`) | the same 0.19 s as any other route |
| CDN asset bundle, one stream | 10 - 12 MB/s (85 MB ⇒ ~8 s) |
| TLS session resumption | already working (`Reused, TLSv1.3`) |
0.19 s for *every* route including a no-op one is ~2 RTT to the VPS, which is 100 ms away - the
server is not spending that time, the wire is. So a room load costs what it costs because the client
makes dozens of calls and **opens a fresh TLS connection for each one** (the `[TLS] Accepting server
certificate ...` line is printed again before every single request in the log). Nothing server-side
removes that; two things were making it worse:
* **`GET /econ/api/roomcurrencies/v1/getBalance` did not exist.** The client asks it immediately
after `getAllBalances` on every room join and retries an answer it cannot use; a 404 is an
exception plus another round trip per retry. Nothing here tracks room-currency balances, so it now
answers `200` with a zero balance that echoes the currency and account asked for
(`RoomCurrencyAPI.GetBalance`).
* **The CDN sent no `Cache-Control` at all.** `Accept-Ranges`, `Content-Length` and `Last-Modified`
were there, but nothing told the client it may reuse bytes it already has - and for a Studio room
the bytes in question are the entire asset bundle. Blob and asset routes now send
`public, max-age=31536000, immutable` for `.assetbundle` names (the id is minted per upload, so a
different bundle is a different URL) and `public, max-age=86400` for everything else, because an
admin repair can replace a room save under its existing name (`VPS/fix-dorm-blob.sh`).
### Two things in the log that are not what they look like
`Malformed Response: '[]'`, `'{}'` and `'[ {...rooms...} ]'` are thrown by the client's obfuscated
DTO reader, which **reads members positionally** - the same property `AlgorithmicListsAPI` documents
for `lists/algorithmiclists`. An empty body is therefore not a valid "nothing here" answer, and the
client retries instead of moving on. Several routes still answer that way by design (keepsakes off,
`progressionEvents/active`, `roomkeys/v1/room`); the retries are visible as 4x `photon_access_token`
and 5x `experience/player` per join.
`Exception: Can't parse '' as a hex number!` fires ~112 times per session and is **not**
`PhotonAccessToken` being empty, despite that field being `""` in
`GET /rooms/photon_access_token`: the client fetches that route 7 times per session, and both
reference servers return `""` there too (`config/v1`'s `UseNewPhotonAuthToken` is `false`, so the
token is not used for the join). Whatever parses hex 112 times is elsewhere, and changing the token
to guess would risk the joins that currently work.
### The failure at the end of a slow load
`Unexpected Photon Disconnect due to cause: ServerTimeout` is what the client reports as *"Failed to
load room due to disconnect during load (Code 8706-3)"*: the load outlives the room's Photon
connection. The room-load stage timeouts are ours to set and are generous
(`RoomLoad.DownloadAssetBundleTimeoutSeconds = 900`, `PreDownloadTimeoutSeconds = 120`,
`PostDownloadTimeoutSeconds = 240`); the Photon peer timeout is not in that table, so the lever there
is client-side, not a config key.
Verified locally against a dev server, then the server was stopped:
```
GET /econ/api/roomcurrencies/v1/getBalance?currencyId=...&accountId=2 -> 200 {"CurrencyId":...,"AccountId":2,"RoomId":0,"Balance":0}
GET /cdn/data/{blob}.room -> 200 Cache-Control: public, max-age=86400
GET /cdn/data/{id}.assetbundle -> 200 Cache-Control: public, max-age=31536000, immutable
GET /cdn/data/{id}.assetbundle (-r 0-1) -> 206, Content-Range: bytes 0-0/1, same directive
```
Tests: the two CDN routes and `getBalance` are pinned in `CdnRangeRequestTests` and
`RoomEconomyPersistenceTests` (8/8), and the full suite is 688 passed / 1 failed - the pre-existing
`AvatarCatalogSourceTests.ShippedLookupCoversTheCatalogServedToClients`.
## Run 30 fixes (2026-10-01): the ambiguous routes, the club category rows, the carousel envelope
One landed batch from a single player report. Each item is the client's own URL, taken from
`BepInEx/LogOutput.log` / `Player.log` in the deployed client rather than from the templates.
### `AmbiguousMatchException` (HTTP 500) on the club member actions
`ClubsAPI` carries `[Route("clubs/club")]` (plus the `api/clubs/v{1,2}/club` copies), so an action
with BOTH the class-relative template `{clubId}/minlevel` and an absolute
`~/clubs/club/{clubId}/minlevel` registers the same path twice. ASP.NET reports that as ambiguity,
so *every* request to it threw before any handler ran - `PUT minlevel`, `POST|DELETE members/remove`,
`PUT members/invite` and the accept/decline actions were all dead. The absolute `~/clubs/club/...`
copies are removed (the `~/club/...` aliases are a different path and stay); the same duplication in
`ClubAnnouncementsAPI` and `api/playerevents/v1/report` (EventsAPI owns v1, ContentReportAPI keeps
v2) went with them, and `PATCH admin/panel/{table}/{id}` vs `{id:long}` is now one action that
parses the id. `ServerTests/RouteAmbiguityTests.cs` reflects over every controller and asserts no
(method, path) pair has two owners, so this cannot come back silently.
### `POST clubs/club/{id}/members/acceptinvite` -> 405
The string literals (and the log) show the client accepts an invitation at `.../members/acceptinvite`;
the server only ever registered `.../members/accept`, so the invite notification led nowhere. Both
verbs are registered now, with `declineinvite` and `changetype` beside them, and `invitemembers`
joins `invite`. The invite reader also accepts the field spellings the reference DTO allows
(`accountId`/`accountIds`/`playerId(s)`/`targetPlayerIds`, single or repeated) instead of only
`accountId`, which is what silently invited nobody.
### `POST econ/api/influencerpartnerprogram/remove` -> 415
The economy client does not post JSON, and `[FromBody]` only accepts JSON content types. The handler
reads the fields from the query string, a urlencoded form or a JSON body (content type or not), the
same way `redeem` does.
### `DELETE api/customAvatarItems/v1/{guid}` -> 405 and `POST .../report` -> 404
The item's editor addresses it by id: `PUT` (metadata edit) and `DELETE` (remove) are registered
alongside the existing `GET`, both owner-gated, and the item report route resolves the item's creator
as the reported player before funnelling into the shared `SubmitReport`.
### `GET matchmaking/room/{roomId}/instances` -> 204
The instance browser is the watch menu every player opens for the room they are in. It required
`RoleType.Moderator` and answered `null` to everyone else - `null` serialises to 204 No Content,
which the client renders as "Failed to get room instance browser: Response was empty". It now
answers an array (empty when the room has no live instance) to any signed-in caller.
### "Clubs are appearing in every single category"
The Clubs screen asks `clubs/club/categoryTags` for its categories and then fetches each row on its
own: `GET clubs/club/search?sort=0&category=Social&count=32`, one call per tag. The server ignored
`category` and returned the whole catalogue, so the single club was drawn in every row.
`ClubStore.CategoryFilter`/`MatchesCategory` now filter on `primaryTag` or `customTags`
(case-insensitive, `#tag` tolerated) for `search`, `mostactivetoday`, `mostactivenow` and
`search/mostactivenow`; a request with no category still gets everything.
### `Error refreshing rooms from source CarouselEndpoint`
Still one per Play-menu open, and now explained: the client's template for the row is
`rooms/carousel/{0}&skip={1}&take={2}` (a paged read), and the client logged our answer as
`Malformed Response: '[ { "RoomId": 167, "IsDorm": false, ... } ]'` - a bare array where it reads
`{ Results, TotalResults }`, the envelope every sibling `*_rooms/{source}&skip=&take=` feed already
answers with. `rooms/carousel/{*source}` answers that envelope now. The earlier "a bare array works"
conclusion came from a run whose Play menu was fed by `lists/algorithmiclists` + `rooms/bulk`
instead, so the route had never actually been read by the client.
Tests: `RouteAmbiguityTests`, `ClubCategoryDiscoveryTests` (9), `CarouselFeedShapeTests` (3) and
three new instance-browser cases in `MatchmakeInstanceRouteTests`; full suite 798 passed / 9 failed,
all nine the pre-existing catalog set (`AvatarCatalogSourceTests`, `DorkNetCatalogTests`,
`EquipmentInventoryTests`, `StorefrontRewardConfigTests`).
## Run 34 (2026-10-15, live): every room's bundle answered `400`, so no room loaded its content
### What the player saw, and what it was not
"Error downloading room assets! Try again later or on a better connection." on every room, for every
player. In `Player.log` the exception surfaces inside `RoomLoad.RoomManager` and the client reports
`FailedToSpawnPlayer / (Code 8715)` to matchmaking; the Zenject
`InvalidOperationException: You should not try to retrieve bindings from a disposed container!` that
follows is that failed load task being torn down, not a second fault. Nothing in the client's own log
names the request, which is what made this look like a connection problem.
### The request the client was actually making
`BepInEx/LogOutput.log` logs every call with its status, and it has the answer:
```
[HttpStatus] #322 Get /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 400 bytes=39 reason=Bad Request
[HttpStatus] #543 Get /rooms/unity_assets/2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle/0/1 -> 400 bytes=39 reason=Bad Request
```
Room 247 is the `MAKER_ROOM:247/313` in the error: subroom 313 advertises
`UnityAssetId: 2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle`, and step two of the download is healthy -
```
GET /data/2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle -> 200 68,673,320 b
GET /rooms/rooms/247?include=5421&unityAssetTarget=0&unityAssetVersion=1
-> 200 SubRooms[0].UnityAssetId = 2n8bbqwnk6zs6k2blzgcdhcnc.assetbundle
```
- so step one, the metadata call, was the only thing broken, and it was broken for every id of that
shape on the server rather than for one room.
### The regression, in one line
`b33d5b3` ("Checkpoint the in-flight work before merging origin/2023") reworked `UnityAssetsCDN` for
the Studio pipeline and put this in front of the resolution:
```csharp
if (!StudioUnityAssetRegistry.IsValidAssetId(assetId) || target < 0 || ...) return BadRequest();
```
`IsValidAssetId` is `value is { Length: 40 } && value.All(char.IsAsciiLetterOrDigit)` - the shape of a
Studio manifest id, and of nothing else that is stored as a bundle. Every legacy id fails it:
`Showdown_0_1.assetbundle` (23 characters, `_` and `.`), the 24-character RecNet ids
(`2n8bbqwnk6zs6k2blzgcdhcnc`, `8aas7b5ivtaggn9swhe3wkgdj`), the 36-character dashed id the importer
binds (`32cafb11-c51b-485c-8f1f-73b73159feff`), and the built-in rooms' `*_0_1.assetbundle` names.
`ResolveLegacyFilename` was still underneath, unreachable for exactly the ids it exists for, so every
room whose subroom points at a bundle - which is every room with custom content, for every player -
answered `400 {"success":false,"error":"bad request"}`. The pre-Studio file had no such gate:
`ResolveFilename(assetId)`, the id with no extension first, then the id plus `.assetbundle`.
### What changed
`Server/CDN/UnityAssetsCDN.cs` - 40 alphanumerics *with a manifest behind them* is still the Studio
path, and a target/version its metadata does not name is a `404` (the file stored under a manifest id
is the manifest JSON, so it must never be handed over as a bundle). Everything else resolves as a
stored file: the name as given first, then `<name>.assetbundle`.
`Server/CDN/CdnStorageNames.cs` (new) holds the two read-side rules the blob routes share - one bare
file name in that one directory (no separator, no `..`), never a name ending in the admin upload's
`.uploading` suffix, and `<id>` resolving to `<id>.assetbundle` - and `DataCDN`, `RoomsCDN` (both the
`/cdn/room` + `/unity` blob route and the raw `{assetId}/{target}/{version}` route) and
`InventionCDN` all go through it. Two things fall out of that: `/data/<name>.uploading` no longer
serves a half-imported bundle by name, and the bare `/unity/{id}` alias now answers for a bundle
stored only as `<id>.assetbundle` - the hard link per bundle that Run 31 needed is no longer
required.
### Verified
`ServerTests/UnityAssetMetadataTests.cs` (21 cases: both spellings of three id shapes, the Studio
manifest with and without a bake, the manifest never served as a bundle, an id that only looks like a
manifest, unknown names, separators and `.uploading`, out-of-contract target/version, and the blob
routes) - 21/21. `CdnRangeRequestTests`, `StudioUploadTests`, `AdminRoomImportTests`,
`AdminRoomPartUploadTests`, `AdminBundleBindingTests` together with it: 59/59.
Live on a local instance (Development, cwd `Server`, `http://127.0.0.1:3031`, storage
`Server/RadicadeServer-Data/CDN/Storage`), the URL from the player's log:
```
GET /rooms/unity_assets/Showdown_0_1.assetbundle/0/1 -> 200
{"UnityAssetId":"Showdown_0_1.assetbundle","Target":0,"Version":1,
"Filename":"Showdown_0_1.assetbundle","RejectedDependenciesFilename":null}
GET /rooms/unity_assets/Showdown_0_1/0/1 -> 200 Filename="Showdown_0_1.assetbundle"
GET /rooms/unity_assets/Showdown_0_1.assetbundle -> 200 (the short form)
GET /rooms/unity_assets/not-on-disk.assetbundle/0/1 -> 404 {"success":false,"error":"not found"}
GET /data/Showdown_0_1.assetbundle (Range 0-15) -> 206 16 b, Accept-Ranges: bytes
GET /unity/Showdown_0_1 -> 200 76,719,774 b (was 404 without the hard link)
GET /data/Showdown.room -> 200 1,222,161 b
```
and the refusals, with the file present on disk so the rule is what refuses:
```
GET /data/probe-upload.assetbundle.uploading -> 400 (was 200, a truncated bundle)
GET /rooms/unity_assets/probe-upload.assetbundle.uploading/0/1 -> 400
GET /rooms/unity_assets/..%2F..%2Fappsettings.Development.json/0/1 -> 400
GET /data/../appsettings.Development.json -> 404
```
The probe file was removed afterwards and the instance stopped; nothing left listening on 3030/3031.
Full suite: 1556 passed / 12 failed. Eight of the twelve fail identically with this change stashed
out (the catalog set the Run 30 note records: `AdminGiftSkinTests`, `ConnectionRecoveryTests`,
`DorkNetCatalogTests` x2, `EquipmentInventoryTests` x2, `StorefrontRewardConfigTests`,
`WeeklyChallengeTests`), and the other four (`GameRewardFlowTests` x2, `RecNetShapeConformanceTests`
x2) pass when their classes run alone and mutate the same two statics -
`DBConfig.DefaultAvatarCatalog` / `DefaultAvatarUnlocks` - that `AvatarCatalogSourceTests`,
`AvatarWireFormatTests` and `EquipmentInventoryTests` write from parallel collections. That race is
pre-existing and is not this change's.
Not verified: nothing was deployed, so production still answers the `400` until the server is
rebuilt and restarted, and the client has to rejoin room 247 to confirm the load.
## Run 35 (2026-10-15, live): the join box had no sender, so the client had no name to put on it
### The box already carried a sender - no room could set one
RecNet's `Gift.FromPlayerId` is the field the client reads to name a box's sender, and the panel's own
Gift Player page already lands there (`AdminGiftFromTests`). A room's join gift built its `Gift` in
`RoomJoinGiftUtils.TryDeliverAsync` and left `FromPlayerId` at its default of zero, so every join box
arrived from nobody, and no room setting could change that.
### What changed
- `RoomData.JoinGiftFromPlayerId` (bigint, `0` = no sender), added both by
`20261015120000_AddRoomJoinGiftFromPlayerId` and by `EnsureRoomJoinGiftColumns` for a database whose
migration history is already past that point.
- `RoomJoinGiftUtils.TryDeliverAsync` puts it on the box it hands out on join.
- The admin panel's Room Details modal carries a FromPlayerId inside the Gift Box On Join block
(`assets/js/rooms.js` in `Radicade-Admin`), and `AdminPanelAPI.UpdateRoom` reads
`join_gift_from_player_id`, refusing anything that is not digits rather than dropping it quietly.
The id travels as digits in a string, never as a JSON number: an account id is eighteen digits, a JSON
number is read back as a double, and its last digits are rounded away - so the panel would save a
different account than the one it was shown. The room row the panel reads and the value it posts are
both text, the same way the Gift Player page already posts ids.
Only a sender that actually changes bumps `JoinGiftVersion`, so a save that leaves the field alone does
not hand every player a fresh box.
### Verified
`ServerTests/RoomJoinGiftFromTests.cs`, 8/8, driving the real routes and then reading the box the way a
client does (`GET api/avatar/v2/gifts`, asserted on the wire JSON): the saved sender becomes the box's
sender; an eighteen-digit id survives the panel both ways; a changed sender re-issues the box while a
save that leaves it alone does not; a blank clears it back to no sender; a payload without the field
keeps the stored sender; a non-digit value is a `400`; and a room that names no sender still hands out
its box.
Live on a local instance (Development, cwd `Server`, `http://127.0.0.1:3031`, SQL Server
`.\SQLEXPRESS`) the migration applied on startup and the route behaved:
```
Applying migration '20261015120000_AddRoomJoinGiftFromPlayerId'.
GET /admin/panel/rooms -> 200 all 45 rooms carry
"join_gift_from_player_id": "0"
PATCH /admin/panel/rooms/58 {"join_gift_from_player_id":"114903603133502227"}
-> 200 {"success":true}
GET /admin/panel/rooms/58 -> 200 "join_gift_from_player_id": "114903603133502227"
"join_gift_version": 2 (was 1)
PATCH /admin/panel/rooms/58 {"join_gift_from_player_id":"not-a-player"}
-> 400 'join_gift_from_player_id' must be
an account id in digits, or blank
for no sender. (room untouched)
PATCH /admin/panel/rooms/58 {"join_gift_from_player_id":"","join_gift_version":1}
-> 200 "join_gift_from_player_id": "0"
"join_gift_version": 1
```
Room 58 was left exactly as it started (no sender, version 1) and the instance was stopped; nothing
left listening on 3031.
Not verified: the modal itself was not clicked in a browser - the panel sits behind SSO, so the served
page redirects before the room list loads. `rooms.js` was parsed as a classic script in the browser
(`new Function` over the served file, no `SyntaxError`) and its field markup and save handler read;
nothing was deployed, so production needs a rebuild and a restart, at which point the column arrives
with the rest of the startup migrations.
Full suite: 1566 passed / 10 failed / 1 skipped, and every failure is one the Run 34 note already
records as pre-existing: `AdminGiftSkinTests` (x1), `ConnectionRecoveryTests` (x1),
`DorkNetCatalogTests` (x2), `EquipmentInventoryTests` (x2), `StorefrontRewardConfigTests` (x1),
`WeeklyChallengeTests` (x1), and the two order-dependent `RecNetShapeConformanceTests` avatar-catalog
cases that mutate `DBConfig.DefaultAvatarCatalog` / `DefaultAvatarUnlocks` from parallel collections.
The two `GameRewardFlowTests` scheduled into that same race passed this run.
+139
View File
@@ -0,0 +1,139 @@
# Forced new-account onboarding: what the 2026-10-12 runs proved
The client-side force (`2023Patch/RadicadeSeptemberClient/ForcedNewAccountOnboarding.cs`) has been
removed. This file records what it measured, because the measurements are the useful part and
several of them contradict the guesses that built it.
The conclusion they point at: **this is a server-side problem.** The 2023 client decides everything
about the title screen from data the server sends it, and every attempt to override that decision
inside the client either did nothing or produced a state the client was never designed to be in.
## 1. Where the title screen decision is actually made
From the Beta install's `BepInEx/LogOutput.log`, in this order, before the token request:
```
[Onboarding] enter KFGCEPNLFBA (BaseRouteFlowModel route-flow update)
[Onboarding] enter NHAJFLJDHBA (route-flow getter)
[Onboarding] exit NHAJFLJDHBA => HHGLIBCDIDA
[Onboarding] enter get_IsInAccountCreationState
[Onboarding] exit get_IsInAccountCreationState => false <- and then "Welcome back!"
[Net] Post /auth/connect/token [AuthGrant] grant_type=cached_login
```
So the question the client asks itself is "am I mid-account-creation?", answered **before** it
decides how to sign in, from the player-settings markers it holds:
- `Recroom.AccountCreation.HasStarted`
- `Recroom.AccountCreation.HasFinished`
and it reads those back out of `GET /playersettings/playersettings`. Answer `false`, and the client
takes the cached-account route and resumes with `grant_type=cached_login`.
`get_IsInAccountCreationState` and `get_IsInCachedAccountState` are both `public bool` getters on
`RRUI.Data.TitleScreenFlowModel` — cheap to patch, and the first one is definitely called. Overriding
it was tried: it answers the question the *client* asks, but the client is not the only thing that
decides, so the result was a different sign-in, not a different flow (see 3).
## 2. The flow update crashes when the cached-account list is not empty
`RRUI.Data.TitleScreenFlowModel.OFOIOHEFGAB()` is the client's own title-screen update. In the runs
where the server returned a real cached account, it threw, immediately after that response arrived:
```
[Net] Get /auth/cachedlogin/forplatformid/{redacted} -> 200 bytes=179
[Onboarding] enter OFOIOHEFGAB
Exception: Il2CppInterop.Runtime.Il2CppException: System.NullReferenceException
at RRUI.Data.TitleScreenFlowModel.OFOIOHEFGAB ()
```
In the run where the same endpoint answered `[]` (2 bytes, no account), the same method did **not**
throw, and no `[ForceOnboard] ... OFOIOHEFGAB threw` line was recorded all run. Same client build,
same plugins; the only difference was that one response.
That member is what moves the title screen to its next page. While it throws, no page — the
client's or a patch's — is applied, which is why "the hook installed but the screen did not move"
looked like a patching problem for so long. It is worth finding out whether the client is being
handed a cached account that is missing something the real service always sends.
What the server sends for that endpoint today (live, `forplatformid/0/76561198624498265`):
```json
[{ "accountId": 18, "platform": 0, "platformId": "76561198624498265",
"lastLoginTime": "2026-10-12T14:41:14.8984545", "requirePassword": false }]
```
## 3. Overriding the state changes the sign-in, not the flow
With `get_IsInAccountCreationState` forced to `true`:
```
[ForceOnboard] forced get_IsInAccountCreationState false -> true
[HttpStatus] #6 Get /auth/cachedlogin/forplatformid/{redacted} -> 200 bytes=2 (empty)
[AuthGrant] connect/token grant_type=create_account (was: cached_login)
[HttpStatus] #8 Post /auth/connect/token -> 200 bytes=703
... token claims carry "userId":"19" ...
[HttpStatus] #26 Post /matchmaking/player/login -> 200
```
The client left the cached-account path, which is what was wanted, and then **provisioned a brand-new
account** rather than being put through the flow for the account that already existed. The creation
pages' own hooks (`SubmitAvatarCustomizationAndGoToNext`, `SubmitAccountCreationBirthdayAndGoToNext`,
`AcceptCodeOfConductAndGoToNext`, `LaunchGameAccountCreation`) never fired: the client did not walk
the pages, it just made an account and launched.
So forcing the state gets a new account, not onboarding. The account that *needs* onboarding is
chosen by the server, from `CachedLogins` and the creation markers.
## 4. Two server-side things found on the way
**The standalone guard cannot fire for this client.** `Server/Auth/Cached/CachedLoginAPI.cs`:
```csharp
if (platform == Platforms.Standalone) return Ok(new List<CachedLogin>());
```
`Platforms.Standalone` is `7`, but this client identifies as platform `0` (**Steam**, with a SteamID64
as its platform id — `"rn.plat":"0"`, `"rn.platid":"76561198624498265"` in its own token). That guard
therefore never applies to the build being tested, and the row is returned.
**Two SteamIDs appear for the same machine.** `...24498265` (account 18, the one with the cached
row) and `...23533377` (the identity that created account 19). A platform change explains it, but it
means `CachedLogins` is keyed on the *platform identity*, not on the machine, and a device that
changes platform looks like a brand-new device to every "has this device been here before?" rule in
`AuthAPI` and `PlayerUtils`.
## 5. Client interop facts worth keeping
`RRUI.Data.TitleScreenFlowModel`, from `BepInEx/interop/Assembly-CSharp.dll` (metadata, not inferred):
| Member | Signature |
|---|---|
| `HBKLANJACMF` | `void (JPDJBDGFIGP)` — the page navigator; its parameter is *named* `ENNJBOCEGDH`, which is why a named-value-type prefix was rejected by HarmonyX |
| `DBHMKMHNJOO` | `JPDJBDGFIGP ()` — the reported page |
| `get_IsInAccountCreationState` | `bool ()` |
| `get_IsInCachedAccountState` | `bool ()` |
| `OFOIOHEFGAB` | `void ()` — the title-screen update |
| `KFGCEPNLFBA` | `void ()` — the route-flow update |
| `get_LocalAccount` | `INMPLDLFHAF ()` |
| `LaunchGameCachedAccount` / `LaunchGameAccountCreation` | `void ()` |
| `SubmitAccountCreationBirthdayAndGoToNext`, `SubmitAccountCreationUsernameAndGoToNext`, `SubmitAccountCreationPasswordAndGoToNext`, `AcceptCodeOfConductAndGoToNext`, `SubmitAvatarCustomizationAndGoToNext` | `void ()` |
Page enum `JPDJBDGFIGP`: `TITLE_SCREEN_NEW_ACCOUNT = 8`, `TITLE_SCREEN_CACHED_ACCOUNT = 9`,
`TITLE_SCREEN_INTERRUPTED_ACCOUNT_CREATION = 10`, `AVATAR_CUSTOMIZATION = 18`, `BIRTHDAY = 19`,
`USERNAME_NON_JUNIOR = 20`, `USERNAME_JUNIOR = 21`, `PASSWORD = 22`.
`TitleScreenFlowModel` also exposes `initialBootPage`, `titleScreenNewAccountPage`,
`titleScreenCachedAccountPage`, `titleScreenInterruptedAccountCreationPage`, `avatarCustomizationPage`,
`birthdayPage`, `usernamePage`, `passwordPage`, `accountCreationCodeOfConductPage`,
`accountCreationLaunchGamePage` as `TitleScreenPage` properties, and the flow navigates by
`RRUI.Navigation.Route` through `RRUI.Data.BaseRouteFlowModel`.
## 6. The one thing not to repeat
The force was gated on a marker file, and an early build wrote that marker when the flow reached its
own end **regardless of whether anything had been forced**. A plain walk of the vanilla flow
therefore consumed the arming, and every later boot printed
`the marker file is present, so nothing will be forced` — which is exactly what the 01:26, 10:41 and
10:43 logs all say. Two test runs in a row were spent on a force that was switched off by its own
bookkeeping, and the gate now says so in the log rather than in a comment.
+160
View File
@@ -0,0 +1,160 @@
# Gift context dump and implementation handoff
Snapshot: 2026-10-08. Source: current local server checkout.
This is the complete server GiftContext enum, not an independently verified client enum dump.
Numeric gaps are intentional. Context identifies a gift's source; it does not supply its item or implement its challenge.
## Friendotron
- There is no enum member named Friendotron.
- POST /econ/api/freegifts/v1/sendmultiple currently returns an empty array and does not grant anything (Server/API/Compatibility/CompatibilityAPI.cs).
- GET /api/config/v1/freegiftbutton currently returns false (Server/API/Config/ConfigAPI.cs).
- The placeholder config contains Friendotron_AllowSelfGifting and Friendotron_MaxUsersPerSession (Server/DB/DBConfig.cs). Placeholder entries do not establish the live database configuration.
- Notification enum FreeGiftButtonItemsAdded = 110 exists in Server/RecNet/Enums/Messages.
- Local inspection log .tmp-gift.log identifies RecNet.MultiRecipientFreeGiftRequestDTO, DIEKLMGBLKA.RequestPurchaseForFreeGiftButtonDTO and DIEKLMGBLKA.GiftItemDTO. Their names are leads, not a verified request schema.
- Next implementation work: inspect the target client's request/response/config schemas and context choice; implement recipient validation, eligibility/cooldowns, gift selection, durable grants and retry deduplication; then enable the feature with the expected config payload. Do not assume Purchased_Gift_A is Friendotron's context merely because it is used for purchased gifts.
## Weeklys
- All_Weekly_Challenge_Complete = 4.
- Weekly_Challenge_Complete = 6.
- RewardType.WeeklyCompleteButHaveSkin = 4 is a DIFFERENT enum from GiftContext.
- Server/API/Progression/Challenges/V2/ChallengeV2.cs has GET getCurrent returning {} and POST updateProgress returning an empty successful response, under both /api/challenge/v2 and /econ/api/challenge/v2.
- Server/API/GameRewards/V1/GameRewardsAPI.cs maps WeeklyCompleteButHaveSkin to All_Weekly_Challenge_Complete. Its BuildReward method constructs token choices of 300, 600 and 900 for that reward type; this does not implement weekly challenge progress or a weekly skin award.
- Next implementation work: recover the target client's challenge DTOs/objectives; provide current weekly definitions, period dates and reward items; persist authenticated progress and completion; grant the configured reward once per period; verify the already-owned-skin fallback and rollover behavior.
## Gift delivery and wire format
- Server/RecNet/Avatar/Gift.cs stores Context as GiftContext and exposes integer GiftContext as an alias. Id and GiftPackageId alias GiftDropId; Message aliases Tooltip; GiftRarity aliases Rarity.
- Item payloads include AvatarItemDesc, ConsumableItemDesc, EquipmentPrefabName / EquipmentModificationGuid, and CurrencyType / Currency. A context alone does not specify a skin.
- POST /api/avatar/v2/gifts/generate (also /econ/api/avatar/v2/gifts/generate) accepts GiftContext or MFPELEJLJLD; AlternateGiftContext or CIEHFJEIOJJ; Message or DPAJDIEBNKM; and Xp / XP. The alternate context is parsed but is not used to select the reward in this method.
- Generation first looks for an outstanding reward, a pending box, or a recently handed-over box; supported activity contexts can resolve a room reward. Otherwise BuildGeneratedGift makes a random token box of 10, 25, 50, 100, 250 or 500. Sending context 4 or 6 alone is therefore not a weekly-skin implementation.
- Generated gifts are persisted with notify:false because the response hands the box to the client.
- Server/API/Storefront/StorefrontShared.cs provides PersistGiftBoxAsync and SendGiftPackageNotificationAsync. Reuse the existing grant/claim lifecycle rather than inventing a separate inventory path.
- Server/Utils/GameRewardRedemption.cs handles outstanding/pending/recent reward reuse; Server/Utils/RroRewardPools.cs maps activity contexts and pools.
- AvatarV2 also implements gift claim and consume routes. Exercise generation, notification, claim, consume, retries and reconnects with the target client before declaring a new reward flow complete.
## Full GiftContext enum
| Value | Name |
| ---: | --- |
| -1 | None |
| 0 | Default |
| 1 | First_Activity |
| 2 | Game_Drop |
| 3 | All_Daily_Challenges_Complete |
| 4 | All_Weekly_Challenge_Complete |
| 5 | Daily_Challenge_Complete |
| 6 | Weekly_Challenge_Complete |
| 10 | Unassigned_Equipment |
| 11 | Unassigned_Avatar |
| 12 | Unassigned_Consumable |
| 20 | Reacquisition |
| 21 | Membership |
| 30 | NUX_TokensAndDressUp |
| 31 | NUX_Experiment1 |
| 32 | NUX_Experiment2 |
| 33 | NUX_Experiment3 |
| 34 | NUX_Experiment4 |
| 35 | NUX_Experiment5 |
| 50 | GameRewards |
| 51 | GameRewards_Tokens |
| 100 | LevelUp |
| 500 | Purchased_Gift_A |
| 501 | Purchased_Gift_B |
| 502 | Purchased_Gift_C |
| 503 | Purchased_Gift_D |
| 1000 | Holiday |
| 1001 | Contest |
| 1002 | Promotion |
| 1003 | SubscribersOnly |
| 1100 | Deprecated |
| 1200 | RecRoyale |
| 2000 | DEPRECATED_Paintball_ClearCut |
| 2001 | DEPRECATED_Paintball_Homestead |
| 2002 | DEPRECATED_Paintball_Quarry |
| 2003 | DEPRECATED_Paintball_River |
| 2004 | DEPRECATED_Paintball_Dam |
| 2005 | DEPRECATED_Paintball_DriveIn |
| 2010 | Paintball_ClearCut |
| 2011 | Paintball_Homestead |
| 2012 | Paintball_Quarry |
| 2013 | Paintball_River |
| 2014 | Paintball_Dam |
| 2015 | Paintball_DriveIn |
| 3000 | DEPRECATED_Discgolf_Propulsion |
| 3001 | DEPRECATED_Discgolf_Lake |
| 3010 | Discgolf_Propulsion |
| 3011 | Discgolf_Lake |
| 3500 | Discgolf_Mode_CoopCatch |
| 4000 | Quest_Goblin_A |
| 4001 | Quest_Goblin_B |
| 4002 | Quest_Goblin_C |
| 4003 | Quest_Goblin_S |
| 4004 | Quest_Goblin_Consumable |
| 4010 | Quest_Cauldron_A |
| 4011 | Quest_Cauldron_B |
| 4012 | Quest_Cauldron_C |
| 4013 | Quest_Cauldron_S |
| 4014 | Quest_Cauldron_Consumable |
| 4100 | Quest_Pirate1_A |
| 4101 | Quest_Pirate1_B |
| 4102 | Quest_Pirate1_C |
| 4103 | Quest_Pirate1_S |
| 4104 | Quest_Pirate1_X |
| 4105 | Quest_Pirate1_Consumable |
| 4200 | Quest_Dracula1_A |
| 4201 | Quest_Dracula1_B |
| 4202 | Quest_Dracula1_C |
| 4203 | Quest_Dracula1_S |
| 4204 | Quest_Dracula1_X |
| 4205 | Quest_Dracula1_Consumable |
| 4206 | Quest_Dracula1_SS |
| 4500 | Quest_SciFi_A |
| 4501 | Quest_SciFi_B |
| 4502 | Quest_SciFi_C |
| 4503 | Quest_SciFi_S |
| 4504 | Quest_Scifi_Consumable |
| 5000 | DEPRECATED_Charades |
| 5001 | Charades |
| 6000 | DEPRECATED_Soccer |
| 6001 | Soccer |
| 7000 | DEPRECATED_Paddleball |
| 7001 | Paddleball |
| 8000 | DEPRECATED_Dodgeball |
| 8001 | Dodgeball |
| 9000 | DEPRECATED_Lasertag |
| 9001 | Lasertag |
| 10000 | DEPRECATED_Bowling |
| 10001 | Bowling |
| 11000 | StuntRunner_TheMainEvent_A |
| 11001 | StuntRunner_TheMainEvent_B |
| 11002 | StuntRunner_TheMainEvent_C |
| 11003 | StuntRunner_TheMainEvent_D |
| 11004 | StuntRunner_TheMainEvent_S |
| 11005 | StuntRunner_TheMainEvent_X |
| 11006 | StuntRunner_TheMainEvent_Consumable |
| 11007 | StuntRunner_TheMainEvent_SS |
| 100000 | Store_LaserTag |
| 100010 | Store_RecCenter |
| 110000 | Consumable |
| 110100 | Token |
| 110200 | Punchcard_Challenge_Complete |
| 110201 | All_Punchcard_Challenges_Complete |
| 200000 | Commerce_Purchase |
## RewardType enum (separate numeric namespace)
| Value | Name |
| ---: | --- |
| 0 | FirstActivityOfDay |
| 1 | DailyChallengesComplete |
| 2 | PostGameActivity |
| 3 | PostQuestActivity |
| 4 | WeeklyCompleteButHaveSkin |
| 5 | Levelup |
| 6 | ReferralReward |
## Scope and validation
Enum values were expanded directly from the C# source, including explicit resets and implicit increments. JSON is in docs/gift-context.json. No runtime code or live data was changed. Friendotron's exact client contract and weekly challenge payloads still require client inspection; this document does not claim those features work.
+72
View File
@@ -0,0 +1,72 @@
# Keepsakes status
Keepsakes are enabled for all rooms, with a room limit of 10 and the catalog's
per-category limits. The September client tool defaults to enabled through
Keepsakes.EnableTool in the BepInEx configuration. RADICADE_KEEPSAKE_TOOL can still
be used to override it (skip disables hooks; off installs observation-only hooks).
## Confirmed client compatibility fixes
The October 4 client log showed repeated FNKNJGBLBHO Keepsake runtime null references,
a disabled client tool patch, and GET /api/keepsakes/events/1. Native inspection of
the installed 20230831 client established these contracts:
- GGHPHLPMAAD reads room data as Instances, CollectionRecords, and
KeepsakeProgressionEventIds. The server previously omitted Instances entirely.
Instances now contains all active room instances, with the caller's collection
records separate, so collected items remain available for count/category lookup.
- EBHENPDHDLN reads event data as Instances and CollectionRecords, each containing
RoomId/KeepsakeInstanceIds groups (FHGMHPEJIGJ). GET events/{id} now serves this.
- AMAOGLDGNLL posts placement to /api/keepsakes with RoomId, SubRoomId and
KeepsakeCategory, and expects a GUID response. Collection is POST
/api/keepsakes/{guid}/collect; deletion is DELETE /api/keepsakes/{guid}.
The older room-based routes remain available.
- JNOKNEBCEMD and NIAPIJPPFCM require KeepsakeRoomLists to contain room DTO objects,
not bare numeric room IDs. Lists now include event identity and unlock settings.
AllRooms enumerates database rooms for the event definition rather than emitting
an empty list that cannot grant room eligibility.
- BAEFNEHEPIJ reads a player progression record (AccountId, Xp, RewardsCollected,
BonusRewardsCollected, GameMinutesToday, XpBoostLastPurchasedAt), not another copy
of the event definition. Event XP derives from stored collection records. A repeat
pickup returns zero additional XP; no player-level XP is awarded by these routes.
Inspection output is under artifacts/keepsake-*.txt. The client patch now uses
explicit September type references and handles zero-argument overloads safely.
No exception suppression was added.
## Validation and installation
56 Keepsake/startup compatibility tests pass, including the actual client request
flow, non-null empty lists, object-shaped event room membership, per-player
collection and XP, placement limits, ownership, removal, and config overrides.
The server and September client plugin both build successfully.
The client DLL was installed in the 2023-09-06T06-53-47Z game folder with a backup
named RadicadeSeptemberClient.dll.before-keepsake-fix-20261004-214443. The normal
local server output was rebuilt. No remote deployment or in-game verification was
performed. Start the rebuilt server and relaunch the game, then place a keepsake,
save/rejoin, collect/rejoin, and delete it. Other unrelated glitches have not been
verified fixed by this work.
## Account authorization fix (October 5)
The screenshot's account authorization banner is a login-token issue, not a room
CAN_INVITE permission. Native inspection of FNKNJGBLBHO.DKKIJJLCNLH shows a call
to BLJANPFJMDG slot 4 (IBDFIOPDFBD.MABOKPPEEMA), which reads the account's Keepsake
flag. DAJDMOPMEGG.NOGOHJKFDFC initializes it from the JWT role `keepsake`; its
neighboring event-edit flag comes from `livekeepsakeeventroomsaveoverride`.
AuthAPI.GenerateToken now issues both when KeepsakeFeature.Enabled is true.
It grants neither developer nor moderator access. The separate room co-owner
check remains in place. Existing tokens need a fresh login after the server update.
Validation: 77 authentication, Keepsake, and startup compatibility tests passed,
including feature-enabled and feature-disabled JWT role checks.
The latest available logs (October 4, 23:40) also show PrefabSpawner.Append and
PrefabSpawner.Replace null references during CompositeController.OnModelChanged,
plus Harmony/IL2CPP startup type-loading warnings. These are not established as
fixed by the token change. No generic exception suppression was added. Evidence:
artifacts/keepsake-auth-check.txt, keepsake-account-auth-real.txt, and
keepsake-auth-roles.txt; full UI stacks are in Unity's Player.log.
The event is now disabled in the shipped configuration (EventEnabled: false), independently of Keepsake placement. Active-event lookup returns JSON null; room payloads advertise no progression event. Set EventEnabled to true to restore the event. The DynamicUiPrefabRepair client patch fills missing JNHHEBGEAMP mappings using a controller-compatible multi-select float prefab.
+30
View File
@@ -0,0 +1,30 @@
# Quest August 31 standalone port
Source: C:/RecRoom/RecRoom-Quest-20230831 (unmodified).
Working files and APK: artifacts/quest-20230831.
Separate package: com.radicade.questaugust, version 2368643.
Original IL2CPP SHA256: 656e5f0e2d83859d4f0c768db601367d5a9d69f6f7902221bd3b90b1b62703cb.
Unity SHA256 matches September's guarded scoped TLS patch exactly.
Metadata header: literal table offset field 0x74, size 0x24, data offset 0xa0, size 0x3c. Eight server literals redirected. The initial candidate data field 0x98 failed validation and was not patched; 0xa0 was verified using the version literal and bounds.
Device runtime inspector produced quest-method-map.tsv and quest-recnet-fields.tsv.
August native changes, guarded by original IL2CPP hash:
- Oculus GetAuthParams 0x3945b40 -> PlatformManager 0x36ad1f4.
- CurrentPlatform 0x394490c -> Standalone 0x4415134.
- Title error 0x3871f38 and cached-login error 0x3877758 -> GoToLogin 0x386ce14. Class method ordering matched September; cached-login error literals independently confirmed.
- Preserve Oculus initialization through SetVRDisplayMode at 0x3945124; redirect at 0x3945128 to Standalone.Initialize 0x4415188 with original frame restoration.
- Input dispatch table 0xbf18740: slot 7 from 0x3f13fc0 to Oculus slot 1, 0x3f13fd0.
compat.c ports September's narrowly scoped image request key-ID hint, exact-placeholder image acceptance, fresh HTTP connection experiment, and path/status/quit logging. August RVAs: chunk reader 0x8f926cc; current URI 0x8f8b598; absolute path 0x9dc711c; quit 0x3899f70; send 0x715e560; image verifier 0x668b438. All four hook prologues matched exact expected bytes. Image verifier identified from its signature-error literal and call after Base64 parsing. TLS verification remains enabled; placeholder acceptance is image compatibility, not cryptographic image authentication.
Signed RadicadeQuest20230831.apk passed apksigner verification after 16 KiB zip alignment. OBB packing passed CRC and entry-list checks; headset SHA256 matches PC: 241328edeecbfeb53321b5fee62edb9284ba4b7880abcc69e5848204d5545a47 (1,415,411,861 bytes).
September app remains separate. August login/gameplay validation pending.
User subsequently confirmed August login and gameplay work. Remember-password request: original independent-login guard rejected cached/refresh grants and suppressed platform account discovery, so the checkbox could not resume login.
Server update a998b5f pushed to origin/2023: remembered Standalone requests require a signed, unexpired HS256 session header bound to client recroom, platform 7, device namespace, account, token version and password-hash fingerprint. Invalid sessions fall back to password; bare IDs and synthetic legacy refresh GUIDs remain insufficient. Successful Standalone auth returns the session in X-Radicade-Quest-Session. Thirteen focused tests passed, including expired/forged/wrong-device/wrong-account/wrong-platform/revoked/password-changed rejection. Existing cached-account enumeration remains hidden.
RadicadeQuest20230831-Remember.apk signed/verified and installed in place. remember.c includes remember.h: private mode-0600 session file (no password stored), checkbox state from ManuallyLogin's require-password field, scoped HTTPS token request header, response Receive hook saving only successful server-issued session header, rejection/logout cleanup. Hooks start after five seconds to precede startup cached login. Exact prologues guarded. Logout trampoline explicitly relocates its fourth ADRP instruction to the verified page 0x10010000. No credential values logged. Server deployment and login/restart/logout testing pending; September APK has not received remembered-login changes.
2026-10-07 restart verification: installed APK SHA256 matches Remember.apk (02a3c9061d494c0686c028238fb982c2219285b396f66391afa2703ecd801159). All three remembered-login hooks reported active. Private session file existed with mode 0600; contents were not printed. Force-stop/relaunch of com.radicade.questaugust produced session-attached diagnostic, HTTP 200 from /auth/connect/token, renewed-session saved, HTTP 200 from /accounts/account/me and /matchmaking/player/login, then room requests. No manual-login event occurred in this startup capture. Automatic session resume verified for this restart; logout/rejection UI behavior and September port remain untested.
+153
View File
@@ -0,0 +1,153 @@
# Quest 20230907 server test
## Current investigation: post-login `doll` and saved-account fallback (2026-10-07)
After the user supplied a valid birthday, login proceeded further. At 19:18:22 client emitted fatal quit 1550502249 after a Hile report; VPS message was `33489407,7`. Exact September disassembly locates the comma-pair formatter in HGEONHBDDLK.PLGOEDKHHBI (0x4f91ffc), gated by `AntiHile.QD`, reporting type 701. Live config verified that key=true. GameConfigAPI now overrides only that key=false; two focused tests pass for seeded true and missing-key cases, including preservation of unrelated values and stored data. Default Debug test build hit a locked DLL; isolated QuestConfigTest build passed. Commit 04826ca pushed to origin/2023 after cherry-picking the tested local 0b7894b onto remote 2a2df52 in artifacts/quest-config-integration. Primary dirty workspace preserved. VPS deployment and gameplay retest pending. Separate crm/me/config exception observed; not established as the cause of this shutdown.
FreshHTTP test at 19:07 passed the formerly failing current-saves response, outfits, and image verification (placeholder acceptance logged 19:07:54.993). No fatal quit or hex parsing error appeared through 19:09; heartbeats continued. Unity explicitly logged `Junior: False` at 19:07:38.968 but `meta junior login error` at 19:07:50.632. User screenshot confirms false database flags, JuniorState=0, and an unset birthday (year 0001). BuildSelfAccount recalculates juniorState=PendingOverride (2) when birthday is absent/year<=1900 regardless of stored zero. Client TitleScreenFlowModel.EJKBHHGALEH -> HBGNIICMAMG -> BKJMLBBNEFL.FBOEAHDMPJF checks the self-account state at +0xa8 for 2 before falling back to its Junior boolean, then checks Meta hardware. This supports incomplete birthday onboarding as the remaining restriction, not an actual true Junior flag. No birthday, Junior flag, or restriction check has been modified.
ImageCompat login at 19:02:38 authenticated successfully (token 200 and self-account 200), but failed before image loading. At 19:02:42.494 the custom-avatar-current-saves response began (200) without a matching chunk end. At 19:02:42.635 Unity reported `Can't parse '' as a hex number!`; manual login returned `doll` at 19:02:43.596. A later matchmaking heartbeat hit the same parsing error. No image-placeholder acceptance or fatal-quit event occurred during this attempt, so the image fix remains unverified. Filtered capture: image-compat-events.log.
FreshHTTP.apk is a one-variable transport experiment on top of ImageCompat: HTTPManager.SendRequest sets IsKeepAlive=false via its runtime setter only for https://ns.radicade.app/ requests. This tests connection reuse as a cause; it does not establish the root cause or suppress parser errors. TLS and response validation are unchanged. Native compilation and APK signature verification passed. Device login test pending.
ImageKey test at 18:52 confirmed the request hint reached the VPS and changed the failure from `Signature malformed` to `Signature verification failed`. Headset log `image-key-events.log` records this failure followed by fatal quit code 1550502249. User supplied matching VPS reports. The server's SignatureData is a fixed 256-byte placeholder, not a signature of the returned image.
Built, apksigner-verified, and installed ImageCompat.apk in place, preserving data. Exact-build image verifier HFFEEGBKKFK.DGDJIHJHHBN at RVA 0x6deb934 is reached after header/key-id parsing. The hook recognizes only the exact server placeholder byte array and accepts it in this image-only path; all other signatures call the original verifier. This intentionally relaxes image authenticity checking for that placeholder, does not provide cryptographic image authentication, and does not alter TLS certificate validation or fatal-quit behavior. Source: tools/quest-http-inspect.c and tools/quest-image-placeholder.h. Prologue verified against original ELF and compilation passed -Wall -Wextra -Werror. First alignment attempt failed in sandbox; elevated alignment succeeded. Login outcome pending. No server update required for this test.
Confirmed next trigger from user-provided VPS reports: two `Obcured` reports say `Signature malformed` for the same image URL with `sig=p1`. QuitInspect captured two fatal quits with code 1550502249 and a delegate-invocation caller; shutdown's SpeedHackDetector exception follows them. Live `/images/{name}` returned key-id `KEY:RSA:p1.rec.net`, whereas patched Quest metadata expects `.radicade.internal`. This is an image-signature key-ID mismatch, not proof of a Junior account restriction or TLS certificate-pin failure. Existing PC ImageSignatureRequest.AddKeyId already solves that header selection mismatch with `rcsig`.
Built, verified and installed ImageKey.apk in place. Added an exact-prologue-checked HTTPManager.SendRequest hook (RVA `0x688e200`) that adds the expected rcsig only to `https://ns.radicade.app/images/` requests, preserves resize/signature query parameters, and uses the IL2CPP write barrier when replacing the URI. Existing hinted/fragment URLs are left unchanged. Original TLS and RSA verification remain active; the hook activates after the diagnostic 30-second delay. A live request with that parameter returned HTTP 200 and the correct `.radicade.internal` key ID. Headset acceptance remains pending; the server still supplies static signature bytes, so successful signature cryptographic verification is not established. Capture: `artifacts/quest-tls-diagnostic/image-key-events.log`.
After deployment, PID 21799 received `/outfits/me` HTTP 200 at 14:21:37 and progressed to version check and additional startup requests. The user saw “Account not supported” before exit. Two `/api/PlayerReporting/v1/hile` requests preceded exit; Android recorded signal 9 after the activity stopped at 14:21:48. A public account-2 probe returned isJunior=false, which does not establish private self-account state. The client's SessionManager message table maps error 83 to the Meta Junior restriction text, but the triggering code has not yet been captured.
At the user's request, inspected https://github.com/recflare/quest at `43efb7b9f46a335e978af5520979d8a4d65e81f2` in `artifacts/recflare-quest-reference`. It targets April 2023, not this September build. Its certificate-pinning and fatal-quit diagnostics suggest a separate post-TLS integrity check worth testing; they do not establish our cause. No repository build scripts or bypass hooks were run. Added an independently implemented exact-prologue-checked trace at this client's SessionManager.FatalApplicationQuit RVA `0x3a1c0b4`, logging code/caller/reason length only and preserving behavior. Signed/verified QuitInspect.apk installed in place. Activation is not yet confirmed because the app exited before diagnostic output; user asked to keep headset awake at the password page. No new server changes or account edits made.
The user reported the VPS updated after `a808239`. An unauthenticated live check of `/outfits/me` now returns 401 instead of 404, consistent with the new protected route being deployed. Reopened HttpInspect-v3 on the connected Quest as PID 21799; filtered capture is `artifacts/quest-tls-diagnostic/http-after-outfit-deploy.log`. Authenticated response/client acceptance is still pending a manual login.
Server fix committed and pushed to origin/2023 as `a808239`: authenticated `GET /outfits/me` now wraps the existing account avatar in the September current-outfit DTO. Exact-client metadata resolved the request state machine `MOEENPJECCH` returning `FMLIPOPOKDJ`; generated formatter `JOJEHOPNNIN` defines DataVersion, LegacyData, CustomizationSettings, Selections, Slot, Name, Accessibility and ThumbnailFileName. Legacy formatter `DEAAHJJLAAE` defines SelectionsV1, SelectionsV2, FaceFeatures, SkinColor and HairColor. The initial adapter uses version 0 legacy data, current slot 0 and an empty modern selections list. Client acceptance of the response remains pending VPS deployment and a headset test. All three QuestOutfitsTests passed, covering account isolation, auth rejection, JSON shape and missing-avatar initialization.
Later capture at 14:04:24–26 reproduced empty chunk-length errors on `/datacollection/data/event` and `/matchmaking/player/heartbeat`, after the 14:02:29 login failure. The intermittent transport issue is therefore not proven fixed. Avatar descriptor warnings also remain unresolved. The field inspector now supports secondary Android users (active user 10) and records RecNet interface fields as well as runtime fields; it reads type metadata only.
HttpInspect-v3 captured a complete manual login attempt at 14:02:21–29. Password token, account/me and matchmaking login all returned HTTP 200. Every observed chunked response completed, including the avatar catalog. `/outfits/me` returned HTTP 404 at 14:02:24; the client reported `AMIIDJOEAMH: HTTP Error 404` and manual-login `doll` at 14:02:29. This attempt did not reproduce the earlier empty chunk-length parse error. The client also logged invalid legacy avatar-item descriptors, a separate compatibility concern. The missing outfit route is the current concrete server lead; its response contract is being inspected before implementation.
Follow-up: the user confirmed the password page but signing in exited PID 14518 with signal 11 at 13:57:06. The v2 diagnostic was unsafe: optimized call sites for `ReadChunkLength` do not preserve its unused receiver argument, so interpreting that argument as an HTTPResponse can dereference invalid memory. Do not reuse HttpInspect or HttpInspect-v2. Updated the hook to `ReadChunked` (RVA `0x6d941e0`), whose receiver is actually consumed, and verified its four copied prologue instructions against the original binary. Built and signature-verified HttpInspect-v3.apk, installed in place, and launched PID 16445. This corrects the identified diagnostic defect; the original post-login `doll` issue remains unresolved pending a captured attempt. Filtered continuous capture is saved locally in `artifacts/quest-tls-diagnostic/http-v3-events.log`.
The user successfully interacted with the v3 password form using Touch controllers. Supplied server logs show the headset password grant accepted, followed by authenticated account and matchmaking requests. Password authentication itself succeeded; gameplay has not been reached. At 01:26:07 the client reported `doll` with BestHTTP `ReadChunkLength` failing to parse an empty string as hexadecimal. Successful server request logs do not identify which response failed in the client.
Added `tools/quest-http-inspect.c`, an exact-prologue-checked experimental native hook that logs HTTP chunk reads using response identity, status and URL path only. It does not log passwords, authorization headers, query strings or response bodies. The diagnostic activates after 30 seconds; this timing remains a diagnostic workaround.
A subsequent saved-account auto-login followed `TitleScreenFlowModel.MNKODPJLNDD`, a different error callback from the initial cached-login path, and showed error 3 before exit. The client patch now redirects that callback at RVA `0x39c67e0` to the existing password page (`GoToLogin`, `0x39bbd80`) as well. Server cached-login rejection remains intact.
Built, signed, verified and installed `artifacts/quest-tls-diagnostic/HttpInspect-v2.apk` as an in-place update to `com.radicade.questtls`. No uninstall or data clearing occurred. Quest PID 14518 logged cached-login error 3 at 13:55:58, then activated the chunk diagnostic at 13:56:11 and remained running. Visual confirmation of the password fallback and a fresh manual login with the diagnostic active are pending. No additional server change or deployment was made for this diagnostic.
## v3 controller dispatch test (2026-10-07)
The user confirmed v2 displays the password form but physical Touch controllers do not interact, although they work in the Meta menu. Static analysis resolved `InputManager.FMJDLCGGGKP`'s virtual call at `0x460a254` as CurrentPlatform (same vtable slot used by `get_CurrentPlatformIsOculusType`). Its dispatch table at RVA `0xc35a550` maps Standalone=7 to return no VR input (`0x460a2b0`). Oculus=1 dispatches to `0x460a2c0`, which selects the existing Quest input handler for hardware type 5.
The v3 script changes only that table entry in addition to v2 changes. It preserves password-based platform-7 server authentication and selects Oculus input using the original hardware check. Installed signed `StandaloneLogin-v3.apk` in the separate diagnostic package without uninstalling. Patched IL2CPP SHA-256 is `1a646b3c85ecf8101e7c131cd87012ab6a9764d01685addee03d2251a811243b`. Signature verification passed; controller interaction and end-to-end password login require headset testing. No additional server changes were made.
## v2 prototype reaches password page (2026-10-07)
Server commit `835534b` was pushed to origin/2023 and the user deployed it. A credential-free live probe returned the specific standalone password-required response, confirming the new guard is active. Those two deployment-probe cached-login 401s were not headset password attempts.
The expanded read-only inspector saved `artifacts/quest-tls-diagnostic/quest-method-map.tsv`. Runtime method names resolve the Quest-specific original initialization call at `0x3d09f14` to `RecRoom.LocalPlayerController.SetVRDisplayMode`; v1 skipped it. The v2 patch preserves original initialization through that call, then invokes standalone account initialization with the original stack frame correctly restored. It also redirects `TitleScreenFlowModel.DALDIFKKDDA` to its existing `GoToLogin` method. This error redirect is currently broad (all title-screen errors), so this remains an experimental build.
Installed `StandaloneLogin-v2.apk` into `com.radicade.questtls` without uninstalling. PID 15954 reported Standalone/OculusQuest, received the expected cached-login 401, and stayed running. An ADB screenshot visually confirmed the username/password page, rather than the error screen. Controller interaction and successful password authentication remain pending user testing. No password was supplied or logged. The original-package TLS-fixed APK and both OBBs remain available. Signature verification, ARM64 stub disassembly, 16 KiB alignment, and Python syntax checks passed.
## Independent-login prototype: not playable; rolled back (2026-10-07)
Added local server support for wire platform 7 (`Standalone`) with password-only login. It clears caller-supplied account identity before resolving the username, generates a local `radicade-quest:` device namespace, ignores bearer headers for this password grant, rejects cached-login/refresh/signup grants, and suppresses unauthenticated standalone cached-account discovery. Existing whitelist and anti-cheat checks remain active. These are experimental server changes, not deployed or pushed. Refresh and new-account onboarding are not implemented; an existing Radicade account with a password is required. With whitelist enabled, the derived local identity must currently be explicitly allowed.
All 26 `AuthOnboardingFlowTests` passed, including five standalone cases covering correct/incorrect passwords, correct token identity, hidden cached accounts, and rejection of device-only cached/refresh/signup requests.
`tools/patch-quest-standalone-login.py` reproduces a failed client experiment: three exact-build checked branches redirect Oculus initialization/auth parameters/platform enum to existing standalone implementations. Only 12 bytes in `libil2cpp.so` differ from the TLS-fixed clone. The signed APK `artifacts/quest-tls-diagnostic/StandaloneLogin.apk` passed v2/v3 verification and 16 KiB alignment. On Quest (PID 12596), it passed platform initialization and logged `Platform: Standalone`, `Hardware: OculusQuest`, but then attempted cached login, received HTTP 401, and displayed error code 3. The user reported no usable hands. A screenshot confirmed the error screen; the cause of missing interaction is not established. This is NOT a working Quest login implementation, and the initialization replacement must not be promoted to a release.
Restored `RadicadeQuest-ScopedTLS-Test.apk` over the separate diagnostic package without uninstalling (ADB Success), then launched the unchanged original-package TLS-fixed app. Both OBBs and private data were retained. Before another client test, trace Quest input initialization and the title-screen transition to password login separately; do not simply replace the full initialization method again. The user reports deploying by pushing Git and gives `C:\radicade-server` as the server folder. No deployment workflow was found in this checkout; production update/restart mechanics are not yet verified.
## Latest result: TLS fixed; Meta account initialization remains blocked (2026-10-07)
The currently installed original-package APK is `artifacts/quest-tls-diagnostic/RecRoomQuest-ScopedTLS-Test.apk`, SHA-256 `064fbf29f4c3340abf45863664fd84af1404a74b46c64fa08d733f40654bdffa`. It retains package `com.AgainstGravity.RecRoom`, versionCode `2378787`, and the original patched metadata. The only changed APK payload is `lib/arm64-v8a/libunity.so`; signing metadata also changes. The user explicitly approved replacing the previous differently signed test app and its resulting private-data deletion. The OBB was restored and its SHA-256 again matched `ef856a56bfe1667f00435c2b07fb465766196c02bac8b220c7cc36db926998fc`.
Native instrumentation found that platform-root X509 validation of `ns.radicade.app` succeeded (`result=0`, `raw_flags=0`, `unity_flags=0`), but the configured managed verification callback subsequently returned `0x8`. This demonstrates an additional client-side rejection; its managed implementation/pinning policy has not been identified. Changing server TLS versions or certificate issuers did not address it.
`tools/diagnose-quest-tls.py --radicade-system-trust` routes only the exact hostname `ns.radicade.app` through Unity's existing default verifier (including platform CA chain and hostname validation), preserving the configured callback for all other hosts. It does not return unconditional verification success. The script accepts only the exact known Unity library SHA-256, uses checked empty RX-segment padding, and preserves existing native instructions outside the three hook sites and RX segment length fields. Without that option it only adds diagnostic logging. The native library SHA-256 for the scoped variant is `37c5709cca6b5dff019e16685a819d50d064d3a4c19f45d642cb5383011e23d7`.
Both the separate diagnostic package (`com.radicade.questtls`) and the original-package scoped build passed TLS and loaded game configuration. The original-package run (PID 6353) remained running past the former exit point, but logged `GetLoggedInUser failed: The user isn't signed in or their account state wasn't in a recoverable state. (2|-1)`, followed by `Unable to load Oculus user account`. Meta's platform logs also show `OBTAIN_TOKEN_3P` failing with HTTP 400 for this package. No account proof, platform identity, entitlement result, or server authentication policy was forged or changed. Gameplay/login is not yet working.
The original signing password was unavailable. The new local diagnostic signing key and password file are in `artifacts/quest-tls-diagnostic/diagnostic.p12` and `diagnostic.password`, excluded by that directory's `.gitignore`; retain these for non-destructive future updates. A separate diagnostic app and its OBB remain installed. Signed v2/v3 verification, 16 KiB alignment, original-package payload comparison, checked ARM64 disassembly, and on-device positive TLS tests passed. Rejection-path behavior has not yet been independently exercised with an invalid server certificate.
The VPS currently serves ZeroSSL and has Caddy debug enabled. It is running in the user's foreground PowerShell window. The older sections below describe the investigation history, not the current installed build.
### Platform integration investigation (2026-10-07)
Added `tools/quest-platform-inspect.c`, a read-only JNI diagnostic which enumerates selected runtime class methods and field offsets without reading account values or invoking authentication methods. Installed it only in the separate `com.radicade.questtls` diagnostic app as `artifacts/quest-tls-diagnostic/PlatformInspect.apk`. The original-package TLS-fixed APK and both OBBs were retained. The first inspector run raced Unity initialization and aborted; delaying inspection by 30 seconds allowed the second run (PID 10871) to complete. This delay is a diagnostic workaround, not a general initialization synchronization guarantee.
The complete successful method inventory is `artifacts/quest-tls-diagnostic/platform-methods.txt`; selected ARM64 disassembly is `platform-disassembly.txt`. Confirmed actual Quest RVAs: `OculusPlatformManager.Initialize` = `0x3d09d8c`, `OculusPlatformManager.GetAuthParams` = `0x3d0a930`, `StandalonePlatformManager.Initialize` = `0x43d6f0c`, and `PlatformManager.GetAuthParams` = `0x396ad48`. The standalone manager exists in the Android build; its platform getter returns enum value 7 and its PC-platform getter returns true. Therefore simply swapping the entire manager would change device behavior and is not yet a validated Quest integration. The server's current Platforms enum does not define value 7. No platform methods or server authentication behavior were changed by this diagnostic.
Next implementation work must preserve Quest hardware behavior and add authenticated Radicade account credentials/session validation before enabling an independent login path. The existing server device-code implementation auto-approves a configured Studio account; it must not be reused as Quest account authorization. A successful metadata inspection does not establish gameplay compatibility or successful login.
### Confirmed remaining Meta failure
The user confirmed their Meta profile and other online apps work. Correlated platform logs for the original-package fixed build show `https://graph.oculus.com/authenticate_application` returning HTTP 400, OAuth error code `27`, subcode `1891543`, title `Unofficial Application`. The response explicitly identifies the app as modified and disables platform functionality. This is an app-authorization rejection, not evidence that the user's headset account needs resetting. Changing Caddy or reinstalling the same modified APK cannot supply the original publisher's signing identity. Remaining work requires a separately designed Radicade sign-in integration (for example, server-validated account login/device pairing or an appropriately registered Meta app), preserving account authentication rather than fabricating a Meta user/proof or accepting arbitrary platform IDs.
The supplied folder is an apktool-decoded Android package plus an OBB asset tree. Its APK manifest is `com.AgainstGravity.RecRoom`, Quest/Quest 2 6DoF, ARM64, and the loaded Unity version literal is `20230907` (not the desktop `20230831` build).
## What is ready
`tools/patch-quest-client-metadata.py` recognizes this exact Quest metadata layout, resolves its live IL2CPP string-literal table, and redirects its bootstrap/auth/site/image URLs to `https://ns.radicade.app`. It keeps the image-signature tail `.radicade.internal`. It is dry-run by default, verifies the rebuilt table against every original literal, writes a `.orig` backup and a patch marker only with `--apply`, and refuses unknown/mismatched builds.
From the repository root, first preview the changes:
```powershell
python tools/patch-quest-client-metadata.py 'C:\RecRoom\RecRoom-Quest-20230907' --server https://ns.radicade.app
```
The image rule targets the server's signed `/images/img/{name}` route; the domain's `/images/avatar/` is not the image endpoint and returns a large generic response. The account's `GET /` nameserver advertises all other service URLs under the same server.
## Earlier state: NSC test APK installed and launched on Quest 3
The Quest 3 is connected via ADB. The local test-signed build was installed after uninstalling the prior test-signed package (the original Store app had already been removed earlier). The app was launched as a VR activity on-device; Unity initialized the OpenXR/Oculus runtime and reached `Connecting to RecNet`. Its TLS request still fails with `Mono.Security.Interface.TlsException: Handshake failed - error code: UNITYTLS_INTERNAL_ERROR, verify result: 4294957312`, then the RecNet nameserver query reports `summer` and the app exits around 17 seconds after start.
A second APK variant adds the official Let’s Encrypt Root YR cross-signed-by-ISRG-Root-X1 certificate as a raw resource and trusts it only for `ns.radicade.app`, while retaining Android system trust and disallowing cleartext. It was aligned for 16 KiB pages and signed with a separate local test key; Apktool build, `apksigner` v2/v3 verification, `zipalign`, package/version/ARM64 checks and inspection of the packaged NSC all passed. Installing it required uninstalling the previous local test package because its signing certificate differed; the user's consent was obtained. The matching OBB was restored from the PC copy and SHA-256 verified identical (`ef856a56bfe1667f00435c2b07fb465766196c02bac8b220c7cc36db926998fc`). This certificate-resource experiment did not change the TLS error or startup exit.
The currently installed APK is `C:\\Users\\user\\Downloads\\RadicadeQuest20230907\\RecRoomQuest-radicade-nsc-test.apk` (package `com.AgainstGravity.RecRoom`, version `20230907`, versionCode `2378787`). Its narrow Android trust config is `apk/res/xml/network_sec_config.xml`, and the cross-signed CA resource is `apk/res/raw/isrg_root_yr_by_x1.pem`. This test shows that adding an Android Network Security Config trust anchor does not fix this Unity `MobileAuthenticatedStream` TLS handshake, so do not keep changing Android trust settings or weaken TLS validation; investigate the TLS chain/protocol at UnityTLS or server-side and keep certificate validation enabled.
The headset OBB at `/sdcard/Android/obb/com.AgainstGravity.RecRoom/main.2378787.com.AgainstGravity.RecRoom.obb` is present and hash-matches the local build. Uninstalling the test app removed the on-headset OBB directory, so the local copy was pushed back and verified after install.
## Earlier APK/OBB build notes
The pre-existing working tree had a decoded Quest APK (`apk/`) and separately decoded OBB asset tree (`obb/`), not the original archives or publisher signing key. The untouched source under `C:\\Users\\user\\Downloads\\RecRoom-Quest-20230907` was not modified. A separate working copy under `C:\\Users\\user\\Downloads\\RadicadeQuest20230907` has the metadata patch applied; Apktool 3.0.3 built its APK, which was page-aligned and signed with local-only debug keys. Package, version, min SDK 25, target SDK 32 and ARM64 native code were validated. A new `tools/package-quest-obb.py` validated all 22,199 OBB entries and built the matching expansion-file name (1,394,848,868 bytes); the on-headset copy now has a verified matching hash. Android platform-tools 37.0.1, build-tools 36.1.0 and Apktool were downloaded into `%LOCALAPPDATA%\\Radicade\\QuestToolchain`.
The remaining significant functional blocker is identity: this APK is built for Oculus/Meta and its IL2CPP metadata contains Oculus Platform APIs (`GetUserProof`/`GetLoggedInUser`-related flows), but the server currently only verifies Steam tickets when `platform == Steam`. Account creation also accepts only platform IDs prefixed `75` or `76`, and the existing allowlist is named/configured as a Steam ID list. The server version-check route currently allows all versions, but `20230907` is not in `DBConfig.SupportedGameVersions`; this does not prove the rest of the server protocol works. The endpoint patch does not add Meta identity verification, change server authentication policy, or port the Windows BepInEx/Harmony plugin to Android.
The metadata patch remains reproducible with the dry-run command above. The current test APK and matching expansion file are installed and have been run on the Quest 3; the app still exits after UnityTLS handshake failure during nameserver discovery. New Meta account onboarding is not supported/verified; existing linked-user login is unknown.
## Safe next steps
1. Keep this test APK/OBB and current headset install available for controlled TLS investigation. Do not uninstall/reinstall again without a reason: that deletes app-private data and Android also removes the OBB directory.
2. Investigate the failure within Unity's `MobileAuthenticatedStream`/UnityTLS against the exact chain served by Caddy (leaf `ns.radicade.app` → YR1 → Root YR cross-signed by ISRG Root X1). Do not disable certificate or hostname validation; the domain-scoped Android NSC root addition had no effect.
3. If the TLS request succeeds, resolve and test Meta/Oculus identity support with the Radicade server before attempting account creation. Do not weaken authentication based on an assumed Oculus ID shape.
## TLS 1.2-only test (2026-10-06 Pacific)
The user identified the server as a netcup Windows VPS, running C:\Caddy\caddy.exe v2.11.4 with C:\Caddy\Caddyfile. The live Radicade site already used key_type rsa2048. The user added protocols tls1.2 tls1.2. Reload initially failed because Caddy was no longer running; the user started it in a foreground PowerShell window.
External OpenSSL checks confirmed that TLS 1.2 succeeded with certificate verification OK and TLS 1.3 was rejected with protocol-version alert 70. A fresh launch of the existing Quest APK (PID 29072) still produced UNITYTLS_INTERNAL_ERROR, verify result 4294957312, followed by nameserver error summer and OnApplicationQuit at approximately 15.2 seconds. The process was absent afterward. TLS 1.2-only did not resolve startup; certificate-chain compatibility remains unproven. No APK reinstall or OBB change was performed. The temporary TLS restriction should be reverted on the VPS; rollback has not yet been confirmed. Caddy currently depends on the user's foreground PowerShell process remaining running.
Rollback confirmed: the user successfully reloaded via 127.0.0.1:2019, and an external TLS 1.3 handshake subsequently succeeded with certificate verification OK. The TLS 1.2-only restriction is no longer active.
## ZeroSSL chain test
After the user changed the Radicade site's ACME issuer to ZeroSSL and reloaded successfully, an external OpenSSL probe confirmed the served chain changed to ns.radicade.app -> ZeroSSL RSA DV SSL CA 2 -> Sectigo Public Server Authentication Root R46 -> USERTrust RSA Certification Authority, with verification OK. The new chain was active before the Quest launch.
The existing Quest APK was launched again (PID 30187). It reached Connecting to RecNet at 9.44 seconds, reported the identical UNITYTLS_INTERNAL_ERROR / verify result 4294957312 at 13.16 seconds, then nameserver error summer and OnApplicationQuit at 15.14 seconds. The process exited. Changing issuer did not fix startup. This weakens a Let's Encrypt-specific explanation but does not establish the exact validation failure. ZeroSSL remains configured on the VPS; no rollback has been performed.
Read-only inspection confirmed that the test APK's live metadata literals 41405 (auth) and 41411 (nameserver) both resolve to https://ns.radicade.app. The headset reports Android 14, and both /system/etc/security/cacerts and /apex/com.android.conscrypt/cacerts contain certificate files. This does not establish that Unity successfully loads or trusts those files. Further diagnosis should correlate Caddy TLS handshake errors with a fresh Quest launch before making additional certificate or client changes.
## Correlated Caddy debug and headset trust-store checks (2026-10-07 Pacific)
Quest PID 31620 again failed at about 13.29 seconds and quit at 15.27 seconds. The user supplied Caddy debug lines at 07:06:07 UTC showing SNI ns.radicade.app, supported protocol versions [771,770,769] (maximum TLS 1.2), successful selection of the ZeroSSL certificate, and handshake EOF. EOF alone does not identify the client's reason for closing.
The numeric Unity verify result 4294957312 is signed -9984 (-0x2700), matching Mbed TLS's certificate verification failed code; this correspondence suggests certificate validation failure but does not identify the precise Unity verification flag or callback.
Pulled the headset's 134 public system CA certificates to artifacts/quest-system-cacerts and built artifacts/quest-system-ca-bundle.pem. OpenSSL TLS 1.2 verification of the live Radicade server succeeded with this CAfile, -no-CApath, -no-CAstore, -verify_return_error, and -verify_hostname ns.radicade.app. This establishes that the served chain can validate with the headset's public system roots using OpenSSL, not that Unity loads these roots or validates identically. Investigation should focus on Unity's trust loading/custom validation and chain-building behavior rather than assume a missing system root or TLS version mismatch. Caddy debug remains enabled; ZeroSSL remains active.
+96
View File
@@ -0,0 +1,96 @@
# Weekly challenges and RadAdmin
The player routes are GET `api/challenge/v2/getCurrent` and POST
`api/challenge/v2/updateProgress`, also under `econ/`. Both require player auth.
The server stores progress in `WeeklyChallenges`, keyed by player and map ID.
The new `20261008120000_AddWeeklyChallenges` migration is applied by the existing
server startup migration step. This work has not been deployed or game-tested.
## Add-A-Challenge
RadAdmin's Challenges page saves library entries through the existing
`/admin/panel/challenges` CRUD API. A gameplay preset generates the client config;
names, descriptions and tooltips are editable. The initial supported presets are
single-event goals: finish an activity, tag a player, defeat an AI enemy, capture
a flag, and block with a shield. Count/streak and arbitrary legacy configs are
not yet supported by the weekly validator.
## Publishing a weekly
Open **Challenges**, choose the current week or one of the next three weeks, and:
1. Enter the display title and duplicate-item token amount (1–10,000).
2. Search Equipment skins or Avatar items and select the exact catalog item.
3. Select three to five library challenges. Create any missing goals with New challenge.
4. Leave Published checked and click Publish weekly. Uncheck it and save to disable the week.
An unscheduled week has no challenges or reward. There is no built-in lineup or reward
rotation. Existing saved schedules remain supported. Publish the current week after
upgrading if it previously used the old automatic rotation.
GET `/admin/panel/weekly-challenges` returns four `weeks`, with map IDs, dates,
content and `Revision`. GET `/admin/panel/weekly-challenges/rewards` returns searchable
catalog choices with `key`, `kind`, `name`, and `detail`. POST the schedule:
```json
{
"ChallengeMapId": 100000,
"Revision": "revision from GET, or empty for a new schedule",
"Title": "Weekly Challenges",
"Enabled": true,
"ChallengeIds": [1, 2, 3],
"RewardKey": "exact key returned by the reward catalog endpoint",
"FallbackTokens": 300
}
```
All admin routes use existing admin-key validation and RadAdmin's same-origin proxy.
The server resolves the reward against its loaded catalog; arbitrary gift payloads
+418
View File
@@ -0,0 +1,418 @@
[
{
"Name": "None",
"Value": -1
},
{
"Name": "Default",
"Value": 0
},
{
"Name": "First_Activity",
"Value": 1
},
{
"Name": "Game_Drop",
"Value": 2
},
{
"Name": "All_Daily_Challenges_Complete",
"Value": 3
},
{
"Name": "All_Weekly_Challenge_Complete",
"Value": 4
},
{
"Name": "Daily_Challenge_Complete",
"Value": 5
},
{
"Name": "Weekly_Challenge_Complete",
"Value": 6
},
{
"Name": "Unassigned_Equipment",
"Value": 10
},
{
"Name": "Unassigned_Avatar",
"Value": 11
},
{
"Name": "Unassigned_Consumable",
"Value": 12
},
{
"Name": "Reacquisition",
"Value": 20
},
{
"Name": "Membership",
"Value": 21
},
{
"Name": "NUX_TokensAndDressUp",
"Value": 30
},
{
"Name": "NUX_Experiment1",
"Value": 31
},
{
"Name": "NUX_Experiment2",
"Value": 32
},
{
"Name": "NUX_Experiment3",
"Value": 33
},
{
"Name": "NUX_Experiment4",
"Value": 34
},
{
"Name": "NUX_Experiment5",
"Value": 35
},
{
"Name": "GameRewards",
"Value": 50
},
{
"Name": "GameRewards_Tokens",
"Value": 51
},
{
"Name": "LevelUp",
"Value": 100
},
{
"Name": "Purchased_Gift_A",
"Value": 500
},
{
"Name": "Purchased_Gift_B",
"Value": 501
},
{
"Name": "Purchased_Gift_C",
"Value": 502
},
{
"Name": "Purchased_Gift_D",
"Value": 503
},
{
"Name": "Holiday",
"Value": 1000
},
{
"Name": "Contest",
"Value": 1001
},
{
"Name": "Promotion",
"Value": 1002
},
{
"Name": "SubscribersOnly",
"Value": 1003
},
{
"Name": "Deprecated",
"Value": 1100
},
{
"Name": "RecRoyale",
"Value": 1200
},
{
"Name": "DEPRECATED_Paintball_ClearCut",
"Value": 2000
},
{
"Name": "DEPRECATED_Paintball_Homestead",
"Value": 2001
},
{
"Name": "DEPRECATED_Paintball_Quarry",
"Value": 2002
},
{
"Name": "DEPRECATED_Paintball_River",
"Value": 2003
},
{
"Name": "DEPRECATED_Paintball_Dam",
"Value": 2004
},
{
"Name": "DEPRECATED_Paintball_DriveIn",
"Value": 2005
},
{
"Name": "Paintball_ClearCut",
"Value": 2010
},
{
"Name": "Paintball_Homestead",
"Value": 2011
},
{
"Name": "Paintball_Quarry",
"Value": 2012
},
{
"Name": "Paintball_River",
"Value": 2013
},
{
"Name": "Paintball_Dam",
"Value": 2014
},
{
"Name": "Paintball_DriveIn",
"Value": 2015
},
{
"Name": "DEPRECATED_Discgolf_Propulsion",
"Value": 3000
},
{
"Name": "DEPRECATED_Discgolf_Lake",
"Value": 3001
},
{
"Name": "Discgolf_Propulsion",
"Value": 3010
},
{
"Name": "Discgolf_Lake",
"Value": 3011
},
{
"Name": "Discgolf_Mode_CoopCatch",
"Value": 3500
},
{
"Name": "Quest_Goblin_A",
"Value": 4000
},
{
"Name": "Quest_Goblin_B",
"Value": 4001
},
{
"Name": "Quest_Goblin_C",
"Value": 4002
},
{
"Name": "Quest_Goblin_S",
"Value": 4003
},
{
"Name": "Quest_Goblin_Consumable",
"Value": 4004
},
{
"Name": "Quest_Cauldron_A",
"Value": 4010
},
{
"Name": "Quest_Cauldron_B",
"Value": 4011
},
{
"Name": "Quest_Cauldron_C",
"Value": 4012
},
{
"Name": "Quest_Cauldron_S",
"Value": 4013
},
{
"Name": "Quest_Cauldron_Consumable",
"Value": 4014
},
{
"Name": "Quest_Pirate1_A",
"Value": 4100
},
{
"Name": "Quest_Pirate1_B",
"Value": 4101
},
{
"Name": "Quest_Pirate1_C",
"Value": 4102
},
{
"Name": "Quest_Pirate1_S",
"Value": 4103
},
{
"Name": "Quest_Pirate1_X",
"Value": 4104
},
{
"Name": "Quest_Pirate1_Consumable",
"Value": 4105
},
{
"Name": "Quest_Dracula1_A",
"Value": 4200
},
{
"Name": "Quest_Dracula1_B",
"Value": 4201
},
{
"Name": "Quest_Dracula1_C",
"Value": 4202
},
{
"Name": "Quest_Dracula1_S",
"Value": 4203
},
{
"Name": "Quest_Dracula1_X",
"Value": 4204
},
{
"Name": "Quest_Dracula1_Consumable",
"Value": 4205
},
{
"Name": "Quest_Dracula1_SS",
"Value": 4206
},
{
"Name": "Quest_SciFi_A",
"Value": 4500
},
{
"Name": "Quest_SciFi_B",
"Value": 4501
},
{
"Name": "Quest_SciFi_C",
"Value": 4502
},
{
"Name": "Quest_SciFi_S",
"Value": 4503
},
{
"Name": "Quest_Scifi_Consumable",
"Value": 4504
},
{
"Name": "DEPRECATED_Charades",
"Value": 5000
},
{
"Name": "Charades",
"Value": 5001
},
{
"Name": "DEPRECATED_Soccer",
"Value": 6000
},
{
"Name": "Soccer",
"Value": 6001
},
{
"Name": "DEPRECATED_Paddleball",
"Value": 7000
},
{
"Name": "Paddleball",
"Value": 7001
},
{
"Name": "DEPRECATED_Dodgeball",
"Value": 8000
},
{
"Name": "Dodgeball",
"Value": 8001
},
{
"Name": "DEPRECATED_Lasertag",
"Value": 9000
},
{
"Name": "Lasertag",
"Value": 9001
},
{
"Name": "DEPRECATED_Bowling",
"Value": 10000
},
{
"Name": "Bowling",
"Value": 10001
},
{
"Name": "StuntRunner_TheMainEvent_A",
"Value": 11000
},
{
"Name": "StuntRunner_TheMainEvent_B",
"Value": 11001
},
{
"Name": "StuntRunner_TheMainEvent_C",
"Value": 11002
},
{
"Name": "StuntRunner_TheMainEvent_D",
"Value": 11003
},
{
"Name": "StuntRunner_TheMainEvent_S",
"Value": 11004
},
{
"Name": "StuntRunner_TheMainEvent_X",
"Value": 11005
},
{
"Name": "StuntRunner_TheMainEvent_Consumable",
"Value": 11006
},
{
"Name": "StuntRunner_TheMainEvent_SS",
"Value": 11007
},
{
"Name": "Store_LaserTag",
"Value": 100000
},
{
"Name": "Store_RecCenter",
"Value": 100010
},
{
"Name": "Consumable",
"Value": 110000
},
{
"Name": "Token",
"Value": 110100
},
{
"Name": "Punchcard_Challenge_Complete",
"Value": 110200
},
{
"Name": "All_Punchcard_Challenges_Complete",
"Value": 110201
},
{
"Name": "Commerce_Purchase",
"Value": 200000
}
]