feat: Enhance UgcPurchasablesController to handle bulk requests with object arrays

- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs.
- Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists.
- Added synthetic placeholder responses for missing UGC items to prevent client errors.

feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController

- Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID.
- Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms.
- Improved logging for matchmaking actions.

fix: Refactor PlayerSettingsController to handle database save exceptions

- Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios.
- Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync.

chore: Update URL coverage documentation for new matchmaking endpoints

- Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md.

chore: Add default room data file for room loading

- Included default_room.room to serve as a fallback for missing room data.

fix: Improve SQLite concurrency handling in DatabaseBootstrap

- Configured SQLite to use WAL mode and set a busy timeout for better concurrency.

chore: Create SqliteErrors utility for handling SQLite exceptions

- Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions.

refactor: Update RoomDataBlobService to serve default room data

- Modified GetDefaultBlob to load and serve a default room blob with proper role data.
This commit is contained in:
Alexa
2026-06-23 20:54:29 +03:00
parent 27e5e0064c
commit 02f7bc386d
18 changed files with 1327 additions and 170 deletions
@@ -17,14 +17,10 @@ public sealed class ImageSignatureService
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID")
?? "p1");
// `.rec.net` in the client's `KEY:RSA:p1.rec.net` literal gets byte-rewritten
// by patch-client.ps1 to match the server domain (e.g. `.localhost`). The
// header suffix we emit has to match that, or the client errors as
// "Signature malformed". By default we derive the suffix from the request
// Host, which works for the standard same-domain patcher setup. Override
// with ImageSigning:KeyIdHostSuffix if your client metadata was patched to
// a different value than the server's host (e.g. running the server at
// `dorknet.example` while the client metadata still says `localhost`).
// 2023 compares Content-Signature's key-id against the embedded
// KEY:RSA:p1.rec.net / d1.rec.net literals. If a specific client build
// is bytepatched to another suffix, ImageSigning:KeyIdHostSuffix can
// override this default.
configuredKeyIdHostSuffix =
config["ImageSigning:KeyIdHostSuffix"]
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID_SUFFIX");
@@ -272,19 +268,12 @@ public sealed class ImageSignatureService
var suffix = configuredKeyIdHostSuffix is { Length: > 0 }
? configuredKeyIdHostSuffix
: ExtractParentDomain(response.HttpContext.Request.Host.Host);
: "rec.net";
response.Headers["Content-Signature"] =
$"key-id=KEY:RSA:{headerKeyId}.{suffix}; data={Convert.ToBase64String(signature)}";
}
private static string ExtractParentDomain(string host)
{
if (string.IsNullOrEmpty(host)) return "rec.net";
var dot = host.IndexOf('.');
return dot > 0 && dot < host.Length - 1 ? host[(dot + 1)..] : host;
}
private static byte[] PlaceholderSignature(byte[] bytes)
{
var signature = new byte[128];
+144 -27
View File
@@ -1,3 +1,4 @@
using System.IO;
using Google.Protobuf;
using RecRoom.Protobuf;
@@ -5,40 +6,115 @@ namespace DorkNet.Server.Services;
/// <summary>
/// Builds the binary `PersistedRoomData` protobuf the client downloads when
/// it loads a room scene. The 2020 RecRoom client gates the Maker Pen,
/// invention spawning, room-role editing and a dozen other tools behind
/// `RoomRoleCollectionData.RoomRoles[i].CanUseMakerPen.InnerValue` etc.,
/// all of which default to `false` if the bytes we serve are empty.
///
/// We emit a minimal blob with one PlayerRoomRoleData entry that flips
/// every relevant `OverridableBoolData` to `(overrides=true, inner_value=true)`,
/// which the client picks up regardless of which RoomRoleId the local
/// player ends up assigned to. Net result: the room's creator (and
/// everyone else, since this is a private server) gets all build/admin
/// tools the moment they spawn into the scene.
///
/// Wire compatibility: our trimmed .proto only defines the messages and
/// field numbers we set — the 2020 client's full PersistedRoomData has
/// many more fields, but proto3 silently ignores unknown wire entries and
/// defaults missing ones, so the bytes we generate deserialize cleanly.
/// 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 int December2020PersistenceVersion = 26;
/// <summary>
/// Cached bytes of the "all-permissions-on" blob. Identical for every
/// room — there's no per-room state we vary yet, so building once at
/// startup beats rebuilding on every download request.
/// </summary>
private readonly byte[] _allPermsBlob = BuildAllPermsBlob();
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
public byte[] GetDefaultBlob() => _allPermsBlob;
/// <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() => _rroEditableBlob;
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));
EnsureEditablePersistenceVersion(msg);
msg.RoomRoleData = BuildDefaultRoomRoleData();
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:
pos += checked((int)ReadVarint(input, ref pos));
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.");
}
public byte[] OverlayAllPermsRoleData(byte[] existingBlob)
{
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
@@ -66,14 +142,39 @@ public class RoomDataBlobService
return msg.ToByteArray();
}
private static byte[] BuildMinimalDefaultBlob()
{
var msg = new PersistedRoomData
{
DEPRECATEDVersion = December2020PersistenceVersion,
RoomRoleData = BuildDefaultRoomRoleData(),
};
return msg.ToByteArray();
}
private static RoomRoleCollectionData BuildAllPermsRoleData()
{
var collection = new RoomRoleCollectionData();
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
};
collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator"));
return collection;
}
private static RoomRoleCollectionData BuildDefaultRoomRoleData()
{
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
};
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
return collection;
}
private static byte[] BuildRroEditableBlob()
{
var msg = new PersistedRoomData
@@ -87,7 +188,10 @@ public class RoomDataBlobService
private static RoomRoleCollectionData BuildRroEditableRoleData()
{
var collection = new RoomRoleCollectionData();
var collection = new RoomRoleCollectionData
{
RecNetMigrationVersion = 1,
};
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
@@ -115,7 +219,7 @@ public class RoomDataBlobService
DEPRECATEDIsRoleActive = true,
DEPRECATEDIsAgRole = true,
RoleName = name,
RoleVersion = 1,
RoleVersion = 20,
RoleGuid = StableRoleGuid(roleId),
// Display name override — keeps the watch's role-list UI from
@@ -133,6 +237,7 @@ public class RoomDataBlobService
// perms is false.
CanAssignRoles = OverrideTrue(),
CanInvite = OverrideTrue(),
DEPRECATEDCanEditCircuits = OverrideTrue(),
CanStartGames = OverrideTrue(),
CanTalk = OverrideTrue(),
CanPrintPhotos = OverrideTrue(),
@@ -145,6 +250,10 @@ public class RoomDataBlobService
CanSaveInventions = OverrideTrue(),
DisableMicAutoMute = OverrideTrue(),
CanUseShareCam = OverrideTrue(),
UnknownPermission20 = OverrideTrue(),
UnknownPermission21 = OverrideTrue(),
UnknownPermission22 = OverrideTrue(),
UnknownPermission23 = OverrideTrue(),
// Vote-kick permission — int field, set to a permissive value.
// 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via
@@ -168,7 +277,15 @@ public class RoomDataBlobService
}
private static string StableRoleGuid(int roleId) =>
GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString();
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(),
_ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(),
};
}
internal static class GuidUtility