feat(cdn): Rec Royale stub blob ships the room's game-rule chip

Frontier has no baked *_GameRuleWrapperCircuit; real Rec Room delivered
Rec Royale's rule chip in the room blob, and without it there is no
BattleRoyaleManager and no match. A newer-client export of a custom Rec
Royale room shows the saved RecRoyaleSquads chip is minimal — prefab id,
palette tag (prefab name lower-cased, 25 chars), circuit node id and an
EMPTY GameRuleWrapperData (field 41) — so the stub can generate it.

Prefab ids read from the client's spawnable-tool table in resources.assets
(SpawnableTool_Runtime_<ENUM> → 20-byte hash → two u32 → 16-byte id), the
Squads id cross-checked against the real save: Solos 983db81a…, Squads
857f7500…. The stub for a room whose name contains RecRoyaleSolos /
RecRoyaleSquads carries the matching chip as a fourth persistence view.
Test: Rec_royale_stub_carries_its_game_rule_chip (both rooms).
This commit is contained in:
Alexa
2026-09-27 23:45:57 +03:00
parent 3edeaa5484
commit 806b160acc
4 changed files with 131 additions and 0 deletions
+42
View File
@@ -197,6 +197,48 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString()); Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
} }
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
/// in the blob. The stub for those rooms must carry it (a fourth
/// persistence view with the palette tag the client generates).</summary>
[Theory]
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
{
using var setup = Client("rooms");
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
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 = roomName,
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
});
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
using var cdnClient = Client("cdn");
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
Assert.True(blobResponse.IsSuccessStatusCode);
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
}
[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()
{ {
@@ -347,6 +347,19 @@ public class RoomDataBlobService
AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "Eliminated", EliminatedGameRoleGuid, nodeId: 11, AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "Eliminated", EliminatedGameRoleGuid, nodeId: 11,
transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f", transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f",
roleId: 4_194_304, roleRank: 1015, isDefaultRole: false)); roleId: 4_194_304, roleRank: 1015, isDefaultRole: false));
// 2: the room's GAME-RULE chip, for baked originals whose rule is not
// in the scene. Rec Royale's Frontier scene has no baked
// *_GameRuleWrapperCircuit (the paintball scenes do); real Rec Room
// delivered it in the blob. A saved RecRoyaleSquads chip (custom
// Rec Royale room export) is just header + prefab id + palette tag +
// node id + an EMPTY GameRuleWrapperData (field 41), so it can be
// generated. Prefab ids come from the client's spawnable-tool table
// in resources.assets (SpawnableTool_Runtime_<ENUM> → 16-byte id),
// cross-checked against ids seen in real saves.
var chip = GameRuleChipFor(roomName);
if (chip is not null)
AppendLengthDelimited(output, 2, BuildGameRuleChipView(seed, chip.Value.PrefabId, chip.Value.Tag, nodeId: 3));
// 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } } // 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } }
AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 }); AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 });
// 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true } // 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true }
@@ -369,6 +382,60 @@ public class RoomDataBlobService
output.Write(payload, 0, payload.Length); output.Write(payload, 0, payload.Length);
} }
/// <summary>Game-rule wrapper chips for baked originals whose rule is not
/// baked into the scene, keyed by (a substring of) the room name. Ids are
/// the client's <c>SpawnableTool_Runtime_*</c> prefab ids (2023-03-21
/// resources.assets); the Squads id also matches a real newer-client
/// save. Tags are what the client generates: prefab name lower-cased,
/// cut to 25 characters.</summary>
private static (byte[] PrefabId, string Tag)? GameRuleChipFor(string roomName)
{
if (string.IsNullOrWhiteSpace(roomName)) return null;
var n = roomName.Replace("-", "").Replace("_", "").Replace(" ", "");
if (n.Contains("RecRoyaleSquads", StringComparison.OrdinalIgnoreCase))
return (Convert.FromHexString("857f750016ca77408ae9f72f9877748a"), "recroyalesquadsgameruleci");
if (n.Contains("RecRoyaleSolos", StringComparison.OrdinalIgnoreCase))
return (Convert.FromHexString("983db81a68927f419edb0daf1243e93e"), "recroyalesolosgamerulecir");
return null;
}
/// <summary>PersistenceViewData for a GameRuleCircuitWrapperTool chip,
/// laid out like the saved RecRoyaleSquads chip: id, empty
/// tool_entity_data, transform, spawnable_tool_data{prefab_id},
/// creation_object_data{is_frozen,is_grabbable}, tagged_tool_data{tag},
/// empty tool_cleanup_data, circuit_node_data{id}, empty
/// game_rule_wrapper_data (field 41).</summary>
private static byte[] BuildGameRuleChipView(string seed, byte[] prefabId, string tag, int nodeId)
{
using var view = new MemoryStream(256);
AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData(
System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-gamerule:{seed}:{tag}")));
AppendLengthDelimited(view, 9, Array.Empty<byte>());
// Same spot the saved Squads chip sat at (position/scale/rotation).
AppendLengthDelimited(view, 10, Convert.FromHexString("0a0f0d804081c21520b58b431d941d50c42d0000803f32140d7cbd5d3f150034353a1d75deffbe250034353a"));
using (var spawnable = new MemoryStream(20))
{
AppendLengthDelimited(spawnable, 1, prefabId);
AppendLengthDelimited(view, 11, spawnable.ToArray());
}
AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 });
using (var tagged = new MemoryStream(40))
{
using var tagData = new MemoryStream(32);
AppendLengthDelimited(tagData, 1, System.Text.Encoding.UTF8.GetBytes(tag));
AppendLengthDelimited(tagged, 1, tagData.ToArray());
AppendLengthDelimited(view, 15, tagged.ToArray());
}
AppendLengthDelimited(view, 22, Array.Empty<byte>());
using (var node = new MemoryStream(4))
{
AppendVarint(node, 1, (ulong)nodeId);
AppendLengthDelimited(view, 25, node.ToArray());
}
AppendLengthDelimited(view, 41, Array.Empty<byte>());
return view.ToArray();
}
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);
+6
View File
@@ -232,6 +232,12 @@ actions:
the second reason "enable + co-owner" still showed no Maker Pen. The the second reason "enable + co-owner" still showed no Maker Pen. The
default role sets `can_use_maker_pen` to true, matching the permissive default role sets `can_use_maker_pen` to true, matching the permissive
room-role overlay; the built-in roles mirror the reference saves. room-role overlay; the built-in roles mirror the reference saves.
For the two Rec Royale rooms the stub also carries the room's
**game-rule chip** (`RecRoyaleSolos_GameRuleWrapperCircuit` prefab id
`983db81a…`, `RecRoyaleSquads_…` `857f7500…`, read from the client's
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.
- **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
+16
View File
@@ -174,6 +174,22 @@ Stunt Runner: "StuntRunner"; GUID `c48bac02-…` in both), "In-Game"
a blob without these leaves a co-owner with no Maker Pen. The stub now 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). generates Rec Royale's own set (view ids derived from the activity id).
Game-rule chip. A newer-client export of a custom Rec Royale room showed
the saved `RecRoyaleSquads_GameRuleWrapperCircuit` chip is minimal:
`spawnable_tool_data.prefab_id` `857f7500…`, `tagged_tool_data` tag
`recroyalesquadsgameruleci` (prefab name lower-cased, 25 chars),
`circuit_node_data.id`, and an EMPTY `game_rule_wrapper_data` (field 41).
Prefab ids live in the client's spawnable-tool table in
`resources.assets`: after `SpawnableTool_Runtime_<ENUM>` comes a 20-byte
hash (len-prefixed), two u32s, then the 16-byte prefab id (len-prefixed),
then the enum again, display name and tag. Extracted:
Solos `983db81a68927f419edb0daf1243e93e`, Squads
`857f750016ca77408ae9f72f9877748a` (matches the save), StuntRunner
`28276d92470ff34ab05f72b2707a04d1`. Stunt Runner's other rule-ish object
(`84eb8434…`, `game_configuration_data` field 40) is the generic Game
Configuration chip, optional — `GameConfigurationTool` falls back to its
baked asset. The stub emits the wrapper chip for the two Rec Royale rooms.
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