feat: lift the CircuitsV2 limits, Rec Royale night mode, RRO room blobs

CircuitsV2 limits are served as UGC.CircuitsV2.Limit.* gameconfig keys and
are editable from admin Settings. Retail drops any send over 5 RPCs/frame
or 1024 bytes/60 frames; both network budgets now default to 1,000,000,000.
The creation limits are raised too, each against the hazard the client
binary shows for it:

- Chips 3000 -> 30000: the chip-culling manager preallocates four
  List<Chip>(Chips), so the 1M clamp stays.
- Compute 50000 -> 500000: a per-frame budget with no allocation; the 10M
  cap keeps its counters from wrapping.
- CloudValues 100 -> 1000, InkPerChip 15 -> 0: InkPerChip is an ink COST,
  multiplied by min(chips, CloudValues), so lowering it is the increase.
  The clamps tighten to 10000 / 30000 to keep that product from overflowing
  (the room ink cap is a hard-coded 30000).
- UnculledChips / VisibleChips 500 -> 3000: render-cost counts only.

Room save uploads may now be up to 100 MB (Kestrel's 30 MB default would
413 a big circuits room). Contract and evidence:
docs/recroom-2023-circuitsv2-limits.md.

Also includes the Rec Royale night mode client feature
(docs/recroom-2023-scene-moods.md) and the admin blob enable/reset for
baked RRO sub-rooms.
This commit is contained in:
Alexa
2026-10-15 14:07:54 +03:00
parent cf3aa29ba2
commit 36e64e99ba
21 changed files with 7994 additions and 34 deletions
+157
View File
@@ -139,6 +139,45 @@ in S3 is modified. Toggle endpoint:
`{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so
flipping the toggle lands within a minute.
## CircuitsV2 limits (`/settings`)
`ServerSettingsEntity.CircuitsV2LimitsJson` backs the
`UGC.CircuitsV2.Limit.*` gameconfig keys the client reads into
`RecRoom.CircuitsV2.Config.LimitConfig`. The panel's headline setting is
the network budget: retail allows a circuit **5 RPCs per frame** and
**1024 bytes per 60 frames**, and silently **drops** every send past
that, which is the usual cause of a busy CV2 room appearing to desync.
DorkNet ships both budgets at 1,000,000,000.
Two things about this are counter-intuitive enough to be worth stating:
- The budgets are spent over a rolling window measured in **frames**, so
widening a window makes the limit *stricter*, not looser. Raise the
budget, not the window.
- The **byte** budget is checked before the **RPC** budget, so raising
`Rpcs` on its own leaves circuits blocked. Both have to move together.
- `InkPerChip` is a **cost** charged against a 30000-ink room cap that is
hard-coded in the client, so lowering it is what gives circuits more
room. DorkNet ships it at 0.
The room-size limits ship at about 10x retail: Compute 500000, Chips
30000, CloudValues 1000, and UnculledChips/VisibleChips 3000.
CloudValuesAutosaveSeconds stays at 300.
Values are clamped on save, and most of the clamps are crash guards
rather than taste. `Rpcs` of 0 divides by zero in the client's CV2 debug
subscreen. A time window allocates one array slot per frame, so an
unbounded window is an out-of-memory kill at circuit init. Chips sets
the size of four list preallocations. InkPerChip × CloudValues is an
unchecked int multiply. Compute is the client's only runaway-loop
guard, so it has to stay finite. Full
reasoning and the disassembly it came from are in
`docs/recroom-2023-circuitsv2-limits.md`.
Endpoints: `GET`/`POST api/admin/v1/settings/circuits-limits`. The client
builds its rate limiter once when circuits start, so **players must
rejoin their room** before a change takes effect.
## Per-sub-room max players (`/rooms` → room detail)
Player caps are two separate settings, and conflating them is the easy
@@ -179,6 +218,124 @@ Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the
advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so
hard enforcement still needs a ClientMod Photon patch.
## RRO room data blob — Maker Pen and chips in a Rec Room Original (`/rooms` → room detail → Sub-rooms)
Baked Rec Room Originals (Rec Royale, Paintball, Laser Tag, …) are seeded
with an empty `RoomSceneEntity.DataBlobName` and empty
`RoomEntity.CurrentDataBlobName`. Every details builder and matchmaking
resolver (`RoomsController.CurrentOrSyntheticDataBlobName`,
`GoToController.ResolveInitialDataBlobAsync`,
`MatchPlayerController`) turns that into an empty `DataBlob`, and the 2023
client reads an empty blob name as "this room has no room data": it skips
the persistence download entirely, so there are no room roles (no Maker
Pen despite the CDN's RRO role overlay), no CV2 room-settings chips, no
room mood engine and no saves. Real Rec Room served these rooms a blob —
Rec Royale's game-rule chip (`RecRoyaleSolos_GameRuleWrapperCircuit` /
`RecRoyaleSquads_GameRuleWrapperCircuit`, prefabs in `resources.assets`)
is delivered that way; the Frontier scene has no baked game rule.
The Sub-rooms tab shows, per sub-room, the blob the client will actually
be told to download (`effectiveBlobName`) and, for baked originals, two
actions:
- **Enable Maker Pen blob** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable`.
Stamps the synthetic default name (`room_{id}_dorknet_v8.dat` for the
entry scene, `room_{id}_dorknet_v8_sub{n}.dat` otherwise) on the scene
and, for sub-room 0, on the room. No resolver changes are involved: they
already return a non-empty name verbatim, and `CdnController` already
answers that name for AG rooms with the RRO-editable blob (permissive
room roles, no objects) while nothing is saved. `IsRRO` is derived from
`IsAGRoom`, so the client keeps loading the baked map and only
additionally fetches the blob. Idempotent; a no-op on a sub-room that
already has a blob. Seeded originals such as Rec Royale have **no
`RoomScenes` row at all** ("Scenes 0"); the list synthesises a virtual
"Home" entry (`isVirtual: true`, max players = room capacity) and enable
creates the real row with the game-side defaults
(`RoomsController.GetOrCreateSceneForMutationAsync`).
What the CDN hands back for that name while nothing is saved is a
**per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`:
the skeleton of a real save (Rec Center's `room_100_v1.dat` was the
reference) — `version` 16 (the client's ceiling; the old roles-only stub
was stamped 19 and the client fetched it and silently loaded the room
with no roles, i.e. no Maker Pen), `activity_id` = the room's
`LocationReplicationId`, `last_save_time`, empty connectable graph /
object model, scene settings and the four `room_mood_*` blocks, plus the
permissive role collection. The miss path now runs the same 2023
version clamp as the S3-hit path. It also carries the room's own three
**game-role objects** (a default role named after the room, plus the
built-in "In-Game" and "Eliminated" roles, well-known GUIDs shared by
every room save — Rec Center and Stunt Runner both have exactly these).
The 2023 client grants Maker Pen off the player's *game* role
(`ICreatorRole.CreatorRoleCanUseMakerPen` on `GameRole`), so a blob with
no role objects leaves even a co-owner with nothing to hold — that was
the second reason "enable + co-owner" still showed no Maker Pen. The
default role sets `can_use_maker_pen` to true, matching the permissive
room-role overlay; the built-in roles mirror the reference saves.
For the two Rec Royale rooms the stub also carries the room's
**game-rule chip** (`RecRoyaleSolos_GameRuleWrapperCircuit` prefab id
`983db81a…`, `RecRoyaleSquads_…` `857f7500…`, read from the client's
spawnable-tool table; a saved Squads chip is header + prefab id + tag +
node id + an empty wrapper payload), so the match logic exists on first
join without anyone spawning it by hand.
- **Upload save** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload?mode=full|basics`,
multipart `file` = a real `PersistedRoomData` save from ANY client
version (`.binpb` / `.room` / `.dat`, e.g. a RecNet export of a custom
Rec Royale room). The server rewrites it for this room
(`RoomDataBlobService.PrepareUploadedRoomBlob`): `activity_id` → the
room's `LocationReplicationId`, drops `output_log`, `sub_room_id` and
every field the 2023 schema doesn't know, clamps the header versions to
the 2023 maxima, and in `basics` mode keeps only the game-role (field 50)
and game-rule (fields 40/41) objects while stripping the circuit payloads
(`circuit_data`, connectable graph, `circuit_v2_data` — a newer
CircuitsV2 graph is rejected by the client's own CV2 version gate no
matter what). Stored under a fresh `room_{id}_upload_*.dat`, recorded in
`RoomDataBlobs`, and the sub-room (and room, for sub-room 0) is pointed
at it. On serve the CDN applies the RRO role overlay and clamp exactly
as for Rec Center's saved blob. Response carries a `summary`
(`viewsIn/viewsKept/roleViews/gameRuleViews/versionIn/versionOut`).
- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`.
Clears both pointers. This is the escape hatch if a save left the room
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
list can bring one back later.
Both are audited (`enable_subroom_blob`, `reset_subroom_blob`). Who can
then save the room in game is the normal rule in `SaveDataCore`: owner,
any admin, or an accepted co-owner (`RoomRoles.Role == 0`). Grant a
co-owner role from the Roles tab if a non-admin should be able to save.
Procedure that makes Rec Royale playable AND editable:
1. Enable the blob on the Solos room's sub-room 0 (and the Squads room's).
2. Join as an admin; the Maker Pen is available. Spawn the room's game-rule
chip (`RecRoyaleSolos_GameRuleWrapperCircuit` in Solos,
`RecRoyaleSquads_GameRuleWrapperCircuit` in Squads) — via the palette's
Game Rules section, or the debug console's `DebugSpawnTool` if it is not
listed — then save. Without that chip there is no `BattleRoyaleManager`
in the scene and the match never starts.
3. From then on the saved blob (with the game-rule chip and whatever
room-settings chips were placed) is what everyone loads. If a save
breaks the room, Reset to baked scene.
Tests: `RroRoomBlobTests`.
### Why Rec Royale still has no Maker Pen after all of the above
The Maker Pen permission check (`AGMBHDNGOEH.IJEIEBGJJLB`) first runs a
room precondition (`LALJEMHEIBH`) that, via `MLKAFHHHEGA.GHPIBJPNOFF` →
`PNPLLCBBMJP.JGPBNEHFLJA`, looks the current room up in
`AGRoomRuntimeConfig` — a table **baked into the client's
`resources.assets`** — and returns a per-original boolean. Rec Center and
Stunt Runner allow creation there; Rec Royale does not. No blob, role or
server response changes that table, and it is consulted only for rooms the
client considers originals (`IsRRO` = our `IsAGRoom`).
Server-side experiment: clone Rec Royale, flip the clone's **Rec Room
Original (IsRRO)** checkbox off on the General tab
(`rooms/{id}/props` `IsAGRoom: false`), upload a real save to it, join. If
the client still loads the Frontier scene for a non-original, Rec Royale
becomes an editable template; if the scene fails to load, the table gate
has to be bypassed client-side (ClientMod postfix on `GHPIBJPNOFF`).
## Test cases ↔ GitHub issues
QA test cases can file and track GitHub issues. Configure with:
+3 -1
View File
@@ -55,7 +55,9 @@ DorkNet.sln
watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex),
Photon AppId override, and TLS trust bypass. It loads under
MelonLoader 0.6.x and reads its settings from a JSON config under
`MelonLoader/UserData/`.
`MelonLoader/UserData/`. Opt-in extras (all off by default) live in the
same config: Desktop Screen-Share FPS, RRO quest party size, and Rec
Royale night mode (see the README and `recroom-2023-scene-moods.md`).
---
+255
View File
@@ -0,0 +1,255 @@
# 2023 client CircuitsV2 limits (`UGC.CircuitsV2.Limit.*`)
How the March-2023 client budgets CircuitsV2, and how DorkNet lifts the
network budget so circuits stop dropping sends. Reverse-engineered from
`RecRoom.CircuitsV2.Config.LimitConfig` in the 2023-03-21 ISIL dump
(`IsilDump/Assembly-CSharp/RecRoom/CircuitsV2/Config/LimitConfig.txt`)
and the limiter chain under `IsilDump/Circuits.V2/`.
## Why it matters
Retail budgets a circuit **5 RPCs per frame** and **1024 bytes per 60
frames**. A circuit that exceeds either budget does not queue or slow
down — the send is **dropped on the floor** and an execution error of
kind 2 (bytes) or 3 (RPCs) is raised, and the room save gets flagged
`cv2_rpc_limited` / `cv2_net_limited`. This is what makes busy CV2 rooms
feel like they're desyncing at random.
DorkNet serves both budgets at 1,000,000,000, which is more than a
circuit can spend in a frame. It also raises the room-size limits
(compute, chips, cloud variables and culling) to about 10x retail.
[The scalar limits](#the-scalar-limits) covers what each one does and
why its value stops where it does.
## The keys
The client reads these through its gameconfig provider, i.e. the array
served by `GET api/gameconfigs/v1/all`. Any key the server omits — or
sends unparseably — silently falls back to a value baked into the
client, with no error logged.
| Key | Type | Client default | DorkNet default |
| --- | --- | --- | --- |
| `UGC.CircuitsV2.Limit.Network` | JSON array | one entry, below | budgets lifted |
| `UGC.CircuitsV2.Limit.Compute` | int | 50000 | 500000 |
| `UGC.CircuitsV2.Limit.Chips` | int | 3000 | 30000 |
| `UGC.CircuitsV2.Limit.CloudValues` | int | 100 | 1000 |
| `UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds` | float | 300 | 300 |
| `UGC.CircuitsV2.Limit.UnculledChips` | int | 500 | 3000 |
| `UGC.CircuitsV2.Limit.VisibleChips` | int | 500 | 3000 |
| `UGC.CircuitsV2.Limit.InkPerChip` | int | 15 | 0 |
The client defaults are the values `LimitConfig..ctor` bakes in (the
`mov dword ptr [rbx+28h..40h]` run in `LimitConfig.txt`). For comparison,
the 2023-06-21 build raised its `COMPUTE_DEFAULT` to 100000 and left the
others unchanged.
Scalars are parsed with `int.TryParse` / `float.TryParse` under
`NumberStyles.Integer` / `NumberStyles.Float` and
`CultureInfo.InvariantCulture` — so no thousands separators, and a dot
decimal separator only.
## The network array
`UGC.CircuitsV2.Limit.Network` is a **top-level JSON array**, parsed by
Newtonsoft (`JsonConvert.DeserializeObject`), not Unity's `JsonUtility`.
Newtonsoft binds public fields and falls back to case-insensitive
matching, but DorkNet emits the client's own field names verbatim:
```json
[{"NumPlayersForActivation":0,"Bytes":1000000000,"BytesTimeWindowFrame60s":60,"Rpcs":1000000000,"RpcTimeWindowFrame60s":1}]
```
The retail value is the same shape with `Bytes` 1024, `Rpcs` 5.
**Entry selection.** The client sorts entries ascending by
`NumPlayersForActivation` and activates the highest one whose threshold
is at or below the room's **max player capacity** (not the live player
count). One entry at threshold 0 therefore applies everywhere.
**Validation.** The client rejects the whole array — falling back to the
retail budget — if it is not valid JSON, is empty, or has no entry with
`NumPlayersForActivation` of 0. `ServerSettingsService` normalizes
around all three: it sorts, de-duplicates thresholds, and rewrites the
lowest entry's threshold to 0.
## The scalar limits
Every getter is consumed in exactly the places listed here. A sweep of
the whole ISIL dump for `LimitConfig.get_` finds only these files.
### Compute: per-frame CPU budget
`CircuitsV2Manager`'s per-frame tick (`OCGDEIJJBAG(float)`, ISIL 240 at
`CircuitsV2Manager.txt:21241`) builds a fresh compute context every
frame through `DIILFAPPLOI` (ISIL 018 at `:12637`), which wraps the value
in `Circuits.V2/BENFNKBKHEA`. That object keeps four int counters. Each
charge adds to one of them and checks the sum against the budget with
`<=`. The default costs come from the `MPKGOFPJCOP` interface: 100, 100,
50 or 500 depending on the kind of operation. When the budget runs out,
circuit work stops for the rest of the frame and the save is flagged
`cv2_cpu_limited`.
Nothing is allocated from this value. **It is the only guard against
runaway loops**, though. Raising it lets a broken loop burn more frame
time before it is stopped. A budget near `int.MaxValue` would let the
summed counters wrap negative so the check never fires. DorkNet serves
500,000, which is 10x retail and 5x the June 2023 build. That allows
1,000 to 10,000 operations per frame, depending on their kind.
### Chips: room chip cap
`CreationManager.OnCircuitsV2LimitsRefreshed` (`CreationManager.txt:3179`)
rebuilds the room's max sandbox resources as
`CostInfo(ink 30000, lights 64, ai 101, numChips = Chips, numCloudVariables = CloudValues)`
(ISIL 050-068). Adding a chip checks `CanAffordResourceCost(OneChip)`
against it. A failed check shows the "Too many chips." toast
(`CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt` ISIL 164-187, 592).
Hazard: the chip-culling manager's constructor preallocates **four**
`List<Chip>(Chips)` (`PAFDMBHNMBP.txt` `.ctor` ISIL 053-108). That costs
32 bytes per unit of the limit whether or not the room uses it, and a
value of 0 or less throws. The clamp is 1..1,000,000, which is 32MB at
the top. DorkNet serves 30,000, which preallocates under 1MB.
### CloudValues: room cloud-variable cap
This is the `numCloudVariables` field of the same max-resources
`CostInfo`. `CreationManager.CalculateActiveResourceCost` also reads it
(ISIL 389-415 at `CreationManager.txt:6136-6162`):
```
inkCost += InkPerChip * Math.Min(numChips, CloudValues)
```
That is an unchecked int multiply. It also means that raising
CloudValues while InkPerChip stays at retail's 15 makes chips use
**more** room ink. Serving InkPerChip 0 removes the term. The clamp was
lowered from 100,000 to 0..10,000 so the product stays at or below 3e8
even with InkPerChip at its own ceiling. DorkNet serves 1,000.
### InkPerChip: ink cost per chip (lower is more)
This is a **cost**, not an allowance. It feeds the
`CalculateActiveResourceCost` term above. The add-chip path also charges
it directly through `CanAffordResourceCost` ("Not enough ink.",
`AMEJFMPHNEO` ISIL 247-265). That direct charge only runs when
`ShowGadgetsToEveryone` is set and `Chips < VisibleChips` (ISIL 221-234),
so with the defaults below it never runs.
The room's ink cap is a **hard-coded 30000** (`0x7530`,
`CreationManager.txt:3374`), not a gameconfig key. Lowering this cost is
therefore the only way to leave circuits more room under that cap. No
client code divides by it. DorkNet serves **0**, so chips use no room
ink. The clamp is 0..30,000, because past 30000 no single chip can be
afforded.
### UnculledChips and VisibleChips: chip culling
The chip-culling manager reads both together (`PAFDMBHNMBP.DJLEFJCOLCF`,
ISIL 079-087 at `PAFDMBHNMBP.txt:2520-2525`). It subtracts
`UnculledChips` from the live chip count and culls that many chips.
`VisibleChips` is used in the same arithmetic further down. Both values
only appear in counts and subtractions: they allocate nothing and are
never divisors. Raising them costs rendering time, not stability.
DorkNet serves 3,000 for both, so every chip in a retail-sized room stays
drawn. Both stay at or below Chips, which keeps the `Chips < VisibleChips`
ink charge above switched off.
### CloudValuesAutosaveSeconds: save cadence
A `CircuitsV2Manager` coroutine reads this value
(`CircuitsV2Manager_NestedType_CDPKCHNIHFG.txt` ISIL 059 at `:193`),
waits that many seconds, then flushes the CircuitsV2 player save-data
manager. It sets how often saves happen, not how much a room can hold. A
shorter interval sends more save traffic to a shared server. A longer
one loses more progress if the client crashes. DorkNet leaves it at
retail's 300 seconds. The clamp stays at 1..3600 and rejects non-finite
values.
### Limits these keys don't raise
- **Room ink 30000, lights 64 and AI controllers 101** are literals in
the max-resources `CostInfo` (`CreationManager.txt:3374`, and again in
the static constructor at `:22761`).
- **Event recursion depth is fixed at 16.**
`Circuits.V2/BCNIMLFCMMD.MENMKOOAJPF` (ISIL 041,
`Compare [rbx+240], 16`) raises "Max recursion depth reached. Do you
have events in a loop?"
- **Room save uploads are capped at 100,000,000 bytes on the server.**
`StorageController.Upload` sets `[RequestSizeLimit]` and
`MultipartBodyLengthLimit` to 100 MB, up from Kestrel's 30 MB default
that a room with tens of thousands of chips could reach. 100 MB is
also Cloudflare's per-request ceiling, so raising it further does
nothing behind the tunnel.
## How the budget is enforced
Each budget is a sliding window with **one bucket per rendered frame**.
`RpcTimeWindowFrame60s` is that window's length in frames, so 1 means
per frame and 60 means roughly per second at 60fps. On each send the
client adds the cost to the current bucket and compares the window's
running sum against the budget.
Two consequences worth keeping in mind:
- **Widening a window makes the limit stricter, not looser** — the same
budget now has to cover more frames. Raise the budget instead.
- The **byte budget is checked before the RPC budget**, so lifting
`Rpcs` alone leaves circuits blocked on `Bytes`. Both have to move.
Enforcement is entirely client-local: each client reads its own
gameconfig and polices its own outgoing sends, and nothing validates
budgets on receive. Photon's own server-side message-rate and bandwidth
caps still apply underneath this and are not affected by these keys.
## Clamps, and why they exist
`NormalizeCircuitsV2Limits` clamps admin input. Most of the clamps are
crash guards read straight off the disassembly:
- **`Rpcs` is floored at 1.** The CircuitsV2 debug subscreen renders
`RpcRecentMax / Rpcs` with a hardware integer divide, so a budget of 0
is a divide-by-zero the moment that panel opens.
- **Time windows are capped at 3600 frames.** A window allocates
`new int[N]` — one bucket per frame — when circuits start, so an
unbounded window is an out-of-memory kill rather than a bigger budget.
- **`Chips` is capped at 1,000,000 and floored at 1**, because it sets
the size of four list preallocations in the chip-culling manager.
- **`InkPerChip` is capped at 30,000 and `CloudValues` at 10,000**,
because the ink calculation multiplies them as unchecked ints.
- **`Compute` is capped at 10,000,000.** It allocates nothing, but it is
the runaway-loop guard, and a value near `int.MaxValue` lets its
counters wrap past the check.
Budgets themselves are capped at 1,000,000,000: the client only ever
compares a counter against them, and this leaves 2x headroom under
`int.MaxValue`.
## Operational notes
- The client builds its rate limiter **once, when circuits start**.
Changing these settings does not affect anyone already in a room —
players have to rejoin.
- Raising `Rpcs` also changes the Maker Pen top bar's network readout,
which renders `<RpcRecentMax> / <Rpcs>`; the ratio collapses toward 0
and the bar effectively tracks byte usage only.
- The keys are additive to the gameconfig array, so older clients that
know nothing about CircuitsV2 just ignore them.
- Defaults are applied when the settings are read. While the
`CircuitsV2LimitsJson` column is empty (no admin has saved the panel),
the server always serves the current defaults. Saving the panel
stores the full record, so every value sticks from then on, including
the ones the admin didn't touch. **Reset** in the panel restores the
current defaults.
## Where this lives
| Concern | File |
| --- | --- |
| Settings record, defaults, clamps, key emission | `DorkNet.Server/Services/ServerSettingsService.cs` |
| Persisted column | `ServerSettingsEntity.CircuitsV2LimitsJson` |
| Admin API | `api/admin/v1/settings/circuits-limits` (GET/POST) |
| Admin SPA | `admin-ui/src/pages/Settings.tsx` → CircuitsV2 limits panel |
| Served to the client | `api/gameconfigs/v1/all` |
| Tests | `DorkNet.Server.Tests/CircuitsV2LimitsTests.cs` |
+68
View File
@@ -132,6 +132,74 @@ role-holder (co-owner/mod/host), nor an instance invitee return
`errorCode=4` (RoomDoesNotExist) so private rooms don't leak their
existence. Tests: `PrivateRoomInstanceTests.cs`.
## Baked Rec Room Originals and data blobs (Rec Royale)
Baked RRO rooms are seeded with empty `DataBlobName` / `CurrentDataBlobName`,
which the details builders and matchmaking resolvers pass through as an
empty `DataBlob`. The 2023 client treats that as "no room data": it never
downloads a blob, so the room has no room roles (no Maker Pen even though
the CDN overlays permissive roles for AG rooms), no CV2 room-settings
chips, no mood engine and nothing to save into. `IsRRO` (= `IsAGRoom`) is
independent of the blob, so a baked room CAN carry one: the client loads
the baked map from its own assets and layers the blob's persistence views
on top — exactly how Rec Center behaves.
Rec Royale needs this to be a game at all: the Frontier scene has no baked
game rule (contrast `PaintballTeamBattle_GameRuleWrapperCircuit`, which is
baked into the paintball scene). Its rule is the Maker Pen chip
`RecRoyaleSolos_GameRuleWrapperCircuit` / `RecRoyaleSquads_GameRuleWrapperCircuit`
(`BattleRoyaleGameRuleWrapper : GameRuleCircuitWrapperTool`), which real
Rec Room delivered inside the room blob. Without it there is no
`BattleRoyaleManager` in the scene.
Stub shape matters. Until something is saved the CDN synthesises the blob,
and the client fetches it through `RecNet.Rooms.GetRoomData` and rejects
one it deems unusable — a clone surfaces that as the bare "Failed to copy
room", a baked room as a silent load with no room roles (no Maker Pen).
Compared against Rec Center's working `room_100_v1.dat`, the old stub was
stamped `PersistedRoomVersion` 19 (client ceiling 16; only the S3-hit path
clamped) and lacked the save skeleton (`last_save_time`, `activity_id` =
LocationReplicationId, connectable graph, scene settings, object model,
`room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that
skeleton per room at version 16.
Second requirement — game-role objects. Both reference saves carry three
`PersistenceViewData` entries whose `game_role_node_data` (field 50) holds a
`PlayerGameRoleData`: the room's default role (Rec Center: "Rec Center",
Stunt Runner: "StuntRunner"; GUID `c48bac02-…` in both), "In-Game"
(`role_id` 2097152, GUID `2c721342-…`) and "Eliminated" (`role_id`
4194304, rank 1015, `can_move` false, GUID `0b9ec39e-…`). Their
`spawnable_tool_data.prefab_id` is the GameRoleNode chip prefab
(`c46e1535…` raw). Since 2023 permissions hang off the player's GameRole,
a blob without these leaves a co-owner with no Maker Pen. The stub now
generates Rec Royale's own set (view ids derived from the activity id).
Game-rule chip. A newer-client export of a custom Rec Royale room showed
the saved `RecRoyaleSquads_GameRuleWrapperCircuit` chip is minimal:
`spawnable_tool_data.prefab_id` `857f7500…`, `tagged_tool_data` tag
`recroyalesquadsgameruleci` (prefab name lower-cased, 25 chars),
`circuit_node_data.id`, and an EMPTY `game_rule_wrapper_data` (field 41).
Prefab ids live in the client's spawnable-tool table in
`resources.assets`: after `SpawnableTool_Runtime_<ENUM>` comes a 20-byte
hash (len-prefixed), two u32s, then the 16-byte prefab id (len-prefixed),
then the enum again, display name and tag. Extracted:
Solos `983db81a68927f419edb0daf1243e93e`, Squads
`857f750016ca77408ae9f72f9877748a` (matches the save), StuntRunner
`28276d92470ff34ab05f72b2707a04d1`. Stunt Runner's other rule-ish object
(`84eb8434…`, `game_configuration_data` field 40) is the generic Game
Configuration chip, optional — `GameConfigurationTool` falls back to its
baked asset. The stub emits the wrapper chip for the two Rec Royale rooms.
Attaching a real save: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/upload`
(multipart `file`, `?mode=basics|full`) rewrites any-version
`PersistedRoomData` for the room — see `docs/admin.md` → "Upload save".
Used to A/B a generated stub against a genuine custom-Rec-Royale save.
Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable`
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data
blob" for the full procedure (enable → spawn the game-rule chip → save).
## Related 2023 quirks fixed alongside
- **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls
+223
View File
@@ -0,0 +1,223 @@
# Rec Room 2023-03-21 — scene moods (sky dome / sun / fog) and Rec Royale night mode
Client-side reference for the room "mood" engine the March-2023 client uses
for Maker Pen sky/sun/fog settings, and how `DorkNet.ClientMod`
(`RecRoyaleNight.cs`) reuses it to give Rec Royale a night variant the game
never shipped. Everything below was read from the Cpp2IL ISIL dump
(`C:\tmp\recroom-2023-03-21-isil`), the interop stubs
(`C:\tmp\recroom-2023-03-21-decompiled`) and the built game data
(`RecRoom_Data\resources.assets`, `globalgamemanagers`).
## Ownership chain
| Object | Where | Notes |
|---|---|---|
| `RecRoomSceneManager` | `MonoBehaviourPun`, one per room scene | static `get_Instance` (interop property name obfuscated: `PDCOCJBICGC`); name-preserved field `sceneMoodSettings` |
| `RecRoom.Core.Scene.SceneMoodSettings` | plain class held by the scene manager | initialised by `PCJGOACJEDL(RecRoomSceneManager)` at scene start |
| `RecRoom.Core.Creation.SceneMoods.MoodSetting` | `ScriptableObject` asset | `FriendlyName`, `SkyBoxSettings`, `SunSettings`, `FogSettings`, `BloomSettings`, `ColorGradingSettings`, `BackgroundObjectsSettings`, `VolumeProfile`, `BakedReflection`, `guid` |
`SceneMoodSettings` name-preserved members: `availableMoods`
(`MoodSetting[]`), `defaultMood`, `CurrentMood`, `PrevMood`,
`CurrentRoomMoodIndex`, `SupportsMoodCircuits`, `HasRecRoomSkyboxShader`,
`HasSunSource`, `Volume`, `DefaultVolumeProfile`, `ReflectionIntensity`,
`AmbientLightIntensity`, plus one `PFFMDAJPHJO<T>` blender per settings
block (`SkyBoxMoodSettingsBlender`, `SunMoodSettingsBlender`, …).
## Apply path (what actually changes the picture)
`SceneMoodSettings.MLLBALMKEGJ(MoodSetting)` — ISIL
`RecRoom/Core/Scene/SceneMoodSettings.txt:3025-3581`:
1. `ALCMNGKOEBK()` — pushes the mood's six settings blocks into the blenders
as the "top" data (6× generic blender set call).
2. `OEKNGEMDMDB(VolumeProfile)` — swaps the URP post-processing profile.
3. `RenderSettings.set_skybox` — the shared Rec Room skybox material; the
`SkyBoxMoodSettings` blender then writes `_SkyTopColor`-style shader
props into it (`SkyBoxMoodSettings.FPKCBLPBOFN(Material)`).
4. `RenderSettings.get_sun` → re-aims/recolours the sun (`SunMoodSettings`,
only when `HasSunSource`).
5. `ReflectionProbe.set_customBakedTexture` ← `MoodSetting.BakedReflection`.
6. 6× blender force-update.
It does **not** write `CurrentRoomMoodIndex`, so calling it is local only.
### RRO scenes: the engine starts disabled
`SceneMoodSettings.isEnabled` (+0x38) gates everything: `get_CurrentMood`
returns null while it is false, `get_RoomMoodsEnabled` is
`isEnabled && defaultMood != null`, and nothing ticks. The flag is only
switched on by the room data-blob loader (`EIMFCPIHEPF.JPKEMACDNCF`, ISIL
`EIMFCPIHEPF.txt:2136-2310`), which custom rooms run and RRO scenes (no
data blob) never do. Its bring-up sequence:
| Call | Meaning |
|---|---|
| `LKIKMLNPCFK(false)` | SetEnabled(false): applies a null mood (blenders reset, default VolumeProfile back) and toggles `BackgroundObjectsRoot` |
| `LIEDFOLBGHL()` | Capture: builds the blenders' "previous" data from `RenderSettings.skybox` / `sun` / fog; sets `hasRecRoomSkyboxShader` = skybox shader name `== "Custom/SkyBox"` |
| `LKIKMLNPCFK(true)` | SetEnabled(true) |
The apply step then swaps `RenderSettings.skybox` to the global
`MoodConfig` skybox material (`LNMDIIFOFPD.NHAEHKJINFH`, +0x18).
The ClientMod replays this exact sequence when it finds `isEnabled` false.
**The apply step only sets blend targets.** The per-frame update
`CEKLEBHDAHA` (ISIL :3895-4031) is what actually writes sky colours,
rotates/recolours the sun, blends fog and background objects and (via
`CPJBHLMCBBC`) pushes bloom/colour grading into the Volume — and it
returns immediately while `get_CurrentMood()` is null. `get_CurrentMood`
(ISIL :297-457) returns null when `isEnabled` is false, else
`_studioRoomUnityMood` if set, else `availableMoods[index]` when the
synced index is set, else `defaultMood`. RRO scenes have `defaultMood`
null and `availableMoods` empty, so even a fully enabled engine with a
successful apply shows only the ungated background objects (moon, stars).
The ClientMod therefore writes the night mood into `defaultMood` and a
one-element `availableMoods` before enabling the engine.
Two flags captured by `LIEDFOLBGHL` gate what the apply step and the
per-frame update are allowed to touch:
| Flag | Set by capture from | Gates |
|---|---|---|
| `hasRecRoomSkyboxShader` (+0xA8) | `RenderSettings.skybox.shader.name == "Custom/SkyBox"` | sky colour blender apply |
| `hasSunSource` (+0xA9) | `RenderSettings.sun != null` | sun direction / colour / intensity |
Note the sun flag is nested inside the sky check: `hasSunSource` can only
become true when the skybox shader test already passed (ISIL :2629-2705).
`RecRoyale_Frontier` fails both at capture time: its skybox is not the
mood shader. The observable result of a naive apply is exactly "moon and
stars appear, sky and sun unchanged" — the mood's
`BackgroundObjectsSettings` are not gated.
Worse, the apply step's own skybox swap does not help in RRO scenes: it
assigns `MoodConfig.SkyBoxMaterial`, and that material
(`Skybox_Moods_Sunny_Default_Mat`, sharedassets77.assets — a custom-room
scene's shared assets) is not loaded there, so `RenderSettings.skybox`
becomes null (plain black sky) and both flags stay false forever. The
`Custom/SkyBox` shader itself is in resources.assets, so
`new Material(Shader.Find("Custom/SkyBox"))` works anywhere.
Workarounds used by the ClientMod: assign the scene's directional `Light`
to `RenderSettings.sun` if unset; apply once; then put a material on the
`Custom/SkyBox` shader into `RenderSettings.skybox` and
`MoodConfig.SkyBoxMaterial`; capture again so both flags flip true; then
apply for real. Post-exposure additionally needs the scene `Volume`
enabled with weight 1 and `UniversalAdditionalCameraData.renderPostProcessing`
on (`RecRoyaleNightForcePostProcessing`). `LILOMACBFPI()` (:2789-2817) force-sets
both flags to true (writes 0x0101 at +0xA8) and clears
`_studioRoomUnityMood`; it is the Studio path and not used here because
a true `hasSunSource` with a null sun would NRE in the update loop.
Fog: `FogMoodSettings.NLDDIDEIEFH()` reads `RenderSettings.fog`,
`fogStartDistance`, `fogEndDistance`, `fogColor` into the block;
`KGBEBONMIII()` writes them back. Swapping `MoodSetting.FogSettings` for
a freshly captured block before the first apply keeps the scene's fog
(`RecRoyaleNightFog: false`). The block exposes only `color`,
`startDistance`, `fadeDistance` (no enabled flag), so "fog off"
(`RecRoyaleNightDisableFog`) pushes both distances to 1e6 and forces
`RenderSettings.fog = false` after each apply.
Frontier's daytime clouds are scene meshes, not mood background objects:
`CloudPlane` ×19 under `Clouds`, static-batched as
`StaticObjectsStaticBatch_Cloud_RecRoyale_Frontier_Mat` (level106).
`RecRoyaleNightHideObjects` disables renderers whose object or
`sharedMaterial` name contains a pattern (default `Cloud`).
Brightness: the mood blocks are plain data the ClientMod edits in place
before the apply (originals restored on scene exit / toggle-off):
| Block · field | Applied as | ClientMod knob |
|---|---|---|
| `ColorGradingMoodSettings.exposure` | `ColorAdjustments.postExposure` via `VolumeParameter<float>.Override` in `KGBEBONMIII` | `RecRoyaleNightExposure` (EV offset) — the only knob that dims baked lightmaps |
| `SunMoodSettings.sunIntensity` | `RenderSettings.sun.intensity` (needs `hasSunSource`) | `RecRoyaleNightSunScale` (×) |
| — | other directional `Light`s (never touched by the engine; Frontier's `RenderSettings.sun` is `SkyboxLight`, the sun-disc light, not the key light) | `RecRoyaleNightMatchSunLights`: copy the driven sun's rotation/colour, intensity × `RecRoyaleNightSunScale` |
| `SkyBoxMoodSettings.ambientLightIntensity` | `RenderSettings.ambientIntensity` | `RecRoyaleNightAmbientScale` (×) |
Other `ColorGradingMoodSettings` fields: `color`, `temperature`, `tint`,
`hueShift`, `saturation`, `contrast`, `colorFilterIntensity`.
`BloomMoodSettings`: `color`, `diffusion`, `intensity`, `threshold`.
### Networked path (do not use for a client-side effect)
`SceneMoodSettings.IPAMPMBDPNB(Guid)` scans `availableMoods` for the GUID
and calls `set_CurrentRoomMoodIndex`. `_currentRoomMoodIndex` is a
`DFAOKLDJPEL<int>` from `RecRoom.Networking.SynchronizedFields.Runtime`,
i.e. a synchronized field that replicates to everyone in the instance.
CircuitsV2 room-settings chips (`RecRoom.CircuitsV2.RoomSettings.RoomSkydome`,
`RoomSun`, `RoomFog`, all `RoomMoodBase<T,U,V>`) drive the same engine
through `ModifyMood(...)` and are also networked.
## Shipped mood assets
All live in `resources.assets` and are registered in the `globalgamemanagers`
ResourceManager table under their lower-cased asset name (no folder), so
`Resources.Load("<name>", typeof(MoodSetting))` works.
| Asset | `FriendlyName` |
|---|---|
| `Night_Calm_Outdoor_Mood` | Calm Night |
| `Night_Spooky_Outdoor_Mood` | Spooky Night |
| `Night_Wild_Outdoor_Mood` | Wild Night |
| `Night_StuntRunner_Outdoor_Mood` | (Stunt Runner night) |
| `OuterSpace_Mood` | Outer Space |
| `Sunset_Cozy_Outdoor_Mood` | Cozy Sunset |
| `Sunset_Burning_Mood` | Burning Sunset |
| `Sunset_Tropical_Outdoor_Mood` | Tropical Sunset |
| `Sunrise_Misty_Outdoor_Mood` | (misty sunrise) |
Reflection cubemaps ship alongside (`Moods_WildNight_ReflectionProbe`,
`Moods_SunnyDay_Skybox_Reflection_Baked`, …) and are referenced from each
`MoodSetting.BakedReflection`.
## Rec Royale specifics
* Only one Rec Royale map in this build:
`Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity`
(Unity scene name `RecRoyale_Frontier`). The `"recroyale"` literal in
`BattleRoyaleManager` is only `get_RuleTypeAutoTag`, not a scene name.
* `BattleRoyaleManager` has no static instance accessor; scene-name
matching via MelonLoader `OnSceneWasLoaded` is the detection choke point.
* Weapon flashlights already exist: `WeaponFlashlightController` (fields
`weapon`, `flashlightPrefab`, `flashlightRoot`) spawns
`WeaponFlashlight` when `get_FlashlightAllowed` is true. That getter
(ISIL `WeaponFlashlightController.txt:3-75`) is
`Matchmaking.FBELDNLEKAC() != null && [obj+0x28] == 15` — a game-mode
gate for a different activity. Interop property name: `KIMMMCCKMJL`.
## Real CV2 mood chips in an RRO scene
The Maker Pen chips ("Skydome Constant", "Room Skydome Modify/Reset", and
the Sun / Fog / Background Objects trios) drive `RoomSkydome`, `RoomSun`,
`RoomFog`, `RoomBackgroundObjects` (`RecRoom.CircuitsV2.RoomSettings`,
all `RoomMoodBase<T,U,V>`), which hang off `RoomElementSettingsManager` (a
`NetworkedSingletonMonoBehaviour` that `CircuitsV2Manager` brings up in
`MPDIMHOMIDP`). Their state persists in the room blob
(`PersistedRoomData` fields 32-35: `room_fog_data`, `room_mood_sun_data`,
`room_mood_background_objects`, `room_mood_skydome`). None of that runs in
a room the client believes has no data blob, so the prerequisite for chips
in Rec Royale is server-side: give the room a blob (`docs/admin.md` → "RRO
room data blob"). The ClientMod probe line
`[royale-night] CV2 probe in 'RecRoyale_Frontier': …` logs instance counts
for `CircuitsV2Manager`, `RoomElementSettingsManager`, `RoomSkydome`,
`RoomSun`, `RoomFog`, `RoomBackgroundObjects` and `MakerPen` so the effect
of the blob can be verified in one join.
## How the ClientMod uses this
`DorkNet.ClientMod/RecRoyaleNight.cs`:
1. `OnSceneWasLoaded` → scene name contains any `RecRoyaleNightScenes`
entry (default `RecRoyale`) ⇒ arm.
2. `OnUpdate` every 30 frames: `RecRoomSceneManager.Instance.sceneMoodSettings`
present and `CurrentMood` non-null (engine initialised) ⇒
`Resources.Load(RecRoyaleNightMood, MoodSetting)` ⇒
`MLLBALMKEGJ(mood)`. Re-asserted every ~10 s (the engine re-applies its
own mood on quality-level changes).
3. Hotkey (`RecRoyaleNightToggleKey`, a `UnityEngine.KeyCode` name) toggles;
off re-applies `CurrentMood`, i.e. the scene's own default.
4. Harmony postfix on `get_FlashlightAllowed` returns `true` while active
and in a Rec Royale scene (`RecRoyaleNightFlashlights`).
Limits: baked lightmaps are untouched (interiors keep their daytime bake);
darkening comes from the mood's colour grading, sun and ambient settings.
Purely per-client — other players see whatever their own client applies.