diff --git a/DorkNet.Server.Tests/ClientDtoParityTests.cs b/DorkNet.Server.Tests/ClientDtoParityTests.cs
new file mode 100644
index 0000000..71f11bb
--- /dev/null
+++ b/DorkNet.Server.Tests/ClientDtoParityTests.cs
@@ -0,0 +1,149 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
+/// generated readers register — no missing ones, no invented ones.
+///
+/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
+/// does not produce a bad request: the server answers HTTP 200, the reader
+/// throws while deserialising, and it surfaces later as a bare "Failed to copy
+/// room" with no URL and no payload logged on either side. By the time it is
+/// visible there is nothing left to diagnose it with.
+///
+/// The expected sets are extracted straight from the binary by
+/// tools/extract-client-dto-keys.py into
+/// data/client-dto-keys-2023.json, keyed by the obfuscated reader type.
+/// Cpp2IL emits each reader's property names as string literals in registration
+/// order, so that file is the client's own contract rather than our reading of
+/// it — regenerate it rather than editing it by hand.
+///
+public sealed class ClientDtoParityTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
+
+ /// GLEGPFFPDBE — the room-details reader. This is what
+ /// GET rooms/{id} and POST rooms/{id}/clone both deserialise
+ /// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).
+ private const string RoomDetailsReader = "GLEGPFFPDBE";
+
+ /// EKEFFNBIOHJ — the chat-thread reader, shared by
+ /// POST thread/withmembers and GET thread/{id}.
+ private const string ChatThreadReader = "EKEFFNBIOHJ";
+
+ [Fact]
+ public async Task Room_details_carries_exactly_the_keys_the_client_reads()
+ {
+ using var client = RoomsClient();
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+
+ var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Parity{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = session.PlayerId,
+ CloningAllowed = true,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
+ await db.SaveChangesAsync();
+ }
+
+ using var response = await client.GetAsync($"/rooms/{roomId}");
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
+
+ AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
+ }
+
+ [Fact]
+ public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
+ {
+ using var client = ChatClient();
+ using var otherClient = ChatClient();
+ var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", starter.AccessToken);
+
+ using var form = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("ids", other.PlayerId.ToString()),
+ new KeyValuePair("messageCount", "50"),
+ ]);
+ using var response = await client.PostAsync("/thread/withmembers", form);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode,
+ $"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
+
+ AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
+ }
+
+ /// Compare one response object's keys against the reader's set.
+ /// Matching is case-insensitive because the readers register three spellings
+ /// per field (Pascal, camel, lower) and accept any of them — casing is the
+ /// one thing they are NOT strict about.
+ private static void AssertKeyParity(string readerType, string body, string what)
+ {
+ var expected = LoadReaderKeys(readerType);
+ var actual = JsonDocument.Parse(body).RootElement
+ .EnumerateObject().Select(p => p.Name)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var missing = expected.Where(k => !actual.Contains(k)).ToList();
+ var extra = actual
+ .Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
+ .Order(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ Assert.True(missing.Count == 0 && extra.Count == 0,
+ $"""
+ {what} does not match what the client's {readerType} reader expects.
+
+ MISSING ({missing.Count}) — the reader registers these and we never send them;
+ a non-nullable field left null is what throws mid-deserialise:
+ {string.Join(", ", missing)}
+
+ UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
+ {string.Join(", ", extra)}
+
+ Expected, in the reader's own registration order:
+ {string.Join(", ", expected)}
+ """);
+ }
+
+ private static List LoadReaderKeys(string readerType)
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
+ Assert.True(File.Exists(path),
+ $"missing {path} — regenerate with tools/extract-client-dto-keys.py");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
+ Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
+ $"{readerType} is not in the extracted catalogue");
+ return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
+ }
+
+ private HttpClient RoomsClient() => ClientFor("rooms");
+
+ private HttpClient ChatClient() => ClientFor("chat");
+
+ private HttpClient ClientFor(string host)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/data/client-dto-keys-2023.json b/DorkNet.Server.Tests/data/client-dto-keys-2023.json
new file mode 100644
index 0000000..461d120
--- /dev/null
+++ b/DorkNet.Server.Tests/data/client-dto-keys-2023.json
@@ -0,0 +1,1749 @@
+{
+ "AALDCAANEMM": [
+ "CurrencyPurchaseOfferId",
+ "CurrencyId",
+ "Order",
+ "Name",
+ "CurrencyAmount",
+ "Price",
+ "ModifiedAt"
+ ],
+ "ACKNGEEHHAE": [
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "Tags",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions"
+ ],
+ "ADDGMGGOLHM": [
+ "BlobName",
+ "Title",
+ "Description",
+ "ThumbnailBlobName",
+ "SourceUrl"
+ ],
+ "AEGCKMKICFG": [
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "AHJKILCAGML": [
+ "ChallengeId",
+ "Name",
+ "Config",
+ "Description",
+ "Tooltip",
+ "Complete"
+ ],
+ "AIEJFLGKIGN": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "AJEDJLBJLFA": [
+ "AccountId",
+ "ClubId",
+ "MembershipType",
+ "CreatedAt"
+ ],
+ "AKDFPANIAFL": [
+ "AnnouncementId",
+ "AnnouncementType",
+ "Title",
+ "Body",
+ "ImageName",
+ "LinkType",
+ "LinkName",
+ "LinkButtonLabel",
+ "LinkUri",
+ "Platform",
+ "CreatedAt"
+ ],
+ "ALGKCAKCFAJ": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "AMPFGJPKHOD": [
+ "Device_code",
+ "User_code",
+ "Verification_uri",
+ "Verification_uri_complete",
+ "Expires_in",
+ "Interval"
+ ],
+ "ANHGLIFGACE": [
+ "RoomKeyId",
+ "ReplicationId",
+ "RoomId",
+ "Name",
+ "Description",
+ "Price",
+ "PurchaseCurrencyId",
+ "CreatedAt",
+ "ImageName"
+ ],
+ "ANNOJCNMHIG": [
+ "CustomAvatarItemId",
+ "CreatorAccountId",
+ "Name",
+ "Description",
+ "Price",
+ "Accessibility",
+ "IsFeatured",
+ "BaseAvatarItemId",
+ "BaseAvatarItemColor",
+ "DesignFilename",
+ "ThumbnailImageFilename",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "AOAACMFGAGK": [
+ "ChallengeMapId",
+ "ChallengeThemeString",
+ "Challenges",
+ "Gift",
+ "FallbackGiftName",
+ "StartAt",
+ "EndAt",
+ "ServerTime",
+ "CompletedRequired"
+ ],
+ "APOJHADGCNA": [
+ "Ids",
+ "CreatedAts",
+ "ConsumableItemDesc",
+ "Count",
+ "InitialCount",
+ "IsActive",
+ "ActiveDurationMinutes",
+ "IsTransferable"
+ ],
+ "BAEBDMPPKKE": [
+ "Take",
+ "ContinuationToken",
+ "InjectedFeedItems",
+ "FeedInstanceId"
+ ],
+ "BBACJMOPFPE": [
+ "ProgressionEventRewardId",
+ "GiftDropId",
+ "ImageName",
+ "Xp",
+ "RewardIndex",
+ "IsBonus"
+ ],
+ "BBEFAELEANL": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "BGFBJCIOIFP": [
+ "SkuId",
+ "Name",
+ "Description",
+ "ImageName",
+ "Price",
+ "OculusSkuId",
+ "AppleProductId",
+ "PsnProductLabel",
+ "XboxProductId",
+ "XboxStoreId",
+ "GooglePlaySkuId",
+ "PicoSkuId",
+ "IsSingleUse",
+ "ShouldAppearInTokenStore",
+ "Data"
+ ],
+ "BKFOGBGPLFP": [
+ "SavedImageId",
+ "ImageName",
+ "Username",
+ "PlayerId",
+ "RoomName",
+ "RoomId"
+ ],
+ "BLADEIPEPOH": [
+ "SavedImageId",
+ "ImageName",
+ "PlayerId",
+ "RoomId",
+ "PlayerEventId",
+ "ClubId",
+ "Description",
+ "Accessibility",
+ "AccessibilityLocked",
+ "SavedImageType",
+ "CreatedAt",
+ "CheerCount",
+ "CommentCount"
+ ],
+ "BLIBJIHOENF": [
+ "ClubId",
+ "Name",
+ "Description",
+ "MainImageName",
+ "State",
+ "CreatorAccountId",
+ "Category",
+ "Visibility",
+ "Joinability",
+ "AllowJuniors",
+ "MinLevel",
+ "MemberCount",
+ "IsRRO",
+ "ClubChatEnabled",
+ "ClubhouseRoomId",
+ "ClubType"
+ ],
+ "BPHCOIBNCDP": [
+ "ClubId",
+ "Type",
+ "EditDetails",
+ "ApproveMember",
+ "CreateEvent",
+ "PostAnnouncement",
+ "EditPermissionSettings",
+ "BanUnban"
+ ],
+ "CAKCKAMAAPP": [
+ "ClubId",
+ "LastAnnouncementId",
+ "LastReadAnnouncementId",
+ "Announcements"
+ ],
+ "CBKDKACJDOF": [
+ "ChatMessageId",
+ "ChatThreadId",
+ "SenderPlayerId",
+ "TimeSent",
+ "Contents",
+ "ModerationState"
+ ],
+ "CDEDNEPPPND": [
+ "LevelProgressionMaps",
+ "DailyObjectives",
+ "ServerMaintenance",
+ "AutoMicMutingConfig",
+ "StorefrontConfig",
+ "RoomKeyConfig",
+ "RoomCurrencyConfig",
+ "ShareBaseUrl"
+ ],
+ "CEAMINNJFGO": [
+ "Left",
+ "Right",
+ "Spine",
+ "Pelvis",
+ "Root",
+ "Torso",
+ "Body",
+ "Hips",
+ "Neck",
+ "Chest",
+ "Head",
+ "Arm",
+ "Hand",
+ "Wrist",
+ "Elbow",
+ "Palm",
+ "Leg",
+ "Thigh",
+ "Calf",
+ "Femur",
+ "Knee",
+ "Foot",
+ "Ankle",
+ "Hip",
+ "Tail",
+ "Eye",
+ "Dummy",
+ "Collar",
+ "Clavicle",
+ "Finger",
+ "Index",
+ "Mid",
+ "Pinky",
+ "Thumb",
+ "Adjust",
+ "Twist",
+ "Toe",
+ "Lid",
+ "Brow",
+ "Lash"
+ ],
+ "CENEMCMGDKG": [
+ "Tags",
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "CGCKIMIEHNM": [
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "CHKOLCGMCKJ": [
+ "ReportBudget",
+ "FilterType",
+ "SampleRate",
+ "LogLineCount",
+ "CaptureNativeCrashes",
+ "ANRThresholdMs",
+ "MessageCount",
+ "MessageRegex",
+ "VersionRegex"
+ ],
+ "CICAKEDCJLA": [
+ "Email",
+ "Phone",
+ "Birthday",
+ "JuniorState",
+ "ParentAccountId",
+ "AvailableUsernameChanges",
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "CICEHLNDLDE": [
+ "RoomConsumableId",
+ "AccountId",
+ "Count",
+ "ConcurrencyCode",
+ "ModifiedAt",
+ "Consumable"
+ ],
+ "CIKAJCJMOFJ": [
+ "MembershipRequestId",
+ "AccountId",
+ "InviterAccountId",
+ "ClubId",
+ "MembershipType",
+ "MembershipStatus",
+ "CreatedAt"
+ ],
+ "CJPKAAJBLAJ": [
+ "Id",
+ "Name",
+ "Description",
+ "StartDate",
+ "EndDate",
+ "WasManuallyClosed",
+ "TestCases",
+ "Tags",
+ "NumTestCases",
+ "NumPassedTestCases",
+ "NumFailedTestCases"
+ ],
+ "CLHBPPNOLHP": [
+ "Version",
+ "ButtonNumber",
+ "Override",
+ "CustomRoomName",
+ "CustomTitle",
+ "CustomDescription",
+ "DefaultRoomName",
+ "DefaultTitle",
+ "DefaultDescription"
+ ],
+ "CLIANCNHNIE": [
+ "AvatarItemType",
+ "AvatarItemDesc",
+ "FriendlyName",
+ "Tooltip",
+ "Rarity",
+ "TagList",
+ "AvatarItemId",
+ "IsBaseAvatarItem"
+ ],
+ "CMLNDGCIPIH": [
+ "Index",
+ "Group",
+ "Progress",
+ "VisualProgress",
+ "IsCompleted",
+ "HasClaimedReward"
+ ],
+ "DDDLCGDEPNH": [
+ "PlayerEventResponseId",
+ "PlayerEventId",
+ "PlayerId",
+ "CreatedAt",
+ "Type"
+ ],
+ "DICIOFKDIEE": [
+ "Season",
+ "Name",
+ "StartAt",
+ "EndAt",
+ "CurrencyType",
+ "EliteUpgrade",
+ "Tiers",
+ "PersonalDetails",
+ "StorefrontType",
+ "NextUpdate",
+ "NewUntil"
+ ],
+ "DPHFOPBAAPN": [
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "DPNLANKOHON": [
+ "AccountId",
+ "CurrencyId",
+ "Balance",
+ "ModifiedAt"
+ ],
+ "DatatypeImplementation": [
+ "ENTITIES",
+ "ENTITY",
+ "ID",
+ "IDREF",
+ "IDREFS",
+ "NMTOKEN",
+ "NMTOKENS",
+ "NOTATION"
+ ],
+ "ECHJAKNMEEC": [
+ "KeepsakeRoomListId",
+ "ProgressionEventId",
+ "UnlockItemAvatarItemId",
+ "UnlockItemGiftDropId",
+ "UnlockItemLockDurationTicks",
+ "RoomUnlockStartOffsetTicks",
+ "RoomUnlockIntervalTicks",
+ "RoomUnlockBatchSize",
+ "RoomType",
+ "KeepsakeRooms"
+ ],
+ "ECIINMENOGP": [
+ "BalanceAddType",
+ "Delta",
+ "Balance",
+ "Platform",
+ "CurrencyType"
+ ],
+ "EHKEOCKBCDH": [
+ "Items",
+ "FeedInstanceId",
+ "FeedContext",
+ "FeedAlgorithmVersion"
+ ],
+ "EIFIAMHKFAG": [
+ "PurchaseReminderId",
+ "SkuId",
+ "TokenPrice",
+ "Title",
+ "Description",
+ "ImageName",
+ "EndDate",
+ "BonusGiftDrops"
+ ],
+ "EJEJHJPCBOE": [
+ "KeepsakeRoomId",
+ "RoomId",
+ "KeepsakeRoomListId",
+ "Type",
+ "Order"
+ ],
+ "EKEFFNBIOHJ": [
+ "ChatThreadId",
+ "LastReadMessageId",
+ "Messages",
+ "LatestMessage",
+ "PlayerIds",
+ "ChatThreadName",
+ "SnoozedUntil",
+ "IsFavorited",
+ "ClubId"
+ ],
+ "EKHJKDNHOPB": [
+ "OutfitSelections",
+ "OutfitSelectionsV2",
+ "FaceFeatures",
+ "SkinColor",
+ "HairColor",
+ "CustomAvatarItems"
+ ],
+ "EKMLNNNABAD": [
+ "UnityAssetId",
+ "RoomData",
+ "SubRoomData",
+ "InventionUsage",
+ "PersistenceVersion",
+ "Description",
+ "AutoPublish"
+ ],
+ "EMBCEDNHFLB": [
+ "GiftDropId",
+ "FriendlyName",
+ "Tooltip",
+ "TagList",
+ "ConsumableItemDesc",
+ "AvatarItemDesc",
+ "AvatarItemType",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "IsQuery",
+ "Unique",
+ "SubscribersOnly",
+ "Rarity",
+ "AvatarItemId",
+ "CurrencyType",
+ "Currency",
+ "Context",
+ "ItemSetId",
+ "ItemSetFriendlyName"
+ ],
+ "EMJAJEHCIFN": [
+ "MarketingEmails",
+ "InviteEmails",
+ "GameClientTextInvites",
+ "FriendRequestEmails",
+ "CreatorPublishedNewRoomEmails",
+ "MyRoomSummaryEmails",
+ "NewPhotoTagEmails",
+ "PlayerEventInviteEmails",
+ "PlayerEventReminderEmails"
+ ],
+ "EncodingTable": [
+ "Big5",
+ "GB2312",
+ "IBM850",
+ "IBM852",
+ "IBM857",
+ "IBM861",
+ "IBM869"
+ ],
+ "FCIBLPCOODP": [
+ "RoomConsumableId",
+ "RoomId",
+ "Name",
+ "Description",
+ "ImageName",
+ "Price",
+ "PurchaseCurrencyId",
+ "ModifiedAt"
+ ],
+ "FKANCKLCCDI": [
+ "Id",
+ "FromPlayerId",
+ "ConsumableItemDesc",
+ "AvatarItemType",
+ "AvatarItemDesc",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "CurrencyType",
+ "Currency",
+ "Xp",
+ "GiftContext",
+ "GiftRarity",
+ "Message",
+ "Platform",
+ "PlatformsToSpawnOn",
+ "BalanceType"
+ ],
+ "FNIDAIDJBBO": [
+ "PlayerID",
+ "RelationshipType",
+ "Muted",
+ "Ignored",
+ "Favorited"
+ ],
+ "FOIMFMEEGDI": [
+ "SupportsJoinInProgress",
+ "UseLevelBasedMatchmaking",
+ "UseAgeBasedMatchmaking",
+ "UseRecRoyaleMatchmaking",
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "FPLGJGDJIJG": [
+ "StorefrontType",
+ "PurchasableItemId",
+ "CurrencyType",
+ "RequestedPrice",
+ "CouponConsumablePlayerMappingId",
+ "Gift"
+ ],
+ "FPNOOOFJBJF": [
+ "RankStart",
+ "RankEnd",
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "FastDualShock4GamepadHID": [
+ "Dpad",
+ "Square",
+ "Cross",
+ "Circle",
+ "Triangle"
+ ],
+ "FastKeyboard": [
+ "AnyKey",
+ "Escape",
+ "Space",
+ "Enter",
+ "Tab",
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "Shift",
+ "Alt",
+ "Backspace",
+ "Home",
+ "End",
+ "Insert",
+ "Delete",
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ "F6",
+ "F7",
+ "F8",
+ "F9",
+ "F10",
+ "F11",
+ "F12"
+ ],
+ "FastMouse": [
+ "Pressure",
+ "Radius",
+ "Position",
+ "Delta",
+ "Scroll",
+ "Press"
+ ],
+ "FastTouchscreen": [
+ "Pressure",
+ "Radius",
+ "Position",
+ "Delta",
+ "Press"
+ ],
+ "GAGIJHJGFJD": [
+ "AccountId",
+ "CurrencyId",
+ "Balance",
+ "AmountAwarded",
+ "AwardedAt"
+ ],
+ "GCKGIDLDBNH": [
+ "Error",
+ "Error_description",
+ "Access_token",
+ "Refresh_token",
+ "Key"
+ ],
+ "GDGCOAMMCIH": [
+ "MicSpamVolumeThreshold",
+ "MicVolumeSampleInterval",
+ "MicVolumeSampleRollingWindowLength",
+ "MicSpamSamplePercentageForWarning",
+ "MicSpamSamplePercentageForWarningToEnd",
+ "MicSpamSamplePercentageForForceMute",
+ "MicSpamSamplePercentageForForceMuteToEnd",
+ "MicSpamWarningStateVolumeMultiplier"
+ ],
+ "GFGKDCENJMB": [
+ "AccountId",
+ "Xp",
+ "GameMinutesToday",
+ "RewardsCollected",
+ "BonusRewardsCollected",
+ "XpBoostLastPurchasedAt"
+ ],
+ "GFIHPOOFHLC": [
+ "AnnouncementId",
+ "CreatorAccountId",
+ "ClubId",
+ "Title",
+ "Body",
+ "ImageName",
+ "CreatedAt",
+ "Meta"
+ ],
+ "GFKKOADFJKC": [
+ "CurrencyId",
+ "RecipientId",
+ "Amount",
+ "TransactionId"
+ ],
+ "GFMOBMCIOJI": [
+ "AccountId",
+ "IsCheerful",
+ "SelectedCheer",
+ "CheerCredit",
+ "CheerGeneral",
+ "CheerHelpful",
+ "CheerCreative",
+ "CheerGreatHost",
+ "CheerSportsman"
+ ],
+ "GIFNHCGDNEM": [
+ "Platform",
+ "PlatformId",
+ "AccountId",
+ "LastLoginTime",
+ "RequirePassword",
+ "RefreshToken"
+ ],
+ "GLEGPFFPDBE": [
+ "DataBlob",
+ "DataBlobHash",
+ "SubRooms",
+ "Roles",
+ "Tags",
+ "LoadScreens",
+ "PromoImages",
+ "PromoExternalContent",
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "GLENKGBDMAO": [
+ "Result",
+ "Code",
+ "Url",
+ "RedemptionUrl"
+ ],
+ "GMFJIAMOIEO": [
+ "SubscriptionId",
+ "RecNetPlayerId",
+ "PlatformType",
+ "PlatformId",
+ "PlatformPurchaseId",
+ "Level",
+ "Period",
+ "ExpirationDate",
+ "IsAutoRenewing",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "GNIPAKCCOOE": [
+ "ProgressionEventId",
+ "Name",
+ "Rewards",
+ "KeepsakeRoomLists",
+ "StartTime",
+ "EndTime",
+ "CollectionEndTime",
+ "UsesBoost",
+ "BoostDailyGameplayMinutesLimit",
+ "BoostXpMultiplier",
+ "PurchasableXpBoostId",
+ "ActiveExperiment",
+ "ChallengesIconImageName",
+ "RewardsPipImageName",
+ "EventInfoImageName"
+ ],
+ "GNMDEJJAHCJ": [
+ "SubscriberDiscountPercent",
+ "StorefrontType",
+ "NextUpdate",
+ "NewUntil",
+ "StoreItems"
+ ],
+ "GOHGJADGDJF": [
+ "ReplicationId",
+ "DataBlob",
+ "SubRooms",
+ "Tags",
+ "UnityAssets",
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "HDOJMEBCAGP": [
+ "Permission",
+ "Role",
+ "Override",
+ "Type",
+ "Value"
+ ],
+ "HHJLCKPJOIA": [
+ "PlayerId",
+ "StatusVisibility",
+ "DeviceClass",
+ "VrMovementMode",
+ "RoomInstance",
+ "IsOnline",
+ "AppVersion",
+ "ClientJoinData"
+ ],
+ "HIKOJENMDKE": [
+ "KeepsakeCategoryId",
+ "VisualId",
+ "LimitPerRoom",
+ "XpValue",
+ "IconOutlineImageName",
+ "IconFilledImageName"
+ ],
+ "HJOIJBMAIPO": [
+ "PlaylistId",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "HJPEABLEEKL": [
+ "RoomConsumableId",
+ "RoomId",
+ "Name",
+ "Description",
+ "ImageName",
+ "PriceAndCurrency"
+ ],
+ "HKIJINJGBGD": [
+ "Type",
+ "User_id",
+ "Identifiers",
+ "Ip",
+ "Gameplay_session_id",
+ "Transaction_id"
+ ],
+ "HPMMKFGDAEC": [
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "IAPMDDFENDD": [
+ "PurchasableItemId",
+ "CouponConsumablePlayerMappingId",
+ "Count",
+ "CreatorPlayerId",
+ "RequestedPrice"
+ ],
+ "IBAPHBCOBHL": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "IBILPLGNAJE": [
+ "Id",
+ "ImageName",
+ "PlayerId",
+ "RoomId",
+ "PlayerEventId",
+ "Accessibility",
+ "AccessibilityLocked",
+ "Type",
+ "CreatedAt",
+ "TaggedPlayerIds",
+ "CheerCount",
+ "CommentCount"
+ ],
+ "IGMCJEFBJCF": [
+ "ServerTime",
+ "PlayerId",
+ "StatusVisibility",
+ "DeviceClass",
+ "VrMovementMode",
+ "RoomInstance",
+ "IsOnline",
+ "AppVersion",
+ "ClientJoinData"
+ ],
+ "IJFKOMJBJMI": [
+ "StatChannel",
+ "RoomId",
+ "StatValue",
+ "CurrentStatValue"
+ ],
+ "ILJOBJDPBNJ": [
+ "Name",
+ "Description",
+ "Price",
+ "BaseAvatarItemId",
+ "BaseAvatarItemColor",
+ "Accessibility"
+ ],
+ "IMMOJHBENFI": [
+ "Slot",
+ "PreviewImageName",
+ "OutfitSelections",
+ "OutfitSelectionsV2",
+ "FaceFeatures",
+ "SkinColor",
+ "HairColor",
+ "CustomAvatarItems"
+ ],
+ "INLPIKBODME": [
+ "CreatorAccountId",
+ "BaseAvatarItemId",
+ "DesignFilename",
+ "BaseAvatarItemColor"
+ ],
+ "IOEPPCKGBFL": [
+ "InventionId",
+ "ReplicationId",
+ "CreatorPlayerId",
+ "Name",
+ "Description",
+ "ImageName",
+ "CurrentVersionNumber",
+ "Accessibility",
+ "ModifiedAt",
+ "CreatedAt",
+ "FirstPublishedAt",
+ "CreationRoomId",
+ "NumPlayersHaveUsedInRoom",
+ "NumDownloads",
+ "CheerCount",
+ "CreatorPermission",
+ "GeneralPermission",
+ "IsAGInvention",
+ "IsCertifiedInvention",
+ "AllowTrial",
+ "Price",
+ "HideFromPlayer"
+ ],
+ "IPMGNJMIONC": [
+ "Revenue_currency",
+ "Revenue_amount",
+ "Revenue_type",
+ "Type",
+ "User_id",
+ "Identifiers",
+ "Ip",
+ "Gameplay_session_id",
+ "Transaction_id"
+ ],
+ "JAFDEPHGOAF": [
+ "Title",
+ "Message",
+ "ImageName",
+ "ActiveExperiments",
+ "Context",
+ "InputType",
+ "Visibility",
+ "AllowCycling",
+ "PlatformMask",
+ "RestrictToNewUsers",
+ "RoomNames"
+ ],
+ "JAHJGFHFKIB": [
+ "Club",
+ "CustomTags",
+ "AdditionalImages",
+ "CoownerPermissions",
+ "ModeratorPermissions",
+ "MemberPermissions",
+ "MyMembershipType"
+ ],
+ "JCPFCOEEELK": [
+ "AccountId",
+ "CurrencyId",
+ "Success",
+ "Error",
+ "Response"
+ ],
+ "JDEKAFDPGBE": [
+ "ReplicationId",
+ "PersistenceVersion",
+ "SupportsJoinInProgress",
+ "UseLevelBasedMatchmaking",
+ "UseAgeBasedMatchmaking",
+ "UseRecRoyaleMatchmaking",
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "JGJFCFAFGCM": [
+ "TotalXP",
+ "Level",
+ "RankIdx",
+ "RankName",
+ "CurrentLevelXPThreshold",
+ "NextLevelXPThreshold",
+ "NextLevelAcornReward"
+ ],
+ "JICIOMDBPDO": [
+ "Id",
+ "ConsumableItemDesc",
+ "Count",
+ "InitialCount",
+ "CreatedAt",
+ "ActiveDurationMinutes",
+ "IsActive",
+ "IsTransferable"
+ ],
+ "JJEKBGHDCEG": [
+ "Tier",
+ "Rewards",
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "JJGEJHCFEJH": [
+ "GiftDropId",
+ "ConsumableItemDesc",
+ "AvatarItemDesc",
+ "AvatarItemType",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "StorefrontType",
+ "Xp",
+ "Level",
+ "GiftContext",
+ "GiftRarity"
+ ],
+ "JLIBBFMMLFF": [
+ "CurrencyType",
+ "BalanceAddType",
+ "BaseAward",
+ "BonusAwardMin",
+ "BonusAwardMax",
+ "RateLimitType",
+ "IgnorePartialMultiplier",
+ "MaxPartialMultiplier",
+ "RateLimit",
+ "BalanceInGiftBox"
+ ],
+ "JNFDPPCIPHG": [
+ "CategoryId",
+ "Importance",
+ "Name",
+ "Description",
+ "IsMuteable"
+ ],
+ "JOMMALIPDGI": [
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "JPNMBICAMEE": [
+ "RoomInstanceId",
+ "RoomId",
+ "SubRoomId",
+ "IsFull",
+ "CreatedAt",
+ "PlayerIds"
+ ],
+ "KCMDEFMLDJF": [
+ "Balance",
+ "CurrencyType",
+ "BalanceType",
+ "Data"
+ ],
+ "KFPHOKPEMOF": [
+ "ItemType",
+ "ItemId",
+ "Name",
+ "Description",
+ "ImageName",
+ "RoomId",
+ "Price",
+ "PurchaseCurrencyId",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "KIANAKFJGCP": [
+ "FeaturedPlayer",
+ "FeaturedRoomGroup",
+ "CurrentAnnouncement",
+ "InstagramImages",
+ "Videos"
+ ],
+ "KILGEFOENFN": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "KKOHONLGIHL": [
+ "Id",
+ "FromPlayerId",
+ "SentTime",
+ "Type",
+ "Data",
+ "RoomId",
+ "PlayerEventId"
+ ],
+ "KMHNOGNHDLO": [
+ "SubscriptionSeasonMilestoneId",
+ "SeasonId",
+ "StartAt",
+ "GiftDrops"
+ ],
+ "KPMMGJELBPP": [
+ "WindowSize",
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "KPNBKKKCOCA": [
+ "UnlockedAt",
+ "ClaimedAt",
+ "Status",
+ "ReferralRewardTier",
+ "IsCurrentTier"
+ ],
+ "KPPINBHEKHE": [
+ "Products",
+ "Identifier",
+ "Genuine",
+ "GenuineCheckAvailable"
+ ],
+ "LAIJIAIHGCO": [
+ "ReportCategory",
+ "Duration",
+ "GameSessionId",
+ "IsHostKick",
+ "Message",
+ "PlayerIdReporter",
+ "IsBan",
+ "IsVoiceModAutoban",
+ "IsWarning",
+ "VoteKickReason",
+ "TimeoutStartedAt"
+ ],
+ "LBOGFNBPIKJ": [
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "LIJHBNDJKNP": [
+ "RewardSelectionId",
+ "RewardType",
+ "Message",
+ "GiftContext",
+ "GiftDrop1",
+ "GiftDrop2",
+ "GiftDrop3",
+ "Subscriber_GiftDrop3",
+ "CreatedAt"
+ ],
+ "LKJPKMBEPCG": [
+ "PrefabName",
+ "ModificationGuid",
+ "FriendlyName",
+ "Tooltip",
+ "Rarity",
+ "Favorited"
+ ],
+ "LKLKHEEHEBC": [
+ "UnityAssetId",
+ "Target",
+ "Version",
+ "Filename",
+ "Hash"
+ ],
+ "LMEECKCCPHG": [
+ "AccountId",
+ "Role",
+ "LastChangedByAccountId",
+ "InvitedRole"
+ ],
+ "LNMAMOCCBAK": [
+ "Filename",
+ "UnityAssetId",
+ "CreatedByAccountId",
+ "BakedUnityAssets"
+ ],
+ "LOOLOADLJPG": [
+ "SubscriptionSeasonId",
+ "Name",
+ "ImageName",
+ "StartAt",
+ "NextSeasonStart",
+ "Milestones"
+ ],
+ "LPAEOGFPGBD": [
+ "UnitySceneId",
+ "Name",
+ "MaxPlayers",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile"
+ ],
+ "MADLMAFINGF": [
+ "KeepsakeInstanceId",
+ "KeepsakeCategoryConfigId",
+ "PlacedByAccountId",
+ "RoomId",
+ "SubRoomId"
+ ],
+ "MCDADMNCDEI": [
+ "WishlistItemId",
+ "AccountId",
+ "PurchasableItemId",
+ "CreatedAt"
+ ],
+ "MEBAFOGDHBB": [
+ "PurchaseCampaignId",
+ "PurchaseReminder",
+ "Name",
+ "TriggerFlags",
+ "LastShown",
+ "ShowCount",
+ "DaysBetweenPopups",
+ "MaxPopups"
+ ],
+ "MEEAEDNAPGI": [
+ "CommentId",
+ "RoomId",
+ "SubRoomId",
+ "AccountId",
+ "CreatedAt",
+ "Message",
+ "Style",
+ "Unread",
+ "PositionX",
+ "PositionY",
+ "PositionZ"
+ ],
+ "MGOLBEMMNDA": [
+ "BalanceAddType",
+ "BaseAward",
+ "BonusAward",
+ "RateLimit",
+ "CurrentCount",
+ "Total",
+ "Platform",
+ "BalanceInGiftBox"
+ ],
+ "MJJDCIAIDKI": [
+ "HasEliteUpgrade",
+ "HasEliteUpgradePlatformMask",
+ "CurrentSeasonTier",
+ "ModifiedAt"
+ ],
+ "MJKDDBKGNOP": [
+ "ContactDetail",
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "MLHBPDKBABN": [
+ "CheerCount",
+ "FavoriteCount",
+ "VisitorCount",
+ "VisitCount"
+ ],
+ "NDIIFIGNAAM": [
+ "Error",
+ "Identifier",
+ "Genuine",
+ "GenuineCheckAvailable"
+ ],
+ "NHGOFGABJIN": [
+ "RoomInstanceId",
+ "RoomId",
+ "SubRoomId",
+ "Location",
+ "EventId",
+ "ClubId",
+ "RoomCode",
+ "PhotonRegionId",
+ "PhotonRoomId",
+ "Name",
+ "MaxCapacity",
+ "IsFull",
+ "IsPrivate",
+ "IsInProgress",
+ "EncryptVoiceChat",
+ "RoomInstanceType"
+ ],
+ "NIJPNDPJICF": [
+ "Id",
+ "Key",
+ "Title",
+ "Description",
+ "RoomName",
+ "Status",
+ "AssignedPlayerNames",
+ "Tags",
+ "JiraUrl",
+ "Comments"
+ ],
+ "NMGHOIMEJIK": [
+ "Name",
+ "Description",
+ "Price",
+ "Accessibility"
+ ],
+ "NNAGOPNGJFE": [
+ "AmplitudeKey",
+ "RudderStackKey",
+ "UseRudderStack",
+ "StatSigKey"
+ ],
+ "OANOOFEPJCI": [
+ "Rooms",
+ "Tags",
+ "PlaylistId",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "OBGIGMCOMDE": [
+ "AdCarouselItemId",
+ "ImageName",
+ "Title",
+ "Description",
+ "PurchasableItemIds",
+ "PurchaseReminderId"
+ ],
+ "OFGIPBHIAGF": [
+ "ProgressionEventPurchasableXpBoostId",
+ "Cost",
+ "XpMultiplier",
+ "XpCap",
+ "LookbackDurationTicks",
+ "CooldownDurationTicks"
+ ],
+ "OFMNNCMPEPA": [
+ "SessionId",
+ "RoomId",
+ "SubRoomId",
+ "UnityAssetId",
+ "RoomData",
+ "SubRoomData",
+ "SavedByAccountId"
+ ],
+ "OLACHOEDKFB": [
+ "ReportCategory",
+ "YesVotes",
+ "NoVotes",
+ "PlayerIdReported"
+ ],
+ "OLIHBGPDPHF": [
+ "Balance",
+ "CurrencyType",
+ "BalanceType",
+ "Data"
+ ],
+ "OLPHKLCPFEF": [
+ "InventionId",
+ "ReplicationId",
+ "VersionNumber",
+ "InstantiationCost",
+ "LightsCost",
+ "ChipsCost",
+ "CloudVariablesCost",
+ "BlobName",
+ "BlobHash"
+ ],
+ "ONGJBLFBHGC": [
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "PCBAACNNDBH": [
+ "FriendlyImageName",
+ "ImageName",
+ "StartTime",
+ "EndTime"
+ ],
+ "PCPOMPIAFMA": [
+ "Key",
+ "Value",
+ "StartTime",
+ "EndTime"
+ ],
+ "PDIFGFKAMBG": [
+ "PlayerCount",
+ "IsFull",
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "PELEHJLMKJO": [
+ "SubRoomDataSaveId",
+ "SubRoomId",
+ "UnityAssetId",
+ "DataBlob",
+ "DataBlobHash",
+ "SavedByAccountId",
+ "SavedOnPlatform",
+ "SavedOnDeviceClass",
+ "Description",
+ "CreatedAt"
+ ],
+ "PEOAOMGOMGC": [
+ "CurrencyId",
+ "RoomId",
+ "Name",
+ "Description",
+ "CurrencyType",
+ "Limit",
+ "ImageName",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "PGACEMBEGJF": [
+ "ToPlayerId",
+ "Message",
+ "Anonymous",
+ "GiftContext"
+ ],
+ "PLLNLPADHCN": [
+ "Resolution",
+ "Language",
+ "Timezone",
+ "Platform"
+ ],
+ "POGBAGAHGIA": [
+ "GiftDrop",
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "PPHLKNGCGOE": [
+ "SessionId",
+ "RoomId",
+ "SubRoomId",
+ "UnityAssetId",
+ "RoomDataFilename",
+ "RoomDataHash",
+ "SubRoomDataFilename",
+ "SubRoomDataHash"
+ ],
+ "PlayerInput": [
+ "OnPlayerLeft",
+ "OnDeviceLost",
+ "OnDeviceRegained",
+ "OnControlsChanged"
+ ],
+ "StringHelpers": [
+ "Mouse",
+ "Mice",
+ "Axis",
+ "Axes"
+ ],
+ "X509Name": [
+ "C",
+ "O",
+ "T",
+ "OU",
+ "CN",
+ "L",
+ "ST",
+ "SERIALNUMBER",
+ "E",
+ "DC",
+ "UID",
+ "STREET",
+ "SURNAME",
+ "GIVENNAME",
+ "INITIALS",
+ "GENERATION",
+ "UniqueIdentifier",
+ "DN",
+ "Pseudonym",
+ "PostalAddress",
+ "CountryOfCitizenship",
+ "CountryOfResidence",
+ "Gender",
+ "PlaceOfBirth",
+ "DateOfBirth",
+ "PostalCode",
+ "BusinessCategory",
+ "TelephoneNumber"
+ ]
+}
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index f3e2662..1ddac0c 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
@@ -2109,8 +2109,6 @@ public class RoomsController(
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
- AllowNewUsers = room.AllowNewUsers,
- AllowsNewUsers = room.AllowNewUsers,
MinLevel = room.MinLevel,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
@@ -2122,39 +2120,13 @@ public class RoomsController(
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
- IsStudioRoom = room.IsStudioRoom,
- IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
- StudioSessionId = room.StudioSessionId,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
LoadScreenLocked = false,
- // Studio/UGC version family. The 2023 client reads UgcVersion to
- // decide the asset-bundle version it requests
- // (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion
- // to recognise a baked Studio room at all. Matches the real
- // RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a
- // non-Studio room these stay at the legacy 0 so behaviour is
- // unchanged.
PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
- UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0,
- UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
- MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0,
IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
- IsJuniorCreated = false,
- IsRecRoomApproved = false,
- ExcludeFromLists = false,
- BoostCount = 0,
- RestrictedCircuitsAllowListNames = Array.Empty(),
- PublishStateAvailability = new
- {
- CanSaveAsBeta = false,
- CanSaveAsUpdate = true,
- AvailableUpdateTokenCount = 3,
- NextAvailableUpdateDateTimeUtc = (string?)null,
- },
- PublishState = 0,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms,
Roles = wireRoles,
@@ -2162,6 +2134,23 @@ public class RoomsController(
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
Tags = tags,
+ // The client's room-details reader (GLEGPFFPDBE) registers these
+ // five alongside the rest. A registered slot wants a value, and
+ // leaving one absent is not the same as sending null — it is what
+ // makes the reader throw mid-deserialise on a response the server
+ // already answered 200 to. Kept last so the additions are visible
+ // against the reader's own list; key ORDER does not matter to a
+ // name-keyed reader, presence does.
+ DataBlob = dataBlobName,
+ DataBlobHash = (string?)null,
+ // Room-level cap mirrors sub-room 0, which is where the client
+ // reads it from for a single-sub-room room.
+ MaxPlayers = sceneRows is { Count: > 0 } ? sceneRows[0].MaxPlayers : 8,
+ // ToxMod is Rec Room's hosted voice moderation. There is no such
+ // service here, so it is off — the client only uses this to decide
+ // whether to show the indicator.
+ ToxmodEnabled = false,
+ RankingContext = (object?)null,
};
}
diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs
index eb6159e..7e1f5f3 100644
--- a/DorkNet.Server/Controllers/Chat/ChatController.cs
+++ b/DorkNet.Server/Controllers/Chat/ChatController.cs
@@ -1147,15 +1147,25 @@ public class ChatController(
["clubId"] = ClubIdFromKey(thread.ThreadKey),
};
+ // latestMessage is ALWAYS present, null when the thread has nothing in
+ // it. The client's reader (EKEFFNBIOHJ) registers a slot for it, and a
+ // registered slot wants a value — null is one, an absent key is not.
+ // Omitting it is what broke starting a chat with someone: a thread
+ // created by thread/withmembers has no messages yet, so this key
+ // vanished from the one response where it was needed and the client
+ // rejected the payload it had just asked for.
if (messages is not null)
{
dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList();
- if (messages.Count > 0)
- dto["latestMessage"] = ToWireMessage(messages[^1], thread.Id);
+ dto["latestMessage"] = messages.Count > 0
+ ? ToWireMessage(messages[^1], thread.Id)
+ : null;
}
- else if (latestMessage is not null)
+ else
{
- dto["latestMessage"] = ToWireMessage(latestMessage, thread.Id);
+ dto["latestMessage"] = latestMessage is not null
+ ? ToWireMessage(latestMessage, thread.Id)
+ : null;
}
return dto;
diff --git a/tools/extract-client-dto-keys.py b/tools/extract-client-dto-keys.py
new file mode 100644
index 0000000..5c4215e
--- /dev/null
+++ b/tools/extract-client-dto-keys.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Extract every generated JSON reader's key set from the 2023-03-21 ISIL dump.
+
+Why this exists
+---------------
+When one of our responses carries the wrong keys the client does NOT report a
+bad request. It answers HTTP 200, the client's generated reader throws while
+deserialising, and the failure surfaces much later as a bare "Failed to copy
+room" with no URL attached. Neither side logs the payload, so the only way to
+know what the client actually wants is to read the readers out of the binary.
+
+Cpp2IL emits each generated reader as a `.ctor` that registers its property
+names as string literals, three spellings per field:
+
+ 262 Move rcx, "DataBlob"
+ 273 Move rcx, "dataBlob"
+ 281 Move rcx, "datablob"
+
+The PascalCase spelling is the wire name; the other two are the reader's
+case-insensitive aliases. A run of >= MIN_FIELDS such triples is a DTO, and the
+registration order is the field order.
+
+Output is a JSON catalogue: {typeName: [field, ...]} in registration order, for
+the parity test to assert our responses against.
+
+ python tools/extract-client-dto-keys.py \
+ C:/tmp/recroom-2023-03-21-isil/IsilDump \
+ DorkNet.Server.Tests/data/client-dto-keys-2023.json
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+# A type with only a couple of fields is usually a wrapper or a false positive
+# (enum name tables register short literal runs too). Real wire DTOs are wider.
+MIN_FIELDS = 4
+
+LITERAL = re.compile(r'Move \w+, "([A-Za-z][A-Za-z0-9_]*)"')
+
+
+def fields_in(text: str) -> list[str]:
+ """Ordered PascalCase field names, de-duplicated.
+
+ A field is only counted when its lowercase alias also appears, which is what
+ separates a reader's property registrations from incidental string literals
+ (log messages, enum names) that happen to be capitalised.
+ """
+ literals = LITERAL.findall(text)
+ seen_lower = {lit.lower() for lit in literals}
+
+ fields: list[str] = []
+ taken: set[str] = set()
+ for lit in literals:
+ if not lit[0].isupper():
+ continue
+ key = lit.lower()
+ if key in taken:
+ continue
+ # The alias spellings are what mark this as a registered property.
+ if key not in seen_lower or literals.count(lit) == 0:
+ continue
+ camel = lit[0].lower() + lit[1:]
+ if camel not in literals and key not in literals:
+ continue
+ taken.add(key)
+ fields.append(lit)
+ return fields
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+
+ dump_root, out_path = Path(sys.argv[1]), Path(sys.argv[2])
+ if not dump_root.is_dir():
+ print(f"not a directory: {dump_root}", file=sys.stderr)
+ return 1
+
+ catalogue: dict[str, list[str]] = {}
+ for path in sorted(dump_root.rglob("*.txt")):
+ try:
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ continue
+ if '"' not in text:
+ continue
+ fields = fields_in(text)
+ if len(fields) >= MIN_FIELDS:
+ catalogue[path.stem] = fields
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(catalogue, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ print(f"{len(catalogue)} DTO reader(s) -> {out_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())