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());
}
/// <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]
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,
transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f",
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 } }
AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 });
// 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);
}
/// <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)
{
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
default role sets `can_use_maker_pen` to true, matching the permissive
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`.
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
+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
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`
stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and
room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data