mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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:
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Cdn;
|
||||
@@ -557,11 +558,18 @@ public class CdnController(
|
||||
return new FileContentResult(TransparentPng, "image/png");
|
||||
}
|
||||
|
||||
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
|
||||
var rroRoom = await ResolveRroRoomAsync(fileName);
|
||||
if (rroRoom is not null)
|
||||
{
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
|
||||
Request.Host.Host, fileName);
|
||||
var rroBlob = roomDataBlob.GetRroEditableBlob();
|
||||
// Per-room stub: a real save's skeleton at the 2023 version
|
||||
// ceiling with the room's own activity_id. The old generic stub
|
||||
// (version 19, roles only) was fetched and silently rejected by
|
||||
// the client, which is why an enabled baked room still had no
|
||||
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
|
||||
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
|
||||
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
|
||||
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, rroBlob);
|
||||
return new FileContentResult(rroBlob, "application/octet-stream");
|
||||
@@ -569,14 +577,34 @@ public class CdnController(
|
||||
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
||||
Request.Host.Host, fileName);
|
||||
var fallbackBlob = roomDataBlob.GetDefaultBlob();
|
||||
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
|
||||
signatures.AddContentSignature(Response, fallbackBlob);
|
||||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
|
||||
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
|
||||
/// synthesised blobs on the miss path — a stub past the client's ceiling
|
||||
/// is rejected exactly like a modern export would be.</summary>
|
||||
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
if (!IsRoomDataName(fileName)) return bytes;
|
||||
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
|
||||
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
|
||||
if (changed)
|
||||
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
|
||||
await ResolveRroRoomAsync(fileName) is not null;
|
||||
|
||||
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
|
||||
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
|
||||
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
|
||||
/// the per-room stub on misses.</summary>
|
||||
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||||
|
||||
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
||||
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
||||
@@ -587,9 +615,9 @@ public class CdnController(
|
||||
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
||||
// RoleData) — the game-rule circuit data and GameRoleData are left
|
||||
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
||||
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
|
||||
r.IsAGRoom && r.CurrentDataBlobName == fileName))
|
||||
return true;
|
||||
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
|
||||
r.IsAGRoom && r.CurrentDataBlobName == fileName);
|
||||
if (byCurrentBlob is not null) return byCurrentBlob;
|
||||
|
||||
// Synthetic/default room-data blob names for rooms with no saved blob:
|
||||
// the CURRENT form is room_{id}_dorknet_v8.dat
|
||||
@@ -603,18 +631,19 @@ public class CdnController(
|
||||
{
|
||||
var rest = fileName["room_".Length..];
|
||||
var underscore = rest.IndexOf('_');
|
||||
if (underscore > 0 &&
|
||||
long.TryParse(rest[..underscore], out var roomId) &&
|
||||
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom))
|
||||
return true;
|
||||
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
|
||||
{
|
||||
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
|
||||
if (byId is not null) return byId;
|
||||
}
|
||||
}
|
||||
|
||||
return await (
|
||||
from scene in db.RoomScenes.AsNoTracking()
|
||||
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
||||
where room.IsAGRoom && scene.DataBlobName == fileName
|
||||
select scene.Id
|
||||
).AnyAsync();
|
||||
select room
|
||||
).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -65,6 +65,12 @@ public class StorageController(
|
||||
|
||||
[HttpPost("/upload", Order = -2000)]
|
||||
[Consumes("multipart/form-data")]
|
||||
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
|
||||
// a big circuits room now that the served CircuitsV2 chip limit is 30000
|
||||
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
|
||||
// import endpoints and Cloudflare's own per-request ceiling.
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
|
||||
public async Task<IActionResult> Upload()
|
||||
{
|
||||
// Form parsing — ASP.NET Core deserialises the multipart body
|
||||
|
||||
Reference in New Issue
Block a user