From 806b160acc549ad851c17f9bc77164d336ba14a1 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:45:57 +0300 Subject: [PATCH] feat(cdn): Rec Royale stub blob ships the room's game-rule chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ → 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). --- DorkNet.Server.Tests/RroRoomBlobTests.cs | 42 ++++++++++++ .../Services/RoomDataBlobService.cs | 67 +++++++++++++++++++ docs/admin.md | 6 ++ docs/recroom-2023-room-save.md | 16 +++++ 4 files changed, 131 insertions(+) diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs index 43d616f..5b83ac8 100644 --- a/DorkNet.Server.Tests/RroRoomBlobTests.cs +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -197,6 +197,48 @@ public sealed class RroRoomBlobTests : IClassFixture Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString()); } + /// 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). + [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(); + (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() { diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index e73c760..618bed3 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -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_ → 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); } + /// 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 SpawnableTool_Runtime_* 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. + 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; + } + + /// 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). + 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()); + // 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()); + using (var node = new MemoryStream(4)) + { + AppendVarint(node, 1, (ulong)nodeId); + AppendLengthDelimited(view, 25, node.ToArray()); + } + AppendLengthDelimited(view, 41, Array.Empty()); + return view.ToArray(); + } + private static void AppendVarint(Stream output, int fieldNumber, ulong value) { WriteVarint(output, ((ulong)fieldNumber << 3) | 0); diff --git a/docs/admin.md b/docs/admin.md index 046f46d..6dd0b8b 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -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 diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 9002a22..6e24aba 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -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_` 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