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

12 KiB

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 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:

[{"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