diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs index e7141b6..5e9d2a2 100644 --- a/DorkNet.Server.Tests/RroRoomBlobTests.cs +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -54,6 +54,7 @@ public sealed class RroRoomBlobTests : IClassFixture IsAGRoom = true, TagsCsv = "recroomoriginal,sport", CurrentDataBlobName = string.Empty, + LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1", }); db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty }); @@ -102,6 +103,14 @@ public sealed class RroRoomBlobTests : IClassFixture var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes); Assert.NotNull(persisted.RoomRoleData); Assert.NotEmpty(persisted.RoomRoleData.RoomRoles); + Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true })); + + // The March-2023 client rejects a blob past PersistedRoomVersion 16 + // (the old stub was stamped 19 and loaded as "no room data"), and a + // real save carries the room's location id as activity_id. Our proto + // doesn't type activity_id, so look for it on the wire. + Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version); + Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", System.Text.Encoding.UTF8.GetString(blobBytes)); // ── reset: back to scene-only ─────────────────────────────────────── var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset"); diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index ff3c5e8..5e0a967 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -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); + 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 ShouldOverlayRroRoomRoleDataAsync(string fileName) + /// 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. + 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 ShouldOverlayRroRoomRoleDataAsync(string fileName) => + await ResolveRroRoomAsync(fileName) is not null; + + /// The AG/RRO room a blob name belongs to, or null when the name + /// is not an RRO room's (current blob, synthetic room_{id}_* form, + /// or a sub-room scene blob). Drives both the role overlay on S3 hits and + /// the per-room stub on misses. + private async Task 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(); } /// diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index b4e6f6a..e06870e 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -19,7 +19,6 @@ public class RoomDataBlobService PersistedRoomVersion.LatestVersion; private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); - private readonly byte[] _rroEditableBlob = BuildRroEditableBlob(); private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData(); /// The blob served when a room_<id>_v1.dat misses S3 @@ -37,7 +36,15 @@ public class RoomDataBlobService } public byte[] GetDefaultBlob() => _defaultBlob; - public byte[] GetRroEditableBlob() => _rroEditableBlob; + public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty); + + /// The blob served for a baked Rec Room Original whose blob name + /// misses S3 (nothing saved yet). is the + /// room's LocationReplicationId — a real save carries it as + /// activity_id (Rec Center's saved blob: activity_id == + /// cbad71af-…, its location id). + public byte[] GetRroEditableBlob(string activityId) => + BuildRroEditableBlob(_rroEditableRoleData, activityId); private static byte[] LoadDefaultBlob() { @@ -267,19 +274,72 @@ public class RoomDataBlobService return BuildRroEditableRoleData(); } - private static byte[] BuildRroEditableBlob() + /// Shape of the stub matters: the March-2023 client fetches this + /// through RecNet.Rooms.GetRoomData and REJECTS a blob it deems + /// unusable (seen as the bare "Failed to copy room" on clones, and as a + /// baked room that silently loads without any room roles / Maker Pen). + /// Two things were wrong with the old six-field stub, measured against + /// Rec Center's real saved blob (room_100_v1.dat, which the same client + /// loads with Maker Pen): + /// + /// • version was stamped PersistedRoomVersion.LatestVersion + /// (19) — past the client's ceiling of + /// (16). The S3-hit path clamps that; the miss path never did. + /// • it lacked the skeleton every real save carries: last_save_time, + /// activity_id (= the room's LocationReplicationId), an empty + /// connectable_graph_data, scene_settings_data, + /// object_model_data and the four room_mood_* blocks. + /// + /// Our server proto only types the role/tag fields, so the rest is + /// appended as raw wire-format fields (protobuf field order is free). + /// The byte payloads below are Rec Center's own defaults, verbatim. + private static byte[] BuildRroEditableBlob(RoomRoleCollectionData roleData, string activityId) { var msg = new PersistedRoomData { DEPRECATEDVersion = LatestDeprecatedPersistenceVersion, - Version = LatestPersistenceVersion, + Version = (PersistedRoomVersion)Client2023MaxPersistedRoomVersion, CreativeRolesEnabled = true, ToolTagSettingsData = BuildDefaultToolTagSettingsData(), GameRoleData = BuildDefaultGameRoleData(), - RoomRoleData = BuildRroEditableRoleData(), + RoomRoleData = roleData.Clone(), }; - return msg.ToByteArray(); + using var output = new MemoryStream(4096); + var typed = msg.ToByteArray(); + output.Write(typed, 0, typed.Length); + + // 3: last_save_time (google.protobuf.Timestamp{seconds}) + var seconds = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + using (var ts = new MemoryStream(12)) + { + WriteVarint(ts, 0x08); + WriteVarint(ts, seconds); + AppendLengthDelimited(output, 3, ts.ToArray()); + } + // 4: activity_id + if (!string.IsNullOrWhiteSpace(activityId)) + AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId)); + // 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } } + AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 }); + // 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true } + AppendLengthDelimited(output, 24, new byte[] { 0x18, 0x01, 0x30, 0x01 }); + // 29: object_model_data (opaque bytes; empty model) + AppendLengthDelimited(output, 29, new byte[] { 0x08, 0x02, 0x1A, 0x00 }); + // 32-35: room_fog_data / room_mood_sun_data / room_mood_background_objects / room_mood_skydome + AppendLengthDelimited(output, 32, Convert.FromHexString("0a070d00000040100110122500004844")); + AppendLengthDelimited(output, 33, Convert.FromHexString("0a070d000000401001580162140dffffff3e15ffffff3e1dffffffbe25ffffff3e")); + AppendLengthDelimited(output, 34, Convert.FromHexString("0a070d000000401001121208ffffffffffffffffff01100d250000803f1a1208ffffffffffffffffff01100d250000803f221208ffffffffffffffffff01100d250000803f2a1208ffffffffffffffffff01100d250000803f")); + AppendLengthDelimited(output, 35, Convert.FromHexString("0a070d0000004010011032182920252825300d3dae47613e45713d8a3e")); + + return output.ToArray(); + } + + private static void AppendLengthDelimited(Stream output, int fieldNumber, byte[] payload) + { + WriteVarint(output, ((ulong)fieldNumber << 3) | 2); + WriteVarint(output, (ulong)payload.Length); + output.Write(payload, 0, payload.Length); } private static RoomRoleCollectionData BuildRroEditableRoleData() diff --git a/docs/admin.md b/docs/admin.md index 4fb874c..e10560f 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -213,6 +213,16 @@ actions: "Home" entry (`isVirtual: true`, max players = room capacity) and enable creates the real row with the game-side defaults (`RoomsController.GetOrCreateSceneForMutationAsync`). + What the CDN hands back for that name while nothing is saved is a + **per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`: + the skeleton of a real save (Rec Center's `room_100_v1.dat` was the + reference) — `version` 16 (the client's ceiling; the old roles-only stub + was stamped 19 and the client fetched it and silently loaded the room + with no roles, i.e. no Maker Pen), `activity_id` = the room's + `LocationReplicationId`, `last_save_time`, empty connectable graph / + object model, scene settings and the four `room_mood_*` blocks, plus the + permissive role collection. The miss path now runs the same 2023 + version clamp as the S3-hit path. - **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 4bcd009..6d86401 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -152,6 +152,17 @@ baked into the paintball scene). Its rule is the Maker Pen chip Rec Room delivered inside the room blob. Without it there is no `BattleRoyaleManager` in the scene. +Stub shape matters. Until something is saved the CDN synthesises the blob, +and the client fetches it through `RecNet.Rooms.GetRoomData` and rejects +one it deems unusable — a clone surfaces that as the bare "Failed to copy +room", a baked room as a silent load with no room roles (no Maker Pen). +Compared against Rec Center's working `room_100_v1.dat`, the old stub was +stamped `PersistedRoomVersion` 19 (client ceiling 16; only the S3-hit path +clamped) and lacked the save skeleton (`last_save_time`, `activity_id` = +LocationReplicationId, connectable graph, scene settings, object model, +`room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that +skeleton per room at version 16. + 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