From d7b1722abdc36e2b2377222b603eea1de76aa458 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:16:51 +0300 Subject: [PATCH] feat(admin): attach a real PersistedRoomData save to a baked RRO sub-room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DorkNet.Server.Tests/RroRoomBlobTests.cs | 105 +++++++++++++++++ .../Controllers/Admin/AdminController.cs | 110 ++++++++++++++++++ .../Services/RoomDataBlobService.cs | 110 ++++++++++++++++++ DorkNet.Server/admin-ui/src/pages/Room.tsx | 51 ++++++++ docs/admin.md | 16 +++ docs/recroom-2023-room-save.md | 5 + 6 files changed, 397 insertions(+) diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs index 5b83ac8..1d34310 100644 --- a/DorkNet.Server.Tests/RroRoomBlobTests.cs +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -239,6 +239,111 @@ public sealed class RroRoomBlobTests : IClassFixture Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes)); } + /// A real save from any client version can be attached to a baked + /// original: activity id rewritten to the target room, output log / + /// sub-room id / unknown fields dropped, version clamped, and in + /// basics mode only the role + rule objects kept with circuits + /// stripped. The room then loads that blob (with the CDN's role overlay). + [Fact] + public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023() + { + using var setup = Client("rooms"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_690_000 + Random.Shared.Next(1, 99_999); + const string location = "253fa009-6e65-4c90-91a1-7137a56a267f"; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"Upload{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + LocationReplicationId = location, + }); + await db.SaveChangesAsync(); + } + + // A "modern" save: version 138, someone else's activity id, an output + // log, a sub-room id, a newer-only field 37, circuit payloads, and + // three views — a game role (field 50), a game rule (field 41) and a + // plain prop (field 15 only). + var modern = BuildModernSave(); + + using var adminClient = Client("admin", admin); + using var form = new MultipartFormDataContent(); + form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb"); + using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form); + var uploadBody = await uploaded.Content.ReadAsStringAsync(); + Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}"); + var result = JsonDocument.Parse(uploadBody).RootElement; + var blobName = result.GetProperty("dataBlobName").GetString()!; + Assert.StartsWith($"room_{roomId}_upload_", blobName); + var summary = result.GetProperty("summary"); + Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32()); + Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32()); + Assert.Equal(138, summary.GetProperty("versionIn").GetInt32()); + Assert.Equal(16, summary.GetProperty("versionOut").GetInt32()); + + // The game client is now pointed at the uploaded blob … + using var gameClient = Client("rooms", player); + var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString()); + + // … and the CDN serves it rewritten: 2 views, clamped header, our + // activity id, the role overlay applied, no field 37 / output log. + using var cdnClient = Client("cdn"); + using var served = await cdnClient.GetAsync($"/room/{blobName}"); + Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}"); + var bytes = await served.Content.ReadAsByteArrayAsync(); + var parsed = PersistedRoomData.Parser.ParseFrom(bytes); + Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version); + Assert.NotEmpty(parsed.RoomRoleData.RoomRoles); + Assert.Equal(2, CountTopLevelPersistenceViews(bytes)); + var text = System.Text.Encoding.UTF8.GetString(bytes); + Assert.Contains(location, text); + Assert.DoesNotContain("someone-elses-activity", text); + Assert.DoesNotContain("Serialized 3 persistence views", text); + } + + private static byte[] BuildModernSave() + { + var ms = new MemoryStream(); + void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); } + void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); } + static byte[] View(int markerField) + { + var v = new MemoryStream(); + WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray()); + WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0); + return v.ToArray(); + } + Varint(1, 38); + Bytes(2, View(50)); // game role + Bytes(2, View(41)); // game rule wrapper + Bytes(2, View(15)); // a prop (tagged tool only) + Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000")); + Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views")); + Varint(6, 3991471883933115623UL); + Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 }); + Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 }); + Varint(30, 138); + Varint(31, 1); + Varint(37, 1); + return ms.ToArray(); + } + + private static void WriteVarint(Stream s, ulong value) + { + while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; } + s.WriteByte((byte)value); + } + [Fact] public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob() { diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 0c3f23d..298bde0 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -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)); } + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload + /// — multipart file = a real PersistedRoomData save (any + /// client version; .binpb / .room / .dat), optional + /// form/query mode = full (default) or basics. + /// Rewrites it for this room (activity id, 2023 version clamp, drops + /// output log / sub-room id / unknown fields; basics also keeps + /// only the game-role and game-rule objects and drops the circuit + /// payloads — see ), + /// stores it under a fresh room_{id}_upload_*.dat 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. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")] + [RequestSizeLimit(64 * 1024 * 1024)] + public async Task 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(), diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index 618bed3..94b2f6a 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -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); + + /// Turn a real PersistedRoomData save (any client + /// version) into something the March-2023 client can load as room + /// 's blob: + /// • field 4 activity_id is rewritten to the target room's + /// location id (real saves carry their own room's); + /// • field 5 output_log, field 6 sub_room_id and every + /// field the 2023 schema does not know (> 36) are dropped; + /// • with , 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. + 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(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)); + } + + /// Does this PersistenceViewData carry a GameRoleNode (field 50) + /// or a game rule (40 game_configuration_data / 41 game_rule_wrapper_data)? + private static (bool IsRole, bool IsRule) ClassifyView(ReadOnlySpan 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); diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 5fc606c..395ddf8 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -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)'} + {sub.isBakedOriginal && ( +
+ + + { + const f = e.target.files?.[0]; + if (f) uploadBlob(sub, f); + e.target.value = ''; + }} + className="text-xs" + /> +
+ )} {sub.isBakedOriginal && (
{!sub.dataBlobName ? ( diff --git a/docs/admin.md b/docs/admin.md index 6dd0b8b..620d2a6 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -238,6 +238,22 @@ actions: spawnable-tool table; a saved Squads chip is header + prefab id + tag + node id + an empty wrapper payload), so the match logic exists on first join without anyone spawning it by hand. +- **Upload save** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload?mode=full|basics`, + multipart `file` = a real `PersistedRoomData` save from ANY client + version (`.binpb` / `.room` / `.dat`, e.g. a RecNet export of a custom + Rec Royale room). The server rewrites it for this room + (`RoomDataBlobService.PrepareUploadedRoomBlob`): `activity_id` → the + room's `LocationReplicationId`, drops `output_log`, `sub_room_id` and + every field the 2023 schema doesn't know, clamps the header versions to + the 2023 maxima, and in `basics` mode keeps only the game-role (field 50) + and game-rule (fields 40/41) objects while stripping the circuit payloads + (`circuit_data`, connectable graph, `circuit_v2_data` — a newer + CircuitsV2 graph is rejected by the client's own CV2 version gate no + matter what). Stored under a fresh `room_{id}_upload_*.dat`, recorded in + `RoomDataBlobs`, and the sub-room (and room, for sub-room 0) is pointed + at it. On serve the CDN applies the RRO role overlay and clamp exactly + as for Rec Center's saved blob. Response carries a `summary` + (`viewsIn/viewsKept/roleViews/gameRuleViews/versionIn/versionOut`). - **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`. Clears both pointers. This is the escape hatch if a save left the room unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 6e24aba..3b65234 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -190,6 +190,11 @@ Solos `983db81a68927f419edb0daf1243e93e`, Squads Configuration chip, optional — `GameConfigurationTool` falls back to its baked asset. The stub emits the wrapper chip for the two Rec Royale rooms. +Attaching a real save: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/upload` +(multipart `file`, `?mode=basics|full`) rewrites any-version +`PersistedRoomData` for the room — see `docs/admin.md` → "Upload save". +Used to A/B a generated stub against a genuine custom-Rec-Royale save. + Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable` stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data