mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
Radicade Public Code
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user