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
@@ -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<string>(),
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,
};
}