mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat(admin): attach a real PersistedRoomData save to a baked RRO sub-room
POST rooms/{id}/subrooms/{sub}/blob/upload (multipart file, ?mode=full|
basics) rewrites a save from ANY client version for this room —
activity_id → the room's LocationReplicationId, output_log / sub_room_id /
unknown-to-2023 fields dropped, header versions clamped to the 2023
maxima; "basics" additionally keeps only the game-role (field 50) and
game-rule (40/41) objects and strips the circuit payloads, since a newer
CircuitsV2 graph is rejected by the client's own CV2 gate regardless —
stores it as room_{id}_upload_*.dat, records the RoomDataBlobs row and
points the sub-room (and room, for sub-room 0) at it. The CDN's RRO role
overlay + clamp then apply on serve exactly as for Rec Center's saved
blob. Admin SPA: "Upload save" file input + mode select on the Sub-rooms
tab for baked originals.
Purpose: A/B a genuine custom-Rec-Royale save (Squads chip room) against
the generated stub, whose four objects the client currently fails to
instantiate (NullReferenceException in CreationManager.InstantiateRequest-
ForViewData, then "Not object found for Persistence View").
Test: Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023.
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;
|
||||
|
||||
@@ -1291,6 +1292,115 @@ public class AdminController(
|
||||
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(),
|
||||
|
||||
@@ -436,6 +436,116 @@ public class RoomDataBlobService
|
||||
return view.ToArray();
|
||||
}
|
||||
|
||||
public sealed record UploadedBlobSummary(int ViewsIn, int ViewsKept, int RoleViews, int GameRuleViews,
|
||||
int VersionIn, int VersionOut, bool CircuitsStripped, string ActivityId);
|
||||
|
||||
/// <summary>Turn a real <c>PersistedRoomData</c> save (any client
|
||||
/// version) into something the March-2023 client can load as room
|
||||
/// <paramref name="activityId"/>'s blob:
|
||||
/// • field 4 <c>activity_id</c> is rewritten to the target room's
|
||||
/// location id (real saves carry their own room's);
|
||||
/// • field 5 <c>output_log</c>, field 6 <c>sub_room_id</c> and every
|
||||
/// field the 2023 schema does not know (> 36) are dropped;
|
||||
/// • with <paramref name="basicsOnly"/>, only the persistence views that
|
||||
/// carry a game role (field 50) or a game rule (fields 40/41) are kept
|
||||
/// and the circuit payloads (18 circuit_data, 21 connectable graph,
|
||||
/// 28 circuit_v2_data) are dropped — a newer CircuitsV2 graph is
|
||||
/// rejected by the client's own CV2 version gate no matter what;
|
||||
/// • the two header versions are clamped to the 2023 maxima.
|
||||
/// Every other byte is copied verbatim.</summary>
|
||||
public static (byte[] Bytes, UploadedBlobSummary Summary) PrepareUploadedRoomBlob(
|
||||
byte[] input, string activityId, bool basicsOnly)
|
||||
{
|
||||
using var output = new MemoryStream(input.Length);
|
||||
var pos = 0;
|
||||
int viewsIn = 0, viewsKept = 0, roleViews = 0, ruleViews = 0, versionIn = 0;
|
||||
var wroteActivity = false;
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var fieldStart = pos;
|
||||
var tag = ReadVarint(input, ref pos);
|
||||
var fieldNumber = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 0x07);
|
||||
var payloadStart = pos;
|
||||
if (wireType == 0)
|
||||
{
|
||||
var value = ReadVarint(input, ref pos);
|
||||
if (fieldNumber == 30) versionIn = (int)value;
|
||||
if (fieldNumber is 5 or 6 || fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
SkipField(input, ref pos, wireType);
|
||||
if (wireType != 2)
|
||||
{
|
||||
if (fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lenPos = payloadStart;
|
||||
var len = checked((int)ReadVarint(input, ref lenPos));
|
||||
var payload = new ReadOnlySpan<byte>(input, lenPos, len);
|
||||
|
||||
switch (fieldNumber)
|
||||
{
|
||||
case 4:
|
||||
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||
wroteActivity = true;
|
||||
continue;
|
||||
case 5:
|
||||
case 18 when basicsOnly:
|
||||
case 21 when basicsOnly:
|
||||
case 28 when basicsOnly:
|
||||
continue;
|
||||
case 2:
|
||||
{
|
||||
viewsIn++;
|
||||
var (isRole, isRule) = ClassifyView(payload);
|
||||
if (isRole) roleViews++;
|
||||
if (isRule) ruleViews++;
|
||||
if (basicsOnly && !isRole && !isRule) continue;
|
||||
viewsKept++;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
default:
|
||||
if (fieldNumber > 36) continue;
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!wroteActivity && !string.IsNullOrWhiteSpace(activityId))
|
||||
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
|
||||
|
||||
var (clamped, _) = ClampVersionsFor2023(output.ToArray());
|
||||
return (clamped, new UploadedBlobSummary(viewsIn, viewsKept, roleViews, ruleViews, versionIn,
|
||||
Math.Min(versionIn == 0 ? Client2023MaxPersistedRoomVersion : versionIn, Client2023MaxPersistedRoomVersion),
|
||||
basicsOnly, activityId));
|
||||
}
|
||||
|
||||
/// <summary>Does this PersistenceViewData carry a GameRoleNode (field 50)
|
||||
/// or a game rule (40 game_configuration_data / 41 game_rule_wrapper_data)?</summary>
|
||||
private static (bool IsRole, bool IsRule) ClassifyView(ReadOnlySpan<byte> view)
|
||||
{
|
||||
var bytes = view.ToArray();
|
||||
var pos = 0; bool role = false, rule = false;
|
||||
try
|
||||
{
|
||||
while (pos < bytes.Length)
|
||||
{
|
||||
var tag = ReadVarint(bytes, ref pos);
|
||||
var fieldNumber = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 0x07);
|
||||
if (fieldNumber == 50) role = true;
|
||||
if (fieldNumber is 40 or 41) rule = true;
|
||||
SkipField(bytes, ref pos, wireType);
|
||||
}
|
||||
}
|
||||
catch { /* malformed view — treat as neither */ }
|
||||
return (role, rule);
|
||||
}
|
||||
|
||||
private static void AppendVarint(Stream output, int fieldNumber, ulong value)
|
||||
{
|
||||
WriteVarint(output, ((ulong)fieldNumber << 3) | 0);
|
||||
|
||||
@@ -591,6 +591,32 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
|
||||
// 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 {
|
||||
@@ -674,6 +700,31 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
|
||||
? '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 ? (
|
||||
|
||||
Reference in New Issue
Block a user