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
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using RecRoom.Protobuf;
namespace DorkNet.Server.Controllers.Admin;
@@ -974,7 +975,8 @@ public class AdminController(
double? HotScore,
int? State,
string? ImageName,
int? MaxCapacity);
int? MaxCapacity,
bool? IsAGRoom);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
@@ -1004,6 +1006,20 @@ public class AdminController(
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
if (body.IsAGRoom is bool ag)
{
// IsAGRoom is what the client reads as IsRRO. True: load the
// baked scene from the client's own assets AND consult the
// client-baked AGRoomRuntimeConfig table (which, per original,
// decides e.g. whether creation is allowed — Rec Center yes, Rec
// Royale no). False: treat the room as a custom room (roles and
// blob decide everything). Flipping a CLONE of a baked original
// to false is the server-side experiment for "editable Rec
// Royale"; whether the client still loads the baked scene for a
// non-original is what that experiment answers.
room.IsAGRoom = ag;
changes.Add($"isAGRoom={ag}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -1117,6 +1133,36 @@ public class AdminController(
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
// Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at
// all; the game-client details builder synthesises a "Home" entry
// scene for them (RoomsController.BuildSubRoomWire with a null scene)
// and the mutation handlers create the row on first write. Mirror
// that here so the SPA has something to act on — the blob/enable
// action below creates the real row.
if (scenes.Count == 0)
{
return Ok(new[]
{
new
{
id = "0",
subRoomId = 0,
name = "Home",
maxPlayers = room.MaxCapacity,
canMatchmakeInto = true,
isSandbox = false,
dataBlobName = string.Empty,
effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal),
isBakedOriginal = bakedOriginal,
isVirtual = true,
dataModifiedAt = room.UpdatedAt,
},
});
}
return Ok(scenes.Select(s => new
{
// Ids go out as strings: the admin SPA is JavaScript and silently
@@ -1128,10 +1174,260 @@ public class AdminController(
canMatchmakeInto = s.CanMatchmakeInto,
isSandbox = s.IsSandbox,
dataBlobName = s.DataBlobName,
// What the client will actually be told to download for this
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
// scene's own blob, else the room's, else a synthetic default —
// except baked Rec Room Originals, which get NO blob until an
// admin enables one (see EnableSubRoomBlob).
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
isBakedOriginal = bakedOriginal,
dataModifiedAt = s.DataModifiedAt,
}).ToList());
}
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal)
{
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName;
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (bakedOriginal) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
/// editing: the synthetic default for the entry scene (the same name the
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
subRoomId == 0
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
/// — give a baked Rec Room Original sub-room a data blob so the client
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
///
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
/// builder + matchmaking resolver turns that into an empty
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
/// no persistence views, no room-role overlay, no Maker Pen, no room
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
/// it, not in the scene). Stamping the synthetic default name is enough:
/// every resolver returns it verbatim, and <c>CdnController</c> already
/// answers that name for AG rooms with the RRO-editable blob (permissive
/// room roles, no objects) when nothing has been saved yet. The first
/// in-game save then replaces the name with a real upload.
///
/// No-op when the sub-room already has a blob. Idempotent.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
var now = DateTime.UtcNow;
if (scene is null)
{
// Seeded baked originals carry no RoomScenes row; create the
// entry scene with the same defaults the game-side mutation
// path uses (RoomsController.GetOrCreateSceneForMutationAsync).
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
await db.SaveChangesAsync();
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
/// — clear the sub-room's data blob reference. For a baked Rec Room
/// Original that puts the room back to "scene only, no blob" (the escape
/// hatch if a save left the room unplayable); for any other room the
/// resolvers fall back to the synthetic default, i.e. an empty room.
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
/// the in-game restore list can bring an old save back.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
var previousScene = scene?.DataBlobName ?? string.Empty;
var previousRoom = room.CurrentDataBlobName;
var changed = false;
var now = DateTime.UtcNow;
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
{
scene.DataBlobName = string.Empty;
scene.DataModifiedAt = now;
changed = true;
}
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = string.Empty;
room.UpdatedAt = now;
changed = true;
}
if (changed)
{
await LogAsync("reset_subroom_blob", "room", id,
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
await db.SaveChangesAsync();
}
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
/// Rewrites it for this room (activity id, 2023 version clamp, drops
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
/// only the game-role and game-rule objects and drops the circuit
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> name, records
/// the RoomDataBlobs row and points the sub-room (and, for sub-room 0,
/// the room) at it. The CDN's RRO role overlay and version clamp then
/// apply on serve exactly as for Rec Center's saved blob.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
[RequestSizeLimit(64 * 1024 * 1024)]
public async Task<ActionResult> UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode)
{
if (file is null || file.Length == 0) return BadRequest("missing file");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
byte[] raw;
using (var ms = new MemoryStream((int)file.Length))
{
await file.CopyToAsync(ms);
raw = ms.ToArray();
}
try { _ = PersistedRoomData.Parser.ParseFrom(raw); }
catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); }
var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase);
var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics);
var now = DateTime.UtcNow;
var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, "application/octet-stream");
}
catch (Exception ex)
{
adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName);
return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message });
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = id,
BlobName = blobName,
SubRoomId = subRoomId,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = now,
});
if (scene is null)
{
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0)
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("upload_subroom_blob", "room", id,
$"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}");
await db.SaveChangesAsync();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
return Ok(new
{
id = scene.Id.ToString(),
subRoomId = scene.OrderIndex,
name = scene.Name,
dataBlobName = scene.DataBlobName,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
bytes = bytes.Length,
// Explicit camelCase: the API's naming policy is "as declared",
// and the rest of this payload is camelCase.
summary = new
{
viewsIn = summary.ViewsIn,
viewsKept = summary.ViewsKept,
roleViews = summary.RoleViews,
gameRuleViews = summary.GameRuleViews,
versionIn = summary.VersionIn,
versionOut = summary.VersionOut,
circuitsStripped = summary.CircuitsStripped,
activityId = summary.ActivityId,
},
});
}
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
{
id = (scene?.Id ?? 0).ToString(),
subRoomId = scene?.OrderIndex ?? 0,
name = scene?.Name ?? "Home",
dataBlobName = scene?.DataBlobName ?? string.Empty,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
changed,
};
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
@@ -1959,6 +2255,22 @@ public class AdminController(
return Ok(settings);
}
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
[HttpGet("settings/circuits-limits")]
public async Task<ActionResult> GetCircuitsV2Limits()
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
[HttpPost("settings/circuits-limits")]
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
{
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code