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