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())