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:
Alexa
2026-09-28 00:16:51 +03:00
parent 806b160acc
commit d7b1722abd
6 changed files with 397 additions and 0 deletions
+105
View File
@@ -239,6 +239,111 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes)); Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
} }
/// <summary>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
/// <c>basics</c> mode only the role + rule objects kept with circuits
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
[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<DorkNetDbContext>();
(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] [Fact]
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob() public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
{ {
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs; using DorkNet.Server.Hubs;
using DorkNet.Server.Services; using DorkNet.Server.Services;
using RecRoom.Protobuf;
namespace DorkNet.Server.Controllers.Admin; namespace DorkNet.Server.Controllers.Admin;
@@ -1291,6 +1292,115 @@ public class AdminController(
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed)); 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 private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
{ {
id = (scene?.Id ?? 0).ToString(), id = (scene?.Id ?? 0).ToString(),
@@ -436,6 +436,116 @@ public class RoomDataBlobService
return view.ToArray(); 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 (&gt; 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) private static void AppendVarint(Stream output, int fieldNumber, ulong value)
{ {
WriteVarint(output, ((ulong)fieldNumber << 3) | 0); 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). // 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 // "Enable" stamps the synthetic default name the CDN already answers with
// the RRO-editable blob; "Reset" clears the pointers back to scene-only. // 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') => { const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
setBlobBusy(sub.subRoomId); setBlobBusy(sub.subRoomId);
try { try {
@@ -674,6 +700,31 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
? 'no data blob — baked scene only (no Maker Pen / chips)' ? 'no data blob — baked scene only (no Maker Pen / chips)'
: '(synthetic default blob)'} : '(synthetic default blob)'}
</div> </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 && ( {sub.isBakedOriginal && (
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
{!sub.dataBlobName ? ( {!sub.dataBlobName ? (
+16
View File
@@ -238,6 +238,22 @@ actions:
spawnable-tool table; a saved Squads chip is header + prefab id + tag + 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 node id + an empty wrapper payload), so the match logic exists on first
join without anyone spawning it by hand. 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`. - **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 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 unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
+5
View File
@@ -190,6 +190,11 @@ Solos `983db81a68927f419edb0daf1243e93e`, Squads
Configuration chip, optional — `GameConfigurationTool` falls back to its Configuration chip, optional — `GameConfigurationTool` falls back to its
baked asset. The stub emits the wrapper chip for the two Rec Royale rooms. 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` Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable`
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data