mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
fix(cdn): RRO stub blob carries the room's own game-role objects
Enable + co-owner still gave no Maker Pen. The 2023 client grants Maker Pen / save / spawn rights off the player's GameRole (ICreatorRole.CreatorRoleCanUseMakerPen on RecRoom.Systems.PlayerRoles .GameRole), and game roles are persisted OBJECTS in the room blob: every real save inspected (Rec Center room_100_v1.dat, Stunt Runner room_125_v1.dat) carries three GameRoleNode chips — a default role named after the room plus the built-in "In-Game" and "Eliminated" roles, with well-known GUIDs shared by every room. A blob with none leaves a co-owner with no role to hold, so MakerPen.CheckMakerPenPermissionAndUpdateMode fails regardless of the room-role collection. BuildRroEditableBlob now emits Rec Royale's own three role objects, laid out field-for-field like the client's writer (including the always-present empty Overridable submessages), with view ids derived from the activity id so repeated downloads describe the same objects. The default role sets can_use_maker_pen, matching the permissive room-role overlay. Test pins three persistence views and the three role GUIDs.
This commit is contained in:
@@ -39,6 +39,7 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
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<DorkNetDbContext>();
|
||||
@@ -49,7 +50,7 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
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<DorkNetServerFactory>
|
||||
// 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<DorkNetServerFactory>
|
||||
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
|
||||
/// walking the wire format — our server proto doesn't type field 2.</summary>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,7 +44,12 @@ public class RoomDataBlobService
|
||||
/// <c>activity_id</c> (Rec Center's saved blob: activity_id ==
|
||||
/// cbad71af-…, its location id).</summary>
|
||||
public byte[] GetRroEditableBlob(string activityId) =>
|
||||
BuildRroEditableBlob(_rroEditableRoleData, activityId);
|
||||
BuildRroEditableBlob(_rroEditableRoleData, activityId, "Room");
|
||||
|
||||
/// <param name="roomName">Name of the room's default game role (Rec
|
||||
/// Center's is "Rec Center", Stunt Runner's "StuntRunner").</param>
|
||||
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.</summary>
|
||||
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");
|
||||
|
||||
/// <summary>One <c>PersistenceViewData</c> 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).</summary>
|
||||
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<byte>()); // 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<byte>()); // 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<byte>()); break;
|
||||
}
|
||||
}
|
||||
return role.ToArray();
|
||||
}
|
||||
|
||||
private static RoomRoleCollectionData BuildRroEditableRoleData()
|
||||
{
|
||||
var collection = new RoomRoleCollectionData
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user