Files
DorkNet/DorkNet.Server/Services/RoomDataBlobService.cs
T
Alexa 27314c1335 fix(cdn): serve baked RRO rooms a loadable stub blob (version 16, save skeleton)
An enabled baked room still had no Maker Pen: the client fetched the CDN's
synthetic stub for room_{id}_dorknet_v8.dat and silently loaded the room
with no room roles. Measured against Rec Center's working room_100_v1.dat
(10,930 bytes, the same client loads it with Maker Pen), the old 1,872-byte
roles-only stub was stamped PersistedRoomVersion 19 — past the March-2023
client's ceiling of 16, which only the S3-hit path clamped — and lacked the
skeleton every real save carries (last_save_time, activity_id = the room's
LocationReplicationId, connectable graph, scene settings, object model,
room_mood_* blocks).

RoomDataBlobService.BuildRroEditableBlob now emits that skeleton per room
at version 16 (untyped fields appended on the wire; payloads are Rec
Center's defaults verbatim). CdnController resolves the RRO room for the
miss path, passes its location id, and clamps synthesised blobs the same
way it clamps S3 hits. Tests pin version 16, the activity id and
CanUseMakerPen on every role.
2026-09-27 23:17:27 +03:00

511 lines
21 KiB
C#

using System.IO;
using Google.Protobuf;
using RecRoom.Protobuf;
namespace DorkNet.Server.Services;
/// <summary>
/// Builds the binary `PersistedRoomData` protobuf the client downloads when
/// it loads a room scene. When a real room blob is missing, serve captured
/// root room data with saved objects stripped and a 2023-compatible role
/// collection instead of fabricating saved objects into the scene.
/// </summary>
public class RoomDataBlobService
{
private const DEPRECATED_RoomPersistenceVersion LatestDeprecatedPersistenceVersion =
DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion;
private const PersistedRoomVersion LatestPersistenceVersion =
PersistedRoomVersion.LatestVersion;
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
/// <summary>The blob served when a room_&lt;id&gt;_v1.dat misses S3
/// (unsaved dorms / fresh customisable rooms). Uses the captured
/// <c>data/default_room.room</c> as an input, but strips top-level
/// persistence_views before serving it. Those views can reference
/// prefabs absent from older clients and crash spawn with
/// "Invalid Prefab Name: \"\""; room-role data uses the 2023 migration
/// marker so the client does not re-run the legacy role importer.</summary>
private readonly byte[] _defaultBlob;
public RoomDataBlobService()
{
_defaultBlob = LoadDefaultBlob();
}
public byte[] GetDefaultBlob() => _defaultBlob;
public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty);
/// <summary>The blob served for a baked Rec Room Original whose blob name
/// misses S3 (nothing saved yet). <paramref name="activityId"/> is the
/// room's <c>LocationReplicationId</c> — a real save carries it as
/// <c>activity_id</c> (Rec Center's saved blob: activity_id ==
/// cbad71af-…, its location id).</summary>
public byte[] GetRroEditableBlob(string activityId) =>
BuildRroEditableBlob(_rroEditableRoleData, activityId);
private static byte[] LoadDefaultBlob()
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "default_room.room");
if (File.Exists(path))
{
var bytes = File.ReadAllBytes(path);
if (bytes.Length > 0)
{
var msg = PersistedRoomData.Parser.ParseFrom(StripTopLevelField(bytes, 2));
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = BuildDefaultRoomRoleData();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
}
}
catch
{
// Fall through to a minimal valid blob — never let a missing/locked
// file take down room loads entirely.
}
return BuildMinimalDefaultBlob();
}
private static byte[] StripTopLevelField(byte[] input, int fieldNumberToStrip)
{
using var output = new MemoryStream(input.Length);
var pos = 0;
while (pos < input.Length)
{
var fieldStart = pos;
var tag = ReadVarint(input, ref pos);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
SkipField(input, ref pos, wireType);
if (fieldNumber != fieldNumberToStrip)
output.Write(input, fieldStart, pos - fieldStart);
}
return output.ToArray();
}
private static ulong ReadVarint(byte[] input, ref int pos)
{
ulong value = 0;
var shift = 0;
while (pos < input.Length)
{
var b = input[pos++];
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
shift += 7;
if (shift > 63) throw new InvalidDataException("Invalid protobuf varint.");
}
throw new EndOfStreamException("Unexpected end of protobuf varint.");
}
private static void SkipField(byte[] input, ref int pos, int wireType)
{
switch (wireType)
{
case 0:
_ = ReadVarint(input, ref pos);
break;
case 1:
pos += 8;
break;
case 2:
// NOTE: read the length into a local first. `pos += ReadVarint(ref pos)`
// adds the payload length to the PRE-length-varint position (C# evaluates
// the left operand of += before the right), landing the skip short by
// the size of the length varint and desyncing the whole walk.
var length = checked((int)ReadVarint(input, ref pos));
pos += length;
break;
case 5:
pos += 4;
break;
default:
throw new InvalidDataException($"Unsupported protobuf wire type {wireType}.");
}
if (pos > input.Length) throw new EndOfStreamException("Unexpected end of protobuf field.");
}
/// <summary>Highest <c>PersistedRoomVersion</c> (field 30) the running
/// March-2023 client (build 2023-03-21) actually accepts. NOT the same
/// as our proto's <see cref="PersistedRoomVersion.LatestVersion"/> (19):
/// that enum came from a runtime dump of a NEWER build, and this client
/// rejects 19 — its real ceiling is 16 (observed on the .room blobs of
/// rooms this client loads without complaint). Clamp to this, not the
/// proto's Latest.</summary>
public const int Client2023MaxPersistedRoomVersion = 16;
/// <summary>Clamp the two top-level version varints of a
/// <c>PersistedRoomData</c> blob to the newest values the March-2023
/// client knows: field 1 (<c>DEPRECATED_RoomPersistenceVersion</c>,
/// max 38) and field 30 (<c>PersistedRoomVersion</c>, max
/// <see cref="Client2023MaxPersistedRoomVersion"/> = 16). Modern RecNet
/// exports stamp much higher values (a Sep-2025 save carries
/// version=131) and the client refuses the whole room with its
/// "update Rec Room to visit this room" gate. Pure wire-format rewrite —
/// every other byte is preserved verbatim, so blobs already at or below
/// the 2023 versions round-trip unchanged. On malformed input the
/// original bytes are returned. NOTE: this only addresses the room-HEADER
/// version gate; a room whose CircuitsV2 graph (field 18) is too new is
/// rejected regardless and this clamp cannot help it.</summary>
public static (byte[] Bytes, bool Changed) ClampVersionsFor2023(byte[] input)
{
try
{
using var output = new MemoryStream(input.Length);
var pos = 0;
var changed = false;
while (pos < input.Length)
{
var fieldStart = pos;
var tag = ReadVarint(input, ref pos);
var fieldNumber = (int)(tag >> 3);
var wireType = (int)(tag & 0x07);
if (wireType == 0 && fieldNumber is 1 or 30)
{
var value = ReadVarint(input, ref pos);
var max = fieldNumber == 1
? (ulong)LatestDeprecatedPersistenceVersion
: (ulong)Client2023MaxPersistedRoomVersion;
if (value > max)
{
value = max;
changed = true;
}
WriteVarint(output, tag);
WriteVarint(output, value);
}
else
{
SkipField(input, ref pos, wireType);
output.Write(input, fieldStart, pos - fieldStart);
}
}
return changed ? (output.ToArray(), true) : (input, false);
}
catch
{
// Not parseable as a protobuf message — serve the original
// bytes rather than dropping the download.
return (input, false);
}
}
private static void WriteVarint(Stream output, ulong value)
{
while (value >= 0x80)
{
output.WriteByte((byte)(value | 0x80));
value >>= 7;
}
output.WriteByte((byte)value);
}
public byte[] OverlayAllPermsRoleData(byte[] existingBlob)
{
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = _allPermsRoleData.Clone();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
public byte[] OverlayRroEditableRoleData(byte[] existingBlob)
{
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
Ensure2023PersistenceHeader(msg);
msg.RoomRoleData = _rroEditableRoleData.Clone();
msg.GameRoleData ??= BuildDefaultGameRoleData();
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
return msg.ToByteArray();
}
private static byte[] BuildAllPermsBlob()
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = LatestPersistenceVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = BuildAllPermsRoleData(),
};
return msg.ToByteArray();
}
private static byte[] BuildMinimalDefaultBlob()
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = LatestPersistenceVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = BuildDefaultRoomRoleData(),
};
return msg.ToByteArray();
}
private static RoomRoleCollectionData BuildAllPermsRoleData()
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet,
};
collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator"));
return collection;
}
private static RoomRoleCollectionData BuildDefaultRoomRoleData()
{
return BuildRroEditableRoleData();
}
/// <summary>Shape of the stub matters: the March-2023 client fetches this
/// through <c>RecNet.Rooms.GetRoomData</c> and REJECTS a blob it deems
/// unusable (seen as the bare "Failed to copy room" on clones, and as a
/// baked room that silently loads without any room roles / Maker Pen).
/// Two things were wrong with the old six-field stub, measured against
/// Rec Center's real saved blob (room_100_v1.dat, which the same client
/// loads with Maker Pen):
///
/// • <c>version</c> was stamped <c>PersistedRoomVersion.LatestVersion</c>
/// (19) — past the client's ceiling of <see cref="Client2023MaxPersistedRoomVersion"/>
/// (16). The S3-hit path clamps that; the miss path never did.
/// • it lacked the skeleton every real save carries: <c>last_save_time</c>,
/// <c>activity_id</c> (= the room's LocationReplicationId), an empty
/// <c>connectable_graph_data</c>, <c>scene_settings_data</c>,
/// <c>object_model_data</c> and the four <c>room_mood_*</c> blocks.
///
/// 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)
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = LatestDeprecatedPersistenceVersion,
Version = (PersistedRoomVersion)Client2023MaxPersistedRoomVersion,
CreativeRolesEnabled = true,
ToolTagSettingsData = BuildDefaultToolTagSettingsData(),
GameRoleData = BuildDefaultGameRoleData(),
RoomRoleData = roleData.Clone(),
};
using var output = new MemoryStream(4096);
var typed = msg.ToByteArray();
output.Write(typed, 0, typed.Length);
// 3: last_save_time (google.protobuf.Timestamp{seconds})
var seconds = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
using (var ts = new MemoryStream(12))
{
WriteVarint(ts, 0x08);
WriteVarint(ts, seconds);
AppendLengthDelimited(output, 3, ts.ToArray());
}
// 4: activity_id
if (!string.IsNullOrWhiteSpace(activityId))
AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId));
// 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 }
AppendLengthDelimited(output, 24, new byte[] { 0x18, 0x01, 0x30, 0x01 });
// 29: object_model_data (opaque bytes; empty model)
AppendLengthDelimited(output, 29, new byte[] { 0x08, 0x02, 0x1A, 0x00 });
// 32-35: room_fog_data / room_mood_sun_data / room_mood_background_objects / room_mood_skydome
AppendLengthDelimited(output, 32, Convert.FromHexString("0a070d00000040100110122500004844"));
AppendLengthDelimited(output, 33, Convert.FromHexString("0a070d000000401001580162140dffffff3e15ffffff3e1dffffffbe25ffffff3e"));
AppendLengthDelimited(output, 34, Convert.FromHexString("0a070d000000401001121208ffffffffffffffffff01100d250000803f1a1208ffffffffffffffffff01100d250000803f221208ffffffffffffffffff01100d250000803f2a1208ffffffffffffffffff01100d250000803f"));
AppendLengthDelimited(output, 35, Convert.FromHexString("0a070d0000004010011032182920252825300d3dae47613e45713d8a3e"));
return output.ToArray();
}
private static void AppendLengthDelimited(Stream output, int fieldNumber, byte[] payload)
{
WriteVarint(output, ((ulong)fieldNumber << 3) | 2);
WriteVarint(output, (ulong)payload.Length);
output.Write(payload, 0, payload.Length);
}
private static RoomRoleCollectionData BuildRroEditableRoleData()
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = MigratedToRecNetVersion.MigratedToRecNet,
};
// The 2023 room-permissions runtime looks up roles by RecNet account-role
// values during load. Keep the AG role bitmasks below, but also provide
// compatibility aliases for the keys OMJKHJLFOCO.ONECLLALJEO can return.
collection.RoomRoles.Add(BuildPermissiveRole(0, 10, "Default"));
collection.RoomRoles.Add(BuildPermissiveRole(10, 60, "Player"));
collection.RoomRoles.Add(BuildPermissiveRole(20, 80, "Host"));
collection.RoomRoles.Add(BuildPermissiveRole(30, 90, "Co-owner"));
collection.RoomRoles.Add(BuildPermissiveRole(255, 100, "Creator"));
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
collection.RoomRoles.Add(BuildPermissiveRole(4_194_304, 90, "Co-owner")); // AG_COOWNER
collection.RoomRoles.Add(BuildPermissiveRole(8_388_608, 80, "Host")); // AG_HOST
collection.RoomRoles.Add(BuildPermissiveRole(16_777_216, 70, "Moderator")); // AG_MODERATOR
return collection;
}
private static PlayerRoomRoleData BuildPermissiveRole(int roleId, int rank, string name)
{
// OverridableBoolData(overrides=true, inner_value=true) — used for
// every Can* field on the role.
OverridableBoolData OverrideTrue() => new()
{
Overrides = true,
InnerValue = true,
};
return new PlayerRoomRoleData
{
// Identity fields — these are deprecated in the 2026 schema
// but the 2020 client still reads them.
DEPRECATEDRoleId = roleId,
DEPRECATEDRoleRank = rank,
DEPRECATEDIsRoleActive = true,
DEPRECATEDIsAgRole = true,
RoleName = name,
RoleVersion = 20,
RoleGuid = StableRoleGuid(roleId),
// Display name override — keeps the watch's role-list UI from
// showing a blank label.
Name = new OverridableStringData
{
Overrides = true,
InnerValue = name,
},
// Permission grants. Each Overridable*Data(overrides=true,
// inner_value=true) tells the client "the room has explicitly
// configured this permission to true". Without overrides=true
// the client falls back to its own defaults, which for most
// perms is false.
CanAssignRoles = OverrideTrue(),
CanInvite = OverrideTrue(),
DEPRECATEDCanEditCircuits = OverrideTrue(),
CanStartGames = OverrideTrue(),
CanTalk = OverrideTrue(),
CanPrintPhotos = OverrideTrue(),
CanEditRoomRoles = OverrideTrue(),
CanSelfRevive = OverrideTrue(),
CanEndGamesEarly = OverrideTrue(),
CanChangeGameMode = OverrideTrue(),
CanUseMakerPen = OverrideTrue(),
CanUseDeleteAllButton = OverrideTrue(),
CanSaveInventions = OverrideTrue(),
DisableMicAutoMute = OverrideTrue(),
CanUseShareCam = OverrideTrue(),
CanSpawnInventions = OverrideTrue(),
CanSpawnConsumables = OverrideTrue(),
CanUseRoomResetButton = OverrideTrue(),
CanUsePlayGizmosToggle = OverrideTrue(),
// Vote-kick permission — int field, set to a permissive value.
// 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via
// VoteKickPermission disasm). overrides=true to make sure the
// client respects it rather than falling back to a stricter
// default.
VoteKickPermission = new OverridableIntData
{
Overrides = true,
InnerValue = 0,
},
};
}
private static GameRoleCollectionData BuildDefaultGameRoleData()
{
return new GameRoleCollectionData();
}
private static ToolTagSettingsData BuildDefaultToolTagSettingsData()
{
return new ToolTagSettingsData
{
RuntimeTagData = new TagData(),
};
}
private static void Ensure2023PersistenceHeader(PersistedRoomData msg)
{
if ((int)msg.DEPRECATEDVersion < 20)
{
msg.DEPRECATEDVersion = LatestDeprecatedPersistenceVersion;
}
msg.Version = LatestPersistenceVersion;
msg.CreativeRolesEnabled = true;
msg.ToolTagSettingsData ??= BuildDefaultToolTagSettingsData();
}
private static string StableRoleGuid(int roleId) =>
roleId switch
{
2_097_152 => "D8B12451-23C7-4B1D-B573-8C3717A47915",
4_194_304 => "88EAECE9-D885-4568-BC96-AF316AD56663",
8_388_608 => "BD0F2F3A-F931-419E-B50C-ECDFF3F56B52",
16_777_216 => "32300035-3BEA-457E-95C0-1630AFDFA6BD",
0 => Guid.Empty.ToString(),
10 => "3C66F53A-6B76-4DB1-A93F-76F1F59E03B8",
20 => "1E8890ED-D729-446B-835B-3C96D8C7D939",
30 => "FA1825F9-8F41-54E8-8DB4-530C6B24B3E5",
255 => "6F31F2B8-AC6E-549F-8F8C-F8344C9BAE1E",
_ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(),
};
}
internal static class GuidUtility
{
public static readonly Guid UrlNamespace = new("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
public static Guid Create(Guid namespaceId, string name)
{
var namespaceBytes = namespaceId.ToByteArray();
SwapByteOrder(namespaceBytes);
var nameBytes = System.Text.Encoding.UTF8.GetBytes(name);
var data = new byte[namespaceBytes.Length + nameBytes.Length];
Buffer.BlockCopy(namespaceBytes, 0, data, 0, namespaceBytes.Length);
Buffer.BlockCopy(nameBytes, 0, data, namespaceBytes.Length, nameBytes.Length);
var hash = System.Security.Cryptography.SHA1.HashData(data);
var newGuid = new byte[16];
Array.Copy(hash, 0, newGuid, 0, 16);
newGuid[6] = (byte)((newGuid[6] & 0x0F) | 0x50);
newGuid[8] = (byte)((newGuid[8] & 0x3F) | 0x80);
SwapByteOrder(newGuid);
return new Guid(newGuid);
}
private static void SwapByteOrder(byte[] guid)
{
(guid[0], guid[3]) = (guid[3], guid[0]);
(guid[1], guid[2]) = (guid[2], guid[1]);
(guid[4], guid[5]) = (guid[5], guid[4]);
(guid[6], guid[7]) = (guid[7], guid[6]);
}
}