Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.
RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
451 KiB
Rec Room 2023-03-21 client — complete API contract
Generated 2026-07-27 from the March-2023 client binary.
Rec Room shut down, so the client can no longer be observed against a live server. Everything here was recovered statically from the shipped build and cross-checked against the DorkNet server source. This is the reference for making DorkNet answer every request the game makes.
Sources
| What | Where |
|---|---|
| Client ISIL disassembly (contains the string literals) | C:\tmp\recroom-2023-03-21-isil\IsilDump |
| Client decompiled C# (Il2CppInterop proxies, no literals) | C:\tmp\recroom-2023-03-21-decompiled |
| Client package / metadata | C:\tmp\recroom-2023-03-21-devdork-package, global-metadata.dat |
| Nearest dump with real type layout (2023-06-21) | Recnet-old\dist\RecRoom-2023.06.21-steam\il2cppdump\dump.cs |
| Server implementation | DorkNet.Server/Controllers/**, DorkNet.Services.* |
The API client layer lives in the RecNet.Runtime assembly. Route literals
appear there only in the ISIL disassembly — the decompiled C# has the type and
field layout but no strings, so both are needed.
How to read the client binary
Route literals
Routes are moved into an argument register just before the call into the shared HTTP dispatcher:
076 Move r8, "rooms/bulk"
Some routes are String.Format templates ("role/{0}/{1}"), so the literal is a
shape rather than a path. Grepping for the literal alone will mis-classify those
as missing — check the call site.
HTTP verbs
The small integer moved into the neighbouring argument register is a
BestHTTP.HTTPMethods ordinal. Confirmed from the static constructor of
RecNet.Runtime/HNLCIDLIIBO, which builds a Dictionary<HTTPMethods,String>:
| Ordinal | Verb |
|---|---|
| 0 | GET |
| 1 | HEAD |
| 2 | POST |
| 3 | PUT |
| 4 | DELETE |
| 5 | PATCH |
| 6 | MERGE |
A verb can be chosen at runtime. rooms/bulk preloads 2 (POST) and
conditionally swaps in 0 (GET) when the id count is under 100, so that one route
legitimately needs both verbs. Implementing only one silently breaks the other.
Response shapes
The issuing method signature is the response contract. Task<List<X>> means a
JSON array; FGLDKEJLAKB<System.Int32> means the body is a bare integer, not
an object wrapping one. Bare-scalar endpoints are a recurring source of defects —
returning {"Limit":1000} where the client reads 1000 makes the deserialize
throw and the feature silently fail.
The untyped promise is not always status-only. Many writes return
LDGADANDBIO (no type argument), which reads like "body ignored". About 40 of
them are not: they pass the response to BFDGEKKNAPB.PEFKBAEAOPD
(RecNet.Runtime/BFDGEKKNAPB.txt:353, "ExpandRecNetResult"), which
deserialises it as DJMHAFPGLLN = {Success, Error} (formatter
LMCCNLLHLCJ.txt:191-218). An empty 200 (or null) parses to null and the
continuation null-derefs, so the player sees "Failed to …" after the server
applied the change. A body without Success parses as a failure with a null
Error, and Promise.Rejected(null) throws ArgumentException: Error message cannot be null, which shows up in Player.log as an unobserved task exception.
Search for callers of BFDGEKKNAPB.PEFKBAEAOPD before you decide a write
needs no body. The server helper is Controllers/RecNetResults.cs.
Watch for a projection between the wire type and the method return type.
comments/unreadcounts returns Dictionary<long,uint> from the method, but the
wire payload is List<UnreadRoomComments> — a Func<List<...>, Dictionary<...>>
runs client-side over the deserialized array.
JSON key names
Most RecNet DTO properties are obfuscated and their wire names come from
attribute metadata that the dumps do not render, so exact key spelling is often
not recoverable statically. Two things make this tractable:
- Json.NET matches member names case-insensitively and ignores unknown members, so casing is forgiving and emitting extra alias keys is inert.
- Some DTOs are not obfuscated.
RecNet.KickPlayerDTOhas real public fields (GameSessionId,PlayerIds) and is serialized with UnityJsonUtility, which writes field names verbatim.
Where a key could not be proven, this document says so rather than guessing.
Coverage summary
- 565 route literals traced across 13 subsystems (518 are real HTTP routes; the rest are cache keys, deeplink prefixes, MIME types or notification topics).
- 527 endpoints diffed against the DorkNet server.
- 315 defects found: 180 degraded, 106 breaks-gameplay, 25 cosmetic, 4 none.
Defect classes:
| Status | Count | Meaning |
|---|---|---|
OK |
210 | handler exists, verb and shape match |
SHAPE_MISMATCH |
183 | wrong JSON shape, keys or body binding |
MISSING |
82 | no handler — the client gets 404 |
VERB_MISMATCH |
27 | route exists under a different verb — 405 |
STUB |
16 | handler returns placeholder data or drops the write |
UNKNOWN |
5 | could not be proven from the binary |
CASING_MISMATCH |
2 | right shape, wrong key casing |
NOT_A_ROUTE |
2 | literal is not an HTTP request |
Realtime (SignalR) surface
Not every request is HTTP. The client opens a SignalR hub at {0}hub/v1
(RecNet.Runtime/OMHPBIEHOHN, negotiate + hub/v1). The full push
contract — every handler key, its payload, and what DorkNet sends — is in
recroom-2023-notifications.md.
- Server→client messages arrive on the hub method named
"Notification"(singular) carrying a singlestringargument whose content is JSON{"Id":..., "Msg":...}. The handler is registered asAction<string>. SendingReceiveNotification, or passing an object instead of a string, makes every push silently vanish. - Client→server:
SubscribeToPlayers({PlayerIds}), plusSubscribeTo/UnsubscribeFrom({Channels}, always empty). SubscribeToPlayersis load-bearing: it is how the client gets presence for players who aren't friends (people in the room, an open profile).
Push dispatch keys — the numeric-vs-name trap
The envelope's Id is looked up in a dispatch dictionary the client built
at startup. Handlers are registered two ways, and the difference decides
what the server must put in Id:
- Enum overload — the client calls
Int32.ToString()on the id and registers ONLY that numeric string (RecNet.Runtime/OMHPBIEHOHN.txt:662and:733). There is no enum-name companion key. - String overload — registers exactly one bespoke name.
Lookup is a plain case-sensitive Dictionary.TryGetValue that returns
silently on a miss (OMHPBIEHOHN.txt:4052-4237). A wrong key is not an
error anywhere — on the server, on the wire, or in the client log. The push
just never arrives, which makes this class of bug very easy to miss.
So send the number, except for these six, which are string-keyed:
| Id | Wire key |
|---|---|
| 11 SubscriptionUpdateProfile | AccountUpdate |
| 12 SubscriptionUpdatePresence | PresenceUpdate |
| 13 SubscriptionUpdateGameSession | RoomInstanceUpdate |
| 15 SubscriptionUpdateRoom | RoomUpdate |
| 90 ChatMessageReceived | ChatMessageReceived |
| 95 CommunityBoardUpdate | CommunityBoardUpdate |
Ids the 2023 client registers NO handler for — sending them is a no-op:
4, 15 (numeric form), 21, 24 ModerationRoomBan, 40, 85. The moderation
manager registers only 22, ModerationUnkick and 23
(RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335), and a kick (22) only sends
the player to their dorm, so bans go out as Logout (6) with Reason 4
(Banned); see recroom-2023-notifications.md.
Ids the client handles that this server never sends (dead client features,
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
string channels — GoToFailure, AppVersionUpdate,
GameConfig.Refresh, ReputationUpdate, KeepsakeInstanceAdded/Removed,
RoomCurrencyCreated/Modified/Deleted, RoomCommentDeleted and others.
Hub methods
The client sends SubscribeToPlayers with a {PlayerIds: List<int>}
payload, fire-and-forget, on every (re)connect and whenever the set changes.
The SubscribeTo/UnsubscribeFrom composition runs on connect but its
channel set is always empty, and UnsubscribeFromPlayers does not appear at
all. The server keeps each connection's subscriptions as presence:{id}
SignalR groups and pushes those players' presence to them.
Negotiate
POST {notifyHost}/hub/v1/negotiate with no negotiateVersion (protocol
version 0), so ASP.NET Core's default MapHub response satisfies it. Auth
is a standard Authorization: Bearer header on both negotiate and the
WebSocket upgrade — no query-string access_token handling is needed.
Already fixed
These were found by this audit and corrected in the same pass. Each was verified against the binary before the change, and the full test suite passes.
| Endpoint | Was | Now |
|---|---|---|
GET role/moderator/{id} |
404 on every remote player | route added alongside role/developer |
GET comments/unreadcounts |
object body where the client reads an array | returns a JSON array of per-room rows |
GET api/banappeal/generateCode |
POST-only (405) + object body | GET added; body is a bare JSON string |
POST api/ageverification/generateCode |
object body where the client reads a string | body is a bare JSON string |
POST api/clubreporting/v1/report |
read message; client sends details |
reads details first |
POST api/screensharereports/v1/report |
ReportedPlayerId/ImageName never bound |
binds the client keys; image kept in the report text |
POST api/playerwarnings |
GET-only (405) | create handler added, moderator-gated |
POST api/playerwarnings/acknowledge |
400 — demanded a warningId the client never sends | no-arg call acknowledges the newest warning |
POST api/PlayerReporting/v1/instantKick |
raw JSON body never parsed — kicked nobody | reads the KickPlayerDTO body |
PUT player/photonregionpings |
parsed only the 2020 pair shape | also reads one field per region name |
POST player/notifydisconnect |
bound 2020 otherPlayerId only |
also binds PlayerId/RoomInstanceId |
PUT player/{statusvisibility,vrmovementmode,avoidjuniors} |
echoed and discarded | persisted per player and read back on heartbeat |
GET player?{ids} statusVisibility |
always 0 (Public); "appear offline" never hid anyone | reports the stored value; Offline hides from everyone else, FriendsOnly / FavoriteFriendsOnly hide from non-qualifying callers |
PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} |
404 — routes missing ("Failed to modify …") | handlers added; form fields displayEmoji / pronounFlags / identityFlags / imageName; values persisted and emitted on every account DTO + pushed as AccountUpdate to all clients |
GET econ/customAvatarItems/v1/itemOwnershipLimit |
object body where the client reads an int | bare JSON integer |
GET api/customAvatarItems/v1/isCreationAllowedForAccount |
object body, then (wrongly) a bare boolean | {Success, Error} envelope — the client parses DJMHAFPGLLN (converter LMCCNLLHLCJ registers only Success/Error); the bare boolean is what logged "Failed to query if local account can create Custom Avatar Items" |
GET api/customAvatarItems/v1/minPriceForPublicItem |
object with four hedge keys | bare JSON integer — sent through FDKKOPAPDGF, continuation <GetPricingConfig>b__69_0(Int32 minimumPrice) |
GET + POST api/avatar/v3/saved{,/set} |
OutfitSelectionsV2 and CustomAvatarItems dropped on save; loading a slot restored the stale V1 selection (the client wears V2 when non-empty) |
all eight IMMOJHBENFI keys persisted and served |
POST api/avatar/v{2,3}/gifts/generate |
415 on the client's form-urlencoded body (seen live) | form-or-JSON binder |
GET api/avatar/v1/lockeditems |
5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape |
GET api/avatar/v4/items (colored variants) |
a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor |
GET api/avatar/v4/items / defaultunlocked / lockeditems (default items) |
the client REPLACES its unlocked set with each downloaded list (OutfitManager.GFEIDFKKNMO clears field +0x70) and never merges its own defaults, so the free default_unlocked_variants items (e.g. Beard Close) showed a price |
every list includes the 239 default items; defaultunlocked serves them before login; lockeditems never reports them |
GET api/avatar/v4/items / defaultunlocked / lockeditems (metadata) |
names came from the catalog dump ((Shirt_Winter) for colour variants), rarity was always 0 and tooltips empty, so 1,864 items disagreed with their shop tile |
each owned row takes FriendlyName / Tooltip / Rarity from its shop tile (StoreService.LoadAvatarItemStoreInfoAsync), matched on the exact AvatarItemDesc |
GET api/avatar/v1/defaultbaseavataritems |
returned the first 100 catalog items; the client flags every row as a custom-clothing template (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips templates in the owned rebuild (OutfitManager.MBPOFMEHDIJ), so those 100 ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even when owned and equipped |
only the UGC templates UGCTee_Shirt (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), IsBaseAvatarItem: true; they're no longer sold or listed as owned clothing |
GET api/customAvatarItems/v2/fromCreator/{id} |
bare array; client reads {Results,TotalResults} | paged container |
POST econ/customAvatarItems/v1/{id}/purchase |
404 — route only existed under api/ | econ/ alias added |
GET api/ugcPurchasables/v1/items/room/{roomId} |
404 — only the ?roomId= form existed | path form added |
POST api/equipment/v1/update |
array body failed model binding (400) | accepts an array or a single object |
POST api/consumables/v1/transfer |
JSON body never read | reads JSON as well as form/query |
GET api/consumables/v1/getTransferable/{id} |
404 | implemented |
PUT api/roomconsumables/.../{consume,purchase/tokens,purchase/currency} |
POST-only (405) | PUT added — consumables are buyable again |
Room consumable descriptors |
nested PriceAndCurrency; client reads flat | flat Price/PurchaseCurrencyId/ModifiedAt |
Room currency + purchase-offer wires |
server-invented key names | client key names, legacy aliases retained |
GET api/roomcurrencies/v1/getBalance |
ignored accountId, so always answered for the caller |
honours accountId |
api/roomcurrencies createCurrency/updateCurrency |
Limit dropped |
Limit accepted |
GET api/roomkeys/v1/mine |
returned keys CREATED, not held | returns purchased keys (plus own) |
GET api/roomkeys/v1/purchased/{id} |
asked "did I buy it" — always false for the creator | asks "did anyone buy it" |
GET api/roomkeys/v1/owns |
ignored playerId |
answers for the named player |
POST api/roomkeys/v1/owns/bulk |
JSON pair array never read | reads {RoomKeyId, AccountId} pairs |
PUT api/roomkeys/v1/update{All,Name,Description,Price} |
405 — none registered | all four added |
13 club write endpoints |
415 — [FromBody] vs the client's form bodies | shared form-or-JSON model binder |
PUT club/{id}/clubhouse |
read the query only, so setting a clubhouse CLEARED it | reads the form body |
PUT club/{id}/members/requesttojoin |
joinability 1 and 2 swapped | Open=0, InviteOnly=1, AskToJoin=2 |
POST thread/{id}/leave |
DELETE-only (405) | POST added |
PUT announcements/club/{cid}/{aid} |
bound to the DELETE handler — editing DELETED the post | its own edit handler |
POST announcements/club/{cid} |
404 — no create route | implemented, returns the new id |
POST api/inventions/v4/addversion |
400 — client sends inventionDataFilename |
accepts it plus the extra cost fields |
POST api/inventions/v1/settags |
400 — tag arrays bound to string | accepts string arrays |
POST api/images/v1/modifyaccessibility |
Accessibility never read; photos stayed private |
reads the enum |
POST leaderboard/CheckAndSetStat |
404 — every 2023 score write was lost | implemented with compare-and-set |
api/externalfriendinvite/v1/get*referrers |
objects where the client reads a bare id array | bare Int32 array |
GET account/search |
bound query; client sends name — always empty |
accepts both |
PUT account/me/birthday |
POST-only (405) | PUT added |
DELETE cachedlogin/current |
GET-only (405) | DELETE added |
PUT api/players/v4/current/contact |
GET-only (405) | PUT added |
PUT roominstance/{id}/markprivate |
POST-only (405) | PUT added |
PUT roominstance/{id}/roomCode |
405, and custom codes were discarded | PUT added; codes persist |
GET roomserver/rooms/base, roomserver/featuredrooms/current |
404 — no roomserver/ twin | twins added |
GET featuredrooms/current |
no RoomName key — tile captions were null |
RoomName emitted |
playlists name/description/image/tags |
415 — [FromBody] vs form bodies | form/query/JSON reader |
playlists accessibility/levelvoting/restrictions/warning |
PUT 405, and values were discarded | PUT added and values persisted |
SignalR push Id (notify) |
enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
SignalR SubscriptionUpdateRoom |
sent as SubscriptionUpdateRoom; client listens on RoomUpdate |
sent as RoomUpdate |
Room ban notification |
sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan (later replaced by Logout Reason 4, below) |
Notification payloads (Oct 2026 audit) |
RelationshipChanged sent {Reason, From}; ModerationKick used the nested 2020 shape and lost every reason; bans, deletes and takeovers only kicked to the dorm; Logout sent an enum NAME and was dropped; ServerMaintenance sent {Message}; ConsumableMapping, PlayerEvent 80/81/83, admin RoomUpdate and level-up pushes had wrong or missing payloads; SubscribeToPlayers was ignored |
every payload matches the client DTO; bans/deletes/takeovers are Logout with a numeric Reason; SubscribeToPlayers drives presence:{id} groups; see recroom-2023-notifications.md |
Presence on SignalR reconnect |
disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
POST matchmake/none |
re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers roomInstance: null until the next matchmake |
Presence fan-out to friends |
only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and isOnline was derived from "has a room" |
one fan-out (PresenceBroadcastService) for every transition, explicit isOnline, stored statusVisibility, never sent to the subject |
GET player (bulk) |
isOnline required a room; statusVisibility hard-coded 0; caller's own row ignored a pending join |
online = heartbeat or SignalR; stored visibility; own row matches the heartbeat |
Join watchdog |
30 s timeout cleared presence in the middle of long room loads, so the next heartbeat bounced the player to the dorm | 3 min backstop that clears the room only (player stays online) |
15 club/{id}/members/* writes, DELETE club/{id}, PUT+DELETE club/home/me |
empty 200; the RecNetResult reader null-derefed, so every join/leave/invite/ban showed "Failed to …" | {success:true,error:""} |
announcements/club/{c}/{a} edit, delete, /read |
empty 200 / {Read:true}; read threw "Error message cannot be null" |
RecNetResult |
DELETE api/keepsakes/{id}, PUT comments/read/{r}/{c}, DELETE subscription/{id} |
empty 200 | RecNetResult |
api/roomcurrencies/v1/deletePurchaseOffer, PUT accountprivacysettings/recenthistoryvisibility |
non-RecNetResult body (no Success) — rejected with a null message |
RecNetResult (privacy keys kept) |
POST api/playerwarnings (refusals) |
failure carried Message, not Error |
Error added |
POST account/me/createlogintoken |
object where the client reads a bare string | bare JSON string |
POST account/me/remoteauth |
matched codes against createlogintoken tokens — always 401 — and answered a token pair | approves a device-authorization user_code for the caller; RecNetResult |
connect/token device_code / login_token grants |
both fell into the device-id fallback (approval bypassed / wrong account) | real grants: authorization_pending until approved; login token redeemed once for its own account |
PUT api/playerevents/v2/{id}/multiinstance |
broadcast-permission fields are boxed enums (sent as NAMES), parsed as ints → always 0 | names (None/RoomOwners/All) and numbers accepted |
GET api/incentivizedreferrals/referrals |
404 — built by the LPPEFJMCJFN URL builder, missed by the literal sweep | implemented (paged) |
POST account/bulk/email, account/bulk/phone |
404 | routed to the bulk account lookup |
GET strings (strings host) |
404 | empty table — no server-side overrides |
gameconfig PlayMenuCarouselOrdering_1 |
missing — "Could not find GameConfig" warning, prefab order | served; ids recovered from the build's DisplayOrderPriorityImpl |
Everything else in the per-subsystem Defects sections below is still
outstanding — but the sections are stale. A 2026-10 re-check of the
breaks-gameplay rooms, playerevents, room-currency, clubs, keepsakes and
storefront entries found all of them fixed except purchase-offer Order
(sent by the client, MIHKAJFMIPE.txt:2664, never persisted; display order only).
Requests outside the route-literal sweep
The sweep that produced client-routes-2023.tsv matched plain literals in
RecNet.Runtime. These were found later and are now in the inventory:
leading-slash literals (/api/playerReputation/v2/bulk,
/api/players/v2/progression/bulk, /strings, /config/{0}, /data/{0}),
base-plus-format routes (api/itemWishlists + {0}/v1/wishlist/me/{1},
api/roomEarningsDistributions + {0}/v1/earningsDistribution/{1}), the
LPPEFJMCJFN URL builder (api/incentivizedreferrals/referrals), the
runtime-composed account/me/{field} and account/bulk/{email,phone}, and
the Assembly-CSharp CDN fetch /sigs/{id} (JFDPIJFDHAD.txt:1432).
Known and deliberately not served:
- GameLogs host (
HKBNPBMNDGG22):PUT /reports/{guid},/reports/{guid}/userinfo,/reports/{guid}/attachments/{name},/Reports/{guid}/Complete(AICEFGDBCGI.txt:671-1944). This is the "Send Game Log" tool, which is hidden unless gameconfigExperience.GameLogTool.HideToolis false — its client default is true (BugReportingVisibilityModel.txt:241-245), and DorkNet does not send the key, so the client never makes these calls. - BugReporting host (18):
POST /ado/bug,POST /ado/file— Rec Room's internal Azure DevOps bug filer for staff builds. - Third-party hosts baked into the client (Amplitude, RudderStack, Unity user reporting, backtrace.io) are not RecNet services.
/sigs/{id}answers an empty payload on purpose: it is an encrypted file-signature set for the client's integrity check. The client logs "/sigs/ is empty" and boot continues (Player.log, 2026-10-10 session).
Subsystems
Identity, accounts, login and version gate
identity-account
Identity-account diff for the 2023-03-21 client vs DorkNet (all claims verified by reading both the server source and the ISIL). 14 defects across 34 checked route/verb pairs. Hard failures (404/405): DELETE /account/me, PUT /account/me/birthday (server POST-only), account/me/{emoji,personalpronouns,identityflags,bannerimage,confirmphone} missing, POST account/bulk/{email,phone} missing, GET accounts/{id}/receives/{cat} missing, GET platformid/{id} missing (nearest route is wrong path + object-vs-bare-string + hardcoded 0), DELETE /cachedlogin/current (GET-only), PUT api/players/v4/current/contact (GET-only). Silent-empty/wrong-data defects: account/search binds 'query' but client sends 'name' → search always returns []; cachedlogin/forplatformids reads 'platformIds' but client sends repeated 'id' → always []; cachedlogin/migrate binds platform/platformId vs client's legacyPlatformId/newPlatformId → no-op; account/me/createlogintoken returns an object where the client requires a bare JSON string → reader throws; GET /account/me hardcodes email/phone/birthday and omits availableUsernameChanges/displayEmoji/bannerImage/personalPronouns/identityFlags; connect/token lacks login_token and device_code grants (both fall into the device-id fallback → wrong-account or approval-bypassed logins), and account/me/remoteauth validates against the wrong token store so passwordless approval always 401s. Clean: haspassword, changepassword, recoverpassword, bulk (repeated 'id' handled), {id}/bio, privacy settings GET/PUT, sanitize v1 + isPure (request field names UNKNOWN — JsonUtility, no literals), versioncheck/v4, isactivecreator, forplatformid, deviceauthorization shape, eac/challenge, namegen, parentalcontrol, and the username/displayname/bio PUTs. account/{id}/clubs works but omits MinLevel and ClubChatEnabled (defaults 0/false — club chat renders disabled).
Client-side notes. SERIALIZER MECHANICS (applies to every DTO above)
- The 2023 client deserializes API responses with Utf8Json-style generated formatters (writer type LMANJAHJEKC, reader type EILLEGCDDNJ, key automaton PIPOGPCBCNM). Each formatter's .ctor registers every property under THREE exact byte keys — PascalCase, camelCase and all-lowercase (e.g. AEGCKMKICFG.txt:855/866/874 -> "AccountId"/"accountId"/"accountid"). Matching is exact-byte per variant, so a fourth spelling does NOT match: DorkNet's
"username"is fine (registered), but a hypothetical"Username"would NOT be (only "UserName"/"userName"/"username" are registered). Unknown keys are skipped; missing keys leave CLR defaults, so a missing key is safe but a wrong-cased key silently yields null/0. - Danger cases: non-nullable value types. MIMFEOHELNC.CreatedAt and MDGJJCFGJNF.LastLoginTime are
System.DateTime(not nullable) — emittingnullfor those keys will throw in the reader, whereas omitting the key is harmless. - Request parameters go through BNDIAONDFFF.AFGEDDANEKP(name, value), which stores List<KeyValuePair<string,string>> and only decides the encoding at send time: GET => joined with "&" and appended after "?" (BNDIAONDFFF.txt:2806-2851), POST/PUT => HTTPFormBase.AddField (urlencoded/multipart) (BNDIAONDFFF.txt:3032/3099). Servers should accept both query and form for the POST/PUT routes.
- Verb enum is BestHTTP.HTTPMethods: 0=Get, 1=Head, 2=Post, 3=Put, 4=Delete, 5=Patch (validated against call sites: account/me DELETE=4, changepassword POST=2, bio/username PUT=3).
- Host is the SECOND ctor arg (GJDLNNLKDIJ) of BNDIAONDFFF..ctor(HTTPMethods, GJDLNNLKDIJ, String) — BNDIAONDFFF.txt:74. Observed ordinals in this subsystem: 0 for connect/, cachedlogin/, eac/challenge, account/me/{haspassword,changepassword,createlogintoken,remoteauth}, account/recoverpassword, platformid/{0} (auth service); 1 for api/* and the CBKANFIOBCF-based clients (api service, hard-coded at CBKANFIOBCF.txt:5174); 10 for account/, accountprivacysettings/, parentalcontrol/me, namegen/options (accounts service); 13 for account/{id}/clubs; 15 for accounts/{id}/receives/{category}. The ordinal->URL table is built at runtime from config (PAPLNIPKAMG.OICOPGCHJAG, Dictionary<GJDLNNLKDIJ,Uri> at PAPLNIPKAMG.txt:2044), so the literal hostnames are NOT in the binary; the family grouping is the evidence. DorkNet serves these routes host-agnostically today, so this only matters for hosts-file/DNS coverage.
DTO NOTE
- MIMFEOHELNC has 13 properties but only 12 serialized keys (AEGCKMKICFG.txt "Compare rax, 11" at :2665 confirms indices 0..11). The unserialized one is the extra ObscuredString at MIMFEOHELNC.txt:195 (HDLPBPJEJNC) — which property name it carries is UNKNOWN. Same pattern in MDGJJCFGJNF (6 keys, 7 properties; the trailing MIMFEOHELNC Account field is client-only).
GAPS vs the current DorkNet server (compared against the server's reflected route table)
DELETE /cachedlogin/current— client sends DELETE (LBNJFPOLCDL.txt:4190); server registers only GET (DorkNet.Server\Controllers\Auth\AuthController.cs:328) => 405 on sign-out.DELETE /account/me— client sends DELETE (OPMAPIOEIFG.txt:8773); server only has GET (Controllers\Accounts\AccountsController.cs:54).GET /platformid/{accountId}— not registered at all; the nearest server route is/account/v1/{accountId}/platformidand it returns an OBJECT{ PlatformId = 0L }(AccountsController.cs:684) while the client expects a BARE JSON STRING.GET /accounts/{accountId}/receives/{category}— not registered anywhere => 404, the promise in AccountSpecificUIBehaviour rejects.POST /account/bulk/emailandPOST /account/bulk/phone— not registered (server only has GET/POST /account/bulk).POST /cachedlogin/forplatformids— field-name mismatch: client sends repeatedid=(OPMAPIOEIFG.txt:11383), server readsplatformIds/PlatformIds(AuthController.cs:289-311) => always returns [].POST /cachedlogin/migrate— field-name mismatch: client sendslegacyPlatformId+newPlatformId(LBNJFPOLCDL.txt:4469/:4479), server binds[FromForm] platform, platformId(AuthController.cs:342).account/me/suffixes missing on the server: emoji, personalpronouns, identityflags, bannerimage, confirmphone (server has username/displayname/bio/birthday/email/phone/profileimage only) => "Failed to modify ".- account/me payload: DorkNet's RecNetSelfAccount (DorkNet.Models\Auth\RecNetResult.cs:48-115) emits accountId/username/displayName/profileImage/isJunior/hasBirthday/platforms/rawUsername/email/phone/birthday/juniorState/parentAccountId. Keys that exist for this client but are never emitted: availableUsernameChanges (Int32 — 0 means "no name changes left" in the UI), displayEmoji, bannerImage, treatAsJunior, personalPronouns, identityFlags, createdAt.
platformsandrawUsernameare ignored by the 2023 reader. - cachedlogin payloads: server's CachedLogin (DorkNet.Models\Auth\CachedLogin.cs) matches 5 of 6 keys;
refreshTokenis never emitted (harmless — String stays null).
FLAGGED NON-ROUTE / PREFIX LITERALS
- "account/me/" and "account/bulk/" are route PREFIXES, concatenated with a suffix before use (OPMAPIOEIFG.txt:8579 and :12121). They are real routes only in their concatenated forms, enumerated in the entries above.
- "account/{0}/bio" appears twice; the occurrence at OPMAPIOEIFG.txt:6239 is NOT an HTTP request — it builds a response-cache key passed to LBIIOPNIDAC.PBGNNIJLBDG (cache invalidation after PUT account/me/bio). The other occurrence (:10822) is a genuine GET, so the literal is still marked isRealHttpRoute=true.
- accounts/{0}/receives/{1}: the {1} segment is an enum boxed and String.Format'ed, i.e. the enum MEMBER NAME. The constant is 8 (LELAJKMOMIA.txt:693) but the CCOKJMJOIJF member names are not present as literals anywhere in the ISIL — the server should treat that segment as an opaque string.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| GET (0) when id count <= 100, POST (2) above that — chosen at runtime by ALHIJCJOLCB.JIECAFGCODK(count, threshold=100) | account/bulk |
repeated "id" (Int32) — query params on GET, urlencoded form fields on POST | JSON array of MIMFEOHELNC (same shape as account/search) |
| POST (2) | account/bulk/ |
path is "account/bulk/" + segment, giving the two concrete routes account/bulk/email and account/bulk/phone. Body: repeated field named after the same word ("email" or "phone"), ea | JSON array of JFFGMHMHMCO = MIMFEOHELNC fields plus a leading ContactDetail(String): keys ContactDetail, AccountId(Int32), UserName, DisplayName, DisplayEmoji, ProfileImage, BannerImage, TreatAsJunior(Boolean), HasBirthd |
| GET (0) | account/isactivecreator/me |
none | bare JSON boolean — Task<System.Boolean> |
| GET (HTTPMethods 0) — also DELETE (4) from a second call site | account/me |
none (GET). DELETE variant also sends no body. | CAALNOGGDLG ("SelfAccount") object. Reader keys registered in formatter ctor, index order: Email(String), Phone(String), Birthday(Nullable), JuniorState(enum EJFPLBEEAMN as Int32), ParentAccountId(Nullable<Int3 |
| prefix only — concatenated with a suffix; PUT (3) for username/displayname/emoji/bio/personalpronouns/identityflags/bannerimage/birthday, POST (2) for email/phone/confirmphone | account/me/ |
Single form field (POST/PUT bodies are urlencoded/multipart; the field name equals the suffix except bio): "username", "displayname", "emoji", "bio", "personalpronouns", "identityf | body ignored — helper returns LDGADANDBIO (fire-and-forget promise); only HTTP status matters. Failure string "Failed to modify ". |
| POST (2) | account/me/changepassword |
form fields: "oldPassword" (String), "newPassword" (String) | body ignored (LDGADANDBIO promise); status only |
| POST (2) | account/me/createlogintoken |
none (no fields on either call site) | bare JSON string (the login token) — FGLDKEJLAKB<System.String> |
| GET (0) | account/me/haspassword |
none | bare JSON boolean (true/false) — Task |
| POST (2) | account/me/remoteauth |
form field "code" (String); request timeout overridden via BNDIAONDFFF.EHPIOGADENJ | body ignored (LDGADANDBIO); failure string "Failed to authorize login" |
| POST (2) | account/recoverpassword |
form field "email" (String) | body ignored (LDGADANDBIO); failure string "ResetPassword failed" |
| GET (0) | account/search |
query "name" (String) | JSON array of MIMFEOHELNC: [{AccountId(Int32), UserName(String), DisplayName(String), DisplayEmoji(String), ProfileImage(String), BannerImage(String), TreatAsJunior(Boolean), HasBirthday(Boolean), PersonalPronouns(Int32 |
| GET (0) | account/{0}/bio |
{0} = accountId (Int32) | CCNKBIGMLGP object: {"AccountId": Int32, "Bio": String} (also accepted: accountId/accountid, bio) |
| GET (0) | account/{0}/clubs |
{0} = accountId (Int32) | JSON array of FOIJDINBPFG: ClubId(Int64), Name(String), Description(String), MainImageName(String), State(enum IACDINKNHKB as Int32), CreatorAccountId(Int32), Category(String), Visibility(enum GJLKOMJKCFL as Int32), Join |
| PUT (3) | accountprivacysettings/recenthistoryvisibility |
form field "isRecentHistoryVisible" (Boolean) | body ignored (LDGADANDBIO); failure string "Failed to modify accountprivacysettings/recenthistoryvisibility" |
| GET (0) | accountprivacysettings/{0} |
{0} = accountId (Int32) | EBPCGLAICIH object: {"AccountId": Int32, "IsRecentHistoryVisible": Boolean} (Pascal/camel/all-lower all accepted). A second client method reads the same route and projects it to a bare Boolean. |
| GET (0) | accounts/{0}/receives/{1} |
{0} = accountId (Int32). {1} = a CCOKJMJOIJF enum value boxed then String.Format'ed, i.e. the enum MEMBER NAME, not the number; the constant passed at this call site is value 8. Th | bare JSON boolean (Action continuation) |
| PUT (3) | api/players/v4/current/contact |
query "email" (String) + raw JSON body of OPMAPIOEIFG/BIIIACIMPBM: {MarketingEmails: Boolean, InviteEmails: Boolean, GameClientTextInvites: Boolean, FriendRequestEmails: Int32-enum | body ignored (KDOPJCNKOOK = send-and-discard); failure string "Failed to update player marketing email prefs" |
| POST (2) | api/sanitize/v1 |
raw JSON body produced by UnityEngine.JsonUtility.ToJson(HPKNELJLNOJ/PurifyStringRequest). JsonUtility serializes FIELD names; those field names are not present as literals in the | bare JSON string — the purified text (continuation is System.Action`1, wrapped client-side into HPKNELJLNOJ/KKMLDOEOADJ(Boolean, String)) |
| POST (2) | api/sanitize/v1/isPure |
raw JSON body from JsonUtility.ToJson(HPKNELJLNOJ/IsStringPureRequest) — four fields set at :481-489 (two reference fields + two int/bool fields). Field names UNKNOWN (JsonUtility | PJLCFFHLNAE object: {"IsPure": Boolean} (also "isPure"/"ispure") |
| GET (0) | api/versioncheck/v4 |
query params: "v" (client version, from PAPLNIPKAMG version field), "p" (platform, Int32), "pid" (Int32) | HABAPHJMBEO/BFEAELMKAKM object: {"VersionStatus": Int32 (enum PHOGEIPMHOK), "UpdateNotificationStage": Int32 (enum MEHPMLBHDOJ)} — also versionStatus/versionstatus, updateNotificationStage/updatenotificationstage |
| DELETE (4) | cachedlogin/current |
none | response body deserialized through the generic FDKKOPAPDGF path but discarded by the caller (method returns LDGADANDBIO, a value-less promise) — treat as "status only" |
| GET (0) | cachedlogin/forplatformid/{0}/{1} |
{0} = platform as Int32 (enum HHJIBNMLOAC converted to int before boxing), {1} = platformId (String) | JSON array of LBNJFPOLCDL/MDGJJCFGJNF: {Platform: Int32 (enum HHJIBNMLOAC), PlatformId: String, AccountId: Int32, LastLoginTime: DateTime (non-nullable), RequirePassword: Boolean, RefreshToken: String} — each key also in |
| POST (2) | cachedlogin/forplatformids |
repeated form field "id" (String) — one entry per platform id | JSON array of LBNJFPOLCDL/MDGJJCFGJNF (same shape as cachedlogin/forplatformid); mapped client-side into List |
| POST (2) | cachedlogin/migrate |
form fields: "legacyPlatformId" (String), "newPlatformId" (String) | body ignored (KDOPJCNKOOK); status only |
| POST (2, urlencoded form via PAPLNIPKAMG.NKGALKBJBJG) | connect/deviceauthorization |
form: client_id="recroom", client_secret="VxZ53kgbbEaRoZAeMe00MagtgD12GLL2" | LBNJFPOLCDL/BENCODMLKEA object, write keys snake_case: {"device_code": String, "user_code": String, "verification_uri": String, "verification_uri_complete": String, "expires_in": Int32, "interval": Int32}. Reader also ac |
| POST (2, urlencoded form via PAPLNIPKAMG.NKGALKBJBJG) | connect/token |
OAuth-ish form. Always added by LBNJFPOLCDL.CONAGEGDJAJ: client_id="recroom", client_secret="VxZ53kgbbEaRoZAeMe00MagtgD12GLL2", platform, platform_id, device_id, device_class, time | LBNJFPOLCDL/HBEJOJNIMBD object, write keys: {"Error": String, "error_description": String, "access_token": String, "refresh_token": String, "Key": String}; reader also accepts error/Error_description/Access_token/Refresh |
| GET (0) — issued through PAPLNIPKAMG.LDBMKNCNKGJ which passes verb 0 | eac/challenge |
none | bare JSON string — the base64 EAC challenge (FGLDKEJLAKB). Must be valid base64: the client feeds it to EACManager challenge-response generation. |
| GET (0) | namegen/options |
none | OPMAPIOEIFG/EAHBKJEMHEM object: {"Nouns": List, "Adjectives": List} — also nouns/adjectives (2 casings only, camel==lower) |
| GET (0) | parentalcontrol/me |
none | MFBCJHHEMKF object: {"AccountId": Int32, "DisallowInAppPurchases": Boolean} (also accountId/accountid, disallowInAppPurchases/disallowinapppurchases). One caller projects the same response down to a bare Boolean. |
| GET (0) | platformid/{0} |
{0} = accountId (Int32); the client short-circuits and never sends the request when accountId <= 0 | bare JSON string — FGLDKEJLAKB<System.String> (the platform id). NOT an object. |
Defects
GET account/me — SHAPE_MISMATCH (degraded)
Handler exists and returns RecNetSelfAccount (DorkNet.Models\Auth\RecNetResult.cs:98). Global serializer PropertyNamingPolicy=null (Startup\ServiceCollectionExtensions.cs:380-381) with JsonPropertyName attrs, so it emits accountId, username, displayName, profileImage, isJunior, platforms, rawUsername, hasBirthday, createdAt, email, phone, birthday, juniorState, parentAccountId. All of those hit registered 2023 reader keys (accountId/username/displayName/profileImage/hasBirthday/createdAt/email/phone/birthday/juniorState/parentAccountId; 'isJunior' lands on the client's Nullable IsJunior). Keys the 2023 CAALNOGGDLG reader registers but the server NEVER emits: AvailableUsernameChanges (Int32 -> defaults 0 = UI shows no username changes remaining), DisplayEmoji, BannerImage, PersonalPronouns, IdentityFlags, TreatAsJunior (server's TreatAsJunior property serializes under the key 'isJunior', so the client's TreatAsJunior bool stays false — benign). Also the values are hardcoded: Email/Phone always string.Empty, Birthday always 2000-01-01, JuniorState always 0 — the real PlayerEntity columns (Email, Phone, Birthday set by SetEmail/SetPhone/SetBirthday in this same controller) are not read back, so the settings screen never shows what the player saved.
Handler: DorkNet.Server\Controllers\Accounts\AccountsController.cs:54
Fix. In AccountsController.GetMe, build the payload from the actual PlayerEntity (Email, Phone, Birthday, IsJunior) and add availableUsernameChanges (e.g. a large int), displayEmoji, bannerImage, personalPronouns (0), identityFlags (0) keys — extend RecNetSelfAccount or return an augmented DTO for the 2023 path.
Status: FIXED. Every account read (account/me, account/{id}, account/bulk, account/search, account/create, api/account/v1/me) and the AccountUpdate / SelfAccountUpdate pushes now go through DorkNet.Server/Services/AccountDtos.cs, built from the real PlayerEntity: displayEmoji, bannerImage, personalPronouns, identityFlags on the public DTO; email/phone/birthday/juniorState plus availableUsernameChanges (99) on the self DTO.
DELETE account/me — VERB_MISMATCH (degraded)
Client's in-game account-deletion action sends DELETE /account/me (OPMAPIOEIFG.txt:8771-8773, verb 4). Server registers only [HttpGet] on /account/me (plus v1/v2 GET aliases) — DELETE returns 405 and the client logs 'Failed to delete local account'.
Handler: DorkNet.Server\Controllers\Accounts\AccountsController.cs:54
Fix. Add a [HttpDelete("/account/me")] action in AccountsController that (soft-)deletes/anonymizes the caller's PlayerEntity and returns 200; body is ignored by the client.
PUT account/me/birthday — VERB_MISMATCH (degraded)
The 2023 client sends PUT (verb 3 — verified at OPMAPIOEIFG.txt:7950-7954: suffix 'birthday', rcx=3) but the server registers only [HttpPost("/account/v1/birthday")] and [HttpPost("/account/me/birthday")] → 405, client shows 'Failed to modify birthday'. This breaks the junior/age-gate birthday prompt.
Handler: DorkNet.Server\Controllers\Accounts\AccountsController.cs:341
Fix. Add [HttpPut("/account/me/birthday")] to the existing SetBirthday action in AccountsController.
PUT (emoji/personalpronouns/identityflags/bannerimage), POST (confirmphone) account/me/emoji, account/me/personalpronouns, account/me/identityflags, account/me/bannerimage, account/me/confirmphone — MISSING (degraded)
None of these five concrete routes exist. server-routes.txt has no account/me/emoji|personalpronouns|identityflags|bannerimage|confirmphone; confirmphone exists only as POST /account/v1/confirmphone (AccountsController.cs:332) and api/account/v1/confirmphone — the 2023 client concatenates 'account/me/' + suffix (OPMAPIOEIFG.txt:8579), so it 404s. Result: profile-emoji / pronoun / identity-flag / banner-image edits and phone confirmation all fail with 'Failed to modify '.
Fix. Add handlers in AccountsController: [HttpPut] /account/me/emoji (form 'emoji'), /account/me/personalpronouns (form 'personalpronouns', int), /account/me/identityflags (form 'identityflags', int), /account/me/bannerimage (form 'bannerimage'); [HttpPost] /account/me/confirmphone (route the existing ConfirmPhone action). Persist emoji/bannerimage/pronouns/flags on PlayerEntity so GET /account/me can echo them back (required for the account/me shape fix above).
Status: FIXED for the four profile edits (confirmphone still open). The form field names suggested above were wrong — the field is NOT the suffix. Verified per call site lambda: emoji → displayEmoji (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57), personalpronouns → pronounFlags (…_LCCEHLBIOPK.txt:67, boxed Int32), identityflags → identityFlags (…_HHDFMJPLFHG.txt:67, boxed Int32), bannerimage → imageName (…_NJKHNGEPODH.txt:57). All four are PUT (rcx=3 before OPMAPIOEIFG.OBAGJBBIFGB). The response is never parsed, so the new value reaches the editor and other players only through the AccountUpdate push (now broadcast to every connected client) and SelfAccountUpdate (owner). Emoji is validated against emojiConfig/whitelistedEmojis (U+FE0F-insensitive, stored in the list's spelling); empty clears. New Players columns DisplayEmoji / BannerImageName / PersonalPronouns / IdentityFlags (migration PlayerProfile2023Fields + DatabaseBootstrap patch). Tests: DorkNet.Server.Tests/ProfileEdit2023Tests.cs.
POST account/me/createlogintoken — SHAPE_MISMATCH (degraded)
Client deserializes the response as a BARE JSON string (FGLDKEJLAKB<System.String>, LBNJFPOLCDL.txt:8382) — server returns an OBJECT { Success, Error, Token, LoginToken, ExpiresAt } (AccountsController.cs:575-582). The strict reader hits '{' where it expects '"' and throws → 'Failed to CreateLoginToken', so every 'open on the web' link (BHKIHEEBBNG builds auth.rec.net URL + token) fails.
Handler: DorkNet.Server\Controllers\Accounts\AccountsController.cs:565
Fix. Return the token as a bare JSON string: Content(JsonSerializer.Serialize(token), "application/json"). Keep the token-minting/PlayerSettings persistence as-is.
GET account/search — SHAPE_MISMATCH (degraded)
REQUEST param name mismatch: the 2023 client sends the search text as query key 'name' (verified in ISIL: OPMAPIOEIFG.txt:12493 'Move rdx, "name"' into AFGEDDANEKP), but the server binds [FromQuery] string? query — 'name' is never read, query stays null, and line 180 short-circuits to an empty array. Watch player search ALWAYS returns zero results for this client. Response shape itself is fine (List of RecNetAccount, camelCase keys all registered by MIMFEOHELNC's reader; DisplayEmoji/BannerImage/PersonalPronouns/IdentityFlags omitted → CLR defaults, cosmetic).
Handler: DorkNet.Server\Controllers\Accounts\AccountsController.cs:175
Fix. In Search, also accept the 'name' query key: e.g. add [FromQuery] string? name and use query ?? name (mirror the existing multi-alias pattern used elsewhere in the controller).
POST account/bulk/email + account/bulk/phone — MISSING (degraded)
The 2023 friend-finder contact import POSTs account/bulk/email and account/bulk/phone with repeated 'email'/'phone' form fields (OPMAPIOEIFG.txt:12121 concat, :12143 body key). No such routes exist — server only has /account/bulk (exact) and /account/v1/emails|phones — so both 404 and the contact-import flow dies. Response must be a JSON array of JFFGMHMHMCO = MIMFEOHELNC fields plus a leading ContactDetail(String) per matched account.
Fix. Add [HttpPost("/account/bulk/email")] and [HttpPost("/account/bulk/phone")] in AccountsController: read the repeated 'email'/'phone' form fields, match against Players.Email/Phone (logic already exists in Bulk()), and return objects of shape { ContactDetail = <matched email/phone>, ...BuildAccount fields }.
GET accounts/{0}/receives/{1} — MISSING (degraded)
No route with an 'accounts/' + 'receives' segment exists anywhere in DorkNet.Server (grep over Controllers confirms; no catch-all remains — GlobalCatchAllController was deleted). Client GETs accounts/{accountId}/receives/{enumMemberName} expecting a bare JSON boolean; the 404 rejects the promise in AGUI AccountSpecificUIBehaviour and blocks the notification-gated UI action.
Fix. Add GET /accounts/{accountId:long}/receives/{category} (category as an opaque {category} string segment — the enum member name is not recoverable, do NOT constrain it) returning Content("true","application/json"), in PlatformNotificationsController or AccountsController.
DELETE cachedlogin/current — VERB_MISMATCH (degraded)
Client sign-out / 'forget this account' sends DELETE /cachedlogin/current (LBNJFPOLCDL.txt:4190, verb 4); server registers only [HttpGet] → 405 and the forget-account promise rejects.
Handler: DorkNet.Server\Controllers\Auth\AuthController.cs:328
Fix. Add [HttpDelete("/cachedlogin/current")] — resolve the bearer's player, clear its LastPlatform/LastPlatformId (so GetCachedLoginsAsync stops returning it), return 200.
POST cachedlogin/forplatformids — SHAPE_MISMATCH (degraded)
REQUEST field mismatch: the 2023 client sends the platform ids as repeated form field 'id' (OPMAPIOEIFG.txt:11383) with NO 'platform' field; the server reads only Form/Query 'platformIds'/'PlatformIds' (AuthController.cs:300-311) → rawIds empty → always returns [] and Steam-friend → Rec Room account resolution silently yields nothing ('Failed to GetAccountsFromPlatformIds' path never even errors — it just gets an empty list).
Handler: DorkNet.Server\Controllers\Auth\AuthController.cs:289
Fix. Also read Form["id"]/Form["Id"] (ASP.NET comma-joins repeated fields; split on ',') in CachedLoginsForPlatformIds, defaulting platform to 0/any when absent.
POST connect/token — SHAPE_MISMATCH (degraded)
Route/verb/response OK: urlencoded POST accepted; BuildTokenResponse (AuthController.cs:36-62) emits access_token, refresh_token, Key/key — all registered by the HBEJOJNIMBD reader; error/error_description emitted on failures. Grant-type coverage is the defect: only cached_login, refresh_token and password(+device fallback) are implemented. (a) grant_type=login_token (+account_id, login_token from createlogintoken) falls through to the device-id fallback and issues a token for whatever account the deviceId maps to, IGNORING account_id and never validating the login token — wrong-account sign-in. (b) grant_type=urn:ietf:params:oauth:grant-type:device_code falls through the same way — the polling device gets a device-bound token immediately, bypassing the remote-approval flow entirely (and 'authorization_pending' retry semantics are never exercised). (c) grant_type=create_account also lands in the fallback, which happens to create-or-fetch by deviceId — acceptable behaviorally.
Handler: DorkNet.Server\Controllers\Auth\AuthController.cs:92
Fix. In AuthController.Token add explicit branches: login_token → validate against the remote_login_token PlayerSettings row for the supplied account_id and issue that account's pair; device_code → look up the deviceauthorization:{device_code} row, return {error:"authorization_pending"} (HTTP 400) until RemoteAuth marks it approved, then issue the approving account's pair.
GET platformid/{0} — MISSING (degraded)
GET /platformid/{accountId} is not registered anywhere (server-routes.txt has no bare 'platformid/' entry; no catch-all remains) → 404, and platform-native profile/invite actions from BaseAccountModel reject. The nearest route, GET /account/v1/{accountId:long}/platformid (AccountsController.cs:684-685), is BOTH the wrong path AND the wrong shape — it returns the object { PlatformId = 0L } where the client expects a BARE JSON string, and its hardcoded 0 marks it a stub besides.
Fix. Add [HttpGet("/platformid/{accountId:long}")] returning Content(JsonSerializer.Serialize(player.LastPlatformId ?? ""), "application/json") — a bare JSON string of the real stored platform id. Fix the /account/v1/.../platformid stub to return the real value too.
GET account/{0}/clubs — SHAPE_MISMATCH (cosmetic)
GET /account/{playerId:long}/clubs exists with a real ClubsForPlayerAsync query. ToWireClub (ClubsController.cs:937-953) emits PascalCase ClubId, Name, Description, MainImageName, State, CreatorAccountId, Category, Visibility, Joinability, AllowJuniors, MemberCount, IsRRO, ClubhouseRoomId, ClubType — all registered by FOIJDINBPFG's reader. Two registered keys are never emitted: MinLevel (Int32 → defaults 0, harmless) and ClubChatEnabled (Boolean → defaults false, so the profile Clubs tab renders every club with chat disabled).
Handler: DorkNet.Server\Controllers\Clubs\ClubsController.cs:318
Fix. Add MinLevel = 0 (or the entity value) and ClubChatEnabled = true/entity value to ToWireClub in ClubsController.cs.
PUT api/players/v4/current/contact — VERB_MISMATCH (cosmetic)
Server registers only [HttpGet] on /api/players/v4/current/contact; the 2023 client PUTs it (query 'email' + JSON prefs body) right after POST account/me/email → 405. The client discards the body (fire-and-forget) so the only visible symptom is the 'Failed to update player marketing email prefs' log line and the prefs never persisting.
Handler: DorkNet.Server\Controllers\API\Players\V2\PlayersController.cs:24
Fix. Add [HttpPut("/api/players/v4/current/contact")] (can share the action or a new no-op that returns 200; optionally persist the email query param / pref booleans).
POST cachedlogin/migrate — SHAPE_MISMATCH (cosmetic)
REQUEST field mismatch: client sends 'legacyPlatformId' + 'newPlatformId' (LBNJFPOLCDL.txt:4469/:4479); server binds [FromForm] platform, platformId → binds 0/null and TagPlatformAsync writes nothing meaningful. No migration happens. Cosmetic for the PC preservation target since the caller is IOSPlatformManager (mobile first-launch id change), but the handler is effectively a silent no-op for this client.
Handler: DorkNet.Server\Controllers\Auth\AuthController.cs:342
Fix. Bind [FromForm(Name="legacyPlatformId")] and [FromForm(Name="newPlatformId")]; re-tag rows whose LastPlatformId == legacyPlatformId to newPlatformId.
Matchmaking, sessions and presence
matchmaking-session
All 20 client routes have real, verb-matching handlers on DorkNet (GoToController / MatchController / MatchPlayerController) and every matchmake/* response uses the camelCase MatchmakingResponseDto/RoomInstanceDto shape (GoToController.cs:1529-1634) that carries every key the 2023 client reads, plus the heartbeat PresenceDto with isOnline and appVersion "20230317" (MatchPlayerController.cs:485-511). No missing endpoints and no response-shape defects that break gameplay. Verified defects: (1) player/notifydisconnect binds 2020's otherPlayerId instead of 2023's PlayerId/RoomInstanceId form keys (log-only, cosmetic); (2) player/photonregionpings parses 2020's region=/ping= pair arrays, so 2023's region-name-keyed fields are silently dropped and pings are never recorded (degraded); (3) the three preference writers (statusvisibility, vrmovementmode, avoidjuniors) are shape-correct but discard the value — avoidjuniors GET is hardcoded false so the toggle resets every session (degraded stubs); (4) MaxPersistenceVersion, sent on every matchmake/* request, is bound nowhere in the repo (0 grep hits) — the server relies on the global admin clamp toggle instead of the client-declared cap; (5) ClientJoinData/AdditionalPlayersAutoFollow on sub-room hops and SubRoomId on code joins are ignored (spawn-target fidelity loss); (6) unknown-room paths at GoToController.cs:288/:324 emit "roomInstance": null (no null-ignore in serializer, ServiceCollectionExtensions.cs:380) while the sibling club/code handlers send an empty object — the 2020 deserializer the file's own header documents throws on non-object roomInstance; 2023's reflective reader behavior is UNKNOWN, cheap to make consistent.
Client-side notes. TRANSPORT/HOST: Every route in this subsystem is issued against the matchmaking host: BNDIAONDFFF..ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, string route) with host enum value 3 at every call site (BNDIAONDFFF.txt:74 for the signature; host base URLs come from the nameserver response — PAPLNIPKAMG has a NestedType_NameServerResponse). Verb enum is BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT — decoded from the rdx immediate at each ctor call. Params added via BNDIAONDFFF.AFGEDDANEKP are sent as an application/x-www-form-urlencoded (or multipart when binary present) BODY on POST/PUT via HTTPFormBase.AddField (BNDIAONDFFF.txt:3032,3099), and as a ?k=v&k=v QUERY string only on body-less verbs (the "?"/"&" concat path, BNDIAONDFFF.txt:3444-3489). DorkNet's [FromForm] bindings therefore match. CENTRAL SENDER: All matchmake/* routes except matchmake/none funnel through Matchmaking.HHJFFJEHLOB (Matchmaking.txt:11330, original name GoToHelper): POST, always adds BypassMovementModeRestriction=bool, LoginLock=string, MaxPersistenceVersion=Int32 (from GJGGDBDBCMJ — clamp-relevant, see room-version-clamp work), repeated AdditionalPlayerIds=Int32; a per-route Action closure adds JoinMode / ClientJoinData / AdditionalPlayersAutoFollow / SubRoomId. Response: JSON -> Matchmaking/PCFNLCMMGKB {errorCode:HMIBNIEBEKK enum, roomInstance:HCHDEHIGEBE} -> mapped by <>c.b__114_2 to the HMIBNIEBEKK error value the UI consumes ('Matchmaking request failed ({0}): {1}' on failure). The exact JSON key casing of PCFNLCMMGKB/HCHDEHIGEBE is reflection/attribute-driven and NOT recoverable from ISIL; the camelCase errorCode/roomInstance shape served by DorkNet (GoToController.cs:1529-1620) is verified working live against this exact build (matchmake/dorm boot, room joins, sub-room hops all functioning on the march-2023 branch), so treat it as the wire truth. 2023-vs-2020 MISMATCHES FOUND (both benign, responses ignored, but fix for fidelity): (1) player/notifydisconnect — 2023 sends PlayerId + RoomInstanceId form keys; server binds 2020's otherPlayerId so the logged id is always null (MatchPlayerController.cs:117). (2) player/photonregionpings — 2023 sends one form field per region keyed by REGION NAME (us=42&eu=90...), not the region=/ping= pair arrays the server parses (MatchPlayerController.cs:239+); 2023 pings are silently dropped. Also note verb: 2023 uses PUT here (server already accepts GET/POST/PUT). HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING "20230317" or the client reports presence out-of-sync / version mismatch. The compare (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): no cached presence = always resync; cached roomInstance null OR RoomInstanceId < 0 (the offline room, HCHDEHIGEBE.GIICPDHJOOH) = in sync only with roomInstance null; otherwise in sync only when HCHDEHIGEBE.DCIJHIPJHOB finds every field equal. The compare is skipped while TryGoToOfflineRoom's matchmake/none is in flight (static flag +0xF8). A PresenceUpdate push about the LOCAL player runs the same apply path (EGPABEDPEJO -> FLAOOBNJAAD), so presence pushes must never go to their subject. avoidjuniors GET must return a bare JSON boolean primitive. COVERAGE: all 20 routes in groups.json are real HTTP routes, all are already registered on the DorkNet server (server-routes.txt:812-847); no missing endpoints, only the two request-binding mismatches above.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| POST | matchmake/chatinvite/{0}/{1} |
route args {0},{1}=the two Int64 params (both boxed as Int64 for String.Format); the Int32 third param was not observed being sent on the wire (purpose UNKNOWN); form body: standar | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} |
| POST | matchmake/club/{0} |
route arg {0}=Int64 clubId (unboxed from ObscuredLong on the club DTO); form body: standard sender params only (no per-route closure) | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; server additionally sets a clubId on the roomInstance for the club UI |
| POST | matchmake/code/{0}/{1} |
route args {0}=Int64 roomId, {1}=string code (custom matchmaking/room code); form body: SubRoomId=Nullable (omitted when null) + standard sender params | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; server uses errorCode 40 for a bad/unknown code |
| POST | matchmake/dorm |
form-urlencoded body (POST params go through HTTPFormBase.AddField): BypassMovementModeRestriction=bool, LoginLock=string (session GUID), MaxPersistenceVersion=Int32, optional repe | {"errorCode": Int32 (0=OK, 4=RoomDoesNotExist, 26=RoomInstanceIsPrivate...), "roomInstance": {"roomInstanceId":Int64, "roomId":Int64, "subRoomId":Int64, "location":string GUID, "photonRegionId":string, "photonRoomId":str |
| POST | matchmake/event/{0} |
route arg {0}=event id from the HPIOAGDJHDH event DTO; form body: JoinMode=Int32 + standard sender params | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} |
| POST | matchmake/instance/{0} |
route arg {0}=Int64 roomInstanceId; form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} for that specific instance |
| POST | matchmake/invite/{0} |
route arg {0}=first Int64 param (invite id); the second Int64 (roomId) is NOT in the route and no extra form key was observed — form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} pointing at the inviter's instance |
| POST | matchmake/none |
form body: LoginLock=string only | Body IGNORED — client sends via BNDIAONDFFF.FPCPAJAAHME (raw Task) and only error-status matters. 200 with any/empty body is fine (DorkNet returns a full MatchmakingResponseDto, which is harmless). |
| POST | matchmake/player/{0} |
route arg {0}=Int32 accountId (player account ids are Int32 in this build); form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; must mirror the target's live roomInstanceId/photonRoomId for the follow-join to land in the same Photon shard; errorCode 26 (RoomInstanceIsPrivate) when |
| POST | matchmake/room/{0} |
route arg {0}=Int64 roomId; form body: JoinMode=Int32 (0 join shared instance / 1 new public / 2 new private) + standard sender params BypassMovementModeRestriction=bool, LoginLock | same MatchmakingResponse JSON as matchmake/dorm: {"errorCode":Int32, "roomInstance":{...camelCase RoomInstance...}} |
| POST | matchmake/room/{0}/{1} |
route args {0}=Int64 roomId, {1}=Int64 subRoomId; form body: JoinMode=Int32, ClientJoinData=string (UnityEngine.JsonUtility.ToJson of RecNet.ClientJoinData — carries the optional s | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} — server must MUTATE roomInstanceId/photonRoomId/subRoomId/location/dataBlob for the target sub-room or the client's transition state machine never fires |
| GET | player/avoidjuniors |
GET: none. PUT (same route, verb enum 3): form body avoidJuniors=bool (boxed Boolean, camelCase key verbatim). | GET: bare JSON primitive boolean (true/false) — typed send BNDIAONDFFF.FDKKOPAPDGF; returning [] or an object breaks it ('Failed to get avoid juniors status'). PUT: body ignored (KDOPJCNKOOK). |
| POST | player/exclusivelogin |
form body: LoginLock=string, TakeOverExclusiveSession=bool | Body unused; status-driven: 409 => already-logged-in-elsewhere path ('This account is already logged in somewhere else!'), 400 => 'Unable to login (code: 4/5)'. 200 = success. |
| POST | player/heartbeat |
form body: LoginLock=string (both variants) | Presence JSON deserialized into BDDJOGCKIJO and compared with local state. DorkNet serves (verified live with this build): {"playerId":Int32, "statusVisibility":Int32, "deviceClass":Int32, "vrMovementMode":Int32, "roomIn |
| POST | player/login |
form body: LoginLock=string (client-generated session GUID, static Matchmaking field) | Body unused. Client branches purely on HTTP status: 409 => 'This account is already logged in somewhere else!', 400 => generic 'Unable to login'. Return 200 with empty/any body for success. |
| POST | player/logout |
Built directly: base match-host URL + 'player/logout', BestHTTP verb enum 2 (POST), Dictionary<string,string> form field LoginLock=string | Body ignored; fire-and-forget teardown. 200 empty is fine. |
| POST | player/notifydisconnect |
form body: PlayerId=Int32 (the remote player who vanished), RoomInstanceId=Int64 (current room instance, read from HCHDEHIGEBE field +16). NOTE: keys are PascalCase 'PlayerId'/'Roo | Body ignored (plain Task via BNDIAONDFFF.KDOPJCNKOOK); 200 empty is fine. Error log 'Failed to request remote player disconnect'. |
| PUT | player/photonregionpings |
form body: one field per measured region, key = region name string (e.g. us, eu, asia — the dictionary KEY is passed directly as the field name), value = Int32 ping ms. NOTE: this | Body ignored (KDOPJCNKOOK); 200 empty fine. Error log 'Failed to upload photon region pings'. |
| PUT | player/statusvisibility |
form body: statusVisibility=Int32 (enum boxed as Int32; camelCase key verbatim) | Body ignored (KDOPJCNKOOK plain Task); 200 empty fine. Error log 'Failed to set player status visibility'. |
| PUT | player/vrmovementmode |
form body: vrMovementMode=Int32 (enum boxed as Int32; camelCase key verbatim) | Body ignored; 200 empty fine. Error log 'Failed to set VR movement mode'. |
Defects
PUT player/photonregionpings — SHAPE_MISMATCH (degraded)
Verb is fine (POST/PUT/GET all mounted, lines 239-241), but the parser reads 2020's repeated pair shape Request.Form["region"] / Request.Form["ping"] (lines 248-253). The 2023 client sends ONE form field per measured region, keyed by the region NAME with the ping as the value (us=42&eu=90...), so both arrays come back empty, pairs.Count stays 0, and presence.SetPhotonRegionPings is never called. Consequence: 2023 clients' region pings are silently dropped — the matchmaker's lowest-ping region bias never applies to them and instance creation always falls back to the configured default region. Client ignores the response, so nothing breaks visibly.
Handler: DorkNet.Server/Controllers/Match/MatchPlayerController.cs:239
Fix. In PhotonRegionPings, after the legacy pair parse, fall back to iterating Request.Form directly: for each (key, value) where key is not 'region'/'ping' and int.TryParse(value) succeeds, treat key as the region name and the value as the ping (same 0-2000 clamp), then feed the merged dictionary to presence.SetPhotonRegionPings.
PUT player/statusvisibility — STUB (degraded)
Verb and form binding are correct ([HttpPut], [FromForm(Name="statusVisibility")] — exact camelCase key the client sends), and the client ignores the response body, so no wire breakage. But the value is only echoed back and never persisted: no presence/db write exists, and Heartbeat/SetVrMovementMode hardcode StatusVisibility = 0 (lines 161, 193). The player's 'who can see my online status' choice is discarded — other players always see them as visible-to-Everyone, and the setting silently reverts.
Handler: DorkNet.Server/Controllers/Match/MatchPlayerController.cs:168
Fix. Persist the value per player (e.g. a field on PlayerPresenceService state or a Players column), read it back in Heartbeat/SetVrMovementMode responses, and honor it in the friend-presence read path.
Status: FIXED. Persisted as PlayerSettings pref:statusVisibility and echoed on heartbeat (earlier change). The enum is Public=0 / FriendsOnly=1 / FavoriteFriendsOnly=2 / Offline=3 (dump.cs IPLLHDINPBL), set from the profile's "Set Online Status Visibility" dialog. GET player?{ids} (PlayerPresenceController) now reports the stored value — to self as well, which the dialog's current selection depends on — and reports a hidden player exactly like an offline one (isOnline=false, roomInstance=null): Offline to everyone but self, FriendsOnly to non-friends (global-friends toggle honored), FavoriteFriendsOnly to callers the player has not favorited. Still open: the SignalR PresenceUpdate push (NotificationService.PlayerPresenceChanged) hard-codes statusVisibility 0 and does not filter recipients, so an appear-offline player's room changes still reach friends live.
PUT player/vrmovementmode — STUB (degraded)
Same pattern as statusvisibility: correct verb and camelCase form key 'vrMovementMode', client ignores the body, but the value is echoed and dropped — Heartbeat always reports vrMovementMode: 0 (line 163). If any server-side matchmaking logic is ever meant to gate on movement mode (the client sends BypassMovementModeRestriction on every matchmake/* call, implying the retail server matched comfort modes), it has no data to work from.
Handler: DorkNet.Server/Controllers/Match/MatchPlayerController.cs:184
Fix. Persist per player alongside statusVisibility and echo the stored value from Heartbeat.
GET player/avoidjuniors — STUB (degraded)
Shape is exactly right — GET returns a bare JSON boolean primitive (ActionResult Ok(false)) which the client's typed FDKKOPAPDGF reader requires, and PUT/POST (lines 219-223) binds the camelCase 'avoidJuniors' form key. But GET is hardcoded false and PUT discards the value (the code comment admits 'Stub for now'), so the 'avoid junior players' toggle appears to work in-session (the client caches its own write in a static) and then silently resets to Off on the next boot when the client re-reads it. Matchmaking also never actually avoids juniors since nothing is stored.
Handler: DorkNet.Server/Controllers/Match/MatchPlayerController.cs:211
Fix. Store the flag per player (Players table column or presence-service map), return the stored value from GetAvoidJuniors, and write it in SetAvoidJuniors. Optionally consult it in ApplyNewInstanceAsync/instance selection for actual junior-avoidance semantics.
POST matchmake/* (cross-cutting): MaxPersistenceVersion form field — SHAPE_MISMATCH (degraded)
The central sender adds MaxPersistenceVersion=Int32 to EVERY matchmake/* request (the client declaring the newest room-blob persistence version it can read), but a repo-wide grep for 'MaxPersistenceVersion' over *.cs returns zero hits — no handler binds it. The server instead relies on the global admin 'room version clamp' toggle (commits f5b5c14/d944a8f) to avoid handing 2023 clients blobs they can't parse. If the toggle is off, or a future mixed-version fleet connects, the server can serve a dataBlob whose persisted room version exceeds the requesting client's cap, triggering the 'update your client' room gate (room-header source; the CircuitsV2 graph-version source remains unfixable regardless).
Fix. Bind [FromForm(Name = "MaxPersistenceVersion")] int? in the matchmake handlers (or read it centrally in BuildResponseAsync via Request.Form) and clamp/normalize the served dataBlob per-request against the client-declared cap, making the per-request value the primary signal and the admin toggle the fallback.
POST matchmake/room/{0} and matchmake/room/{0}/{1} (error path): roomInstance null vs empty object — UNKNOWN (degraded)
The unknown-room branches return RoomInstance = null (MatchmakeRoom line 288, MatchmakeSubRoom line 324, GoToRoom line 91), and with no null-ignore condition configured (ServiceCollectionExtensions.cs:380-381 sets only PropertyNamingPolicy=null) the wire carries "roomInstance": null. GoToController's own header comment (lines 20-23, from the 2020 disassembly at RVA 0x1447DD0) documents that MatchmakingResponse.Deserialize THROWS 'Matchmaking response has no RoomInstance field' when the key is missing or non-object. The 2023 client uses a reflective attribute-driven reader (per the inventory) that likely tolerates null, but this is UNVERIFIED — and the sibling error paths (GoToClub :450, GoToCode :493/:506, GoToPlayer :887) all send an empty RoomInstanceDto object instead, so the codebase is internally inconsistent. If the 2023 reader shares the strict behavior, the friendly 'room not found' toast becomes a client-side deserialization exception.
Handler: DorkNet.Server/Controllers/Match/GoToController.cs:288
Fix. Change the three null-returning branches to RoomInstance = new RoomInstanceDto() to match the club/code/player error paths — one-line changes at GoToController.cs:91, :288, :324; alternatively verify the 2023 PCFNLCMMGKB reader tolerates null from the decompiled tree first.
POST player/notifydisconnect — CASING_MISMATCH (cosmetic)
Handler binds [FromForm(Name = "otherPlayerId")] — the 2020 wire shape. The 2023 client sends PascalCase 'PlayerId' (Int32) plus 'RoomInstanceId' (Int64), so otherPlayerId binds null on every 2023 call and the log line records other=null; RoomInstanceId is dropped entirely. Functionally harmless: the handler only logs, returns bare 200, and the client ignores the response — but the endpoint's diagnostic purpose (presence GC visibility) is defeated for 2023 clients.
Handler: DorkNet.Server/Controllers/Match/MatchPlayerController.cs:113
Fix. In MatchPlayerController.NotifyDisconnect add [FromForm(Name = "PlayerId")] int? playerId2023 and [FromForm(Name = "RoomInstanceId")] long? roomInstanceId, coalesce otherPlayerId ?? playerId2023 for the log, and log/use the instance id (e.g. presence GC of the reported player's row when it matches).
Rooms, sub-rooms and room browsing
rooms
Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem against DorkNet (branch march-2023). 14 routes are fully OK — notably the boot/save-critical paths: GET rooms/{id} (bare + roomserver, BuildRoomServerDetails), subroom data commit (envelope {success,value:{Room,SubRoomDataSave}}), subroom saves (paged), room clone, recommendations/similar/bulk/browse lists, filters, verifyRole, and the matchmaking instances/inprogress/reportjoinresult trio. The defects cluster into five systemic causes. (1) roomserver/ prefix gaps: live traffic (docs/recroom-2023-room-save.md:83-93) proves every NLDBPDCNNCF request arrives prefixed 'roomserver/', but ~20 routes are registered bare-only — name, description, image, tags, warning, restrictions, cloning, automute, comments, voice_chat_encryption, modify, all bans routes, roles (all 3), interactionby/me/cheer+favorite, playerdata/me, subrooms POST/DELETE, rooms/base, rooms/hot, rooms/search, rooms/rro_ids, featuredrooms/current — each is a silent 404 for the 2023 client (settings don't save, moderation broken). (2) Wrong mutation response shape: ApplyAndReturn and the subroom mutations return slim ToWireRoom/SceneWire objects, but the client re-parses full FGCPNAACHIK details after EVERY mutation and NREs on the missing SubRooms/Roles/Tags/Stats (the repo already fixed exactly this for clone — one ApplyAndReturn change fixes ~15 routes). (3) [FromBody] vs x-www-form-urlencoded: the client sends form bodies everywhere; handlers with [FromBody] params 415 before running (mechanism empirically documented in the repo itself), and several also read the wrong keys (tags: tag/autoTag vs Tags; automute: disable; warning: warningMask; restrictions: 4 supports* bools; bans: repeated id+banMask; subroom maxplayers/modify/move/accessibility: Value/NewIndex vs maxPlayers/newRoomId/accessibility; move even has wrong semantics — reorder vs move-to-other-room). (4) Verb gaps: roominstance markprivate (POST-only, client PUTs) and roomCode (GET/POST-only, client PUTs custom code), DELETE rooms/{id} (admin-prefixed only), DELETE loadscreen (absent). (5) Stubs: playerdata/me GET hardcodes Data="CAE=" with no PUT at all (CV2 per-room player persistence dead), comments/voice_chat_encryption are acknowledged no-ops, subroom permissions matrix is never stored. Shape-only extras: magic_door lacks the {Room,RefreshesAt,RefreshIntervalMinutes} wrapper; bans GET uses BannedPlayerId/CreatedAt instead of AccountId/BannedByAccountId/BanStartTime; featuredrooms Rooms rows lack RoomName; promo_external stores Type as string and its DELETE reads the wrong keys. rooms/requiring/{mode} element type remains UNKNOWN on the client side (server returns names).
Client-side notes. CROSS-CUTTING FINDINGS (rooms subsystem, client 2023-03-21):
-
VERB ENCODING: every request is a BNDIAONDFFF request object; its ctor (0x1830036A0) takes BestHTTP.HTTPMethods in rdx (0=GET, 2=POST, 3=PUT, 4=DELETE) and the route string in r9. In the NLDBPDCNNCF thin wrappers the verb shows up as the 'Move r8, N' immediately before the shared send helper. All verbs above were read off these constants, not guessed.
-
BODY ENCODING: mutating endpoints add fields via BNDIAONDFFF.AFGEDDANEKP(key, boxedValue) = x-www-form-urlencoded (verified against live traffic for clone: 'name=...' form body, docs/recroom-2023-room-save.md). The ONE JSON-body rooms endpoint is POST rooms/{id}/subrooms/{id}/data, which sets a raw serialized body via BNDIAONDFFF.FJLLPHFOOJJ and adds an X-On-Behalf-Of header. List-valued form fields repeat the key (id=..&id=.., tag=..&tag=..).
-
RESPONSE KEY CASING: every RecNet.Runtime DTO reader (files with 'DTO JFCMHHFNDFE' methods) matches each key against three literals — PascalCase, camelCase, all-lowercase — so PascalCase server output is always safe for these DTOs. The full DTO→reader→keyset map is saved at the audit working set (291 DTOs).
-
STRICTNESS: every room mutation (name/description/accessibility/roles/subroom ops/clone/...) expects the FULL FGCPNAACHIK room-details object back, and the client dispose-walk NREs on missing nested objects (Stats, SubRooms, Roles, Tags) — a partial response makes the UI show failure even though the server persisted. RankingContext is parsed by LHGPEEIOPHN as an opaque STRING (String getter/setter only) — sending a JSON object for it broke every room-details parse (see DorkNet.Server.Tests/RoomSave2023Tests.cs comment re
befd590revert). -
HOST/PREFIX: NLDBPDCNNCF targets the rooms host; the same routes are also issued with a 'roomserver/' path prefix depending on service config (verified for clone in docs/recroom-2023-room-save.md). DorkNet serves every bare route in this subsystem (diffed against the server's reflected route table — zero bare gaps), but these roomserver/ twins are NOT registered and are potential silent-404s if the client picks the prefixed base: rooms/base, rooms/hot, rooms/search, rooms/rro_ids, rooms/{id}/{name,description,image,tags,warning,restrictions,cloning,comments,voice_chat_encryption,automute,modify}, rooms/{id}/roles[...], rooms/{id}/bans[...], rooms/{id}/interactionby/me/{cheer,favorite}, rooms/{id}/playerdata/me.
-
MATCHMAKING-SERVICE ROUTES: room/{0}/instances, roominstance/{0}/* and rooms/requiring/ are issued by RecNet.Matchmaking with service id 3 (r8=3 in the BNDIAONDFFF ctor) — the matchmaking host, not the rooms host. RoomInstance payloads elsewhere in matchmaking deserialize via HCHDEHIGEBE (reader NHGOFGABJIN.txt line 2590: ClubId, EncryptVoiceChat, EventId, IsFull, IsInProgress, IsPrivate, Location, MaxCapacity, Name, PhotonRegionId, PhotonRoomId, RoomCode, RoomId, RoomInstanceId, RoomInstanceType, SubRoomId).
-
NON-ROUTES: 7 of the 82 literals are not HTTP routes — hot_rooms/…, magic_door/{0}, my_visited_rooms/…, roominteraction/{0}, roomsbycreators/…, search_rooms/…, visited_rooms/… are cache keys minted by the string-returning methods of IBEOONPEELF_NestedType_GGDPFFDEDKN.txt, and 'room/' is a share-code deeplink prefix (RoomCode_NestedType_IDKJONDCJKN.txt).
-
UNKNOWNS: rooms/requiring/{mode} response element type could not be resolved from the ISIL metadata token (stored internally as a restricted-rooms list; server already implements rooms/requiring/{restriction}). The exact wire type of BNLPBMJJOMM.Value (subroom permissions) is a variant read through the shared value reader — keys are certain, scalar type is not.
Sub-room ids, IsRRO and the instance browser
These cut across the whole rooms surface.
-
Every room and playlist mutation answers
{success, value, error}. They all go through one generic helper,NLDBPDCNNCF.BDBHCEGKGDC<TResult>(opName, verb, route, …)(RVA 0x35E50A0,Call 0x1835E50A0at 47 call sites in NLDBPDCNNCF.txt). It is the same helper and the sameBDBHCEGKGDC<FGCPNAACHIK>instantiation token ([0x1884C1948]) that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and clone is proven in game to need the envelope. A bare room-details body has nosuccess; the helper rejects it with no message, and the player sees "Exception of type 'CEMNLBKJABA' was thrown." (CEMNLBKJABA is the RecNet request exception, base BCPHBJHOIEM : Exception, built with a null message) although the server applied the change. This is what broke the co-owner invite in game after the role fixes. Routes: rooms/{0}/{accessibility, allow_new_users, automute, cloning, comments, creator, description, image, loadscreen, max_player_calculation_mode, min_level, modify, name, promo_external, promo_images, restrictions, tags, voice_chat_encryption, warning}, rooms/{0}/roles/{1} and /invite, rooms/{0}/subrooms (POST), rooms/{0}/subrooms/{1} (DELETE) and its accessibility, clone, maxplayers, modify, move, name, permissions, publish_save, plus playlists (POST) and playlists/{0}/{accessibility, description, image, levelvoting, name, restrictions, rooms/{1}, tags, warning}.valueis the full FGCPNAACHIK (playlist DTO for playlists). Refusals are HTTP 200 withsuccess:falseand a message inerror, since a 403/404 also shows only the message-less exception. Server:Controllers/RecNetEnvelopeAttribute.cson each handler. Wherever a row below says "FGCPNAACHIK full room details" for one of these routes, read it as the envelope'svalue. -
Sub-room ids are OrderIndex + 1 on the wire. The client refuses to load a sub-room whose id is not positive:
SubroomModel.DOKCPLJOKIE(Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) isRoomId > 0 && SubroomId > 0, andModel1.LNPEMMMGCPCskips the load when it fails. With the first sub-room sent as 0, its Room Settings > Subrooms row never fetched its details and showed no name, and every row action ran against a null sub-room. The wire id is nowRoomSceneEntity.OrderIndex + 1in the room detailsSubRooms[], the sub-room routes (rooms/{0}/subrooms/{1}/*),matchmake/room/{0}/{1}, the room-instance JSON (RoomInstanceDto.subRoomId), the instance browser and the sub-room save DTOs; incoming 0 is accepted as the first sub-room. Storage (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the OrderIndex. SeeDorkNet.Server/Services/RoomSceneIds.cs`. -
Every room owns a real, named entry sub-room. Cloning a template that has no RoomScenes rows now creates a "Home" row from the clone's own location, blob and capacity, and a startup pass (
RoomService.RepairRoomScenesAsync) adds the row to existing rooms that lack one and names blank rows ("Home", "Subroom N"). -
IsRROis false for every player-owned room. RoomModel's owner UI — get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom, CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning, CanChangeLoadingScreen, CanCreateNewSubroom (RoomModel.txt:275, 429, 560, 682, 1002, 1473, 1680, 1816) — requires!IsRRO, so a clone of a Rec Room Original template advertised as RRO could not be published ("Cannot publish room") or have its settings changed. The load pipeline (RecRoom.RoomLoading.Runtime) never reads the flag.RoomService.WireIsRRO= AG room still owned by the system account;RoomEntity.IsAGRoomkeeps its server-side meaning. -
Publish preconditions are client-side. Besides
!IsRRO, the client requires role ≥ CoOwner, not a dorm, a valid name (2–128 chars) and a room image other thanDefaultRoomImage.jpg("Cannot publish room: Missing room photo") before it sendsPUT rooms/{0}/accessibility accessibility=1. -
Room settings are creator or accepted co-owner. Sub-room mutations (name, max players, accessibility, modify, add, delete, duplicate, restore, permissions) used to be creator-only and the room and sub-room renames also let moderators in; all now use the same creator-or-co-owner gate as the bare room mutations.
-
room/{0}/instances(Matchmaking.KAIPGDGACGG, Matchmaking.txt:13966, verb 0) is shown unfiltered by the client (RoomInstancePagedGrid, current instance first, then oldest CreatedAt), which derives PlayerCount from PlayerIds and SubroomName from the room's SubRooms by SubRoomId. The server used to return nothing at all on Redis deployments (no per-room index), dropped private instances from presence and re-added only the caller's own with no players, counted crashed clients, and always sent IsFull false. It now lists live instances from a per-room presence index, with the real players (live activity marker only), IsFull from capacity, and private instances for the people allowed into them.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| GET | api/rooms/v1/filters |
none | AKCLLEJNFFD object: {"PinnedFilters": [String], "PopularFilters": [String], "TrendingFilters": [String]} — reader iterates exactly these 3 List fields; accepts PascalCase/camelCase/lowercase variants of each key |
| POST | api/rooms/v1/verifyRole |
x-www-form-urlencoded fields: roomId (Int64, current room from Matchmaking ObscuredLong), role (Int32 enum), context (String). Keys are camelCase. | none parsed — client ignores the body; return 200 |
| POST | api/rooms/v2/report |
x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 |
| GET | featuredrooms/current |
none | JBHJKCCFCIO: {"FeaturedRoomGroupId": Int64, "Name": String, "Rooms": [COGJIOGPNGD {"ImageName": String, "RoomId": Int64, "RoomName": String}]} (casing-tolerant) |
| GET | room/{0}/instances |
none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64 (wire id, see "Sub-room ids" below), "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": UTC DateTime string}] (casing-tolerant). Live instances only (at least one player with a live activity marker); private instances only for the creator, accepted role holders, the instance owner, its invitees and players inside |
| PUT | roominstance/{0}/inprogress |
x-www-form-urlencoded field: inProgress (Boolean) | none parsed ('Failed to modify current room's game-in-progress state' on error); return 200 |
| PUT | roominstance/{0}/markprivate |
none (empty body) | none parsed ('Failed to mark room instance {0} as private'); return 200 |
| POST | roominstance/{0}/reportjoinresult |
x-www-form-urlencoded field: result (Int32 enum MEIJCIADDHJ) | none parsed ('Failed to report room join result'); return 200 |
| PUT | roominstance/{0}/roomCode |
x-www-form-urlencoded fields: roomCode (String), forceChange (Boolean) | String (the accepted custom room code) — FGLDKEJLAKB promise |
| GET | rooms/base |
none | [NEMINAEBALC] — room summary, keys (PascalCase, casing-tolerant): RoomId Int64, Name String, Description String, ImageName String, CreatorAccountId Int64, CreatedAt DateTime, Accessibility Int32 enum, MaxPlayers Int32, M |
| GET | rooms/bulk |
query params: repeated id (Int64) for the id overload; repeated name (String) for the name overload | [NEMINAEBALC] (same keyset as rooms/base) |
| GET | rooms/cheeredby/me |
none | [NEMINAEBALC] |
| GET | rooms/contestwinners |
none | [NEMINAEBALC] |
| GET | rooms/createdby/me |
none | [NEMINAEBALC] |
| GET | rooms/createdby/{0} |
none (accountId in path) | [NEMINAEBALC] |
| GET | rooms/curated_playlists |
none | [Int64] — bare array of curated playlist ids |
| GET | rooms/favoritedby/me |
none | [NEMINAEBALC] |
| GET | rooms/fromcreators |
query params: repeated id (Int32 creator account ids), skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET | rooms/hot |
query params: repeated tag (String), skip (Int32), take (Int32) | FFCPFGBNLHN paged object: {"Results": [NEMINAEBALC], "TotalResults": Int32} |
| GET | rooms/magic_door |
query param: partySize (Int32) | GAPJAGDNFEO: {"Room": NEMINAEBALC, "RefreshesAt": DateTime string, "RefreshIntervalMinutes": Int32} |
| GET | rooms/moderatedby/me |
none | [NEMINAEBALC] |
| GET | rooms/ownedby/me |
none | [NEMINAEBALC] |
| GET | rooms/ownedby/{0} |
none | [NEMINAEBALC] |
| GET | rooms/recommendations |
none | [OPGDCAELKMJ {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]}] — list of seed-room groups, NOT a flat room list |
| GET | rooms/requiring/ |
none — mode enum name lowercased and concatenated onto the path: rooms/requiring/{mode} | Restricted-rooms list stored internally ('Failed to get restricted rooms list: ' on error). Element type not resolvable from ISIL metadata token — UNKNOWN (DorkNet serves rooms/requiring/{restriction}); likely an array o |
| GET | rooms/rro_ids |
none | [Int64] — bare array of Rec Room Original room ids |
| GET | rooms/search |
query params: query (String), skip (Int32), take (Int32) | FFCPFGBNLHN: {"Results": [NEMINAEBALC], "TotalResults": Int32} |
| GET | rooms/topcreators |
none | [NEMINAEBALC] |
| GET | rooms/visitedby/me |
query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET | rooms/visitedby/{0} |
query params: skip (Int32), take (Int32) | [NEMINAEBALC] |
| GET (2 overloads) + DELETE | rooms/{0} |
GET summary: none. GET details: query params include (Byte bitmask), unityAssetTarget (String), unityAssetVersion (Int32) from closure CAEBODLFAMA. DELETE: none. | GET summary → NEMINAEBALC. GET details → FGCPNAACHIK = NEMINAEBALC keys PLUS: "DataBlob" String, "DataBlobHash" String, "LoadScreens": [{"ImageName","Title","Subtitle" all String}], "PromoImages": [String], "PromoExterna |
| PUT | rooms/{0}/accessibility |
x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL: Private 0, Public 1, Unlisted 2). This is the "Publish room" button (RoomModel.TryPublishRoom → DBKKLDIPCHP(1)); unpublish sends 0 | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE |
| PUT | rooms/{0}/allow_new_users |
form field: allowNewUsers (Boolean) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/automute |
form field: disable (Boolean) — NOTE key is 'disable', maps to DisableMicAutoMute | FGCPNAACHIK full room details |
| GET + POST | rooms/{0}/bans |
GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
| POST | rooms/{0}/bans/import |
form field: sourceRoomId (Int64) | none parsed; return 200 |
| DELETE | rooms/{0}/bans/{1} |
form field: banMask (Int32 enum) | none parsed; return 200 |
| POST | rooms/{0}/clone |
x-www-form-urlencoded field: name (String) — new room name | {success, value, error} envelope with value = FGCPNAACHIK FULL room details of the clone (same helper as "save room"; a bare room has no success and is rejected with an empty error). Client 404→empty-body also surfaces as message-less 'Failed to copy room' |
| PUT | rooms/{0}/cloning |
form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/comments |
form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details |
| PUT | rooms/{0}/creator |
form field: accountId (Int32) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/description |
form field: description (String) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/image |
form field: imageName (String — pre-uploaded image blob name) | FGCPNAACHIK full room details |
| GET | rooms/{0}/interactionby/me |
none | CNINIABILDI: {"Cheered": Boolean, "Favorited": Boolean, "LastVisitedAt": DateTime string} (casing-tolerant) |
| PUT + DELETE | rooms/{0}/interactionby/me/cheer |
none (empty body) | none parsed; return 200 |
| PUT + DELETE | rooms/{0}/interactionby/me/favorite |
none (empty body) | none parsed; return 200 |
| PUT + DELETE | rooms/{0}/loadscreen |
PUT form fields: imageName (String), title (String), subtitle (String). DELETE form field: imageName (String). | FGCPNAACHIK full room details |
| PUT | rooms/{0}/max_player_calculation_mode |
form field: maxPlayerCalculationMode (Int32 enum DDIFIKACNBN) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/min_level |
form field: minLevel (Int32) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/modify |
form fields (camelCase): name, description (String), accessibility (Int32 enum), supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR, cloningAllowed, disableMicAut | FGCPNAACHIK full room details |
| PUT | rooms/{0}/name |
form field: name (String) | FGCPNAACHIK full room details |
| GET + PUT | rooms/{0}/playerdata/me |
GET: none. PUT: form field data (String — opaque per-room player data blob). | GET → ILKMFMCOPPO: {"Data": String}. PUT → none parsed. |
| PUT + DELETE | rooms/{0}/promo_external |
form fields: type (Int32 enum OJGHPAELGBO), reference (String) — same keys on both verbs | FGCPNAACHIK full room details |
| PUT + DELETE | rooms/{0}/promo_images |
form field: imageName (String) on both verbs | FGCPNAACHIK full room details |
| PUT | rooms/{0}/restrictions |
form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | FGCPNAACHIK full room details |
| GET | rooms/{0}/roles |
none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant). One row per account; the creator is Role 255 (Creator) |
| GET + PUT | rooms/{0}/roles/{1} |
GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255). Also how an invitee ACCEPTS (own id, role 30) and how anyone leaves their role (own id, role 0) | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. |
| PUT | rooms/{0}/roles/{1}/invite |
form field: role (Int32 enum). Owner invites with 30; the invitee REJECTS by sending role 0 here on their own id | FGCPNAACHIK full room details |
| GET | rooms/{0}/similar |
none | single OPGDCAELKMJ: {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]} |
| POST | rooms/{0}/subrooms |
form field: name (String) — new subroom name | FGCPNAACHIK full room details (with the new SubRooms entry) |
| DELETE | rooms/{0}/subrooms/{1} |
none | FGCPNAACHIK full room details |
| PUT | rooms/{0}/subrooms/{1}/accessibility |
form field: accessibility (Int32 enum) | FGCPNAACHIK full room details |
| POST | rooms/{0}/subrooms/{1}/clone |
none (empty body) | FGCPNAACHIK full room details |
| POST | rooms/{0}/subrooms/{1}/data |
JSON body (raw body via BNDIAONDFFF.FJLLPHFOOJJ, not form): {"UnityAssetId": null|String, "RoomData": {"Filename": String, "Hash": null, "OwnershipProof": null}, "SubRoomData": {" | legacy {success,value,error} envelope; value = NEOPBOMGIOG: {"Room": FGCPNAACHIK (FULL details — must include DataBlob, DataBlobHash, MaxPlayers, ToxmodEnabled, SubRooms/Roles/Tags/Stats or the client NREs in the respons |
| PUT | rooms/{0}/subrooms/{1}/maxplayers |
form field: maxPlayers (Int32) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/subrooms/{1}/modify |
form fields: name (String), accessibility (Int32 enum), maxPlayers (Int32) | FGCPNAACHIK full room details |
| POST | rooms/{0}/subrooms/{1}/move |
form field: newRoomId (Nullable — target room) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/subrooms/{1}/name |
form field: name (String) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/subrooms/{1}/permissions |
raw JSON array (BNDIAONDFFF.FJLLPHFOOJJ, not form) of BNLPBMJJOMM cells, keys from formatter HDOJMEBCAGP: Permission (String), Role (OMMBGJMJJPN), Override (Boolean), Type (FEOJFGECKGH: Bool 0, Int 1, StringList 2), Value (String). The editor sends only the changed cell | FGCPNAACHIK full room details |
| POST | rooms/{0}/subrooms/{1}/publish_save |
form field: subRoomDataSaveId (Int64) | FGCPNAACHIK full room details |
| GET | rooms/{0}/subrooms/{1}/saves |
query params: skip (Int32), take (Int32) | paged object GJDIKIMLCHA: {"Results": [JKIFFPPAJNK {"SubRoomDataSaveId" Int64, "SubRoomId" Int64, "UnityAssetId" String, "DataBlob" String, "DataBlobHash" String, "Description" String, "SavedByAccountId" Int64, "SavedOnP |
| PUT | rooms/{0}/tags |
form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/voice_chat_encryption |
form field: encryptVoiceChat (Boolean) | FGCPNAACHIK full room details |
| PUT | rooms/{0}/warning |
form fields: warningMask (Int32 enum NGPHEBMBPIC), customWarning (String) | FGCPNAACHIK full room details |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
hot_rooms/{0}&skip={1}&take={2}— Cache-key factory for the hot-rooms client cachemagic_door/{0}— Cache-key factorymy_visited_rooms/skip={0}&take={1}— Cache-key factoryroom/— Room share-code / deeplink constructionroominteraction/{0}— Client cache invalidation after cheer/favorite mutationsroomsbycreators/{0}&skip={1}&take={2}— Cache-key factorysearch_rooms/{0}&skip={1}&take={2}— Cache-key factoryvisited_rooms/account_id={0}&skip={1}&take={2}— Cache-key factory
Defects
PUT roominstance/{0}/markprivate — VERB_MISMATCH (breaks-gameplay)
Server registers POST only ([HttpPost] at :326). The 2023 client sends PUT (Matchmaking.txt:15483/15774, ctor verb constant 3=PUT) with an empty body → ASP.NET returns 405 Method Not Allowed, the client logs 'Failed to mark room instance {0} as private', and the host cannot close a public instance to private.
Handler: DorkNet.Server/Controllers/Match/MatchController.cs:326
Fix. Add [HttpPut("/roominstance/{instanceId:long}/markprivate")] to MarkPrivate (keep POST for the 2020 client).
PUT roominstance/{0}/roomCode — VERB_MISMATCH (breaks-gameplay)
Server registers GET+POST only; the 2023 client sets a custom room code via PUT with form fields roomCode + forceChange and expects the accepted code back as a raw JSON string → PUT gets 405 and the host cannot set/change a custom room code. Secondary: the existing handler ignores any submitted roomCode and always returns RoomCodeService.Generate(instanceId), so even after adding PUT the requested custom code would be discarded (the string response shape itself is correct: Content(JsonSerializer.Serialize(code))).
Handler: DorkNet.Server/Controllers/Match/MatchController.cs:363
Fix. Add [HttpPut] to the roomcode route; when the request carries a roomCode form field, validate/uniqueness-check it, persist it against the instance, and return it as a JSON string (honor forceChange).
GET rooms/base — MISSING (breaks-gameplay)
Bare rooms/base exists, but there is NO roomserver/rooms/base registration (routes list has roomserver/rooms — the by-name lookup — which does not match /roomserver/rooms/base). This route is issued by NLDBPDCNNCF whose requests all carry the roomserver/ prefix in this deployment (docs:83-93, RoomsController.cs:439-443), so the 2023 base/AG-rooms list request 404s. Secondary: rows are slim ToWireRoom objects missing NEMINAEBALC keys MaxPlayers/MinLevel/MaxPlayerCalculationMode/PersistenceVersion/ToxmodEnabled — tolerant reader defaults these, cosmetic only.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:307
Fix. Add [HttpGet("roomserver/rooms/base")] to BaseRooms; optionally emit rows via BuildRoomServerListAsync for key parity with the other list endpoints.
GET + DELETE rooms/{0} — MISSING (breaks-gameplay)
GET is fine: bare (:390) and roomserver (:1445) both return BuildRoomServerDetails, and the include/unityAssetTarget/unityAssetVersion query params are numeric on the wire (closure CAEBODLFAMA boxes Int32/Nullable/Nullable) so the int? bindings work. DELETE is MISSING on the game path entirely — the only [HttpDelete("rooms/{id:long}")] lives in AdminController under [Route("api/admin/v1")] (AdminController.cs:25,442), so the client's owner delete-room (NLDBPDCNNCF.FPDBNHOHIAO, verb 4) gets 405 on rooms/{id} and 404 on roomserver/rooms/{id} — owners cannot delete rooms.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:390 (GET); none (DELETE)
Fix. Add DELETE rooms/{roomId:long} + roomserver twin on the rooms controller: owner/co-owner gate, soft-archive (State=1) like AdminController.DeleteRoom, return 200 (client parses no body).
PUT rooms/{0}/automute — MISSING (breaks-gameplay)
No roomserver twin → 404 under the 2023 client's prefix. Additionally the handler binds [FromBody] BareBoolRequest + [FromForm(Name="DisableMicAutoMute")], but the client's form key is 'disable' — so even via the bare path the value is never read (no-op), and the [FromBody] parameter makes a form POST 415 (repo-documented mechanism). Response is ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:739
Fix. Add roomserver twins; drop [FromBody], read form key disable (plus DisableMicAutoMute alias) via ReadBareBoolAsync; return full details.
GET + POST rooms/{0}/bans — SHAPE_MISMATCH (breaks-gameplay)
No roomserver twins for either verb (404 under prefix). GET response keys are Id/RoomId/BannedPlayerId/BannedByPlayerId/BanType/Until/Reason/CreatedAt (:245-255) but the client's IBHAKOOKEEE reader wants AccountId/BannedByAccountId/BanStartTime — zero overlap, so the banned-players screen shows default-zero entries. POST binds [FromBody] BareBanRequest{PlayerId,BanType} while the client sends form repeated 'id' + 'banMask' → 415 (form) and wrong keys anyway — in-room bans never persist.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1167
Fix. Add roomserver twins; GET: emit {AccountId=(int)BannedPlayerId, BannedByAccountId=(int)BannedByPlayerId, BanStartTime=CreatedAt}; POST: read form repeated id + banMask, create a ban row per id.
PUT rooms/{0}/cloning — MISSING (breaks-gameplay)
No roomserver twin → 404 under the 2023 prefix. Bare handler also mixes [FromBody]+[FromForm] (form POST likely 415 per the repo-documented [FromBody] behavior; the form Name="CloningAllowed" would match the client's 'cloningAllowed' case-insensitively if binding got that far) and returns ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:729
Fix. Add roomserver twins; drop [FromBody] and use ReadBareBoolAsync("cloningAllowed",...); return full details.
PUT rooms/{0}/description — MISSING (breaks-gameplay)
No roomserver twin → the 2023 edit-description PUT 404s. Bare handler also carries a [FromBody] parameter (form POST at risk of 415) and returns ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:459
Fix. Add roomserver twins; replace [FromBody]+[FromForm] with ReadStringValueAsync("description",...); return full details.
PUT rooms/{0}/image — MISSING (breaks-gameplay)
No roomserver twin → set-thumbnail 404s. Same [FromBody] 415 risk and ToWireRoom response as description.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:466
Fix. Add roomserver twins; ReadStringValueAsync("imageName",...); full-details response.
PUT + DELETE rooms/{0}/loadscreen — MISSING (breaks-gameplay)
DELETE is not registered at all (bare or roomserver) — 'remove room loading screen' gets 405 → cannot remove load screens. PUT exists with twins and form support, but ReadJsonElementAsync serializes the whole form as ONE object and REPLACES LoadScreensJson (:845-847) instead of appending, so a room can never hold more than one load screen. Response is ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:834
Fix. Register DELETE (bare + roomserver) reading form imageName and removing the matching entry; change PUT to append {ImageName,Title,Subtitle} to the existing list; return full details.
PUT rooms/{0}/modify — MISSING (breaks-gameplay)
No roomserver twin → the room-settings bulk save 404s. Bare BareModify also binds [FromBody] ModifyRoomRequest → 415 for the client's x-www-form-urlencoded body; the DTO is missing the client's fields supportsJuniors/cloningAllowed/disableRoomComments/encryptVoiceChat/accessibility-as-form; response is {Result, Room: ToWireRoom} — not the FGCPNAACHIK object.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:950
Fix. Add roomserver twins; parse the 11 form keys (name/description/accessibility/supportsJuniors/supportsScreens/supportsTeleportVR/supportsWalkVR/cloningAllowed/disableMicAutoMute/disableRoomComments/encryptVoiceChat) directly from Request.Form; return BuildRoomServerDetails.
PUT rooms/{0}/name — MISSING (breaks-gameplay)
No roomserver/rooms/{id}/name twin (the SUBROOM rename got twins at :2658-2659, the room rename did not) → 2023 rename 404s. Bare handler also mixes [FromForm]+[FromBody RenameBody] (form PUT at risk of the repo-documented 415) and returns a minimal {Result,RoomId,Name,...} object instead of full FGCPNAACHIK details (no Stats/SubRooms/Roles/Tags) → client parse fails even on success.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2590
Fix. Add roomserver twins; drop the [FromBody] parameter (read form name); return BuildRoomServerDetailsWithRolesAsync.
GET + PUT rooms/{0}/playerdata/me — STUB (breaks-gameplay)
PUT is MISSING everywhere (only GET registered) → saving per-room CV2 player data gets 405 and nothing persists. GET is a STUB: Data is hardcoded to "CAE=" (:2331) for every player/room, so persistent per-room player state always loads the same canned blob. Also GET has no roomserver twin → 404 under the 2023 prefix. The client contract is GET → {Data: string}, PUT form field data.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2307
Fix. Add a PlayerRoomData table keyed (playerId, roomId); PUT (bare + roomserver) stores form 'data'; GET returns the stored blob (default empty/"CAE="); add roomserver twin for GET.
PUT rooms/{0}/restrictions — MISSING (breaks-gameplay)
No roomserver twin → 404 under prefix. Bare handler reads a single bool (form AllowsJuniors / body Value) but the client sends FOUR booleans: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR — three of four settings are never read, and the [FromBody] parameter risks 415 on the form body. Response is ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:752
Fix. Add roomserver twins; read all four form keys via ReadBareBoolAsync each and map to SupportsScreens/SupportsTeleportVR/SupportsWalkVR/AllowsJuniors; return full details.
POST rooms/{0}/subrooms — MISSING (breaks-gameplay)
Only the bare POST exists (the GET at :532 has a twin; the POST does not) → 'add new subroom' 404s under the 2023 prefix. The bare handler also binds [FromBody] CreateSubRoomRequest → 415 for the client's form 'name', and returns SceneWire instead of the full FGCPNAACHIK details the client re-parses.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3298
Fix. Add [HttpPost("roomserver/rooms/{roomId:long}/subrooms")]; read form name; return BuildRoomServerDetailsWithRolesAsync.
DELETE rooms/{0}/subrooms/{1} — MISSING (breaks-gameplay)
Bare DELETE only (the roomserver/rooms/{id}/subrooms/{sub} registration at :554-555 is GET) → delete-subroom 404s under the prefix. Response {Result:0} is also not the expected full details.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3330
Fix. Add roomserver DELETE twin; return full details.
PUT rooms/{0}/subrooms/{1}/accessibility — SHAPE_MISMATCH (breaks-gameplay)
Twins exist, but the handler binds [FromBody] SubRoomBoolRequest{Value:bool} while the client sends form 'accessibility' as an Int32 enum (JHAOFLCJNAL) → form body gets 415 (repo-documented [FromBody] behavior) and the key/type don't match anyway; response is SceneWire, not full details. Subroom accessibility can't be changed.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3383
Fix. Drop [FromBody]; read form int accessibility (ReadBareIntAsync pattern) and map to CanMatchmakeInto/scene accessibility; return full details.
PUT rooms/{0}/subrooms/{1}/maxplayers — SHAPE_MISMATCH (breaks-gameplay)
Twins exist (added after the 404 was diagnosed), but the handler binds [FromBody] SubRoomIntRequest{Value} while the client sends form 'maxPlayers' → the form PUT 415s and even a JSON body would need key Value, so the max-players slider still can't persist. Response is SceneWire, not full details.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3375
Fix. Drop [FromBody]; read form maxPlayers (int); return full details.
PUT rooms/{0}/subrooms/{1}/modify — SHAPE_MISMATCH (breaks-gameplay)
Twins exist, but [FromBody] SubRoomModifyRequest → 415 on the client's form body (keys name/accessibility/maxPlayers); 'accessibility' isn't even a property of the DTO. Response is SceneWire. Subroom settings bulk-save fails.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3525
Fix. Drop [FromBody]; read the three form keys; map accessibility; return full details.
PUT rooms/{0}/tags — MISSING (breaks-gameplay)
No roomserver twin → edit-tags 404s under the 2023 prefix. Bare handler reads form key 'Tags' as CSV, but the client sends REPEATED form keys 'tag' and 'autoTag' → never read (no-op) and the [FromBody] parameter risks 415. Response ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:473
Fix. Add roomserver twins; read Request.Form["tag"] + Request.Form["autoTag"] as lists, join into TagsCsv; return full details.
PUT rooms/{0}/warning — MISSING (breaks-gameplay)
No roomserver twin → 404 under the 2023 prefix. Bare handler is [FromBody] BareWarningRequest{RoomWarningMask,CustomRoomWarning} → 415 on the client's form body, whose keys are warningMask/customWarning anyway. Response ToWireRoom. Content warnings can't be saved.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:762
Fix. Add roomserver twins; read form warningMask (int) + customWarning (string); return full details.
POST api/rooms/v2/report — SHAPE_MISMATCH (degraded)
Handler is [FromBody] RoomReportRequest{RoomId,Category,Message} (JSON only). The client sends x-www-form-urlencoded with PascalCase keys RoomId/RoomKeyId/Details/ReportCategory. A [FromBody] handler returns 415 for a form POST before the handler runs (mechanism empirically documented in this repo at RoomsModerationController.cs:963-968 and RoomSave2023Tests.cs:246-248). Even with JSON the field names differ (Category vs ReportCategory, Message vs Details). Result: room reports are never persisted; client logs 'Failed to report room' but UI is otherwise unaffected.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:267
Fix. In Report(), drop [FromBody]; read Request.Form keys RoomId/RoomKeyId/Details/ReportCategory (case-insensitive) with a JSON fallback, map ReportCategory→Category and Details→Message.
GET featuredrooms/current — SHAPE_MISMATCH (degraded)
Two issues: (1) Rooms entries are RoomService.ToWireRoom objects (RoomId/Name/ImageName...) but the 2023 COGJIOGPNGD reader reads ImageName/RoomId/RoomName — there is no 'RoomName' key, so every featured tile's room name deserializes to null (reader is casing-tolerant and defaults missing keys, so no crash). (2) No roomserver/featuredrooms/current twin is registered; this route is called from NLDBPDCNNCF, the client whose every other request arrives with the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93), so the 2023 call likely 404s outright.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2535
Fix. Add RoomName (= room.Name) to each Rooms entry in FeaturedRoomsCurrent, and register [HttpGet("roomserver/featuredrooms/current")] on the same action.
GET rooms/hot — MISSING (degraded)
Paged {Results,TotalResults} wrapper is correct, but no roomserver/rooms/hot twin is registered → the NLDBPDCNNCF hot-rooms call 404s under the roomserver/ prefix (the play-menu 'hot' UI separately uses rooms/hot_rooms/{...} which IS served, so impact is limited to whatever surface uses NLDBPDCNNCF.FANOAMOPHBE). Secondary: [FromQuery] string? tag binds only the FIRST repeated tag value, and rows are slim ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:56
Fix. Add [HttpGet("roomserver/rooms/hot")]; read Request.Query["tag"] as a list.
GET rooms/magic_door — SHAPE_MISMATCH (degraded)
Server returns the bare room-details object. The 2023 GAPJAGDNFEO reader expects a wrapper {Room: , RefreshesAt: DateTime, RefreshIntervalMinutes: Int32}; with no 'Room' key the parsed DTO has Room=null and MagicDoorManager gets no destination — Magic Door dead/blank. partySize query param is ignored (acceptable).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:154
Fix. Wrap the response: Ok(new { Room = wireRoom, RefreshesAt = , RefreshIntervalMinutes = N }).
GET rooms/requiring/ — UNKNOWN (degraded)
Route + roomserver twin exist and accept the lowercased mode segment. Server returns a bare array of room NAMES (strings). The client's element type could not be resolved from ISIL metadata (restricted-rooms list; likely room ids). If the client expects Int64 ids, string names would parse to defaults or fail silently — cannot confirm either way.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2351
Fix. Resolve the element type from the client (trace the list's consumer in Matchmaking) before changing; if ids are expected, Select(r => r.Id).
GET rooms/rro_ids — MISSING (degraded)
Bare route returns [Int64] (correct shape) but no roomserver/rooms/rro_ids twin exists → the NLDBPDCNNCF call 404s under the roomserver/ prefix and RRO badging/gating falls back to nothing.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2511
Fix. Add [HttpGet("roomserver/rooms/rro_ids")] to AgRoomIds.
GET rooms/search — MISSING (degraded)
Paged {Results,TotalResults} shape and query/skip/take params are correct, but no roomserver/rooms/search twin → 404 under the roomserver/ prefix (play-menu search separately uses rooms/search_rooms/{...} which IS served and test-covered, so impact limited to the NLDBPDCNNCF.MOBJJDBNBMF surface). Rows are slim ToWireRoom (cosmetic).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:267
Fix. Add [HttpGet("roomserver/rooms/search")].
PUT rooms/{0}/accessibility — SHAPE_MISMATCH (degraded)
Routing/verbs/body OK (roomserver twins registered, [Consumes] form, reads 'accessibility'). Defect: ApplyAndReturn responds with RoomService.ToWireRoom (RoomsModerationController.cs:1210) — the slim room object with NO SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob/MinLevel/MaxPlayers. The 2023 client re-parses the FULL FGCPNAACHIK details after every mutation and its dispose-walk NREs on the missing nested lists (same failure the repo already documented for clone at RoomsModerationController.cs:1117-1125), so the toggle persists but the client reports failure.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:485
Fix. Change ApplyAndReturn to return RoomsController.BuildRoomServerDetails (load scenes+roles like BuildRoomServerDetailsWithRolesAsync) instead of ToWireRoom — this one fix repairs the response of every bare-path room mutation.
Fixed. ApplyAndReturn returns the full details. This route is also the "Publish room" button; publishing a cloned room still failed because the clone was advertised as IsRRO, which hides and refuses Publish client-side. Player-owned rooms are now never RRO on the wire (see "Sub-room ids, IsRRO and the instance browser" above).
PUT rooms/{0}/allow_new_users — SHAPE_MISMATCH (degraded)
Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:789
Fix. Same ApplyAndReturn fix.
POST rooms/{0}/bans/import — SHAPE_MISMATCH (degraded)
No roomserver twin; handler binds [FromBody] ImportRoomBansRequest (expects a Bans list) while the client sends form sourceRoomId → 415, and even as JSON the contract differs (client names a source ROOM to copy bans from; handler expects an explicit ban list). Import-ban-list never works.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1195
Fix. Add roomserver twin; read form sourceRoomId, copy that room's RoomBans rows into the target (owner-gated on both rooms).
DELETE rooms/{0}/bans/{1} — MISSING (degraded)
Bare DELETE works (banMask form field is ignored, which is fine — client parses no response), but no roomserver twin → unban 404s under the 2023 prefix.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1181
Fix. Add [HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")].
PUT rooms/{0}/comments — STUB (degraded)
No roomserver twin (404 under prefix), and the bare handler BareAck is an acknowledged no-op — DisableRoomComments is never persisted and the wire always reports DisableRoomComments=false, so the toggle never sticks across loads. Client's form key 'disable' is never read.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:940
Fix. Add a DisableRoomComments column on RoomEntity, read form 'disable', persist, surface in both detail builders; add roomserver twins; return full details.
PUT rooms/{0}/creator — SHAPE_MISMATCH (degraded)
Twins + form key accountId handled (ReadBareLongAsync includes 'accountId'); response is ToWireRoom (same ApplyAndReturn defect).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:771
Fix. Same ApplyAndReturn fix.
PUT + DELETE rooms/{0}/interactionby/me/cheer — MISSING (degraded)
Bare PUT/POST/DELETE all exist and the client parses no body, but there are NO roomserver twins → cheer/uncheer 404 under the 2023 prefix and the button silently fails.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2761
Fix. Add roomserver/ twins for PUT+DELETE (and favorite below).
PUT + DELETE rooms/{0}/interactionby/me/favorite — MISSING (degraded)
Same as cheer: bare verbs fine, no roomserver twins → 404 under prefix.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2780
Fix. Add roomserver twins.
PUT rooms/{0}/max_player_calculation_mode — SHAPE_MISMATCH (degraded)
Twins + form key maxPlayerCalculationMode OK; response is ToWireRoom (ApplyAndReturn defect).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:817
Fix. Same ApplyAndReturn fix.
PUT rooms/{0}/min_level — SHAPE_MISMATCH (degraded)
Twins + form key minLevel OK; response is ToWireRoom (ApplyAndReturn defect).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:803
Fix. Same ApplyAndReturn fix.
PUT + DELETE rooms/{0}/promo_external — SHAPE_MISMATCH (degraded)
Twins exist for both verbs. PUT stores the form dict verbatim, so Type is persisted as the STRING "1" while the client's EPPPACFECMH reader expects Type as Int32 — the strict value reader will reject or zero it when details are re-read. DELETE reads keys id/url/value but the client sends type+reference → always 400 missing_promo_external, so promo links can't be removed. Response is ToWireRoom.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:885
Fix. PUT: build {Type=(int), Reference=(string)} explicitly from form. DELETE: match on form type+reference. Return full details.
PUT + DELETE rooms/{0}/promo_images — SHAPE_MISMATCH (degraded)
Twins for POST/PUT/DELETE all present, form key imageName read on both verbs — request side fully OK. Only defect: ToWireRoom response (ApplyAndReturn).
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:851
Fix. Same ApplyAndReturn fix.
GET rooms/{0}/roles, GET + PUT rooms/{0}/roles/{1}, PUT rooms/{0}/roles/{1}/invite — FIXED
Was: the roomserver/ twins were added earlier, but three defects remained.
(1) The creator was listed as Role 30 (CoOwner). The client takes the local
role from the Roles list (FGCPNAACHIK.ONECLLALJEO, first AccountId match)
and the permission editor only allows roles strictly below your own, with
Creator (255) the one unconditional pass (OMJKHJLFOCO.DIIMLMKEJLC,
RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367), so the creator could
not edit Co-Owner or Creator permissions. (2) Only the creator or a co-owner
could call either route, so an invitee could never accept (own id, role 30)
or reject (/invite on own id, role 0), and re-inviting an accepted co-owner
demoted them to pending. (3) Nobody was told: the invitee's prompt is a
message, Type 62 RoomCoOwnerInvited with RoomId (KPFKPIKDBFJ, factory
OCGKOJMFLAI.ALEHDHAEFOK), and none was sent.
Fixed. Creator is 255 and listed once (RoomRoleRules.BuildWires); one
row per account with accepted Role and pending InvitedRole side by side.
RoomRoleRules.CheckChange enforces the ladder: the creator may set any role
on anyone but themselves; otherwise the actor must be an accepted co-owner and
both the target's current or invited role and the requested role must be
strictly below theirs; anyone may drop their own role or decline their invite;
an invitee may accept exactly the invited role. Messages 62 (invite), 60
(added) and 61 (removed) go to the target, answered invites are marked read,
and the full room goes out as RoomUpdate to everyone in the room plus the
changed player.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs (GrantRole, InviteRole, ChangeRoomAccountRoleAsync), rules in DorkNet.Server/Services/RoomRoleRules.cs
POST rooms/{0}/subrooms/{1}/clone — SHAPE_MISMATCH (degraded)
Twins + empty-body POST OK; clone logic sound; but returns SceneWire instead of the full FGCPNAACHIK room details → client reports failure after a successful duplicate.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3483
Fix. Return BuildRoomServerDetailsWithRolesAsync(roomId).
POST rooms/{0}/subrooms/{1}/move — SHAPE_MISMATCH (degraded)
Twins exist but the semantics are wrong: the client sends form 'newRoomId' (Nullable) to move a subroom to ANOTHER ROOM; the handler binds [FromBody] SubRoomMoveRequest{NewIndex} and reorders scenes within the same room. Form body also 415s. Response SceneWire.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3543
Fix. Read form newRoomId; when present re-parent the RoomSceneEntity to the target room (owner-gated on both, next OrderIndex there); return full details.
PUT rooms/{0}/subrooms/{1}/name — SHAPE_MISMATCH (degraded)
roomserver twins registered. Two residual defects: the action mixes [FromForm] name params with a [FromBody] RenameBody — the [FromBody] binder puts the form PUT at risk of the repo-documented 415; and the response is a minimal {Result,RoomId,SubRoomId,Name,...} object, not the full FGCPNAACHIK details the client re-parses.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656
Fix. Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync.
PUT rooms/{0}/subrooms/{1}/permissions + GET photon_access_token — FIXED
Was: the PUT accepted the matrix and dropped it (no storage), and
photon_access_token always answered a hardcoded seed, so a permission
change never survived a reload and nobody else in the room saw it.
Fixed. Cells persist in RoomRolePermissions (one row per room, sub-room
OrderIndex, role, permission; upsert, cells not sent are untouched).
photon_access_token (ALIIIAGAPND {RoomInstanceId, PhotonAccessToken, Permissions}, now also under roomserver/) serves the defaults with the
caller's current sub-room cells applied on top, and every save pushes the
same body as the PhotonAccessToken hub message (registered as
Action<ALIIIAGAPND>, IBEOONPEELF.txt:519) to everyone in that sub-room. Writes
follow the client's own rule: the creator may edit every role row, a co-owner
only rows below 30, nobody else may write. Role and Type are accepted as
numbers, numeric strings or enum names.
Unproven: the client's permission-id spelling. The defaults keep the numeric
ids the server has always sent ("13" maker pen, "20"–"23"); RecFlare sends
names such as CAN_USE_MAKER_PEN. Stored cells are echoed exactly as the
client wrote them, so the client's own edits round-trip either way.
Handler: RoomsController.SubRoomPermissions, MatchController.PhotonAccessToken, DorkNet.Server/Services/RoomPermissionMatrix.cs
POST rooms/{0}/subrooms/{1}/publish_save — SHAPE_MISMATCH (degraded)
Twins + form key subRoomDataSaveId are handled (ReadPublishSubRoomSaveRequestAsync :3440-3478); restore logic sets the scene blob. Only defect: SceneWire response instead of full details → restore shows failure in the client after succeeding.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3404
Fix. Return BuildRoomServerDetailsWithRolesAsync.
PUT rooms/{0}/voice_chat_encryption — STUB (degraded)
Same as comments: no roomserver twin, no-op BareAck handler, EncryptVoiceChat never persisted and hardcoded false on the wire (RoomService.cs:1123, RoomsController.cs:2026); client form key encryptVoiceChat never read.
Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:942
Fix. Persist an EncryptVoiceChat column; read form encryptVoiceChat; add twins; full-details response.
Room currencies, consumables and keys
room-economy
All 25 real client routes were checked against DorkNet. RoomConsumables routes exist but three of them (consume, purchase/tokens, purchase/currency) only map POST while the client PUTs (405), and every consumable response embeds a nested PriceAndCurrency object where the client's response formatter (FCIBLPCOODP) reads FLAT Price/PurchaseCurrencyId/ModifiedAt — so all shop prices deserialize as 0/token. RoomCurrencies is the worst area: every response DTO uses server-invented key names (RoomCurrencyId/DailyLimit/UpdatedAt/PurchaseOfferId/Amount/PlayerId) that share almost no keys with the client formatters (CurrencyId/CurrencyType/Limit/ModifiedAt/CurrencyPurchaseOfferId/CurrencyAmount/Order/AccountId), awardCurrency/bulk cannot parse the client's JSON-array body at all, getPurchaseOffersBatch ignores the client's "ids" param and returns an ungrouped flat list, and v2/purchase returns a shape with zero keys in common with EGHOOCGCKAD. RoomKeys: all four edit routes (PUT updateAll/updateName/updateDescription/updatePrice) are missing (server only has POST v1/update), owns/bulk never reads the JSON array body and answers with "Owns" instead of "DoesPlayerOwnRoomKey", owns/purchased/mine have caller-vs-target semantic mismatches, and ToWire drops PurchaseCurrencyId/CreatedAt/ImageName. Only 5 routes are fully OK: consumable delete, consumable isOwned, roomkeys delete, roomcurrencies deletePurchaseOffer, and (verb/shape-wise) nothing else without caveats.
Client-side notes. VERB ENUM (ground truth): BNDIAONDFFF ctor (C:\tmp\recnet-runtime-decomp\BNDIAONDFFF.cs:194) takes BestHTTP.HTTPMethods (C:\tmp\recnet-runtime-decomp\BestHTTP\HTTPMethods.cs): 0=Get, 1=Head, 2=Post, 3=Put, 4=Delete. KEY CASING: every generated formatter carries three casing variants per key (PascalCase/camelCase/all-lowercase), so reads are casing-tolerant across those three exact forms, but the key NAME must match — unknown keys are skipped and missing value-type fields silently default. SERVER GAPS FOUND (DorkNet source cross-check): (1) RoomKeys update: client PUTs api/roomkeys/v1/updateAll|updateName|updateDescription|updatePrice; server only maps POST api/roomkeys/v1/update (DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:96) → all key edits 404. (2) owns/bulk: client POSTs a JSON array body [{RoomKeyId,AccountId}] which ReadRoomKeyIdsAsync (RoomKeysController.cs:215-232) never reads (query/form only), and the response key is "Owns" (RoomKeysController.cs:171) where the client reads "DoesPlayerOwnRoomKey" and also expects AccountId echoed. (3) RoomKeys ToWire (RoomKeysController.cs:180-190) omits PurchaseCurrencyId/CreatedAt/ImageName (defaults, non-fatal but lossy). (4) Consumables: client uses PUT for purchase/tokens, purchase/currency, and consume; server maps POST only (RoomConsumablesController.cs:230, :266, :307 — create/update at :139-140 already has PUT) → 405 on real client traffic. (5) Consumable RESPONSE desc must be FLAT (MOONCMIECPL formatter FCIBLPCOODP keys: Price, PurchaseCurrencyId, ModifiedAt); server ToDescWire (RoomConsumablesController.cs:407-414) sends nested PriceAndCurrency, so prices/currency deserialize as 0/null in catalog, /me, and edit responses. The nested PriceAndCurrency shape is correct ONLY for the create/update REQUEST DTO (JPFDLKMGKHF) and ExpectedPriceAndCurrency purchase bodies. docs/recroom-2023-room-consumables.md is wrong on both points (says POST, and attributes the nested desc to the response DTO) — update it. (6) RoomCurrencies responses are wrong nearly everywhere (RoomCurrenciesController.cs): currency ToWire (:376) sends RoomCurrencyId/Id/DailyLimit/UpdatedAt but client reads CurrencyId/Limit/ModifiedAt/CurrencyType (send CurrencyType=300 RoomCurrency); getBalance/getAllBalances (:82,:105) send PlayerId/RoomCurrencyId but client reads AccountId/CurrencyId/Balance/ModifiedAt; getBalance also ignores the client's "accountId" query param (server reads playerId); offer ToOfferWire (:391) sends PurchaseOfferId/Amount/CreatedAt but client reads CurrencyPurchaseOfferId/CurrencyId/CurrencyAmount/Order/ModifiedAt; getPurchaseOffersBatch takes param "ids" (room-currency Guids; server reads purchaseOfferIds/roomCurrencyIds → always empty filter) and must return List of {CurrencyId, PurchaseOffers:[...]} groups, not a flat offer list; awardCurrency/bulk request is a JSON ARRAY body [{TransactionId,RecipientId,Amount,CurrencyId}] which ReadFieldsAsync (:294-331) drops (object-root only) and the response must be [{AccountId,CurrencyId,Success,Error,Response:{AccountId,CurrencyId,Balance,AmountAwarded,AwardedAt}}]; roomCurrencies/v2/purchase response must be {CurrencyBalanceResponse:{AccountId,CurrencyId,Balance,ModifiedAt}, TokenBalanceResponse:{Balance,CurrencyType,Platform}} not the current {Success,Balance,...}. (7) Non-route literals seen alongside: "RoomCurrencyCreated"/"RoomCurrencyModified"/"RoomCurrencyDeleted" (MIHKAJFMIPE.txt:224-248) are in-client event/notification names, not HTTP routes. Storefront room-key purchasing (api/storefronts/v1/buyRoomKey GET!, api/storefronts/v1/PurchaseRoomKeyWithCurrency POST with RoomKeyId/RequestedPrice/RequestedPurchaseCurrencyId — DCFKEFHJAGC.txt:7085/:7424) belongs to the storefronts subsystem but completes the key-buy flow.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| POST | api/roomCurrencies/v2/purchase |
params: "PurchaseOfferId" (Guid), "RequestedAmount" (Int64), "RequestedPrice" (Int64) | EGHOOCGCKAD: {"CurrencyBalanceResponse": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime}, "TokenBalanceResponse": {"Balance": Int64, "CurrencyType": Int32 (EAFDEJBEFJB, RecCenterTokens= |
| PUT | api/roomconsumables/v1/roomConsumable |
JSON body JPFDLKMGKHF: {"RoomConsumableId": Guid (absent/empty on create, set on update), "RoomId": Int64, "Name": String, "Description": String, "ImageName": String, "PriceAndCurr | GFKDAADENEE: {"Status": Int32 (enum DLDNGEDFMOJ, Success=0), "Consumable": MOONCMIECPL} where the RESPONSE desc is FLAT: {"RoomConsumableId": Guid, "RoomId": Int64, "Name": String, "Description": String, "ImageName": Str |
| GET | api/roomconsumables/v1/roomConsumable/room/{roomId} (composed) |
none | JSON array of MOONCMIECPL (flat desc: RoomConsumableId, RoomId, Name, Description, ImageName, Price, PurchaseCurrencyId, ModifiedAt) |
| GET | api/roomconsumables/v1/roomConsumable/room/{roomId}/me (composed) |
none | JSON array of HOMJKAOHGDG: [{"RoomConsumableId": Guid, "AccountId": Int32, "Count": Int32, "ConcurrencyCode": Guid, "ModifiedAt": DateTime, "Consumable": MOONCMIECPL (never null)}] |
| DELETE | api/roomconsumables/v1/roomConsumable/{id} (composed from prefix) |
none (id in path) | bare Int32 status body (enum DLDNGEDFMOJ; deserialized as FGLDKEJLAKB) |
| PUT | api/roomconsumables/v1/roomConsumable/{id}/consume (composed) |
JSON body CFOBCEPBBHA: {"CurrentConcurrencyCode": Guid?, "NewConcurrencyCode": Guid} — client GENERATES NewConcurrencyCode and adopts it locally; server must store it verbatim or n | BGHBAILNNJJ: {"Status": Int32 (DLDNGEDFMOJ), "InventoryItem": HOMJKAOHGDG (current row; returned on mismatch for resync)} |
| GET | api/roomconsumables/v1/roomConsumable/{id}/isOwned (composed) |
none | bare JSON boolean |
| PUT | api/roomconsumables/v1/roomconsumable/{0}/purchase/currency |
JSON body ILNJDMFNOCD: {"ConcurrencyCodes": {...}, "ExpectedPriceAndCurrency": {"Price": Int64, "CurrencyId": Guid}} | DEILBLCDNEA: {"OperationResult": Int32 (DLDNGEDFMOJ), "BalanceUpdateResult": Int32? (enum GACBLALELBP: 0 Success, 1 NotEnoughCredit), "CurrencyBalanceResponse": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, |
| PUT | api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens |
JSON body ILNJDMFNOCD: {"ConcurrencyCodes": {"CurrentConcurrencyCode": Guid?, "NewConcurrencyCode": Guid}, "ExpectedPriceAndCurrency": {"Price": Int64, "CurrencyId": null}} | MJKFEHPIDME: {"OperationResult": Int32 (DLDNGEDFMOJ), "BalanceUpdateResult": Int32? (enum CABBDKFODEC: 0 OK, 2 NotEnoughCredit, 6 RequestedPriceDoesNotMatch...), "TokenBalanceResponse": {"Balance": Int64, "CurrencyType": |
| POST | api/roomcurrencies/v1/awardCurrency/bulk |
JSON body = bare ARRAY of KOEIABFLEPM: [{"TransactionId": Guid, "RecipientId": Int32, "Amount": Int64, "CurrencyId": Guid}] — serialized then BNDIAONDFFF.FJLLPHFOOJJ(SetBody); NOT | JSON array of KBPKAAIDCIJ: [{"AccountId": Int32, "CurrencyId": Guid, "Success": Boolean, "Error": String, "Response": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "AmountAwarded": Int64, "AwardedAt": DateTi |
| POST | api/roomcurrencies/v1/createCurrency |
form/query params via AFGEDDANEKP: "RoomId" (Int64), "Name" (String), "Description" (String), "Limit" (Int64), "ImageName" (String, optional) | single KDEOPJCNKMC object (same 9 keys as /currencies rows: CurrencyId, RoomId, Name, Description, CurrencyType, Limit, ImageName, CreatedAt, ModifiedAt) |
| POST | api/roomcurrencies/v1/createPurchaseOffer |
params: "CurrencyId" (Guid), "Name" (String), "Amount" (Int64), "Price" (Int64, tokens), "Order" (Int32) | single CJPPFBEAAHD: {"CurrencyPurchaseOfferId": Guid, "CurrencyId": Guid, "Order": Int32, "Name": String, "CurrencyAmount": Int64, "Price": Int64, "ModifiedAt": DateTime} |
| GET | api/roomcurrencies/v1/currencies |
query param "roomId" (Int64) — added via BNDIAONDFFF.AFGEDDANEKP | JSON array of currency DTO KDEOPJCNKMC: [{"CurrencyId": Guid, "RoomId": Int64? (nullable), "Name": String, "Description": String, "CurrencyType": Int32 enum EAFDEJBEFJB (RoomCurrency=300), "Limit": Int64, "ImageName": St |
| POST | api/roomcurrencies/v1/deletePurchaseOffer |
param: "PurchaseOfferId" (Guid) | ignored by client (any 200 works) |
| GET | api/roomcurrencies/v1/getAllBalances |
query param "roomId" (Int64) | JSON array of LGAMPMJNGFH: [{"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime}] |
| GET | api/roomcurrencies/v1/getBalance |
query params "currencyId" (Guid) and "accountId" (Int32) — note camelCase on the wire | single LGAMPMJNGFH: {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime} |
| GET | api/roomcurrencies/v1/getPurchaseOffersBatch |
repeated/list query param "ids" = room-currency Guids (verb chosen dynamically by helper ALHIJCJOLCB.JIECAFGCODK(ids, 100) — GET for small batches; serve GET and POST) | JSON array GROUPED BY CURRENCY, List: [{"CurrencyId": Guid, "PurchaseOffers": [CJPPFBEAAHD objects]}] — NOT a flat offer list |
| POST | api/roomcurrencies/v1/updateCurrency |
params: "CurrencyId" (Guid) + optional "Name", "Description", "Limit" (Int64), "ImageName" (only sent when non-null) | single KDEOPJCNKMC object (see /currencies) |
| POST | api/roomcurrencies/v1/updatePurchaseOffer |
params: "PurchaseOfferId" (Guid) + optional "Name", "Amount" (Int64), "Price" (Int64), "Order" (Int32) | single CJPPFBEAAHD (same keys as createPurchaseOffer) |
| POST | api/roomkeys/v1/create |
params: "RoomId" (Int64), "Name", "Description", "Price" (Int32), "ImageName", "PurchaseCurrencyId" (Guid as string, "" when null) | ONLIDBLNMCC: {"Status": Int32 (enum GDIOEPIOPEE: 0 Success, 3 NameTooShort, 5 DuplicateName, 10/11 PriceTooLow/High, 12 PermissionDenied...), "RoomKey": {"RoomKeyId": Int64, "ReplicationId": Guid, "RoomId": Int64, "Name" |
| DELETE | api/roomkeys/v1/delete/{roomKeyId} (composed) |
none (id in path) | bare Int32 status body (enum GDIOEPIOPEE) |
| GET | api/roomkeys/v1/mine |
none | JSON array of JBOBOABIEBN room-key DTOs |
| GET | api/roomkeys/v1/owns |
query params "playerId" (Int32) and "roomKeyId" (Int64) | bare JSON boolean |
| POST | api/roomkeys/v1/owns/bulk |
JSON body = bare ARRAY of DIJEKBHKJDD: [{"RoomKeyId": Int64, "AccountId": Int32}] (serialized then SetBody FJLLPHFOOJJ — NOT query/form) | JSON array of NJBNPAOKNEE: [{"RoomKeyId": Int64, "AccountId": Int32, "DoesPlayerOwnRoomKey": Boolean}] — all three are non-nullable value types in the DTO; echo request pairs back |
| GET | api/roomkeys/v1/purchased/{roomKeyId} (composed) |
none | bare JSON boolean |
| GET | api/roomkeys/v1/room?roomId={id} (composed via NHNGFFFCBPD) |
query roomId (baked into route string) | JSON array of JBOBOABIEBN room-key DTOs (RoomKeyId/ReplicationId/RoomId/Name/Description/Price/PurchaseCurrencyId/CreatedAt/ImageName) |
| PUT | api/roomkeys/v1/updateAll | updateName | updateDescription | updatePrice (composed, 4 routes) |
PUT with params: "RoomKeyId" (Int64) always, plus per-action: updateAll={Name, Description, Price, PurchaseCurrencyId ("" if null), ImageName}; updateName={Name}; updateDescription | ONLIDBLNMCC {"Status", "RoomKey"} — same as create |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
api/roomconsumables— n/a — prefix literalapi/roomkeys/— n/a — prefix literalapi/roomkeys/v1/— n/a — prefix; concatenated with an action string to form the real update routes (see updateAll entry)
Defects
GET api/roomcurrencies/v1/currencies — SHAPE_MISMATCH (breaks-gameplay)
Handler exists (GET, reads roomId query — both OK), but ToWire (RoomCurrenciesController.cs:376-389) emits RoomCurrencyId/Id/InternalId/CreatorPlayerId/DailyLimit/UpdatedAt. Client formatter PEOAOMGOMGC reads CurrencyId, RoomId, Name, Description, CurrencyType, Limit, ImageName, CreatedAt, ModifiedAt (verified PEOAOMGOMGC.txt:627-798). Missing CurrencyId means every currency deserializes with Guid.Empty as its id, so all downstream balance/offer lookups key on Guid.Empty; CurrencyType defaults to 0 instead of 300 (RoomCurrency), Limit and ModifiedAt default.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:18
Fix. Rewrite ToWire to { CurrencyId = currency.PublicId, RoomId (nullable long), Name, Description, CurrencyType = 300, Limit = currency.DailyLimit (long), ImageName, CreatedAt, ModifiedAt = currency.UpdatedAt }.
POST api/roomcurrencies/v1/createCurrency — SHAPE_MISMATCH (breaks-gameplay)
Route+verb OK, RoomId/Name/Description/ImageName read OK (case-insensitive dict), but the client sends "Limit" (Int64) and the server only reads dailyLimit/DailyLimit (line 48) so the limit is silently dropped to 0. Response uses the broken ToWire — the creator's client never learns the new currency's CurrencyId (Guid.Empty).
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:30
Fix. Add "Limit" (and "limit") to the ReadInt/ReadLong alias list at line 48 (store as long), and fix ToWire as above.
POST api/roomcurrencies/v1/awardCurrency/bulk — SHAPE_MISMATCH (breaks-gameplay)
Client serializes a bare JSON ARRAY body [{TransactionId, RecipientId, Amount, CurrencyId}] and PUTs it via SetBody (verified: MIHKAJFMIPE.txt route at instr 037, verb 2 at 039, serialize+FJLLPHFOOJJ at 055-061; request formatter GFKKOADFJKC keys TransactionId/RecipientId/Amount/CurrencyId). Server ReadFieldsAsync (lines 294-331) only enumerates a JSON OBJECT root — an array root yields zero fields — so FindCurrencyAsync returns null and every award 404s. Even if parsed, the handler reads playerIds/amount (single amount for all) and responds [{PlayerId, RoomCurrencyId, Balance}] where the client formatter JCPFCOEEELK reads AccountId/CurrencyId/Success/Error/Response and nested GAGIJHJGFJD reads AccountId/CurrencyId/Balance/AmountAwarded/AwardedAt (verified). CircuitsV2 award chips are fully dead.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:113
Fix. In AwardCurrencyBulk, parse the raw body as a JSON array of {TransactionId:Guid, RecipientId:int, Amount:long, CurrencyId:Guid}; per element resolve the currency by PublicId, apply AddBalanceAsync per recipient, and return [{AccountId, CurrencyId, Success:bool, Error:string, Response:{AccountId, CurrencyId, Balance, AmountAwarded, AwardedAt}}].
GET api/roomcurrencies/v1/getBalance — SHAPE_MISMATCH (breaks-gameplay)
Route+verb OK and "currencyId" is accepted by FindCurrencyAsync. Two defects: (1) client sends query "accountId" (verified MIHKAJFMIPE.txt instrs 051/063 this session — can target OTHER players) but server reads only playerId/PlayerId (line 80) and falls back to the caller, so circuits querying another player's balance silently get the caller's. (2) Response {PlayerId, RoomCurrencyId, Balance} vs client formatter DPNLANKOHON reading AccountId/CurrencyId/Balance/ModifiedAt (verified DPNLANKOHON.txt:331-414) — AccountId=0 and CurrencyId=Guid.Empty so the client cannot attribute the balance it receives.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:74
Fix. Read "accountId" (add to the ReadLong aliases at line 80) and respond { AccountId, CurrencyId = currency.PublicId, Balance, ModifiedAt }.
GET api/roomcurrencies/v1/getAllBalances — SHAPE_MISMATCH (breaks-gameplay)
Route/verb/roomId OK. Response rows {PlayerId, RoomCurrencyId, Balance} — client reads AccountId/CurrencyId/Balance/ModifiedAt (DPNLANKOHON). CurrencyId deserializes as Guid.Empty on every row so the currency HUD cannot map any balance to a currency on room entry.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:90
Fix. Emit { AccountId = (int)playerId, CurrencyId = x.c.PublicId, x.b.Balance, ModifiedAt = x.b.UpdatedAt } per row.
POST api/roomcurrencies/v1/createPurchaseOffer — SHAPE_MISMATCH (breaks-gameplay)
Route/verb OK; CurrencyId/Name/Amount/Price are read. "Order" (Int32) is not read or persisted (entity has no Order). Response ToOfferWire (lines 391-404) emits PurchaseOfferId/RoomCurrencyPackageId/Id/InternalId/RoomCurrencyId/Amount/CurrencyType/CreatedAt/UpdatedAt — client formatter AALDCAANEMM reads CurrencyPurchaseOfferId, CurrencyId, Order, Name, CurrencyAmount, Price, ModifiedAt (verified AALDCAANEMM.txt:499-622). Only Name and Price land; the offer id and amount come back as Guid.Empty/0, so the creator UI shows a broken pack.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:137
Fix. Add an Order int column to RoomCurrencyPurchaseOfferEntity (read "Order"/"order"), and change ToOfferWire to { CurrencyPurchaseOfferId = offer.PublicId, CurrencyId = currency.PublicId, Order, Name, CurrencyAmount = offer.Amount (long), Price (long), ModifiedAt = offer.UpdatedAt }.
GET (POST fallback for big batches) api/roomcurrencies/v1/getPurchaseOffersBatch — SHAPE_MISMATCH (breaks-gameplay)
GET+POST both mapped (good — client picks verb dynamically via ALHIJCJOLCB.JIECAFGCODK). Two defects: (1) client sends the list param "ids" containing room-currency Guids (verified MIHKAJFMIPE_NestedType_OBLJADOFIED.txt instr 066 route / 076 "ids", this session); server reads purchaseOfferIds/roomCurrencyIds/offerIds/currencyIds (lines 202-203) so both filters stay empty and it dumps up to 200 arbitrary offers. (2) Response must be GROUPED: List<{CurrencyId, PurchaseOffers:[offer]}> per formatter LKBGCOELLIN (verified: only keys CurrencyId/PurchaseOffers), but the server returns a flat ToOfferWire list — the client deserializes rows with CurrencyId=Guid.Empty and PurchaseOffers=null, so the This Room pack list is empty/garbage.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:196
Fix. Add "ids"/"Ids" to the currency-id alias list, group results by currency PublicId, and return rows.GroupBy(...).Select(g => new { CurrencyId = g.Key, PurchaseOffers = g.Select(fixed ToOfferWire) }).
POST api/roomCurrencies/v2/purchase — SHAPE_MISMATCH (breaks-gameplay)
POST mapped, PurchaseOfferId lookup works. RequestedAmount/RequestedPrice are ignored (no price-agreement validation — degraded but not fatal). Fatal part: response is {Success, Balance, CurrencyType, RoomCurrencyId, RoomCurrencyBalance} while the client formatter KNGMIABFJHK reads exactly CurrencyBalanceResponse + TokenBalanceResponse (verified), with nested DPNLANKOHON {AccountId,CurrencyId,Balance,ModifiedAt} and PECGEJAAMHB {Balance,CurrencyType,Platform} (verified). Zero keys match, so after buying a pack the client sees null balances — the token counter and currency balance never update and the buy UI errors.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:216
Fix. Validate RequestedPrice==offer.Price (and RequestedAmount==offer.Amount), then return { CurrencyBalanceResponse = { AccountId=(int)pid, CurrencyId=currency.PublicId, Balance=customBalance, ModifiedAt=UtcNow }, TokenBalanceResponse = { Balance=newBalance, CurrencyType=2, Platform=0 } }. The insufficient-funds path must also emit this shape (with the unchanged balances) — the current {Success=false,...} anonymous object is equally unreadable.
GET api/roomconsumables/v1/roomConsumable/room/{roomId} — SHAPE_MISMATCH (breaks-gameplay)
Route/verb OK, real data. But every row goes through nested ToDescWire while the client reads flat Price/PurchaseCurrencyId/ModifiedAt (FCIBLPCOODP.txt:670-710, verified). Every item in every room shop deserializes with Price=0 and PurchaseCurrencyId=null, i.e. the whole catalog appears token-priced at 0 — the room economy is effectively free and currency-priced items take the wrong purchase path.
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:80
Fix. Same ToDescWire flattening as above (single shared fix).
PUT api/roomconsumables/v1/roomConsumable/{id}/consume — VERB_MISMATCH (breaks-gameplay)
Client PUTs (verified this session: LPNHMEFDAAG.txt:1449-1460, route format "{0}/v1/roomConsumable/{1}/consume" + verb literal 3 at instr 065). Server maps [HttpPost] only → ASP.NET returns 405 Method Not Allowed and no consumable can ever be consumed. Handler logic and the {Status, InventoryItem} response otherwise match LPFAFIGAIOE (concurrency-code store-verbatim behavior is already correct), apart from the nested Consumable desc issue.
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:307
Fix. Add [HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")] alongside the existing HttpPost.
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens — VERB_MISMATCH (breaks-gameplay)
Client PUTs (verified this session: DCFKEFHJAGC.txt instrs 097-108 — route literal + verb 3). Server maps [HttpPost] only → 405; no token-priced consumable can be bought. The response builder TokenResponse (lines 382-392) otherwise matches HJOKFJKKEJM/PECGEJAAMHB exactly (verified key sets this session).
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:230
Fix. Add [HttpPut] for the same route.
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency — VERB_MISMATCH (breaks-gameplay)
Client PUTs (verified this session: DCFKEFHJAGC.txt instrs 112-123 — route literal + verb 3). Server maps [HttpPost] only → 405; currency-priced consumables unbuyable. CurrencyResponse shape (lines 394-405) matches the claimed IDBFKCFEAHM/DPNLANKOHON shape. Secondary nit: mismatch paths return BadRequest("price_mismatch") instead of an OperationResult body — the client's strict reader gets a 400 with a non-DTO body; prefer returning the DTO with a failure OperationResult.
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:266
Fix. Add [HttpPut] for the same route; optionally convert the BadRequest branches to 200 + DTO.
PUT api/roomkeys/v1/updateAll \| updateName \| updateDescription \| updatePrice — MISSING (breaks-gameplay)
Client builds "api/roomkeys/v1/" + action with verb PUT and param "RoomKeyId" (verified this session: AHMBBJNANBP.txt instr 058 concat prefix, 067 verb 3, 079 "RoomKeyId"; action literals at :1345 updateAll, :1559 updateName, :1772 updateDescription, :2043 updatePrice). The server registers only POST api/roomkeys/v1/update — none of the four real routes exist, so every room-key edit 404s. Per-action fields: updateAll={Name,Description,Price,PurchaseCurrencyId,ImageName}, updateName={Name}, updateDescription={Description}, updatePrice={Price,PurchaseCurrencyId}. Response must be the {Status, RoomKey} envelope.
Handler: none (nearest: POST api/roomkeys/v1/update, DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:96)
Fix. Add [HttpPut] routes api/roomkeys/v1/updateAll, /updateName, /updateDescription, /updatePrice (can share the existing Update handler with a partial-update flag per action), reading RoomKeyId + the per-action fields from query/form, returning RoomKeyResponse.
POST api/roomkeys/v1/owns/bulk — SHAPE_MISMATCH (breaks-gameplay)
Verb OK (POST mapped; verified client POSTs with a serialized JSON ARRAY body via SetBody — AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065, read this session). Three defects: (1) request body [{RoomKeyId:Int64, AccountId:Int32}] is never read — ReadRoomKeyIdsAsync (lines 215-232) parses query and form only, so ids is always empty and the handler returns [] for every batch; (2) even with ids, the server checks only the CALLER's ownership while the request pairs name arbitrary AccountIds; (3) response key is "Owns" (line 171) and AccountId is absent, but the client formatter KHFKIJEGPDG reads RoomKeyId/AccountId/DoesPlayerOwnRoomKey, all non-nullable (verified KHFKIJEGPDG.txt:279-346) — "Owns" is skipped as unknown and DoesPlayerOwnRoomKey defaults to false. Batched key-door resolution is fully dead.
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:159
Fix. Parse the JSON body as an array of {RoomKeyId:long, AccountId:int}, query RoomKeyPurchases for each (AccountId, RoomKeyId) pair, and echo [{RoomKeyId, AccountId, DoesPlayerOwnRoomKey}] per request element.
POST api/roomcurrencies/v1/updateCurrency — SHAPE_MISMATCH (degraded)
Route+verb OK; FindCurrencyAsync (line 245) accepts "CurrencyId" so lookup works. But optional "Limit" is ignored (server reads dailyLimit only, line 68) and the response is the broken ToWire, so the edit UI redisplays wrong data (Limit=0, CurrencyId=empty).
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:55
Fix. Read "Limit" alias in UpdateCurrency; fix ToWire.
POST api/roomcurrencies/v1/updatePurchaseOffer — SHAPE_MISMATCH (degraded)
Route/verb OK; PurchaseOfferId lookup works via FindOfferAsync (line 256), Name/Amount/Price read. "Order" is silently dropped and the response is the same broken ToOfferWire (CurrencyPurchaseOfferId/CurrencyAmount/CurrencyId/Order/ModifiedAt all default), so the pack list re-renders wrong after every edit.
Handler: DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:159
Fix. Read/persist Order; fix ToOfferWire (same change as createPurchaseOffer).
PUT api/roomconsumables/v1/roomConsumable (create/update) — SHAPE_MISMATCH (degraded)
PUT and POST both mapped (verb OK); request parsing handles the nested PriceAndCurrency{Price,CurrencyId} body correctly (ReadPriceAndCurrency:476). Defect is the response: EditResponse embeds ToDescWire (lines 407-415) which nests PriceAndCurrency{Price,CurrencyId}, but the client's RESPONSE formatter FCIBLPCOODP reads FLAT keys Price, PurchaseCurrencyId, ModifiedAt (verified FCIBLPCOODP.txt:670-710 — no PriceAndCurrency key exists in that formatter). After saving, the editor shows Price=0, no currency, default ModifiedAt.
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:139
Fix. Change ToDescWire to flat: { RoomConsumableId, RoomId, Name, Description, ImageName, Price, PurchaseCurrencyId = c.CurrencyId, ModifiedAt = c.UpdatedAt }. Keep the nested reader for REQUESTS only. Also update docs/recroom-2023-room-consumables.md, which wrongly documents the nested desc as the response shape.
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me — SHAPE_MISMATCH (degraded)
Route/verb OK; the inventory-row envelope (RoomConsumableId/AccountId/Count/ConcurrencyCode/ModifiedAt/Consumable, Consumable never null) matches CICEHLNDLDE exactly — no NRE risk. But the nested Consumable is the same nested-PriceAndCurrency desc, so owned items carry Price=0/PurchaseCurrencyId=null/ModifiedAt=default inside the inventory too.
Handler: DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:95
Fix. Fixed automatically by the ToDescWire flattening.
POST api/roomkeys/v1/create — SHAPE_MISMATCH (degraded)
POST mapped, Status/RoomKey envelope and status enum match GDIOEPIOPEE. Three gaps: (1) request fields ImageName and PurchaseCurrencyId sent by the client are never read (ReadBodyAsync lines 234-286 only maps roomId/name/description/price), so keys lose their image and can never be room-currency-priced; (2) ToWire (lines 180-190) omits PurchaseCurrencyId, CreatedAt, ImageName which the client formatter ANHGLIFGACE reads (verified full key set this session) — they silently default; (3) ReadBodyAsync reads form or JSON body but never Request.Query — whether the 2023 client sends these as query string or form body is UNKNOWN (AFGEDDANEKP bodies are stripped in the decomp); the sibling RoomCurrenciesController reads both, RoomKeys should too for safety.
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:61
Fix. Extend RoomKeyEntity + ReadBodyAsync with ImageName and PurchaseCurrencyId (Guid?, empty-string = null); add query-param fallback; extend ToWire with PurchaseCurrencyId, CreatedAt, ImageName.
GET api/roomkeys/v1/purchased/{roomKeyId} — SHAPE_MISMATCH (degraded)
Route/verb/bare-boolean OK, but the semantics are inverted: the client call-site is the creator asking "has ANYONE bought this key" before destructive edits, while the server answers "has the CALLER bought it" (p.PlayerId == pid, line 147). A creator never buys their own key, so this always returns false and the sold-stock warning never fires.
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:143
Fix. Change the query to AnyAsync(p => p.RoomKeyId == roomKeyId) (optionally excluding the key's creator), no PlayerId filter.
GET api/roomkeys/v1/room?roomId={id} — SHAPE_MISMATCH (degraded)
Route/verb/array-shape OK; RoomKeyId/ReplicationId(Guid)/RoomId/Name/Description/Price present. ToWire omits PurchaseCurrencyId, CreatedAt, ImageName which the client formatter ANHGLIFGACE reads (verified) — key thumbnails blank, currency-priced keys indistinguishable from token-priced, CreatedAt defaults to DateTime.MinValue (non-nullable value type, silently defaulted, no crash).
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:22
Fix. Extend ToWire (and the entity, per the create fix) with PurchaseCurrencyId (Guid?), CreatedAt, ImageName.
GET api/roomkeys/v1/mine — SHAPE_MISMATCH (degraded)
Route/verb/array OK, but semantics differ: the client call-site is the watch key ring — keys the caller OWNS/PURCHASED — while the server returns keys the caller CREATED (k.CreatorPlayerId == pid, line 39). Players see none of their bought keys (and creators see keys they don't hold). Also inherits the lossy ToWire.
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:34
Fix. Join RoomKeyPurchases on PlayerId == caller and return the purchased keys' JBOBOABIEBN wires (decide whether creators' own keys should also appear; purchased set is what the UI labels 'mine').
GET api/roomkeys/v1/owns — SHAPE_MISMATCH (degraded)
Route/verb/bare-boolean OK, but the client sends BOTH "playerId" and "roomKeyId" query params and can ask about OTHER players (key-door gating); the server ignores playerId entirely and always answers for the caller (line 154-155). Key doors evaluating another player's ownership get the wrong answer whenever the target differs from the caller.
Handler: DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:151
Fix. Accept [FromQuery] long? playerId and check ownership for playerId ?? caller.
Avatar, wardrobe, consumables and custom items
avatar-inventory
Verified all 37 real client HTTP calls against the DorkNet server source (every handler file opened and read; the regex route index was cross-checked against actual attributes). 20 routes are fully OK. 9 endpoints are MISSING/verb-less: POST api/customAvatarItems/v1 (create), PUT+DELETE .../v1/{id}, GET+PUT+DELETE .../v1/design, POST .../v1/{id}/report, POST econ/customAvatarItems/v1/{id}/purchase (exists only under api/), GET api/consumables/v1/getTransferable/{id}, GET api/ugcPurchasables/v1/items/room/{roomId} — together these kill the entire custom-shirt designer/purchase pipeline and the consumable-transfer and in-room-store list flows. 8 shape/binding mismatches: bare-scalar violations on econ itemOwnershipLimit (object vs Int32, EconController.cs:101) and isCreationAllowedForAccount (object vs Boolean, CustomAvatarItemsController.cs:194); fromCreator returns a bare array where the client parses paged {Results,TotalResults} (CustomAvatarItemsController.cs:131); customAvatarItems/v1/bulk cannot receive the client's 'customAvatarItemIds' key by body ([FromBody] whitelist + 415-on-form, :60-71/:303-326) so remote players' shirts never resolve; consumables/v1/transfer binds [FromForm] but the client sends a JSON body → 415 (InventoryController.cs:212); saved-outfit DTO drops OutfitSelectionsV2 + CustomAvatarItems on both GET and set (AvatarSavedOutfitsController.cs:95-103); equipment/v1/update binds one object where ISIL shows the client posting a JSON array (PLAUSIBLE — server comments imply a single object bound live; needs a trace); gifts/generate v2+v3 keep [FromBody] and would 415 on the form-urlencoded body the same client helper provably produced for gifts/consume (PLAUSIBLE). Minor: v2/getUnlocked consumables hardcodes IsActive/IsTransferable=false; lockeditems ships a 5-key subset of the shared owned-item DTO (null TagList risk); minPriceForPublicItem key match is UNKNOWN (single-Int32 client DTO, key not in ISIL).
Client-side notes. HTTP layer: every request goes through request-builder BNDIAONDFFF; its ctor (native 0x1830036A0) takes the VERB ENUM in rdx and the route string in r9. Enum decoded from update/delete semantics across this subsystem: 0=GET, 2=POST, 3=PUT, 4=DELETE (e.g. customAvatarItems v1/{id} uses 3 for the update overload and 4 for delete; design save uses 3, design delete 4). Helpers: AFGEDDANEKP(key, boxedValue) adds a named body/query param (key literals visible in ISIL — the only place exact request keys can be read); FJLLPHFOOJJ sets a JSON body (JsonConvert via ALHIJCJOLCB or JsonUtility.ToJson); BPHHLAIILHP adds a multipart file part (always filename "file.bin"); AMDPEBKIHOH is the cached-GET variant; LBIIOPNIDAC.PBGNNIJLBDG is response-cache INVALIDATION — route strings passed to it (EAIGBBHMIKM.txt:2733, EAIGBBHMIKM_NestedType___c.txt:1335) are cache keys, not HTTP calls. Response DTO property getters are obfuscated auto-props with no key literals in ISIL (Newtonsoft reflection over name-preserved metadata), so exact response keys were grounded in the DorkNet server implementation that runs live against this exact client, which itself embeds per-key decompile citations (e.g. EKHJKDNHOPB.txt:471-718 for the avatar reader/writer). SERVER GAPS FOUND (client calls with no/incompatible server route): (1) GET api/consumables/v1/getTransferable/{id} — missing entirely; (2) POST api/customAvatarItems/v1 (create, multipart metadata+thumbnailImage) — no POST on collection; (3) PUT + DELETE api/customAvatarItems/v1/{id} — missing; (4) GET + PUT + DELETE api/customAvatarItems/v1/design — server only has POST; (5) POST api/customAvatarItems/v1/{id}/report — missing; (6) POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice= — purchase only exists under api/, not econ/; (7) GET api/ugcPurchasables/v1/items/room/{roomId} — server only accepts ?roomId= query form. SHAPE MISMATCHES (route exists but wire likely wrong): (a) econ/customAvatarItems/v1/itemOwnershipLimit — client parses BARE Int32, server sends {Limit,ItemOwnershipLimit} object; (b) api/customAvatarItems/v1/isCreationAllowedForAccount — client parses BARE Boolean, server sends an object (siblings isCreationEnabled/isRenderingEnabled correctly send bare booleans); (c) api/customAvatarItems/v2/fromCreator/{id} — client expects paged {Results,TotalResults} (CKFLFJCNEPH), server returns a bare array; (d) api/equipment/v1/update — client posts a JSON ARRAY (List), server binds a single object; (e) api/consumables/v1/transfer — client sends a JsonUtility JSON body, server only reads form/query; (f) api/customAvatarItems/v1/bulk — client's param key is "customAvatarItemIds", which the server's form-key whitelist does not include (works only if the client emits it as a query param); (g) saved outfits (BEPMJICDNDD) carry a 7th string (likely OutfitSelectionsV2) + CustomAvatarItems list that the server's SavedOutfitDto silently drops. The bare literals "api/avatar/", "api/consumables/", "api/customAvatarItems", "econ/customAvatarItems", "api/ugcPurchasables" are String.Format prefixes, not standalone routes. No key-literal evidence exists for GGKCJMIFLDJ (catalog SKU), NOAAFNFJPFB, CKFLFJCNEPH, JEFHGGIDKEG, NCMMAGDIBND, IHLMDLKOLNB — their key sets above come from the live-verified server wire (dual-cased where uncertain); treat unlabeled names as PLAUSIBLE, not CONFIRMED.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| GET | api/avatar/v1/defaultbaseavataritems |
none | JSON array of the same UnlockedAvatarItem shape as v4/items: only the custom-clothing templates (UGCTee_Shirt 2184, UGCPulloverHoodie_Shirt 2918) with IsBaseAvatarItem: true. Every row becomes a template client-side, so never list ordinary clothing here |
| GET | api/avatar/v1/defaultunlocked |
none | Same array shape as v4/items (server aliases it to the same handler; logged out it returns the default_unlocked_variants items, logged in the full owned set including them) |
| GET | api/avatar/v1/lockeditems |
Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own |
| GET | api/avatar/v2 |
none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte |
| GET | api/avatar/v2/gifts |
none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid |
| POST | api/avatar/v2/gifts/consume/ |
POST to the literal URL WITH trailing slash (no id in path). Params: "Id" (boxed Int64 gift id), "UnlockedLevel" (boxed Int32) — sent form-urlencoded (server had to accept form; [F | Gift object echo (server returns ToWire(gift)); client treats non-2xx as 'Gift is missing from the list.' error |
| POST | api/avatar/v2/gifts/generate |
Body params (exact key literals from ISIL): "GiftContext" (boxed Int32 enum IFKEEPDDNBC), "IsGameGift" (boxed Boolean), "AlternateGiftContext" (boxed Nullable), "Message" (s | Single gift object, same shape as api/avatar/v2/gifts element |
| POST | api/avatar/v2/set |
JSON body written by EKHJKDNHOPB writer, PascalCase keys: OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor, CustomAvatarItems:[{CustomAvatarItemId, BodyPart | Client fires-and-forgets (void); server echoes AvatarV2Dto |
| GET | api/avatar/v2/{playerId} |
none (player id is bare path segment under v2) | Same HHDLNAPEMGP object as api/avatar/v2 |
| POST | api/avatar/v3/gifts/generate |
Body params: "GiftContext" (boxed Int32), "Message" (string, optional) | Single gift object (same as v2) |
| GET | api/avatar/v3/saved |
none | JSON array of BEPMJICDNDD. Converter IMMOJHBENFI keys (IMMOJHBENFI.txt:587-910): Slot(Int32), PreviewImageName, OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor (strings), CustomAvatarItems([{CustomAvatarItemId, BodyPart}]). Client stores them in a Dictionary keyed by Slot. The client wears OutfitSelectionsV2 when non-empty, else OutfitSelections (BEPMJICDNDD.NLGCNDMLACA) |
| POST | api/avatar/v3/saved/set |
Raw JSON body (application/json) = one BEPMJICDNDD written by IMMOJHBENFI; same eight keys as the GET element. Verb ordinal 2 (FFFIMAGLKEG.txt:4071-4073). No delete route exists in this client | Client ignores body (status only, LDGADANDBIO); server echoes the outfit |
| GET | api/avatar/v4/items |
none | JSON array. Server DTO (verified live against this client): AvatarItemType(Int32), AvatarItemDesc(string, MUST have >=4 comma-parts 'guid,,,'), AvatarItemId(Int32 — must be unique+non-zero or wardrobe collapses), Platfor |
| GET | api/catalog/v1/all?onlyAvailableSkus=true |
query baked into the literal: onlyAvailableSkus=true | JSON array of SKU objects. Exact client key set not in ISIL (reflection DTO); server ships dual-cased dictionary verified live — required keys include SkuId(Int32 — string here throws FormatException), Slug/Sku, Name, De |
| POST | api/consumables/v1/consume |
JSON body = JsonUtility.ToJson(ConsumableItemRequest); server binds {Id(Int64), DeltaCount(Int32)} (client passes Mathf.Abs of the count) | Server returns {Id, Count}; client parse target is a string callback (Action) — tolerant |
| GET | api/consumables/v1/getTransferable/{playerId} |
none (Int32 id is a path segment — likely the prospective recipient account id) | JSON array of the same bulk-consumable shape. SERVER GAP: no route registered for api/consumables/v1/getTransferable/* (server-routes.txt has only consume/getUnlocked/transfer/updateActive) — this call 404s |
| POST | api/consumables/v1/transfer |
JSON body = JsonUtility.ToJson(TransferConsumableRequest) — field keys UNKNOWN from ISIL. LIKELY MISMATCH: server binds [FromForm] id/recipientPlayerId/quantity with a query fallba | Server returns {Success, SourceCount, RecipientCount}; client logs 'Failed to TransferConsumable' on error |
| POST | api/consumables/v1/updateActive |
JSON body = JsonUtility.ToJson(ActivateConsumableItemRequest). Field keys not visible in ISIL; server binds {Id(Int64), IsActive(bool)} and works live | Client ignores; error toast 'Invalid consumable' on failure. Server returns {Id, IsActive} |
| GET | api/consumables/v2/getUnlocked |
none | JSON array of BulkConsumable groups (PascalCase, verified live): Ids(Int64[]), CreatedAts(DateTime[]), ConsumableItemDesc(string), Count(Int32), InitialCount(Int32), IsActive(bool), ActiveDurationMinutes(Int32), IsTransf |
| POST | api/customAvatarItems/v1 |
multipart/form-data: named param "metadata" (JSON string) + file part "thumbnailImage" (filename "file.bin") added via BNDIAONDFFF.BPHHLAIILHP; two Byte[] args imply a second file | Single NOAAFNFJPFB custom-item object; server ToWire shape (used by sibling endpoints): Id/CustomAvatarItemId(Guid), CreatorId/CreatorPlayerId(Int32), Name, Description, Price(Int32), ItemType(Int32), BaseAvatarItemId(In |
| POST | api/customAvatarItems/v1/bulk |
Param key literal "customAvatarItemIds" (guid list added via helper 0x181F2DCD0). CAUTION: server ReadIdsAsync reads form keys ids/Ids/itemIds/ItemIds and ALL query pairs, but NOT | JSON array of NOAAFNFJPFB custom-item objects |
| GET | api/customAvatarItems/v1/design |
none | Single design-info object (4 props per ISIL getter set). SERVER GAP: only POST design exists; this GET 405s — client logs 'Unable to get custom avatar design info' |
| PUT | api/customAvatarItems/v1/design |
multipart: named param "metadata" + file part "design" (filename "file.bin"). SERVER GAP: design route only accepts POST — the client's PUT 405s | status only (LDGADANDBIO) |
| DELETE | api/customAvatarItems/v1/design |
none. SERVER GAP: no DELETE on design — client logs 'Unable to delete custom avatar item design' | status only |
| GET | api/customAvatarItems/v1/featured |
none | JSON array of custom-item objects (server ToWire shape as listed for the create endpoint) |
| GET | api/customAvatarItems/v1/hot |
none | JSON array of custom-item objects |
| GET | api/customAvatarItems/v1/isCreationAllowedForAccount |
none | `{"Success": bool, "Error": string |
| GET | api/customAvatarItems/v1/isCreationEnabled |
none | Bare JSON boolean; server correctly returns Content("true","application/json") |
| GET | api/customAvatarItems/v1/isRenderingEnabled |
none | Bare JSON boolean; server returns bare true |
| GET | api/customAvatarItems/v1/me |
Query params "skip" (boxed Int32), "take" (boxed Int32) | Paged object {Results:[NOAAFNFJPFB...], TotalResults:Int32} (server also emits extra Created/Owned arrays which the client ignores) |
| GET | api/customAvatarItems/v1/minPriceForPublicItem |
none | BARE JSON integer — FDKKOPAPDGF (MPBLNLMCEDL.txt:6922, same generic token as itemOwnershipLimit) continued by <GetPricingConfig>b__69_0(Int32 minimumPrice); the IHLMDLKOLNB config is built client-side |
| PUT | api/customAvatarItems/v1/{id} |
JSON body via FJLLPHFOOJJ carrying the changed fields (name/description/price/publish-state). SERVER GAP: only GET is registered on v1/{id:guid}; PUT 405s | Updated NOAAFNFJPFB object |
| DELETE | api/customAvatarItems/v1/{id} |
none (guid in path). SERVER GAP: no DELETE registered — 405 | status only (client fire-and-forget) |
| POST | api/customAvatarItems/v1/{id}/report |
JSON body via FJLLPHFOOJJ with report reason enum + free-text (exact keys UNKNOWN — body serialised from method args). SERVER GAP: no report route registered — 404 | status only |
| GET | api/customAvatarItems/v2/fromCreator/{creatorId} |
none beyond path id | Client expects the PAGED container CKFLFJCNEPH {Results, TotalResults}. LIKELY MISMATCH: server FromCreator returns a bare JSON array (Ok(rows.Select(ToWire))) not the paged object |
| POST | api/equipment/v1/update |
JSON body = serialised List (new List built at ISIL 038, JsonConvert via ALHIJCJOLCB then FJLLPHFOOJJ) — a JSON ARRAY of equipment objects. POSSIBLE MISMATCH: server b | Client fire-and-forget; error toast 'Failed to upload equipment slot updates'. Server returns {Updated:true} |
| GET | api/equipment/v2/getUnlocked |
none | JSON array (verified live): PrefabName(string), ModificationGuid(string), PlatformMask(Int32), IsPlatformLocked(bool), FriendlyName(string), Tooltip(string), Rarity(Int32), Favorited(bool), Equipped(bool), IsEquipped(boo |
| POST | api/ugcPurchasables/v1/items/bulk |
JSON body {"RoomId":N, "Ids":[{"itemType":Int32, "itemId":"guid"}]} — Ids is an array of OBJECTS, not flat guid strings (server comment documents this shape from live traffic; body | JSON array with one entry PER requested id, in request order (server synthesizes placeholders for unknown ids — missing entries make the client re-query forever and break player spawn) |
| GET | api/ugcPurchasables/v1/items/room/{roomId} |
none beyond path roomId. SERVER GAP: only GET api/ugcPurchasables?roomId=N is registered; the /v1/items/room/{roomId} path form 404s | JSON array of ugc-purchasable objects; server wire: UgcPurchasableId/PurchasableItemId/Id(Guid), InternalId(Int64), RoomId(Int64), CreatorPlayerId(Int32), Name, Description, ImageName, Price(Int32), CurrencyType(Int32), |
| GET | econ/customAvatarItems/v1/itemOwnershipLimit |
none | Client's declared response type is a BARE Int32 (method returns FGLDKEJLAKB). LIKELY MISMATCH: server returns object {Limit:1000, ItemOwnershipLimit:1000} — deserialising an object into Int32 fails; should be a ba |
| GET | econ/customAvatarItems/v1/owned |
Query "skip", "take" | Paged {Results:[custom item wire...], TotalResults:Int32}; server econ wire per item: CustomAvatarItemId/Id(Guid), CreatorPlayerId(Int32), Name, Description, Price, ItemType, ImageName, AssetName, Color |
| POST | econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} |
none beyond path guid + query requestedPrice(Int32). SERVER GAP: purchase exists only under api/customAvatarItems/.../purchase; there is NO econ/customAvatarItems/v1/{id}/purchase | JEFHGGIDKEG object, exact keys UNKNOWN; the api-side server handler returns {Success, Balance, Item} / {Success, AlreadyOwned, Item} |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
api/avatar/— String.Format prefix producing api/avatar/v2/{playerId}api/consumables/— String.Format prefix producing api/consumables/v1/getTransferable/{id}api/customAvatarItems— URL compositionapi/ugcPurchasables— URL compositionecon/customAvatarItems— URL composition
Defects
POST api/customAvatarItems/v1 — MISSING (breaks-gameplay)
The controller's bare collection route ([Route("api/customAvatarItems/v1")] class prefix) has only [HttpGet] List — there is no [HttpPost] on the collection, so the client's multipart create (named param 'metadata' JSON + file part 'thumbnailImage', filename file.bin, plus a second binary part for the item asset) gets 405 Method Not Allowed. Publishing a custom shirt from the in-game designer is impossible. (POST design at :221 is a different route and takes flat fields, not the multipart metadata+thumbnail contract.)
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:22
Fix. Add [HttpPost] Create to CustomAvatarItemsController: read Request.Form, JSON-parse the 'metadata' form field into the item fields (name/description/price/itemType/baseAvatarItemId/color/isPublic — capture the real metadata key names via request tracing on first client hit), persist Request.Form.Files parts ('thumbnailImage' → ImageName via the image store, remaining file part → AssetName), create the CustomAvatarItemEntity + creator ownership, return ToWire(item).
POST api/customAvatarItems/v1/bulk — SHAPE_MISMATCH (breaks-gameplay)
GET+POST bulk are registered, but the client's guid-list param key is 'customAvatarItemIds' (ISIL literal) and no server read path honors it as a body key: (a) [FromBody] BulkRequest only binds Ids/ItemIds, so a JSON body {"customAvatarItemIds":[...]} binds nothing; (b) the form-key whitelist at :319 is ids/Ids/itemIds/ItemIds only — and worse, a form-urlencoded POST never reaches it because the [FromBody] parameter makes ASP.NET reject non-JSON bodies with 415. Only a query-string encoding would work (ReadIdsAsync scans all query pairs at :311-314). Result: resolving custom shirts worn by other players returns [] (or 415), so remote players' shirts never render — the exact failure mode the UGC bulk endpoint's comment describes for its own past bug.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:60
Fix. Mirror UgcPurchasablesController.Bulk: drop [FromBody], add [Consumes] for json+form, read the raw JSON body case-insensitively accepting customAvatarItemIds/ids/itemIds arrays, and add 'customAvatarItemIds'/'CustomAvatarItemIds' to the form-key whitelist.
PUT api/customAvatarItems/v1/design — MISSING (breaks-gameplay)
The client saves the in-progress shirt design as PUT multipart ('metadata' param + 'design' file part, filename file.bin); the server route only accepts POST → 405, so the designer can never save the texture. Together with the missing collection POST this makes the whole shirt-designer feature non-functional.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221
Fix. Add [HttpPut("design")] handling multipart: JSON-parse the 'metadata' form field, store the 'design' file bytes (image store / blob table) on the per-player design row, return Ok() (client return type is status-only LDGADANDBIO).
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} — MISSING (breaks-gameplay)
A working purchase handler exists ONLY under the api/customAvatarItems/{v1,v2}/{id:guid}/purchase prefixes; EconController registers no purchase route, so the client's POST to econ/customAvatarItems/v1/{id}/purchase/?requestedPrice=N 404s — buying a custom shirt is impossible. Secondary risk once routed: the api-side handler returns {Success, Balance, Item} / {Success, AlreadyOwned, Item} while the client's result DTO JEFHGGIDKEG keys are UNKNOWN — verify the first live purchase response parse.
Handler: none (api-side sibling: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:265)
Fix. Add [HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")] (rooted route, trailing-slash-tolerant) on CustomAvatarItemsController delegating to the existing Purchase(Guid) action; also read the requestedPrice query param and reject/adjust when it differs from item.Price to keep price-display honesty.
POST api/avatar/v2/gifts/generate — FIXED
The client's AFGEDDANEKP params go out form-urlencoded; a live trace showed GiftContext=12000&IsGameGift=True&Message=… answered 415 while the handler was [FromBody]. It now binds through FormOrJsonModelBinder.
Handler: DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
POST api/avatar/v3/gifts/generate — FIXED
Same handler and fix as v2 generate.
GET api/avatar/v3/saved — FIXED
SavedOutfitDto now carries OutfitSelectionsV2 and CustomAvatarItems, the two IMMOJHBENFI keys it used to drop. Rows saved before the fix read back with V2 empty, which the client treats as "use OutfitSelections".
Handler: DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs
POST api/avatar/v3/saved/set — FIXED
The body is parsed by hand, case-insensitively, so all eight keys persist. Before this, loading a saved slot restored the stale V1 selection (or nothing), because the client wears OutfitSelectionsV2 whenever it is non-empty (BEPMJICDNDD.NLGCNDMLACA), and every custom shirt in the slot was lost.
Handler: DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs
GET api/consumables/v1/getTransferable/{playerId} — MISSING (degraded)
No handler anywhere in the server registers getTransferable (grep over all Controllers confirms; InventoryController has only getUnlocked/consume/transfer/updateActive). The client's DNNDEMBIDKJ.PJJFBMAICLN GET String.Format("{0}v1/getTransferable/{1}") therefore 404s, and the gifting/transfer UI cannot list which consumables can be sent to the target player.
Fix. Add [HttpGet("api/consumables/v1/getTransferable/{playerId:long}")] to InventoryController returning the caller's transferable consumables in the SAME BulkConsumable group shape as GetUnlockedConsumablesV2 (List client-side), with IsTransferable=true; the path id is the prospective recipient (use it for any recipient-side filtering or ignore).
POST api/consumables/v1/transfer — SHAPE_MISMATCH (degraded)
Route/verb exist but the handler binds [FromForm] id/recipientPlayerId/quantity (lines 214-216) with only a query-string fallback and never reads a JSON body. The client serializes TransferConsumableRequest via JsonUtility.ToJson and sends it as the request body — with [ApiController]+[FromForm], an application/json request is rejected 415 before the action runs (and even if it ran, the JSON values would never populate the form params → 400 invalid_transfer). Sending a consumable to another player always fails ('Failed to TransferConsumable').
Handler: DorkNet.Server/Controllers/API/Inventory/InventoryController.cs:212
Fix. Rework TransferConsumable to read the body manually (same pattern as AvatarGiftsController.ConsumeViaBody): if form content read form keys, else JSON-parse the raw body case-insensitively accepting the plausible JsonUtility field names (id/Id, recipientPlayerId/RecipientPlayerId/destinationPlayerId, quantity/Quantity/count/Count — exact client field names are UNKNOWN from ISIL, so log unmatched bodies via request tracing to capture the real keys on first live use), keep the query fallback.
POST api/equipment/v1/update — SHAPE_MISMATCH (degraded)
Route/verb exist, but the handler binds [FromBody] EquipmentUpdateRequest — a SINGLE object — while the client ISIL shows it serializing a List via JsonConvert, i.e. a JSON ARRAY body. System.Text.Json cannot bind an array into an object → model binding fails → automatic 400 → the favorite/equip change never persists (client shows 'Failed to upload equipment slot updates'). PLAUSIBLE rather than CONFIRMED: the server code comment (lines 69-73) describes live-observed behavior ('only Favorited was read... the skin never stuck') that implies some client body DID bind as a single object, which contradicts the array reading — one of the two observations is wrong, so verify with request tracing.
Handler: DorkNet.Server/Controllers/API/Inventory/InventoryController.cs:81
Fix. Bind the body as JsonElement (or read raw), accept BOTH a single object and an array of {PrefabName, ModificationGuid, Favorited, Equipped?, IsEquipped?}, and apply the existing per-item update loop to each element.
PUT api/customAvatarItems/v1/{id} — MISSING (degraded)
Only [HttpGet("{id:guid}")] exists on the id route — the client's PUT (JSON body with changed name/description/price/publish-state) 405s, so editing an existing custom shirt's metadata/price is impossible.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:50
Fix. Add [HttpPut("{id:guid}")] to CustomAvatarItemsController: load by PublicId, enforce CreatorPlayerId == caller, apply the DesignRequest-style optional fields from a leniently-parsed JSON body, return ToWire(item).
DELETE api/customAvatarItems/v1/{id} — MISSING (degraded)
No [HttpDelete] on the id route — deleting one's own custom item 405s (client is fire-and-forget, so the item just silently persists).
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:50
Fix. Add [HttpDelete("{id:guid}")]: creator-only, soft-delete (add an IsDeleted flag or set IsPublic=false and remove ownership rows), return Ok().
GET econ/customAvatarItems/v1/itemOwnershipLimit — SHAPE_MISMATCH (degraded)
Handler returns the JSON OBJECT {"Limit":1000,"ItemOwnershipLimit":1000}, but the client method signature is FGLDKEJLAKB<System.Int32> — it deserializes the response body as a BARE integer. Deserializing an object into Int32 throws, so the ownership-cap check preceding a custom-shirt purchase errors out. CONFIRMED from both sides (server code + client method signature).
Handler: DorkNet.Server/Controllers/Econ/EconController.cs:101
Fix. Change CustomAvatarItemOwnershipLimit to return Content("1000", "application/json") — the same bare-scalar pattern the server already uses for isCreationEnabled.
GET api/customAvatarItems/v2/fromCreator/{creatorId} — SHAPE_MISMATCH (degraded)
Route+verb exist (fromCreator/{creatorId:long} under the v2 class prefix), but the handler returns a BARE JSON array (Ok(rows.Select(ToWire)) at :131) while the client deserializes the paged container CKFLFJCNEPH {Results, TotalResults}. Parsing an array into that object throws / yields empty — viewing another player's creations shows nothing.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:114
Fix. Wrap in the paged object like Owned(): return Ok(new { Results = rows.Select(ToWire), TotalResults = total }) with a CountAsync before Skip/Take.
POST api/customAvatarItems/v1/{id}/report — MISSING (degraded)
No report route exists on CustomAvatarItemsController (grep for '/report' finds only clubreporting/bugreporting/inventions/playerevents/PlayerReporting) — reporting a custom shirt 404s.
Fix. Add [HttpPost("{id:guid}/report")] that leniently parses the JSON body (reason enum + free text; exact client keys UNKNOWN — log the first live body), stores a moderation row (reuse the PlayerReporting entity or a new CustomAvatarItemReport table), returns Ok().
GET api/customAvatarItems/v1/design — MISSING (degraded)
Only [HttpPost("design")] exists; the client's GET (load saved in-progress design, response NCMMAGDIBND {Int32, Int32?, String, String}) 405s → 'Unable to get custom avatar design info' every time the designer opens. Note the existing POST design action is also semantically different from the client's contract (it creates/updates a published item from flat fields; the client never POSTs design).
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221
Fix. Add [HttpGet("design")] returning the caller's saved design record. Response key names for NCMMAGDIBND are UNKNOWN from ISIL — ship a dual-cased dictionary hedging the 4 props (e.g. BaseAvatarItemId/ItemType ints + Color/ImageName strings) and capture the real names from client behavior; requires a per-player design storage row (new entity).
DELETE api/customAvatarItems/v1/design — MISSING (degraded)
No [HttpDelete("design")] — discarding the saved shirt design 405s ('Unable to delete custom avatar item design').
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221
Fix. Add [HttpDelete("design")] that removes the caller's design row and returns Ok().
GET api/customAvatarItems/v1/isCreationAllowedForAccount — FIXED
The first audit read the method's FGLDKEJLAKB<Boolean> return type as the wire type and switched the body to a bare boolean. That was wrong: the request is sent as FDKKOPAPDGF (MPBLNLMCEDL.txt:6216-6223) and the boolean is projected client-side from field +0x10 (HONEEPBHNLB, :9155-9216). DJMHAFPGLLN's converter LMCCNLLHLCJ registers only Success and Error (LMCCNLLHLCJ.txt:191-254). The bare true failed that parse, and the client logged Failed to query if local account can create Custom Avatar Items: An error occurred. The server now returns {"Success": allowed, "Error": null|reason}.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
GET api/ugcPurchasables/v1/items/room/{roomId} — MISSING (degraded)
The server only registers GET api/ugcPurchasables with a ?roomId= query (plus /update, /delete, /v1/items/bulk). The client's path form api/ugcPurchasables/v1/items/room/{roomId} matches nothing → 404 → a room's in-room UGC store list never loads on room join. The response shape itself (ToWire at :323-341) already matches the documented wire, so only the route is missing.
Handler: DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs:14
Fix. Add [HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")] mapped to the existing List logic (roomId from route instead of query).
GET api/customAvatarItems/v1/minPriceForPublicItem — FIXED
The wire is a bare Int32, not an IHLMDLKOLNB object. HCBOHHEACPK sends FDKKOPAPDGF (MPBLNLMCEDL.txt:6922, the same generic token as the bare-Int32 itemOwnershipLimit call at :2981), and the continuation is <GetPricingConfig>b__69_0(Int32 minimumPrice) (MPBLNLMCEDL_NestedType___c.txt:286). The server now returns a bare 100.
Handler: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
Clubs, threads, chat and comments
social-clubs-chat
Of 65 real client routes: ~17 fully work. 20 routes are entirely MISSING (all club-chat, room-comments CRUD, member/banned/requests lists+search, hasDisabledClubChat, clubChatEnabled, minlevel, permissions PUT, club DELETE, home-clubhouse PUT/DELETE, image DELETEs, thread favorite/moderate). 13 club write endpoints exist but bind [FromBody] JSON while the 2023 client always sends application/x-www-form-urlencoded (BNDIAONDFFF transport), so every club member-management action 415s. One systemic response-shape defect: the club details envelope (returned by details/create/clubhouse/modify/mainimage/additionalimage) sends int bitmasks for CoownerPermissions/ModeratorPermissions/MemberPermissions where the 2023 MMOCDPPONNG DTO is an object — Json.NET throws, so the club page and every club-settings save fails to parse even when the mutation succeeds. Two logic bugs: clubhouse PUT reads roomId from query so the form-posted roomId silently CLEARS the clubhouse, and requesttojoin inverts the Joinability enum (AskToJoin=2 is Forbidden, InviteOnly=1 is accepted as pending). Verified against client ISIL: comments/unreadcounts wire shape is a JSON ARRAY of UnreadRoomComments (OPELMBNJHNO.txt:856, Func<List,Dictionary<Int64,UInt32>> is a client-side projection) — the server's array response is correct and the task inventory's 'JSON object map' claim is wrong; it is however a zero-count stub. members/bulk guaranteed-400s because the client only sends repeated 'id' params and never clubId.
Client-side notes. TRANSPORT (applies to every endpoint here): the shared request builder BNDIAONDFFF (C:\tmp\recroom-2023-03-21-isil\IsilDump\RecNet.Runtime\BNDIAONDFFF.txt; decompile C:\tmp\recnet-runtime-decomp\BNDIAONDFFF.cs) is constructed as ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ serviceHost, string route). Verb ints observed: 0=GET, 2=POST, 3=PUT, 4=DELETE (BestHTTP enum). Hosts: 13=Clubs (all club/* + members/bulk), 8=Chat (thread/), 12=RoomComments (comments/), 3=Matchmaking (clubhousesearch) — enum in recnet-runtime-decomp/GJDLNNLKDIJ.cs. CRITICAL: parameters added via AFGEDDANEKP become URL query string ONLY for GET; for POST/PUT/DELETE they are sent as an application/x-www-form-urlencoded body (HTTPUrlEncodedForm/HTTPFormBase.AddField, or multipart when files present) — see FGHNOKLDOKO branch at BNDIAONDFFF.txt:2900-3260. The 2023 client NEVER sends JSON bodies on this path; every DorkNet handler that binds [FromBody] JSON for these routes (club member ops ClubsController.cs:503-677, snooze/rename in ChatController) will 415/400 for this client. ChatController already has form-reading twins for thread/withmembers, thread, thread/{id} (ChatController.cs:438-528) — the same treatment is needed for the club write endpoints. Clubs write helpers: IKMMOCKDKAF.IBAKMFKEEDJ(desc, verb, route, Action) parses an LCLFBBPEMIH details envelope; JNFEEDLCGHH is the status-only variant (no DTO parse). SERVER GAPS (2023 client will 404/405/415): missing routes — club/{id} DELETE, club/home/me PUT+DELETE, club/{id}/mainimage DELETE, club/{id}/additionalimage/{slot} DELETE, club/{id}/clubChatEnabled PUT, club/{id}/hasDisabledClubChat GET, club/{id}/minlevel PUT, club/{id}/members GET, club/{id}/members/banned GET, club/{id}/members/requests GET, club/{id}/members/requests/search GET, club/{id}/members/search GET, club/{id}/permissions/{role} PUT, thread/club/{clubId} GET, thread/message/{id}/moderate PUT, thread/{id}/favorite PUT, comments/get/{roomId} GET, comments/create/{roomId} POST, comments/delete/{id} DELETE, comments/read/{roomId}/{id} PUT. Verb mismatches — thread/{id}/leave (client POST, server DELETE only), thread/{id}/rename (client POST form, server PUT JSON). Param mismatch — members/bulk: client sends only repeated 'id' query params (clubId never transmitted; it's only the client cache key), server demands clubId+playerIds → always 400. Shape risk — the three Permissions keys in the club details envelope: server sends int bitmasks (ClubsController.cs:806-809) but the 2023 MMOCDPPONNG DTO is an object {ClubId:long, MembershipType:int, approveMember/banUnban/createEvent/editDetails/editPermissionSettings/postAnnouncement:bool} (keys per the PUT permissions closure; response casing unverified). RESPONSE KEY CASING caveat: 2023 DTO JSON names live in DataMember attribute blobs in global-metadata.dat and are NOT visible in the ISIL or decompile dumps; where I state keys they come from DorkNet's live-validated projections (ClubsController/ChatController) whose casing the 2023 client demonstrably accepts for the already-working endpoints — chat is camelCase (chatThreadId, chatMessageId, senderPlayerId, timeSent, contents, chatResult), clubs are PascalCase (ClubId, MemberCount, MembershipType, Clubs/TotalClubs/ContinuationToken), consistent with the per-DTO-casing memory note. MessageJson field names (Type/Version/Data) are UNOBFUSCATED in the binary and are the exact contents-string schema. Enum wire values worth persisting: MJOOBDNCHBO membership (Banned=-1, None=0, Pending_Requested=1, Pending_Invited=2, Pending_Denied=3, Member=10, Moderator=20, CoOwner=30, Creator=100 — matches ClubService constants memory), club State IACDINKNHKB (Active=0, PendingJunior=11, Moderation_ 100/101, MarkedForDelete=1000), message moderation MOFGOGFEHNN (0/11/100/101/102). Non-route literal: club_events/{0} is a player-events cache key (EMMAMFINMMJ/ENKNLDABNBB string builders), not HTTP; club/account/{0}/created, club/mine/created, club/mine/member, club/home/me double as client cache keys in IKMMOCKDKAF (GMCDPOAJOID:10948, AJDNEGBEKNI:30123-30328) but are also real GET routes.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| GET | club/account/{0}/created |
none (accountId in path) | JSON array of Club objects. Club CLR layout (recnet-runtime-decomp/FOIJDINBPFG.cs): ObscuredLong ClubId, string Name, string Description, string MainImageName, IACDINKNHKB State (int: Active=0,PendingJunior=11,Moderation |
| GET | club/categoryTags |
none | JSON array of strings (category tag names) |
| POST | club/create |
application/x-www-form-urlencoded: name (string), description (string), category (string). NOT JSON — BNDIAONDFFF.FGHNOKLDOKO puts AFGEDDANEKP params into HTTPUrlEncodedForm for no | LCLFBBPEMIH envelope (recnet-runtime-decomp/LCLFBBPEMIH.cs): Club (FOIJDINBPFG), CustomTags (List), AdditionalImages (List{string imageName,int slot}), 3x role-permission objects (MMOCDPPONNG), MyMem |
| GET | club/home/me |
none | single Club object (see club/account entry) |
| PUT | club/home/me |
form-urlencoded: clubId (Int64) | status-only (JNFEEDLCGHH parses no DTO — IKMMOCKDKAF.txt:26503-26612) |
| DELETE | club/home/me |
none | status-only |
| GET | club/mine/created |
none | JSON array of Club |
| GET | club/mine/member |
none | JSON array of Club |
| GET | club/mostactivetoday |
none | JSON array of Club (bare list, not paged envelope) |
| GET | club/search |
query: sort (int; MJLECOMCJCN: MemberCount_Desc=0,NewestFirst=1,OldestFirst=2,MostRecentlyUpdatedRoom=3,MostRecentlyUpdatedInvention=4), query (string), category (string), count (i | paged envelope EEMELNLCJJF: List + Int32 total + String continuationToken. Server's validated keys: Clubs, TotalClubs, ContinuationToken (ClubsController.cs:100-106) |
| GET | club/{0} |
none | single Club object |
| DELETE | club/{0} |
none (name arg is client-side only) | status-only |
| PUT | club/{0}/additionalimage/{1} |
form-urlencoded: imageName (string) | LCLFBBPEMIH details envelope |
| DELETE | club/{0}/additionalimage/{1} |
none | LCLFBBPEMIH details envelope |
| PUT | club/{0}/clubChatEnabled |
form-urlencoded: clubChatEnabled (bool) | LCLFBBPEMIH details envelope |
| PUT | club/{0}/clubhouse |
form-urlencoded: roomId (Int64). Server binds [FromQuery(Name="roomId")] (ClubsController.cs:271) — will miss a form-encoded roomId on PUT | LCLFBBPEMIH details envelope |
| DELETE | club/{0}/clubhouse |
none | LCLFBBPEMIH details envelope |
| GET | club/{0}/details |
none | LCLFBBPEMIH envelope (see club/create). MMOCDPPONNG permission object CLR layout: long ClubId, MJOOBDNCHBO MembershipType, 6 bools whose request-side key names are approveMember/banUnban/createEvent/editDetails/editPermi |
| GET | club/{0}/hasDisabledClubChat |
none | bare JSON boolean (FGLDKEJLAKB) |
| PUT | club/{0}/mainimage |
form-urlencoded: imageName (string) | LCLFBBPEMIH details envelope |
| DELETE | club/{0}/mainimage |
none | LCLFBBPEMIH details envelope |
| GET | club/{0}/members |
query: membershipType (int; Banned=-1,None=0,Pending_Requested=1,Pending_Invited=2,Pending_Denied=3,Member=10,Moderator=20,CoOwner=30,Creator=100), sortBy (int; Default=0,JoinDate_ | JSON array of member rows. CADEIMCFIIG CLR: int AccountId, long ClubId, MJOOBDNCHBO MembershipType, DateTime CreatedAt (keys per server's validated ToWireMembership, ClubsController.cs:836-842). MISSING on server (only / |
| POST | club/{0}/members/acceptinvite |
none | status-only |
| POST | club/{0}/members/acceptrequest |
form-urlencoded: accountId (Int32). Server binds [FromBody] JSON (ClubsController.cs:571) — content-type mismatch for this client | status-only |
| POST | club/{0}/members/acceptrequests |
form-urlencoded: accountIds (repeated Int32 list) | status-only |
| POST | club/{0}/members/ban |
form-urlencoded: accountId (Int32) | status-only |
| GET | club/{0}/members/banned |
query: sortBy, skip, take | JSON array; ICPNBOOIDLI CLR: int AccountId, long ClubId, DateTime CreatedAt (banned-at). MISSING on server |
| PUT | club/{0}/members/changetype |
form-urlencoded: accountId (Int32), membershipType (int enum) | status-only |
| POST | club/{0}/members/declineinvite |
none | status-only |
| POST | club/{0}/members/denyrequest |
form-urlencoded: accountId (Int32) | status-only |
| POST | club/{0}/members/denyrequests |
form-urlencoded: accountIds (repeated list) | status-only |
| POST | club/{0}/members/directJoin |
form-urlencoded: inviterAccountId (Int32), joinability (int; Open=0,InviteOnly=1,AskToJoin=2). Client short-circuits with toast "You're already a member of this club!" (IKMMOCKDKAF | status-only |
| PUT | club/{0}/members/invite |
form-urlencoded: accountId (Int32), membershipType (int enum) | status-only |
| POST | club/{0}/members/invitemembers |
form-urlencoded: accountIds (repeated list), bulkInviteType (int; Nearby=0,Friends=1) | status-only |
| POST | club/{0}/members/leave |
none | status-only |
| POST | club/{0}/members/remove |
form-urlencoded: accountId (Int32) | status-only |
| GET | club/{0}/members/requests |
query: skip, take | JSON array; AADIHDCMEDB CLR: long (request id), int AccountId, int? (inviter account id), long ClubId, MJOOBDNCHBO MembershipType, MDFFODMAIGJ status (Invited=0,Requested=1,Denied=2), DateTime CreatedAt. Response key cas |
| GET | club/{0}/members/requests/search |
query: parameters.name, parameters.sortBy (Default=0,RequestDate_Asc=1,RequestDate_Desc=2,Username_Asc=3,Username_Desc=4), parameters.maxCount, parameters.status (Invited=0,Request | paged envelope EDFOCLNECPM: List + Int32 total + String continuationToken (key casing UNKNOWN). MISSING on server |
| PUT | club/{0}/members/requesttojoin |
none | status-only |
| GET | club/{0}/members/search |
query: parameters.name, parameters.type (membership enum int), parameters.sortBy, parameters.maxCount, continuationToken (unprefixed) | paged envelope MBMNHFAPFCJ: List + Int32 total + String continuationToken (key casing UNKNOWN). MISSING on server |
| POST | club/{0}/members/unban |
form-urlencoded: accountId (Int32) | status-only |
| GET | club/{0}/members/{1} |
none | single membership row: AccountId (int), ClubId (long), MembershipType (int), CreatedAt (DateTime) — server-validated keys ClubsController.cs:836-842 |
| PUT | club/{0}/minlevel |
form-urlencoded: minLevel (Int32) | LCLFBBPEMIH details envelope |
| PUT | club/{0}/modify |
form-urlencoded: name, description, category (strings) | LCLFBBPEMIH details envelope |
| PUT | club/{0}/modifydetails |
form-urlencoded: allowJuniors (bool), customTags (list), joinability (int), visibility (int; Private=0,Public=1) | LCLFBBPEMIH details envelope |
| PUT | club/{0}/permissions/{1} |
form-urlencoded: approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement (6 bools) | LCLFBBPEMIH details envelope |
| GET | clubhousesearch/mostactivenow |
none | JSON array; OEJFMLMJINJ CLR: long (RoomId — same field name EEFNKAFLPLG as RoomComment.RoomId), long ClubId (field AAGOEEGKGJL, the ClubId name across club DTOs), int (player/active count, key UNKNOWN). Sent to Matchmaki |
| POST | comments/create/{0} |
form-urlencoded: message (string), subRoomId (Int64), style (int; Feedback=0,Idea=1), positionX, positionY, positionZ (floats). RoomComments host (r8=12) | single RoomComment. CLR (recnet-runtime-decomp/RecNet/RoomComment.cs): long CommentId, long RoomId, long? SubRoomId, int? AccountId, DateTime CreatedAt, Vector3? position, string message, EKHGDABMJOJ style, 2 bools. JSON |
| DELETE | comments/delete/{0} |
none | status-only ('Failed to delete comment'). MISSING on server |
| GET | comments/get/{0} |
query: count (Int32), minId (Int64) | JSON array of RoomComment. MISSING on server |
| PUT | comments/read/{0}/{1} |
none | status-only ('Failed to update latest read comment'). MISSING on server |
| GET | comments/unreadcounts |
roomIds (repeated Int64 list). Verb is dynamic: GET when <100 room ids (query), POST with form body when >=100 (verb = count<100 ? 0 : 2 — cmovl at instr 059-061). Server registers | JSON object mapping roomId (stringified Int64 key) -> unread count (UInt32) |
| GET | members/bulk |
query: id (repeated, one per account). clubId is NOT transmitted — it is only the client-side cache key (response handler NJNJEOFNJIG(clubId, List) at IKMMOCKDKAF.txt: | JSON array; FCKGOFHNDNJ CLR: int AccountId, MJOOBDNCHBO MembershipType |
| GET | thread/club/{0} |
query: maxCount (int, default 10), mode (int, semantics UNKNOWN — client always sends 0). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. The thread reader (EKEFFNBIOHJ.txt instrs 082/109/133/149/173/197/221/245/269) takes exactly nine keys in three casings: ChatThreadId, LastReadMessageId, Messages, LatestMessage, PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. IMPLEMENTED: ChatController.GetClubThreads — one club:{clubId} thread per club, roster-gated, returned as a one-element array |
| PUT | thread/message/{0}/moderate |
form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value (Action<MGGNHLHPHOF> at DLDKCILCKNA.txt instr 101). STILL MISSING on server — blocked on a ChatMessageEntity.ModerationState column |
| POST | thread/withmembers |
form-urlencoded: ids (repeated Int32 list), messageCount (int). Server has a form-reading variant (ChatController.cs:438-446) — matches | single thread object (get-or-create DM/group with these members) |
| GET | thread/{0} |
query: messageCount (int) | single thread object with messages |
| POST | thread/{0} |
form-urlencoded: messageContents = JSON string of DLDKCILCKNA.MessageJson with PRESERVED field names {Type (int: Text=0,PartyInvite=1), Version (int), Data (string)} (recnet-runtim | ANDECIOOJNB: {ChatMessage, chat-result enum}. Server's validated keys: chatMessage, chatResult; message keys: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents (ChatController.cs:792-810) |
| PUT | thread/{0}/favorite |
form-urlencoded: favorite (bool) | NOT a bare enum — the continuation is Func<GLIKNMPPEHL, MGGNHLHPHOF> (DLDKCILCKNA.txt instr 142), so the body is the wrapper object GLIKNMPPEHL, read by OGCMIDEFOEE.txt (instrs 042/069) with exactly two keys in three casings: ChatResult, IsFavorited. IMPLEMENTED: ChatController.Favorite — flag persisted in PlayerSettings under chat.favorite:<threadKey> and echoed back in the thread DTO's IsFavorited |
| POST | thread/{0}/leave |
none | chat-result enum. VERB MISMATCH: server registers HttpDelete only (ChatController.cs:337) → 405 for this client's POST |
| POST | thread/{0}/member/{1} |
none (both ids in path) | chat-result enum (PlayerAlreadyOnThread etc.). Server POST exists (ChatController.cs:673) |
| GET | thread/{0}/message |
query: messageCount (int), mode (int, semantics UNKNOWN), referenceMessageId (Int64) | JSON array of ChatMessage. CLR (recnet-runtime-decomp/RecNet/ChatMessage.cs): long MessageId, long ThreadId, int SenderAccountId, DateTime SentAt, string contents, MOFGOGFEHNN moderation state, MessageJson parsed content |
| POST | thread/{0}/message/{1}/read |
none | chat-result enum. Server accepts POST+PUT (ChatController.cs:268-269) — OK |
| POST | thread/{0}/rename |
form-urlencoded: name (string) | chat-result enum, bare value (Action<MGGNHLHPHOF> at DLDKCILCKNA.txt instr 162). FIXED: ChatController.Rename now registers PUT+POST and binds via FormOrJsonModelBinder |
| POST | thread/{0}/snooze |
form-urlencoded: snooze (bool) | chat-result enum. ENCODING MISMATCH: server POST exists but binds [FromBody] SnoozeRequest JSON (ChatController.cs:307-308) → 415 on form content-type |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
club_events/{0}— Player-events cache layer, keyed per club
Defects
POST club/create — SHAPE_MISMATCH (breaks-gameplay)
Request side is fine (ReadCreateClubRequestAsync at :888-912 reads form keys name/description/category). But the response is BuildDetailsResponseAsync (:794-808) whose CoownerPermissions/ModeratorPermissions/MemberPermissions are int bitmasks (PermissionsForRole, :819-826) while the 2023 LCLFBBPEMIH DTO declares three MMOCDPPONNG OBJECT fields (long ClubId, MJOOBDNCHBO MembershipType, 6 bools — verified C:/tmp/recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115, MMOCDPPONNG.cs:16-157). Json.NET deserializing an integer into a complex object throws, so club creation appears to fail client-side even though the club row is created.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:338
Fix. In BuildDetailsResponseAsync emit each *Permissions key as an object {ClubId, MembershipType, approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement} (key casing of the 6 bools per the PUT permissions request closure; response casing UNKNOWN — mirror request casing and validate live). Applies to all 8 envelope-returning handlers at once.
PUT club/home/me — FIXED
ClubsController.HomeMeSet reads the form-urlencoded clubId (query fallback) and stores it through ClubService.SetHomeClubAsync, which persists into the general-purpose PlayerSettings key/value bag under key club.home — no new column needed. Returns an empty 200; the client's LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>) parses no DTO. HomeClubAsync now prefers the stored pick and only falls back to most-recently-joined when nothing is stored or the stored club was disbanded.
DELETE club/home/me — FIXED
ClubsController.HomeMeClear removes the club.home PlayerSettings row (idempotent) and returns an empty 200.
DELETE club/{0} — FIXED
ClubsController.ClubDelete → ClubService.DisbandAsync: owner-only (co-owners/moderators are rejected, unlike ModifyAsync), soft delete stamping State = 1000 (IACDINKNHKB.MarkedForDelete). Every read path here filters State == 0, so the club disappears from browse/mine/details while its announcements and membership rows survive for moderation. Returns an empty 200.
PUT club/{0}/clubChatEnabled — MISSING (breaks-gameplay) — BLOCKED ON SCHEMA
No route → 404. Club-chat toggle in settings fails. Verified from the binary: MEPEAAJOGLP(Int64, Boolean) at IKMMOCKDKAF.txt:25601, route literal :25779, verb ordinal 3 (PUT) at :25802, form key clubChatEnabled (IKMMOCKDKAF_NestedType_BLCIHIILJNE.txt:67), response is the LCLFBBPEMIH details envelope. Deliberately NOT stubbed: a handler that accepted the toggle and dropped it would make the settings screen lie, and GET club/{0}/hasDisabledClubChat (now served) would keep answering false.
Fix. Needs ClubEntity.ClubChatEnabled (bool, default true). Once the column exists: [HttpPut("/club/{clubId:long}/clubChatEnabled")] reading the form clubChatEnabled through FormOrJsonModelBinder, write via ModifyAsync, return BuildDetailsResponseAsync, and update BOTH ToWireClub's ClubChatEnabled = true literal and ClubsController.ClubHasDisabledChat's false.
PUT club/{0}/clubhouse — SHAPE_MISMATCH (breaks-gameplay)
Route+verb exist, but the handler binds [FromQuery(Name="roomId")] (:271) while the 2023 client sends roomId in the form-urlencoded BODY. roomId binds null, and the PUT branch then executes c.ClubhouseRoomId = null (:277-278) — setting a clubhouse silently CLEARS it instead. Response envelope also hits the *Permissions int defect.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:269
Fix. In ClubClubhouse, when Request.HasFormContentType also parse Request.Form["roomId"] and prefer it over the query value before mutating.
GET club/{0}/details — SHAPE_MISMATCH (breaks-gameplay)
All 7 keys present (Club, CustomTags, AdditionalImages, CoownerPermissions, ModeratorPermissions, MemberPermissions, MyMembershipType) but the three *Permissions values are ints (0x7FFE/0x00FF/0x0007 from PermissionsForRole :819-826) whereas 2023's LCLFBBPEMIH fields JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG are MMOCDPPONNG OBJECTS (LCLFBBPEMIH.cs:64-115). Strict Json.NET read of int-into-object throws → the whole club page load fails. CustomTags/AdditionalImages are always empty (not persisted) — cosmetic on top.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:258
Fix. Emit MMOCDPPONNG-shaped objects for the three permission keys (see club/create finding); persist CustomTags/AdditionalImages later.
GET club/{0}/hasDisabledClubChat — FIXED (pending schema)
ClubsController.ClubHasDisabledChat 404s an unknown/disbanded club and otherwise answers a bare JSON false (the issuing method is FGLDKEJLAKB<System.Boolean> OFOJAHBKMOJ(Int64) — a bare boolean body, not an object). false is the true answer today: the value is the negation of the Club wire field ClubChatEnabled, which has no ClubEntity column and whose setter (PUT club/{0}/clubChatEnabled) is therefore still unimplemented, so no club can be in the disabled state. When the column lands, this handler and ToWireClub's ClubChatEnabled literal must change together.
PUT club/{0}/mainimage — SHAPE_MISMATCH (breaks-gameplay)
Handler binds [FromBody] MainImageRequest (JSON) at :399; the 2023 client posts form-urlencoded imageName → 415 Unsupported Media Type before the action runs. 'Modify Club Image' always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:396
Fix. Read Request.Form["imageName"] when HasFormContentType (mirror ReadCreateClubRequestAsync pattern), keep JSON path for other callers.
GET club/{0}/members — FIXED
ClubsController.ClubMembers binds membershipType/sortBy/skip/take and returns the bare List<CADEIMCFIIG> array via the existing ToWireMembership projection. membershipType is compared as the MJOOBDNCHBO wire value (after MembershipTypeFromPerms), not the stored perms int; with no filter the roster is real members only, so ban markers (256) and pending rows (128) stay in their own endpoints. sortBy implements GNLOJEONFIG (Default=privileged-first, JoinDate_Asc/Desc, Username_Asc/Desc) — usernames are joined in from Players once per request.
POST club/{0}/members/acceptrequest — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] MemberTargetRequest JSON binding (:571) vs client's form-urlencoded accountId → 415. Approving a join request always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:569
Fix. Add form support: when Request.HasFormContentType read accountId from Request.Form (a shared helper for all member ops); property name AccountId already aligns.
POST club/{0}/members/acceptrequests — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] BulkTargetRequest JSON vs client's repeated form accountIds → 415. 'Approve all' fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:589
Fix. Read repeated Request.Form["accountIds"] values when form content-type.
POST club/{0}/members/ban — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] JSON vs form accountId → 415. Ban always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:644
Fix. Same form-reading helper.
PUT club/{0}/members/changetype — SHAPE_MISMATCH (breaks-gameplay)
PUT is registered (:675) but [FromBody] JSON binding vs client's form accountId+membershipType → 415. Promote/demote always fails. (Wire enum handling via PermsFromMembershipType is otherwise correct: Member=10/Moderator=20/CoOwner=30 map to 0/24/124, ClubService.cs:366-372.)
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:674
Fix. Form-reading helper for accountId + membershipType.
POST club/{0}/members/denyrequest — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] JSON vs form accountId → 415.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:604
Fix. Form-reading helper.
POST club/{0}/members/denyrequests — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] JSON vs repeated form accountIds → 415.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:615
Fix. Form-reading helper.
PUT club/{0}/members/invite — SHAPE_MISMATCH (breaks-gameplay)
PUT registered (:504) but [FromBody] MemberInviteRequest JSON vs client's form accountId+membershipType → 415. Inviting a player always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:503
Fix. Form-reading helper.
POST club/{0}/members/invitemembers — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] InviteMembersRequest JSON vs client's repeated form accountIds + bulkInviteType → 415. Bulk invite fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:525
Fix. Form-reading helper (accountIds repeated; bulkInviteType may be ignored).
POST club/{0}/members/remove — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] JSON vs form accountId → 415. Kick always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:626
Fix. Form-reading helper.
PUT club/{0}/members/requesttojoin — SHAPE_MISMATCH (breaks-gameplay)
Verb OK (POST+PUT registered, no body needed) but the Joinability switch is inverted vs the client enum LNJLEKOPAHB (Open=0, InviteOnly=1, AskToJoin=2): server maps 1→pending and everything else→Forbid (:453-459). So the 'Ask to join' button on AskToJoin(2) clubs always gets 403, while InviteOnly(1) clubs wrongly accept join requests as pending.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:446
Fix. Change the switch: 0→instant member, 2→pending(128), 1(InviteOnly)→Forbid. Audit other Joinability comparisons in ClubService for the same inversion.
POST club/{0}/members/unban — SHAPE_MISMATCH (breaks-gameplay)
[FromBody] JSON vs form accountId → 415. Unban always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:659
Fix. Form-reading helper.
PUT club/{0}/modify — SHAPE_MISMATCH (breaks-gameplay)
PUT registered but [FromBody] ModifyClubRequest JSON (:371) vs client's form name/description/category → 415. Editing club name/description/category always fails.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:366
Fix. Read form fields when HasFormContentType (extend ReadCreateClubRequestAsync pattern to modify).
PUT club/{0}/modifydetails — SHAPE_MISMATCH (breaks-gameplay)
Same handler as /modify: [FromBody] JSON → 415 on the client's form allowJuniors/customTags/joinability/visibility. Additionally ModifyClubRequest has no CustomTags member and tags are never persisted.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:368
Fix. Form-read allowJuniors (bool), joinability (int), visibility (int), repeated customTags; persist CustomTags so the details envelope can echo them.
GET members/bulk — SHAPE_MISMATCH (breaks-gameplay)
Client sends ONLY repeated 'id' query params (clubId is a client-side cache key, never transmitted). Server hard-requires a clubId param (:702-704 → 400) and reads ids from 'playerIds' (:706-708), so even with clubId it would collect zero ids. Every call is a guaranteed 400 → membership badges on nameplates never resolve.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:697
Fix. Accept repeated 'id' query params; when clubId is absent, return each account's membership without club scoping (e.g. highest/most-relevant membership per account) or infer from context — response stays [{AccountId, MembershipType}].
GET thread/club/{0} — FIXED (was breaks-gameplay)
'thread/club/123' (3 segments) matched no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab could not open at all.
Fixed. ChatController.GetClubThreads — [HttpGet("/thread/club/{clubId:long}")], get-or-creates the club:{clubId} thread (named after the club on first materialisation), honours maxCount, accepts and ignores mode, and returns a one-element JSON ARRAY of ToWireThread objects. Non-roster callers and unknown clubs get [] (the client deserialises the body before it looks at the status). The thread helpers now understand club: keys: LoadParticipantsAsync resolves participants from ClubMemberships (pending 128 / banned 256 excluded), ThreadRecipientsAsync fans new messages out over the roster, IsThreadMemberAsync gates rename/favorite on roster membership, Leave refuses club threads, and GetThreads lists club channels that have messages. ToWireThread now also emits the two keys the 2023 reader wants and the 2020 DTO lacked: isFavorited and clubId.
POST thread/{0}/leave — VERB_MISMATCH (breaks-gameplay)
Server registers HttpDelete only; the 2023 client sends POST → 405. Leaving a group chat is impossible.
Handler: DorkNet.Server/Controllers/Chat/ChatController.cs:337
Fix. Add [HttpPost("/thread/{chatThreadId}/leave")] on the same action.
PUT club/{0}/additionalimage/{1} — STUB (degraded)
Two defects: (1) handler binds [FromBody] MainImageRequest JSON but the 2023 client sends form-urlencoded imageName → ASP.NET [ApiController] returns 415 before the handler runs; (2) even reachable, the handler is an admitted stub — it never persists the slot (comment at :411-416, 'gallery renders blank') and just re-emits current details. Response envelope also carries the *Permissions int defect.
Handler: DorkNet.Server/Controllers/Clubs/ClubsController.cs:417
Fix. Persist additional image slots (new AdditionalImages storage keyed clubId+slot), read Request.Form["imageName"] when HasFormContentType, echo them in the envelope's AdditionalImages as [{imageName,slot}] per HIKCHBLAMLP.
DELETE club/{0}/additionalimage/{1} — MISSING (degraded) — BLOCKED ON SCHEMA
Only POST/PUT registered → DELETE 405. 'Remove Additional Club Image' fails. Verified from the binary: AKJJLMLPBKN(Int64, Int32 slot, String imageName) at IKMMOCKDKAF.txt:16054 branches on the captured imageName (:16219) — verb ordinal 4 with no body when null (description "Remove Additional Club Image", :16229-16234), verb 3 with the form field otherwise (:16256-16260) — and both branches parse the LCLFBBPEMIH details envelope.
Deliberately NOT registered: the PUT twin is already an admitted stub that persists nothing, so a DELETE would have nothing to clear. Registering it would only convert a visible 405 into a silent success while the gallery stays blank.
Fix. Needs the additional-image store the PUT twin also wants: a ClubAdditionalImageEntity keyed (ClubId, Slot) with an ImageName column. Then register [HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")], delete the row, and have BuildDetailsResponseAsync project the rows into the envelope's AdditionalImages as HIKCHBLAMLP {imageName, slot} instead of the current Array.Empty<object>().
DELETE club/{0}/mainimage — FIXED
ClubsController.ClubMainImageDelete is its own action (NOT a third verb binding on the POST/PUT setter — aliasing an edit verb onto a destructive handler is exactly what made PUT /announcements/club/{c}/{a} delete posts). Clears ImageName through ModifyAsync and returns the full LCLFBBPEMIH details envelope, which is what IBAKMFKEEDJ parses on both branches of BLEDOCHHHJM — the DELETE branch is not status-only.
GET club/{0}/members/banned — FIXED
ClubsController.ClubMembersBanned returns the bare List<ICPNBOOIDLI> array — three fields only ({AccountId:int, ClubId:long, CreatedAt}; ICPNBOOIDLI has no MembershipType) — over rows carrying the perms 256 ban marker, with sortBy/skip/take. Moderator-gated (CanManageAsync): the ban list is staff-only information.
GET club/{0}/members/requests — FIXED
ClubsController.ClubMemberRequests returns the bare List<AADIHDCMEDB> array over rows with the 128 pending marker (and without the 256 ban marker), skip/take, moderator-gated. Two fields are still un-fillable rather than invented: InviterAccountId is emitted as null (the DTO declares it nullable; invites and requests both collapse to the single 128 marker on the target's own row, so no inviter is recorded) and Status is always Requested for the same reason. Neither is read by the accept/deny flow, which keys off accountId. Persisting invited-vs-requested would let Status be filled properly.
GET club/{0}/members/requests/search — FIXED
ClubsController.ClubMemberRequestsSearch reads the dot-prefixed keys straight off Request.Query (parameters.name, parameters.sortBy, parameters.maxCount, parameters.status, plus the unprefixed continuationToken) and returns the EDFOCLNECPM paged envelope. continuationToken is an opaque row offset (String on the DTO); an empty string means no further pages, matching the club/search envelope. NAHBJCGNJKA's RequestDate_Asc/Desc index the same JoinedAt column GNLOJEONFIG's JoinDate_Asc/Desc do, so both enums share one comparator. Envelope key names remain unverified (DataMember blobs are not in the ISIL/decompile), so it emits the entity-named pair validated on the sibling club/search envelope — Requests/TotalRequests — alongside Results/TotalResults; Json.NET ignores whichever pair the DTO doesn't declare.
GET club/{0}/members/search — FIXED
ClubsController.ClubMembersSearch, same treatment: parameters.name/parameters.type/parameters.sortBy/parameters.maxCount + continuationToken off Request.Query, returning the MBMNHFAPFCJ paged envelope with Members/TotalMembers plus Results/TotalResults and ContinuationToken.
PUT club/{0}/minlevel — MISSING (degraded) — BLOCKED ON SCHEMA
No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails. Verified from the binary: AOAFDLMFCGB(Int64, Int32) at IKMMOCKDKAF.txt:16448, route literal :16561, verb ordinal 3 (PUT) at :16584, form key minLevel (IKMMOCKDKAF_NestedType_AOOCOABJPDN.txt:67), response is the LCLFBBPEMIH details envelope via IBAKMFKEEDJ.
Fix. Needs ClubEntity.MinLevel (int, default 0). Once the column exists: [HttpPut("/club/{clubId:long}/minlevel")] reading the form minLevel through FormOrJsonModelBinder, write via ModifyAsync, return BuildDetailsResponseAsync, and swap ToWireClub's MinLevel = 0 literal for the column.
PUT club/{0}/permissions/{1} — MISSING (degraded)
Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. BLOCKED ON SCHEMA. Verified from the binary: HDDNEPNKCIK(MMOCDPPONNG) at IKMMOCKDKAF.txt:17537 formats the route from the DTO's own ClubId + MembershipType (:17671-17689), verb ordinal 3 (PUT) at :17709, description "Modify Club Permissions", response is the LCLFBBPEMIH details envelope. Form keys are the 6 bools editDetails/approveMember/createEvent/postAnnouncement/editPermissionSettings/banUnban (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253), matching the response-side reader BPHCOIBNCDP.txt:638-758.
Deliberately NOT stubbed: PermissionsForRole derives the six bools from the role with a fixed default policy and there is nowhere to store an override, so a handler would accept the checklist and silently discard it — the screen would appear to save and revert on reload.
Fix. Needs a per-role permission table (ClubId, MembershipType, the 6 bools). Then register [HttpPut("/club/{clubId:long}/permissions/{role:int}")] binding those 6 form keys through FormOrJsonModelBinder, upsert the row owner-only, and have PermissionsForRole read the stored row (falling back to the current derived default when absent) so BuildDetailsResponseAsync reflects the save.
POST comments/create/{0} — MISSING (degraded)
No comments/create route (grep zero hits; only /comments/unreadcounts and an unrelated rooms/{id}/comments exist) → 404. Posting room feedback/idea comments fails.
Fix. Add RoomComments CRUD to CommentsController: [HttpPost("/comments/create/{roomId:long}")] reading form message/subRoomId/style/positionX/Y/Z, persist a RoomCommentEntity, return a single RoomComment object (CommentId, RoomId, SubRoomId?, AccountId?, CreatedAt, position?, message, style, 2 bools — key casing UNKNOWN, dual-case via Dictionary and validate live).
DELETE comments/delete/{0} — MISSING (degraded)
No route → 404. Deleting a comment fails.
Fix. Add [HttpDelete("/comments/delete/{commentId:long}")], status-only 200.
GET comments/get/{0} — MISSING (degraded)
No route → 404. Room-details comment list never loads ('Failed to get room comments').
Fix. Add [HttpGet("/comments/get/{roomId:long}")] with query count (int) + minId (long), returning JSON array of RoomComment.
PUT thread/message/{0}/moderate — MISSING (degraded) — BLOCKED ON SCHEMA
4-segment path matches no thread template → 404. Chat moderation (hide/flag) fails.
Verb + shape re-verified from the binary: DLDKCILCKNA.txt PLBCHNGAEML(ChatMessage, MOFGOGFEHNN), instr 065 Move rcx, "thread/message/{0}/moderate", verb Move rdx, 3 at instr 075 (PUT), host Move r8, 8 (Chat), single form field moderationState at instr 087, response consumed by Action<MGGNHLHPHOF> at instr 101 = bare int.
Fix. Add ChatMessageEntity.ModerationState (int, default 0 = Active) + migration, emit it as the ModerationState key in ChatController.ToWireMessage (the ChatMessage reader CBKDKACJDOF.txt reads ChatMessageId / ChatThreadId / SenderPlayerId / TimeSent / Contents / ModerationState), then add [HttpPut("/thread/message/{messageId:long}/moderate")] reading form moderationState and returning bare int 0. Deliberately NOT stubbed: without the column the handler could only discard the request, which hides the gap behind a 200.
POST thread/{0}/rename — FIXED (was verb_mismatch, degraded)
Server registered PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat failed.
Handler: DorkNet.Server/Controllers/Chat/ChatController.cs — ChatController.Rename
Fixed. Route now carries both [HttpPut] and [HttpPost]; RenameRequest became a plain class with a parameterless ctor bound by [ModelBinder(typeof(FormOrJsonModelBinder))], so the client's form name and the admin SPA's JSON both bind. Still returns the bare int (Action<MGGNHLHPHOF> at DLDKCILCKNA.txt instr 162). Membership is now checked through IsThreadMemberAsync, which also understands club threads.
POST thread/{0}/snooze — SHAPE_MISMATCH (degraded)
Three defects for the 2023 client: (1) [FromBody] SnoozeRequest JSON → 415 on the form content-type; (2) the client's field is 'snooze' (bool), not an Until date — never bound; (3) the response is an object {chatResult, snoozedUntil} (:325-329) while the 2023 client deserializes a bare MGGNHLHPHOF enum — object-into-int throws even after the 415 is fixed.
Handler: DorkNet.Server/Controllers/Chat/ChatController.cs:307
Fix. Add a form-consuming variant: read Request.Form["snooze"] bool, set SnoozeUntil = snooze ? DateTime.MaxValue (or a policy window) : null, and return bare int 0; keep the JSON+object variant for the 2020 watch on the JSON content-type.
PUT comments/read/{0}/{1} — MISSING (cosmetic)
No route → 404. Unread-comment badge never clears.
Fix. Add [HttpPut("/comments/read/{roomId:long}/{commentId:long}")] persisting per-player last-read comment id (also lets comments/unreadcounts return real counts), status-only 200.
GET comments/unreadcounts — STUB (cosmetic)
Route exists for both GET and POST with form+JSON+query parsing of roomIds — matches the client's dynamic verb (GET <100 ids, POST >=100). Wire shape verified correct against the client binary: the response IS a JSON array — OPELMBNJHNO.txt:856 (instr 109) constructs Func<List, Dictionary<Int64,UInt32>>, i.e. the dictionary in the method signature is a client-side projection; the task inventory's 'JSON object map' claim is wrong. Element key names are attribute-driven/UNKNOWN; server hedges with RoomId/UnreadCount/Count aliases (Json.NET matches case-insensitively). Defect: counts are hardcoded 0 (no read-state tracking) so the unread badge never appears.
Handler: DorkNet.Server/Controllers/API/Comments/CommentsController.cs:40
Fix. Once comments/read persists per-player read state, compute real counts (max comment id per room vs player's last-read).
PUT thread/{0}/favorite — FIXED (was cosmetic)
No route → 404. Starring a thread failed.
Fixed. ChatController.Favorite — [HttpPut]+[HttpPost] on /thread/{chatThreadId}/favorite, form field favorite bound with FormOrJsonModelBinder. The response is NOT a bare int as previously recorded: the client's continuation is Func<GLIKNMPPEHL, MGGNHLHPHOF> (DLDKCILCKNA.txt instr 142), i.e. a client-side projection off a wrapper object, so the handler returns {chatResult, isFavorited} (reader OGCMIDEFOEE.txt, both keys casing-tolerant). The flag is per-(player, thread) and there is no column for it, so it is persisted in the generic PlayerSettings table under chat.favorite:<threadKey> and read back into every thread DTO's IsFavorited via LoadFavoritesAsync. Unknown threads answer ThreadNotFound(2), non-members MembershipNotFound(3).
Relationships, friends and messaging
relationships-messaging
Audited all 20 real HTTP routes of the relationships-messaging subsystem against DorkNet (RelationshipsController.cs, MessagesController.cs, ExternalFriendInviteController.cs; global JSON options at ServiceCollectionExtensions.cs:380-381 confirm PascalCase-verbatim serialization and NO AllowReadingFromString). 11 routes match the 2023-03-21 client exactly (v2/get, v2/addfriend, v2/removefriend, v2/sendfriendrequest, v2/acceptfriendrequest, v1/addfriendwithcode, v1 mute/unmute/ignore/unignore, messages v2/get, v2/send, v3/delete, favoriteFriendOnlineStatus). 9 defects: (worst) both externalfriendinvite referrer endpoints return object arrays where the client requires bare [Int32,...] — a hard Utf8Json fault at login bootstrap once a player has any stored invite; sendMultiple 400s whenever the client attaches a room because RoomId arrives as a JSON string and AllowReadingFromString is unset; sendfriendintroductions binds [FromForm] against a JSON body and always 400s; bulkignoreplatformusers reads key PlatformUserIds but the client sends PlatformIds (silent zero-row import); mutualfriends binds playerId instead of id AND returns account objects instead of Relationship objects (always-empty mutual-friends UI); createplatforminvite's response lacks Success/Error so the client always sees failure; sendtextmessageinvite drops friendCode/senderName; favorite/unfavorite return RecNetResult instead of a Relationship (non-fatal); offlineinvite/v1/send is fully mismatched but has zero call sites in this build. Cross-cutting constraint: ECGNEHMCGCN.PlayerID and KJECOLODAFA.FromPlayerId are ReadInt32 on this client — player ids must stay <= Int32.MaxValue.
Client-side notes. HOW I READ THE WIRE SHAPES. Response DTO JSON keys are NOT il2cpp string literals in the DTO types — this build serializes with a code-generated Utf8Json resolver, so each DTO has a sibling formatter type whose .ctor registers the key strings. Those ctors DO contain the literals (canonical PascalCase first, then the camelCase and all-lowercase automata variants). Formatter map used here: ECGNEHMCGCN -> RecNet.Runtime/FNIDAIDJBBO.txt; KJECOLODAFA -> KKOHONLGIHL.txt; PHMHCPEMABG -> GBPDOLJBABB.txt; DJMHAFPGLLN -> LMCCNLLHLCJ.txt; RecNet.DeleteMessagesRequestDTO -> AAKOLCABMCP.txt; PDNMHAFBHIB/KECFAGHHOKD -> JNCIDDKKHHN.txt. C:\tmp\recroom-2023-03-21-decompiled contains ONLY Assembly-CSharp, so RecNet.Runtime DTO layouts came from the Cpp2IL/Il2CppInterop dummy assemblies shipped in the dev package: "C:\tmp\recroom-2023-03-21-devdork-package\Z\Rec Room old versions\staging\7490748483298966814\MelonLoader\Dependencies\Il2CppAssemblyGenerator\Cpp2IL\cpp2il_out\RecNet.Runtime.dll" (field/enum constants) and ...\MelonLoader\Il2CppAssemblies\Il2CppRecNet.Runtime.dll (property CLR types). Il2CppDumper 6.7.46 fails on this binary (metadata v27 / il2cpp v29.1 overflow), so dump.cs is not available for 2023-03-21.
REQUEST-ENCODING RULE (applies to every endpoint above). All requests are built by BNDIAONDFFF..ctor(HTTPMethods verb, GJDLNNLKDIJ host, String route) — fields [+16]=verb, [+20]=host, [+24]=route, [+32]=param list, [+40]=raw JSON body (RecNet.Runtime/BNDIAONDFFF.txt:74-124). Verb enum is BestHTTP.HTTPMethods: Get=0, Head=1, Post=2, Put=3, Delete=4, Patch=5, Merge=6, Options=7. Host enum GJDLNNLKDIJ: Auth=0, API=1, Commerce=2, ... — every route in this subsystem uses host 1 (api.). BNDIAONDFFF.FGHNOKLDOKO (BNDIAONDFFF.txt:2490, ISIL 072-073 vs 099-133) shows: if verb==Get the AFGEDDANEKP params become a '?'-joined QUERY STRING; otherwise they become a BestHTTP.Forms.HTTPUrlEncodedForm body (application/x-www-form-urlencoded). AFGEDDANEKP skips null values (ISIL 031) and expands IEnumerable values into repeated keys. FJLLPHFOOJJ sets a raw JSON string body instead.
STRICTNESS. Unlike the 2020 client (LitJson + Util.GetKey, which throws on a missing key), the 2023 Utf8Json object formatters tolerate missing and unknown keys — a missing field silently becomes default(T). The real crash risks are TYPE mismatches: an object where an Int32 is expected (getplatformreferrers), or a JSON string where a number is expected. Enum-valued fields are safe either way: the enum formatter (RecRoom.Utf8json.Runtime/FMIHCGAPIOJ.txt:2187, ISIL 034-040 compares the token against 6=String and 5=Number) accepts both numeric and name form.
INT32 vs INT64 TRAP. ECGNEHMCGCN.PlayerID and KJECOLODAFA.FromPlayerId are read with ReadInt32 (FNIDAIDJBBO.txt deserialize; KKOHONLGIHL.txt deserialize), while KJECOLODAFA.Id / RoomId / PlayerEventId are Int64. Serving a player id above Int32.MaxValue in those two fields will break the 2023 client even though the same server field is a long for the 2020 client.
SERVER-SIDE GAPS FOUND WHILE CROSS-CHECKING (DorkNet at C:\Users\Alexa\Documents\Recnet). Every route in this subsystem is registered, but several bind or emit the wrong shape for THIS client:
- api/externalfriendinvite/v1/getplatformreferrers + gettextmessagereferrers — DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:46-66 returns an array of OBJECTS {InviteCode,Kind,Value,CreatedAt}; the client wants [Int32,...]. This is a hard deserialize fault, not a silent default.
- api/externalfriendinvite/v1/createplatforminvite — same file :16-30 returns {InviteCode,InviteUrl,Platform,PlatformId}; client wants {"Success":bool,"Error":string} and will read Success=false.
- api/externalfriendinvite/v1/sendtextmessageinvite — same file :32-44 binds [FromForm] phoneNumber + message; the 2023 client sends phoneNumber + friendCode + senderName (no "message"). Response should be {"Success","Error"}; the current {Success,InviteCode,PhoneNumber} only accidentally satisfies Success.
- api/relationships/sendfriendintroductions — RelationshipsController.cs:475-489 binds [FromForm] playerId + introducedPlayerId; the 2023 client posts a JSON body {"ToPlayerIds":[…],"AboutPlayerId":N}. Nothing binds. Response should be {"Success","Message"}.
- api/relationships/v1/bulkignoreplatformusers — RelationshipsController.cs:427-451 expects JSON key PlatformUserIds; the client sends PlatformIds. Zero rows imported, silently.
- api/relationships/mutualfriends — RelationshipsController.cs:454-473 binds [FromQuery] playerId, but the 2023 client sends id, and it returns account objects {AccountId,Username,DisplayName,ProfileImage} where the client expects Relationship objects. Both param name and element shape are wrong.
- api/relationships/v1/favorite|unfavorite — RelationshipsController.cs:354-360 returns RecNetResult {Success,Error}; the 2023 client parses the response as a Relationship (ECGNEHMCGCN). Non-fatal (defaults) but the wire contract is a Relationship.
- api/offlineinvite/v1/send — MessagesController.cs:509-531 binds RecipientId + Data and returns {success,messageId} (lowercase); the 2023 client sends only PlayerId and reads {"Message":string}. Fully broken for this client (though no 2023 call site exists, so it is currently unreachable).
- api/messages/v1/sendMultiple — MessagesController.cs:455-492 binds
long? RoomId, but the 2023 client sends RoomId as a JSON STRING ("123"). Server JSON options (DorkNet.Server/Startup/ServiceCollectionExtensions.cs:380-381) only null the naming policy; NumberHandling.AllowReadingFromString is NOT set, so a sendMultiple that carries a room will 400. Matching correctly today: api/relationships/v2/get, v2/addfriend, v2/removefriend, v2/sendfriendrequest, v2/acceptfriendrequest, v1/addfriendwithcode, v1/mute|unmute|ignore|unignore (server accepts both GET query id and POST form PlayerId), api/messages/v2/get (MessageDto keys Id/FromPlayerId/SentTime/Type/Data/RoomId/PlayerEventId are exactly right), api/messages/v2/send, api/messages/v3/delete, api/messages/v1/favoriteFriendOnlineStatus.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| POST | api/externalfriendinvite/v1/createplatforminvite |
application/x-www-form-urlencoded, single field: platformId= (lowercase p) | {"Success":Boolean, "Error":String}. Deserializer also accepts "success"/"error". Note this DTO uses Error, NOT Message. |
| POST | api/externalfriendinvite/v1/getplatformreferrers |
none — POST with NO body and NO params | BARE JSON ARRAY OF INTEGERS: [Int32, ...] (player ids). NOT an array of objects — the Utf8Json Int32 formatter will throw on an object/string element and fault the client task. |
| POST | api/externalfriendinvite/v1/gettextmessagereferrers |
none — POST with NO body and NO params | BARE JSON ARRAY OF INTEGERS: [Int32, ...] |
| POST | api/externalfriendinvite/v1/sendtextmessageinvite |
application/x-www-form-urlencoded, three fields: phoneNumber=, friendCode=, senderName= | {"Success":Boolean, "Error":String} — same generic-instantiation as createplatforminvite (identical metadata token 0x188477F70 on the typed-parse call in both methods) |
| GET | api/messages/v1/favoriteFriendOnlineStatus |
none | body is NOT parsed at all — BNDIAONDFFF.KDOPJCNKOOK only awaits status success. Any 2xx body (even empty) satisfies the client. |
| POST | api/messages/v1/sendMultiple |
raw JSON body (Dictionary<String,Object> serialized by LFHNDFEFEOD.ODHIHDMAPDF with tag "SendMessageMultiple"): {"ToPlayerIds":[Int64,...], "Type":Int32, "Data":String, "RoomId":"< | body is NOT parsed (KDOPJCNKOOK, status-only) |
| GET | api/messages/v2/get |
none (no query params) | BARE JSON ARRAY of Message (KJECOLODAFA). Element: {"Id":Int64, "FromPlayerId":Int32, "SentTime":DateTime (ISO-8601 string), "Type":Int32 enum MEPCALGGMJC, "Data":String, "RoomId":Int64|null, "PlayerEventId":Int64|null |
| POST | api/messages/v2/send |
application/x-www-form-urlencoded fields: ToPlayerId=, Type=, Data=, RoomId= (omitted entirely when the Nullable has no value — AFGEDDANEKP | body is NOT parsed (BNDIAONDFFF.KDOPJCNKOOK, status-only). Any 2xx is success. |
| POST | api/messages/v3/delete |
raw JSON body from RecNet.DeleteMessagesRequestDTO: {"MessageIds":[Int64,...]}. Deserializer-side variants accepted by the same DTO are "messageIds"/"messageids", but the client al | body is NOT parsed (KDOPJCNKOOK, status-only) |
| POST | api/offlineinvite/v1/send |
application/x-www-form-urlencoded, single field: PlayerId=. No Data/RecipientId field is sent. | {"Message":String} — a single-key object. Deserializer also accepts "message". The client projects it to the String result. |
| GET | api/relationships/mutualfriends |
query: id= (NOT playerId) | BARE JSON ARRAY of Relationship (ECGNEHMCGCN) — same element shape as v2/get: {"PlayerID":Int32,"RelationshipType":Int32,"Muted":Int32,"Ignored":Int32,"Favorited":Int32}. Response is client-cached via BNDIAONDFFF.AMDPEBK |
| POST | api/relationships/sendfriendintroductions |
JSON body produced by UnityEngine.JsonUtility.ToJson over EEGNOHOELBG/SendFriendIntroductionsRequest — exact keys: {"ToPlayerIds":[Int32,...], "AboutPlayerId":Int32}. Content is a | {"Success":Boolean, "Message":String}. Deserializer also accepts "success"/"message". Missing keys default (false/null) without throwing. |
| GET | api/relationships/v1/addfriendwithcode |
query: code= (AFGEDDANEKP(String,String,bool,bool) overload) | single Relationship object (see v2/get element shape) |
| POST | api/relationships/v1/bulkignoreplatformusers |
JSON body via JsonUtility.ToJson over EEGNOHOELBG/BulkBlockPlatformUsersRequest — exact keys: {"Platform":Int32 (HHJIBNMLOAC: 0 All,1 Steam,2 Oculus,3 PlayStation,4 Xbox,5 RecNet,6 | body is NOT parsed (BNDIAONDFFF.KDOPJCNKOOK, status-only) |
| GET | api/relationships/v1/favorite |
query: id= | single Relationship object (see v2/get element shape). Client discards it and projects to Boolean via a Func continuation. |
| GET | api/relationships/v1/unfavorite |
query: id= | single Relationship object (see v2/get element shape), projected to Boolean client-side |
| GET | api/relationships/v2/acceptfriendrequest |
query: id= | single Relationship object (see v2/get element shape) |
| GET | api/relationships/v2/addfriend |
query: id= (BNDIAONDFFF.AFGEDDANEKP("id", boxed Int32) on a GET request => query string) | single Relationship object, same shape as v2/get elements: {"PlayerID":Int32,"RelationshipType":Int32,"Muted":Int32,"Ignored":Int32,"Favorited":Int32} |
| GET | api/relationships/v2/get |
none (no query params, no body) | BARE JSON ARRAY of Relationship (ECGNEHMCGCN). Each element: {"PlayerID":Int32, "RelationshipType":Int32 enum (0 None,1 FriendRequestSent,2 FriendRequestReceived,3 Friend), "Muted":Int32 enum (0 None,1 Local,2 Remote,3 M |
| GET | api/relationships/v2/removefriend |
query: id= | single Relationship object (see v2/get element shape) |
| GET | api/relationships/v2/sendfriendrequest |
query: id= | single Relationship object (see v2/get element shape) |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
api/relationships/— Mute/unmute and block(ignore)/unblock from the player card and the personal block list (Assembly-CSharp/JKNCKAMFODO.txt, AGUI/StackedUI/PersonalBlockListScreen.txt)friend/— Generating the shareable friend-code URL when the player opens "share my friend code"
Defects
POST api/externalfriendinvite/v1/getplatformreferrers — SHAPE_MISMATCH (breaks-gameplay)
Verb OK (GET+POST registered; client uses body-less POST). But the response is an array of OBJECTS {InviteCode,Kind,Value,CreatedAt} (:54, :68-78) while the client deserializes List — Utf8Json's Int32 formatter throws on an object element, faulting the client task during SessionManager login bootstrap. Today the list is empty ([]) for players who never used createplatforminvite, which parses fine — but as soon as a player creates one platform invite, every subsequent login of THAT player hits a non-empty array and the bootstrap task hard-faults. Semantics are also inverted: the route means 'player ids who referred ME', but the server returns MY OWN outgoing invite records.
Handler: DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:46
Fix. In GetPlatformReferrers return a bare int array of referrer player ids for the CURRENT player as invitee. Requires tracking redemptions: when an externalinvite:platform code is redeemed by a new account, record (inviteePlayerId -> inviterPlayerId); the handler then selects those inviter ids as (int) and returns Ok(thatList). Until redemption tracking lands, returning Ok(Array.Empty()) is wire-safe, but per the no-stubs rule implement the redemption record with the fix.
POST api/externalfriendinvite/v1/gettextmessagereferrers — SHAPE_MISMATCH (breaks-gameplay)
Identical defect to getplatformreferrers: returns array of {InviteCode,Kind,Value,CreatedAt} objects where the client requires a bare [Int32,...]. Non-empty response (any player who used sendtextmessageinvite) hard-faults the login bootstrap task; also returns outgoing invites instead of referrers.
Handler: DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:57
Fix. Same as getplatformreferrers: return bare int array of the player ids who referred the current player via text invite, backed by a redemption record keyed on the externalinvite:text code.
POST api/relationships/sendfriendintroductions — SHAPE_MISMATCH (degraded)
Server binds [FromForm] long playerId + [FromForm] long introducedPlayerId, but the 2023 client posts a raw JSON body {"ToPlayerIds":[Int32,...],"AboutPlayerId":Int32} (JsonUtility.ToJson + FJLLPHFOOJJ raw body). With a JSON content type the form value provider never runs, the two non-nullable value-type params fail to bind, and [ApiController] returns 400 — the 'Introduce my friends' flow from the watch social UI always fails. The response shape is also wrong for the client's PHMHCPEMABG reader ({"Success":bool,"Message":string}): the current Ok(new { Success = true }) at :491 would pass on Success but never runs.
Handler: DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:475
Fix. Replace the [FromForm] binding with a [FromBody] DTO { public List? ToPlayerIds; public int AboutPlayerId; }, create one MessageEntity per recipient (Type 130 FriendIntroduction per the client's MEPCALGGMJC enum, Body carrying AboutPlayerId), and return Ok(new { Success = true, Message = "" }).
POST api/relationships/v1/bulkignoreplatformusers — SHAPE_MISMATCH (degraded)
Server's BulkIgnoreRequest (:417-421) has property PlatformUserIds, but the client's JSON body key is PlatformIds ({"Platform":Int32,"PlatformIds":[String,...]}). System.Text.Json leaves PlatformUserIds null, the guard at :432 returns {Imported:0}, and the Steam block-list import at login silently imports zero rows. Client does not parse the response (status-only), so no crash — just a silently non-functional platform block sync.
Handler: DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:427
Fix. In BulkIgnoreRequest add a second property bound to the client's key — e.g. [JsonPropertyName("PlatformIds")] public List? PlatformIds — and in the handler use req.PlatformIds ?? req.PlatformUserIds (keep the old name for any 2020-era caller).
POST api/messages/v1/sendMultiple — SHAPE_MISMATCH (degraded)
SendMultipleBody.RoomId is long? (:461) but the 2023 client stringifies RoomId into the JSON body as a STRING ("RoomId":"123", Int64.ToString at PDNMHAFBHIB.txt:4176-4183). Server JSON options (ServiceCollectionExtensions.cs:380-381) only null the naming policy — NumberHandling.AllowReadingFromString is NOT set — so System.Text.Json throws during body deserialization and [ApiController] returns 400. Every fan-out invite that carries a room (the party-invite path in SessionManager_NestedType_FMGMGPGPBKE) fails; the client is status-only so the failure is silent. sendMultiple WITHOUT a room (RoomId omitted when null) works today.
Handler: DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs:467
Fix. Annotate SendMultipleBody (MessagesController.cs:456) with [System.Text.Json.Serialization.JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] (scoped to this DTO, avoiding a global behavior change), or change RoomId to string? and long.TryParse it in the handler.
POST api/externalfriendinvite/v1/createplatforminvite — SHAPE_MISMATCH (degraded)
Request binding is fine ([FromForm] platformId matches the client's lowercase form field; platform defaults 0). But the response {InviteCode,InviteUrl,Platform,PlatformId} contains neither key of the client's DJMHAFPGLLN reader {"Success":bool,"Error":string} — Utf8Json leaves defaults, so the client always sees Success=false and treats every platform-friend invite as failed (no success feedback in the RRUI add-friends contact list).
Handler: DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:16
Fix. Return the invite-record keys plus the contract keys, e.g. Ok(new Dictionary<string,object>{["Success"]=true,["Error"]="",["InviteCode"]=code,["InviteUrl"]=url}) in ExternalFriendInviteController.CreatePlatformInvite.
GET api/relationships/v1/favorite — SHAPE_MISMATCH (cosmetic)
Server returns RecNetResult {"success":bool,"error":string} (DorkNet.Models/Auth/RecNetResult.cs:29-36), but the 2023 client parses the body as a Relationship (ECGNEHMCGCN). Utf8Json tolerates the unknown keys, so the client gets a default Relationship (PlayerID=0, Favorited=0) instead of the updated row. Non-fatal — the flag IS persisted (SetPreference :385-415) and resurfaces via v2/get — but the immediate response object is all-defaults, so any client logic reading the returned Favorited bit sees 0.
Handler: DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:354
Fix. In RelationshipsController.cs, make SetPreference (for the favorite/unfavorite call sites at :356/:360) return the caller's merged Relationship dictionary — after SaveChangesAsync, reload the pair rows and return Ok(BuildMerged(me, otherId, pair)) instead of Ok(new RecNetResult...). The mute/ignore call sites can share this (their responses are status-only on the client).
GET api/relationships/v1/unfavorite — SHAPE_MISMATCH (cosmetic)
Identical to v1/favorite: returns RecNetResult instead of a Relationship object; client reads all-default Relationship. Flag persists correctly server-side.
Handler: DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:358
Fix. Same as favorite — return BuildMerged from SetPreference.
GET api/relationships/mutualfriends — SHAPE_MISMATCH (cosmetic)
TWO defects. (1) Binds [FromQuery] long playerId but the 2023 client sends ?id=, so playerId binds 0 and the guard at :458 always returns an empty array — the 'X mutual friends' text is permanently 0/absent for every profile. (2) Even if the param bound, the element shape is {AccountId, Username, DisplayName, ProfileImage} (:462-471) while the client deserializes List and reads PlayerID/RelationshipType/Muted/Ignored/Favorited — unknown keys are ignored, so every element would be an all-default Relationship (PlayerID=0). Empty/duplicate-zero list means the mutual-friends UI silently shows nothing; no crash.
Handler: DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:454
Fix. In MutualFriends: bind [FromQuery(Name = "id")] long id (keep playerId as a fallback alias if desired), and return the mutual ids as Relationship dictionaries — for each mutual id, load the caller's pair rows and emit BuildMerged(me, mutualId, pair) (or SynthFriend under global-friends mode) so each element carries PlayerID/RelationshipType=3/Muted/Ignored/Favorited.
POST api/externalfriendinvite/v1/sendtextmessageinvite — SHAPE_MISMATCH (cosmetic)
Server binds [FromForm] phoneNumber + message, but the 2023 client sends phoneNumber + friendCode + senderName (there is no 'message' field). Both server params are nullable so binding succeeds; friendCode and senderName are silently dropped, and the stored record has an empty message slot. Response {Success:true,InviteCode,PhoneNumber} accidentally satisfies the client's {"Success","Error"} reader (Success=true, Error defaults null), so the SMS-invite UI reports success. Defect is data loss (the friend code that was supposed to be texted is never persisted), not a client fault.
Handler: DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:32
Fix. Change SendTextMessageInvite's binding to [FromForm] string? phoneNumber, [FromForm] string? friendCode, [FromForm] string? senderName; persist all three in the Value string; return {"Success":true,"Error":""} (Dictionary or PascalCase DTO, plus InviteCode if wanted).
POST api/offlineinvite/v1/send — SHAPE_MISMATCH (none)
Server binds [FromForm] RecipientId + Data, but this client sends a single form field PlayerId; recipientId binds null and the handler returns 200 {success:false,error:"invalid_recipient"} (lowercase keys). The client would read {"Message":string} => null. Fully mismatched — but NO call site exists anywhere in the 2023-03-21 dump (dead API surface), so nothing is currently reachable/broken for this client. Severity 'none' for this build; still wrong wire for completeness.
Handler: DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs:513
Fix. If closing the gap: additionally bind [FromForm(Name="PlayerId")] long? playerId, use it as the recipient when RecipientId is absent, and return Ok(new Dictionary<string,object>{["Message"]="ok"}) alongside the existing keys (a Dictionary sidesteps the camelCase anonymous-object trap).
Player events, playlists and keepsakes
events-playlists
Verified all 64 real client routes against DorkNet.Server. Telemetry, report, keepsake categories/globalconfig/rooms, progression active/event, and most playlist reads are OK. Broken clusters: (1) PlayerEvents — GET v1 list, GET v1/{id}/responses, POST v2 create/edit, POST v2/delete/{id} are wrong-verb (405); respond/deleteResponse read form+return bare int vs JSON+{Result}; club/{id} lacks {Events,ContinuationToken} wrapper; v1/all emits raw entity keys; broadcast implements a different feature; every v2 field-PUT returns a flat event instead of the PHHAKLPGNGC wrapper, and the tags/multiinstance/club PUTs also misread the request. (2) Playlists — bulk is GET-only; warning/accessibility/restrictions/levelvoting are POST-only no-ops; name/description/image/tags PUTs have [FromBody] params that 415 the client's form bodies. (3) Keepsakes — POST create has wrong request DTO and returns an object where the client needs a bare Guid; DELETE and collect routes missing; events/{id} shape wrong. (4) Progression — record DTO keys wrong; collect, xpboosts, previewEarnedXp routes missing. (5) event/{id}/instances (matchmaking host) not registered. Server serializes with PropertyNamingPolicy=null (ServiceCollectionExtensions.cs:380), and the 2023 Utf8Json readers accept Pascal/camel casings, so casing itself is not a defect anywhere; missing keys and verbs are.
Client-side notes. METHOD: verbs read from the BestHTTP.HTTPMethods immediate (0=GET,2=POST,3=PUT,4=DELETE) passed to the shared request helpers (CBKANFIOBCF.OHOGDIIEIJK-family, NLDBPDCNNCF named-op helper where verb sits in r8, BNDIAONDFFF ctor where verb is rdx). JSON keys read from Utf8Json generated-formatter ctors in the ISIL (each formatter lists reader-accepted casings Pascal/camel/lower per member, then the written PascalCase key array); member CLR types from the DTO type's getter signatures and the formatter's JECENNBIMEI typeof fetches. Enums (BAMLHAODDOG accessibility, AKFCIMLMAKA broadcast-perm, CAMMJINEJFD response type, BNCFHOOCHAI result, HMECHOKOCBB keepsake category, NGPHEBMBPIC warning, JHAOFLCJNAL accessibility) have no string-enum formatter in the dump -> serialized as integers. Servers may emit any accepted casing; PascalCase is what the client itself writes.
CLIENT ARCHITECTURE: CBKANFIOBCF = playerevents API client, EMMAMFINMMJ = PlayerEventService cache layer (its underscore literals are cache keys, not routes), NLDBPDCNNCF = rooms+playlists API client, NCCLEJPIABA = keepsakes client, CDBFONFHJDO = progressionEvents client, JDHIDKCKHDK = GameSight telemetry, RecNet.Matchmaking issues event/{id}/instances from the matchmaking host root. Two request-body styles coexist: Utf8Json DTO bodies (respond, report, v2 create/edit, broadcast, keepsake create) and UnityEngine.JsonUtility bodies (deleteResponse, bulkInvite) whose field names are NOT recoverable from the ISIL (fields only, no methods) - server should keep accepting multiple key spellings there.
SERVER GAPS FOUND (DorkNet.Server): (1) POST api/playerevents/v2 and POST api/playerevents/v2/{id} not registered (create/edit 405) and POST api/playerevents/v2/delete/{id} is HttpDelete-only - event creation/editing/deletion is broken for the 2023 client; (2) GET api/playerevents/v1 is POST-only; GET v1/{id}/responses is POST-only; (3) v1/respond + v1/deleteResponse read form fields and return bare ints, but 2023 sends JSON {PlayerEventId,Type} and expects {Result:int}; (4) v1/club/{id} must return {Events,ContinuationToken}; v1/all must return {Created:[PlayerEvent],Responses:[{PlayerEvent,PlayerEventResponse}]}; tagfilters missing TrendingFilters; broadcast is a different feature (set BroadcastingRoomInstanceId, request {PlayerEventId,BroadcastRoomInstanceId}, respond PHHAKLPGNGC); v2 field-PUTs should return the PHHAKLPGNGC wrapper; (5) playlists/bulk is GET-only but client POSTs form 'id'/'name'; playlists {accessibility,levelvoting,restrictions,warning} are POST-only but client PUTs (PlaylistsController.cs:118-122,242); (6) keepsakes: POST api/keepsakes must take {RoomId,SubRoomId,KeepsakeCategory} and return a bare Guid; DELETE api/keepsakes/{guid} and POST api/keepsakes/{guid}/collect ({TotalXp,SocialBoostXp}) are missing; api/keepsakes/events/{id} must return KeepsakeRoomInstanceIdsDTO lists; (7) progressionEvents record DTO keys wrong (client wants AccountId/Xp/GameMinutesToday/RewardsCollected/BonusRewardsCollected/XpBoostLastPurchasedAt); collect/{eventId}/{rewardIndex} (POST -> GiftDrop), {id}/xpboosts, {id}/xpboosts/{boostId}/previewEarnedXp missing; (8) event/{eventId}/instances (matchmaking-host instance browser, List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}>) not registered - only room/{roomId}/instances exists.
Also observed but outside this group's literal list: POST playlists (create, NLDBPDCNNCF.COBELCLDHFE, form 'name') and GET playlists (by name, query 'name') use the bare 'playlists' literal; rooms/curated_playlists GET -> List is registered server-side.
Endpoints
| Verb | Route | Request | Response |
|---|---|---|---|
| POST | api/gamesight/event |
form field 'EventData' = Utf8Json-serialized object with snake_case properties: {"type":String,"user_id":String,"identifiers":{"resolution":String,"language":String,"timezone":Stri | ignored by client (plain Task) - 200 with any/empty body |
| POST | api/keepsakes |
JSON body: {"RoomId":Int64,"SubRoomId":Int64?,"KeepsakeCategory":Int32(enum HMECHOKOCBB)} | bare JSON GUID string (the new KeepsakeInstanceId) - NOT an object |
| GET | api/keepsakes/categories |
none | {"Results":[{"KeepsakeCategoryId":Int32(enum HMECHOKOCBB),"VisualId":String,"LimitPerRoom":Int32,"XpValue":Int32,"IconOutlineImageName":String,"IconFilledImageName":String}],"TotalResults":Int32} - paged wrapper, not a b |
| GET | api/keepsakes/events/{progressionEventId} |
none | {"Instances":[{"RoomId":Int64,"KeepsakeInstanceIds":[Guid]}],"CollectionRecords":[{"RoomId":Int64,"KeepsakeInstanceIds":[Guid]}]} (both List) |
| GET | api/keepsakes/globalconfig |
none | {"KeepsakeFeatureEnabled":Boolean,"KeepsakeRoomLimit":Int32,"SocialXpBoostEnabled":Boolean} |
| GET | api/keepsakes/rooms/{roomId} |
none | {"Instances":[{"KeepsakeInstanceId":Guid,"KeepsakeCategoryConfigId":Int32(enum HMECHOKOCBB),"PlacedByAccountId":Int32,"RoomId":Int64,"SubRoomId":Int64?}],"CollectionRecords":[{"AccountId":Int32,"KeepsakeInstanceId":Guid, |
| DELETE | api/keepsakes/{keepsakeInstanceId:guid} |
none (path = String.Format("{0}/{1}", "api/keepsakes", guid)) | no body consumed; 200 suffices |
| POST | api/keepsakes/{keepsakeInstanceId:guid}/collect |
none | {"TotalXp":Int32,"SocialBoostXp":Int32} |
| GET | api/playerevents/v1 |
none | JSON array of HPIOAGDJHDH (see v1/{0}) |
| GET | api/playerevents/v1/all |
none | {"Created":[HPIOAGDJHDH],"Responses":[{"PlayerEvent":HPIOAGDJHDH,"PlayerEventResponse":{"PlayerEventResponseId":Int64,"PlayerEventId":Int64,"PlayerId":Int32,"CreatedAt":DateTime,"Type":Int32(enum CAMMJINEJFD)}}]} |
| GET | api/playerevents/v1/all/{0} |
none | same as api/playerevents/v1/all |
| POST | api/playerevents/v1/broadcast |
JSON body: {"PlayerEventId":Int64,"BroadcastRoomInstanceId":Int64?} (null clears) | PHHAKLPGNGC wrapper |
| POST | api/playerevents/v1/bulk |
form field 'Ids' (generic list overload of request-builder AFGEDDANEKP; event ids) | JSON array of HPIOAGDJHDH |
| POST | api/playerevents/v1/bulkInvite |
JSON body via JsonUtility.ToJson(RecNet.Events.BulkInviteRequest); field names not in ISIL (fields only); server-side DTO {PlayerEventId, InvitedPlayerIds:List} matches the me | {"FailedInvites":[{"InvitedPlayerId":Int32,"Result":Int32(enum BNCFHOOCHAI)}],"Result":Int32(enum BNCFHOOCHAI)} |
| GET | api/playerevents/v1/club/{0} |
query: take (Int32, only when non-null), continuationToken (String, only when non-null) | {"Events":[HPIOAGDJHDH],"ContinuationToken":String} (both required keys) |
| GET | api/playerevents/v1/clubs |
query: repeated 'id' per club id | JSON array of HPIOAGDJHDH |
| POST | api/playerevents/v1/deleteResponse |
JSON body via UnityEngine.JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest) carrying eventId (Int64 at +0x10) and response type (enum at +0x18); exact JSON field names UNKNOW | {"Result":Int32(enum BNCFHOOCHAI)} |
| POST | api/playerevents/v1/report |
JSON body: {"ReportCategory":Int32(enum),"PlayerEventId":Int64,"Details":String} | {"Success":Boolean,"Message":String} |
| POST | api/playerevents/v1/respond |
JSON body (Utf8Json): {"PlayerEventId":Int64,"Type":Int32(enum CAMMJINEJFD)} | {"Result":Int32(enum BNCFHOOCHAI)} - an OBJECT, not the 2020 bare int |
| GET | api/playerevents/v1/room/{0} |
none | JSON array of HPIOAGDJHDH |
| GET | api/playerevents/v1/search |
query: query (String), sort (Int32 enum PJBLEKMMACM), scheduleFilter (Int32 enum BJBLPLKMLBE, omitted when null) | JSON array of HPIOAGDJHDH |
| GET | api/playerevents/v1/searchlive |
query: query (String) | JSON array of CLDDIKOJMAM = HPIOAGDJHDH keys + {"PlayerCount":Int32,"IsFull":Boolean} |
| GET | api/playerevents/v1/tagfilters |
none | {"PinnedFilters":[String],"PopularFilters":[String],"TrendingFilters":[String]} |
| GET | api/playerevents/v1/{0} |
query: includeDetails (Boolean; lambda __c), OAKKDBLNKLG variant adds clubId (Int64; lambda DEIBMMABHIK keys 'includeDetails','clubId') | HPIOAGDJHDH: {"PlayerEventId":Int64,"CreatorPlayerId":Int32,"RoomId":Int64,"SubRoomId":Int64?,"ClubId":Int64?,"Name":String,"Description":String,"ImageName":String,"StartTime":DateTime,"EndTime":DateTime,"AttendeeCount": |
| GET | api/playerevents/v1/{0}/responses |
none | JSON array of {"PlayerEventResponseId":Int64,"PlayerEventId":Int64,"PlayerId":Int32,"CreatedAt":DateTime,"Type":Int32(enum CAMMJINEJFD)} |
| POST | api/playerevents/v2 |
JSON body: {"RoomId":Int64,"SubRoomId":Int64?,"ClubId":Int64?,"Name":String,"Description":String,"Tags":[String],"ImageName":String,"StartTime":DateTime,"EndTime":DateTime,"Accessi | {"PlayerEvent":MDCBEPJCJPO(HPIOAGDJHDH+Tags),"Result":Int32(enum BNCFHOOCHAI),"TagModifyResult":{"Result":Int32(enum OFLPPEFGGOP),"Tags":[String]}} |
| POST | api/playerevents/v2/delete/{0} |
none (empty POST) | PHHAKLPGNGC wrapper |
| POST | api/playerevents/v2/{0} |
same JSON body as POST api/playerevents/v2 | PHHAKLPGNGC (see v2 create) |
| PUT | api/playerevents/v2/{0}/accessibility |
form field: accessibility (Int32 enum) | PHHAKLPGNGC wrapper {PlayerEvent,Result,TagModifyResult} |
| PUT | api/playerevents/v2/{0}/club |
form field: clubId (Int64, only when non-null; omitted = clear) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/description |
form field: description (String) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/image |
form field: imageName (String) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/multiinstance |
form fields: isMultiInstance (Boolean), supportsMultiInstanceRoomChat (Boolean), defaultBroadcastPermissions (Int32 enum), canRequestBroadcastPermissions (Int32 enum) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/name |
form field: name (String) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/room |
form fields: roomId (Int64), subRoomId (Int64, only when non-null) | PHHAKLPGNGC wrapper |
| PUT | api/playerevents/v2/{0}/tags |
JSON body = bare array of tag strings (lambda serializes the List via Utf8Json static ALHIJCJOLCB and sets it as body; no field name) | PHHAKLPGNGC wrapper (TagModifyResult carries accepted tags) |
| PUT | api/playerevents/v2/{0}/time |
form fields: startTime, endTime (DateTime strings) | PHHAKLPGNGC wrapper |
| GET | api/progressionEvents/active |
none | bare JSON integer = active progression event id (client wraps as Int64?) |
| POST | api/progressionEvents/collect/{eventId}/{rewardIndex} |
none | gift-drop object: {"Id":...,"FromPlayerId":...,"ConsumableItemDesc":...,"AvatarItemType":...,"AvatarItemDesc":...,"EquipmentPrefabName":String,"EquipmentModificationGuid":...,"CurrencyType":...,"Currency":...,"Xp":Int32, |
| GET | api/progressionEvents/event/{eventId} |
none | {"ProgressionEventId":Int64,"Name":String,"Rewards":[{"ProgressionEventRewardId":Int64,"GiftDropId":Int64,"ImageName":String,"Xp":Int32,"RewardIndex":Int32,"IsBonus":Boolean}],"KeepsakeRoomLists":[{"KeepsakeRoomListId":. |
| GET | api/progressionEvents/record/{progressionEventId} |
none | {"AccountId":Int32,"Xp":Int32,"GameMinutesToday":Int32,"RewardsCollected":Int32,"BonusRewardsCollected":Int32,"XpBoostLastPurchasedAt":DateTime?} |
| GET | api/progressionEvents/{eventId}/xpboosts |
none | JSON array of {"ProgressionEventPurchasableXpBoostId":Guid,"Cost":Int32,"XpMultiplier":number,"XpCap":Int32,"LookbackDurationTicks":Int64,"CooldownDurationTicks":Int64} |
| GET | api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp |
none | bare JSON integer (XP the boost would grant) |
| POST | data/event |
form fields: eventType (String), eventParams (serialized parameter bag) | ignored by client (fire-and-forget; error log 'Failed to send event data') - 200 empty is fine |
| GET | event/{0}/instances |
none (path served from the matchmaking-host root, same builder as matchmake/* and room/{0}/instances) | JSON array of {"RoomInstanceId":Int64,"RoomId":Int64,"SubRoomId":Int64,"IsFull":Boolean,"CreatedAt":DateTime,"PlayerIds":[Int32]} |
| POST | playlists/bulk |
form fields: repeated 'id' (Int64 overload) or repeated 'name' (String overload) | JSON array of CNIPGJIIFJF |
| GET | playlists/cheeredby/me |
none | JSON array of CNIPGJIIFJF |
| GET | playlists/createdby/me |
none | JSON array of CNIPGJIIFJF |
| GET | playlists/favoritedby/me |
none | JSON array of CNIPGJIIFJF |
| GET | playlists/visitedby/me |
none | JSON array of CNIPGJIIFJF |
| GET | playlists/{0} |
details variant: query include (bitmask; __c lambda) | CNIPGJIIFJF: {"PlaylistId":Int64,"Name":String,"Description":String,"ImageName":String,"WarningMask":Int32(enum NGPHEBMBPIC),"CustomWarning":String,"CreatorAccountId":Int32,"State":Int32(enum),"Accessibility":Int32(enum |
| DELETE | playlists/{0} |
none | no body consumed (Task); 200 suffices |
| PUT | playlists/{0}/accessibility |
form field: accessibility (Int32 enum JHAOFLCJNAL) | DAFNFCFDPJB |
| PUT | playlists/{0}/description |
form field: description (String) | DAFNFCFDPJB |
| PUT | playlists/{0}/image |
form field: imageName (String) | DAFNFCFDPJB |
| GET | playlists/{0}/interactionby/me |
none | {"Cheered":Boolean,"Favorited":Boolean,"LastVisitedAt":DateTime?} |
| PUT | playlists/{0}/interactionby/me/cheer |
none | no body consumed; 200 suffices. Client then invalidates cache key playlistinteraction/{id} |
| PUT | playlists/{0}/interactionby/me/favorite |
none | no body consumed; 200 suffices |
| PUT | playlists/{0}/levelvoting |
form field: supportsLevelVoting (Boolean) | DAFNFCFDPJB |
| PUT | playlists/{0}/name |
form field: name (String) | DAFNFCFDPJB (full playlist details) |
| PUT | playlists/{0}/restrictions |
form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | DAFNFCFDPJB |
| PUT | playlists/{0}/rooms/{1} |
none (empty PUT to add; DELETE to remove) | DAFNFCFDPJB |
| PUT | playlists/{0}/tags |
form fields: repeated 'tag' and repeated 'autoTag' | DAFNFCFDPJB |
| PUT | playlists/{0}/warning |
form fields: warningMask (Int32 enum), customWarning (String) | DAFNFCFDPJB |
Not HTTP routes
These literals look like paths but are cache keys, deeplinks or MIME types:
api/keepsakes/events— Cache invalidation after keepsake place/collectapi/progressionEvents— Cache invalidationevent/{0}/{1}— Share-event code/link generationevent_responses/{0}— RSVP-list cache in EMMAMFINMMJevents_by_club_ids/— Club-events cachemeetup/— Meetup share-code link generationplaylistinteraction/{0}— Playlist interaction cacheremote_player_event_list/{0}— Profile events cacheroom_events/{0}— Room events cache
Defects
GET api/playerevents/v1 — VERB_MISMATCH (breaks-gameplay)
Only [HttpPost("api/playerevents/v1")] (Create) is registered on this path; GET returns 405. The watch Events tab upcoming-events list (CBKANFIOBCF.HIHEFBMPOGC) fails entirely. The GET list handler that exists is on api/playerevents/v2 (line 32), which this client never GETs as a list.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:281
Fix. Add [HttpGet("api/playerevents/v1")] returning a JSON array of full HPIOAGDJHDH-shaped events (reuse the fixed ToWire).
GET api/playerevents/v1/club/{0} — SHAPE_MISMATCH (breaks-gameplay)
Handler returns a bare JSON array (line 119: Ok(rows.Select(ToWire))). Client deserializes IOKLNPFOLGI, an OBJECT {"Events":[...],"ContinuationToken":string}; array-vs-object makes the Utf8Json reader throw and the club events tab errors out. take/continuationToken query params are also ignored (no paging).
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:99
Fix. Return Ok(new { Events = rows.Select(ToWire), ContinuationToken = "" }) (non-null string), honoring take/continuationToken for paging.
GET api/playerevents/v1/all — SHAPE_MISMATCH (breaks-gameplay)
Created items are raw entity projections {Id,Title,Description,RoomId,StartsAt,EndsAt,Capacity} (lines 187-196) — none of the HPIOAGDJHDH keys match, so every created event deserializes as all-defaults. Responses items are flat {Id,EventId,Response,CreatedAt} (lines 201-207) but the client reads [{"PlayerEvent":{...},"PlayerEventResponse":{PlayerEventResponseId,PlayerEventId,PlayerId,CreatedAt,Type}}] — PlayerEvent comes back null and the My Events page breaks/NREs.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:177
Fix. Emit Created via ToWire and Responses as {PlayerEvent:ToWire(ev), PlayerEventResponse:{PlayerEventResponseId:r.Id, PlayerEventId:r.EventId, PlayerId:(int)r.PlayerId, CreatedAt:r.CreatedAt, Type:r.Response}} joining each response row to its event.
GET api/playerevents/v1/all/{0} — SHAPE_MISMATCH (breaks-gameplay)
Identical raw-entity-key defect as v1/all (lines 217-243). Another player's profile events page gets null PlayerEvents.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:214
Fix. Same wrapper/key fix as v1/all.
GET api/playerevents/v1/{0}/responses — VERB_MISMATCH (breaks-gameplay)
Path registered as [HttpPost] only (RespondList RSVP alias). Client GETs it for the attendee list -> 405; event details attendee list broken.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:250
Fix. Add [HttpGet("api/playerevents/v1/{eventId:long}/responses")] returning [{PlayerEventResponseId,PlayerEventId,PlayerId,CreatedAt,Type}] from PlayerEventResponses (Type = stored Response int).
POST api/playerevents/v1/respond — SHAPE_MISMATCH (breaks-gameplay)
Handler binds [FromForm] EventId/Response, but the 2023 client sends a JSON body {"PlayerEventId":long,"Type":int} — the form provider yields nothing, eventId stays null, and the handler returns bare int 2 (NoSuchEvent) without ever writing the RSVP. The client then Deserialize-s an OBJECT {"Result":int} from the bare int and throws. RSVP button fully broken.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:344
Fix. Accept JSON {PlayerEventId,Type} (keep form fallback for 2020), and return Ok(new { Result = 0 }).
POST api/playerevents/v1/deleteResponse — SHAPE_MISMATCH (breaks-gameplay)
Same defect pair as respond: form-only read of EventId (client sends a JsonUtility JSON body whose exact field names are unrecoverable from the ISIL) and bare-int response where the client expects {"Result":int}. Un-RSVP broken.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:363
Fix. Parse the JSON body accepting multiple candidate keys (eventId/EventId/PlayerEventId/playerEventId and type/Type/response/Response), keep form fallback, return {Result:0}.
POST api/playerevents/v2 — VERB_MISMATCH (breaks-gameplay)
Path only has [HttpGet] (ListUpcoming). The 2023 create-event flow POSTs a 14-field JSON body here -> 405. Event creation is impossible. (POST api/playerevents/v1 exists but the 2023 client never uses it.)
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:32
Fix. Add [HttpPost("api/playerevents/v2")] binding BIEFKAOABMP {RoomId,SubRoomId,ClubId,Name,Description,Tags,ImageName,StartTime,EndTime,Accessibility,IsMultiInstance,SupportMultiInstanceRoomChat,DefaultBroadcastPermissions,CanRequestBroadcastPermissions} and returning the PHHAKLPGNGC wrapper {PlayerEvent:<full event+Tags>, Result:0, TagModifyResult:{Result:0,Tags:[...]}}.
POST api/playerevents/v2/{0} — VERB_MISMATCH (breaks-gameplay)
Path only has [HttpGet]. Edit-event save POSTs the same BIEFKAOABMP body -> 405. Editing impossible.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:48
Fix. Add [HttpPost("api/playerevents/v2/{eventId:long}")] applying the full-body edit and returning the PHHAKLPGNGC wrapper.
PUT api/playerevents/v2/{0}/tags — SHAPE_MISMATCH (breaks-gameplay)
PUT registered, but the client body is a BARE JSON ARRAY of tag strings and ReadRequestFieldsAsync only parses JSON OBJECTS (line 742 checks ValueKind==Object) — the array is silently dropped, ReadStringFieldAsync returns null, and the handler stores empty string: saving tags always clears them. Tags are also stored in the side-table and never emitted; flat response instead of wrapper (whose TagModifyResult.Tags the client reads back).
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:468
Fix. When Content-Type is JSON and root is an array, read it as List; persist tags on the entity; return wrapper with TagModifyResult:{Result:0,Tags:accepted}.
PUT api/playerevents/v2/{0}/multiinstance — SHAPE_MISMATCH (breaks-gameplay)
PUT registered but reads field 'multiInstance' — the client sends 'isMultiInstance', 'supportsMultiInstanceRoomChat', 'defaultBroadcastPermissions', 'canRequestBroadcastPermissions'; none are read (case-insensitive dict, but names differ), so the setting is always stored false and the three other fields are dropped entirely. Flat response instead of wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:479
Fix. Read the four actual field names, persist all four on the entity (they are also HPIOAGDJHDH response keys), return wrapper.
POST api/playerevents/v1/broadcast — SHAPE_MISMATCH (breaks-gameplay)
Handler implements a text-message broadcast: binds {PlayerEventId,Message} and messages all RSVPs, returning {Success,Sent}. The 2023 client's feature is 'set/clear the broadcasting room instance': body {"PlayerEventId":long,"BroadcastRoomInstanceId":long?} and response PHHAKLPGNGC. BroadcastRoomInstanceId is never stored, the response shape is wrong, and as a bonus every RSVP gets a spurious message.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:573
Fix. Rewrite: bind {PlayerEventId,BroadcastRoomInstanceId?}, persist it as the event's BroadcastingRoomInstanceId (null clears), return the PHHAKLPGNGC wrapper.
POST api/playerevents/v2/delete/{0} — VERB_MISMATCH (breaks-gameplay)
Registered [HttpDelete] only; the 2023 client sends verb 2 (POST) -> 405, delete button broken. Response {success,error} is also wrong — client reads the PHHAKLPGNGC wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:375
Fix. Add [HttpPost("api/playerevents/v2/delete/{id:long}")] (keep HttpDelete) and return {PlayerEvent:, Result:0, TagModifyResult:{Result:0,Tags:[]}}.
POST playlists/bulk — VERB_MISMATCH (breaks-gameplay)
[HttpGet] only, reading query 'id'/'ids'. The 2023 client POSTs a form with repeated 'id' (or repeated 'name' for the by-name overload) -> 405. Hot/featured playlist hydration fails. No 'name' lookup exists at all.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:242
Fix. Add [HttpPost("/playlists/bulk")] reading form fields 'id' (repeated) and 'name' (repeated, resolve by Name), returning the same bare list of union entries.
PUT playlists/{0}/name — SHAPE_MISMATCH (breaks-gameplay)
PUT is registered, but the action declares a non-optional [FromBody] StringFieldRequest parameter alongside [FromForm]. The 2023 client sends application/x-www-form-urlencoded ('name'); with [ApiController], the JSON input formatter rejects the form content type and the request dies with 415 before the action runs. Rename broken. (Field-name case 'name' vs 'Name' would be fine — form binding is case-insensitive.)
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:77
Fix. Drop the [FromBody] parameter and read form/JSON manually (like PlayerEventsController.ReadRequestFieldsAsync), then keep returning BuildDetailsResponseAsync (that response shape matches DAFNFCFDPJB).
PUT playlists/{0}/description — SHAPE_MISMATCH (breaks-gameplay)
Same [FromBody]+form 415 defect as playlists/{0}/name.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:84
Fix. Same manual field-reading fix.
PUT playlists/{0}/image — SHAPE_MISMATCH (breaks-gameplay)
Same [FromBody]+form 415 defect; client sends form field 'imageName' (would bind to the [FromForm(Name="ImageName")] fallback case-insensitively once the FromBody parameter is removed).
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:91
Fix. Same manual field-reading fix.
PUT playlists/{0}/tags — SHAPE_MISMATCH (breaks-gameplay)
Two defects: (a) same [FromBody]+form 415 rejection; (b) even past that, the client sends REPEATED form fields 'tag' and 'autoTag', while the server reads a single 'Tags' CSV — the actual fields would never bind. Tag saves broken.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:98
Fix. Read form["tag"] and form["autoTag"] as repeated values, join into TagsCsv, return details response.
PUT playlists/{0}/warning — VERB_MISMATCH (breaks-gameplay)
[HttpPost] only -> client PUT gets 405. Additionally the handler is an explicit no-op ('field not persisted yet') — warningMask/customWarning are never stored, so even after adding PUT the setting is a stub (WarningMask always 0 in the union entry, line 3260 of RoomsController).
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:122
Fix. Add [HttpPut], read form warningMask/customWarning, persist to PlaylistEntity columns and surface them in BuildPlaylistUnionEntry.
PUT playlists/{0}/accessibility — VERB_MISMATCH (breaks-gameplay)
[HttpPost] only -> 405 on the client's PUT, and the shared PlaylistAck handler is a no-op — publish/private (accessibility enum) is never persisted; union entry hardcodes Accessibility=1. Publishing a playlist is impossible.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:118
Fix. Add [HttpPut], persist 'accessibility' form int on the entity, emit it in BuildPlaylistUnionEntry.
PUT playlists/{0}/restrictions — VERB_MISMATCH (breaks-gameplay)
[HttpPost] only + no-op stub. Client PUTs supportsJuniors/supportsScreens/supportsTeleportVR/supportsWalkVR booleans; 405 today, discarded after a verb-only fix.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:121
Fix. Add [HttpPut], persist the four booleans, surface in union entry.
PUT playlists/{0}/levelvoting — VERB_MISMATCH (breaks-gameplay)
[HttpPost] only + no-op stub; client PUTs 'supportsLevelVoting'; union entry hardcodes SupportsLevelVoting=false.
Handler: DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:120
Fix. Add [HttpPut], persist supportsLevelVoting, surface in union entry.
POST api/keepsakes — SHAPE_MISMATCH (breaks-gameplay)
Create() reads a legacy DTO {category,eventKey,title,description,imageName,earnedAt}; the 2023 client sends {"RoomId":long,"SubRoomId":long?,"KeepsakeCategory":int} — all three ignored, so the row is stored as category 'event' with a synthetic 'manual:{guid}' key. Response is a full object (ToWire) but the client does Deserialize expecting a BARE JSON GUID string -> throws. Placing keepsakes is fully broken, and the knock-on is that GET api/keepsakes/rooms/{roomId} (which filters category=='room' with room:{id} EventKeys) can never surface a client-placed keepsake.
Handler: DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:117
Fix. Bind {RoomId,SubRoomId,KeepsakeCategory}, store a real KeepsakeInstance row (guid id, room, subroom, category, placer), and return the bare Guid as the JSON body.
DELETE api/keepsakes/{keepsakeInstanceId:guid} — FIXED
Was: no DELETE route under api/keepsakes at all; a GUID path segment matched no template in KeepsakesController -> 404, so removing a placed keepsake failed.
Fixed. KeepsakesController.Delete ([HttpDelete("{keepsakeInstanceId:guid}")]) resolves the guid back to the row whose EventKey encodes it, gates on placer / room creator / accepted co-owner (RoomRoles.Role==0) / admin, deletes the instance plus any collection rows pointing at it, and returns a bodiless 200 — the client's issuing method returns the non-generic LDGADANDBIO promise (NCCLEJPIABA.JBKENCNIEPA, NCCLEJPIABA.txt:1544 + Move rdx, 4@1698) so no body is read.
POST api/keepsakes/{keepsakeInstanceId:guid}/collect — FIXED
Was: route not registered -> 404, so collecting a keepsake in-room failed.
Fixed. KeepsakesController.Collect ([HttpPost("{keepsakeInstanceId:guid}/collect")]) writes a collection row (Category="collection", EventKey="collect:{roomId}:{instanceGuid:N}" — no schema change) and returns {"TotalXp":int,"SocialBoostXp":int} (DHNBKMHDANK; keys from its Utf8Json formatter, RecNet.Runtime/PKCMBJFBHBO.txt:42,69). TotalXp is the whole award — the client renders TotalXp - SocialBoostXp as the base figure (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99) — and is priced from the same category XpValue that api/keepsakes/categories serves. SocialBoostXp is 0 while SocialXpBoostEnabled=false. Re-collecting the same instance is idempotent and awards 0.
Two supporting corrections in the same controller: api/keepsakes/categories now emits the full PIHCLHIKEPH id set (0-8, dump.cs:1199524) instead of three entries keyed by DorkNet's internal account/event/room buckets — the client folds the list into a Dictionary<PIHCLHIKEPH, KeepsakeCategoryConfigDTO> that every placed instance is looked up in — and api/keepsakes/rooms/{roomId} now returns real CollectionRecords ({AccountId, KeepsakeInstanceId, CollectedAt}, dump.cs:1234642 / CNNAFLNKDCL.txt:48,75,99) scoped to the caller instead of an empty array.
GET api/keepsakes/events/{progressionEventId} — SHAPE_MISMATCH (breaks-gameplay)
Returns {KeepsakeProgressionEventId, Instances:<legacy keepsake rows {Id,PlayerId,Category,EventKey,Title,...}>, CollectionRecords:[], KeepsakeProgressionEventIds}. Client KeepsakeProgressionEventInstancesDTO wants Instances AND CollectionRecords as List = [{"RoomId":long,"KeepsakeInstanceIds":[guid]}]. The row objects contain neither key -> RoomId=0 and KeepsakeInstanceIds=null per entry (NRE risk in the event hunt map).
Handler: DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:59
Fix. Group event keepsake instances by room and emit {Instances:[{RoomId,KeepsakeInstanceIds:[...]}],CollectionRecords:[same shape for the caller's collected ones]}.
POST api/progressionEvents/collect/{eventId}/{rewardIndex} — MISSING (breaks-gameplay)
Route not registered anywhere -> 404. Claiming a reward chest on the progression track fails; client expects the standard GiftDrop object (same DTO as gifts/generate, singular GiftDrop keys per docs/recroom-2023-room-consumables.md).
Fix. Add [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] that increments the player's RewardsCollected and returns a GiftDrop object (reuse the gifts/generate wire builder).
GET event/{0}/instances — FIXED
Was: only /room/{roomId:long}/instances existed; no event/{eventId}/instances was registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populated. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits.
Verb/shape ground truth: Matchmaking+<GetEventInstanceBrowser>b__0 (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats "event/{0}/instances" at :296 with the PlayerEventId ([rdi+16] of the HPIOAGDJHDH it closes over) and news up BNDIAONDFFF with rdx=0 at :124 = HTTPMethods.Get. Return type FGLDKEJLAKB<List<PNDCMIMEJLD>> = bare JSON array; PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are Int64/Int64/Int64/Boolean/DateTime/List<Int32>/String/Int32/Boolean/String, i.e. the same SimpleRoomInstance the room browser returns (Matchmaking.txt:14237 uses an identical BNDIAONDFFF/verb-0 sequence). The trailing SubroomName/PlayerCount/HasModPresent/HashedInstanceId are populated locally on the watch and are not read off the wire.
Fixed. [HttpGet("/event/{eventId:long}/instances")] now lives on PlayerEventsController.EventInstances (the route is a matchmaking-host-root path but the data is a player-event one): it resolves the event's RoomId plus the SubRoomId persisted in the event's extras blob, then enumerates PlayerPresenceService instances for that room (filtered to the sub-room when the event names one) and merges in the caller's own PrivateInstances rows, emitting the same six camelCase keys as room/{id}/instances. isFull is computed from the instance's MaxCapacity vs its presenced player count.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs (EventInstances)
GET api/playerevents/v1/{0} — SHAPE_MISMATCH (degraded)
Verb OK (HttpGet v1/{eventId} shares GetOne with v2). ToWire (lines 262-274) emits only {PlayerEventId,Name,Description,StartTime,EndTime,CreatorPlayerId,AttendeeCount,RoomId,ImageName,Accessibility}. Missing vs HPIOAGDJHDH: SubRoomId, ClubId, IsMultiInstance, SupportMultiInstanceRoomChat, DefaultBroadcastPermissions, CanRequestBroadcastPermissions, BroadcastingRoomInstanceId (Utf8Json leaves defaults: false/0/null). AttendeeCount hardcoded 0, Accessibility hardcoded 1, ImageName always "". includeDetails=true (MDCBEPJCJPO) never gets a Tags key -> Tags list null on the details page.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:49
Fix. Extend ToWire with SubRoomId/ClubId/IsMultiInstance/SupportMultiInstanceRoomChat/DefaultBroadcastPermissions/CanRequestBroadcastPermissions/BroadcastingRoomInstanceId (persist them on PlayerEventEntity instead of the PlayerSettings side-table), compute AttendeeCount from PlayerEventResponses, and emit Tags:[{Tag,Type}] when includeDetails=true.
POST api/playerevents/v1/bulk — SHAPE_MISMATCH (degraded)
POST registered; ReadEventIdsAsync (line 627) reads repeated form 'Ids' correctly. Response uses the shared ToWire, so each event is missing the seven HPIOAGDJHDH keys listed for v1/{0} (multi-instance flags, ClubId, etc.) and AttendeeCount is always 0.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:152
Fix. No route change; fix the shared ToWire shape.
GET api/playerevents/v1/room/{0} — SHAPE_MISMATCH (degraded)
Route and verb OK; array response OK; items use the shared incomplete ToWire (see v1/{0}).
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:87
Fix. Fix shared ToWire.
GET api/playerevents/v1/clubs — SHAPE_MISMATCH (degraded)
Verb/array OK, but the client's repeated 'id' query params (one per club id) are completely ignored — the handler returns events derived from ALL club members' rooms, not the requested clubs. Items also use the incomplete ToWire, and ClubId is never emitted so the client can't bucket events per club.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:123
Fix. Read the repeated 'id' query values, filter events by ClubId (requires persisting ClubId on the event entity), and emit ClubId in the wire object.
GET api/playerevents/v1/search — SHAPE_MISMATCH (degraded)
Verb OK; 'query' param handled (line 66). 'sort' (PJBLEKMMACM enum) and 'scheduleFilter' (BJBLPLKMLBE enum) are ignored — results always start-time ordered regardless of the chip the user picks. Items use incomplete ToWire.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:58
Fix. Honor sort/scheduleFilter query ints; fix shared ToWire.
GET api/playerevents/v1/searchlive — SHAPE_MISMATCH (degraded)
Shares the Search handler. Client DTO CLDDIKOJMAM additionally reads PlayerCount (Int32) and IsFull (Boolean); neither is emitted, so every happening-now event shows 0 players and never-full. Handler also doesn't restrict to currently-live events (only EndsAt > now, includes future).
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:59
Fix. Give searchlive its own handler filtered to StartsAt <= now < EndsAt, emitting ToWire + PlayerCount (from presence) + IsFull.
GET api/playerevents/v1/tagfilters — SHAPE_MISMATCH (degraded)
Emits PinnedFilters and PopularFilters only (lines 166-170). Client AKCLLEJNFFD also reads TrendingFilters:[String]; missing key deserializes to a null list — NRE risk when the chips UI iterates it.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:165
Fix. Add TrendingFilters = new[]{...} (can duplicate PopularFilters).
PUT api/playerevents/v2/{0}/accessibility — SHAPE_MISMATCH (degraded)
PUT registered and 'accessibility' form field read, but (a) response is the flat ToWire event, not the PHHAKLPGNGC wrapper {PlayerEvent,Result,TagModifyResult} the client reads -> PlayerEvent=null/Result=0 after save; (b) the value is written to a PlayerSettings side-table (SetEventSettingAsync line 759) while ToWire hardcodes Accessibility=1, so the change never reflects on any read — an effective stub.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:455
Fix. Persist Accessibility on PlayerEventEntity, return the PHHAKLPGNGC wrapper from all v2 field mutations.
PUT api/playerevents/v2/{0}/name — SHAPE_MISMATCH (degraded)
PUT + 'name' field OK and persisted to Title; response is flat ToWire instead of PHHAKLPGNGC wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:387
Fix. Return the wrapper.
PUT api/playerevents/v2/{0}/description — SHAPE_MISMATCH (degraded)
PUT + 'description' field OK; flat ToWire response instead of wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:401
Fix. Return the wrapper.
PUT api/playerevents/v2/{0}/image — SHAPE_MISMATCH (degraded)
PUT + 'imageName' read, but value goes to the PlayerSettings side-table while ToWire hardcodes ImageName="" (line 272) — image never persists visibly; flat response instead of wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:445
Fix. Persist ImageName on the entity; return wrapper.
PUT api/playerevents/v2/{0}/room — SHAPE_MISMATCH (degraded)
PUT + 'roomId' OK and persisted; client's optional 'subRoomId' form field is ignored (SubRoomId not stored/emitted); flat response instead of wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:414
Fix. Read+persist subRoomId; return wrapper.
PUT api/playerevents/v2/{0}/club — SHAPE_MISMATCH (degraded)
PUT registered, but client omits clubId entirely to CLEAR the club and the handler returns 400 missing_club when absent (line 496) — detaching a club is impossible. Value also goes to the side-table (never emitted, ToWire has no ClubId key); flat response instead of wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:490
Fix. Treat absent clubId as clear (null), persist ClubId on the entity, emit it in ToWire, return wrapper.
PUT api/playerevents/v2/{0}/time — SHAPE_MISMATCH (degraded)
PUT + startTime/endTime fields OK and persisted; flat ToWire response instead of PHHAKLPGNGC wrapper.
Handler: DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:429
Fix. Return the wrapper.
GET playlists/visitedby/me — STUB (degraded)
Hardcoded Ok(Array.Empty