diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs index 5e9d2a2..43d616f 100644 --- a/DorkNet.Server.Tests/RroRoomBlobTests.cs +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -39,6 +39,7 @@ public sealed class RroRoomBlobTests : IClassFixture var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); var roomId = 9_600_000 + Random.Shared.Next(1, 99_999); + var roomName = $"Royale{Guid.NewGuid():N}"[..18]; await using (var scope = _factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); @@ -49,7 +50,7 @@ public sealed class RroRoomBlobTests : IClassFixture db.Rooms.Add(new RoomEntity { Id = roomId, - Name = $"Royale{Guid.NewGuid():N}"[..18], + Name = roomName, CreatorPlayerId = 1, IsAGRoom = true, TagsCsv = "recroomoriginal,sport", @@ -110,7 +111,20 @@ public sealed class RroRoomBlobTests : IClassFixture // 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)); + var blobText = System.Text.Encoding.UTF8.GetString(blobBytes); + Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText); + + // Every real room save carries three GameRoleNode chips (default role + // named after the room, "In-Game", "Eliminated" — well-known GUIDs). + // The 2023 client grants Maker Pen off the player's GameRole, so a + // blob with no role objects leaves even a co-owner with nothing. + Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes)); + Assert.Contains("In-Game", blobText); + Assert.Contains("Eliminated", blobText); + Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText); + Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText); + Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText); + Assert.Contains(roomName, blobText); // ── reset: back to scene-only ─────────────────────────────────────── var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset"); @@ -215,6 +229,46 @@ public sealed class RroRoomBlobTests : IClassFixture Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString()); } + /// Count top-level persistence_views (field 2) entries by + /// walking the wire format — our server proto doesn't type field 2. + private static int CountTopLevelPersistenceViews(byte[] blob) + { + var pos = 0; + var count = 0; + while (pos < blob.Length) + { + var tag = ReadVarint(blob, ref pos); + var field = (int)(tag >> 3); + var wireType = (int)(tag & 7); + switch (wireType) + { + case 0: ReadVarint(blob, ref pos); break; + case 1: pos += 8; break; + case 2: + var len = (int)ReadVarint(blob, ref pos); + if (field == 2) count++; + pos += len; + break; + case 5: pos += 4; break; + default: throw new InvalidDataException($"wire type {wireType}"); + } + } + return count; + } + + private static ulong ReadVarint(byte[] input, ref int pos) + { + ulong value = 0; + var shift = 0; + while (true) + { + var b = input[pos++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } + private static JsonElement SubRoomZero(JsonElement details) => details.GetProperty("SubRooms").EnumerateArray() .First(e => e.GetProperty("SubRoomId").GetInt64() == 0); diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index 5e0a967..f1069d2 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -568,7 +568,7 @@ public class CdnController( // 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); + var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name); rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName); Response.Headers.CacheControl = "public, max-age=60"; signatures.AddContentSignature(Response, rroBlob); diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index e06870e..e73c760 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -44,7 +44,12 @@ public class RoomDataBlobService /// activity_id (Rec Center's saved blob: activity_id == /// cbad71af-…, its location id). public byte[] GetRroEditableBlob(string activityId) => - BuildRroEditableBlob(_rroEditableRoleData, activityId); + BuildRroEditableBlob(_rroEditableRoleData, activityId, "Room"); + + /// Name of the room's default game role (Rec + /// Center's is "Rec Center", Stunt Runner's "StuntRunner"). + public byte[] GetRroEditableBlob(string activityId, string roomName) => + BuildRroEditableBlob(_rroEditableRoleData, activityId, roomName); private static byte[] LoadDefaultBlob() { @@ -293,7 +298,7 @@ public class RoomDataBlobService /// 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) + private static byte[] BuildRroEditableBlob(RoomRoleCollectionData roleData, string activityId, string roomName) { var msg = new PersistedRoomData { @@ -320,6 +325,28 @@ public class RoomDataBlobService // 4: activity_id if (!string.IsNullOrWhiteSpace(activityId)) AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId)); + + // 2: persistence_views — the room's own GAME-ROLE objects. Every real + // room save carries three GameRoleNode chips (default role named after + // the room, "In-Game", "Eliminated"; Rec Center and Stunt Runner both + // do, with the same well-known role GUIDs), and the 2023 client reads + // Maker Pen / save / spawn rights off the player's GameRole + // (ICreatorRole.get_CreatorRoleCanUseMakerPen on + // RecRoom.Systems.PlayerRoles.GameRole). A blob with no role objects + // gives a co-owner nothing to hold, so MakerPen.CheckMakerPen- + // PermissionAndUpdateMode fails even though the room-role collection + // says yes. View ids are derived from the activity id so repeated + // downloads describe the same objects. + var seed = string.IsNullOrWhiteSpace(activityId) ? "rro" : activityId; + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, roomName, DefaultGameRoleGuid, nodeId: 7, + transform: "0a0f0db7968041152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 0, roleRank: 0, isDefaultRole: true)); + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "In-Game", InGameGameRoleGuid, nodeId: 9, + transform: "0a0f0d51308241152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 2_097_152, roleRank: 1, isDefaultRole: false)); + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "Eliminated", EliminatedGameRoleGuid, nodeId: 11, + transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 4_194_304, roleRank: 1015, isDefaultRole: false)); // 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 } @@ -342,6 +369,102 @@ public class RoomDataBlobService output.Write(payload, 0, payload.Length); } + private static void AppendVarint(Stream output, int fieldNumber, ulong value) + { + WriteVarint(output, ((ulong)fieldNumber << 3) | 0); + WriteVarint(output, value); + } + + // Well-known game-role GUIDs, identical in every room save inspected + // (Rec Center room_100_v1.dat, Stunt Runner room_125_v1.dat). + private const string DefaultGameRoleGuid = "c48bac02-a743-4797-94c4-e2d6f0749004"; + private const string InGameGameRoleGuid = "2c721342-c7ed-49fe-b8f0-93f8d282a6df"; + private const string EliminatedGameRoleGuid = "0b9ec39e-2cc9-4825-8321-da11561f1c4f"; + + // SpawnableToolData.prefab_id of the GameRoleNode chip prefab (16 raw + // bytes, same in both reference saves). + private static readonly byte[] GameRoleNodePrefabId = Convert.FromHexString("c46e153588a47147835d6cc2f69bfd6c"); + + /// One PersistenceViewData carrying a GameRoleNode chip, + /// laid out exactly as the client's own writer emits it: id, empty + /// tool_entity_data, transform, spawnable_tool_data{prefab_id}, + /// creation_object_data{is_frozen, is_grabbable}, empty tool_cleanup_data, + /// circuit_node_data{id}, game_role_node_data{PlayerGameRoleData}. The + /// PlayerGameRoleData mirrors the reference saves field-for-field, + /// including the empty Overridable* submessages the writer always emits + /// (the reader expects them present). + private static byte[] BuildGameRoleView(string seed, string roleName, string roleGuid, int nodeId, + string transform, int roleId, int roleRank, bool isDefaultRole) + { + using var view = new MemoryStream(512); + // 1: id — stable per (room, role) + AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-role:{seed}:{roleGuid}"))); + AppendLengthDelimited(view, 9, Array.Empty()); // tool_entity_data + AppendLengthDelimited(view, 10, Convert.FromHexString(transform)); // transform + using (var spawnable = new MemoryStream(20)) // spawnable_tool_data + { + AppendLengthDelimited(spawnable, 1, GameRoleNodePrefabId); + AppendLengthDelimited(view, 11, spawnable.ToArray()); + } + AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 }); // creation_object_data + AppendLengthDelimited(view, 22, Array.Empty()); // tool_cleanup_data + using (var node = new MemoryStream(4)) // circuit_node_data + { + AppendVarint(node, 1, (ulong)nodeId); + AppendLengthDelimited(view, 25, node.ToArray()); + } + using (var roleNode = new MemoryStream(512)) // game_role_node_data + { + AppendLengthDelimited(roleNode, 1, BuildPlayerGameRole(roleName, roleGuid, roleId, roleRank, isDefaultRole)); + AppendLengthDelimited(view, 50, roleNode.ToArray()); + } + return view.ToArray(); + } + + private static byte[] BuildPlayerGameRole(string roleName, string roleGuid, int roleId, int roleRank, bool isDefaultRole) + { + // Field order and the set of always-present empty submessages copied + // from Rec Center's save (role_version 33 = the 2023 writer). + int[] order = + { + 3, 5, 8, 11, 12, 13, 14, 15, 17, 18, 19, 21, 22, 23, 24, 26, 27, 30, 31, 32, 33, 34, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 76, + 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, + }; + static byte[] OverrideBool(bool value) => value ? new byte[] { 0x08, 0x01, 0x10, 0x01 } : new byte[] { 0x08, 0x01 }; + + using var role = new MemoryStream(320); + if (!isDefaultRole) + { + AppendVarint(role, 1, (ulong)roleId); // role_id (AG bitmask) + AppendVarint(role, 2, (ulong)roleRank); // role_rank + } + foreach (var f in order) + { + switch (f) + { + case 3: AppendVarint(role, 3, 1); break; // is_role_active + case 5: AppendLengthDelimited(role, 5, System.Text.Encoding.UTF8.GetBytes(roleName)); break; + case 17 when roleName == "Eliminated": + AppendLengthDelimited(role, 17, new byte[] { 0x08, 0x01, 0x10, 0x01 }); break; // item_pickup_restrictions + case 26 when isDefaultRole: + AppendLengthDelimited(role, 26, new byte[] { 0x08, 0x01, 0x10, 0x14 }); break; // voice_rolloff_max_distance=20 + case 27: AppendVarint(role, 27, 33); break; // role_version + case 31 when roleName == "Eliminated": + AppendLengthDelimited(role, 31, OverrideBool(false)); break; // can_move + case 49: AppendLengthDelimited(role, 49, System.Text.Encoding.UTF8.GetBytes(roleGuid)); break; + // DorkNet policy for editable RROs: the default role may use + // the Maker Pen regardless of room role (matches the + // permissive room-role overlay the CDN applies). + case 94 when isDefaultRole: + AppendLengthDelimited(role, 94, OverrideBool(true)); break; // can_use_maker_pen + default: AppendLengthDelimited(role, f, Array.Empty()); break; + } + } + return role.ToArray(); + } + private static RoomRoleCollectionData BuildRroEditableRoleData() { var collection = new RoomRoleCollectionData diff --git a/docs/admin.md b/docs/admin.md index e10560f..046f46d 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -222,7 +222,16 @@ actions: `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. + version clamp as the S3-hit path. It also carries the room's own three + **game-role objects** (a default role named after the room, plus the + built-in "In-Game" and "Eliminated" roles, well-known GUIDs shared by + every room save — Rec Center and Stunt Runner both have exactly these). + The 2023 client grants Maker Pen off the player's *game* role + (`ICreatorRole.CreatorRoleCanUseMakerPen` on `GameRole`), so a blob with + no role objects leaves even a co-owner with nothing to hold — that was + the second reason "enable + co-owner" still showed no Maker Pen. The + default role sets `can_use_maker_pen` to true, matching the permissive + room-role overlay; the built-in roles mirror the reference saves. - **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 6d86401..9002a22 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -163,6 +163,17 @@ LocationReplicationId, connectable graph, scene settings, object model, `room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that skeleton per room at version 16. +Second requirement — game-role objects. Both reference saves carry three +`PersistenceViewData` entries whose `game_role_node_data` (field 50) holds a +`PlayerGameRoleData`: the room's default role (Rec Center: "Rec Center", +Stunt Runner: "StuntRunner"; GUID `c48bac02-…` in both), "In-Game" +(`role_id` 2097152, GUID `2c721342-…`) and "Eliminated" (`role_id` +4194304, rank 1015, `can_move` false, GUID `0b9ec39e-…`). Their +`spawnable_tool_data.prefab_id` is the GameRoleNode chip prefab +(`c46e1535…` raw). Since 2023 permissions hang off the player's GameRole, +a blob without these leaves a co-owner with no Maker Pen. The stub now +generates Rec Royale's own set (view ids derived from the activity id). + 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