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
+140 -3
View File
@@ -165,6 +165,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
const [description, setDescription] = useState(room.description);
const [accessibility, setAccessibility] = useState(room.accessibility);
const [cloningAllowed, setCloningAllowed] = useState(room.cloningAllowed);
const [isAGRoom, setIsAGRoom] = useState(room.isAGRoom);
const [tagsCsv, setTagsCsv] = useState(room.tagsCsv);
const [hotScore, setHotScore] = useState(String(room.hotScore));
const [imageName, setImageName] = useState(room.imageName);
@@ -176,6 +177,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
setDescription(room.description);
setAccessibility(room.accessibility);
setCloningAllowed(room.cloningAllowed);
setIsAGRoom(room.isAGRoom);
setTagsCsv(room.tagsCsv);
setHotScore(String(room.hotScore));
setImageName(room.imageName);
@@ -193,6 +195,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
Description: description,
Accessibility: accessibility,
CloningAllowed: cloningAllowed,
IsAGRoom: isAGRoom,
TagsCsv: tagsCsv,
HotScore: Number.isFinite(hot) ? hot : undefined,
ImageName: imageName,
@@ -283,6 +286,14 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
/>
<span>Cloning allowed</span>
</label>
<label className="flex items-center gap-2 text-sm" title="What the client reads as IsRRO. On: load the baked scene from the client's assets and consult the client's built-in per-original table (which forbids creation in Rec Royale). Off: treat as a custom room — roles and blob decide. Flip a CLONE of an original off to test editable Rec Royale.">
<input
type="checkbox"
checked={isAGRoom}
onChange={e => setIsAGRoom(e.target.checked)}
/>
<span>Rec Room Original (IsRRO) — baked scene + client creation table</span>
</label>
<div className="flex gap-2 pt-2 border-t border-ink-800">
<button onClick={save} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving…' : 'Save changes'}
@@ -563,6 +574,14 @@ interface SubRoom {
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
/** What the client is actually told to download: scene blob, else room
* blob, else synthetic default — or '' for a baked Rec Room Original
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
effectiveBlobName: string;
isBakedOriginal: boolean;
/** No RoomScenes row exists yet (seeded baked originals); the entry
* scene is synthesised on read and created by "Enable Maker Pen blob". */
isVirtual?: boolean;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
@@ -576,6 +595,60 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const [blobBusy, setBlobBusy] = useState<number | null>(null);
const [resetArmed, setResetArmed] = useState<number | null>(null);
// Baked Rec Room Originals ship with no data blob, so the client never
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
// "Enable" stamps the synthetic default name the CDN already answers with
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
// Attach a real PersistedRoomData save (any client version) to a sub-room:
// the server rewrites it for this room (activity id, 2023 version clamp,
// drops output log / sub-room id / unknown fields; "basics" keeps only the
// game-role + game-rule objects and strips circuits).
const [uploadMode, setUploadMode] = useState<'full' | 'basics'>('basics');
const uploadBlob = async (sub: SubRoom, file: File) => {
setBlobBusy(sub.subRoomId);
try {
const fd = new FormData();
fd.append('file', file, file.name);
const res = await api<{ dataBlobName: string; summary: { viewsIn: number; viewsKept: number; versionIn: number; versionOut: number } }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/upload?mode=${uploadMode}`,
{ method: 'POST', formData: fd, timeoutMs: 120000 },
);
toast.push(
`Uploaded ${res.dataBlobName}: ${res.summary.viewsKept}/${res.summary.viewsIn} objects kept, version ${res.summary.versionIn} → ${res.summary.versionOut}`,
'success',
);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
setBlobBusy(sub.subRoomId);
try {
const res = await api<{ effectiveBlobName: string; changed: boolean }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/${action}`,
{ method: 'POST' },
);
toast.push(
action === 'enable'
? `${sub.name || `Sub-room ${sub.subRoomId}`} now loads ${res.effectiveBlobName}`
: `${sub.name || `Sub-room ${sub.subRoomId}`} reset to the baked scene (no blob)`,
'success',
);
setResetArmed(null);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
@@ -631,8 +704,71 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
{sub.effectiveBlobName
? sub.effectiveBlobName
: sub.isBakedOriginal
? 'no data blob — baked scene only (no Maker Pen / chips)'
: '(synthetic default blob)'}
</div>
{sub.isBakedOriginal && (
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<label className="text-xs text-ink-400">Upload save</label>
<select
value={uploadMode}
onChange={e => setUploadMode(e.target.value as 'full' | 'basics')}
className="input text-xs py-0.5 w-40"
title="basics = keep only the game-role and game-rule objects and strip circuits (a newer CircuitsV2 graph is rejected by the 2023 client); full = keep every object"
>
<option value="basics">basics (roles + game rule)</option>
<option value="full">full (every object)</option>
</select>
<input
type="file"
accept=".binpb,.room,.dat,.bin"
disabled={blobBusy === sub.subRoomId}
onChange={e => {
const f = e.target.files?.[0];
if (f) uploadBlob(sub, f);
e.target.value = '';
}}
className="text-xs"
/>
</div>
)}
{sub.isBakedOriginal && (
<div className="flex items-center gap-2 mt-1.5">
{!sub.dataBlobName ? (
<button
onClick={() => setBlob(sub, 'enable')}
disabled={blobBusy === sub.subRoomId}
className="btn-secondary text-xs"
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
>
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
</button>
) : resetArmed === sub.subRoomId ? (
<>
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
<button
onClick={() => setBlob(sub, 'reset')}
disabled={blobBusy === sub.subRoomId}
className="btn-danger text-xs"
>
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
</button>
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
</>
) : (
<button
onClick={() => setResetArmed(sub.subRoomId)}
className="btn-secondary text-xs"
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
>
Reset to baked scene
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
@@ -647,8 +783,9 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
/>
<button
onClick={() => save(sub)}
disabled={!dirty || saving === sub.subRoomId}
disabled={!dirty || saving === sub.subRoomId || sub.isVirtual}
className="btn-primary text-xs"
title={sub.isVirtual ? 'This room has no sub-room row yet — enable the blob first' : undefined}
>
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
</button>
+238 -1
View File
@@ -139,10 +139,31 @@ interface DiscoveredGameConfigSettings {
updatedAt: string;
}
interface CircuitsV2NetworkLimit {
numPlayersForActivation: number;
bytes: number;
bytesTimeWindowFrame60s: number;
rpcs: number;
rpcTimeWindowFrame60s: number;
}
interface CircuitsV2LimitSettings {
network: CircuitsV2NetworkLimit[];
compute: number;
chips: number;
cloudValues: number;
cloudValuesAutosaveSeconds: number;
unculledChips: number;
visibleChips: number;
inkPerChip: number;
updatedAt: string;
}
export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [gameConfigs, setGameConfigs] = useState<DiscoveredGameConfigSettings | null>(null);
const [circuits, setCircuits] = useState<CircuitsV2LimitSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
avatarItems: [],
consumables: [],
@@ -152,20 +173,23 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
const [busy, setBusy] = useState(false);
const [weeklyBusy, setWeeklyBusy] = useState(false);
const [gameConfigBusy, setGameConfigBusy] = useState(false);
const [circuitsBusy, setCircuitsBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const [s, w, rewards, configs] = await Promise.all([
const [s, w, rewards, configs, limits] = await Promise.all([
get<ServerSettings>('/settings'),
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
get<RewardOptions>('/settings/weekly-challenges/reward-options'),
get<DiscoveredGameConfigSettings>('/settings/gameconfigs'),
get<CircuitsV2LimitSettings>('/settings/circuits-limits'),
]);
setSettings(s);
setWeekly(w);
setRewardOptions(rewards);
setGameConfigs(configs);
setCircuits(limits);
setErr(null);
} catch (e) {
setErr((e as Error).message);
@@ -470,6 +494,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const updateCircuits = (patch: Partial<CircuitsV2LimitSettings>) => {
setCircuits((current) => current ? { ...current, ...patch } : current);
};
const updateCircuitsNetwork = (index: number, patch: Partial<CircuitsV2NetworkLimit>) => {
setCircuits((current) => current
? {
...current,
network: current.network.map((entry, i) => i === index ? { ...entry, ...patch } : entry),
}
: current);
};
const saveCircuits = async (reset = false) => {
if (!circuits && !reset) return;
setCircuitsBusy(true);
try {
const body = reset ? defaultCircuitsLimits() : circuits;
const updated = await api<CircuitsV2LimitSettings>('/settings/circuits-limits', {
method: 'POST',
body,
});
setCircuits(updated);
toast.push(
reset ? 'CircuitsV2 limits reset.' : 'CircuitsV2 limits saved. Players must rejoin their room.',
'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setCircuitsBusy(false);
}
};
const refreshBtn = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
@@ -665,6 +722,17 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
/>
)}
{circuits && (
<CircuitsV2LimitsPanel
settings={circuits}
busy={circuitsBusy}
onChange={updateCircuits}
onNetworkChange={updateCircuitsNetwork}
onSave={() => saveCircuits(false)}
onReset={() => saveCircuits(true)}
/>
)}
{weekly && (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -1068,19 +1136,168 @@ function DiscoveredGameConfigsPanel({
);
}
function CircuitsV2LimitsPanel({
settings,
busy,
onChange,
onNetworkChange,
onSave,
onReset,
}: {
settings: CircuitsV2LimitSettings;
busy: boolean;
onChange: (patch: Partial<CircuitsV2LimitSettings>) => void;
onNetworkChange: (index: number, patch: Partial<CircuitsV2NetworkLimit>) => void;
onSave: () => void;
onReset: () => void;
}) {
const number = (key: keyof CircuitsV2LimitSettings, value: string) => {
const n = Number.parseFloat(value);
onChange({ [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2LimitSettings>);
};
const netNumber = (index: number, key: keyof CircuitsV2NetworkLimit, value: string) => {
const n = Number.parseInt(value, 10);
onNetworkChange(index, { [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2NetworkLimit>);
};
return (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">CircuitsV2 limits</h2>
<p className="mt-1 text-xs text-ink-400">
Served as the <span className="font-mono">UGC.CircuitsV2.Limit.*</span> gameconfig keys.
Retail budgets a circuit 5 RPCs per frame and 1024 bytes per second and silently drops
everything past that; DorkNet ships both effectively unlimited, and raises the room-size
limits below about 10x past retail. Clients read these once
when circuits start, so players must rejoin their room to pick up a change.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={onReset} disabled={busy} className="btn-secondary text-xs">
<RefreshCw />
Defaults
</button>
<button onClick={onSave} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
{settings.network.map((entry, index) => (
<div key={index} className="rounded-lg border border-ink-800 bg-ink-950/30 p-4 lg:col-span-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">
Network budget{settings.network.length > 1 ? ` — from ${entry.numPlayersForActivation} players` : ''}
</h3>
<p className="mt-1 text-[11px] text-ink-500">
Each budget is spent over a rolling window measured in frames, so a window of 1 means
per frame and 60 means roughly per second at 60fps. Widening a window makes the limit
stricter, not looser — raise the budget instead.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<NumberConfig
label="Rpcs"
value={entry.rpcs}
onChange={(v) => netNumber(index, 'rpcs', v)}
/>
<NumberConfig
label="RpcTimeWindowFrame60s"
value={entry.rpcTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'rpcTimeWindowFrame60s', v)}
/>
<NumberConfig
label="Bytes"
value={entry.bytes}
onChange={(v) => netNumber(index, 'bytes', v)}
/>
<NumberConfig
label="BytesTimeWindowFrame60s"
value={entry.bytesTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'bytesTimeWindowFrame60s', v)}
/>
</div>
</div>
))}
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Circuit budget</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="Compute"
value={settings.compute}
hint="Per-frame CPU budget for all circuits in the room. Retail 50000. Keep it finite: it is what stops a runaway loop. Max 10,000,000."
onChange={(v) => number('compute', v)}
/>
<NumberConfig
label="Chips"
value={settings.chips}
hint="Room chip cap. Retail 3000. Max 1,000,000."
onChange={(v) => number('chips', v)}
/>
<NumberConfig
label="UnculledChips"
value={settings.unculledChips}
hint="Chips past this count get culled. Retail 500. Higher costs frame time."
onChange={(v) => number('unculledChips', v)}
/>
<NumberConfig
label="VisibleChips"
value={settings.visibleChips}
hint="Visible-chip limit. Retail 500. Above Chips, adding a chip also charges InkPerChip."
onChange={(v) => number('visibleChips', v)}
/>
<NumberConfig
label="InkPerChip"
value={settings.inkPerChip}
hint="Ink COST per chip against the fixed 30000-ink room cap. Retail 15; 0 = chips use no ink. Max 30,000."
onChange={(v) => number('inkPerChip', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Cloud variables</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="CloudValues"
value={settings.cloudValues}
hint="Room cloud-variable cap. Retail 100. Max 10,000."
onChange={(v) => number('cloudValues', v)}
/>
<NumberConfig
label="CloudValuesAutosaveSeconds"
value={settings.cloudValuesAutosaveSeconds}
hint="Seconds between player save-data autosaves. Retail 300. Lower means more save traffic."
onChange={(v) => number('cloudValuesAutosaveSeconds', v)}
/>
</div>
</div>
</div>
</div>
);
}
function NumberConfig({
label,
value,
hint,
onChange,
}: {
label: string;
value: number;
hint?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="label font-mono normal-case tracking-normal">{label}</span>
<input className="input mt-1" type="number" value={value} onChange={(e) => onChange(e.target.value)} />
{hint && <span className="mt-1 block text-[11px] text-ink-500">{hint}</span>}
</label>
);
}
@@ -1128,6 +1345,26 @@ function JsonConfig({
);
}
function defaultCircuitsLimits(): CircuitsV2LimitSettings {
return {
network: [{
numPlayersForActivation: 0,
bytes: 1_000_000_000,
bytesTimeWindowFrame60s: 60,
rpcs: 1_000_000_000,
rpcTimeWindowFrame60s: 1,
}],
compute: 500_000,
chips: 30_000,
cloudValues: 1_000,
cloudValuesAutosaveSeconds: 300,
unculledChips: 3_000,
visibleChips: 3_000,
inkPerChip: 0,
updatedAt: new Date().toISOString(),
};
}
function defaultGameConfigs(): DiscoveredGameConfigSettings {
return {
friendsPostGamePromptUnderFriendCount: 3,