Files
DorkNet/docs/recroom-2023-circuitsv2-limits.md
Alexa 36e64e99ba 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.
2026-10-15 14:07:54 +03:00

256 lines
12 KiB
Markdown

# 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` |