Files
DorkNet/docs/recroom-2023-client-api-complete.md
Alexa 5ab28b1d4f fix: creating a room failed with a blank "Failed to copy room"
The 2023 client sends rooms/{id}/clone through the same RecNet rooms
helper as "save room" and "restore subroom save", which reads the
{success, value, error} envelope. The clone handler answered with the
bare room details, so the client found no success flag and rejected the
reply with an empty error, even though the room had been created.

Clone now returns the envelope with the full room details in value, and
refusals (unknown source, cloning not allowed) are a 200 with
success=false and a message instead of a bodyless 404/403.
2026-10-17 20:21:54 +03:00

442 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.KickPlayerDTO has real public fields (GameSessionId, PlayerIds) and is serialized with Unity JsonUtility, 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).

  • Server→client messages arrive on the hub method named "Notification" (singular) carrying a single string argument whose content is JSON {"Id":..., "Msg":...}. The handler is registered as Action<string>. Sending ReceiveNotification, or passing an object instead of a string, makes every push silently vanish.
  • Client→server: SubscribeToPlayers / UnsubscribeFromPlayers, plus "SubscribeTo" / "UnsubscribeFrom" prefixes composed at runtime.
  • Presence pushes are routed by the friend graph server-side, so the subscription calls are advisory rather than load-bearing.

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:662 and :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. Room bans must therefore go out as ModerationKick (22) with IsBan set; the moderation manager registers only 22, ModerationUnkick and 23 (RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335).

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, PhotonAccessToken, AppVersionUpdate, GameConfig.Refresh, ReputationUpdate, KeepsakeInstanceAdded/Removed, RoomCurrencyCreated/Modified/Deleted, RoomCommentDeleted and others.

Hub methods

The client invokes exactly one server method: SubscribeToPlayers, with a {PlayerIds: List<int>} payload, fire-and-forget. The SubscribeTo/ UnsubscribeFrom composition exists in the binary but has no callers in this build, and UnsubscribeFromPlayers does not appear at all — the server's extra hub methods are unused but harmless. Presence is routed server-side by the friend graph, so these calls are advisory.

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
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 (HKBNPBMNDGG 22): 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 gameconfig Experience.GameLogTool.HideTool is 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) — emitting null for 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)

  1. DELETE /cachedlogin/current — client sends DELETE (LBNJFPOLCDL.txt:4190); server registers only GET (DorkNet.Server\Controllers\Auth\AuthController.cs:328) => 405 on sign-out.
  2. DELETE /account/me — client sends DELETE (OPMAPIOEIFG.txt:8773); server only has GET (Controllers\Accounts\AccountsController.cs:54).
  3. GET /platformid/{accountId} — not registered at all; the nearest server route is /account/v1/{accountId}/platformid and it returns an OBJECT { PlatformId = 0L } (AccountsController.cs:684) while the client expects a BARE JSON STRING.
  4. GET /accounts/{accountId}/receives/{category} — not registered anywhere => 404, the promise in AccountSpecificUIBehaviour rejects.
  5. POST /account/bulk/email and POST /account/bulk/phone — not registered (server only has GET/POST /account/bulk).
  6. POST /cachedlogin/forplatformids — field-name mismatch: client sends repeated id= (OPMAPIOEIFG.txt:11383), server reads platformIds/PlatformIds (AuthController.cs:289-311) => always returns [].
  7. POST /cachedlogin/migrate — field-name mismatch: client sends legacyPlatformId + newPlatformId (LBNJFPOLCDL.txt:4469/:4479), server binds [FromForm] platform, platformId (AuthController.cs:342).
  8. 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 ".
  9. 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. platforms and rawUsername are ignored by the 2023 reader.
  10. cachedlogin payloads: server's CachedLogin (DorkNet.Models\Auth\CachedLogin.cs) matches 5 of 6 keys; refreshToken is 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):

  1. 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.

  2. 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=..).

  3. 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).

  4. 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 befd590 revert).

  5. 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.

  6. 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).

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

  8. 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.

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, "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": DateTime string}] (casing-tolerant)
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) 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)
GET + PUT rooms/{0}/roles/{1} GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN). GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details.
PUT rooms/{0}/roles/{1}/invite form field: role (Int32 enum) 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 serialized list of BNLPBMJJOMM permission entries; per-entry keys (from its serializer): Role (Int32), Type (Int32), Permission (Int32), Override (Boolean), Value (variant) 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 cache
  • magic_door/{0} — Cache-key factory
  • my_visited_rooms/skip={0}&take={1} — Cache-key factory
  • room/ — Room share-code / deeplink construction
  • roominteraction/{0} — Client cache invalidation after cheer/favorite mutations
  • roomsbycreators/{0}&skip={1}&take={2} — Cache-key factory
  • search_rooms/{0}&skip={1}&take={2} — Cache-key factory
  • visited_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.

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 — MISSING (degraded)

Handler + wire shape are correct ([{AccountId,Role,InvitedRole,LastChangedByAccountId}], BuildRoomAccountRoleWire :4003-4014) but there is no roomserver twin → the permissions screen's list fetch 404s under the 2023 prefix.

Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3695

Fix. Add [HttpGet("roomserver/rooms/{roomId:long}/roles")] (and twins for the two routes below).

GET + PUT rooms/{0}/roles/{1} — MISSING (degraded)

Handlers are contract-correct: GET returns a single role wire object; PUT reads form 'role' (ReadClientRoomRoleAsync :3944-3973) and returns full BuildRoomServerDetailsWithRolesAsync. But neither has a roomserver twin → 404 under the 2023 prefix.

Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3818 (GET), 3835 (PUT)

Fix. Add roomserver twins for GET and PUT/POST.

PUT rooms/{0}/roles/{1}/invite — MISSING (degraded)

Handler correct (form role, Accepted=false row, full-details response); no roomserver twin → 404 under prefix.

Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3879

Fix. Add roomserver twin.

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 — STUB (degraded)

Twins exist but the handler accepts SubRoomModifyRequest (Name/MaxPlayers/IsSandbox/CanMatchmakeInto) — the client actually sends a serialized LIST of BNLPBMJJOMM permission entries {Role,Type,Permission,Override,Value}. No permission matrix is ever stored (there is no storage for it), and the client body would 415/no-op. Response SceneWire. The role-permission matrix silently never saves.

Handler: DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3391

Fix. Parse the request as a list of {Role,Type,Permission,Override,Value}; persist per-subroom permission rows (new table); echo full room details. Scope explicitly if deferred — currently it is a silent stub.

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 literal
  • api/roomkeys/ — n/a — prefix literal
  • api/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 composition
  • api/ugcPurchasables — URL composition
  • econ/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:

  1. 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.
  2. api/externalfriendinvite/v1/createplatforminvite — same file :16-30 returns {InviteCode,InviteUrl,Platform,PlatformId}; client wants {"Success":bool,"Error":string} and will read Success=false.
  3. 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.
  4. 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"}.
  5. api/relationships/v1/bulkignoreplatformusers — RelationshipsController.cs:427-451 expects JSON key PlatformUserIds; the client sends PlatformIds. Zero rows imported, silently.
  6. 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.
  7. 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.
  8. 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).
  9. 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/collect
  • api/progressionEvents — Cache invalidation
  • event/{0}/{1} — Share-event code/link generation
  • event_responses/{0} — RSVP-list cache in EMMAMFINMMJ
  • events_by_club_ids/ — Club-events cache
  • meetup/ — Meetup share-code link generation
  • playlistinteraction/{0} — Playlist interaction cache
  • remote_player_event_list/{0} — Profile events cache
  • room_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