fix: make room-details and chat-thread byte-identical to the client's readers

Both bugs were the same shape: we answered HTTP 200 with a key set the client's
generated reader did not agree with, the reader threw mid-deserialise, and it
surfaced later as a bare "Failed to copy room" / a chat that never opened, with
no URL in the client log and no payload in the server log.

Rather than guess at fields, tools/extract-client-dto-keys.py reads the readers
out of the ISIL dump. Cpp2IL emits each generated reader's property names as
string literals in registration order (three spellings per field: Pascal,
camel, lower), so the extracted catalogue is the client's own contract. 156
reader types land in DorkNet.Server.Tests/data/client-dto-keys-2023.json, and
ClientDtoParityTests asserts our real responses against them — nothing missing,
nothing extra.

Chat (EKEFFNBIOHJ) was missing exactly one key: LatestMessage. It was only
emitted when a thread HAD messages, so it vanished from the one response where
it was needed — the thread that thread/withmembers has just created, which by
definition has none. It is now always present, null when there is nothing to
report; a registered slot wants a value and an absent key is not one.

Room details (GLEGPFFPDBE) was missing DataBlob, DataBlobHash, MaxPlayers,
ToxmodEnabled and RankingContext, and sent 15 keys the reader has no slot for.
The 15 are gone: UgcVersion, UgcSubVersion, MinUgcSubVersion, PublishState,
PublishStateAvailability, BoostCount, ExcludeFromLists, IsJuniorCreated,
IsRecRoomApproved, IsRoomLinkedToRecRoomStudio, IsStudioRoom, StudioSessionId,
RestrictedCircuitsAllowListNames, AllowNewUsers, AllowsNewUsers.

That deletion also removes a comment asserting the 2023 client reads UgcVersion
to build its asset-bundle URL. It does not — the literal appears nowhere in the
entire decompile, and neither do PublishState or BoostCount.
This commit is contained in:
Alexa
2026-07-28 11:00:13 +03:00
parent da7f980166
commit cc523181d2
5 changed files with 2032 additions and 32 deletions
@@ -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;
/// <summary>
/// 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
/// <c>tools/extract-client-dto-keys.py</c> into
/// <c>data/client-dto-keys-2023.json</c>, 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.
/// </summary>
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
private const string RoomDetailsReader = "GLEGPFFPDBE";
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
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<DorkNetDbContext>();
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<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("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");
}
/// <summary>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.</summary>
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<string> 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;
}
}
@@ -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"
]
}
@@ -2109,8 +2109,6 @@ public class RoomsController(
SupportsQuest2 = true, SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile, SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors, SupportsJuniors = room.AllowsJuniors,
AllowNewUsers = room.AllowNewUsers,
AllowsNewUsers = room.AllowNewUsers,
MinLevel = room.MinLevel, MinLevel = room.MinLevel,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"), .ToString("yyyy-MM-ddTHH:mm:ssZ"),
@@ -2122,39 +2120,13 @@ public class RoomsController(
VisitCount = room.VisitCount, VisitCount = room.VisitCount,
}, },
IsDorm = room.IsDormRoom, IsDorm = room.IsDormRoom,
IsStudioRoom = room.IsStudioRoom,
IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio,
StudioSessionId = room.StudioSessionId,
CloningAllowed = room.CloningAllowed, CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute, DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false, DisableRoomComments = false,
EncryptVoiceChat = false, EncryptVoiceChat = false,
LoadScreenLocked = 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, 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"), IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"),
IsJuniorCreated = false,
IsRecRoomApproved = false,
ExcludeFromLists = false,
BoostCount = 0,
RestrictedCircuitsAllowListNames = Array.Empty<string>(),
PublishStateAvailability = new
{
CanSaveAsBeta = false,
CanSaveAsUpdate = true,
AvailableUpdateTokenCount = 3,
NextAvailableUpdateDateTimeUtc = (string?)null,
},
PublishState = 0,
MaxPlayerCalculationMode = room.MaxPlayerCalculationMode, MaxPlayerCalculationMode = room.MaxPlayerCalculationMode,
SubRooms = subRooms, SubRooms = subRooms,
Roles = wireRoles, Roles = wireRoles,
@@ -2162,6 +2134,23 @@ public class RoomsController(
PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson), PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson),
PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson), PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson),
Tags = tags, 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,
}; };
} }
@@ -1147,15 +1147,25 @@ public class ChatController(
["clubId"] = ClubIdFromKey(thread.ThreadKey), ["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) if (messages is not null)
{ {
dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList(); dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList();
if (messages.Count > 0) dto["latestMessage"] = messages.Count > 0
dto["latestMessage"] = ToWireMessage(messages[^1], thread.Id); ? 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; return dto;
+103
View File
@@ -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())