Compare commits

..

96 Commits

Author SHA1 Message Date
Alexa 6605f61047 feat(admin): IsAGRoom (IsRRO) toggle on room props, for the editable-Rec-Royale experiment
The Maker Pen permission check runs a room precondition that looks the
current room up in AGRoomRuntimeConfig — a table baked into the client's
resources.assets — and returns a per-original boolean. Rec Center and
Stunt Runner allow creation there; Rec Royale does not, so no blob, role or
server response can grant the pen while the client treats the room as
that original. The table is consulted only for rooms with IsRRO (our
IsAGRoom) set.

Expose IsAGRoom on rooms/{id}/props (+ a checkbox on the General tab) so a
CLONE of Rec Royale can be flipped to a non-original with a real save
attached; whether the client still loads the baked Frontier scene for a
non-original is what that experiment answers. docs/admin.md explains the
gate and the two possible outcomes.
2026-09-28 00:34:56 +03:00
Alexa d7b1722abd feat(admin): attach a real PersistedRoomData save to a baked RRO sub-room
POST rooms/{id}/subrooms/{sub}/blob/upload (multipart file, ?mode=full|
basics) rewrites a save from ANY client version for this room —
activity_id → the room's LocationReplicationId, output_log / sub_room_id /
unknown-to-2023 fields dropped, header versions clamped to the 2023
maxima; "basics" additionally keeps only the game-role (field 50) and
game-rule (40/41) objects and strips the circuit payloads, since a newer
CircuitsV2 graph is rejected by the client's own CV2 gate regardless —
stores it as room_{id}_upload_*.dat, records the RoomDataBlobs row and
points the sub-room (and room, for sub-room 0) at it. The CDN's RRO role
overlay + clamp then apply on serve exactly as for Rec Center's saved
blob. Admin SPA: "Upload save" file input + mode select on the Sub-rooms
tab for baked originals.

Purpose: A/B a genuine custom-Rec-Royale save (Squads chip room) against
the generated stub, whose four objects the client currently fails to
instantiate (NullReferenceException in CreationManager.InstantiateRequest-
ForViewData, then "Not object found for Persistence View").
Test: Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023.
2026-09-28 00:16:51 +03:00
Alexa 806b160acc feat(cdn): Rec Royale stub blob ships the room's game-rule chip
Frontier has no baked *_GameRuleWrapperCircuit; real Rec Room delivered
Rec Royale's rule chip in the room blob, and without it there is no
BattleRoyaleManager and no match. A newer-client export of a custom Rec
Royale room shows the saved RecRoyaleSquads chip is minimal — prefab id,
palette tag (prefab name lower-cased, 25 chars), circuit node id and an
EMPTY GameRuleWrapperData (field 41) — so the stub can generate it.

Prefab ids read from the client's spawnable-tool table in resources.assets
(SpawnableTool_Runtime_<ENUM> → 20-byte hash → two u32 → 16-byte id), the
Squads id cross-checked against the real save: Solos 983db81a…, Squads
857f7500…. The stub for a room whose name contains RecRoyaleSolos /
RecRoyaleSquads carries the matching chip as a fourth persistence view.
Test: Rec_royale_stub_carries_its_game_rule_chip (both rooms).
2026-09-27 23:45:57 +03:00
Alexa 3edeaa5484 fix(cdn): RRO stub blob carries the room's own game-role objects
Enable + co-owner still gave no Maker Pen. The 2023 client grants Maker
Pen / save / spawn rights off the player's GameRole
(ICreatorRole.CreatorRoleCanUseMakerPen on RecRoom.Systems.PlayerRoles
.GameRole), and game roles are persisted OBJECTS in the room blob: every
real save inspected (Rec Center room_100_v1.dat, Stunt Runner
room_125_v1.dat) carries three GameRoleNode chips — a default role named
after the room plus the built-in "In-Game" and "Eliminated" roles, with
well-known GUIDs shared by every room. A blob with none leaves a co-owner
with no role to hold, so MakerPen.CheckMakerPenPermissionAndUpdateMode
fails regardless of the room-role collection.

BuildRroEditableBlob now emits Rec Royale's own three role objects, laid
out field-for-field like the client's writer (including the always-present
empty Overridable submessages), with view ids derived from the activity id
so repeated downloads describe the same objects. The default role sets
can_use_maker_pen, matching the permissive room-role overlay. Test pins
three persistence views and the three role GUIDs.
2026-09-27 23:36:09 +03:00
Alexa 27314c1335 fix(cdn): serve baked RRO rooms a loadable stub blob (version 16, save skeleton)
An enabled baked room still had no Maker Pen: the client fetched the CDN's
synthetic stub for room_{id}_dorknet_v8.dat and silently loaded the room
with no room roles. Measured against Rec Center's working room_100_v1.dat
(10,930 bytes, the same client loads it with Maker Pen), the old 1,872-byte
roles-only stub was stamped PersistedRoomVersion 19 — past the March-2023
client's ceiling of 16, which only the S3-hit path clamped — and lacked the
skeleton every real save carries (last_save_time, activity_id = the room's
LocationReplicationId, connectable graph, scene settings, object model,
room_mood_* blocks).

RoomDataBlobService.BuildRroEditableBlob now emits that skeleton per room
at version 16 (untyped fields appended on the wire; payloads are Rec
Center's defaults verbatim). CdnController resolves the RRO room for the
miss path, passes its location id, and clamps synthesised blobs the same
way it clamps S3 hits. Tests pin version 16, the activity id and
CanUseMakerPen on every role.
2026-09-27 23:17:27 +03:00
Alexa 36c5dd5ca9 fix(admin): RRO blob switch works for rooms with no RoomScenes row
The seeded Rec Royale rows carry no RoomScenes at all ("Scenes 0"), so the
Sub-rooms tab listed nothing and blob/enable would have 404'd. The admin
list now synthesises the same virtual "Home" entry scene the game-client
details builder does (isVirtual: true, max players = room capacity),
blob/enable creates the real row with the game-side defaults
(RoomsController.GetOrCreateSceneForMutationAsync), and blob/reset copes
with a missing row. Max-players save is disabled on the virtual row.
Test: Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it.
2026-09-27 23:02:40 +03:00
Alexa 258183fb94 feat: Rec Royale night mode (ClientMod) + admin switch to give baked RRO rooms a data blob
ClientMod (2023-03-21): opt-in Rec Royale night mode. RRO scenes never
enable the room mood engine (no data blob → SceneMoodSettings.isEnabled
stays false, CurrentMood null, the per-frame update returns at once), so
the mod replays the room-data loader's bring-up, registers a shipped
night MoodSetting (Night_Calm_Outdoor_Mood by default) as the scene's
default mood, supplies a skybox material on the Custom/SkyBox shader
(MoodConfig.SkyBoxMaterial is not loaded outside custom rooms), assigns
the scene's directional light as RenderSettings.sun, matches the key
light to the engine-driven sun, darkens via the mood's post-exposure and
sun/ambient intensities, forces post-processing on, keeps or disables
fog, hides the daytime cloud meshes, unlocks weapon flashlights, and
restores everything on hotkey-off / scene change. All knobs live in
dorknet-clientmod.json (RecRoyaleNight*), written by install-melon.ps1.

Server/admin: baked Rec Room Originals are seeded with an empty
DataBlobName, which the 2023 client reads as "no room data" — no Maker
Pen, no CV2 room-settings chips, no saves, and for Rec Royale no game at
all (its game-rule chip RecRoyaleSolos_GameRuleWrapperCircuit ships in
the blob, not the scene). New admin actions on the Sub-rooms tab:
POST rooms/{id}/subrooms/{sub}/blob/enable stamps the synthetic default
blob name (which CdnController already answers with the RRO-editable
blob) and blob/reset clears it back to scene-only. IsRRO is derived from
IsAGRoom, so the client keeps loading the baked map.

Docs: README section, docs/admin.md procedure, docs/recroom-2023-room-save.md,
new docs/recroom-2023-scene-moods.md with the ISIL offsets behind all of it.
Tests: RroRoomBlobTests.
2026-09-27 22:45:40 +03:00
Alexa cf3aa29ba2 fix: address the 15 CodeRabbit findings from the wire-audit review
Security:
- roomcode PUT now requires instance ownership (any player could plant
  or relocate codes on foreign private instances)
- /platformid/{accountId} requires auth (anonymous Steam/Oculus id
  enumeration oracle)
- request tracing no longer captures successful /thread bodies by
  default (chat messages landed unredacted in logs); opt in via
  Trace__SuccessBodyPaths
- anonymous action-link renewal clamped to 720h per consume

Data integrity:
- buyPurchaseReminder upserts its objective sentinel (second purchase
  threw UNIQUE after the wallet was debited)
- progression reward gift + idempotency sentinel commit in one
  transaction (crash between them double-minted the reward)
- ClubPermissionsGalleryAndModeration migration defaults corrected to
  the entity contract (CommentsJson '[]', ClubChatEnabled true); new
  one-shot ClubChatDefaultRepair migration fixes databases that
  applied the wrong defaults; bootstrap repairs '' CommentsJson rows
- admin subroom-maxplayers audit entry commits with the mutation
- account purge covers CustomAvatarItems (ownership deleted, created
  items reassigned to the system account)
- duplicate photo reports ignored while one is unresolved

Stability:
- bootstrap only swallows the expected duplicate-column error; locked
  databases and schema failures now fail startup instead of the first
  endpoint
- clone blob write failure degrades to an empty sub-room instead of a
  500 after the clone row committed
- resumed photo-feed cursor stamped DateTimeKind.Utc (Npgsql rejects
  Unspecified for timestamptz)
- unreadcounts queries per requested room instead of materialising the
  whole comment keyspace

Tests:
- destroys-the-probe-session template guard is case-insensitive

74/74 tests pass.
2026-08-02 01:16:38 +03:00
Alexa c8a7068070 Merge pull request #48 from DorkSquadRR/2023-client-wire-audit
Correct 2023-03-21 client wire contracts across the API and notify surfaces
2026-08-02 01:03:31 +03:00
Alexa 0a51c61f66 feat(admin-ui): sub-room caps and test-case issue linking
Two admin screens for work that previously had server support but no way to
reach it.

Rooms → a room → Sub-rooms: per-sub-room max players, with the room's own
advertised capacity shown alongside so the two are not mistaken for each other.
Each row also shows whether the sub-room is a matchmaking target and which blob
it loads.

Activity → Test cases: file a GitHub issue against a case, follow the link, or
unlink it, plus a reconcile button. Filing is idempotent server-side, so the
button is safe to press twice. When GitHub is unconfigured the actions disable
themselves and the page explains why rather than failing on click — a server
with no token is an ordinary deployment.

Both go in as tabs on the existing hub pages rather than new sidebar entries,
matching how the nav is organised.

Server side adds GET api/admin/v1/testcases (the list the SPA renders, with
each case's issue number) and mirrors the three issue routes under
/api/admin/v1/testcases/... because the SPA's client speaks that prefix
exclusively.

I had told the user this UI was not possible on this branch because
wwwroot/admin holds a built bundle. That was wrong — the source is in
DorkNet.Server/admin-ui and builds to it.
2026-07-28 17:21:49 +03:00
Alexa 0af23c26b1 fix: a closed issue marks the failing test case Passed
Closed means fixed. The case that was failing on that bug passes, and reopening
the issue undoes it:

  closed + Failed -> Passed
  open   + Passed -> Failed

Replaces the previous closed -> NotYetTested, which sent the case back for
another run instead of recording the outcome.

The guard that matters is unchanged: only those two states are the
reconciler's to move, so a case a tester has Claimed — or one nobody has run
yet — is never rewritten underneath them.
2026-07-28 17:16:44 +03:00
Alexa 7ce106ed1b feat: link QA test cases to GitHub issues
File a GitHub issue from a failing test case, and let the case follow the
issue afterwards.

  POST   api/testcasemanagement/v1/testcase/{id}/issue   file + link
  DELETE api/testcasemanagement/v1/testcase/{id}/issue   unlink
  POST   api/testcasemanagement/v1/issues/sync           reconcile now

plus TestCaseIssueReconciler, a background sweep on the same code path as the
manual endpoint so the two cannot drift. Admin-gated: these are not part of the
2023 client's surface, unlike the existing test-case routes which are open
because the watch gates that tab client-side.

The link lives in TestCaseEntity.JiraBugUrl — the field Rec Room's own tooling
used for the bug filed against a failing case. That avoids a migration, which
matters more here than the name: the Postgres path is EnsureCreated-only and
never replays migrations, so a new column has to be added twice or it is missing
in production. A leftover genuine Jira link is ignored rather than misparsed,
since the issue number is only read from a GitHub-shaped URL.

Filing is idempotent — a case already carrying a live issue returns it instead
of filing again, so sweeping a failing pass repeatedly does not spam duplicates.
Unlinking leaves the issue alone; closing someone's issue as a side effect of
tidying a QA link would be the wrong call.

Reconcile moves a case only between the two states it owns: closed+Failed goes
to NotYetTested, open+NotYetTested goes back to Failed. A closed issue is NOT
marked Passed — closing is a developer's claim that the fix landed, not a
tester's observation that the case passes, so the case goes back in the queue.
The first draft flipped any non-Failed case to Failed on an open issue, which
contradicted its own doc comment and yanked a case out from under a tester
mid-claim; the test for that caught it.

Everything degrades rather than fails when unconfigured: IsConfigured is false
without a token, endpoints answer 503 with the reason, and the reconciler logs
once and idles. GitHub is faked in the tests, which cover idempotency, both
state transitions, the mid-claim case, and the unconfigured path.
2026-07-28 17:13:31 +03:00
Alexa 9f173193a7 fix: a cloned template carries its content instead of starting empty
Cloning a first-party template (RecCenter and friends) deliberately left the
new room with no blob, on the reasoning that its content is baked client
geometry and any blob is just a MakerPen overlay. That left the copy with
nothing to load.

The copy flow is what exposes it. IBEOONPEELF.GetRoomData / GetRoomMetadata
fetch "/room/{blob}" from the CDN as part of copying and throw a
BCPHBJHOIEM-family exception when that does not give them a usable room —
surfacing as the bare "Failed to copy room: Exception of type 'CEMNLBKJABA'"
with no message. For a name nothing is stored under, the CDN synthesises a
~1.8 KB stub: room_1583_dorknet_v8.dat served 1872 bytes where the source's
room_100_v1.dat is 10,930. The clone that DID work carried real content.

Since GiveCloneItsOwnBlobsAsync copies the bytes under the clone's own name,
the original worry — inheriting an overlay on a SHARED template — no longer
applies: nothing is shared either way. IsFirstPartyTemplate had no other
callers and is gone.

The existing assertion that a template clone must have NO blob encoded the
belief this replaces, so it now asserts the clone carries content. The
"doesn't share the source's object" half is covered by
RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob, which runs
with a real object store; this suite has none and deliberately falls back to
keeping the reference.
2026-07-28 12:00:15 +03:00
Alexa ec37f1c790 fix: heartbeat during a pending join reports the target, not null
The client cancels its own room load when the server says it is nowhere.
OnPresenceHeartbeatResponse XORs (cachedRoomInstance != null) against
(serverRoomInstance != null), so a client holding the instance it is joining
while we answer null reads as the room vanishing mid-join: it logs "presence
heartbeat response indicates local presence is out-of-sync", then "Attempting
to go to Invalid RoomInstance", and cancels the very join the server was
waiting on. It then retried until a heartbeat happened to miss the window, so
entering a dorm took several attempts and looked like a load failure.

Deferring the presence commit is about what everyone ELSE sees — presence stays
uncommitted until the join is confirmed so a failed join leaves no ghost behind.
It was never meant to change what the joining player is told about themselves.
Echoing the pending target satisfies the XOR and matches what the client
already has, while presence itself stays uncommitted.

The null-room branch a few lines below already documents this exact XOR for the
boot race and synthesises a dorm rather than answering null; the deferred branch
contradicted it.

The test drives matchmake/dorm and heartbeats inside the window before the join
is reported, which is the state the client is actually in. Verified to fail
against the previous behaviour rather than passing vacuously.
2026-07-28 11:52:16 +03:00
Alexa 4a1ac47007 fix: nested room-details objects carry only keys the client reads
Extends the parity check into the payload's nested objects — sub-room 0, the
first role, stats and the first tag each have their own generated reader, and a
key set that disagrees with one of those breaks the room just as hard as a
wrong top-level set.

It immediately found seven invented keys. Six on every sub-room (CurrentSave,
DefaultMatchmakingPolicy, LastModeratedSaveModerationState, ShouldAutoStageSaves,
StagedSubRoomDataSaveId, SubRoomUnityAssetId) and IsPrimaryGenre on every tag,
whose real shape is exactly {Tag, Type}. Six of the seven appear NOWHERE in the
decompile; SubRoomUnityAssetId exists but belongs to another DTO, not this one.

The extractor's minimum field count drops from 4 to 2, which is what let this
hide: a room tag has two fields, so a 4-field floor skipped its reader entirely
and the invented third key had nothing to be checked against. The alias rule in
fields_in() is what rejects false positives — the field count never was.

This does not explain the clone failure on its own: the captured response
carries all 41 top-level keys and is still rejected. But it removes the whole
class of "we send something the reader has no slot for" from the search.
2026-07-28 11:27:35 +03:00
Alexa 078d576383 fix: stop sending a room-level DataBlob — it broke the dorm
Adding DataBlob/DataBlobHash at room level for reader parity was wrong, and the
dorm is what proved it: rooms came back "the data in the room you are trying to
load is corrupt", and every room loaded fine before the key existed.

Which blob a room loads is decided per INSTANCE and per SUB-ROOM, not per room.
The matchmake response carries it (a dorm's is per-player,
dorm_p{id}_v*.dat) and each sub-room carries its own. A room-scoped answer to
the same question is one the client preferred, and for a dorm it named a file
that was never written.

A registered slot does not oblige us to fill it: Newtonsoft leaves an absent
key at its default, which is the state every room loaded in. The parity test
records both keys as deliberately omitted with the reason, so restoring them
takes a decision rather than a tidy-up.

Also drops the room-level/sub-room DataBlob invariant test added an hour ago —
there is no room-level DataBlob left for it to check.

Note for the clone bug: the captured response now shows the clone carries all
41 keys the reader registers and the client still rejects it, so key parity is
NOT that failure. It is a value the reader refuses, not a missing name.
2026-07-28 11:20:39 +03:00
Alexa 79a2b9844e fix: room-level DataBlob must be the blob sub-room 0 actually loads
Regression from adding the room-level DataBlob key: it was filled from the
per-ROOM name while sub-room 0 resolves its own, override included. A dorm's
data is per-PLAYER (dorm_p{id}_v*.dat) and reaches the builder as that
override, so the room level advertised a room_{id}_* file that was never
written. The client fetched it and rejected the whole room with "the data in
the room you are trying to load is corrupt" — which reads like a broken save
rather than a payload that disagrees with itself.

Both now come from one resolved value, so whichever key the client reads
first it gets the same file. The no-scenes fallback sub-room honours the
override too, instead of falling back to the per-room name.

Adds the invariant as a test: room-level DataBlob == SubRooms[0].DataBlob.
The DTO parity tests could not have caught this — the key was present and
correctly named, it just pointed somewhere else.
2026-07-28 11:09:58 +03:00
Alexa f43ce3f368 feat: per-sub-room max players, editable in game and from admin
Player caps were two settings pretending to be one. RoomEntity.MaxCapacity is
what the ROOM advertises (and what matchmaking hands to
RoomInstance.MaxCapacity); RoomSceneEntity.MaxPlayers is what ONE sub-room caps
itself at. The details payload was reading its room-level MaxPlayers off
sub-room 0, which collapsed the two — editing a sub-room would have moved the
room's advertised capacity with it. It now reports MaxCapacity, and each entry
in SubRooms[] reports its own figure.

The in-game path already existed (the "Max Player Count in This Subroom"
slider). Admin had no way to reach it at all — only zip import ever set it — so
a cap could not be corrected on a room the admin does not own:

  GET api/admin/v1/rooms/{id}/subrooms
  PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers  {"MaxPlayers":n}

subRoomId is the sub-room's ORDER INDEX — the id the client and the rest of the
wire use — not the row's primary key. Row ids are serialised as strings because
the admin SPA is JavaScript and mangles integers past 2^53. Both paths clamp to
1..80, matching rooms/{id}/props: 0 would make a sub-room unjoinable, and a
shared range stops the two settings disagreeing about what is legal.

The test drives the client's exact wire rather than a convenient one:
NLDBPDCNNCF.MNBPCGJNLNP builds a BLNOGFGHIIF whose single JSON key is
"maxPlayers" and PUTs it — this route does NOT form-encode like most of its
siblings, so a form-bodied test would have passed while the real client's body
went unread. It also asserts the reply carries the new value, since the client
deserialises it back as FGCPNAACHIK.
2026-07-28 11:06:49 +03:00
Alexa cc523181d2 fix: make room-details and chat-thread byte-identical to the client's readers
Both bugs were the same shape: we answered HTTP 200 with a key set the client's
generated reader did not agree with, the reader threw mid-deserialise, and it
surfaced later as a bare "Failed to copy room" / a chat that never opened, with
no URL in the client log and no payload in the server log.

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

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

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

That deletion also removes a comment asserting the 2023 client reads UgcVersion
to build its asset-bundle URL. It does not — the literal appears nowhere in the
entire decompile, and neither do PublishState or BoostCount.
2026-07-28 11:00:13 +03:00
Alexa da7f980166 diag: log success response bodies for the routes that fail silently
When the client rejects a response it still answers HTTP 200 and throws while
reading it, so the server log shows a clean 200, the client shows a bare
"Failed to copy room" / an unopened chat with no URL, and neither side records
the payload. Every diagnosis then comes down to guessing which key the strict
reader tripped on.

Success bodies are now logged for /player, /thread and /clone, and the response
cap is raised to 8000 chars for those paths — a room-details payload is a few
KB and a 300-char truncation answers nothing. Everything else is unchanged:
failures are still always read, successes still discarded, bodies still pass
through RedactSecrets.

Override with the Trace__SuccessBodyPaths env var (comma-separated substrings,
empty to disable).
2026-07-28 10:48:13 +03:00
Alexa 9c9f1841d6 feat: cloned rooms get their own copy of the source's save data
Cloning was a shallow copy: the new room's scenes kept pointing at the SOURCE
room's blob until someone saved over them. So every clone of a room shared one
object, a clone read as "already has content" while owning none of it, and the
room a player copied stayed load-bearing for their copy indefinitely. Cloning
RecCenter produced rooms whose data blob was literally room_100_v1.dat, which
is visible in the join log.

Each sub-room's bytes are now copied into the clone's own namespace, keyed by
the CLONE's room id so BlobRouter files them under that room's prefix. Sub-room
0 takes the canonical default name, which matters because the details builder
falls back to exactly that name when a room has no recorded blob — writing the
copy there means both the recorded and the fallback path resolve to real
content. Each copy also gets a RoomDataBlobs row so it is tracked like any
other save.

Two cases deliberately do not clear a reference:

- With no object store configured there is nothing to copy, and a blob name is
  only a reference; clearing it would throw away the player's edits, which is
  strictly worse than the shared reference. That path keeps the old shallow
  behaviour, which is also what the existing user-room clone test asserts.
- A source object that is missing or unreadable leaves the clone empty rather
  than failing the request. The room and its sub-rooms are already committed by
  that point, so a hard failure would strand a half-made room, and an unsaved
  source clones to an empty room anyway.

Everywhere else a reference is either replaced by a real copy or cleared — it
is never left pointing into another room.
2026-07-28 10:28:50 +03:00
Alexa 3968adfcc4 test: cover starting a chat with a player end to end
Reproduces the two-step handshake the client performs — POST thread/withmembers
to create the thread, then GET thread/{id} to open it — on a conversation that
has never carried a message, which is the case that was 404ing.

The request is sent as a form body rather than a query string, matching the
wire ("ids=9741717&messageCount=50"). That detail is load-bearing for routing,
not just fidelity: POST thread/{chatThreadId} also matches the literal path
thread/withmembers, and the content type is what separates them. Posting with
no body makes the two indistinguishable and the request fails as an ambiguous
match across CreateGroup, WithMembersForm and SendMessageForm.
2026-07-28 10:04:54 +03:00
Alexa 08ab122adc fix: an empty chat thread is not a missing chat thread
Starting a chat with a player POSTs thread/withmembers (which creates the
thread) and then immediately GETs thread/{id}. At that instant the thread has
no messages, and GetThreadById returned NotFound purely on message count — so
the client got a 404 for the thread it had just been handed and the
conversation could never be opened.

Confirmed from a server log: POST /thread/withmembers -> 200 at 06:38:20.862,
GET /thread/15 -> 404 at 06:38:21.277.

The handler now 404s only when nothing records the thread — no ChatThreads row
and no ChatThreadMembers rows — and otherwise returns it with an empty message
list.

Also adds RoomCloneShapeTests. The 2023 copy-room flow reads the clone reply
into FGCPNAACHIK, the same type get-by-id returns
(NLDBPDCNNCF.txt:4676 signature, :4802 route literal), so the two responses
must stay shape-identical: if they drift, the server still answers 200 and the
client throws while reading, surfacing much later from the finalizer as a bare
"Failed to copy room" with nothing in the server log. The test compares both
top-level and nested sub-room keys, and seeds a real scene row so the
comparison exercises the populated branch rather than the synthesised-default
fallback that a room with no scenes would take.
2026-07-28 09:54:28 +03:00
Alexa dc1f4c3492 fix: improve thread existence check to allow new conversations without 404 2026-07-28 09:48:38 +03:00
Alexa 936aa061c0 test: guard both schema-delivery paths; fix playlist backfill defaults
Adds SchemaBootstrapParityTests, which closes the hole that let the
missing-column outage ship green. Every other test runs against a FRESH
database where EnsureCreated builds the whole model from the entities, so a
column that only exists in the model always appears — the failure mode is
invisible by construction. These two tests model an EXISTING database instead:

- Every model table/column must be reachable via a committed migration OR an
  idempotent Ensure* patch. Something covered by neither reaches only brand-new
  databases, and every query touching it throws on a deployed server.
- Anything added after the Initial migration must additionally appear as a
  Postgres ALTER/CREATE in DatabaseBootstrap, because that path is
  EnsureCreated-only and never replays migrations. This is precisely the rule
  PrivateInstances.RoomCode, Clubs.ClubChatEnabled and
  ChatMessages.ModerationState broke.

The first test asserts the union rather than migrations alone: a large amount of
schema legitimately predates the rule and lives only in the helpers. Tightening
that is separate cleanup; what must never happen is a column covered by nothing.

The helper-coverage parser reads the Ensure* SQL out of the source rather than
executing the private methods, and handles both C# string styles the file mixes
plus the loops that interpolate a column name (EnsureGameRewardColumnsAsync).

Also fixes a real data bug found in review: the Client2023WireFixes migration
backfilled SupportsJuniors/SupportsScreens/SupportsTeleportVR/SupportsWalkVR to
false for existing rows, while the entity default is true. A C# property
initializer is not a database default, so EF emitted false and every playlist
predating the migration would have been silently marked as not supporting
juniors, screens or either VR movement mode — the opposite of intent, and
inconsistent with the bootstrap helper, which already used true.
2026-07-28 09:42:48 +03:00
Alexa 81698180dd fix: add the new 2023-wire columns to the idempotent schema bootstrap
Adding entity columns and an EF migration was not enough, and the gap took
down a live dev server: club home, matchmake/dorm, the presence heartbeat and
the chat thread list all returned 500 with "column does not exist".

The Postgres path is EnsureCreated-only and never replays migrations, so an EF
migration adds nothing to a database that already exists. Every post-Initial
column has to be repeated as an idempotent ALTER in DatabaseBootstrap — that is
what the existing Ensure*Columns helpers are for — and the columns added by the
2023 wire work had no such helper.

Observed failures, all from a running deployment:

- PrivateInstances.RoomCode  -> PrivateInstanceService.EnsureForDormAsync,
  which is on the path of BOTH matchmake/dorm and player/heartbeat. This is
  why the client could not load into the dorm at all.
- Clubs.ClubChatEnabled / MinLevel -> ClubService.HomeClubAsync, so
  GET club/home/me 500'd on every boot.
- ChatMessages.ModerationState -> ChatController.GetThreads, so the chat
  inbox 500'd.

EnsureClient2023WireColumnsAsync covers all of them plus the playlist settings
columns and the two new club tables, for Postgres and SQLite, and runs after
the provider branch so both get it. Every statement is idempotent, so it is a
no-op on a fresh database that EnsureCreated already built correctly.

The test suite did not catch this because tests always run against a fresh
database, where EnsureCreated builds the full model from the entities. Only an
EXISTING database can be missing these columns.
2026-07-28 09:31:10 +03:00
Alexa 933c7eb51c feat: close the last route gaps — 100% client route+verb coverage
Every HTTP request the 2023-03-21 client can issue now reaches a handler under
the verb it actually uses: 522/522, with an empty KnownGaps allowlist. The
coverage test asserts it, so a regression fails the build rather than becoming
a 404 in the wild.

The six routes left after the previous pass all needed storage that did not
exist, so they are done here as one schema change rather than stubbed:

- ClubRolePermissionEntity (ClubId, MembershipType, six bools) backs
  PUT club/{id}/permissions/{role}. A club with no stored row falls back to the
  derived default policy, so every club that predates the table behaves exactly
  as before. Partial writes keep the values they currently resolve to, so a
  client that omits a field cannot silently revoke a permission.
- ClubAdditionalImageEntity (ClubId, Slot, ImageName) backs the gallery. The
  details envelope now emits real {ImageName, Slot} rows instead of an empty
  array, and DELETE clears a slot.
- ClubEntity.MinLevel and ClubEntity.ClubChatEnabled back their setters and are
  read back by hasDisabledClubChat and the Club wire shape, which previously
  reported hardcoded defaults.
- ChatMessageEntity.ModerationState backs PUT thread/message/{id}/moderate,
  gated so an author can retract their own message but only club moderators and
  admins can action anyone else's.
- TestCaseEntity.CommentsJson backs the QA comment endpoint, appending rather
  than overwriting so a second tester's note cannot erase the first.

Also corrects the Joinability comment on ClubEntity, which documented
1=RequestToJoin / 2=InviteOnly. The real enum is Open=0, InviteOnly=1,
AskToJoin=2 — the inverted reading the requesttojoin handler was already fixed
away from, left behind where it could mislead the next change.
2026-07-28 09:19:35 +03:00
Alexa 4b5086cdf1 feat: implement the client routes that had no handler
Sixteen agents, one controller each, over every route the coverage test
reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%),
up from 90.8%.

Each handler was written against binary evidence rather than the audit's prose:
verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from
the issuing method's return type, request shape from whether the client uses
form fields, a raw JsonUtility body, or query params. Agents were told to skip
rather than stub, and several did.

New surface worth noting:

- Link host: actionlink create/get/consume and datalink get/put, previously
  unrouted entirely.
- Club roster reads: members, banned, requests, requests/search, members/search,
  each with the client's real sort/paging params and the wire row shape its
  reader expects (the banned row has no MembershipType field, so it isn't sent).
- Home clubhouse set/clear, club disband (soft, via the existing State column),
  and DELETE mainimage as its own action rather than a verb alias — the client
  parses the details envelope from it, so it is not status-only.
- Club chat threads, comment CRUD, keepsake collect/delete, progression-event
  xp boosts, image report and cheered/bulk, iOS StoreKit product reporting.

The six routes still missing all need storage that does not exist yet
(per-role club permissions, club additional-image slots, MinLevel,
ClubChatEnabled). Those are left for a single schema change rather than faked.

Also excludes DELETE /account/me from the endpoint contract probe. That route is
now a real, correctly-guarded account purge, and the probe walks every route
with one test player — so calling it destroyed the probe's own session and
turned one real event into 49 cascading foreign-key and 401 failures.
2026-07-28 09:11:26 +03:00
Alexa 90e069ee4b fix: second pass over the 2023-client wire defects, plus a coverage harness
Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.

Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.

Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.

Notable findings from this pass:

- The club permissions envelope key is literally "Type", not "MembershipType"
  as the audit's suggested fix guessed. Read off the generated reader, then
  cross-checked against the 2020 decompile — the int bitmask we were emitting
  was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
  { Status, InventoryItem }; the scalar operation enum belongs in
  BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
  which exposes exactly those two properties. A test asserting the old scalar
  shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
  (PlayerId, Key) is unique, so a second purchase of the same boost threw
  "UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
  Now an upsert.

Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
2026-07-28 08:50:58 +03:00
Alexa 289072583e feat: Enhance room management and moderation APIs
- Added support for multiple tag filters in HotRoomServer endpoint, allowing clients to query hot rooms with multiple tags.
- Introduced a new DeleteRoom endpoint for owners to soft-archive their rooms, ensuring room data integrity.
- Updated MagicDoor endpoint to return a structured response including refresh timing.
- Improved reporting functionality to handle form submissions correctly, ensuring room reports are persisted.
- Refactored per-field mutation endpoints to support both legacy and new client formats, accommodating changes in request structure.
- Implemented a multi-field payload reader to handle complex requests more efficiently.
- Enhanced logging for room code management to track player actions and conflicts.
2026-07-27 23:54:25 +03:00
Alexa 49df7393dc fix: push presence when a player comes back online
OnDisconnectedAsync told friends a player went offline, but nothing ever
pushed the opposite transition — PlayerCameOnline existed in
NotificationService with zero callers. The pair was asymmetric, so a SignalR
reconnect (network blip, replica restart) greyed the player out on every
friend's list for the rest of the session even though they were still
playing. Only re-entering a room, which pushes presence via /goto, brought
them back.

AddConnectionAsync already computed the first-connection transition to
maintain the online set but returned void; it now reports it, mirroring
RemoveConnectionAsync.

The push is deliberately conditional on a room already being known. The
presence payload derives isOnline from roomInstance being non-null, so
pushing with no room reads as OFFLINE — worse than staying quiet. On a fresh
boot there is no room yet and the /goto that follows sends the real presence
anyway, so this only fires on reconnects, where the cached room is exactly
what friends need to see again.
2026-07-27 23:40:11 +03:00
Alexa 327dcb789c fix: correct 2023-03-21 client wire contracts across the API and notify surfaces
Audits every route the March-2023 client can call against what this server
actually serves, and fixes the defects that were breaking gameplay. Adds
docs/recroom-2023-client-api-complete.md as the reference: ~500 route
literals across 13 subsystems, with verb, request/response shape, and a
per-subsystem diff. 315 defects catalogued; the ones fixed here are listed
in its "Already fixed" table.

Path coverage was never the problem — 465 of 506 routes already had
handlers. The failures were in shape, and they cluster into four causes:

- Wrong verb. The verb is a BestHTTP.HTTPMethods ordinal (GET=0 .. PATCH=5),
  confirmed from that enum's static constructor. Routes registered POST-only
  while the client PUTs returned 405, which made room consumables neither
  buyable nor consumable and blocked every room-key edit.
- Bare scalar wrapped in an object. The client reads `1000`, not
  `{"Limit":1000}`; the issuing method signature says which. Wrapping makes
  the deserializer throw and the feature dies silently.
- [FromBody] vs form-urlencoded. The client sends nearly every write as form
  data, which an [ApiController] action bound [FromBody] rejects with 415
  before the handler runs. This alone broke all 13 club member-management
  endpoints. Adds Binding/FormOrJsonModelBinder so this is fixed once.
- Notify dispatch keys. Both enum overloads of the client's RegisterHandler
  lower the id via Int32.ToString() and register only that numeric key, and
  lookup is a case-sensitive TryGetValue that returns silently on a miss. We
  were sending the enum NAME for most ids, so friend-request updates, forced
  logout, maintenance broadcasts, live balance and consumable updates, and
  invention moderation pushes all evaporated with no error anywhere.

Notable behavioural bugs fixed:

- Editing a club announcement deleted it: PUT was a third binding on the
  delete handler. Split apart, and the missing create route added.
- leaderboard/CheckAndSetStat did not exist. The 2023 client has no SetStat
  literal at all, so every leaderboard score it wrote was discarded.
- Setting a club clubhouse cleared it: the handler read roomId from the query
  while the client sends it in the body, then wrote the resulting null.
- Club join requests were inverted (the enum is Open=0, InviteOnly=1,
  AskToJoin=2), so ask-to-join clubs rejected everyone and invite-only clubs
  quietly admitted them.
- Room bans were pushed as id 24, for which the client registers no handler;
  they now go out as ModerationKick with IsBan, so the ban takes effect
  immediately instead of at next login.
- Room-key ownership queries answered for the wrong player, and "has anyone
  bought this key" asked "did I buy it" — always false for the creator.

Also persists settings that were previously acknowledged and dropped
(playlist accessibility/restrictions/level-voting/warning, per-player status
visibility, VR movement mode and avoid-juniors, custom room codes), which is
what the new columns in the migration are for.

One test asserted the old nested PriceAndCurrency response shape; the
client's consumable formatter has no such key and reads Price and
PurchaseCurrencyId flat, so the test was corrected alongside the handler.
2026-07-27 23:27:23 +03:00
Alexa 674a3fc257 feat: Update room version clamping logic; adjust maximum persisted room version for March-2023 client compatibility 2026-07-17 00:16:54 +03:00
Alexa e93bbb14cb feat: Implement room blob version clamp toggle; add settings endpoint and UI support for managing version clamping of imported rooms 2026-07-16 23:34:55 +03:00
Alexa 618bcd9203 feat: Update quest team size handling; correct method references for GameConfigurationAsset and GameSpawnManager 2026-07-16 00:19:22 +03:00
Alexa 6d78b7b115 feat: Update API and documentation to ensure sub-room saves return a paged object; fix deserialization issues for the 2023 client 2026-07-13 12:23:18 +03:00
Alexa 6ee018b844 feat: Implement private room cloning and instance management; ensure cloned rooms start private and funnel joins into a single instance 2026-07-13 12:10:12 +03:00
Alexa 690e77f520 feat: Enhance room cloning functionality; add support for roomserver routes and blob management for first-party templates 2026-07-13 11:51:20 +03:00
Alexa e4126e38ce feat: Inherit IsAGRoom property from source for proper cloning of RRO rooms; ensure compatibility with baked geometry loading 2026-07-13 01:16:54 +03:00
Alexa a2ffb5ef4b feat: Update room properties to support cloning for RRO/AG base rooms; adjust moderation logic for cloning permissions 2026-07-13 01:04:10 +03:00
Alexa 1152d9ba48 feat: Update room cloning API to accept multiple content types for name input; enhance compatibility with 2023 client 2026-07-13 01:00:00 +03:00
Alexa 5460cc3601 feat: Add regression tests for room cloning functionality; ensure cloned rooms return full details 2026-07-13 00:54:12 +03:00
Alexa 2c749c2677 feat: Implement base room management; add API endpoints for retrieving and setting curated base rooms 2026-07-13 00:40:35 +03:00
Alexa 2795d2ed7d feat: Enhance room cloning functionality; ensure cloned rooms include source scenes for complete structure 2026-07-13 00:15:57 +03:00
Alexa 757308b44d feat: Update PlayerCheer and Avatar handling; enhance API responses and add new fields for 2023 client compatibility 2026-07-12 23:54:11 +03:00
Alexa 3d7d7ebdf1 feat: Enhance API endpoints and add new features
- Added support for v3 gift generation in AvatarGiftsController.
- Updated club report messages to include club IDs for better tracking in CompatibilityFeatureController.
- Introduced new endpoints for voice moderation and whitelisted emojis in ConfigController.
- Modified image retrieval logic to support single image lookups by name in ImagesController.
- Adjusted incentivized referrals to return an empty array when no rewards are available in IncentivizedReferralsController.
- Implemented trial invention functionality in InventionsController.
- Added top creators today endpoint in PlayerSubscriptionsController.
- Expanded room earnings distribution functionality in RoomEarningsDistributionsController.
- Enhanced room search capabilities with pagination in RoomsController.
- Improved account name generation options in AccountsController.
- Added party invite privacy settings in PlayerSettingsController.
- Created CommentsController for handling unread comment counts.
- Established PurchaseCampaignController for handling purchase campaign telemetry.
2026-07-12 22:18:58 +03:00
Alexa 5fcef3ffe7 feat: Remove unnecessary push notifications for avatar updates in AdminController; update comments for clarity 2026-07-12 13:18:32 +03:00
Alexa 28b5d5525b feat: Refactor notification handling to use SystemNotificationService; remove outdated NotificationService references and update related logic 2026-07-12 12:47:29 +03:00
Alexa 35ef784bd9 feat: Update notification handling to prevent account DTO issues; add SystemNotificationService for event notifications 2026-07-12 12:43:09 +03:00
Alexa 63e3b11736 feat: Enhance avatar gifts and items handling; normalize gift descriptions and include color variants with unique IDs 2026-07-12 00:54:01 +03:00
Alexa c472e7f342 feat: Update gift handling to ensure AvatarItemDesc matches v4/items format; prevent duplicate wardrobe entries 2026-07-12 00:33:55 +03:00
Alexa 1a946f3023 feat: Add AvatarItemId, TagList, and IsBaseAvatarItem to owned avatar items; implement stable ID generation and corresponding tests 2026-07-10 00:08:10 +03:00
Alexa e27b06ce5b feat: Implement "everyone owns all avatar items" toggle; update server settings, controller logic, and admin UI 2026-07-09 11:37:47 +03:00
Alexa 479ea273ea fix: Revert room-details DTO extras that broke room parsing
af0d7b6 added RankingContext/DataBlob/MaxPlayers/ToxmodEnabled to the
shared BuildRoomServerDetails (and DataBlobHash/Description to
BuildStudioSaveWire) to try to satisfy the 2023 SaveRoom commit-response
Dispose walk. The invented RankingContext object shape is rejected by
the strict room-details mapper, so GET rooms/{id} (and every room list
that shares the builder) failed with "Malformed Response" -> rooms
broke. The 2023 mappers validate value SHAPES, not just key presence, so
bolting unverified keys onto the shared room DTO is unsafe.

Reverts af0d7b6 code changes. Room saving remains functionally correct
(blob persists, fanout delivers); the SaveRoom success toast is left for
a separately-verified fix.
2026-07-08 20:33:39 +03:00
Alexa af0d7b6c20 feat: Enhance room save flow documentation and tests to ensure all required fields are present; update validation logic for Room and SubRoomDataSave 2026-07-08 03:15:06 +03:00
Alexa 4021f7c6e0 feat: Update RoomInstance handling to serialize roomInstanceId as string for JSON compatibility; add tests to ensure correct behavior 2026-07-08 03:04:45 +03:00
Alexa 74951d5c30 feat: Implement quest reward chest functionality; enhance gift generation and consumption logic, update database schema, and add tests for new behavior 2026-07-08 02:40:42 +03:00
Alexa 892c27ffea feat: Implement quest reward system with item selection and storage; update related services and database schema 2026-07-08 01:51:55 +03:00
Alexa 0defa37928 feat: Implement consumables support in StoreService and add related tests; include new consumables data 2026-07-08 01:33:29 +03:00
Alexa f23df2659d feat: Update Images and Rooms APIs to support 2023 client URL structure and add tests for new functionality 2026-07-08 01:29:54 +03:00
Alexa 1417651c77 feat: Enhance room save flow with new metadata handling and add tests for 2023 client integration 2026-07-08 01:19:19 +03:00
Alexa a18236d064 fix: Correct AvatarItemId type handling to prevent malformed responses in storefront payload 2026-07-08 01:04:51 +03:00
Alexa 3d2deeedf8 fix: Update AvatarItemId handling to ensure correct data type and prevent malformed responses in Shop tab 2026-07-08 01:04:39 +03:00
Alexa 7b7eb651e3 feat: Add tests for 2023 watch shop tab and room consumables; implement new storefront endpoint and update documentation 2026-07-07 23:27:13 +03:00
Alexa 8e87a9de82 feat: Implement Room Consumables API with CRUD operations and purchase logic; add tests for end-to-end coverage 2026-07-07 14:42:05 +03:00
Alexa fc60c9bf40 Refactor URL coverage documentation and update StoreService for color variants
- Updated `url-coverage.md` to clarify the behavior of the `api/rooms/v2/myRecent` endpoint.
- Enhanced `StoreService.cs` to include new properties for color variants: `combination_id` and `descriptor`.
- Modified logic in `StoreService` to validate color variants based on the new descriptor instead of swatch GUID.
- Updated database bootstrap scripts to create new tables for `RoomConsumables` and `RoomConsumableOwnership`, including necessary indexes.
- Revised README to reflect changes in debugging options and the introduction of `DorkNet.DebugMod`.
- Updated documentation on request expectations to clarify server-side behavior for room visibility.
- Enhanced installation script to provide clearer instructions for enabling the debug console.
- Added a new document detailing the process for dumping avatar items and color variants from the Rec Room client.
2026-07-07 14:28:40 +03:00
Alexa 9a1af3a99d data: add 66 color variants from runtime catalog dump (566 to 632)
Runtime AvatarCatalogDumpMelon dump surfaced item->swatch pairings the
static bundle scan's (slot,name) matcher missed. Kept only the 66 that
map a swatch 1:1 to a single color combination (unambiguous store tile
key); the remaining shared-swatch/multi-color items need a richer variant
model and are deferred. Purely additive.
2026-07-07 13:48:55 +03:00
Alexa a8d7ab4944 feat: Add tests for room visit visibility and bootstrap logic; update versioning and image references 2026-07-03 22:05:20 +03:00
Alexa 111ee6a548 data: add 147 color variants from full March bundle scan (419 to 566)
Scanned all 6365 StreamingAssets/aa bundles for *_Combination
MonoBehaviours, parsed each name (Item_Slot_Colour) plus its
{combinationId},{swatchGuid} pair, and matched to the 882-item catalog
by exact (slot,name). Yields 147 new colored variants beyond the
prior name-prefix pass. Purely additive; the store seeds the new
wardrobe-colored-* tiles on restart.
2026-07-03 17:59:02 +03:00
Alexa 77e5ef5678 Merge branch 'march-2023-03-21' of https://github.com/DorkSquadRR/DorkNet into march-2023-03-21 2026-07-02 10:37:36 +03:00
Alexa 0986a0a118 feat: Enhance room image handling by introducing default image and updating display logic; add unit tests for image resolution 2026-07-02 10:37:34 +03:00
Alexa 628f430645 Add Charades word lists management, profanity filter, and matchmaking routes (#41)
* feat: add 3D Charades word lists management and profanity filter toggle

- Implemented Charades word lists management in the admin UI, allowing for unlimited named word lists with difficulty settings.
- Added functionality to bind live card slots to specific word lists, with automatic fallback to built-in lists.
- Introduced a profanity filter toggle in server settings, enabling or disabling the server-side profanity filter.
- Updated database schema to support new features, including additional columns for profanity filter status and charades slot bindings.
- Enhanced documentation to reflect new features and their usage.

* feat: add identityserver-signing.pfx to .gitignore

* feat: add new matchmake routes for player, event, club, and code
2026-07-02 10:11:18 +03:00
Alexa fbfabaf26e feat: Update README with branch version and single-server defaults; enhance inventory and room handling for equipped items 2026-06-28 02:15:58 +03:00
Alexa 7da20f2047 feat: Update appVersion to 2023.03.21 and enhance avatar-item linkage in store service 2026-06-28 01:31:21 +03:00
Alexa 50e03af296 feat: Add endpoint for ad carousel items returning an empty banner 2026-06-26 19:25:25 +03:00
Alexa a1a29bb64d Refactor code structure for improved readability and maintainability 2026-06-26 12:01:54 +03:00
Alexa ac1dcf3ad7 feat: Implement room restoration functionality and update room state management 2026-06-26 11:14:21 +03:00
Alexa 273352db08 feat: Add DorkNet.DebugMod for optional diagnostic features and improved debugging capabilities 2026-06-26 11:03:14 +03:00
Alexa 159ca2c271 Refactor code structure for improved readability and maintainability 2026-06-25 23:23:09 +03:00
Alexa 5af3fa8fc5 feat: Add ProtoDumper for runtime Google.Protobuf schema extraction and decoding 2026-06-25 20:21:56 +03:00
Alexa c9ed2df278 Merge branch 'march-2023-03-21' of https://github.com/DorkSquadRR/DorkNet into march-2023-03-21 2026-06-25 16:10:12 +03:00
Alexa 80f0691516 chore: Remove dorknet-reupload-staging subproject reference 2026-06-25 16:10:10 +03:00
Alexa 9d33b1f4d6 feat: Add support for Studio single-room dumps in RoomZipImportController and update ImportRoom UI 2026-06-25 15:27:34 +03:00
Alexa 44fc9047e0 feat: Make S3 bucket names configurable via environment variables 2026-06-25 14:04:36 +03:00
Alexa 98fa7b89cf feat: Enhance domain configuration to support hyphen subdomains and update related methods 2026-06-25 11:46:34 +03:00
Alexa ff55baa0c5 refactor: Remove unused ToxMod voice component patch and related logging 2026-06-25 10:56:45 +03:00
Alexa 312ecbbdc8 feat: Add account privacy settings endpoint
- Implemented new endpoints for retrieving account privacy settings.
- Added fields for privacy preferences such as profile visibility, online status, and activity visibility.

feat: Extend play menu tags functionality

- Updated PlayMenuTagsRequest to include TrendingTags.
- Modified SetPlayMenuTags to handle trending tags.
- Enhanced logging to include trending tags.

fix: Increase maximum archive size for room zip imports

- Updated MaxArchiveBytes from 12GB to 25GB in RoomZipImportController.

feat: Improve CDN image handling

- Added local image path resolution for room images.
- Implemented lazy loading of room image aliases from JSON file.

feat: Enhance match player presence resolution

- Updated ResolveDataBlobForPresenceAsync to consider sub-room IDs and private instance status.

feat: Extend RoomEntity with new properties

- Added AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson fields.

feat: Update database schema for new room properties

- Added new columns to the Rooms table for AllowNewUsers, MinLevel, MaxPlayerCalculationMode, LoadScreensJson, PromoImagesJson, and PromoExternalContentJson.

feat: Implement platform notifications controller

- Created PlatformNotificationsController for managing user notification preferences.
- Added endpoints for retrieving and setting notification preferences and categories.
2026-06-25 01:23:57 +03:00
Alexa 1d4777e3da feat: Enhance RoomZipImportController to support Unity asset bundles
- Added support for .assetbundle files in RoomZipImportController.
- Introduced new properties in RoomSceneEntity for handling Studio asset metadata.
- Updated ZipImport method to handle asset bundles during bulk imports.
- Enhanced CdnController to serve Unity asset bundles via a new route.
- Modified GoToController to include Unity asset ID in matchmaking responses.
- Updated database schema to accommodate new fields related to Studio asset bundles.
- Improved URL coverage documentation to reflect new routes for asset bundles.
2026-06-24 16:11:21 +03:00
Alexa 38fbd0faf1 Refactor room data blob handling and permissions
- Updated the RoomsController to utilize synthetic CDN keys for data blobs.
- Introduced methods for determining current or synthetic data blob names.
- Adjusted room role permissions to align with new game role definitions.
- Enhanced the SanitizeController with a new endpoint for purity checks.
- Modified Match and PlayerPresence services to handle deferred presence commits.
- Improved RoomDataBlobService to support new game role and tool tag settings.
- Added support for boot dorm grace period in PlayerPresenceService.
- Updated protobuf definitions to reflect new room role and game role structures.

You are now able to go into dorm and the rec center!
2026-06-24 12:02:13 +03:00
Alexa 99d3faec5b feat: Enhance HttpTracePatches with detailed logging for RecNet requests and responses 2026-06-23 22:48:10 +03:00
Alexa 02f7bc386d feat: Enhance UgcPurchasablesController to handle bulk requests with object arrays
- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs.
- Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists.
- Added synthetic placeholder responses for missing UGC items to prevent client errors.

feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController

- Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID.
- Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms.
- Improved logging for matchmaking actions.

fix: Refactor PlayerSettingsController to handle database save exceptions

- Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios.
- Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync.

chore: Update URL coverage documentation for new matchmaking endpoints

- Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md.

chore: Add default room data file for room loading

- Included default_room.room to serve as a fallback for missing room data.

fix: Improve SQLite concurrency handling in DatabaseBootstrap

- Configured SQLite to use WAL mode and set a busy timeout for better concurrency.

chore: Create SqliteErrors utility for handling SQLite exceptions

- Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions.

refactor: Update RoomDataBlobService to serve default room data

- Modified GetDefaultBlob to load and serve a default room blob with proper role data.
2026-06-23 20:54:29 +03:00
Alexa 27e5e0064c feat: Enhance API endpoints with new methods, improved response structures, and added Photon access token endpoint 2026-06-23 12:42:24 +03:00
Alexa 4034449a6d Refactor configuration endpoints and update client installation scripts
- Removed Photon configuration fields from dorknet-clientmod.json.template.
- Added new backtrace and Azure Speech configuration endpoints in ConfigController.
- Updated response format for IsCreationEnabled and IsRenderingEnabled endpoints in CustomAvatarItemsController.
- Enhanced InfluencerPartnerProgramController to include additional player properties.
- Implemented RoomKeepsakes endpoint in KeepsakesController to retrieve keepsakes by room ID.
- Modified ReportsController to standardize response for Hile reporting.
- Added PlayerDataForMe and RoomsRequiring endpoints in RoomsController for enhanced player data retrieval.
- Introduced new dorm matchmaking methods in MatchController for player session management.
- Updated MatchPlayerController to handle exclusive login functionality.
- Revised install scripts to reflect changes in Photon AppId handling and patching process.
- Added new patch-client.ps1 script for applying static byte patches to the Rec Room client.
2026-06-23 10:42:25 +03:00
Alexa 142f52569a feat: Update GetMine method to return a list of SettingDto instead of a dictionary 2026-06-23 00:29:11 +03:00
Alexa df73507ee9 feat: Enhance API endpoints with pagination and new subscription details for players 2026-06-23 00:21:58 +03:00
Alexa 70980bf3b6 feat: Add new API controllers for progression events, room consumables, currencies, earnings distributions, screen share reports, subscription seasons, and UGC purchasables
- Implemented ProgressionEventsController to handle progression event retrieval and active events.
- Created RoomConsumablesController for listing and purchasing room consumables.
- Developed RoomCurrenciesController for managing room currencies, including creation, updates, and balance retrieval.
- Added RoomEarningsDistributionsController to fetch earnings distribution data for rooms.
- Introduced ScreenShareReportsController for reporting screen share incidents.
- Established SubscriptionSeasonsController to provide current subscription season details and rewards.
- Launched UgcPurchasablesController for managing user-generated content purchasables, including bulk operations.
- Added new data entities for customization and room economy, including custom avatar items, room currencies, and UGC purchasables.
2026-06-22 16:08:23 +03:00
223 changed files with 85993 additions and 8800 deletions
+3
View File
@@ -86,3 +86,6 @@ Recovered(*)/
local-releases/**
DorkNet.Models/bin-verify/**
DorkNet.Models/bin-verify2/**
identityserver-signing.pfx
-2
View File
@@ -53,14 +53,12 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin
# SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
+336 -2104
View File
@@ -3,9 +3,17 @@
// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. Photon AppId — override the baked-in Photon Cloud AppId
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
// 4. TLS trust bypass — make BouncyCastle never reject server certs
// 2. TLS trust bypass — make BouncyCastle never reject server certs
//
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
// needed so a modded client isn't kicked, and three opt-in features that
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
// Rec Royale night mode).
//
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
// the Mods folder to debug); the dev-menu / dev-debug-tools tooling
// lives outside the public repo entirely.
//
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
// install-melon.ps1 wrapper writes that file on install; you can also
@@ -22,13 +30,22 @@
// Il2CppAssemblies directory just to write the source.
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MelonLoader;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
@@ -39,15 +56,13 @@ public class Mod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static Mod? Instance;
internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _joinTracePatchesRegistered;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame;
private bool _firstUpdateLogged;
private bool _antiTamperCallbackPatchesComplete;
private int _antiTamperCallbackRetryFrame;
private bool _toxModPatchesComplete;
private int _toxModRetryFrame;
// Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this
@@ -55,38 +70,7 @@ public class Mod : MelonMod
public static class Cfg
{
public static string ServerHost = "localhost";
public static string PhotonAppId = "";
public static string PhotonVoiceAppId = "";
// Forced PhotonServerSettings.PreferredRegion (with HostType
// switched to PhotonCloud) so two watches don't auto-ping
// themselves into different "best" regions and end up in
// parallel Photon rooms sharing only a name. PhotonPatches
// .ApplyForcedRegion prefers the server-told region from
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
// and falls back to this config value on initial boot before
// any /goto has returned. Default matches the server's
// appsettings.json Photon:CloudRegion ("eu").
public static string PhotonCloudRegion = "eu";
// Photon Custom Auth injector parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
public static bool EnableTlsTrustBypass = true;
// Log-only trace for the stuck first-dorm "Change Username?"
// dialog. When true, RegistrationPatches hooks the dorm
// registration-prompt flow + the confirm-dialog buttons and writes
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
// brand-new account that reproduces the freeze tells us exactly
// where it breaks (does the click reach Button_Affirmative? does
// the prompt even run?). No behaviour change — purely diagnostic.
// See RegistrationPatches and the registration site in
// OnLateInitializeMelon.
public static bool TraceRegistrationDialog = true;
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
// ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
@@ -111,29 +95,6 @@ public class Mod : MelonMod
public static int DesktopScreenShareResolution = 0; // horizontal px
public static int DesktopScreenShareQuality = 0; // JPEG quality
// One-shot dev-UI diagnostic (no behaviour change). When true,
// ~5s after load the mod logs whether the watch's native developer
// menu content actually exists in this build (HomeScreenFlow.
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
// + whether its static Execute() command path works. This tells us
// stripped-vs-gated so we know whether to un-gate the game's own dev
// menu or build a custom watch entry. Output goes to the MelonLoader
// console + dorknet-diagnostics.log.
public static bool DiagnoseDevMenu = false;
// Inject a "Dev Console" button into the watch's native dev-menu
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
// devButtonPrefab. The panel/prefab exist in this build (just gated
// off), so we instantiate + activate + label, then wire its click to
// run console commands. Native Button3D → works in VR and Screen Mode.
public static bool EnableDevWatchButton = false;
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
// is on). Each entry is "Label=command"; clicking runs
// DebugConsole.Execute(command). Edit to match this build's actual
// DebugConsoleCommandConfig command names/args.
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
// Raise RRO quest party size past the baked 4. The cap lives in each
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
// ScriptableObject (baked in the client bundle; no server lever). The
@@ -149,54 +110,67 @@ public class Mod : MelonMod
// generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
}
// ── Rec Royale night mode (client-side visual) ──
// Re-skins Rec Royale with one of the game's own shipped night
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
// through SceneMoodSettings' apply path, and unlocks the weapon
// flashlight prefab while it is on. Purely local: the networked room
// mood index is never touched. See RecRoyaleNight.
public static bool RecRoyaleNightMode = false;
// Resources asset name of the MoodSetting to apply. Shipped options:
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
// Unity scene names (substring, case-insensitive) that count as Rec
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
public static bool RecRoyaleNightFlashlights = true;
// Apply the mood's fog block too. Off = the mood's FogSettings are
// swapped for a block captured from the scene, so fog stays as-is.
public static bool RecRoyaleNightFog = false;
// Darkening knobs applied to the mood data before it is used. Baked
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
// ColorGradingSettings.exposure) is what actually darkens the whole
// frame; the sun/ambient multipliers tone down realtime lighting.
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
// Post-exposure only renders if the scene Volume is on and the camera
// renders post-processing; force both while night mode is active.
public static bool RecRoyaleNightForcePostProcessing = true;
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
// light). Copy its rotation/colour onto every other directional light
// and scale their intensity by RecRoyaleNightSunScale.
public static bool RecRoyaleNightMatchSunLights = true;
// Turn fog off entirely while night is on (RenderSettings.fog=false and
// the mood's fog block pushed out of range). Ignored when
// RecRoyaleNightFog is true.
public static bool RecRoyaleNightDisableFog = false;
// Renderers whose GameObject or material name contains any of these
// (case-insensitive) are hidden while night is on. Frontier's daytime
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
// "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = "";
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig();
Log.Msg($"DorkNet ClientMod loaded — host={Cfg.ServerHost}, tlsBypass={Cfg.EnableTlsTrustBypass}");
RegisterNetworkPatches();
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
}
public override void OnLateInitializeMelon()
{
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ===");
RegisterNetworkPatches();
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
// Hook the watch's own AppSettings + AuthValues builders directly.
// Both are private PUNNetworkManager methods with obfuscated 2020.12
// names — we know they always run before any Photon connect because
// ConnectUsingSettings reads their return values. Postfix logs them
// after construction.
//
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely
// "for voice"). Two call paths in the binary.
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
// environment + accessToken AuthGetParameters.
//
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
// return type first so a future platform/reseed does not make these
// two critical hooks depend on the private method names.
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
fallbackMethodName: "FJOLIPKKIBE",
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
fallbackMethodName: "EMGGKBALALH",
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
// string-like arg the IL2CPP method takes (declared `string` in
// dump.cs but Il2CppInterop wraps it in a way that breaks
// Harmony's IL emit when we declare `string` directly). Using
// `object` lets Harmony pass through whatever wrapper the IL2CPP
// proxy uses without compile-error.
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
TryPatchByName("RecRoom.AntiCheat.EACManager",
"GenerateChallengeResponse",
args: new[] { typeof(string) },
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
// Even when the prefix's return value is "let original run", the
// detour itself still has to be JIT-installed when BouncyCastle
@@ -214,145 +188,11 @@ public class Mod : MelonMod
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
}
// Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push
// nor SubscriptionUpdatePresence pushes the server fires after
// save propagate into this field. So the PostSaveReloading
// sub-state's MasterReloadRoomAsync deserializes the cached
// bytes from the OLD blob name (the one /goto returned at boot),
// wiping any MakerPen object the user just placed even though
// the new blob is already on S3.
//
// Verified live: after a dorm save with object placement, server
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
// set_RoomDataBlobName trace immediately after the save shows
// old="v(N)" new="v(N)" — no propagation, no fresh download,
// no respawn. Leave+return triggers /goto which DOES update
// RoomDataBlobName, so the fix is to do the same write
// ourselves: when entering the SavingRoomSuccess state on the
// RoomPersistenceManager, read the just-pushed
// Rooms.LocalRoomScene.DataBlobName and force-set
// __instance.RoomDataBlobName to it. That fires the real
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
// for the new blob → PostSaveReloading picks up the fresh
// bytes and respawns the MakerPen objects.
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
"set_RoomDataBlobName",
args: new[] { typeof(string) },
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
// stash the just-arrived Scenes[0].DataBlobName into a static
// so the set_RoomDataBlobName hijack can use it — otherwise
// LocalRoomDetails / LocalRoomScene never get the new blob
// name on the second-or-later push for the same (room, sub)
// tuple (= every post-save push in a dorm).
TryPatchByName("RecNet.Rooms",
"OnSubscriptionUpdateRoom",
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
// Force AccountExtensions.CanLocalPlayerChat to return true. The
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
// Coroutine d__53 offset 040) gates on this — if false, the
// coroutine bails before raising the Photon RPC, so the sender
// doesn't even see their own message. The check ANDs three
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
// 0x40), a PlatformManager flag, and a ModerationManager
// singleton field. At least one returns the wrong value against
// our server. Force-allow — single-tenant private server, text
// moderation isn't a concern.
TryPatchByName("RecNet.AccountExtensions",
"CanLocalPlayerChat",
args: Type.EmptyTypes,
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
// font asset's character table via TMP_FontAsset.HasCharacters
// to strip glyphs the font can't render. On 2020 Rec Room the
// chat font field can be null at the moment this fires (font
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
// inside Unity's TMP — which propagates out through the chat
// send callback and aborts the RPC. The watch's own ISIL has
// "skipping invalid character check" log paths for missing
// assets but doesn't actually short-circuit on the null field
// we hit. Skip the method entirely; chat lines will just keep
// any glyphs the font can't render (most common: emoji).
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RemoveInvalidCharactersFromMessage",
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
// Trace hooks so we can see exactly where chat dies. Send path:
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
// → PlayerEmotes.SendChatEmote(msg, true)
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
// → b__0 callback (error, cleanVersion) — fires Photon RPC
// AND local-echoes via ProcessNewChatMessageReceived
// → on each receiver (including self): RpcChatEmote(msg) →
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
// the chat log buffer.
TryPatchByName("RecRoom.Players.PlayerEmotes",
"SendChatEmote",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"ProcessNewChatMessageReceived",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RpcChatEmote",
args: new[] { typeof(string) },
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
// Diagnostic trace for the stuck first-dorm "Change Username?"
// dialog (new accounts can't get past it; "Okay" highlights but
// never dismisses the modal, on every device). This is LOG-ONLY —
// no behaviour change. It bisects the failure for a brand-new
// account that reproduces the freeze, writing to
// MelonLoader/UserData/dorknet-diagnostics.log:
//
// 1. RegistrationModel.IsFullyRegistered() return value — if
// false, the dorm prompt path runs; if true, no prompt (so a
// freeze here would be something else entirely).
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
// which prompt branch executed (b__6_1 = a dialog was raised).
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
// whether the click actually reaches the dialog's button
// handler when the user taps Okay. If these FIRE but the modal
// stays, the break is in the resolve/hide completer
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
// click never routes to the button (input/raycast) and the fix
// is elsewhere. Either way the next step is unambiguous.
if (Cfg.TraceRegistrationDialog)
{
TryPatchByName("RRUI.Data.NUX.RegistrationModel",
"IsFullyRegistered",
args: Type.EmptyTypes,
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
// The two registration dialogs are raised from
// DormroomSceneManager's PromptForRegistration via its
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
// same nested-lambda patcher the join trace uses.
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
nameof(RegistrationPatches.PromptLambda_Prefix));
// The confirm-dialog buttons. Clean, non-generic, no-arg names
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
// Button_Affirmative, the screenshot's only button.
foreach (var (method, label) in new[]
{
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
})
{
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
method, args: Type.EmptyTypes, prefix: label);
}
}
RegisterJoinTracePatches();
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
// patches — core, always on, or the modded client gets kicked.
RegisterAntiTamperCallbackPatches(logMisses: true);
RegisterToxModPatches(logMisses: true);
PatchFileHashCheckerCallback();
// Desktop Screen Sharing FPS override. Only register when a target
// is set; the postfix on the gadget's Awake rewrites the baked
// refresh-frequency field (and, if enabled, raises the Photon
@@ -362,15 +202,8 @@ public class Mod : MelonMod
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
"Awake", args: Type.EmptyTypes,
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
}
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
"set_LaserPointerEnabled",
args: new[] { typeof(bool) },
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
// RRO quest party-size bump. Prefix the runtime config-apply path so
// we can read the config's Name (scope to the allowlisted quests) and
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
@@ -378,159 +211,164 @@ public class Mod : MelonMod
if (Cfg.QuestMaxTeamSize > 0)
{
// The runtime team cap is built from the baked GameConfigurationAsset
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
// (a readable ScriptableObject) by GameConfigurationAsset.FIFOMGAOOHH()
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
// struct-array) into the protobuf TeamSize. Prefix that builder and
// bump the source array IN PLACE before it converts, scoped by
// asset.Name. This avoids the obfuscated protobuf entirely (its
// generic RepeatedField getter isn't emitted by Il2CppInterop).
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "FIFOMGAOOHH",
prefix: nameof(QuestTeamSize.Build_Prefix));
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
TryPatchByName("GameSpawnManager", "KMPAOJGOOCH",
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
}
RegisterDiagnostics();
Log.Msg("=== Client patches registered ===");
}
// ── Room-join + watch-button trace ────────────────────────────────
// Traces the full "press the Dorm Room button → join the room" chain
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
// is debuggable from the log alone. The chain, verified against the
// 2020.12.18 IL2CPP dump:
//
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
// → NotificationManager.Play("An error occured", "…happyfox…")
// (the "contact recroom.happyfox.com" toast)
//
// We can't express the obfuscated RunJoinRoom param types as a Type[]
// for AccessTools, so every RunJoinRoom overload + every /goto overload
// is patched by iterating declared methods by name, and the prefixes
// read arguments generically via Harmony's `object[] __args`.
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
args: Type.EmptyTypes,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
// Patch every declared method on `typeName` named `methodName`,
// regardless of signature, with a single prefix. Used for obfuscated
// overload sets whose param types we can't name in a Type[].
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
// Rec Royale night mode: armed when enabled OR when a hotkey is set
// (so it can be switched on in-game). Scene detection + mood apply
// run from OnSceneWasLoaded / OnUpdate below.
if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey))
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
return false;
_recRoyaleNightArmed = true;
RecRoyaleNight.Register(HarmonyInstance);
}
var prefixMethod = GetPatchMethod(prefix);
}
private bool _recRoyaleNightArmed;
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
{
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
}
private bool PatchFileHashCheckerCallback()
{
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
foreach (var type in GetLoadableTypes(asm))
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
if (!MatchesFileHashCheckerCallbackType(type)) continue;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
Log.Warning("[patch-miss] file hash checker callback");
return false;
}
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
return true;
}
// Patch compiler-generated lambda methods (names containing "b__")
// on the nested display classes of `typeName` whose (de-mangled) name
// also contains `nameContains`. Il2CppInterop mangles the angle
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
// method name itself intact, so a plain substring match still works.
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
private void RegisterAntiTamperCallbackPatches(bool logMisses)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
return false;
}
var complete = true;
complete &= PatchCallbackMethod(
"hile warning callback",
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
logMisses);
complete &= PatchCallbackMethod(
"unknown DLL callback",
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
logMisses);
_antiTamperCallbackPatchesComplete = complete;
}
private void RegisterToxModPatches(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
"CanInitializeToxMod",
args: Type.EmptyTypes,
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
logMiss: logMisses);
_toxModPatchesComplete = complete;
}
private bool PatchCallbackMethod(string label,
Func<Type, bool> typePredicate,
Func<MethodInfo, bool> methodPredicate,
string prefix,
bool logMiss)
{
if (_diagnosticPatchLabels.Contains(label))
return true;
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var method in AccessTools.GetDeclaredMethods(nested))
foreach (var type in GetLoadableTypes(asm))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
try
if (!typePredicate(type)) continue;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (!methodPredicate(method)) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
if (patched == 0 && logMiss)
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
return patched > 0;
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {label}");
return false;
}
_diagnosticPatchLabels.Add(label);
return true;
}
private static bool TypeNameContains(Type type, string value)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains(value, StringComparison.Ordinal);
}
private static bool MethodNameContains(MethodInfo method, string value)
=> method.Name.Contains(value, StringComparison.Ordinal);
private static bool MatchesFileHashCheckerCallbackType(Type type)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
}
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
{
if (method.ReturnType != typeof(void)) return false;
var parameters = method.GetParameters();
if (parameters.Length != 1) return false;
var name = method.Name;
if (name == "<InitializeFileHashChecker>b__0") return true;
if (name == "_InitializeFileHashChecker_b__0") return true;
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
&& name.Contains("b__0", StringComparison.Ordinal);
}
private void RegisterNetworkPatches()
{
if (_networkPatchesRegistered) return;
@@ -551,7 +389,6 @@ public class Mod : MelonMod
_networkPatchesRegistered = complete;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
@@ -578,33 +415,27 @@ public class Mod : MelonMod
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
return true;
}
public override void OnUpdate()
{
if (!_firstUpdateLogged)
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{
_firstUpdateLogged = true;
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
_antiTamperCallbackRetryFrame = 0;
RegisterAntiTamperCallbackPatches(logMisses: false);
}
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
if ((_diagnosticRetryFrame % 60) != 0) return;
var logMisses = (_diagnosticRetryFrame % 300) == 0;
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
if (_diagnosticGameComplete)
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
{
_toxModRetryFrame = 0;
RegisterToxModPatches(logMisses: false);
}
}
public override void OnApplicationQuit()
{
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
}
// ── Config ────────────────────────────────────────────────────────
private static void LoadConfig()
{
@@ -612,28 +443,20 @@ public class Mod : MelonMod
{
var userData = ResolveUserDataDirectory();
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
@@ -643,28 +466,39 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood;
if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble();
if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean();
if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
var names = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
names.Add(e.GetString()!);
Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing)
}
if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? "";
if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array)
{
var scenes = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
scenes.Add(e.GetString()!);
if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray();
}
// "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
}
}
private static string ResolveUserDataDirectory()
{
foreach (var typeName in new[]
@@ -684,7 +518,6 @@ public class Mod : MelonMod
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
return Path.Combine(gameDir, "MelonLoader", "UserData");
}
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
@@ -702,8 +535,6 @@ public class Mod : MelonMod
value = default;
return false;
}
// ── Patch dispatch helpers ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null,
bool logMiss = true)
@@ -727,7 +558,6 @@ public class Mod : MelonMod
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {label}");
return true;
}
catch (Exception ex)
@@ -736,13 +566,6 @@ public class Mod : MelonMod
return false;
}
}
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
// by walking every loaded assembly. Tries the exact name first, then
// the same name with an `Il2Cpp` prefix, then a substring scan as a
// last resort — covers all the IL2CPP interop naming schemes the user
// might end up with depending on the MelonLoader / Il2CppInterop
// version installed.
private bool TryPatchByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
@@ -756,59 +579,6 @@ public class Mod : MelonMod
}
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
}
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
string fallbackMethodName,
string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var type = ResolveType(typeName);
var label = $"{typeName}.<returns {returnTypeName}>";
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
}
var matches = new List<MethodInfo>();
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
}
if (matches.Count == 1)
{
var method = matches[0];
try
{
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
return false;
}
}
if (matches.Count > 1)
{
var names = new List<string>();
foreach (var method in matches) names.Add(method.Name);
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
}
else if (logMiss)
{
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
}
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
}
internal static Type? ResolveType(string name)
{
if (ResolvedTypeCache.TryGetValue(name, out var cached))
@@ -820,6 +590,8 @@ public class Mod : MelonMod
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t);
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with
@@ -844,13 +616,11 @@ public class Mod : MelonMod
}
return null;
}
private static Type CacheResolvedType(string name, Type type)
{
ResolvedTypeCache[name] = type;
return type;
}
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
{
try
@@ -871,25 +641,16 @@ public class Mod : MelonMod
return Array.Empty<Type>();
}
}
private static bool MatchesRequestedType(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
if (type.FullName == requested || type.FullName == prefixed) return true;
var dottedPrefixed = "Il2Cpp." + requested;
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static bool ReturnTypeMatches(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
if (type.FullName == requested || type.FullName == prefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
@@ -898,74 +659,16 @@ public class Mod : MelonMod
names.Add(arg.Name);
return $"{typeName}.{methodName}({string.Join(",", names)})";
}
private MethodInfo GetPatchMethod(string name)
{
// Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups.
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
// Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
private void RegisterDiagnostics()
{
Log.Msg("=== Registering diagnostics ===");
if (!_diagnosticCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
_diagnosticCoreRegistered = true;
}
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
Log.Msg("=== Diagnostics registered ===");
}
private bool RegisterGameDiagnostics(bool logMisses)
{
var complete = true;
// CheatManager fires these when its anti-cheat heuristics trip —
// logging them surfaces "client thinks it's been tampered with",
// a common cause of silent dorm-drops / kicks on a modded client.
// Names verified in CheatManager.txt for 2020.12.18; the older
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
// there's no DeveloperFlag/StreamingAsset callback nor a
// BootSequence.RegisterError sink in this build — those legacy
// hooks were removed (they only ever logged [patch-miss] forever).
foreach (var method in new[]
{
"OnTimeCheatDetected",
"OnObscuredTypeCheatDetected",
"OnHeightCheatDetected",
"OnAdvancedMovementCheatDetected",
})
{
complete &= TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses);
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
// pure-logging diagnostics that fired during Unity's very first
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
// methods) MonoMod's lazy CompileMethodHook on the trampoline
// crashes the CLR with 0x80131506 the instant Awake JITs. The
// hooks gave us no behavioral leverage — only a `[call] …` log
// line — so they're disabled. If anyone needs Steam-side visibility
// later, route it through the game's own log lines instead.
return complete;
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
@@ -982,626 +685,6 @@ public class Mod : MelonMod
}
}
// ── Patch holders ─────────────────────────────────────────────────────
// Each holder is a static class with public static Prefix methods that
// Harmony invokes. Kept separate from Mod for readability and so the
// reflection lookup in GetPatchMethod stays simple.
// ── Debug console access ───────────────────────────────────────────────
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
// dev console (text input + a DebugConsoleCommandConfig command table:
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
// …). Retail gates it behind a developer flag. This holder force-toggles
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
// CheatManager's tamper detectors so movement/time commands don't drop you
// to the dorm.
//
// Everything is resolved by reflection at runtime so the SAME code works on
// both client builds despite their differing obfuscation:
// * The show/hide toggle is the one non-compiler-generated private
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
// one, then fall back to that structural match.
// * The live component is found via UnityEngine.Object.FindObjectOfType
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
// * Execute(string) keeps its readable name on both builds (kept here as
// a scripted-command entry point even though the hotkey drives the UI).
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _pollErrorLogged;
private static int _probeFrame;
private static bool _probeDone;
private static bool _inputResolved;
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
private static object? _toggleKeyValue; // boxed KeyCode enum value
private static bool _consoleResolved;
private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
// Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path.
public static bool SuppressCheatDetected_Prefix() => false;
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
try
{
if (!_inputResolved) ResolveInput();
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
// console object actually exists, so we learn that even if the
// hotkey never fires.
if (!_probeDone && _probeFrame++ > 180)
{
_probeDone = true;
ProbeConsole();
}
if (_getKeyDown is null || _toggleKeyValue is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
{
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
Toggle();
}
}
catch (Exception ex)
{
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
}
}
// Diagnostic: reports whether a live DebugConsole component exists and
// whether the show/hide method resolved — without needing a keypress.
private static void ProbeConsole()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
}
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
public static void ExecuteCommand(string command)
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) return;
try
{
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
execute.Invoke(null, new object[] { command });
Mod.Log.Msg($"[debugconsole] executed: {command}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
}
private static void ResolveInput()
{
_inputResolved = true;
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
var inputType = Mod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
catch
{
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
}
private static void ResolveConsole()
{
_consoleResolved = true;
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
// name, then a structural fallback that fits both builds.
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(_consoleType);
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
var objType = Mod.ResolveType("UnityEngine.Object");
_findObjectOfType = objType is null ? null
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
}
// The console's show/hide is the single private, instance, void method
// taking exactly one bool, excluding compiler-generated members (the
// property backing setter on 2020.12 is also void(bool) but is marked
// [CompilerGenerated]).
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null; // ambiguous — don't guess
found = m;
}
return found;
}
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
try
{
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
if (instance is null)
{
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
return;
}
// Best-effort: flip the static "show button" enable so the
// game's own Update keeps the console available instead of
// re-hiding it. Failures are ignored (obfuscated builds).
EnableShowButton();
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
try
{
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
{
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
}
}
catch { /* best-effort — the toggle still works without it */ }
}
}
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
// Answers stripped-vs-gated for the watch developer menu. With a real dev
// account + forced flag the menu still doesn't appear, so the gate isn't the
// account flag — either the native dev-menu prefab content is stripped from
// this retail build, or a build-level flag skips its init. This logs the
// actual runtime state once ~5s after load (no behaviour change) so we know
// whether to un-gate the game's own dev menu or build our own watch entry.
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
// once. Not a Harmony holder — invoked directly, so it's not registered in
// GetPatchMethod.
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
if ((_frame % 60) != 0) return; // then check ~once per second
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
// UIBase wired (it gets assigned when the watch StackedUI initialises
// in the dorm). The earlier version fired during boot on the loaded
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
// whose runtime UIBase is null) and, being one-shot, captured nothing
// and never retried. NO timeout now — we wait until the watch is up.
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
var home = homeType != null ? FindLiveHome(homeType) : null;
if (home == null)
{
if ((_frame % 600) == 0)
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
return;
}
_done = true;
try { Run(homeType!, home); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
}
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
// copies FindObjectsOfTypeAll also returns.
private static object? FindLiveHome(Type homeType)
{
try
{
var il2 = ToIl2CppType(homeType);
if (il2 is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
int n = ArrLen(all);
for (int i = 0; i < n; i++)
{
var o = ArrItem(all, i);
if (o is null) continue;
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
}
}
catch { }
return null;
}
private static void Run(Type homeType, object homeInst)
{
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
try { DumpHomeScreen(homeType, homeInst); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
Mod.Log.Msg("=== [devmenu-probe] done ===");
}
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
{
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
var inst = FindOne(t);
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
}
private static void DumpHomeScreen(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
// managed proxy, so a plain AccessTools.Field misses them (the earlier
// "field not found" was a false negative). Try property first, then
// field. NULL => content stripped from the prefab (can't un-gate);
// present => only gated.
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
{
var (found, mt, v) = GetMember(t, inst, name);
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
var extra = "";
if (v != null && name == "devMenuPanel")
{
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
}
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
}
// Calibration: are ordinary serialized field names exposed at all on
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
// in this build. If profileButton is ALSO not found, Il2CppInterop
// isn't exposing these fields by name (need a different access path).
foreach (var cal in new[] { "profileButton", "settingsButton" })
{
var (cf, cmt, cv) = GetMember(t, inst, cal);
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
}
// Named lookups miss → dump the proxy's actual member names to see
// Il2CppInterop's scheme, then enumerate Button3D children directly
// (no field names needed) — those are our clone sources for injection.
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
DumpWatchButtons(t, inst);
DumpCollections(t, inst, "HomeScreenFlow");
DumpStackedUi(t, inst);
}
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
// to build a CUSTOM watch page by cloning a real flow:
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
// are our clone-target candidates (pick a simple self-contained one).
// * the Type->flow cache — what's already instantiated (do NOT mutate
// these cached singletons; clone an independent copy instead).
// * ScreenHeader presence — proves a cloned flow brings its own title
// bar + back button for free.
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
// Dictionary<Type,flow>) rather than by field name.
private static void DumpStackedUi(Type homeType, object homeInst)
{
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
var uiType = ui.GetType();
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
if (hdr != null)
{
var ht = hdr.GetType();
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
}
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
object? v; try { v = f.GetValue(ui); } catch { continue; }
if (v is null) continue;
int n = ArrLen(v);
// Flow-prefab catalog: a non-empty array whose elements expose a
// TypeName string (FlowPrefabResourceReference).
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
{
var first = ArrItem(v, 0);
var et = first?.GetType().Name ?? "?";
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
for (int i = 0; i < n; i++)
{
var e = ArrItem(v, i); if (e is null) continue;
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
}
continue;
}
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
var vt = v.GetType();
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
{
var ga = vt.GetGenericArguments();
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
{
var cp = vt.GetProperty("Count");
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
}
}
}
}
// Property by name, else parameterless getter method, else null.
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
{
var (found, _, v) = GetMember(t, inst, prop);
if (found && v != null) return v;
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
null, Type.EmptyTypes, null);
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
return v;
}
// First non-empty string field/property on an object — for obfuscated
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
private static string ReadStringMember(object o)
{
var t = o.GetType();
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (f.FieldType == typeof(string))
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
return "(no string member)";
}
// Enumerate Button3D components under the HomeScreenFlow via Unity's
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
// Confirms we can grab native buttons to clone for the dev entry.
private static void DumpWatchButtons(Type homeType, object homeInst)
{
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
var il2 = ToIl2CppType(btnType);
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
MethodInfo? m = null;
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (mi.Name != "GetComponentsInChildren") continue;
var mp = mi.GetParameters();
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
}
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
object? arr;
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
int n = ArrLen(arr);
Mod.Log.Msg($" [buttons] Button3D children: {n}");
for (int i = 0; i < n && i < 20; i++)
{
var b = ArrItem(arr, i);
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
Mod.Log.Msg($" [{i}] {nm}");
}
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
var f = AccessTools.Field(t, name);
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
return (false, typeof(object), null);
}
private static void DumpWatchUi(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
DumpCollections(t, inst, "WatchUI");
}
private static void DumpDebugConsole(Type t, object? inst)
{
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
{
try
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
}
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
}
// Does the static command path work without the console UI in-scene?
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
}
// Log every array / List<> field whose element type mentions Flow/Watch,
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
// regardless of its obfuscated name, so we can see if dev flows exist.
private static void DumpCollections(Type t, object inst, string label)
{
var members = new List<(string name, object? val)>();
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
foreach (var (name, v) in members)
{
if (v is null) continue;
int? count = null; string elem = "";
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
else
{
var ft = v.GetType();
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
{
var cp = ft.GetProperty("Count");
if (cp != null && cp.PropertyType == typeof(int))
{
count = (int?)cp.GetValue(v);
var ga = ft.GetGenericArguments();
elem = ga.Length > 0 ? ga[0].Name : "?";
}
}
}
if (count.HasValue &&
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
}
}
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
// in the log. Convert the managed Type to an Il2CppSystem.Type via
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
// returns INACTIVE objects + loaded prefab instances — important, the
// watch UI is usually inactive until opened). Falls back to the
// active-only FindObjectOfType.
private static object? FindOne(Type t)
{
try
{
var il2cppType = ToIl2CppType(t);
if (il2cppType is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
if (first != null) return first;
var objType = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch { return null; }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true } : new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
// Find a public static method `name` whose single parameter accepts `arg`.
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
{
if (t is null) return null;
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
}
return null;
}
// First element of an Il2CppReferenceArray / Array / IList, or null.
private static object? FirstOf(object? arr)
{
if (arr is null) return null;
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
var at = arr.GetType();
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
if (lenProp?.GetValue(arr) is int n && n > 0)
{
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
}
return null;
}
}
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
// shared desktop image. Capture cadence is the baked [SerializeField]
// float screenShareImageRefreshFrequency; the broadcast tick gates on
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
// an Awake postfix. The captured frame is transmitted via the component's
// IPunObservable OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
// Photon send/serialization rate to cover the target — otherwise a 30 fps
// capture still only delivers ~10. Field names are un-obfuscated on both
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
// lookup works.
internal static class ScreenSharePatches
{
private static bool _photonRateApplied;
@@ -1620,9 +703,6 @@ internal static class ScreenSharePatches
if (Mod.Cfg.DesktopScreenShareQuality > 0)
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
RaisePhotonRates((int)Math.Ceiling(fps));
@@ -1653,7 +733,6 @@ internal static class ScreenSharePatches
WriteInt(pn, "SendRate", newSend);
WriteInt(pn, "SerializationRate", target);
_photonRateApplied = true;
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
}
private static int ReadInt(Type t, string name)
@@ -1673,55 +752,18 @@ internal static class ScreenSharePatches
if (f is not null) f.SetValue(null, value);
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class UriPatches
{
public static void HttpRequestSetUri_Prefix(ref Uri __0)
{
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
}
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
{
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
}
public static void HttpManagerSendRequestString_Prefix(ref string url)
{
RewriteString(ref url, "httpmanager-rewrite");
@@ -1746,14 +788,24 @@ internal static class UriPatches
{
try
{
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
if (uriProp?.GetValue(request) is not Uri uri) return;
if (request is null) return;
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
uriProp.SetValue(request, rewritten);
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryRewriteUriProperty(request, prop, label)) return;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryRewriteUriField(request, field, label)) return;
}
}
catch (Exception ex)
{
@@ -1761,15 +813,51 @@ internal static class UriPatches
}
}
private static void RewriteUriArg(ref Uri uri, string label)
{
if (uri is null) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
uri = rewritten;
}
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
{
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
if (prop.GetIndexParameters().Length != 0) return false;
if (prop.GetValue(request) is not Uri uri) return false;
var rewritten = RewriteUri(uri);
if (rewritten is null) return false;
prop.SetValue(request, rewritten);
return true;
}
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
{
if (field is null || field.IsInitOnly) return false;
if (field.GetValue(request) is not Uri uri) return false;
var rewritten = RewriteUri(uri);
if (rewritten is null) return false;
field.SetValue(request, rewritten);
return true;
}
private static void RewriteString(ref string uriString, string label)
{
if (string.IsNullOrEmpty(uriString)) return;
var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
var rewritten = uriString.Replace(".rec.net", "." + host);
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
var rewritten = rewrittenUri.ToString();
if (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
uriString = rewritten;
}
@@ -1788,582 +876,42 @@ internal static class UriPatches
}.Uri;
}
}
internal static class PhotonPatches
internal static class AntiCheatPatches
{
public static void PhotonAppIdOverride_Prefix()
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
{
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return;
try
{
// PhotonNetwork.PhotonServerSettings — accessed via reflection
// so the type-name resolution stays in one place. Mutating the
// ScriptableObject in-place is what the BepInEx plugin does
// too; the Photon Connect path reads AppID / VoiceAppID off
// this object immediately after.
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork");
if (photonNetwork is null) return;
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
var settings = settingsProp?.GetValue(null);
if (settings is null) return;
var settingsType = settings.GetType();
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
var newId = Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
// Diagnostic: dump every readable AppSettings field before
// we mutate, so a Photon Custom Auth failure on first connect
// is debuggable from a single log dump. Lists every public
// property + field on PhotonServerSettings (Server, Port,
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
// (Server/Port) and 2020.12 (AppSettings) layouts.
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
DumpSettings(settings, settingsType, indent: " ");
appIdProp?.SetValue(settings, newId);
voiceProp?.SetValue(settings, newVoice);
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}");
// Force a deterministic Photon region so two watches running
// this mod end up on the same Photon master and "JoinByName"
// produces ONE shared room (not two parallel ones in eu/us
// because each watch auto-picked a different best region).
//
// The 2020 watch's default HostType is BestRegion(4) — every
// launch pings all Photon Cloud regions and picks the lowest
// latency. Two players geographically apart hit different
// "best" regions; same room name, two regions = two rooms.
// Server-side our matchmaking response carries
// `photonRegionId` but the watch's Photon code never reads
// it; the only consumer is Matchmaking.SetPhotonRegionPings
// which only reports pings *back* to the server.
//
// Strategy: switch HostType to PhotonCloud(1) and set
// PreferredRegion. Prefer the server-told region (the most
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
// any) over the config default — that way a server-side
// region change propagates without redeploying the mod.
ApplyForcedRegion(settings, settingsType);
}
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
return false;
}
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
/// and writes <c>PreferredRegion</c>. Honors any region the server
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
/// instance has been received yet (initial boot).</summary>
private static void ApplyForcedRegion(object settings, Type settingsType)
{
try
{
var hostTypeProp = settingsType.GetField("HostType")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
var preferredProp = settingsType.GetField("PreferredRegion")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
if (hostTypeProp is null || preferredProp is null)
{
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
return;
}
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
// type from the field/property so we don't depend on a
// specific Il2Cpp proxy namespace.
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
?? ((PropertyInfo)hostTypeProp).PropertyType;
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
?? ((PropertyInfo)preferredProp).PropertyType;
// Prefer server-provided region over config. We read it via
// the same reflection style as the existing patches so the
// Il2Cpp type-name suffix doesn't bite us.
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
object regionValue;
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
catch
{
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
region = "us";
}
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
}
/// <summary>Reads
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
/// lowercase enum name (eu, us, asia, …). Returns null if no room
/// instance has been received yet, or if any link in the chain is
/// null. Server-told region wins over the static config default
/// because the server is authoritative about which Photon region
/// hosts the room the watch is about to join.</summary>
private static string? ReadServerProvidedRegion()
{
try
{
var matchmaking = FindGameType("RecNet.Matchmaking");
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localRoom is null) return null;
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
if (regionObj is null) return null;
// CloudRegionCode enum.ToString() gives the lowercase region
// name (eu, us, asia, …) — see CloudRegionCode.cs.
return regionObj.ToString();
}
catch { return null; }
}
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a
// harmony hook in this branch anyway. See
// attic/AuthValuesInjector.cs.attic.
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
/// <c>Photon.Realtime.AppSettings</c> it just built.
///
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
/// <c>AppIdVoice</c> with our configured values before the AppSettings
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
/// those are unrelated leftover strings, NOT the AppIds the SDK
/// actually reads. The only reliable swap point is here, after
/// FJOLIPKKIBE constructs the AppSettings object but before the
/// LoadBalancingClient consumes it.
///
/// Then dumps every readable field/property for diagnostics.</summary>
public static void LogAppSettings_Postfix(object __result)
{
try
{
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
// ── AppId injection ────────────────────────────────────────
var t = __result.GetType();
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
? (newPun ?? null)
: Mod.Cfg.PhotonVoiceAppId;
if (newPun is not null)
{
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
var oldPun = realtimeProp?.GetValue(__result) as string;
realtimeProp?.SetValue(__result, newPun);
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
}
if (newVoice is not null)
{
var voiceProp = t.GetProperty("AppIdVoice");
var oldVoice = voiceProp?.GetValue(__result) as string;
voiceProp?.SetValue(__result, newVoice);
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
}
// Also force FixedRegion to the configured region so two clients
// never end up on different regions of the same room name.
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
{
var fixedRegionProp = t.GetProperty("FixedRegion");
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
{
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
}
}
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
DumpSettings(__result, t, indent: " ");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
}
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
/// is the AuthValues it just built with environment + accessToken
/// AuthGetParameters. Logs AuthType, UserId, and the full
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
/// forward to the configured Custom Auth URL.</summary>
public static void LogAuthValues_Postfix(object __result)
{
try
{
if (__result is null)
{
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
return;
}
var t = __result.GetType();
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
var authType = Read("AuthType");
var userId = Read("UserId");
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
var postStr = postData is byte[] bytes
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
: postData?.ToString() ?? "<null>";
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
}
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
/// — the watch's UI-error formatter only logs a truncated Unity-
/// stringified version of the debugMessage. Capturing the raw
/// string before the watch's handler runs gives us the FULL
/// Photon error including the URL Photon Cloud actually tried to
/// call (which is the smoking-gun answer to "is the dashboard URL
/// saved or is Photon hitting a stale one").</summary>
public static void OnCustomAuthenticationFailed_Prefix(object __0)
{
try
{
var debugMessage = __0?.ToString() ?? "<null>";
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
Mod.Log.Error(debugMessage);
// Also dump current AppSettings — if Photon rejected the auth
// we want to know what the watch's actual final config looked
// like (post-override).
var photonNetwork = FindGameType("PhotonNetwork");
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (settings is not null)
{
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
DumpSettings(settings, settings.GetType(), indent: " ");
}
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
}
/// <summary>Logs every public field + property on <paramref name="obj"/>
/// — used by the photon-diag patches above to dump AppSettings in one
/// pass without coupling to specific field names that differ across
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
/// PUN 2.x, etc.).</summary>
private static void DumpSettings(object obj, Type t, string indent)
{
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
// we don't reflect over any property whose getter internally
// constructs a System.Uri. The Uri ctor is harmony-patched, so
// the FIRST call to a Uri-returning getter triggers MonoMod's
// lazy CompileMethodHook for the ctor, which fatal-CLRs
// (0x80131506) on this build. The override has already run by
// the time DumpSettings is called — the dump is diagnostic
// only, so it's safer to stay scoped to user-land properties.
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
try
{
foreach (var p in t.GetProperties(flags))
{
if (p.GetIndexParameters().Length > 0) continue;
if (!p.CanRead) continue;
// Defensively skip any property whose declared return
// type is Uri (would still trigger ctor on .ToString()
// path even if we tried to read it).
if (p.PropertyType == typeof(Uri)) continue;
object? v;
try { v = p.GetValue(obj); } catch { continue; }
if (v is Uri) continue;
var str = v is null ? "<null>" : v.ToString();
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
{
Mod.Log.Msg($"{indent}{p.Name} (nested):");
DumpSettings(v, v.GetType(), indent + " ");
continue;
}
Mod.Log.Msg($"{indent}{p.Name} = {str}");
}
foreach (var f in t.GetFields(flags))
{
if (f.FieldType == typeof(Uri)) continue;
object? v;
try { v = f.GetValue(obj); } catch { continue; }
if (v is Uri) continue;
var str = v is null ? "<null>" : v.ToString();
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
{
Mod.Log.Msg($"{indent}{f.Name} (nested):");
DumpSettings(v, v.GetType(), indent + " ");
continue;
}
Mod.Log.Msg($"{indent}{f.Name} = {str}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
}
// GetCurrentLockToken parked alongside the injector — see
// attic/AuthValuesInjector.cs.attic.
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class SavePatches
internal static class AntiTamperPatches
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
// our currently-loaded room. Captured BEFORE the watch's caching
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
// (roomId, subRoomId) tuple — which is every post-save push for a
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
// would-be no-op setter call into a real transition.
private static volatile string? s_lastPushedBlobName;
private static long s_lastPushedRoomId;
private static long s_lastPushedSubRoomId;
// Hook on Notifications.OnNotificationReceived(string json). Fires for
// every SignalR message before any handler dispatch. Parses the JSON
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
public static void OnNotificationReceived_Prefix(string message)
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
{
try
{
if (string.IsNullOrEmpty(message)) return;
// Fast reject: only parse JSON if it could plausibly be a
// SubscriptionUpdateRoom payload. Saves cycles on the (very
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
return;
using var doc = System.Text.Json.JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!root.TryGetProperty("Id", out var idEl) ||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
return;
if (!root.TryGetProperty("Msg", out var msg) ||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!msg.TryGetProperty("Room", out var room) ||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
long roomId = 0;
if (room.TryGetProperty("RoomId", out var ridEl) &&
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
roomId = ridEl.GetInt64();
if (!msg.TryGetProperty("Scenes", out var scenes) ||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
scenes.GetArrayLength() == 0) return;
var first = scenes[0];
long subRoomId = 0;
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
subRoomId = sidEl.GetInt64();
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
var blob = blobEl.GetString();
if (string.IsNullOrEmpty(blob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = blob;
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
}
return false;
}
public static void OnSubscriptionUpdateRoom_Prefix(object message)
public static bool UnknownDllDetectedCallback_Prefix()
{
try
{
if (message is null) return;
var dict = message;
// The watch parses this dict as Dictionary<string, object>
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
// get_Item(key) — call it via reflection.
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (getItem is null) return;
// The push wire shape from server (BuildRoomDetails) is
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
// on the Room sub-object; the scene's blob on Scenes[0].
object? roomObj = TryGet(dict, getItem, "Room");
object? scenesObj = TryGet(dict, getItem, "Scenes");
if (roomObj is null || scenesObj is null) return;
long roomId = 0;
long subRoomId = 0;
try
{
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (roomItem is not null)
{
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
}
}
catch { /* roomId stays 0 — we'll log it and bail later */ }
// Scenes is an IL2CPP List<RoomScene>. Take the first
// (current dorm has one scene; multi-scene rooms target
// Scenes[currentSubRoomId] specifically, but the post-save
// push always carries the scene that was just saved).
object? firstScene = null;
try
{
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
}
catch { /* leave firstScene null */ }
if (firstScene is null) return;
string? newBlob = null;
try
{
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (sceneItem is not null)
{
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
newBlob = blob?.ToString();
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
}
}
catch { /* leave newBlob null */ }
if (string.IsNullOrEmpty(newBlob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = newBlob;
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
}
return false;
}
private static object? TryGet(object dict, MethodInfo getItem, string key)
{
try { return getItem.Invoke(dict, new object[] { key }); }
catch { return null; }
}
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
// path only when the new value differs from the cached field. The
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
// save it calls the setter with the OLD blob — a no-op that skips
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
// pointed at the boot-time bytes, and PostSaveReloading deserializes
// those over the live world — wiping any MakerPen object the user
// just placed.
//
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
// after b__4 returns True, even though the SubscriptionUpdateRoom
// push already carried Scenes[0].DataBlobName="v11".
//
// Strategy: intercept the setter. If the incoming value matches the
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
// newer version (the freshly-pushed one), substitute that into the
// value parameter so the real setter sees a transition and fires
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
// Falls through silently when no override is needed.
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
{
string currentForLog = "<unread>";
string sceneForLog = "<unread>";
string scenes0ForLog = "<unread>";
try
{
if (__instance is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
return;
}
var rpmType = __instance.GetType();
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (blobNameProp is null)
{
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
return;
}
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
var roomsType = FindGameType("RecNet.Rooms");
if (roomsType is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
return;
}
var localScene = roomsType.GetProperty("LocalRoomScene",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localScene is not null)
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
var localDetails = roomsType.GetProperty("LocalRoomDetails",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localDetails is not null)
{
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
if (scenes is not null)
{
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
var first = indexer?.Invoke(scenes, new object[] { 0 });
if (first is not null)
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
}
}
// Unconditional trace of EVERYTHING we read so we can see in
// the log exactly why we did or didn't override.
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
// Pick a candidate "newer" blob name. Priority order:
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
// cache gate drops the push).
// 2. LocalRoomScene.DataBlobName.
// 3. Scenes[0].DataBlobName.
string? pushedBlob = s_lastPushedBlobName;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
if (string.IsNullOrEmpty(pushedBlob)) return;
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
value = pushedBlob;
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
}
}
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class FileHashCheckerPatches
{
public static bool InitializeCallback_Prefix(object? result)
{
return false;
}
}
internal static class ToxModPatches
{
public static bool CanInitializeToxMod_Prefix(ref bool __result)
{
__result = false;
return false;
}
}
internal static class TlsPatches
{
// Both BouncyCastle TLS authenticators have the same `void
@@ -2376,319 +924,3 @@ internal static class TlsPatches
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
}
}
internal static class DiagnosticPatches
{
public static void Write(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
try { Mod.Log.Msg(line); } catch { }
try
{
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
}
catch { }
}
public static void ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
}
public static void ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static void NamedMethod_Prefix(MethodBase __originalMethod)
{
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
return false;
}
private static void WriteStack()
{
try
{
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[stack] failed: " + ex.Message);
}
}
}
internal static class ChatPatches
{
private static bool s_logged;
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
// before raising the in-room Photon chat RPC; if it returns false
// the coroutine bails silently and the sender doesn't even see
// their own message echo.
public static void CanLocalPlayerChat_Postfix(ref bool __result)
{
if (!__result && !s_logged)
{
s_logged = true;
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
}
__result = true;
}
// Skip the original. Returning false from a Harmony prefix stops
// the underlying call.
public static bool RemoveInvalidCharactersFromMessage_Prefix()
{
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
return false;
}
public static void SendChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
}
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
{
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
}
public static void RpcChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
// See the registration site in OnLateInitializeMelon and
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
// hook only writes to the diagnostics log.
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
// Trace hooks for the watch's room-join flow. Registered by
// Mod.RegisterJoinTracePatches — see that method for the verified call
// chain. Everything here is log-only; no prefix returns false, so the
// game's own join logic runs unchanged.
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
+41 -186
View File
@@ -1,27 +1,28 @@
// Raise RRO quest party size past the baked 4 — precisely, only for the
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
//
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL; names
// re-verified against the March-2023 dump — 2023 obfuscated names below):
// GameConfigurationTool.Awake()
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
// Unity ScriptableObject @0x58)
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset,
// a Unity ScriptableObject)
// → calls GameConfigurationAsset.FIFOMGAOOHH() ← builds the runtime
// protobuf GameConfigurationData (GPIGCAJICEF) FROM the asset
// → stores and applies it to the team manager.
// Inside FIFOMGAOOHH the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array) and writes each straight into a
// new GameTeamConfigurationData.TeamSize.
//
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
// READABLE, typed struct array — NOT from anything that needs the obfuscated
// protobuf. Earlier attempts failed because:
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
// • bumping the asset on a 2s poll happened AFTER the builder already built &
// cached the protobuf (timing, not the wrong field);
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
// getter, and the simple int getter wasn't emitted either.
// • patching the protobuf getters is impossible — Il2CppInterop doesn't emit
// the generic RepeatedField<T> getter, and the simple int getter wasn't
// emitted either.
//
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
// THE FIX: Harmony-PREFIX GameConfigurationAsset.FIFOMGAOOHH() and bump the
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
// before it builds the protobuf. The built config then carries the new cap and
// the team manager / pre-game roster / open-slots all honour it. Scoped by
@@ -42,87 +43,18 @@ internal static class QuestTeamSize
{
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
private static bool _loggedBump;
private static bool _activeAllowedQuestConfig;
private static int _teamIndexNormalizeLogCount;
private static int _spawnModeDataNormalizeLogCount;
private static int _requiredSpawnIndexNormalizeLogCount;
private static int _spawnRelaxLogCount;
// 2023 spawn path (verified in the March-2023 ISIL): the spawn driver
// HCPLAILIHEL reads the team-player index from the roster entry
// (GameTeamManager/BNBIBPFPJPJ.JPIHHGBLLNG()) and passes it straight into
// the KMPAOJGOOCH spawn-point filter. December's intermediate helpers are
// gone from this path — the out-param resolver is inlined (zero call
// sites), and GetTeamPlayerIndex is only called by Charades/RecRally, so
// normalizing it would touch non-quest games. The KMPAOJGOOCH index
// argument is the single choke point: normalize it in a prefix and keep
// the empty-filter relax postfix as the backstop.
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
{
var ok = true;
ok &= TryPatchTeamIndexNormalizer(harmony);
ok &= TryPatchSpawnModeDataNormalizer(harmony);
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
return ok;
}
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var managerType = Mod.ResolveType("GameTeamManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (managerType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
return false;
}
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
return false;
}
}
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
return false;
}
return TryPatchRequiredSpawnIndexNormalizer(harmony);
}
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
@@ -130,17 +62,17 @@ internal static class QuestTeamSize
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
var indexType = Mod.ResolveType("NBCOFGIKKHM");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
var target = AccessTools.Method(spawnType, "KMPAOJGOOCH");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer");
return false;
}
@@ -148,74 +80,21 @@ internal static class QuestTeamSize
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, prefix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
Mod.Log.Error($"[patch-fail] GameSpawnManager.KMPAOJGOOCH index normalizer: {ex.Message}");
return false;
}
}
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
// scenes only have spawn points for indexes 0-3. After raising the quest
// team cap, players 5+ legitimately receive INDEX_4+, but those values
// cannot match any baked quest spawn point. Normalize just the enum result
// that spawn code asks for; the actual team roster/cap remains raised.
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__result);
if (!TryNormalizeIndex(value, out var normalized)) return;
__result = (T)Enum.ToObject(typeof(T), normalized);
if (_teamIndexNormalizeLogCount < 12)
{
_teamIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
}
}
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__3);
if (!TryNormalizeIndex(value, out var normalized)) return;
__3 = (T)Enum.ToObject(typeof(T), normalized);
if (_spawnModeDataNormalizeLogCount < 12)
{
_spawnModeDataNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
}
}
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
// This is the last guard before the spawn point field compare at +0x88.
// PREFIX on GameSpawnManager.KMPAOJGOOCH(..., NBCOFGIKKHM index, ...).
// The RRO quest scenes only have spawn points for indexes 0-3. After
// raising the quest team cap, players 5+ legitimately receive INDEX_4+,
// but those values cannot match any baked quest spawn point. Normalize
// just the index the spawn-point filter compares against; the actual team
// roster/cap remains raised.
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
{
try
@@ -226,13 +105,6 @@ internal static class QuestTeamSize
if (!TryNormalizeIndex(value, out var normalized)) return;
__2 = (T)Enum.ToObject(typeof(T), normalized);
if (_requiredSpawnIndexNormalizeLogCount < 12)
{
_requiredSpawnIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
@@ -246,7 +118,7 @@ internal static class QuestTeamSize
return value >= 4;
}
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
// PREFIX on GameConfigurationAsset.FIFOMGAOOHH() (asset → protobuf builder).
public static void Build_Prefix(object __instance)
{
try
@@ -255,11 +127,7 @@ internal static class QuestTeamSize
if (target <= 0 || __instance is null) return;
var name = GetMember(__instance, "Name") as string ?? "";
var allowed = IsAllowed(name);
_activeAllowedQuestConfig = allowed;
if (_seen.Add(name))
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
if (!allowed) return;
if (!IsAllowed(name)) return;
var arr = GetMember(__instance, "TeamConfigurations");
int len = ArrLen(arr);
@@ -276,24 +144,18 @@ internal static class QuestTeamSize
SetArrItem(arr!, i, elem)) // write the mutated struct back
bumped++;
}
if (bumped > 0 && !_loggedBump)
{
_loggedBump = true;
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
}
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
// POSTFIX on GameSpawnManager.KMPAOJGOOCH(..., outputList). That method
// filters spawn points by state/game-mode/spectator/team/team-player-index/
// custom-tag before the spawn selector runs. The RRO quest prefabs only
// have indexed start points for players 0-3; once we raise the team size,
// player index 4+ can filter every point out before the built-in overlap
// fallback has a chance to help.
//
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
// The caller (2023: HCPLAILIHEL) runs this filter, then if output.Count
// is still 0 it logs "due to spawn point requirement tests" and falls back
// to the unfiltered source list as an arbitrary spawn set. Filling the
// output list here keeps the normal overlap pass and spawn-selection mode
@@ -315,14 +177,7 @@ internal static class QuestTeamSize
var sourceCount = Count(__0);
if (sourceCount <= 0) return;
var copied = CopyListItems(__0, __7);
if (_spawnRelaxLogCount < 12)
{
_spawnRelaxLogCount++;
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
$"source={sourceCount} copied={copied} " +
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
}
CopyListItems(__0, __7);
}
catch (Exception ex)
{
+1401
View File
@@ -0,0 +1,1401 @@
// Rec Royale night mode — client-side, opt-in.
//
// Rec Royale never shipped a night variant, but the March-2023 client
// carries everything needed to build one from the game's own tooling:
//
// • Every room scene owns a RecRoomSceneManager (name-preserved field
// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That
// object is the room "mood" engine behind the Maker Pen sky/sun/fog
// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood`
// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and
// colour grading.
// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step
// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six
// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP
// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the
// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader
// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every
// ReflectionProbe.customBakedTexture to the mood's baked cubemap and
// force-updates the blenders. It does NOT touch the networked mood
// index, so calling it is purely local.
// • The build ships ready-made night moods as Resources assets
// (resources.assets; ResourceManager table in globalgamemanagers):
// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood
// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"),
// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space").
// • The only Rec Royale map in this build is
// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity.
//
// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag
// (SceneMoodSettings+0x38) is only switched on by the room-data loader
// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms
// only. While it is off, get_CurrentMood returns null and nothing ticks.
// The loader's bring-up sequence is:
// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood
// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the
// // blenders' "previous" data (checks shader name
// // "Custom/SkyBox" → HasRecRoomSkyboxShader)
// LKIKMLNPCFK(true) // SetEnabled(true)
// This mod replays exactly that before applying the night mood.
//
// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) /
// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a
// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL<int>, i.e. it
// replicates to every player in the instance. Applying the mood directly
// keeps night mode a per-client visual choice.
//
// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed
// (ISIL: WeaponFlashlightController.txt:3-75) is
// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode
// gate for an activity that isn't Rec Royale. A Harmony postfix forces it
// true while night mode is active, so weapons picked up in the dark get
// the game's own flashlight prefab.
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
namespace DorkNet.ClientMod;
internal static class RecRoyaleNight
{
private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
// Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips
// with the hotkey; `_inRoyale` tracks whether the current scene is one
// of Cfg.RecRoyaleNightScenes.
public static bool Active { get; private set; }
private static bool _inRoyale;
private static string _sceneName = "";
private static bool _appliedThisScene;
private static int _frame;
private static int _sceneFrames; // frames since the Royale scene loaded
private static int _reassertFrames; // frames since the last re-apply
// Per-scene bring-up bookkeeping so toggling off can put things back.
private static bool _engineWasDisabled; // we enabled the mood engine ourselves
private static object? _originalSkybox; // RenderSettings.skybox before we touched it
private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves
private static object? _originalFog; // MoodSetting.FogSettings before we swapped it
private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced
private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed
private static float? _origExposure, _origSunIntensity, _origAmbient;
private static bool _diagLogged;
private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps
private const int SettleFrames = 90; // let the scene's own lighting settle first
private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min
private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s
// Resolved reflection handles (lazy, cached).
private static bool _resolved;
private static Type? _sceneManagerType;
private static PropertyInfo? _sceneManagerInstance;
private static FieldInfo? _sceneMoodSettingsField;
private static PropertyInfo? _sceneMoodSettingsProp;
private static Type? _moodSettingType;
private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting)
private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool)
private static MethodInfo? _capture; // LIEDFOLBGHL()
private static FieldInfo? _isEnabled; // isEnabled
private static PropertyInfo? _roomMoodsEnabled;
private static PropertyInfo? _hasSkyShader;
private static PropertyInfo? _hasSunSource;
private static PropertyInfo? _volume;
private static FieldInfo? _availableMoods;
private static PropertyInfo? _currentMood;
private static PropertyInfo? _friendlyName;
private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox
private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun
private static Type? _lightType; // UnityEngine.Light
private static Type? _fogSettingsType; // FogMoodSettings
private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog
private static PropertyInfo? _moodFog; // MoodSetting.FogSettings
private static Type? _materialType; // UnityEngine.Material
private static Type? _shaderType; // UnityEngine.Shader
private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder)
private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
private static object? _ourSkybox; // Material we created for the mood skybox
private static object? _nightMood; // MoodSetting interop wrapper
private static string _nightMoodLoadedName = "";
// Hotkey
private static bool _inputResolved;
private static MethodInfo? _getKeyDown;
private static object? _toggleKey;
// ── registration ──────────────────────────────────────────────────
public static void Register(HarmonyLib.Harmony harmony)
{
Active = Mod.Cfg.RecRoyaleNightMode;
Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " +
$"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " +
$"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " +
$"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "<none>" : Mod.Cfg.RecRoyaleNightToggleKey)}");
if (Mod.Cfg.RecRoyaleNightFlashlights)
TryPatchFlashlightGate(harmony);
}
// POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The
// interop property carries the obfuscated name (KIMMMCCKMJL in the
// 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`,
// so resolve by name first and fall back to "the only read-only bool
// property declared on the type".
private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony)
{
try
{
var type = Mod.ResolveType("WeaponFlashlightController");
if (type is null)
{
Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found");
return;
}
MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed")
?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL");
if (getter is null)
{
PropertyInfo? only = null;
int count = 0;
foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue;
only = p; count++;
}
if (count == 1) getter = only!.GetGetMethod(true);
}
if (getter is null)
{
Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed");
return;
}
var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!;
harmony.Patch(getter, postfix: new HarmonyMethod(postfix));
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}");
}
}
public static void FlashlightAllowed_Postfix(ref bool __result)
{
if (Active && _inRoyale) __result = true;
}
// ── lifecycle (called from Mod) ───────────────────────────────────
public static void OnSceneLoaded(string sceneName)
{
_sceneName = sceneName ?? "";
var was = _inRoyale;
_inRoyale = MatchesScene(_sceneName);
if (_inRoyale)
{
_appliedThisScene = false;
_sceneFrames = 0;
_reassertFrames = 0;
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
_diagLogged = false;
RestoreMoodPatches();
Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}");
}
else if (was)
{
_appliedThisScene = false;
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
RestoreMoodPatches();
}
}
public static void Tick()
{
_frame++;
PollHotkey();
if (!_inRoyale || !Active) return;
_sceneFrames++;
if (_frame % PollEveryFrames != 0) return;
if (!_appliedThisScene)
{
if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return;
if (TryApply(true)) _appliedThisScene = true;
return;
}
// Cheap safeguard: the game re-applies its own mood on quality
// changes / studio events. Re-assert every ~10 s; when the blenders
// are already at target this is visually a no-op.
_reassertFrames += PollEveryFrames;
if (_reassertFrames >= ReassertEveryFrames)
{
_reassertFrames = 0;
TryApply(true);
}
}
public static void Toggle()
{
Active = !Active;
Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}");
if (!_inRoyale) return;
if (TryApply(Active)) _appliedThisScene = Active;
else if (!Active) _appliedThisScene = false;
}
// ── the actual apply ──────────────────────────────────────────────
// night=true → bring the mood engine up if needed, apply the night mood
// night=false → restore: scene mood if the engine was already running,
// otherwise disable the engine again + put the skybox back
private static bool TryApply(bool night)
{
try
{
if (!_resolved && !Resolve()) return false;
if (_applyMood is null) return false;
var manager = GetSceneManager();
if (manager is null) { Diag("scene manager instance is null"); return false; }
var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager);
if (moods is null) { Diag("sceneMoodSettings is null"); return false; }
// `isEnabled` reads differently through the interop property vs the
// field (seen True/False for the same state), so don't gate on it.
// RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed
// game-side and is false for every RRO scene; bring the engine up
// exactly once per Royale scene whenever it is not true.
var enabled = GetMember(moods, "isEnabled") is true;
var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true;
LogState(moods, enabled);
if (night)
{
var target = GetNightMood();
if (target is null) return false;
if (!roomMoodsEnabled && !_engineWasDisabled)
{
// Replay the room-data loader's bring-up. Remember the
// scene's own skybox so toggle-off can restore it.
if (_originalSkybox is null && _renderSkybox is not null)
_originalSkybox = _renderSkybox.GetValue(null);
if (_setEnabled is null || _capture is null)
{
Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up");
return false;
}
// RRO scenes leave RenderSettings.sun unset, and the engine
// skips every sun change (direction, colour, intensity) when
// HasSunSource is false. Point it at the scene's directional
// light BEFORE capture so the flag comes out true.
EnsureSun();
// Keep the scene's own fog unless asked otherwise: replace
// the mood's FogSettings with a block captured from the
// current RenderSettings before anything changes them.
if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target);
// Baked lightmaps can't be dimmed, so darken the frame via
// the mood's own post-exposure and tone down sun/ambient.
PatchDarkness(target);
_setEnabled.Invoke(moods, new object[] { false });
_capture.Invoke(moods, null);
// The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns
// at once while get_CurrentMood() is null, and the one-shot
// apply only sets blend TARGETS — the update is what writes
// sky colours, rotates the sun and pushes exposure. In RRO
// scenes CurrentMood is null because defaultMood is unset.
// Register the night mood as this scene's defaultMood and
// sole availableMoods entry so the engine runs like a
// one-mood custom room.
InstallSceneMood(moods, target);
_setEnabled.Invoke(moods, new object[] { true });
_engineWasDisabled = true;
ProbeCircuitsSupport();
var cm = _currentMood?.GetValue(moods);
Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " +
$"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "<null>" : Describe(cm))})");
// First apply swaps RenderSettings.skybox to
// MoodConfig.SkyBoxMaterial. In RRO scenes that material is
// not loaded (it lives in the custom-room scene's shared
// assets), so the skybox ends up null → black sky and the
// engine's HasRecRoomSkyboxShader / HasSunSource stay false
// (both need shader "Custom/SkyBox" on RenderSettings.skybox).
// Supply a material built from that shader, re-capture so
// the flags flip, then fall through to the real apply.
_applyMood.Invoke(moods, new[] { target });
EnsureMoodSkybox();
_capture.Invoke(moods, null);
Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})");
EnsurePostProcessing(moods);
}
_applyMood.Invoke(moods, new[] { target });
if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights();
if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff();
HideObjects();
Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'");
return true;
}
// restore
if (_engineWasDisabled)
{
RestoreOtherSunLights();
ShowObjects();
RestoreFogState();
_setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data
UninstallSceneMood(moods);
if (_originalSkybox is not null && _renderSkybox is not null)
_renderSkybox.SetValue(null, _originalSkybox);
if (_sunWasNull && _renderSun is not null)
_renderSun.SetValue(null, null);
RestoreMoodPatches();
_engineWasDisabled = false;
_originalSkybox = null;
_sunWasNull = false;
Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'");
return true;
}
var current = _currentMood?.GetValue(moods);
if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; }
_applyMood.Invoke(moods, new[] { current });
Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'");
return true;
}
catch (Exception ex)
{
Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}");
return false;
}
}
private static void LogState(object moods, bool enabled)
{
if (_diagLogged) return;
_diagLogged = true;
try
{
var arr = GetMember(moods, "availableMoods");
var cur = _currentMood?.GetValue(moods);
Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " +
$"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "<null>" : Describe(cur))} " +
$"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); }
}
private static object? Read(PropertyInfo? p, object o)
{
try { return p?.GetValue(o); } catch { return null; }
}
private static void Diag(string why)
{
if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}");
}
private static bool Resolve()
{
_resolved = true;
_sceneManagerType = Mod.ResolveType("RecRoomSceneManager");
var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings");
_moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting");
if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null)
{
Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " +
$"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}");
return false;
}
// RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but
// the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21).
// Pick the static property whose type is RecRoomSceneManager.
_sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance");
if (_sceneManagerInstance is null)
{
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0)
{ _sceneManagerInstance = p; break; }
}
}
_sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings");
if (_sceneMoodSettingsField is null)
{
foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; }
}
_currentMood = AccessTools.Property(moodSettingsType, "CurrentMood");
_roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled");
_hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader");
_hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource");
_volume = AccessTools.Property(moodSettingsType, "Volume");
_availableMoods = AccessTools.Field(moodSettingsType, "availableMoods");
_isEnabled = AccessTools.Field(moodSettingsType, "isEnabled");
_friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName");
// Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the
// only non-setter void method taking exactly one MoodSetting.
_applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType });
if (_applyMood is null)
{
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; }
}
}
// SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only
// public non-setter void(bool) declared on the type.
_setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) });
if (_setEnabled is null)
{
MethodInfo? only = null; int n = 0;
foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; }
}
if (n == 1) _setEnabled = only;
}
// Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too
// many void() methods on the type to pick by shape — name only.
_capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes);
var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings");
_renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox");
_renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun");
_lightType = Mod.ResolveType("UnityEngine.Light");
// Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads
// RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the
// block (ISIL FogMoodSettings.txt, called right after the ctor in
// LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference
// the apply step hands to the fog blender.
_fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings");
_fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes);
_moodFog = AccessTools.Property(_moodSettingType, "FogSettings");
_materialType = Mod.ResolveType("UnityEngine.Material");
_shaderType = Mod.ResolveType("UnityEngine.Shader");
_moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD");
_cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData");
var ok = _sceneManagerInstance is not null
&& (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null)
&& _applyMood is not null;
Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " +
$"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " +
$"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " +
$"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " +
$"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}");
return ok;
}
// RenderSettings.sun ← the scene's directional Light when unset. Frontier
// ships without the reference ("Sun Source reference has not been set in
// Lighting settings" is the engine's own warning), so nothing sun-related
// from the mood would otherwise apply.
private static void EnsureSun()
{
try
{
if (_renderSun is null || _lightType is null) return;
var current = _renderSun.GetValue(null);
if (current is not null && !IsUnityNull(current))
{
Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'");
return;
}
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppLight = Il2CppTypeOf(_lightType);
if (unityObject is null || il2cppLight is null) return;
object? directional = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppLight });
var n = Count(arr);
float best = -1f;
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var light = AsType(item, _lightType);
if (light is null) continue;
// LightType.Directional == 1
var kind = GetMember(light, "type");
if (kind is null || Convert.ToInt32(kind) != 1) continue;
var intensity = GetMember(light, "intensity") is float f ? f : 0f;
if (intensity > best) { best = intensity; directional = light; }
}
break;
}
if (directional is null)
{
Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change");
return;
}
_renderSun.SetValue(null, directional);
_sunWasNull = true;
Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); }
}
// Swap the mood asset's FogSettings for a block captured from the
// scene's current RenderSettings so applying the mood leaves fog as-is.
// The asset is shared for the session, so RestoreFog puts the original
// block back when we leave or toggle off.
private static void PreserveFog(object mood)
{
try
{
if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite)
{
Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply");
return;
}
if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return;
var captured = Activator.CreateInstance(_fogSettingsType);
if (captured is null) return;
_fogCapture.Invoke(captured, null);
if (Mod.Cfg.RecRoyaleNightDisableFog)
{
// The block has no exposed enabled flag; push it out of range so
// even if the blender re-enables fog each frame it is invisible.
SetMember(captured, "startDistance", 1_000_000f);
SetMember(captured, "fadeDistance", 1_000_000f);
}
_originalFog = _moodFog.GetValue(mood);
_fogPatchedMood = mood;
_moodFog.SetValue(mood, captured);
Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreFog()
{
try
{
if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite)
_moodFog.SetValue(_fogPatchedMood, _originalFog);
}
catch { }
_fogPatchedMood = null;
_originalFog = null;
}
private static void RestoreMoodPatches()
{
RestoreFog();
RestoreDarkness();
RestoreOtherSunLights();
// Scene objects die with the scene; just forget them.
_probed = false;
_hidden.Clear();
_hideScanned = false;
_fogStateSaved = false;
_origFogEnabled = null;
// The scene's SceneMoodSettings dies with the scene; just forget it.
_sceneMoodInstalled = false;
_sceneMoodsObj = null;
_origDefaultMood = _origAvailableMoods = null;
}
// The engine drives exactly one light: RenderSettings.sun (Frontier:
// "SkyboxLight", the sun-disc light). The map's actual key light is a
// separate directional Light it never touches, so daylight keeps
// casting. Copy the engine-driven sun's rotation and colour onto every
// other directional light and scale their intensity down. Originals are
// kept per light and restored on toggle-off / scene change.
private sealed class LightState
{
public object Light = null!;
public object? Intensity, Color, Rotation, ShadowStrength;
}
private static readonly List<LightState> _otherSuns = new();
private static void MatchOtherSunLights()
{
try
{
if (_renderSun is null || _lightType is null) return;
var sun = _renderSun.GetValue(null);
if (sun is null || IsUnityNull(sun)) return;
var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
var sunTransform = GetMember(sun, "transform");
var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation");
var sunColor = GetMember(sun, "color");
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppLight = Il2CppTypeOf(_lightType);
if (unityObject is null || il2cppLight is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var arr = findAll.Invoke(null, new object[] { il2cppLight });
var n = Count(arr);
var touched = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var light = AsType(item, _lightType);
if (light is null) continue;
var kind = GetMember(light, "type");
if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only
var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero;
if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one
var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr);
if (state is null)
{
state = new LightState
{
Light = light,
Intensity = GetMember(light, "intensity"),
Color = GetMember(light, "color"),
ShadowStrength = GetMember(light, "shadowStrength"),
Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null,
};
_otherSuns.Add(state);
}
if (state.Intensity is float orig)
SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale);
if (sunColor is not null) SetMember(light, "color", sunColor);
if (sunRotation is not null && GetMember(light, "transform") is object t)
SetMember(t, "rotation", sunRotation);
touched.Add(ObjectName(light));
}
if (touched.Count > 0 && !_otherSunsLogged)
{
_otherSunsLogged = true;
Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); }
}
private static bool _otherSunsLogged;
// Register the mood as the scene's defaultMood + sole availableMoods entry
// (get_CurrentMood returns availableMoods[index] when the synced index is
// set, else defaultMood — either way our mood). Originals are kept so
// UninstallSceneMood can put the scene back to "no room moods".
private static object? _sceneMoodsObj;
private static object? _origDefaultMood, _origAvailableMoods;
private static bool _sceneMoodInstalled;
private static void InstallSceneMood(object moods, object mood)
{
try
{
if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return;
_sceneMoodsObj = moods;
_origDefaultMood = GetMember(moods, "defaultMood");
_origAvailableMoods = GetMember(moods, "availableMoods");
var okDefault = SetMember(moods, "defaultMood", mood);
// availableMoods is an Il2CppReferenceArray<MoodSetting>; build a
// one-element array of the same interop type and fill slot 0.
var okArray = false;
var arrProp = moods.GetType().GetProperty("availableMoods", Any);
var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType();
if (arrType is not null)
{
object? arr = null;
foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } })
{
var ctor = arrType.GetConstructor(sig);
if (ctor is null) continue;
try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { }
}
if (arr is not null)
{
var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! });
if (set is null)
{
foreach (var m in arrType.GetMethods())
if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; }
}
try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { }
}
}
_sceneMoodInstalled = okDefault || okArray;
Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void UninstallSceneMood(object moods)
{
try
{
if (!_sceneMoodInstalled) return;
if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood);
else SetMember(moods, "defaultMood", null!);
if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods);
}
catch { }
_sceneMoodInstalled = false;
_sceneMoodsObj = null;
_origDefaultMood = _origAvailableMoods = null;
}
// ── fog off / hide objects ────────────────────────────────────────
private static bool _fogStateSaved;
private static object? _origFogEnabled;
private static Type? _renderSettingsType;
private static void ForceFogOff()
{
try
{
_renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings");
if (_renderSettingsType is null) return;
var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static);
if (p is null) return;
if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; }
if (p.GetValue(null) is true) p.SetValue(null, false);
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); }
}
private static void RestoreFogState()
{
try
{
if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b)
_renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b);
}
catch { }
_fogStateSaved = false;
_origFogEnabled = null;
}
// Disable every Renderer whose GameObject or material name contains one
// of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans
// once per scene (the matches are static scenery); ShowObjects re-enables.
private static readonly List<object> _hidden = new();
private static bool _hideScanned;
private static void HideObjects()
{
if (_hideScanned) return;
_hideScanned = true;
try
{
var patterns = new List<string>();
foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects)
if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim());
if (patterns.Count == 0) return;
var rendererType = Mod.ResolveType("UnityEngine.Renderer");
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType);
if (rendererType is null || unityObject is null || il2cppRenderer is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var arr = findAll.Invoke(null, new object[] { il2cppRenderer });
var n = Count(arr);
var names = new HashSet<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var r = AsType(item, rendererType);
if (r is null) continue;
var name = ObjectName(r);
var matName = "";
try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { }
var hit = false;
foreach (var p in patterns)
if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; }
if (!hit) continue;
if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false))
{
_hidden.Add(r);
names.Add(name);
}
}
Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void ShowObjects()
{
foreach (var r in _hidden)
{
try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { }
}
_hidden.Clear();
_hideScanned = false;
}
// ── CV2 room-settings probe ───────────────────────────────────────
// The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify",
// Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun /
// RoomFog components that hang off RoomElementSettingsManager, which
// CircuitsV2Manager brings up. Log whether any of that exists in this
// scene so we know what a chips-in-Rec-Royale build would have to add.
private static bool _probed;
private static void ProbeCircuitsSupport()
{
if (_probed) return;
_probed = true;
try
{
var unityObject = Mod.ResolveType("UnityEngine.Object");
if (unityObject is null) return;
MethodInfo? findAll = null;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; }
if (findAll is null) return;
var parts = new List<string>();
foreach (var name in new[]
{
"RecRoom.CircuitsV2.CircuitsV2Manager",
"RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager",
"RecRoom.CircuitsV2.RoomSettings.RoomSkydome",
"RecRoom.CircuitsV2.RoomSettings.RoomSun",
"RecRoom.CircuitsV2.RoomSettings.RoomFog",
"RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects",
"MakerPen",
})
{
var t = Mod.ResolveType(name);
if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}=<type missing>"); continue; }
var il2 = Il2CppTypeOf(t);
if (il2 is null) { parts.Add($"{t.Name}=?"); continue; }
int n = 0;
try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; }
parts.Add($"{t.Name}={n}");
}
Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreOtherSunLights()
{
foreach (var s in _otherSuns)
{
try
{
if (IsUnityNull(s.Light)) continue;
if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity);
if (s.Color is not null) SetMember(s.Light, "color", s.Color);
if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength);
if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation);
}
catch { }
}
_otherSuns.Clear();
_otherSunsLogged = false;
}
// Make sure RenderSettings.skybox is a material on the "Custom/SkyBox"
// shader. The apply step assigns MoodConfig.SkyBoxMaterial
// (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that
// reference resolves to nothing, leaving the skybox null. Prefer any
// already-loaded material on that shader, else create one, and also
// write it into MoodConfig so later applies keep using it.
private static void EnsureMoodSkybox()
{
try
{
if (_renderSkybox is null || _materialType is null || _shaderType is null) return;
var current = _renderSkybox.GetValue(null);
var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current);
if (currentShader == "Custom/SkyBox") return;
object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null;
// 1. Any loaded material already on the shader (e.g. the real
// Skybox_Moods_Sunny_Default_Mat if some scene brought it in).
if (mat is null)
{
var resources = Mod.ResolveType("UnityEngine.Resources");
var il2cppMat = Il2CppTypeOf(_materialType);
if (resources is not null && il2cppMat is not null)
{
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppMat });
var n = Count(arr);
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var wrapped = AsType(item, _materialType);
if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; }
}
break;
}
}
if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'");
}
// 2. Build one: new Material(Shader.Find("Custom/SkyBox")).
if (mat is null)
{
var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) });
var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" });
if (shader is null || IsUnityNull(shader))
{
Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply");
return;
}
var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() });
if (ctor is null)
{
Mod.Log.Warning("[royale-night] Material(Shader) constructor not found");
return;
}
mat = ctor.Invoke(new[] { shader });
SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox");
Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox");
}
_ourSkybox = mat;
_renderSkybox.SetValue(null, mat);
// Keep the engine's own apply path pointing at it too.
if (_moodConfigHolder is not null)
{
foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (!p.CanRead || p.GetIndexParameters().Length != 0) continue;
var cfg = p.GetValue(null);
if (cfg is null || IsUnityNull(cfg)) continue;
if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material");
break;
}
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); }
}
private static string ShaderNameOf(object material)
{
try
{
var shader = GetMember(material, "shader");
return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader);
}
catch { return ""; }
}
private static string DescribeSkybox()
{
try
{
var sky = _renderSkybox?.GetValue(null);
if (sky is null || IsUnityNull(sky)) return "<null>";
return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]";
}
catch { return "?"; }
}
// Post-exposure lives in the URP Volume the mood engine drives. It only
// shows if the Volume is enabled with weight and the camera renders
// post-processing (Rec Room's graphics settings can turn that off).
private static void EnsurePostProcessing(object moods)
{
try
{
var volume = Read(_volume, moods);
if (volume is not null && !IsUnityNull(volume))
{
var enabled = GetMember(volume, "enabled");
var weight = GetMember(volume, "weight");
var isGlobal = GetMember(volume, "isGlobal");
var profile = GetMember(volume, "profile");
Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "<null>" : ObjectName(profile))}");
if (Mod.Cfg.RecRoyaleNightForcePostProcessing)
{
if (enabled is false) SetMember(volume, "enabled", true);
if (weight is float w && w < 1f) SetMember(volume, "weight", 1f);
}
}
else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable");
if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; }
var unityObject = Mod.ResolveType("UnityEngine.Object");
var il2cppCam = Il2CppTypeOf(_cameraDataType);
if (unityObject is null || il2cppCam is null) return;
foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue;
var arr = m.Invoke(null, new object[] { il2cppCam });
var n = Count(arr);
int forced = 0;
var states = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var cam = AsType(item, _cameraDataType);
if (cam is null) continue;
var pp = GetMember(cam, "renderPostProcessing");
states.Add($"{ObjectName(cam)}={pp}");
if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++;
}
Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}");
break;
}
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); }
}
// Darken the mood data in place before it is applied:
// ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the
// ColorGrading blender writes it into ColorAdjustments.postExposure
// via VolumeParameter.Override — the only knob that also dims baked
// lightmaps, since it's a full-frame post effect)
// SunSettings.sunIntensity *= RecRoyaleNightSunScale
// SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale
// Originals are kept and restored by RestoreDarkness so the shared asset
// is clean again for any custom room that uses the same mood.
private static void PatchDarkness(object mood)
{
try
{
if (ReferenceEquals(_darkPatchedMood, mood)) return;
RestoreDarkness();
var grading = GetMember(mood, "ColorGradingSettings");
var sun = GetMember(mood, "SunSettings");
var sky = GetMember(mood, "SkyBoxSettings");
if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev)
{
_origExposure = ev;
SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure);
}
if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si)
{
_origSunIntensity = si;
SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale);
}
if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai)
{
_origAmbient = ai;
SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale);
}
_darkPatchedMood = mood;
Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " +
$"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); }
}
private static void RestoreDarkness()
{
try
{
if (_darkPatchedMood is not null)
{
var grading = GetMember(_darkPatchedMood, "ColorGradingSettings");
var sun = GetMember(_darkPatchedMood, "SunSettings");
var sky = GetMember(_darkPatchedMood, "SkyBoxSettings");
if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e);
if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s);
if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a);
}
}
catch { }
_darkPatchedMood = null;
_origExposure = _origSunIntensity = _origAmbient = null;
}
private static bool SetMember(object o, string name, object value)
{
var t = o.GetType();
var p = t.GetProperty(name, Any);
if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } }
var f = t.GetField(name, Any);
if (f is not null) { try { f.SetValue(o, value); return true; } catch { } }
return false;
}
// Il2CppInterop exposes il2cpp fields as properties (and, on some
// versions, as fields). Read either.
private static object? GetMember(object o, string name)
{
var t = o.GetType();
var p = t.GetProperty(name, Any);
if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return p.GetValue(o); } catch { } }
var f = t.GetField(name, Any);
if (f is not null) { try { return f.GetValue(o); } catch { } }
return null;
}
// Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this
// project keeps not referencing Il2Cppmscorlib at compile time.
private static object? Il2CppTypeOf(Type type)
{
foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "From" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type))
{
var args = new object?[ps.Length];
args[0] = type;
for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null);
var r = m.Invoke(null, args);
if (r is not null) return r;
}
}
return null;
}
private static object? GetSceneManager()
{
var inst = _sceneManagerInstance?.GetValue(null);
if (inst is null || IsUnityNull(inst)) return null;
return inst;
}
// Loads the configured MoodSetting asset. Resources.Load(path, Type)
// pulls it out of resources.assets by name (the ResourceManager table
// keys are the lower-cased asset names, no folder). Fallback:
// Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees
// assets already in memory.
private static object? GetNightMood()
{
var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim();
if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood";
if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood))
return _nightMood;
_nightMood = null;
var resources = Mod.ResolveType("UnityEngine.Resources");
if (resources is null || _moodSettingType is null)
{
Mod.Log.Warning("[royale-night] UnityEngine.Resources not found");
return null;
}
var il2cppType = Il2CppTypeOf(_moodSettingType);
if (il2cppType is null)
{
Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found");
return null;
}
object? found = null;
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "Load" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue;
try
{
found = m.Invoke(null, new object[] { wanted, il2cppType });
if (found is not null && !IsUnityNull(found)) break;
found = null;
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); }
}
if (found is null)
{
foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue;
var ps = m.GetParameters();
if (ps.Length != 1) continue;
try
{
var arr = m.Invoke(null, new object[] { il2cppType });
var n = Count(arr);
var names = new List<string>();
for (int i = 0; i < n; i++)
{
var item = Item(arr, i);
if (item is null) continue;
var name = ObjectName(item);
names.Add(name);
if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; }
}
if (found is null)
Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}");
}
catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); }
break;
}
}
if (found is null)
{
Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'");
return null;
}
// Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the
// same il2cpp pointer as MoodSetting so the apply method accepts it.
_nightMood = AsType(found, _moodSettingType);
_nightMoodLoadedName = wanted;
return _nightMood;
}
private static object? AsType(object obj, Type target)
{
if (target.IsInstanceOfType(obj)) return obj;
if (obj is Il2CppObjectBase il2cpp)
{
var ctor = target.GetConstructor(new[] { typeof(IntPtr) });
if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer });
}
return obj;
}
// ── hotkey ────────────────────────────────────────────────────────
private static void PollHotkey()
{
var key = Mod.Cfg.RecRoyaleNightToggleKey;
if (string.IsNullOrWhiteSpace(key)) return;
try
{
if (!_inputResolved) ResolveInput(key);
if (_getKeyDown is null || _toggleKey is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle();
}
catch (Exception ex)
{
Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}");
_getKeyDown = null; // stop spamming
}
}
private static void ResolveInput(string key)
{
_inputResolved = true;
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
var inputType = Mod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); }
catch
{
Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled");
return;
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled");
}
// ── helpers ───────────────────────────────────────────────────────
private static bool MatchesScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName)) return false;
foreach (var s in Mod.Cfg.RecRoyaleNightScenes)
{
if (string.IsNullOrWhiteSpace(s)) continue;
if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
// UnityEngine.Object wrappers are non-null C# objects even when the
// native object is destroyed/missing; `== null` is overloaded in the
// interop, so route through the static op_Equality when present.
private static bool IsUnityNull(object obj)
{
try
{
var t = obj.GetType();
var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
if (eq is not null && eq.GetParameters().Length == 2)
return eq.Invoke(null, new[] { obj, null }) is true;
}
catch { }
return false;
}
private static string ObjectName(object obj)
{
try
{
var p = obj.GetType().GetProperty("name", Any);
return p?.GetValue(obj) as string ?? "";
}
catch { return ""; }
}
private static string Describe(object mood)
{
var friendly = "";
try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { }
var name = ObjectName(mood);
return friendly.Length > 0 ? $"{friendly} ({name})" : name;
}
private static int Count(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (p?.GetValue(arr) as int?) ?? 0;
}
private static object? Item(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
}
@@ -1,132 +0,0 @@
// ─────────────────────────────────────────────────────────────────────
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18)
// ─────────────────────────────────────────────────────────────────────
//
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
// doesn't pick it up. Don't rename to .cs unless you're putting the
// feature back.
//
// What this used to do
// --------------------
// Built a Photon AuthenticationValues with `userid` + `LoginLock`
// custom-auth parameters from the in-game RecNet account state. The
// server's /photon/customauth controller (PhotonCustomAuthController.cs)
// validated those parameters against the player's /player/login lock.
//
// Status in this branch
// ---------------------
// The method existed but was **never actually wired to a harmony
// hook** in this branch's OnLateInitializeMelon. December's Photon
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it
// doesn't inject. So this code was effectively dead in december.
//
// Removed because:
// 1. Photon Cloud is currently configured with Custom Authentication
// disabled at the AppId level (anonymous connects allowed), so
// no server-side auth probe.
// 2. Even when the watch did send AuthValues, the upstream Cloudflare
// / Localtunnel layer 403'd Photon's auth probe before our
// controller could decide anything.
//
// How to put it back
// ------------------
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix).
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig
// line.
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and
// mutating its returned AuthValues in place — that's the mangled
// method december verifies fires (LogAuthValues_Postfix already
// proves this entry point works).
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template.
// 5. If you also want the server to enforce the lock, set
// Photon__RequireCustomAuth=true on the server.
using System.Reflection;
namespace DorkNet.ClientMod.Attic;
internal static class AuthValuesInjector
{
public static void AuthValuesInjector_Prefix(object __instance)
{
if (!Mod.Cfg.InjectAuthValues) return;
try
{
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
// watch never sets them itself, so we set them right before
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
// from PlayerPrefs key LoginLockTokenV2.
// RecNet.Core.LocalAccountId is declared `int` in the game
// build (dump.cs:573622 → 0x30 backing field). Convert via
// Convert.ToInt64 so boxing as int still gives us the long we
// need below; a direct (long) cast on boxed int would throw.
var coreType = FindGameType("RecNet.Core");
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
if (accountId <= 0)
{
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
return;
}
var photonNetwork = FindGameType("PhotonNetwork");
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
var existing = avProp?.GetValue(null);
if (existing is not null)
{
var userIdProp = existing.GetType().GetProperty("UserId");
var existingUserId = userIdProp?.GetValue(existing) as string;
if (!string.IsNullOrEmpty(existingUserId)) return;
}
var token = GetCurrentLockToken();
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
var av = Activator.CreateInstance(authValuesType);
var authTypeProp = authValuesType.GetProperty("AuthType");
if (authTypeProp is not null)
{
var customAuthValue = authTypeProp.PropertyType.IsEnum
? Enum.ToObject(authTypeProp.PropertyType, 0)
: Convert.ChangeType(0, authTypeProp.PropertyType);
authTypeProp.SetValue(av, customAuthValue);
}
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
avProp?.SetValue(null, av);
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
}
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
}
private static string? GetCurrentLockToken()
{
try
{
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
if (string.IsNullOrEmpty(raw)) return null;
const string key = "\"LockToken\":\"";
var ix = raw.IndexOf(key, StringComparison.Ordinal);
if (ix < 0) return null;
ix += key.Length;
var end = raw.IndexOf('"', ix);
return end < 0 ? null : raw[ix..end];
}
catch { return null; }
}
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType.
// Use that on restore; this is just a placeholder to keep the file
// compilable if someone untreats it without re-checking the wiring.
private static Type? FindGameType(string name) => null;
}
@@ -1,9 +1,9 @@
{
"ServerHost": "{HOST}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DevCommands": [],
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
"CrimsonCauldron",
@@ -11,5 +11,18 @@
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
]
],
"RecRoyaleNightMode": false,
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
"RecRoyaleNightScenes": ["RecRoyale"],
"RecRoyaleNightFlashlights": true,
"RecRoyaleNightFog": false,
"RecRoyaleNightExposure": -3.5,
"RecRoyaleNightSunScale": 0.15,
"RecRoyaleNightAmbientScale": 0.2,
"RecRoyaleNightForcePostProcessing": true,
"RecRoyaleNightMatchSunLights": true,
"RecRoyaleNightDisableFog": false,
"RecRoyaleNightHideObjects": ["Cloud"],
"RecRoyaleNightToggleKey": ""
}
+11 -14
View File
@@ -46,6 +46,12 @@ public static class DorkNetRouteOwnership
"/discover",
"/featuredrooms",
"/goto",
// 2023 clients renamed the whole goto/* matchmaking surface
// to matchmake/* (matchmake/room, /instance, /invite,
// /chatinvite, …). Same controller as /goto, so it belongs
// to the match service; without this it fell through to the
// Monolith default owner.
"/matchmake",
"/hot_rooms",
"/hot_roomsandplaylists",
"/invite",
@@ -115,9 +121,14 @@ public static class DorkNetRouteOwnership
"/api/gamerewards",
"/api/inventions",
"/api/purchase",
"/api/roomconsumables",
"/api/roomcurrencies",
"/api/storefronts",
"/commerce",
"/econ",
"/purchase",
"/purchasecampaign",
"/reminder",
]),
new(
ServiceNames.Platform,
@@ -164,20 +175,6 @@ public static class DorkNetRouteOwnership
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
public static IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
// api.* is a shared public host: it owns no service by host and is
// dispatched by path (see ResolvePublicService's path-prefix pass),
// so it never appears in a group's HostSubdomains. It must still be
// whitelisted here or HostFiltering rejects every api.{apex} request.
.Append("api")
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
public static bool IsInfrastructurePath(string path)
{
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
+3751
View File
@@ -0,0 +1,3751 @@
// DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
//
// The shipping ClientMod carries ONLY the patches needed to connect and
// survive anti-cheat. All of the verbose diagnostic/tracing code lives
// here: HTTP header/request tracing, join/quit/studio/room-load traces,
// the process-level exception sink, auth-token tracing, the registration
// dialog trace, and the in-game DebugConsole enable/toggle path. Drop
// DorkNet.DebugMod.dll into the Mods folder next to DorkNet.ClientMod.dll
// to turn all of it on; remove it to ship a quiet, minimal client.
//
// It is a fully self-contained MelonMod (its own Harmony instance + its
// own copy of the reflection/patch helpers) so it neither needs nor
// couples to ClientMod at load time. Debug knobs are read from the same
// <MelonLoader>\UserData\dorknet-clientmod.json the ClientMod uses.
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MelonLoader;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DebugMod;
public class DebugMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DebugMod? Instance;
internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _httpDiagnosticsRegistered;
private bool _joinTracePatchesRegistered;
private bool _quitTraceCoreRegistered;
private bool _quitTraceGameComplete;
private int _quitTraceRetryFrame;
private bool _debugConsolePatchesComplete;
private int _debugConsoleRetryFrame;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private bool _processExceptionHandlersRegistered;
private bool _firstUpdateLogged;
private bool _devCommandsDumped;
private int _devCommandsDumpFrame;
private int _devCommandsDumpTries;
public static class Cfg
{
// Log-only trace for the stuck first-dorm "Change Username?" dialog.
public static bool TraceRegistrationDialog = true;
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors so
// movement/time commands don't drop you to the dorm. Default OFF. The
// toggle key is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static string[] DevCommands = Array.Empty<string>();
// Read-only one-shot: dump the full runtime command table to
// UserData/dorknet-devcommands.txt. Default OFF.
public static bool DumpDevCommands = false;
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DebugMod loading ===");
LoadConfig();
}
public override void OnLateInitializeMelon()
{
Log.Msg("=== Registering diagnostic patches ===");
RegisterProcessExceptionHandlers();
RegisterHttpDiagnostics();
RegisterQuitTracePatches(logMisses: true);
RegisterStudioTracePatches();
RegisterJoinTracePatches();
// Auth-token setter trace (LBNJFPOLCDL.JHLKGBGJKKK(access, refresh, key)).
// The shipping ClientMod used to register this unconditionally; it now
// lives here with the rest of the diagnostics.
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
args: new[] { typeof(string), typeof(string), typeof(string) },
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
// Deep game-side diagnostics: CheatManager tamper-detection logging,
// the Unity Debug.LogError/LogException sink, Photon connect/join
// tracing, and the room-load MoveNext state dumps. Unwired in the
// shipping client (kept out of the minimal footprint); this is their
// home now that diagnostics are opt-in.
RegisterDiagnostics();
if (Cfg.EnableDebugConsole)
RegisterDebugConsolePatches(logMisses: true);
Log.Msg("=== Diagnostic patches registered ===");
}
public override void OnUpdate()
{
if (!_firstUpdateLogged)
{
_firstUpdateLogged = true;
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
}
if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
{
_quitTraceRetryFrame = 0;
RegisterQuitTracePatches(logMisses: false);
}
if (Cfg.EnableDebugConsole && !_debugConsolePatchesComplete && ++_debugConsoleRetryFrame >= 300)
{
_debugConsoleRetryFrame = 0;
RegisterDebugConsolePatches(logMisses: false);
}
if (Cfg.EnableDebugConsole)
DebugConsolePatches.PollToggleKey();
// Retry the read-only command dump every ~5s (the config singleton only
// exists after the ~26s load + dorm entry). Stops as soon as it finds a
// populated runtime table, or after ~2.5 min of attempts.
if (Cfg.DumpDevCommands && !_devCommandsDumped && ++_devCommandsDumpFrame >= 300)
{
_devCommandsDumpFrame = 0;
if (DebugConsolePatches.DumpCommandDiagnostics() || ++_devCommandsDumpTries >= 30)
_devCommandsDumped = true;
}
}
// ── Config ────────────────────────────────────────────────────────
private static void LoadConfig()
{
try
{
var userData = ResolveUserDataDirectory();
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
Log.Msg($"[config] no file at {path}; using debug defaults");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (TryGetConfigValue(r, "TraceRegistrationDialog", out var v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DumpDevCommands", out v)) Cfg.DumpDevCommands = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var commands = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
commands.Add(e.GetString()!);
Cfg.DevCommands = commands.ToArray();
}
Log.Msg($"[config] debug: debugConsole={Cfg.EnableDebugConsole}, " +
$"dumpDevCommands={Cfg.DumpDevCommands}, devCommands={Cfg.DevCommands.Length}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
}
}
private MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { typeof(HttpTracePatches), typeof(AuthPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(MakerPenGiftPreviewPatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
try
{
var method = methodName == "ctor"
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
: (args is null ? AccessTools.Method(target, methodName)
: AccessTools.Method(target, methodName, args));
if (method is null)
{
// `logMiss` is throttled by the retry-loop caller — once per
// 5 seconds in OnUpdate, every time in the one-shot initial
// RegisterDiagnostics. Without the gate the per-second retry
// loop would spam `[patch-miss]` lines forever for any method
// whose name drifted in a newer game build.
if (logMiss) Log.Warning($"[patch-miss] {label}");
return false;
}
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {label}");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {label}: {ex.Message}");
return false;
}
}
private bool TryPatchByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
if (_diagnosticPatchLabels.Contains(label))
return true;
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
return false;
_diagnosticPatchLabels.Add(label);
return true;
}
internal static Type? ResolveType(string name)
{
if (ResolvedTypeCache.TryGetValue(name, out var cached))
return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t);
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
}
}
catch { /* unloaded / dynamic asm — skip */ }
}
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var t in GetLoadableTypes(asm))
{
if (MatchesRequestedType(t, name))
return CacheResolvedType(name, t);
}
}
return null;
}
private static Type CacheResolvedType(string name, Type type)
{
ResolvedTypeCache[name] = type;
return type;
}
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
{
try
{
return asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
var types = new List<Type>();
foreach (var t in ex.Types)
{
if (t is not null) types.Add(t);
}
return types;
}
catch
{
return Array.Empty<Type>();
}
}
private static bool MatchesRequestedType(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
var dottedPrefixed = "Il2Cpp." + requested;
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
var names = new List<string>();
foreach (var arg in args)
names.Add(arg.Name);
return $"{typeName}.{methodName}({string.Join(",", names)})";
}
private static string FormatParameterTypes(ParameterInfo[] parameters)
{
var names = new List<string>();
foreach (var parameter in parameters)
names.Add(parameter.ParameterType.Name);
return string.Join(",", names);
}
private static bool TypeNameContains(Type type, string value)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains(value, StringComparison.Ordinal);
}
private static bool MethodNameContains(MethodInfo method, string value)
=> method.Name.Contains(value, StringComparison.Ordinal);
private static string ResolveUserDataDirectory()
{
foreach (var typeName in new[]
{
"MelonLoader.MelonEnvironment, MelonLoader",
"MelonLoader.MelonUtils, MelonLoader",
})
{
var type = Type.GetType(typeName);
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
return value;
}
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
return Path.Combine(gameDir, "MelonLoader", "UserData");
}
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
args: Type.EmptyTypes,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
return false;
}
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
return true;
}
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
{
foreach (var method in AccessTools.GetDeclaredMethods(nested))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
}
}
}
if (patched == 0 && logMiss)
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
return patched > 0;
}
private void RegisterHttpDiagnostics()
{
if (_httpDiagnosticsRegistered) return;
var complete = true;
complete &= TryPatchBndRequestBuilderDiagnostics();
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
_httpDiagnosticsRegistered = complete;
}
private bool TryPatchBndRequestBuilderDiagnostics()
{
const string label = "BNDIAONDFFF.request-builder";
if (_diagnosticPatchLabels.Contains(label))
return true;
var type = ResolveType("BNDIAONDFFF");
if (type is null)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
return false;
}
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
var patched = 0;
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var parameters = ctor.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != "FILJELOLBKK") continue;
var parameters = method.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
return false;
}
_diagnosticPatchLabels.Add(label);
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
return true;
}
private void RegisterQuitTracePatches(bool logMisses)
{
if (!_quitTraceCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
logMiss: logMisses);
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
logMiss: logMisses);
_quitTraceCoreRegistered = true;
}
var complete = true;
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
logMiss: logMisses);
_quitTraceGameComplete = complete;
}
private void RegisterDiagnostics()
{
Log.Msg("=== Registering diagnostics ===");
if (!_diagnosticCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
_diagnosticCoreRegistered = true;
}
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
Log.Msg("=== Diagnostics registered ===");
}
private bool RegisterGameDiagnostics(bool logMisses)
{
// CheatManager fires these when its anti-cheat heuristics trip —
// logging them surfaces "client thinks it's been tampered with",
// a common cause of silent dorm-drops / kicks on a modded client.
// Names verified in CheatManager.txt for 2020.12.18; the older
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
// there's no DeveloperFlag/StreamingAsset callback nor a
// BootSequence.RegisterError sink in this build — those legacy
// hooks were removed (they only ever logged [patch-miss] forever).
foreach (var method in new[]
{
"OnTimeCheatDetected",
"OnObscuredTypeCheatDetected",
"OnHeightCheatDetected",
"OnAdvancedMovementCheatDetected",
})
{
TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
RegisterDeepRoomDiagnostics(logMisses);
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
// pure-logging diagnostics that fired during Unity's very first
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
// methods) MonoMod's lazy CompileMethodHook on the trampoline
// crashes the CLR with 0x80131506 the instant Awake JITs. The
// hooks gave us no behavioral leverage — only a `[call] …` log
// line — so they're disabled. If anyone needs Steam-side visibility
// later, route it through the game's own log lines instead.
return true;
}
private void RegisterDebugConsolePatches(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
"Awake",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.Awake_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
"InputField_OnEndEdit",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.InputFieldOnEndEdit_Prefix),
logMiss: logMisses);
// Dev commands that change movement/time trip retail anti-cheat
// callbacks. Suppress the three callbacks present in the 2023.03.21
// dump; keep the legacy height detector as a best-effort optional
// patch for adjacent builds without keeping this retry loop alive.
complete &= TryPatchDiagnosticByName("CheatManager",
"OnTimeCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("CheatManager",
"OnObscuredTypeCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("CheatManager",
"OnAdvancedMovementCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
TryPatchDiagnosticByName("CheatManager",
"OnHeightCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: false);
_debugConsolePatchesComplete = complete;
if (complete)
Log.Msg("[debugconsole] restoration patches armed");
}
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
string? prefix = null, string? postfix = null,
string? finalizer = null, bool logMiss = true)
{
var label = $"{typeName}.{methodName}(*)";
if (_diagnosticPatchLabels.Contains(label))
return true;
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
return false;
}
_diagnosticPatchLabels.Add(label);
Log.Msg($"[patch-ok] {label} x{patched}");
return true;
}
private bool RegisterDeepRoomDiagnostics(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
args: new[] { typeof(object) },
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
args: new[] { typeof(Exception) },
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
logMiss: logMisses);
foreach (var method in new[]
{
"ConnectUsingSettings",
"ConnectToRegion",
"JoinRoom",
"JoinOrCreateRoom",
"CreateRoom",
"LeaveRoom",
"Disconnect",
})
{
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
logMiss: logMisses);
}
foreach (var item in new (string Type, string Method)[]
{
("CMMIJKCDKPL", "CDNPLAGEDBK"),
("KGHEDLNPANK", "CDNPLAGEDBK"),
("HMPNINFEDFB", "IJIOJAIBPKE"),
("PhotonNetwork", "Disconnect"),
})
{
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
logMiss: logMisses);
}
complete &= PatchKnownRoomLoadMoveNext(logMisses);
return true;
}
private void RegisterStudioTracePatches()
{
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
}
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
{
const string label = "room-load-state-machines.MoveNext";
if (_diagnosticPatchLabels.Contains(label))
return true;
var nameNeedles = new[]
{
"OMJKHJLFOCO", "AFMPFLKGABO",
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
};
var patched = 0;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
var fullName = type.FullName ?? type.Name;
var match = false;
foreach (var needle in nameNeedles)
{
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
{
match = true;
break;
}
}
if (!match) continue;
IEnumerable<MethodInfo> methods;
try { methods = AccessTools.GetDeclaredMethods(type); }
catch { continue; }
foreach (var method in methods)
{
if (method.Name != "MoveNext") continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method,
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
patched++;
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
return false;
}
_diagnosticPatchLabels.Add(label);
return true;
}
private void RegisterProcessExceptionHandlers()
{
if (_processExceptionHandlersRegistered) return;
_processExceptionHandlersRegistered = true;
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try
{
var ex = e.ExceptionObject as Exception;
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
}
catch { }
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
try
{
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
}
catch { }
};
}
}
internal static class RoomLoadDiagnostics
{
private static readonly Dictionary<string, int> Counts = new();
private static int _globalCount;
public static void LogError_Prefix(object? message)
{
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
WriteStack("unity-error");
}
public static void LogException_Prefix(Exception exception)
{
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
WriteStack("unity-exception");
}
public static void NamedCall_Prefix(MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("call:" + name, 80)) return;
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
if (IsImportantCall(name))
WriteStack("runtime-call");
}
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
WriteStack("runtime-call-exception");
}
return __exception;
}
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("enter:" + name, 120)) return;
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
}
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("exit:" + name, 120)) return;
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
}
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write(
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
WriteStack("room-load-exception");
}
return __exception;
}
// ── Studio baked-asset load trace ──────────────────────────────────
// Pinpoints where the baked-Studio-scene load chain breaks. The room
// joins but renders no custom geometry and logs "Expected a prefab,
// but found none"; the server serves everything but the client never
// requests a bundle. These probes answer: (1) does the client ever
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
// ever register baked prefabs; (3) which prefab GUIDs does it look up
// and miss.
/// <summary>RecNet unity-asset getter
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
/// this never fires, the client decided not to fetch the bundle at all
/// (trigger missing upstream); if it fires, the URL/host/response is the
/// problem.</summary>
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
{
DiagnosticPatches.Write(
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
WriteStack("studio-assetfetch");
}
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
/// prefabs being registered after a bundle loads. If this never fires,
/// no bundle was loaded.</summary>
public static void StudioRegisterPrefabs_Postfix(object? __0)
{
int count = -1;
try
{
if (__0 is not null)
{
var p = __0.GetType().GetProperty("Count");
if (p?.GetValue(__0) is int c) count = c;
}
}
catch { /* best-effort count */ }
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
WriteStack("studio-registerprefabs");
}
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
/// — the lookup that fails with "Expected a prefab, but found none".
/// Logs the GUID + whether it resolved.</summary>
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
{
if (!ShouldLog("studio-trygetprefab", 200)) return;
DiagnosticPatches.Write(
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
}
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
{
if (!ShouldLog("studio-isknownprefab", 200)) return;
DiagnosticPatches.Write(
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
}
public static string FormatException(Exception? ex, object? fallback)
{
if (ex is null) return fallback?.ToString() ?? "<null>";
var builder = new StringBuilder();
AppendException(builder, ex, 0);
return Sanitize(builder.ToString(), 5000);
}
private static void AppendException(StringBuilder builder, Exception ex, int depth)
{
if (depth > 4) return;
if (builder.Length > 0) builder.Append(" | ");
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
builder.Append(" stack=").Append(ex.StackTrace);
if (ex is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions)
AppendException(builder, inner, depth + 1);
}
else if (ex.InnerException is not null)
{
AppendException(builder, ex.InnerException, depth + 1);
}
}
private static bool ShouldLog(string key, int perKeyLimit)
{
lock (Counts)
{
_globalCount++;
if (_globalCount > 12000)
return false;
Counts.TryGetValue(key, out var count);
if (count >= perKeyLimit)
return false;
Counts[key] = count + 1;
return true;
}
}
private static string DumpInstanceFields(object? instance, int maxFields)
{
if (instance is null) return "<null>";
try
{
var type = instance.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? value;
try { value = field.GetValue(instance); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
var described = DescribeValue(value, 400);
if (ShouldExpandField(field, value))
described += " " + DumpNestedFields(value, 16);
parts.Add($"{field.Name}={described}");
}
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
}
catch (Exception ex)
{
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
}
}
private static bool ShouldExpandField(FieldInfo field, object? value)
{
if (value is null) return false;
if (value is string) return false;
var valueType = value.GetType();
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
return false;
if (value is IEnumerable)
return false;
var fieldName = field.Name ?? string.Empty;
var typeName = valueType.FullName ?? valueType.Name;
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string DumpNestedFields(object value, int maxFields)
{
try
{
var type = value.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? nested;
try { nested = field.GetValue(value); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
}
return "{nested " + string.Join("; ", parts) + "}";
}
catch (Exception ex)
{
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
}
}
private static string DescribeValue(object? value, int limit)
{
if (value is null) return "null";
var type = value.GetType();
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
if (value is Exception ex) return FormatException(ex, ex);
if (type.IsPrimitive || type.IsEnum || value is decimal)
return Sanitize(value.ToString() ?? "<null>", limit);
if (value is IEnumerable enumerable && value is not string)
return DescribeEnumerable(enumerable, type, limit);
var rendered = value.ToString();
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
return "<" + type.FullName + ">";
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
}
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
{
var parts = new List<string>();
var count = 0;
foreach (var item in enumerable)
{
if (count++ >= 6)
{
parts.Add("...");
break;
}
parts.Add(DescribeValue(item, 120));
}
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
}
private static string Signature(MethodBase method)
{
try
{
var parameters = method.GetParameters();
var parts = new List<string>(parameters.Length);
foreach (var parameter in parameters)
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
return string.Join(", ", parts);
}
catch
{
return "<signature unread>";
}
}
private static string FullMethodName(MethodBase method) =>
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
private static bool IsImportantCall(string name) =>
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
private static void WriteStack(string tag)
{
try
{
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
}
}
private static string Sanitize(string value, int limit)
{
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _pollErrorLogged;
private static int _probeFrame;
private static bool _probeDone;
private static bool _inputResolved;
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
private static object? _toggleKeyValue; // boxed KeyCode enum value
private static bool _consoleResolved;
private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
private static List<ConfiguredCommand>? _commandCache;
// Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path.
public static bool SuppressCheatDetected_Prefix() => false;
public static bool Awake_Prefix()
{
return false;
}
public static bool InputFieldOnEndEdit_Prefix(object __instance)
{
var command = ReadConsoleInput(__instance);
if (string.IsNullOrWhiteSpace(command))
return true;
ExecuteCommand(command, __instance);
return false;
}
public static string FormatConfiguredCommandsForLog()
{
var commands = GetAvailableCommands();
if (commands.Count == 0) return "<none>";
var parts = new List<string>();
foreach (var command in commands)
parts.Add($"{command.Label}={command.Command}");
return string.Join(", ", parts);
}
// Polled every frame from DebugMod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
try
{
if (!_inputResolved) ResolveInput();
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
// console object actually exists, so we learn that even if the
// hotkey never fires.
if (!_probeDone && _probeFrame++ > 180)
{
_probeDone = true;
ProbeConsole();
}
if (_getKeyDown is null || _toggleKeyValue is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
{
DebugMod.Log.Msg($"[debugconsole] toggle key '{DebugMod.Cfg.DebugConsoleToggleKey}' detected");
Toggle();
}
}
catch (Exception ex)
{
if (!_pollErrorLogged) { _pollErrorLogged = true; DebugMod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
}
}
// Diagnostic: reports whether a live DebugConsole component exists and
// whether the show/hide method resolved — without needing a keypress.
private static void ProbeConsole()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = FindConsoleInstance();
DebugMod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
}
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
}
public static void ExecuteCommand(string command) => ExecuteCommand(command, consoleInstance: null);
private static void ExecuteCommand(string command, object? consoleInstance)
{
if (string.IsNullOrWhiteSpace(command))
return;
var output = RunCommand(command);
if (consoleInstance is null)
{
if (!_consoleResolved) ResolveConsole();
consoleInstance = FindConsoleInstance();
}
if (consoleInstance is not null)
WriteConsoleOutput(consoleInstance, output);
var parts = SplitCommandLine(command);
var verb = parts.Count > 0 ? parts[0] : "<empty>";
DebugMod.Log.Msg($"[debugconsole] command '{CleanLabel(verb)}': {FirstLine(output)}");
}
private static string RunCommand(string commandLine)
{
var tokens = SplitCommandLine(commandLine);
if (tokens.Count == 0)
return string.Empty;
var verb = tokens[0];
if (IsHelpCommand(verb))
return BuildCommandReferenceText("Debug Console Commands", maxCommands: 120);
var args = tokens.GetRange(1, tokens.Count - 1);
if (!TryFindCommand(verb, out var command))
return $"Unknown debug command: {verb}\n\n{BuildCommandReferenceText("Available Debug Console Commands", maxCommands: 80)}";
if (!command.HasRuntimeTarget)
{
var expanded = SplitCommandLine(command.Command);
if (expanded.Count == 0)
return $"Debug command '{verb}' has no backing command.";
var expandedArgs = expanded.Count > 1 ? expanded.GetRange(1, expanded.Count - 1) : new List<string>();
expandedArgs.AddRange(args);
if (!TryFindCommand(expanded[0], out command) || !command.HasRuntimeTarget)
return $"Debug command '{verb}' points at '{command.Command}', but no runtime command metadata was found.";
args = expandedArgs;
}
if (!TryResolveCommandType(command, out var targetType, out var typeError))
return typeError;
if (!TryFindCallableMethod(targetType!, command.MethodName, args, out var method, out var converted, out var methodError))
return methodError;
object? target = null;
if (!method!.IsStatic)
{
target = FindFirstInstance(targetType!);
if (target is null)
return $"Command '{command.MethodName}' needs a live {targetType!.Name} instance, but none was found.";
}
try
{
var result = method.Invoke(target, converted);
var suffix = result is null ? string.Empty : $"\nResult: {FormatResult(result)}";
return $"Executed: {command.Label}{suffix}";
}
catch (TargetInvocationException ex)
{
var inner = ex.InnerException ?? ex;
return $"Command '{command.MethodName}' failed: {inner.GetType().Name}: {inner.Message}";
}
catch (Exception ex)
{
return $"Command '{command.MethodName}' failed: {ex.GetType().Name}: {ex.Message}";
}
}
private static bool IsHelpCommand(string verb) =>
string.Equals(verb, "help", StringComparison.OrdinalIgnoreCase) ||
string.Equals(verb, "?", StringComparison.OrdinalIgnoreCase) ||
string.Equals(verb, "ListRegisteredCommands", StringComparison.OrdinalIgnoreCase);
private static bool TryResolveCommandType(ConfiguredCommand command, out Type? type, out string error)
{
foreach (var name in new[] { command.ObfuscatedDeclaringType, command.DeclaringType })
{
var normalized = NormalizeTypeName(name);
if (string.IsNullOrWhiteSpace(normalized)) continue;
type = DebugMod.ResolveType(normalized);
if (type is not null)
{
error = string.Empty;
return true;
}
}
type = null;
error = $"Command '{command.MethodName}' has no resolvable declaring type (declaring='{command.DeclaringType}', obfuscated='{command.ObfuscatedDeclaringType}').";
return false;
}
private static string NormalizeTypeName(string value)
{
value = (value ?? string.Empty).Trim();
var comma = value.IndexOf(',');
return comma >= 0 ? value[..comma].Trim() : value;
}
private static bool TryFindCallableMethod(Type type, string methodName, List<string> args,
out MethodInfo? method, out object?[] converted, out string error)
{
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (candidate.Name != methodName) continue;
if (candidate.IsAbstract || candidate.ContainsGenericParameters) continue;
if (TryConvertArguments(candidate.GetParameters(), args, out converted))
{
method = candidate;
error = string.Empty;
return true;
}
}
method = null;
converted = Array.Empty<object?>();
error = $"No overload of {type.Name}.{methodName} accepts {args.Count} argument(s): {string.Join(" ", args)}";
return false;
}
private static bool TryConvertArguments(ParameterInfo[] parameters, List<string> args, out object?[] converted)
{
converted = new object?[parameters.Length];
var argIndex = 0;
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
if (IsVector3(parameter.ParameterType) && args.Count - argIndex >= 3)
{
if (!TryParseFloat(args[argIndex], out var x) ||
!TryParseFloat(args[argIndex + 1], out var y) ||
!TryParseFloat(args[argIndex + 2], out var z) ||
!TryCreateVector3(parameter.ParameterType, x, y, z, out converted[i]))
return false;
argIndex += 3;
continue;
}
if (argIndex >= args.Count)
{
if (parameter.HasDefaultValue)
{
converted[i] = parameter.DefaultValue;
continue;
}
return false;
}
if (!TryConvertArgument(args[argIndex], parameter.ParameterType, out converted[i]))
return false;
argIndex++;
}
return argIndex == args.Count;
}
private static bool TryConvertArgument(string value, Type targetType, out object? converted)
{
converted = null;
if (targetType == typeof(string) || targetType.FullName == "System.String")
{
converted = value;
return true;
}
var nullable = Nullable.GetUnderlyingType(targetType);
if (nullable is not null)
targetType = nullable;
if (targetType == typeof(bool))
{
if (bool.TryParse(value, out var b))
{
converted = b;
return true;
}
if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
value == "1")
{
converted = true;
return true;
}
if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
value == "0")
{
converted = false;
return true;
}
return false;
}
if (targetType.IsEnum)
{
try
{
converted = Enum.Parse(targetType, value, ignoreCase: true);
return true;
}
catch { return false; }
}
try
{
if (targetType == typeof(int)) { converted = int.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(long)) { converted = long.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(float)) { converted = float.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(double)) { converted = double.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(short)) { converted = short.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(byte)) { converted = byte.Parse(value, CultureInfo.InvariantCulture); return true; }
}
catch { return false; }
return false;
}
private static bool IsVector3(Type type) =>
type.Name == "Vector3" && (type.Namespace == "UnityEngine" || type.FullName?.EndsWith(".Vector3", StringComparison.Ordinal) == true);
private static bool TryParseFloat(string value, out float result) =>
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
private static bool TryCreateVector3(Type vectorType, float x, float y, float z, out object? vector)
{
vector = null;
try
{
var ctor = vectorType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
if (ctor is null) return false;
vector = ctor.Invoke(new object[] { x, y, z });
return true;
}
catch { return false; }
}
private static object? FindFirstInstance(Type type)
{
try
{
var il2cppType = ToIl2CppType(type);
if (il2cppType is null) return null;
var resources = DebugMod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
{
var item = ArrItem(all, i);
if (item is not null) return item;
}
}
catch { }
return null;
}
private static string FormatResult(object result)
{
var text = result.ToString() ?? result.GetType().Name;
return text.Length <= 500 ? text : text[..500] + "...";
}
private static string FirstLine(string value)
{
value = value.Replace("\r", "");
var idx = value.IndexOf('\n');
return idx >= 0 ? value[..idx] : value;
}
private static List<string> SplitCommandLine(string value)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(value)) return result;
var sb = new StringBuilder();
var inQuotes = false;
var quote = '\0';
var escaping = false;
foreach (var ch in value.Trim())
{
if (escaping)
{
sb.Append(ch);
escaping = false;
continue;
}
if (ch == '\\')
{
escaping = true;
continue;
}
if (inQuotes)
{
if (ch == quote)
{
inQuotes = false;
continue;
}
sb.Append(ch);
continue;
}
if (ch is '"' or '\'')
{
inQuotes = true;
quote = ch;
continue;
}
if (char.IsWhiteSpace(ch))
{
if (sb.Length > 0)
{
result.Add(sb.ToString());
sb.Clear();
}
continue;
}
sb.Append(ch);
}
if (escaping) sb.Append('\\');
if (sb.Length > 0) result.Add(sb.ToString());
return result;
}
private static void ResolveInput()
{
_inputResolved = true;
var keyCodeType = DebugMod.ResolveType("UnityEngine.KeyCode");
var inputType = DebugMod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
DebugMod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKeyValue = Enum.Parse(keyCodeType, DebugMod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
catch
{
DebugMod.Log.Warning($"[debugconsole] '{DebugMod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) DebugMod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
else DebugMod.Log.Msg($"[debugconsole] hotkey armed: '{DebugMod.Cfg.DebugConsoleToggleKey}' (poll live)");
}
private static void ResolveConsole()
{
_consoleResolved = true;
_consoleType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsole");
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
// name, then a structural fallback that fits both builds.
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
?? FindToggleByShape(_consoleType);
if (_toggleMethod is null) DebugMod.Log.Warning("[debugconsole] show/hide method not found");
}
// The console's show/hide is the single private, instance, void method
// taking exactly one bool, excluding compiler-generated members (the
// property backing setter on 2020.12 is also void(bool) but is marked
// [CompilerGenerated]).
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null; // ambiguous — don't guess
found = m;
}
return found;
}
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null) return;
try
{
var instance = FindConsoleInstance();
if (instance is null)
{
DebugMod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
return;
}
// Best-effort: flip the static "show button" enable so the
// game's own Update keeps the console available instead of
// re-hiding it. Failures are ignored (obfuscated builds).
EnableShowButton();
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
if (_consoleVisible)
ShowConfiguredCommands(instance);
DebugMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
}
private static object? FindConsoleInstance()
{
if (_consoleType is null) return null;
try
{
var il2cppType = ToIl2CppType(_consoleType);
if (il2cppType is null) return null;
var resources = DebugMod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
{
var item = ArrItem(all, i);
if (item is not null) return item;
}
var objType = DebugMod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
return null;
}
}
private static void ShowConfiguredCommands(object instance)
{
try
{
if (_consoleType is null) return;
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
if (_setConsoleText is null)
{
DebugMod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
return;
}
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
DebugMod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
}
}
private static string BuildConfiguredCommandText()
{
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
}
private static string ReadConsoleInput(object consoleInstance)
{
var input = FindConsoleInput(consoleInstance);
return input is null ? string.Empty : ReadText(input);
}
private static void WriteConsoleOutput(object consoleInstance, string text)
{
try
{
if (_consoleType is not null)
{
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
if (_setConsoleText is not null && _setConsoleText.DeclaringType?.IsInstanceOfType(consoleInstance) == true)
{
try
{
_setConsoleText.Invoke(consoleInstance, new object[] { text });
return;
}
catch (TargetException)
{
// The live IL2CPP proxy can differ from the resolved wrapper type.
}
}
}
var input = FindConsoleInput(consoleInstance);
if (input is not null)
SetText(input, text);
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] output write failed: {ex.Message}");
}
}
private static object? FindConsoleInput(object consoleInstance)
{
foreach (var name in new[] { "KECJGKLKFGP", "inputField", "InputField" })
{
var value = GetMemberValue(consoleInstance, name);
if (value is not null) return value;
}
var type = consoleInstance.GetType();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (!LooksLikeInputField(prop.PropertyType) || !prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try { if (prop.GetValue(consoleInstance) is { } value) return value; } catch { }
}
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (!LooksLikeInputField(field.FieldType)) continue;
try { if (field.GetValue(consoleInstance) is { } value) return value; } catch { }
}
return null;
}
private static bool LooksLikeInputField(Type type) =>
type.Name.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0 ||
type.FullName?.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0;
private static string ReadText(object textObject)
{
try
{
var type = textObject.GetType();
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanRead && prop.GetValue(textObject) is string s)
return s;
var getter = type.GetMethod("get_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null)
?? type.GetMethod("get_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
return getter?.Invoke(textObject, Array.Empty<object>()) as string ?? string.Empty;
}
catch { return string.Empty; }
}
private static void SetText(object textObject, string text)
{
try
{
var type = textObject.GetType();
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanWrite)
{
prop.SetValue(textObject, text);
return;
}
var setter = type.GetMethod("set_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)
?? type.GetMethod("set_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null);
setter?.Invoke(textObject, new object[] { text });
}
catch { }
}
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
{
var commands = GetAvailableCommands();
var sb = new StringBuilder();
sb.AppendLine(header);
sb.AppendLine();
if (commands.Count == 0)
{
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
}
else
{
var visible = Math.Min(commands.Count, maxCommands);
for (var i = 0; i < visible; i++)
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
if (visible < commands.Count)
{
sb.AppendLine();
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
}
}
return sb.ToString().TrimEnd();
}
internal static List<ConfiguredCommand> GetAvailableCommands()
{
if (_commandCache is not null) return _commandCache;
var commands = new List<ConfiguredCommand>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddConfiguredCommands(commands, seen);
AddRuntimeCommands(commands, seen);
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
_commandCache = commands;
DebugMod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
return commands;
}
// Read-only one-shot diagnostic (Cfg.DumpDevCommands). Writes a full report
// to UserData/dorknet-devcommands.txt: does a live DebugConsole exist, and
// for every runtime command meta, does its declaring type resolve and does
// a method with that name exist on it (i.e. is it actually callable on this
// obfuscated build). Invokes NOTHING and touches no game UI — safe to run
// in a live session. This is what turns the text-console path from guessing
// into engineering: the resolvable/total tally says whether the feature is
// buildable as-is or needs an obfuscation name map.
// Returns true once it observes a populated runtime command table (so the
// caller can stop retrying); false while the config singleton isn't up yet.
public static bool DumpCommandDiagnostics()
{
var sb = new StringBuilder();
int runtime = 0;
void W(string s) { sb.Append(s).Append('\n'); DebugMod.Log.Msg("[devcmd] " + s); }
try
{
// Force a fresh scan — an early call (before the config singleton
// loads) must not poison the cache with an empty table.
_commandCache = null;
W("=== DorkNet dev-command diagnostic ===");
if (!_consoleResolved) ResolveConsole();
object? liveConsole = null;
try { liveConsole = FindConsoleInstance(); } catch (Exception ex) { W($"FindConsoleInstance threw: {ex.Message}"); }
W($"DebugConsole type resolved : {_consoleType?.FullName ?? "<none>"}");
W($"DebugConsole live instance : {(liveConsole is null ? "NULL (UI shell not instantiated — text console can't be shown, would need our own UI)" : "FOUND (UI can be driven)")}");
W($"toggle method : {_toggleMethod?.Name ?? "<none>"}");
W("");
List<ConfiguredCommand> commands;
try { commands = GetAvailableCommands(); }
catch (Exception ex) { W($"GetAvailableCommands threw: {ex}"); commands = new List<ConfiguredCommand>(); }
int typeOk = 0, methodOk = 0;
var samplesOk = new List<string>();
var samplesBad = new List<string>();
foreach (var c in commands)
{
if (!c.HasRuntimeTarget) continue;
runtime++;
var resolvedType = TryResolveCommandType(c, out var t, out _) ? t : null;
if (resolvedType is null)
{
if (samplesBad.Count < 30)
samplesBad.Add($" [no-type] {c.MethodName} (declaring='{c.DeclaringType}' obf='{c.ObfuscatedDeclaringType}')");
continue;
}
typeOk++;
var hasMethod = false;
foreach (var m in resolvedType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
if (m.Name == c.MethodName) { hasMethod = true; break; }
if (hasMethod)
{
methodOk++;
if (samplesOk.Count < 40) samplesOk.Add($" [OK] {resolvedType.Name}.{c.MethodName}");
}
else if (samplesBad.Count < 30)
{
samplesBad.Add($" [no-method] {resolvedType.Name}.{c.MethodName} (type resolved, method name not present → obfuscated/stripped)");
}
}
W($"total commands in table : {commands.Count}");
W($"runtime-target commands : {runtime}");
W($" declaring type resolves : {typeOk}");
W($" method callable by name : {methodOk} <-- this number decides feasibility");
W("");
W("--- sample CALLABLE commands ---");
foreach (var s in samplesOk) W(s);
W("");
W("--- sample UNRESOLVED commands ---");
foreach (var s in samplesBad) W(s);
W("");
W("--- FULL runtime command list (label | methodName | declaringType | obfuscatedDeclaringType) ---");
foreach (var c in commands)
{
if (!c.HasRuntimeTarget) continue;
sb.Append(" ").Append(c.Label).Append(" | ").Append(c.MethodName)
.Append(" | ").Append(c.DeclaringType).Append(" | ").Append(c.ObfuscatedDeclaringType).Append('\n');
}
}
catch (Exception ex)
{
sb.Append("DIAGNOSTIC FAILED: ").Append(ex).Append('\n');
DebugMod.Log.Warning($"[devcmd] diagnostic failed: {ex.Message}");
}
// Only persist the report once the table is actually populated, so an
// early empty attempt doesn't overwrite a good report from a later try.
if (runtime > 0)
{
try
{
var dir = Path.GetDirectoryName(DebugMod.DiagnosticsLogPath) ?? ".";
var outPath = Path.Combine(dir, "dorknet-devcommands.txt");
File.WriteAllText(outPath, sb.ToString());
DebugMod.Log.Msg($"[devcmd] wrote {outPath} ({runtime} runtime commands)");
}
catch (Exception ex) { DebugMod.Log.Warning($"[devcmd] could not write report: {ex.Message}"); }
}
else
{
DebugMod.Log.Msg("[devcmd] command table not populated yet — will retry");
}
return runtime > 0;
}
private static bool TryFindCommand(string verb, out ConfiguredCommand command)
{
var commands = GetAvailableCommands();
foreach (var candidate in commands)
{
if (!candidate.HasRuntimeTarget) continue;
if (CommandMatches(candidate, verb))
{
command = candidate;
return true;
}
}
foreach (var candidate in commands)
{
if (CommandMatches(candidate, verb))
{
command = candidate;
return true;
}
}
command = default;
return false;
}
private static bool CommandMatches(ConfiguredCommand command, string verb)
{
if (string.Equals(command.MethodName, verb, StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(command.Command, verb, StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(command.Label, verb, StringComparison.OrdinalIgnoreCase)) return true;
var shortLabel = command.Label;
var dot = shortLabel.LastIndexOf('.');
if (dot >= 0 && dot < shortLabel.Length - 1)
shortLabel = shortLabel[(dot + 1)..];
if (string.Equals(shortLabel, verb, StringComparison.OrdinalIgnoreCase)) return true;
var commandTokens = SplitCommandLine(command.Command);
return commandTokens.Count > 0 && string.Equals(commandTokens[0], verb, StringComparison.OrdinalIgnoreCase);
}
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
foreach (var entry in DebugMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var split = entry.IndexOf('=');
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
if (string.IsNullOrWhiteSpace(command)) continue;
if (string.IsNullOrWhiteSpace(label)) label = command;
var parts = SplitCommandLine(command);
var methodName = parts.Count > 0 ? parts[0] : command;
AddCommand(commands, seen, label, command, declaringType: string.Empty, obfuscatedDeclaringType: string.Empty, methodName, isRuntime: false);
}
}
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
try
{
var cfgType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
if (cfgType is null)
{
DebugMod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
return;
}
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.GetParameters().Length != 0) continue;
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
catch { }
}
var il2cppType = ToIl2CppType(cfgType);
var resources = DebugMod.ResolveType("UnityEngine.Resources");
if (il2cppType is not null && resources is not null)
{
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
AddCommandConfig(ArrItem(all, i), commands, seen);
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
}
}
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
{
if (config is null) return;
var metas = GetMemberValue(config, "Metas");
var count = ArrLen(metas);
for (var i = 0; i < count; i++)
{
var meta = ArrItem(metas, i);
if (meta is null) continue;
var methodName = ReadStringMember(meta, "MethodName");
if (string.IsNullOrWhiteSpace(methodName)) continue;
var declaringType = ReadStringMember(meta, "DeclaringType");
var obfuscatedDeclaringType = ReadStringMember(meta, "ObfuscatedDeclaringType");
var label = string.IsNullOrWhiteSpace(declaringType)
? methodName
: $"{ShortTypeName(declaringType)}.{methodName}";
AddCommand(commands, seen, label, methodName, declaringType, obfuscatedDeclaringType, methodName, isRuntime: true);
}
}
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command,
string declaringType, string obfuscatedDeclaringType, string methodName, bool isRuntime)
{
label = CleanLabel(label);
command = (command ?? string.Empty).Trim();
methodName = (methodName ?? string.Empty).Trim();
if (command.Length == 0 || methodName.Length == 0) return;
var key = isRuntime
? $"{NormalizeTypeName(obfuscatedDeclaringType)}|{NormalizeTypeName(declaringType)}|{methodName}"
: $"configured|{label}|{command}";
if (!seen.Add(key)) return;
commands.Add(new ConfiguredCommand(label, command, declaringType, obfuscatedDeclaringType, methodName, isRuntime));
}
private static object? GetMemberValue(object instance, string name)
{
var type = instance.GetType();
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
{
try { return prop.GetValue(instance); } catch { }
}
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (field is not null)
{
try { return field.GetValue(instance); } catch { }
}
return null;
}
private static string ReadStringMember(object instance, string name)
{
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
}
private static string CleanLabel(string label)
{
label = (label ?? string.Empty).Trim();
if (label.Length == 0) return "Command";
var cleaned = new char[label.Length];
for (var i = 0; i < label.Length; i++)
{
var ch = label[i];
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
}
return new string(cleaned);
}
private static string ShortTypeName(string typeName)
{
var idx = typeName.LastIndexOf('.');
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
}
internal readonly struct ConfiguredCommand
{
public ConfiguredCommand(string label, string command, string declaringType,
string obfuscatedDeclaringType, string methodName, bool hasRuntimeTarget)
{
Label = label;
Command = command;
DeclaringType = declaringType;
ObfuscatedDeclaringType = obfuscatedDeclaringType;
MethodName = methodName;
HasRuntimeTarget = hasRuntimeTarget;
}
public string Label { get; }
public string Command { get; }
public string DeclaringType { get; }
public string ObfuscatedDeclaringType { get; }
public string MethodName { get; }
public bool HasRuntimeTarget { get; }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
try
{
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
{
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
}
}
catch { /* best-effort — the toggle still works without it */ }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = DebugMod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true }
: new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
{
if (type is null) return null;
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (method.Name != name) continue;
var parameters = method.GetParameters();
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
return method;
}
return null;
}
private static int ArrLen(object? array)
{
if (array is null) return 0;
if (array is Array managedArray) return managedArray.Length;
var type = array.GetType();
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
return (length?.GetValue(array) as int?) ?? 0;
}
private static object? ArrItem(object? array, int index)
{
if (array is null) return null;
if (array is Array managedArray) return managedArray.GetValue(index);
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
return getItem?.Invoke(array, new object[] { index });
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class AuthPatches
{
private static string? _latestAccessToken;
private static int _tokenLogCount;
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
{
try
{
var accessToken = __0?.ToString();
if (LooksLikeJwt(accessToken))
_latestAccessToken = accessToken;
if (_tokenLogCount++ < 3)
{
DiagnosticPatches.Write(
"[auth-trace] token setter " +
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
}
}
private static bool LooksLikeJwt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
var dots = 0;
foreach (var ch in value)
if (ch == '.') dots++;
return dots == 2;
}
private static string Presence(object? value) =>
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
}
internal static class HttpTracePatches
{
private const int BodyPreviewBytes = 700;
private static readonly Regex SensitivePairRegex = new(
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
RegexOptions.Compiled);
private static readonly Regex BearerRegex = new(
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
RegexOptions.Compiled);
private static readonly Regex SensitiveHeaderRegex = new(
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
RegexOptions.Compiled);
private static readonly Regex SensitiveJsonStringRegex = new(
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
RegexOptions.Compiled);
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
private static int _requestId;
private static int _failureLogCount;
private static int _responseTupleLogCount;
private static int _builderSendLogCount;
private static int _callbackLogCount;
private static int _uriUnreadDumpCount;
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
{
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
{
var id = NextRequestId();
DiagnosticPatches.Write(
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
}
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
{
try
{
var ptr = TryGetIl2CppPtr(__instance);
if (ptr == IntPtr.Zero) return;
var info =
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
lock (RecNetRequestInfoByPtr)
{
RecNetRequestInfoByPtr[ptr] = info;
if (RecNetRequestInfoByPtr.Count > 256)
RecNetRequestInfoByPtr.Clear();
}
DiagnosticPatches.Write($"[recnet-request-map] {info}");
}
catch (Exception ex)
{
LogFailure("recnet-request-map", ex);
}
}
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
{
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
try
{
DiagnosticPatches.Write(
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
}
catch (Exception ex)
{
LogFailure("recnet-send-enter", ex);
}
}
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
{
if (_builderSendLogCount > 120) return;
try
{
DiagnosticPatches.Write(
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
}
catch (Exception ex)
{
LogFailure("recnet-send-exit", ex);
}
}
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
{
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
try
{
DiagnosticPatches.Write(
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
}
catch (Exception ex)
{
LogFailure("recnet-response-map-enter", ex);
}
}
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
{
if (_responseTupleLogCount > 80) return;
try
{
DiagnosticPatches.Write(
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
}
catch (Exception ex)
{
LogFailure("recnet-response-map-exit", ex);
}
}
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write(
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
$"state={DumpObjectFields(__instance, 36, 1200)}");
}
return __exception;
}
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
{
try
{
var name = __0?.ToString() ?? "<null>";
var value = RedactHeaderValue(name, __1?.ToString());
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
DiagnosticPatches.Write(
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
$"{SanitizeInline(uri)} {name}: {value}");
}
catch (Exception ex)
{
LogFailure("header", ex);
}
}
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
{
try
{
DiagnosticPatches.Write(
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)}");
}
catch (Exception ex)
{
LogFailure("callback-enter", ex);
}
}
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
{
try
{
if (!ShouldLogCallback(__instance)) return;
DiagnosticPatches.Write(
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)}");
}
catch (Exception ex)
{
LogFailure("callback-exit", ex);
}
}
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
try
{
DiagnosticPatches.Write(
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)} " +
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
$"state={DumpObjectFields(__instance, 32, 900)}");
}
catch (Exception ex)
{
LogFailure("callback-finalizer", ex);
}
}
return __exception;
}
public static void TraceUrl(string? url, string source)
{
if (string.IsNullOrWhiteSpace(url)) return;
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return;
var id = NextRequestId();
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
}
public static void TraceRequestObject(object? request, string source)
{
if (request is null) return;
try
{
var id = NextRequestId();
var uriValue = TryReadRequestUri(request);
var uri = uriValue?.ToString() ?? "<uri unread>";
var method = TryReadHttpMethod(request);
var headers = TryReadHeaders(request);
var body = TryReadBodyPreview(request);
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
? $" requestFields={DumpObjectFields(request, 24, 260)}"
: "";
DiagnosticPatches.Write(
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
$"headers={headers} body={body}{fallback}");
}
catch (Exception ex)
{
LogFailure(source, ex);
}
}
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
private static Uri? TryReadRequestUri(object request)
{
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var type = request.GetType();
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try
{
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
}
catch { }
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
try
{
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
}
catch { }
}
return null;
}
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
{
uri = null;
try
{
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
if (method is null) return false;
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
}
catch
{
return false;
}
}
private static bool TryReadUriValue(object? value, out Uri? uri)
{
uri = null;
if (value is null) return false;
if (value is Uri direct)
{
uri = direct;
return true;
}
var text = value.ToString();
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
{
uri = parsed;
return true;
}
return false;
}
private static string TryReadHttpMethod(object request)
{
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
{
if (TryReadMemberValue(request, name, out var value))
return SanitizeInline(value?.ToString() ?? "<null>");
}
return "<method unread>";
}
private static string TryReadHeaders(object request)
{
try
{
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
return SanitizeInline(rawDump.ToString() ?? "<empty>");
}
catch { }
var known = new List<string>();
foreach (var name in new[]
{
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
"Content-Type", "User-Agent"
})
{
var value = TryReadHeaderValue(request, name);
if (!string.IsNullOrWhiteSpace(value))
known.Add($"{name}:{RedactHeaderValue(name, value)}");
}
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
}
private static string? TryReadHeaderValue(object request, string name)
{
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
{
try
{
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
var value = method?.Invoke(request, new object[] { name })?.ToString();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
catch { }
}
return null;
}
private static string TryReadBodyPreview(object request)
{
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
{
try
{
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
if (method is null) continue;
var body = method.Invoke(request, Array.Empty<object>());
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
if (bytes is null) continue;
if (bytes.Length == 0) return "<empty>";
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
var text = Encoding.UTF8.GetString(bytes, 0, shown);
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
}
catch { }
}
return "<body unread>";
}
private static bool ShouldLogCallback(object request)
{
var response = TryReadResponseObject(request);
var status = response is null ? null : TryReadStatusCode(response);
if (status >= 400) return true;
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
}
private static string DescribeRequestAndResponse(object request)
{
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
var method = TryReadHttpMethod(request);
var response = TryReadResponseObject(request);
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
}
private static object? TryReadResponseObject(object request)
{
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
{
if (TryReadMemberValue(request, name, out var value) && value is not null)
return value;
}
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var prop in request.GetType().GetProperties(flags))
{
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try
{
var value = prop.GetValue(request);
if (value is not null) return value;
}
catch { }
}
foreach (var field in request.GetType().GetFields(flags))
{
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
try
{
var value = field.GetValue(request);
if (value is not null) return value;
}
catch { }
}
return null;
}
private static int? TryReadStatusCode(object response)
{
if (TryReadMemberValue(response, "StatusCode", out var value))
{
try { return Convert.ToInt32(value); }
catch { }
}
return null;
}
private static string DescribeResponseObject(object response)
{
var known = DescribeKnownMembers(response,
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
return $"{response.GetType().FullName} {{{known}}}";
}
private static string DescribeKnownMembers(object obj, params string[] names)
{
var parts = new List<string>();
foreach (var name in names)
{
if (!TryReadMemberValue(obj, name, out var value)) continue;
parts.Add($"{name}={FormatValue(value, 700)}");
}
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
}
private static string FormatValue(object? value, int limit)
{
if (value is null) return "null";
if (value is byte[] bytes)
return $"byte[{bytes.Length}]";
if (value is IEnumerable enumerable && value is not string)
return FormatEnumerable(enumerable, value.GetType(), limit);
var text = value is Exception ex
? $"{ex.GetType().Name}: {ex.Message}"
: value.ToString() ?? "<null>";
text = SanitizeInline(text);
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
}
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
{
try
{
var parts = new List<string>();
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
var shown = 0;
foreach (var item in enumerable)
{
if (shown++ >= 6)
{
parts.Add("...");
break;
}
parts.Add(FormatValue(item, 180));
}
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
}
catch (Exception ex)
{
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
{
count = 0;
if (enumerable is ICollection collection)
{
count = collection.Count;
return true;
}
try
{
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop?.GetValue(enumerable) is int value)
{
count = value;
return true;
}
}
catch { }
return false;
}
private static byte[]? TryCopyBytes(object? value, int limit)
{
if (value is null) return null;
if (value is byte[] direct)
return CopyLimited(direct, limit);
if (value is IEnumerable enumerable)
{
var bytes = new List<byte>();
foreach (var item in enumerable)
{
if (item is null) continue;
try { bytes.Add(Convert.ToByte(item)); }
catch { return null; }
if (bytes.Count >= limit) break;
}
return bytes.ToArray();
}
return null;
}
private static byte[] CopyLimited(byte[] source, int limit)
{
var count = Math.Min(source.Length, limit);
var copy = new byte[count];
Array.Copy(source, copy, count);
return copy;
}
private static bool TryReadMemberValue(object request, string name, out object? value)
{
value = null;
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var type = request.GetType();
try
{
var prop = type.GetProperty(name, flags);
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
{
value = prop.GetValue(request);
return true;
}
}
catch { }
try
{
var field = type.GetField(name, flags);
if (field is not null)
{
value = field.GetValue(request);
return true;
}
}
catch { }
try
{
var method = AccessTools.Method(type, name, Type.EmptyTypes);
if (method is not null)
{
value = method.Invoke(request, Array.Empty<object>());
return true;
}
}
catch { }
return false;
}
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
{
if (instance is null) return "<null>";
try
{
var type = instance.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? value;
try { value = field.GetValue(instance); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
}
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
}
catch (Exception ex)
{
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static string LookupRecNetRequestInfo(object instance)
{
var ptr = TryGetIl2CppPtr(instance);
if (ptr == IntPtr.Zero) return "ptr=<unread>";
lock (RecNetRequestInfoByPtr)
{
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
? info
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
}
}
private static string DumpBndNativeFields(object? instance)
{
var ptr = TryGetIl2CppPtr(instance);
if (ptr == IntPtr.Zero) return "native=<unread>";
try
{
var method = Marshal.ReadByte(ptr, 16);
var service = Marshal.ReadInt32(ptr, 20);
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
var bodyKind = Marshal.ReadInt32(ptr, 56);
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
var path = ReadIl2CppString(pathPtr);
return
"native={" +
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
"}";
}
catch (Exception ex)
{
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static string ReadIl2CppString(IntPtr ptr)
{
if (ptr == IntPtr.Zero) return "<null>";
try
{
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
}
catch (Exception ex)
{
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
}
}
private static IntPtr TryGetIl2CppPtr(object? instance)
{
try
{
return instance is Il2CppObjectBase il2CppObject
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
: IntPtr.Zero;
}
catch
{
return IntPtr.Zero;
}
}
private static string RedactHeaderValue(string name, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
{
return "<redacted>";
}
return SanitizeInline(value);
}
private static string SanitizeInline(string value)
{
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
}
private static void LogFailure(string source, Exception ex)
{
if (_failureLogCount++ < 10)
DebugMod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
}
}
internal static class SavePatches
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
// our currently-loaded room. Captured BEFORE the watch's caching
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
// (roomId, subRoomId) tuple — which is every post-save push for a
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
// would-be no-op setter call into a real transition.
private static volatile string? s_lastPushedBlobName;
private static long s_lastPushedRoomId;
private static long s_lastPushedSubRoomId;
// Hook on Notifications.OnNotificationReceived(string json). Fires for
// every SignalR message before any handler dispatch. Parses the JSON
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
public static void OnNotificationReceived_Prefix(string message)
{
try
{
if (string.IsNullOrEmpty(message)) return;
// Fast reject: only parse JSON if it could plausibly be a
// SubscriptionUpdateRoom payload. Saves cycles on the (very
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
return;
using var doc = System.Text.Json.JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!root.TryGetProperty("Id", out var idEl) ||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
return;
if (!root.TryGetProperty("Msg", out var msg) ||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!msg.TryGetProperty("Room", out var room) ||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
long roomId = 0;
if (room.TryGetProperty("RoomId", out var ridEl) &&
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
roomId = ridEl.GetInt64();
if (!msg.TryGetProperty("Scenes", out var scenes) ||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
scenes.GetArrayLength() == 0) return;
var first = scenes[0];
long subRoomId = 0;
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
subRoomId = sidEl.GetInt64();
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
var blob = blobEl.GetString();
if (string.IsNullOrEmpty(blob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = blob;
DebugMod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
}
}
public static void OnSubscriptionUpdateRoom_Prefix(object message)
{
try
{
if (message is null) return;
var dict = message;
// The watch parses this dict as Dictionary<string, object>
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
// get_Item(key) — call it via reflection.
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (getItem is null) return;
// The push wire shape from server (BuildRoomDetails) is
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
// on the Room sub-object; the scene's blob on Scenes[0].
object? roomObj = TryGet(dict, getItem, "Room");
object? scenesObj = TryGet(dict, getItem, "Scenes");
if (roomObj is null || scenesObj is null) return;
long roomId = 0;
long subRoomId = 0;
try
{
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (roomItem is not null)
{
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
}
}
catch { /* roomId stays 0 — we'll log it and bail later */ }
// Scenes is an IL2CPP List<RoomScene>. Take the first
// (current dorm has one scene; multi-scene rooms target
// Scenes[currentSubRoomId] specifically, but the post-save
// push always carries the scene that was just saved).
object? firstScene = null;
try
{
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
}
catch { /* leave firstScene null */ }
if (firstScene is null) return;
string? newBlob = null;
try
{
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (sceneItem is not null)
{
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
newBlob = blob?.ToString();
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
}
}
catch { /* leave newBlob null */ }
if (string.IsNullOrEmpty(newBlob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = newBlob;
DebugMod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
}
}
private static object? TryGet(object dict, MethodInfo getItem, string key)
{
try { return getItem.Invoke(dict, new object[] { key }); }
catch { return null; }
}
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
// path only when the new value differs from the cached field. The
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
// save it calls the setter with the OLD blob — a no-op that skips
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
// pointed at the boot-time bytes, and PostSaveReloading deserializes
// those over the live world — wiping any MakerPen object the user
// just placed.
//
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
// after b__4 returns True, even though the SubscriptionUpdateRoom
// push already carried Scenes[0].DataBlobName="v11".
//
// Strategy: intercept the setter. If the incoming value matches the
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
// newer version (the freshly-pushed one), substitute that into the
// value parameter so the real setter sees a transition and fires
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
// Falls through silently when no override is needed.
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
{
string currentForLog = "<unread>";
string sceneForLog = "<unread>";
string scenes0ForLog = "<unread>";
try
{
if (__instance is null)
{
DebugMod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
return;
}
var rpmType = __instance.GetType();
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (blobNameProp is null)
{
DebugMod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
return;
}
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
var roomsType = FindGameType("RecNet.Rooms");
if (roomsType is null)
{
DebugMod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
return;
}
var localScene = roomsType.GetProperty("LocalRoomScene",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localScene is not null)
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
var localDetails = roomsType.GetProperty("LocalRoomDetails",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localDetails is not null)
{
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
if (scenes is not null)
{
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
var first = indexer?.Invoke(scenes, new object[] { 0 });
if (first is not null)
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
}
}
// Unconditional trace of EVERYTHING we read so we can see in
// the log exactly why we did or didn't override.
DebugMod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
// Pick a candidate "newer" blob name. Priority order:
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
// cache gate drops the push).
// 2. LocalRoomScene.DataBlobName.
// 3. Scenes[0].DataBlobName.
string? pushedBlob = s_lastPushedBlobName;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
if (string.IsNullOrEmpty(pushedBlob)) return;
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
DebugMod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
value = pushedBlob;
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
}
}
private static Type? FindGameType(string name) => DebugMod.ResolveType(name);
}
internal static class QuitTracePatches
{
public static bool ApplicationQuit_Prefix()
{
Write("[quit-trace] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
WriteStack();
if (exitCode == 1550502249 || exitCode == 533223478)
{
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
return false;
}
return true;
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
WriteStack();
return true;
}
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
WriteStack();
return true;
}
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
WriteStack();
return true;
}
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
WriteStack();
}
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
WriteStack();
}
private static string FormatOriginal(MethodBase method)
{
return $"{method.DeclaringType?.FullName}.{method.Name}";
}
private static void Write(string message)
{
try { DebugMod.Log.Msg(message); } catch { }
}
private static void WriteStack()
{
try
{
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[quit-trace-stack] failed: " + ex.Message);
}
}
}
internal static class DiagnosticPatches
{
public static void Write(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
try { DebugMod.Log.Msg(line); } catch { }
try
{
if (!string.IsNullOrEmpty(DebugMod.DiagnosticsLogPath))
File.AppendAllText(DebugMod.DiagnosticsLogPath, line + Environment.NewLine);
}
catch { }
}
public static bool ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
return true;
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static void NamedMethod_Prefix(MethodBase __originalMethod)
{
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
return true;
}
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
WriteStack();
return true;
}
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
WriteStack();
return true;
}
private static void WriteStack()
{
try
{
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[stack] failed: " + ex.Message);
}
}
}
internal static class ChatPatches
{
private static bool s_logged;
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
// before raising the in-room Photon chat RPC; if it returns false
// the coroutine bails silently and the sender doesn't even see
// their own message echo.
public static void CanLocalPlayerChat_Postfix(ref bool __result)
{
if (!__result && !s_logged)
{
s_logged = true;
DebugMod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
}
__result = true;
}
// Skip the original. Returning false from a Harmony prefix stops
// the underlying call.
public static bool RemoveInvalidCharactersFromMessage_Prefix()
{
DebugMod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
return false;
}
public static void SendChatEmote_Prefix(string message)
{
DebugMod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
}
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
{
DebugMod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
}
public static void RpcChatEmote_Prefix(string message)
{
DebugMod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
+56
View File
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
The shipping ClientMod carries ONLY the patches needed to connect and
survive anti-cheat. ALL of the verbose diagnostic/tracing code (HTTP
tracing, join/quit/studio/room-load traces, the process-exception sink,
auth-token tracing, the registration-dialog trace, the in-game
DebugConsole enable path) lives in THIS mod. Drop this dll into the
Mods folder next to DorkNet.ClientMod.dll to debug; remove it to ship a
quiet, minimal client.
Fully self-contained: its own Harmony instance + its own copy of the
reflection/patch helpers, so it never couples to ClientMod at load time.
Debug knobs are read from the same UserData\dorknet-clientmod.json.
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
-->
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DebugMod</RootNamespace>
<AssemblyName>DorkNet.DebugMod</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
<PropertyGroup>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
</PropertyGroup>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-290
View File
@@ -1,290 +0,0 @@
using HarmonyLib;
using Il2CppRecRoom.Debugging;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using UnityEngine;
using UObject = UnityEngine.Object;
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DevMenu;
public class DevMenuMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DevMenuMod? Instance;
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
public static class Cfg
{
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static bool DiagnoseDevMenu = false;
public static bool EnableDevWatchButton = false;
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DevMenu loading ===");
LoadConfig();
if (Cfg.EnableDebugConsole)
{
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
}
public override void OnUpdate()
{
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
}
private static void LoadConfig()
{
try
{
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
Directory.CreateDirectory(userData);
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
var path = File.Exists(devPath) ? devPath : legacyPath;
if (!File.Exists(path))
{
Log.Msg($"[config] no dev config found; all dev menu features disabled");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
}
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
}
}
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
{
try
{
var type = ResolveType(typeName);
if (type is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
return false;
}
var method = args is null
? AccessTools.Method(type, methodName)
: AccessTools.Method(type, methodName, args);
if (method is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
return false;
}
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
Instance!.PatchHarmony.Patch(method, pre, post);
Log.Msg($"[patch-ok] {typeName}.{methodName}");
return true;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found");
}
internal static Type? ResolveType(string requested)
{
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
Type? found = null;
try
{
found = asm.GetType(requested, throwOnError: false)
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
if (found is null)
{
foreach (var t in asm.GetTypes())
{
if (t.FullName == requested ||
t.Name == requested ||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
{
found = t;
break;
}
}
}
}
catch { }
if (found is null) continue;
ResolvedTypeCache[requested] = found;
return found;
}
return null;
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _warned;
private static KeyCode _toggleKey = KeyCode.BackQuote;
private static bool _keyParsed;
private static MethodInfo? _toggleMethod;
public static bool SuppressCheatDetected_Prefix() => false;
public static void PollToggleKey()
{
try
{
if (!_keyParsed)
{
_keyParsed = true;
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
{
_toggleKey = KeyCode.BackQuote;
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
}
}
if (Input.GetKeyDown(_toggleKey))
{
Toggle();
}
}
catch (Exception ex)
{
if (!_warned)
{
_warned = true;
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
}
}
}
private static void Toggle()
{
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
if (console == null)
{
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
return;
}
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(typeof(DebugConsole));
if (_toggleMethod is null)
{
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
return;
}
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
}
}
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null;
found = m;
}
return found;
}
}
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 180) return;
_done = true;
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
}
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
}
}
-124
View File
@@ -1,124 +0,0 @@
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
// we use real types here: clone an existing watch Button3D, relabel it, and
// wire its OnClick to run console commands via DebugConsole.Execute (which
// works headless even though the console UI isn't in the scene). It's the
// game's own Button3D, so it's interactable with the VR laser AND the
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
using System;
using Il2CppInterop.Runtime;
using Il2CppAGUI.StackedUI;
using Il2CppRecRoom.Debugging;
using Il2CppTMPro;
using UnityEngine.Events;
using UObject = UnityEngine.Object;
namespace DorkNet.DevMenu;
internal static class DevWatchButton
{
private static int _frame;
private static bool _done;
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
// the watch's HomeScreenFlow to exist, then injects once.
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
var home = UObject.FindObjectOfType<HomeScreenFlow>();
if (home == null && _frame < 3600) return; // wait for the watch
_done = true;
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
try { Inject(home); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
}
private static void Inject(HomeScreenFlow home)
{
var buttons = home.GetComponentsInChildren<Button3D>(true);
if (buttons == null || buttons.Length == 0)
{
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
return;
}
// Prefer a source button that HAS a TMP label, so our clones can be
// relabeled (HomeButton etc. are icon-only — no text child).
Button3D? src = null;
for (int i = 0; i < buttons.Length; i++)
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
bool labeled = src != null;
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
int n = 0;
foreach (var (label, cmd) in ParsePresets())
{
var clone = UObject.Instantiate(src, src.transform.parent);
clone.name = $"DorkNetDev_{label}";
clone.gameObject.SetActive(true);
clone.transform.localScale = src.transform.localScale;
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null) tmp.text = label;
var command = cmd; // capture per-button
clone.OnClick.RemoveAllListeners();
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
_clones.Add(clone);
n++;
}
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
DumpCommands();
}
// Log the real console command names from DebugConsoleCommandConfig.Metas
// so we can set DevCommands correctly (the asset loads even though the
// console component isn't in the scene).
private static void DumpCommands()
{
try
{
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
for (int c = 0; c < cfgs.Length; c++)
{
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
var metas = cfg?.Metas;
if (metas == null) continue;
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
for (int i = 0; i < metas.Count; i++)
{
var m = metas[i];
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
}
}
}
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
}
private static void Run(string label, string command)
{
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
try { DebugConsole.Execute(command); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
}
// Presets come from Cfg.DevCommands as "Label=command" strings.
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
{
foreach (var entry in DevMenuMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var eq = entry.IndexOf('=');
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
else yield return (entry.Trim(), entry.Trim());
}
}
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
}
-68
View File
@@ -1,68 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DevMenu</RootNamespace>
<AssemblyName>DorkNet.DevMenu</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<PropertyGroup>
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
</PropertyGroup>
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -1,11 +0,0 @@
{
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DiagnoseDevMenu": false,
"EnableDevWatchButton": false,
"DevCommands": [
"Help=help",
"Fly=fly",
"NoClip=noclip"
]
}
-41
View File
@@ -26,20 +26,6 @@ var app = builder.Build();
app.MapDorkNetServiceDefaults();
var publicHosts = BuildPublicHosts(apexDomain);
app.Use(async (ctx, next) =>
{
if (DorkNetRouteOwnership.IsInfrastructurePath(ctx.Request.Path.Value ?? "/")
|| IsAllowedPublicHost(ctx.Request.Host.Host, publicHosts))
{
await next();
return;
}
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
await ctx.Response.WriteAsync("Unknown DorkNet host.");
});
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () =>
@@ -204,33 +190,6 @@ static string[] WebBrowserHosts(string apexDomain)
return [apexDomain, SubdomainHost("www", apexDomain)];
}
static HashSet<string> BuildPublicHosts(string apexDomain)
{
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
apexDomain,
"localhost",
"127.0.0.1",
};
foreach (var subdomain in DorkNetRouteOwnership.PublicSubdomains)
{
hosts.Add(SubdomainHost(subdomain, apexDomain));
}
return hosts;
}
static bool IsAllowedPublicHost(string? host, HashSet<string> publicHosts)
{
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return publicHosts.Contains(host.Split(':', 2)[0].Trim());
}
static void AddHostRoute(
List<RouteConfig> routes,
ref int order,
@@ -0,0 +1,76 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
Google.Protobuf FileDescriptor that the running client has loaded.
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
build (2020, 2023, …) without the version-specific ClientMod patches
misfiring. Everything game-side is reached through plain
System.Reflection over the Il2CppInterop proxy assemblies, so no game
DLLs are referenced at compile time (they would be derivative works).
Build (desktop net6 MelonLoader, same pin as the ClientMod):
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
MelonLoader-patched build and launch. Output lands in
<RecRoom>\DorkNet-ProtoDump\.
-->
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
+378
View File
@@ -0,0 +1,378 @@
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
//
// Rec Room compiles its room-save / creation / circuits data model from
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
// those classes are name-obfuscated and the descriptor base64 is split into
// scattered string-literal chunks, so static reconstruction from
// global-metadata.dat is unreliable.
//
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
// FileDescriptorProto (message names, field names, numbers, types intact).
// The catch: these descriptors are built LAZILY and in a specific dependency
// order by the game. Force-building them ourselves (by reading a message
// type's static descriptor property out of order) throws inside the type's
// static constructor and the failure is cached — poisoning the type. So we do
// NOT enumerate types. Instead we Harmony-postfix the one library method every
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
// builds it. Completely passive: just play the client through the dorm, a
// created room, the Maker Pen and a circuits board, and every descriptor that
// loads is recorded.
//
// Output (under <RecRoom>\DorkNet-ProtoDump\):
// manifest.tsv — one "<proto path>\t<base64>" line per file
// files\<sanitised>.b64 — same base64, one file each
// dump.log — capture log
//
// Decode offline with decode_dump.py (see README.md).
using HarmonyLib;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.ProtoDumper;
public class ProtoDumper : MelonMod
{
private static MelonLogger.Instance? _log;
// proto file name -> descriptor base64 (accumulated as the game builds them)
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
private static readonly object Gate = new();
private static string _outDir = "";
private static string _filesDir = "";
private static volatile bool _dirty;
private int _frame;
// Interception heartbeat: how many times any hooked method actually ran.
// Lets us distinguish "hook never fires (inlined/wrong target)" from
// "content that builds protos hasn't loaded yet".
private static long _fireCount;
private static bool _firstFireLogged;
public override void OnInitializeMelon()
{
_log = LoggerInstance;
try
{
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
_filesDir = Path.Combine(_outDir, "files");
Directory.CreateDirectory(_filesDir);
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
}
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
InstallHooks();
}
private void InstallHooks()
{
try
{
// Google.Protobuf library type — NOT obfuscated (it's the library,
// not game code), reached by its original name via every loaded
// assembly so we never reference the interop DLL at compile time.
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
if (fd == null)
{
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
return;
}
var postfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
BindingFlags.Public | BindingFlags.Static));
var ctorPostfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
BindingFlags.Public | BindingFlags.Static));
int patched = 0;
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
// is built via `newobj` through this ctor, which is NOT an inlining
// candidate the way the small static BuildFrom/FromGeneratedCode
// helpers are (those get AOT-inlined into each generated cctor, so a
// Harmony patch on the managed wrapper never fires — the real work
// runs as inlined native code). The ctor stores SerializedData /
// Proto / Name before it returns, so __instance carries the full
// descriptor bytes.
foreach (ConstructorInfo c in fd.GetConstructors(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var ps = c.GetParameters();
// Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg).
if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue;
try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); }
}
// SECONDARY hooks (kept as a belt-and-braces net in case the ctor is
// itself inlined on some build): BuildFrom / FromGeneratedCode.
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
{
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (m.Name != name) continue;
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
}
}
// STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once
// per message/enum/service symbol while a descriptor is built. Unlike
// the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does
// real work (name validation + dictionary insert + duplicate-throw),
// so the AOT compiler does NOT inline it away — the managed patch
// actually fires. From the symbol we walk `.File` to the owning
// FileDescriptor and collect its serialized bytes. We also stash the
// pool so ScanPools() can later enumerate everything it holds.
Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool");
if (pool != null)
{
var symPostfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix),
BindingFlags.Public | BindingFlags.Static));
foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue;
try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); }
}
}
else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped.");
_log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'.");
}
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
}
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
// "Google.Protobuf.Reflection.FileDescriptor" ->
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
private static Type? ResolveType(string name)
{
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
if (t != null) return t;
int dot = name.IndexOf('.');
if (dot > 0)
{
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
if (ns != null) return ns;
}
}
catch { }
}
return null;
}
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
// object — same pattern the ClientMod uses for il2cpp reference returns).
public static void Captured_Postfix(object? __result)
{
Fired();
if (__result == null) return;
try { CollectFile(__result); }
catch { /* never let our hook disturb the game */ }
}
// Postfix on the FileDescriptor instance ctor — __instance is the freshly
// constructed descriptor (SerializedData/Proto/Name already set).
public static void Ctor_Postfix(object? __instance)
{
Fired();
if (__instance == null) return;
try { CollectFile(__instance); }
catch { /* never let our hook disturb the game */ }
}
// Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can
// enumerate every descriptor a pool holds (already-built → no force-build).
private static readonly List<object> Pools = new();
// Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool,
// __0 is the IDescriptor symbol being registered. Walk symbol.File to the
// owning FileDescriptor and collect it.
public static void AddSymbol_Postfix(object? __instance, object? __0)
{
Fired();
try
{
if (__instance != null)
{
lock (Gate)
{
bool known = false;
foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; }
if (!known) Pools.Add(__instance);
}
}
if (__0 == null) return;
object? file = GetProp(__0, "File");
if (file != null) CollectFile(file);
}
catch { /* never let our hook disturb the game */ }
}
// Interception heartbeat — proves a hooked method actually ran. Logged once
// on first fire, so "no output" can be diagnosed as inlining (never fires)
// vs. content-not-loaded (fires but no proto-building content yet).
private static void Fired()
{
long n = System.Threading.Interlocked.Increment(ref _fireCount);
if (!_firstFireLogged)
{
_firstFireLogged = true;
_log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live.");
}
}
private static void CollectFile(object file)
{
string? name = GetProp(file, "Name") as string;
if (string.IsNullOrEmpty(name)) return;
lock (Gate)
{
if (Files.ContainsKey(name!)) return;
string b64 = DescriptorBase64(file);
Files[name!] = b64;
_dirty = true;
}
// Capture import dependencies too (closure of well-known + leaf protos).
try
{
object? deps = GetProp(file, "Dependencies");
if (deps != null)
{
var dt = deps.GetType();
var countP = dt.GetProperty("Count");
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
if (countP != null && itemM != null)
{
int n = (int)(countP.GetValue(deps) ?? 0);
for (int i = 0; i < n; i++)
{
object? dep = itemM.Invoke(deps, new object[] { i });
if (dep != null) CollectFile(dep);
}
}
}
}
catch { }
}
private static string DescriptorBase64(object file)
{
try
{
// FileDescriptor exposes no public SerializedData property — only
// the Il2CppInterop backing field (a ByteString with the original
// descriptor bytes). Fall back to re-serialising the parsed Proto.
object? sd = GetProp(file, "_SerializedData_k__BackingField")
?? GetProp(file, "SerializedData");
if (sd != null)
{
string b = ByteStringToBase64(sd);
if (b.Length > 0) return b;
}
object? proto = GetProp(file, "Proto");
if (proto != null)
{
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
object? bs = toBs?.Invoke(proto, null);
if (bs != null) return ByteStringToBase64(bs);
}
}
catch { }
return "";
}
private static string ByteStringToBase64(object byteString)
{
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
return toB64?.Invoke(byteString, null) as string ?? "";
}
private static object? GetProp(object obj, string propName)
{
var p = obj.GetType().GetProperty(propName,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
return p?.GetValue(obj);
}
private long _lastReportedFire = -1;
public override void OnUpdate()
{
// Flush to disk shortly after new descriptors are captured (every ~1s).
if (++_frame % 60 == 0 && _dirty)
Flush();
// Every ~5s, if the hooks fired since last report, log the running
// fire/capture counts — surfaces "fired but 0 files" (empty base64)
// separately from "never fired".
if (_frame % 300 == 0)
{
long f = System.Threading.Interlocked.Read(ref _fireCount);
if (f != _lastReportedFire)
{
_lastReportedFire = f;
int files; lock (Gate) files = Files.Count;
_log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}");
}
}
}
public override void OnApplicationQuit() => Flush();
private static void Flush()
{
lock (Gate)
{
if (!_dirty) return;
_dirty = false;
try
{
var sb = new StringBuilder();
int withData = 0;
foreach (var kv in Files)
{
if (kv.Value.Length == 0) continue;
withData++;
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
}
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
string line = $"captured files={Files.Count} (with-data={withData})";
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
}
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
}
}
private static string Sanitize(string protoPath)
{
var sb = new StringBuilder(protoPath.Length);
foreach (char c in protoPath)
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
return sb.ToString();
}
}
+81
View File
@@ -0,0 +1,81 @@
# DorkNet ProtoDumper
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
creation / circuits data model). Produces clean `.proto` files for any
client build — including obfuscated ones like 2023.03.21 where static
reconstruction from `global-metadata.dat` is unreliable (the descriptor
base64 is split into scattered string-literal chunks whose concat order
only lives in IL).
At runtime every `FileDescriptor` still carries its original,
un-obfuscated `FileDescriptorProto` (real message names, field names,
numbers and types intact). The catch: Rec Room builds these descriptors
**lazily and in a specific dependency order** as content loads. Trying to
force-build them by reading a message type's static descriptor out of
order throws inside the type's static constructor, and the failure is
cached — *poisoning* the type (and crashing the client). So this mod does
**not** enumerate types.
Instead it Harmony-postfixes the one library method every generated cctor
funnels through —
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
`.FromGeneratedCode` — and records each descriptor as the game builds it.
Completely passive: **play the client through the content you care about**
and every descriptor that loads is captured.
## Pipeline
1. **Build the mod**
```sh
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
```
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
> **offline-analysis payload, not a runnable build**. Use a full build
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
installs the descriptor-build hooks (see `dump.log` / MelonLoader
console for "installed N descriptor-build hooks").
- **Log in and play through the content whose schema you want:** your
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
subsystem builds its protos the first time it loads. Descriptors are
captured the instant they build — no scene/timer scanning, nothing is
force-built, so the client stays stable.
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
disk ~1 s after new descriptors load, and again on quit:
- `manifest.tsv` — `<proto path>\t<base64>` per file
- `files\*.b64` — same, one file each
- `dump.log` — running capture count
3. **Decode to .proto**
```sh
pip install protobuf
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
```
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
file) for an easy diff against
`DorkNet.Server/Protos/recroom_2020.proto`.
## Notes
- No game DLLs are referenced at compile time. `FileDescriptor` is the
Google.Protobuf *library* type (not obfuscated); it's reached by its
original name with the Il2Cpp namespace-prefix fallback, and its members
are read via reflection. The same DLL works across builds and stays clean
of derivative game code.
- The only Harmony patches are the two read-only descriptor-build
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
- The descriptor base64 is the same `FileDescriptorProto` wire format
Google's own tooling emits, so `decode_dump.py` (or `protoc
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
directly.
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Decode a DorkNet ProtoDumper manifest into .proto text.
Usage:
python decode_dump.py manifest.tsv [out_dir]
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
recroom_2023.proto with every rec_room/circuits message in one file for an
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
Requires: pip install protobuf
"""
import sys, os, base64, re
from google.protobuf import descriptor_pb2 as D
FDP = D.FileDescriptorProto
# proto3 scalar type names keyed by FieldDescriptorProto.Type
SCALAR = {
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
}
LABEL_REPEATED = 3
def type_name(f):
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
return f.type_name.lstrip(".")
return SCALAR.get(f.type, f"type{f.type}")
def emit_enum(e, indent):
pad = " " * indent
out = [f"{pad}enum {e.name} {{"]
for v in e.value:
out.append(f"{pad} {v.name} = {v.number};")
out.append(f"{pad}}}")
return out
def emit_message(m, indent=0):
pad = " " * indent
out = [f"{pad}message {m.name} {{"]
# map<> detection: synthetic nested *Entry types with map_entry option
map_entries = {}
for nt in m.nested_type:
if nt.options.map_entry:
kt = type_name(nt.field[0])
vt = type_name(nt.field[1])
map_entries[nt.name] = (kt, vt)
# which oneofs are real (not proto3 optional synthetic)
for nt in m.nested_type:
if nt.name in map_entries:
continue
out += emit_message(nt, indent + 1)
for en in m.enum_type:
out += emit_enum(en, indent + 1)
# group fields by oneof
printed_oneof = set()
for f in m.field:
# map field?
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
kt, vt = map_entries[f.type_name.split(".")[-1]]
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
continue
if f.HasField("oneof_index") and not f.proto3_optional:
idx = f.oneof_index
if idx not in printed_oneof:
printed_oneof.add(idx)
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
for g in m.field:
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
out.append(f"{pad} }}")
continue
label = "repeated " if f.label == LABEL_REPEATED else ""
opt = "optional " if f.proto3_optional else ""
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
out.append(f"{pad}}}")
return out
def render_file(fdp):
out = []
syntax = fdp.syntax or "proto2"
out.append(f'syntax = "{syntax}";')
out.append("")
if fdp.package:
out.append(f"package {fdp.package};")
for dep in fdp.dependency:
out.append(f'import "{dep}";')
if fdp.package or fdp.dependency:
out.append("")
for en in fdp.enum_type:
out += emit_enum(en, 0)
out.append("")
for m in fdp.message_type:
out += emit_message(m, 0)
out.append("")
return "\n".join(out).rstrip() + "\n"
def main():
if len(sys.argv) < 2:
print(__doc__); sys.exit(1)
manifest = sys.argv[1]
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
os.makedirs(out_dir, exist_ok=True)
files = []
with open(manifest, "r", encoding="utf-8") as fh:
for ln in fh:
ln = ln.rstrip("\n")
if not ln or "\t" not in ln:
continue
name, b64 = ln.split("\t", 1)
try:
raw = base64.b64decode(b64)
fdp = FDP(); fdp.ParseFromString(raw)
except Exception as e:
print(f" ! failed to parse {name}: {e}")
continue
files.append((name, fdp))
print(f"parsed {len(files)} descriptors")
# per-file .proto
for name, fdp in files:
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
w.write(render_file(fdp))
# combined rec_room/circuits view (skip google/* well-known imports)
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
total_msgs = 0
for name, fdp in sorted(rr, key=lambda x: x[0]):
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
for m in fdp.message_type:
if m.name in seen:
continue
seen.add(m.name)
combo += emit_message(m, 0)
combo.append("")
total_msgs += 1
for en in fdp.enum_type:
combo += emit_enum(en, 0)
combo.append("")
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
w.write("\n".join(combo).rstrip() + "\n")
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
f"from {len(rr)} rec_room/circuits descriptors")
if __name__ == "__main__":
main()
@@ -0,0 +1,154 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression: admin "pull a player into an instance" broke because the
/// instance id (an int64 well past JS's 2^53 safe-integer range) was
/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
/// nearest double, so the pull URL targeted a non-existent instance and
/// the server returned <c>instance_not_active</c>. The fix serializes the
/// id as a STRING so the exact digits survive the browser round-trip.
/// </summary>
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
{
// A dorm-style instance id past 2^53 — the value class that a JS
// Number cannot represent exactly.
private const long BigInstanceId = 7597291079085522944L;
private readonly DorkNetServerFactory _factory;
public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
{
using var setup = ApiClient();
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long roomId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
// Promote the caller to admin (AdminOnly reads IsAdmin from DB).
var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
adminRow.IsAdmin = true;
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
await db.SaveChangesAsync();
// Put the streamer in a live instance carrying the huge id.
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
{
RoomInstanceId = BigInstanceId,
RoomId = roomId,
SubRoomId = 0,
PhotonRoomId = "^teststreamerinstance",
PhotonRegionId = "us",
MaxCapacity = 8,
});
presence.MarkActive(streamer.PlayerId);
}
using var adminClient = ApiClient(admin);
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
// The list must emit roomInstanceId as a STRING equal to the exact
// digits — never a number (which would already be rounded here).
var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
var row = list.EnumerateArray()
.First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
// Pull the second player in using the EXACT id from the list. This
// is what the SPA does; with the string round-trip the digits are
// intact and the instance matches (pre-fix: 404 instance_not_active).
var idFromList = row.GetProperty("roomInstanceId").GetString()!;
using var pull = await adminClient.PostAsync(
$"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
// The pulled player now shares the streamer's instance.
await using var verify = _factory.Services.CreateAsyncScope();
var presenceCheck = verify.ServiceProvider.GetRequiredService<PlayerPresenceService>();
Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
}
[Fact]
public async Task Pull_with_precision_mangled_id_is_rejected()
{
using var setup = ApiClient();
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long roomId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
await db.SaveChangesAsync();
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
{
RoomInstanceId = BigInstanceId,
RoomId = roomId,
PhotonRoomId = "^teststreamerinstance2",
PhotonRegionId = "us",
MaxCapacity = 8,
});
presence.MarkActive(streamer.PlayerId);
}
using var adminClient = ApiClient(admin);
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
// The value the admin SPA's JSON.parse actually produced for this
// id (from the bug report log) — the exact failure mode.
const long mangled = 7597291079085523000L;
Assert.NotEqual(BigInstanceId, mangled);
using var pull = await adminClient.PostAsync(
$"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using DorkNet.Server.Data;
namespace DorkNet.Server.Tests;
public sealed class CanonicalRoomBootstrapTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name()
{
using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false });
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner");
var stuntRunnerScenes = await db.RoomScenes
.Where(s => s.RoomId == stuntRunner.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
Assert.Collection(
stuntRunnerScenes,
scene =>
{
Assert.Equal(0, scene.OrderIndex);
Assert.Equal("StuntRunner", scene.Name);
Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId);
Assert.Equal("", scene.DataBlobName);
},
scene =>
{
Assert.Equal(1, scene.OrderIndex);
Assert.Equal("TheMainEvent", scene.Name);
Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId);
Assert.Equal("", scene.DataBlobName);
});
var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally");
Assert.Equal("image_RecRally.jpg", recRally.ImageName);
}
}
@@ -0,0 +1,88 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// End-to-end coverage for the 3D Charades word-list feature and the
/// profanity-filter toggle. Boots the full server (which runs the seeder)
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
/// public wire endpoints the March 2023 client actually calls.
/// </summary>
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("Charades")] // enum-name form
[InlineData("CharadesAprilFoolsDay")]
[InlineData("Icebreakers")]
[InlineData("0")] // int form the client may send
[InlineData("1")]
[InlineData("2")]
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
{
using var client = ApiClient();
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
var first = doc.RootElement[0];
// Exact wire key casing the client's LitJson importer reads.
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
"each card must expose a string EN_US field");
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
"each card must expose a numeric Difficulty field");
}
[Fact]
public async Task December_noparam_route_serves_the_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Unknown_source_falls_back_to_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Profanity_filter_is_active_by_default()
{
using var client = ApiClient();
using var res = await client.PostAsync(
"/api/sanitize/v1/requestIsStringPure",
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = (await res.Content.ReadAsStringAsync()).Trim();
// Default state: filter on → the profane string is NOT pure.
Assert.Equal("false", body.ToLowerInvariant());
}
}
@@ -0,0 +1,88 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// Starting a chat with a player is a two-step handshake, and both steps have to
/// work on a thread that has never carried a message:
///
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
/// GET thread/{id} -> opens it
///
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
/// GET 404'd 415ms later, because the handler decided a thread existed by
/// counting its messages. A brand-new conversation has none, so the client was
/// handed a thread id it was then told did not exist, and no chat could ever be
/// opened with someone you had not already messaged.
/// </summary>
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
{
using var starterClient = Client();
using var otherClient = Client();
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
starterClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
// Step 1 — the client asks for the thread with that player. It sends the
// ids as a form body (observed on the wire as
// "ids=9741717&messageCount=50"), which also matters for routing: the
// content type is what separates this from POST thread/{chatThreadId},
// whose parameter segment otherwise also matches "withmembers".
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("messageCount", "50"),
]);
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
var createBody = await createResponse.Content.ReadAsStringAsync();
Assert.True(createResponse.IsSuccessStatusCode,
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
var thread = JsonDocument.Parse(createBody).RootElement;
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|| thread.TryGetProperty("chatThreadId", out idElement),
$"no thread id in the withmembers reply: {createBody}");
var threadId = idElement.ValueKind == JsonValueKind.String
? idElement.GetString()
: idElement.ToString();
Assert.False(string.IsNullOrWhiteSpace(threadId));
// Step 2 — and immediately opens it. No message has been sent yet.
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
var openBody = await openResponse.Content.ReadAsStringAsync();
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
$"""
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
had just created. An empty thread is not a missing thread — the chat
cannot be opened until someone has already sent a message, which is
impossible to do first.
""");
Console.WriteLine("=== WITHMEMBERS ===");
Console.WriteLine(createBody);
Console.WriteLine("=== GET THREAD ===");
Console.WriteLine(openBody);
Assert.True(openResponse.IsSuccessStatusCode,
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
}
private HttpClient Client()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
return client;
}
}
@@ -0,0 +1,202 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
/// generated readers register — no missing ones, no invented ones.
///
/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
/// does not produce a bad request: the server answers HTTP 200, the reader
/// throws while deserialising, and it surfaces later as a bare "Failed to copy
/// room" with no URL and no payload logged on either side. By the time it is
/// visible there is nothing left to diagnose it with.
///
/// The expected sets are extracted straight from the binary by
/// <c>tools/extract-client-dto-keys.py</c> into
/// <c>data/client-dto-keys-2023.json</c>, keyed by the obfuscated reader type.
/// Cpp2IL emits each reader's property names as string literals in registration
/// order, so that file is the client's own contract rather than our reading of
/// it — regenerate it rather than editing it by hand.
/// </summary>
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
private const string RoomDetailsReader = "GLEGPFFPDBE";
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
private const string ChatThreadReader = "EKEFFNBIOHJ";
/// <summary>Keys the reader registers that we deliberately do NOT send.
///
/// A registered slot does not oblige us to fill it — Newtonsoft leaves an
/// absent key at its default. These two were added for parity and broke
/// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
/// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
/// per SUB-ROOM. A room-scoped answer to the same question is one the
/// client preferred, and for a dorm it named a file that was never written,
/// so the room came back "corrupt".
///
/// Every room loaded fine while these were absent. Their absence is the
/// fix, not a gap to close later — do not "restore parity" here without a
/// dorm join to prove it.</summary>
private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
[
(RoomDetailsReader, "DataBlob"),
(RoomDetailsReader, "DataBlobHash"),
];
[Fact]
public async Task Room_details_carries_exactly_the_keys_the_client_reads()
{
using var client = RoomsClient();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Parity{Guid.NewGuid():N}"[..18],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
await db.SaveChangesAsync();
}
using var response = await client.GetAsync($"/rooms/{roomId}");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
// The reader walks into the nested objects too, and a wrong key set
// there fails exactly the same way — a 200 the client throws on.
var details = JsonDocument.Parse(body).RootElement;
foreach (var (property, reader) in NestedReaders)
{
if (!details.TryGetProperty(property, out var value)) continue;
var element = value.ValueKind switch
{
JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
JsonValueKind.Object => value,
_ => default,
};
if (element.ValueKind != JsonValueKind.Object) continue;
AssertKeyParity(reader, element.GetRawText(),
$"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
}
}
/// <summary>Nested objects in the room-details payload and the reader each
/// one is deserialised by. Sub-room 0, the first role, the stats object and
/// the first tag all have their own generated readers, and a key set that
/// disagrees with them breaks the whole room just as hard as a wrong
/// top-level one.</summary>
private static readonly (string Property, string Reader)[] NestedReaders =
[
("SubRooms", "JDEKAFDPGBE"),
("Roles", "LMEECKCCPHG"),
("Stats", "MLHBPDKBABN"),
("Tags", "GEGADPCDGBO"),
];
[Fact]
public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
{
using var client = ChatClient();
using var otherClient = ChatClient();
var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("messageCount", "50"),
]);
using var response = await client.PostAsync("/thread/withmembers", form);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
}
/// <summary>Compare one response object's keys against the reader's set.
/// Matching is case-insensitive because the readers register three spellings
/// per field (Pascal, camel, lower) and accept any of them — casing is the
/// one thing they are NOT strict about.</summary>
private static void AssertKeyParity(string readerType, string body, string what)
{
var expected = LoadReaderKeys(readerType)
.Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
.ToList();
var actual = JsonDocument.Parse(body).RootElement
.EnumerateObject().Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = expected.Where(k => !actual.Contains(k)).ToList();
var extra = actual
.Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert.True(missing.Count == 0 && extra.Count == 0,
$"""
{what} does not match what the client's {readerType} reader expects.
MISSING ({missing.Count}) — the reader registers these and we never send them;
a non-nullable field left null is what throws mid-deserialise:
{string.Join(", ", missing)}
UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
{string.Join(", ", extra)}
Expected, in the reader's own registration order:
{string.Join(", ", expected)}
""");
}
private static List<string> LoadReaderKeys(string readerType)
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
Assert.True(File.Exists(path),
$"missing {path} — regenerate with tools/extract-client-dto-keys.py");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
$"{readerType} is not in the extracted catalogue");
return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
}
private HttpClient RoomsClient() => ClientFor("rooms");
private HttpClient ChatClient() => ClientFor("chat");
private HttpClient ClientFor(string host)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
return client;
}
}
@@ -0,0 +1,115 @@
using System.Text.RegularExpressions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
/// a handler on this server, under the verb the client actually uses.
///
/// Rec Room is shut down, so the client can't be observed against a live server.
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
/// extracted. This test is the regression guard on that inventory: adding a
/// client route without a handler, or moving a handler to a different verb,
/// fails here rather than silently 404/405-ing a real player.
///
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
/// expected to shrink and never grow. Deleting an entry as you implement it is
/// the point — it is the project's running score against the client.
/// </summary>
public sealed class ClientRouteCoverageTests
{
/// <summary>Client routes with no handler yet. Each entry is
/// <c>VERB route</c> exactly as it appears in the inventory file.
///
/// This is EMPTY, and that is the point: every request the 2023-03-21
/// client can issue reaches a handler under the verb it actually uses. Do
/// not add an entry to make a red build green — an entry here is a promise
/// that the client gets a 404 or 405 in the wild. Implement the route, or
/// if the inventory itself is wrong (a literal that is really a cache key,
/// a deeplink, or a base the client concatenates onto), fix the inventory
/// so the claim stays honest.</summary>
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase);
[Fact]
public void Every_client_route_has_a_handler()
{
var client = LoadClientRoutes();
Assert.NotEmpty(client);
var discovered = EndpointContractDiscovery.Discover()
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
.ToList();
var server = discovered.ToHashSet();
// A bare [Route] with no verb attribute serves every verb.
var verbless = discovered
.Where(d => d.Verb == "ANY")
.Select(d => d.Pattern)
.ToHashSet(StringComparer.Ordinal);
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
var missing = new List<string>();
var wrongVerb = new List<string>();
foreach (var (verb, route, _) in client)
{
var pattern = Normalise(route);
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
// Distinguish "no handler at all" from "handler exists, wrong verb" —
// they have different causes and different fixes.
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
else missing.Add($"{verb} {route}");
}
var gaps = missing.Concat(wrongVerb)
.Where(g => !KnownGaps.Contains(g))
.OrderBy(g => g, StringComparer.Ordinal)
.ToList();
var covered = client.Count - missing.Count - wrongVerb.Count;
var pct = 100.0 * covered / client.Count;
Assert.True(gaps.Count == 0,
$"""
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
{string.Join(Environment.NewLine, gaps)}
""");
}
/// <summary>Collapse a route to a comparable shape: no host, no leading or
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
private static string Normalise(string route)
{
var r = route.Trim();
var q = r.IndexOf('?');
if (q >= 0) r = r[..q];
r = r.Trim('/', '~');
r = Regex.Replace(r, @"\{[^}]*\}", "*");
return r.ToLowerInvariant();
}
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
Assert.True(File.Exists(path), $"missing route inventory at {path}");
var rows = new List<(string, string, string)>();
foreach (var line in File.ReadLines(path))
{
if (line.Length == 0 || line[0] == '#') continue;
var parts = line.Split('\t');
if (parts.Length < 2) continue;
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
parts.Length > 2 ? parts[2].Trim() : string.Empty));
}
return rows;
}
}
@@ -17,10 +17,16 @@
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+2 -2
View File
@@ -38,8 +38,8 @@ public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
["S3:Endpoint"] = "",
["S3:AccessKey"] = "",
["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
});
});
builder.ConfigureServices(services =>
@@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.Cast<IRouteTemplateProvider>()
.ToArray();
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
// which routes ALL verbs — the CDN's unity_assets handler is the live
// example. Skipping those made the discovered table under-report the
// server, so route-coverage checks saw phantom gaps.
if (httpAttrs.Length == 0)
{
httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
.ToArray();
}
if (httpAttrs.Length == 0)
{
continue;
@@ -44,8 +57,8 @@ public static partial class EndpointContractDiscovery
foreach (var attr in httpAttrs)
{
var attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
? verbs
: new[] { "ANY" };
foreach (var httpMethod in attrMethods)
@@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests :
[Fact]
public async Task Game_client_contract_probe_reaches_every_controller_route()
{
var routes = EndpointContractDiscovery.Discover();
var routes = EndpointContractDiscovery.Discover()
.Where(r => !DestroysTheProbeSession(r))
.ToList();
Assert.NotEmpty(routes);
using var client = _factory.CreateClient(new()
@@ -48,6 +50,31 @@ public sealed class GameClientEndpointContractTests :
+ string.Join(Environment.NewLine, failures));
}
/// <summary>Routes this probe must not call, because they destroy the very
/// session it authenticates with.
///
/// The probe walks every route in order using one test player. Account
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
/// player row is gone, so every later probe fails on a foreign-key
/// violation or a 401. Excluding these keeps the failure list meaningful
/// instead of one real event cascading into dozens of phantom failures.
///
/// This is deliberately narrow: only self-destruct routes belong here, not
/// anything merely inconvenient.</summary>
private static bool DestroysTheProbeSession(EndpointContract route)
{
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
return false;
// Case-insensitive to match the method comparison above: a route
// declared "Account/v1/me" would otherwise slip through and delete
// the probe player mid-run.
var template = route.Template.Trim('/');
return template.Equals("account/me", StringComparison.OrdinalIgnoreCase)
|| template.Equals("account/v1/me", StringComparison.OrdinalIgnoreCase)
|| template.Equals("account/v2/me", StringComparison.OrdinalIgnoreCase);
}
private static async Task<EndpointProbeResult> ProbeAsync(
HttpClient client,
EndpointContract route,
@@ -61,8 +88,8 @@ public sealed class GameClientEndpointContractTests :
ToHttpMethod(route.Method),
new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method))
+4 -4
View File
@@ -21,8 +21,8 @@ public static class GameClientSessionFactory
HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
@@ -49,8 +49,8 @@ public static class GameClientSessionFactory
HttpMethod.Get,
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
userInfoRequest.Headers.Accept.ParseAdd("application/json");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest);
@@ -0,0 +1,65 @@
using System.Net;
using DorkNet.Contracts;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression coverage for the 2023 "pull a player into a private instance"
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
/// for chat party invites; the server had neither the routes nor the gateway
/// ownership entry, so those requests 404'd.
/// </summary>
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
public async Task Matchmake_pull_routes_exist(string path)
{
using var client = ApiClient();
using var res = await client.PostAsync(path, new StringContent(string.Empty));
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
// matchmaking response when the instance/invite doesn't exist, so any
// non-404 status proves the route is now mounted.
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")]
[InlineData("/matchmake/room/5")]
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")]
public void Matchmake_paths_route_to_same_service_as_goto(string path)
{
const string apex = "dork.test";
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
// matchmake/* must land on the same service that owns goto/* — before
// the fix it fell through to the Monolith default owner.
Assert.Equal(gotoOwner, owner);
}
}
@@ -0,0 +1,238 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
/// id, so the second item collided, the unlocked-items list failed to build
/// (empty wardrobe = "no owned items"), and any refetch — including the one
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
/// ("can't buy items"). Each item must carry a unique, non-zero id.
/// </summary>
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
var ids = new List<int>();
foreach (var it in items.EnumerateArray())
{
// Every 2023 key present and correctly typed.
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
var id = it.GetProperty("AvatarItemId").GetInt32();
Assert.NotEqual(0, id);
ids.Add(id);
}
// No duplicate ids — a collision is what crashed the wardrobe.
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Purchased_item_appears_owned_with_valid_shape()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
// Buy the first avatar-backed watch-storefront item.
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
.GetProperty("PurchasableItemId").GetInt32();
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
new StringContent(JsonSerializer.Serialize(new
{
StorefrontType = 3,
PurchasableItemId = purchasableId,
CurrencyType = 2,
RequestedPrice = 1,
Gift = (object?)null,
}), Encoding.UTF8, "application/json"));
buy.EnsureSuccessStatusCode();
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
// Still no duplicate ids after the new item joins the list.
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
// Buy an avatar-backed item — the server queues a post-purchase gift box.
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
.GetProperty("PurchasableItemId").GetInt32();
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
new StringContent(JsonSerializer.Serialize(new
{
StorefrontType = 3, PurchasableItemId = purchasableId,
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
}), Encoding.UTF8, "application/json"));
buy.EnsureSuccessStatusCode();
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
var gift = gifts[0];
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
// emits — the client matches on it verbatim in
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
// a bare guid missed and NRE'd ("items removed on every buy").
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
Assert.Contains(giftDesc, descs);
// Consuming the box must not add a SECOND wardrobe row for the item
// the direct purchase already granted as a bare guid.
using var consume = await client.PostAsync(
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
new StringContent("{}", Encoding.UTF8, "application/json"));
consume.EnsureSuccessStatusCode();
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
var baseGuid = giftDesc.Split(',')[0];
var occurrences = after.EnumerateArray()
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
Assert.Equal(1, occurrences);
}
[Fact]
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
// (no ",,," suffix) — the class of row that made the post-consume
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
string bareGuid;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
bareGuid = item.Slug["wardrobe-".Length..];
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0, // outfit
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
IsValid = true,
});
await db.SaveChangesAsync();
}
using var client = ApiClient(player);
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
var gift = gifts.EnumerateArray()
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
// Both desc keys are served as the 4-part form regardless of how the
// row was stored, so the client's exact-match unlock succeeds. (A
// real buy also grants the item, so v4/items contains it — covered by
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
}
[Fact]
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var settings = scope.ServiceProvider
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
}
try
{
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
// present, not just base "{guid},,," rows; and every id is unique
// (base + variants share a base guid, so a base-guid hash would
// collide → wardrobe dict crash).
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
var hasVariant = descs.Any(d =>
{
var p = d.Split(',');
return p.Length >= 2 && p[1].Length > 0;
});
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
@@ -0,0 +1,101 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// While a join is pending, the heartbeat must tell the joining player which
/// instance they are joining — never null.
///
/// Deferring the presence commit is about what everyone ELSE sees: presence
/// stays uncommitted until the join is confirmed so a failed join leaves no
/// ghost behind. It must not change what the joining player is told about
/// themselves, because the client XORs (cachedRoomInstance != null) against
/// (serverRoomInstance != null): holding the target while the server says null
/// reads as the room vanishing mid-join. The client then logs "presence
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
/// go to Invalid RoomInstance", and cancels the very join the server was
/// waiting on — so it retries until a heartbeat happens to miss the window, and
/// entering a dorm takes several attempts while looking like a load failure.
/// </summary>
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
{
using var client = Client("match");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// matchmake/dorm is what arms the deferred-commit pending join.
using var dormForm = new FormUrlEncodedContent(
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
var dormBody = await dorm.Content.ReadAsStringAsync();
Assert.True(dorm.IsSuccessStatusCode,
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
var instanceId = FindInstanceId(expectedInstance);
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
// The join has NOT been reported yet, so the commit is still deferred —
// this is exactly the window the client heartbeats in.
using var beatForm = new FormUrlEncodedContent(
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
var beatBody = await beat.Content.ReadAsStringAsync();
Assert.True(beat.IsSuccessStatusCode,
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
var presence = JsonDocument.Parse(beatBody).RootElement;
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|| presence.TryGetProperty("RoomInstance", out reported),
$"no roomInstance key in the heartbeat: {beatBody}");
Assert.True(reported.ValueKind != JsonValueKind.Null,
$"""
The heartbeat returned a null roomInstance while the player's join was
still pending. The client reads that as the room vanishing mid-join and
cancels the load it is waiting on:
{beatBody}
""");
Assert.Equal(instanceId, FindInstanceId(reported));
}
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
private static string? FindInstanceId(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object) return null;
foreach (var property in element.EnumerateObject())
{
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
return property.Value.ToString();
if (property.Value.ValueKind == JsonValueKind.Object
&& FindInstanceId(property.Value) is { } nested)
return nested;
}
return null;
}
private HttpClient Client(string host)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
return client;
}
}
@@ -0,0 +1,145 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Private-room semantics:
/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of
/// the source's visibility — the owner publishes explicitly.
/// 2. A private room has exactly ONE instance per sub-room: every
/// JoinMode (plain matchmake / new public / new private) funnels
/// into the same deterministic invite-gated instance instead of
/// nonce-forking parallel copies.
/// 3. Players who are neither the creator nor role-holders/invitees
/// are turned away with ErrorCode=4 (indistinguishable from a
/// nonexistent room, so private rooms don't leak).
/// </summary>
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Clone_is_private_by_default()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var sourceId = 9_400_100L;
await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1);
using var roomsClient = ApiClient(owner, subdomain: "rooms");
using var resp = await roomsClient.PostAsync(
$"/rooms/{sourceId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId);
Assert.Equal(0, clone.Accessibility); // private by default
}
[Fact]
public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_400_200L;
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
using var matchClient = ApiClient(owner, subdomain: "match");
var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>();
foreach (var joinMode in new[] { 0, 1, 2, 0 })
{
using var resp = await matchClient.PostAsync(
$"/matchmake/room/{roomId}",
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = joinMode.ToString() }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32());
var inst = json.RootElement.GetProperty("roomInstance");
joins.Add((
inst.GetProperty("roomInstanceId").GetInt64(),
inst.GetProperty("photonRoomId").GetString()!,
inst.GetProperty("isPrivate").GetBoolean()));
}
// Every join mode must land in the SAME single instance.
Assert.Single(joins.Select(j => j.InstanceId).Distinct());
Assert.Single(joins.Select(j => j.Photon).Distinct());
Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated"));
}
[Fact]
public async Task Private_room_turns_away_uninvited_players()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_400_300L;
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
using var strangerClient = ApiClient(stranger, subdomain: "match");
using var resp = await strangerClient.PostAsync(
$"/matchmake/room/{roomId}",
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = "0" }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
// 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden".
Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32());
}
private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"PrivRoom_{roomId}",
Description = "private-room test",
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = accessibility,
CloningAllowed = true,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Home",
});
await db.SaveChangesAsync();
}
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,127 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// The end-of-quest reward chest is the gifts/generate + gifts/consume
/// flow. This covers the load-bearing new behaviour: a gift stamped with
/// a <c>SourceStoreItemId</c> (how quest chests are built) grants exactly
/// that store item to the player's inventory when the box is opened,
/// regardless of item kind. (The room-keyed pick itself is data-driven
/// via data/quest_rewards.json and exercised once real per-quest lists
/// are configured.)
/// </summary>
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Consuming_quest_chest_grants_backing_item()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long giftId;
string wardrobeGuid;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
wardrobeGuid = item.Slug["wardrobe-".Length..];
var gift = new GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0,
AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
Message = "You earned it!",
SourceStoreItemId = item.Id,
IsValid = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
giftId = gift.Id;
}
using var client = ApiClient(player);
using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
new StringContent("{}", Encoding.UTF8, "application/json"));
consume.EnsureSuccessStatusCode();
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
Assert.NotNull(avatar);
var inv = JsonSerializer.Deserialize<List<string>>(avatar!.InventoryJson) ?? new();
Assert.Contains(wardrobeGuid, inv);
var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
Assert.True(gift.Consumed);
}
}
[Fact]
public async Task Consuming_is_idempotent_and_does_not_double_grant()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long giftId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
var gift = new GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0,
AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
SourceStoreItemId = item.Id,
IsValid = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
giftId = gift.Id;
}
using var client = ApiClient(player);
for (var i = 0; i < 2; i++)
{
using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
new StringContent("{}", Encoding.UTF8, "application/json"));
r.EnsureSuccessStatusCode();
}
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
// The wardrobe guid appears exactly once despite two consumes.
Assert.Equal(inv.Distinct().Count(), inv.Count);
}
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,95 @@
using DorkNet.Server.Services;
using Google.Protobuf;
using RecRoom.Protobuf;
namespace DorkNet.Server.Tests;
/// <summary>
/// A modern RecNet export's subroom save stamps PersistedRoomData
/// versions past what the March-2023 client knows (field 1
/// DEPRECATED_RoomPersistenceVersion, max 38; field 30
/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client
/// rejects the whole room with its "update Rec Room to visit this room"
/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the
/// canonical repro. The CDN serve path clamps both varints down; these
/// tests pin the rewrite to exactly those two fields.
/// </summary>
public sealed class RoomBlobVersionClampTests
{
private static byte[] BuildModernBlob(int deprecatedVersion, int version)
{
// Emit the wire shape a modern save actually has: version fields
// plus payload fields the 2023 schema does and doesn't know about.
using var ms = new MemoryStream();
var output = new CodedOutputStream(ms);
output.WriteTag(1, WireFormat.WireType.Varint);
output.WriteInt32(deprecatedVersion);
// Field 2: persistence_views payload (opaque here — the clamp
// must copy it verbatim).
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03));
// Field 6: sub_room_id.
output.WriteTag(6, WireFormat.WireType.Varint);
output.WriteInt64(19589779);
output.WriteTag(30, WireFormat.WireType.Varint);
output.WriteInt32(version);
// Field 37: unknown-to-2023 varint field from newer schemas.
output.WriteTag(37, WireFormat.WireType.Varint);
output.WriteInt32(1);
output.Flush();
return ms.ToArray();
}
[Fact]
public void Clamps_modern_versions_to_2023_maxima()
{
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.True(changed);
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)msg.Version);
Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion);
}
[Fact]
public void Preserves_every_non_version_byte()
{
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input);
// version=131 is a two-byte varint, 16 is one byte — the output
// shrinks by exactly that and matches the input everywhere else.
Assert.Equal(input.Length - 1, bytes.Length);
var expected = BuildModernBlob(
deprecatedVersion: 38,
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
Assert.Equal(expected, bytes);
}
[Fact]
public void Blob_already_at_2023_versions_passes_through_unchanged()
{
var input = BuildModernBlob(
deprecatedVersion: 38,
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.False(changed);
Assert.Same(input, bytes);
}
[Fact]
public void Malformed_input_is_returned_verbatim()
{
var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.False(changed);
Assert.Same(input, bytes);
}
}
@@ -0,0 +1,91 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 play-menu browse sources. The client's room-discovery URL
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
/// <c>rooms/search_rooms/{query}&amp;skip={skip}&amp;take={take}</c> —
/// note the paging pairs live INSIDE the final path segment, and the
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
/// trip int model binding.
/// </summary>
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
{
using var setupClient = Client();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var marker = $"Zz{Guid.NewGuid():N}"[..14];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = 9_300_000 + Random.Shared.Next(1, 99_999),
Name = $"Search{marker}",
Description = "2023 search test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
});
await db.SaveChangesAsync();
}
using var roomsClient = Client(owner, subdomain: "rooms");
using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
var text = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var names = json.RootElement.EnumerateArray()
.Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
.ToArray();
Assert.Contains(names, n => n.Contains(marker));
}
[Fact]
public async Task Room_images_accept_2023_enum_name_sort_and_filter()
{
using var setupClient = Client();
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
using var apiClient = Client(player);
using var response = await apiClient.GetAsync(
"/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
var text = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
}
private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+223
View File
@@ -0,0 +1,223 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
/// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
/// with the route literal on :4802).
///
/// So the two responses must be shape-identical. When they drift, the server
/// still answers 200 and the client throws while reading it — and because the
/// clone promise's exception is never observed, it surfaces from the finalizer
/// thread seconds later as a bare "Failed to copy room: Exception of type
/// '...' was thrown", with nothing in the server log to show for it. That is
/// exactly how this failed in the wild: the room WAS created, and the client
/// simply never registered it.
/// </summary>
public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Clone_response_matches_the_get_by_id_shape()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = sourceId,
Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
});
// Give the source a real sub-room. Without one, BOTH responses fall
// back to the synthesised default sub-room and the comparison never
// exercises the branch a real room (like the seeded RecCenter the
// room-creation picker clones) actually takes.
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = sourceId,
Name = "Home",
OrderIndex = 0,
});
await db.SaveChangesAsync();
}
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
// The reader walks the nested sub-room list too, so a key missing there
// breaks it just as hard as one missing at the top level.
foreach (var list in new[] { "SubRooms", "Scenes" })
{
if (!getBody.TryGetProperty(list, out var expectedList) ||
expectedList.ValueKind != JsonValueKind.Array ||
expectedList.GetArrayLength() == 0) continue;
Assert.True(cloneBody.TryGetProperty(list, out var actualList)
&& actualList.ValueKind == JsonValueKind.Array,
$"clone response has no {list} array while get-by-id does");
Assert.True(actualList.GetArrayLength() > 0,
$"clone response has an EMPTY {list} array while get-by-id returns "
+ $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
}
Assert.True(missing.Count == 0,
$"""
The clone response is missing {missing.Count} key(s) that get-by-id
returns. Both deserialize into FGCPNAACHIK, so the client throws while
reading the clone and silently never registers the new room:
{string.Join(Environment.NewLine, missing)}
clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
""");
}
/// <summary>A clone must own its save data. Cloning used to be a shallow
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
/// someone saved over them — so every copy of a room shared one object and
/// cloning RecCenter produced rooms whose data blob was literally
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
/// the bug, whatever the name.</summary>
[Fact]
public async Task Clone_does_not_reference_the_source_rooms_blob()
{
// The suite runs with S3 unconfigured, where there is nothing to copy
// and the clone deliberately keeps the shallow reference. Swap in a
// real (in-memory) store so this exercises the copy path itself.
var store = new InMemoryObjectStorage();
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IObjectStorage>();
services.AddSingleton<IObjectStorage>(store);
}));
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
var sourceBlob = $"room_{sourceId}_v1.dat";
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = sourceId,
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
CurrentDataBlobName = sourceBlob,
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = sourceId,
Name = "Home",
OrderIndex = 0,
DataBlobName = sourceBlob,
});
await db.SaveChangesAsync();
}
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
Assert.False(room.CurrentDataBlobName == sourceBlob,
$"the clone's room row still points at the source's blob ({sourceBlob})");
foreach (var scene in scenes)
Assert.False(scene.DataBlobName == sourceBlob,
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
// It must be a real copy, not just a cleared reference — the point
// is that the player's content comes along.
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
"the clone has no blob at all; the source's data should have been copied, not dropped");
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
}
}
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
/// with S3 unconfigured, so without this the clone's copy path short-circuits
/// and never gets exercised.</summary>
private sealed class InMemoryObjectStorage : IObjectStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public bool IsS3Configured => true;
public Task<long> PutAsync(
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
{
_objects[$"{bucket}/{key}"] = bytes;
return Task.FromResult((long)bytes.Length);
}
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
}
private static async Task<JsonElement> ReadJsonAsync(
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"{method} {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
return client;
}
}
@@ -0,0 +1,235 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// End-to-end coverage of the 2023 client's in-room shop
/// (api/roomconsumables/v1) using the exact request/response wire shapes
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
/// formatters in the ISIL dump). The critical regression guard: every
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
/// otherwise.
/// </summary>
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_200_001L;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
Description = "shop test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
State = 0,
Accessibility = 1,
IsAGRoom = true,
TagsCsv = "community",
});
await db.SaveChangesAsync();
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
}
using var ownerClient = ApiClient(owner);
using var buyerClient = ApiClient(buyer);
// ── Create (client body = the desc DTO itself) ───────────────────
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{
"RoomId": {{roomId}},
"Name": "Cola",
"Description": "A refreshing test beverage",
"ImageName": "",
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
}
""");
Assert.Equal(0, created.GetProperty("Status").GetInt32());
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
Assert.NotEqual(Guid.Empty, consumableId);
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
// ── Non-owner cannot create in someone else's room ───────────────
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
""");
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
// ── Room catalog ─────────────────────────────────────────────────
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
Assert.Equal(1, catalog.GetArrayLength());
// Responses carry the price FLAT. The client's consumable formatter
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
// ── Purchase at the wrong price is rejected ──────────────────────
var codeA = Guid.NewGuid();
var badPrice = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
{
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
}
""");
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
// enum plus the inventory row. The scalar token-operation enum lives in
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
var purchase = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
{
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
}
""");
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
// ── Inventory on room join: Consumable must NEVER be null ────────
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
Assert.Equal(1, inventory.GetArrayLength());
var row = inventory[0];
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
Assert.Equal(1, row.GetProperty("Count").GetInt32());
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
// ── isOwned: a non-creator holds stock ───────────────────────────
using var isOwnedResponse = await ownerClient.GetAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
// ── Consume with a stale concurrency code resyncs the client ─────
var codeB = Guid.NewGuid();
var staleConsume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
""");
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
// ── Consume with the right code decrements and rotates the code ──
var consume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
""");
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
// ── Consuming an empty stack is refused ──────────────────────────
var emptyConsume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
""");
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
// ── Update via POST with RoomConsumableId in the body ────────────
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{
"RoomConsumableId": "{{consumableId}}",
"RoomId": {{roomId}},
"Name": "Diet Cola",
"Description": "Now with fewer tokens",
"ImageName": "",
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
}
""");
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
using var buyerDelete = await buyerClient.DeleteAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
using var ownerDelete = await ownerClient.DeleteAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
Assert.Equal(0, emptied.GetArrayLength());
}
[Fact]
public async Task Unprefixed_2023_commerce_probes_respond()
{
// The 2023 client calls these WITHOUT the api/ prefix on startup;
// a 404 here surfaces as "CleanupPendingTransactions failed" +
// unobserved-HTTP-404 crash reports in Player.log.
using var client = ApiClient();
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
Assert.True(cleanup.IsSuccessStatusCode);
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
{
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
+348
View File
@@ -0,0 +1,348 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
/// 2026-07-07). The client uploads two blobs to storage /upload —
/// FileType=6 room metadata, then FileType=1 scene save — and commits
/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
/// { "UnityAssetId": null,
/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
/// SubRoomData.Filename is the scene blob and must become the room's
/// CurrentDataBlobName.
/// </summary>
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_200_001L;
var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Save2023_{Guid.NewGuid():N}"[..24],
Description = "2023 save flow test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
});
await db.SaveChangesAsync();
}
else
{
await db.Rooms.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
var body = new
{
UnityAssetId = (long?)null,
RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
};
using var response = await roomsClient.PostAsync(
$"/rooms/{roomId}/subrooms/0/data",
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
var responseText = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
using var json = JsonDocument.Parse(responseText);
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
// 2023 commit-response contract (NEOPBOMGIOG): value carries a
// Room and a SubRoomDataSave. The Room reuses the exact GET
// rooms/{id} shape (BuildRoomServerDetails) — do NOT bolt extra
// keys onto it: the room-details mapper is strict about value
// shapes and an invented RankingContext object broke every
// room-details parse ("Malformed Response"). See befd590 revert.
var value = json.RootElement.GetProperty("value");
var roomDto = value.GetProperty("Room");
Assert.Equal(JsonValueKind.Object, roomDto.ValueKind);
foreach (var key in new[] { "RoomId", "SubRooms", "Roles", "Tags", "Stats" })
Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}");
var save = value.GetProperty("SubRoomDataSave");
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "SavedByAccountId", "CreatedAt" })
Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}");
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
Assert.Equal(blobName, room.CurrentDataBlobName);
}
}
[Fact]
public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
{
using var setupClient = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
using var storageClient = ApiClient(player, subdomain: "storage");
using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(file, "File", "file.bin");
form.Add(new StringContent("6"), "FileType");
using var response = await storageClient.PostAsync("/upload", form);
var responseText = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
using var json = JsonDocument.Parse(responseText);
var filename = json.RootElement.GetProperty("filename").GetString();
Assert.NotNull(filename);
Assert.StartsWith("roommeta_", filename);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(
await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
$"expected a RoomDataBlobs row for {filename}");
}
/// <summary>
/// Regression: cloning a room — and then cloning the CLONE — must both
/// succeed and return the full room-details shape (FGCPNAACHIK). Previously
/// the handler relied on DB auto-increment for the room id AND the copied
/// scene ids, which collide with the seeded id range once the identity
/// sequence lags → the clone POST 500s and the client boots the player to
/// their dorm ("Failed to copy room: Failed to clone room").
///
/// The first clone goes through the <c>roomserver/</c>-prefixed route:
/// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends
/// that prefix like every other room mutation, and the bare-only route
/// 404'd with an empty body ("Failed to copy room: Exception of type
/// '…' was thrown").
///
/// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must
/// give the clone a FRESH empty blob — the template's blob is a MakerPen
/// overlay against the shared baked scene, not the clone's content.
/// Cloning a USER-owned room (even AG-flagged) must carry its blob so
/// "copy room" keeps the player's edits.
/// </summary>
[Fact]
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_300_777L;
var templateBlob = $"room_{roomId}_v1.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
Description = "clone source",
// A first-party RRO/AG base room (RecCenter-like):
// system-owned, CloningAllowed=false, yet the base-room
// picker must still be able to clone it. Its blob is a
// MakerPen overlay that must NOT leak into clones.
CreatorPlayerId = PlayerService.SystemAccountId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
CloningAllowed = false,
CurrentDataBlobName = templateBlob,
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Main",
DataBlobName = templateBlob,
});
await db.SaveChangesAsync();
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
// First clone: template → player copy, via the roomserver/ prefix the
// real client uses.
var firstCloneId = await CloneAndAssertAsync(
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
Assert.NotEqual(roomId, firstCloneId);
// A clone of a first-party template used to be left with NO blob, on
// the reasoning that its content is baked client geometry. That left
// the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
// a name it has nothing stored under, and the client fetches exactly
// that through GetRoomData ("/room/{blob}") while copying, then rejects
// it. Clones that carried real content joined fine.
//
// The content now comes across. Sharing the SOURCE's object is still
// wrong, but that is prevented by copying it under the clone's own name
// rather than by having no blob at all — see
// RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
// which runs with a real object store. This suite has none, so the
// copy deliberately falls back to keeping the reference and only the
// "has content" half is checkable here.
await AssertBlobsAsync(firstCloneId, expectEmpty: false,
"a clone of a first-party template must carry its content");
// Simulate the player saving MakerPen edits into their clone.
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId);
room.CurrentDataBlobName = editedBlob;
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId);
scene.DataBlobName = editedBlob;
await db.SaveChangesAsync();
}
// The regression: cloning the clone (bare path still works too).
// The source is user-owned — even though it inherited IsAGRoom, the
// player's edits must carry over.
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
Assert.NotEqual(firstCloneId, secondCloneId);
await AssertBlobsAsync(secondCloneId, expectEmpty: false,
"copying a user-owned room must keep its blob (player edits)");
}
private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.SingleAsync(r => r.Id == roomId);
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId);
if (expectEmpty)
{
Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'");
Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'");
}
else
{
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty");
Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty");
}
}
private static async Task<long> CloneAndAssertAsync(
HttpClient client, long sourceId, string name, string pathPrefix = "")
{
// The real 2023 client posts the name as x-www-form-urlencoded
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
// a [FromBody] handler returns for a form POST.
using var resp = await client.PostAsync(
$"{pathPrefix}/rooms/{sourceId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var root = json.RootElement;
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64();
}
/// <summary>
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
/// (PagedResultsDTO&lt;SubRoomDataSaveDTO&gt;), NOT a bare array. The 2023
/// client's GetSubRoomDataSaves strict reader throws
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
/// calling flow — room clone fails with the message-less "Failed to copy
/// room" and the private-instance button silently does nothing.
/// </summary>
[Fact]
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_300_888L;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
CurrentDataBlobName = $"room_{roomId}_v1.dat",
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Home",
DataBlobName = $"room_{roomId}_v1.dat",
});
await db.SaveChangesAsync();
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
foreach (var path in new[]
{
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
$"/rooms/{roomId}/subrooms/0/saves",
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
})
{
using var resp = await roomsClient.GetAsync(path);
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
}
}
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Text.Json;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Tests;
public sealed class RoomServiceTests
{
[Fact]
public void ToWireRoom_never_emits_empty_image_name()
{
var room = new RoomEntity
{
Id = 1001,
Name = "CustomRoomWithoutImage",
Description = "Custom room",
ImageName = "",
Accessibility = 1,
State = 0,
};
var json = JsonSerializer.Serialize(RoomService.ToWireRoom(room));
using var document = JsonDocument.Parse(json);
Assert.Equal(RoomService.DefaultRoomImageName, document.RootElement.GetProperty("ImageName").GetString());
}
[Fact]
public void ResolveDisplayImageName_preserves_existing_image_name()
{
var room = new RoomEntity
{
Name = "Paintball",
ImageName = "custom-thumbnail.png",
};
Assert.Equal("custom-thumbnail.png", RoomService.ResolveDisplayImageName(room));
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
public sealed class RoomsVisitedByTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomsVisitedByTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Continue_rooms_hide_private_rooms_from_non_owners()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var visitor = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var suffix = Guid.NewGuid().ToString("N")[..8];
var publicRoomId = 9_100_001L;
var privateRoomId = 9_100_002L;
var publicRoomName = $"ContinuePublic_{suffix}";
var privateRoomName = $"ContinuePrivate_{suffix}";
var now = DateTime.UtcNow;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.AddRange(
BuildRoom(publicRoomId, publicRoomName, owner.PlayerId, accessibility: 1),
BuildRoom(privateRoomId, privateRoomName, owner.PlayerId, accessibility: 0));
db.RoomVisits.AddRange(
BuildVisit(publicRoomId, visitor.PlayerId, now.AddMinutes(-2)),
BuildVisit(privateRoomId, visitor.PlayerId, now.AddMinutes(-1)),
BuildVisit(privateRoomId, owner.PlayerId, now));
await db.SaveChangesAsync();
}
using var visitorClient = ApiClient(visitor);
var visitorNames = await GetContinueRoomNamesAsync(visitorClient);
Assert.Contains(publicRoomName, visitorNames);
Assert.DoesNotContain(privateRoomName, visitorNames);
using var ownerClient = ApiClient(owner);
var ownerNames = await GetContinueRoomNamesAsync(ownerClient);
Assert.Contains(privateRoomName, ownerNames);
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<string[]> GetContinueRoomNamesAsync(HttpClient client)
{
using var response = await client.GetAsync("/rooms/visitedby/me");
response.EnsureSuccessStatusCode();
await using var body = await response.Content.ReadAsStreamAsync();
using var document = await JsonDocument.ParseAsync(body);
return document.RootElement
.EnumerateArray()
.Select(room => room.GetProperty("Name").GetString() ?? string.Empty)
.ToArray();
}
private static RoomEntity BuildRoom(long id, string name, long creatorPlayerId, int accessibility) => new()
{
Id = id,
Name = name,
Description = name,
CreatorPlayerId = creatorPlayerId,
ImageName = RoomService.DefaultRoomImageName,
State = 0,
Accessibility = accessibility,
IsAGRoom = true,
IsDormRoom = false,
HiddenFromBrowse = false,
TagsCsv = "community",
};
private static RoomVisitEntity BuildVisit(long roomId, long playerId, DateTime visitedAt) => new()
{
RoomId = roomId,
PlayerId = playerId,
FirstVisitAt = visitedAt,
LastVisitAt = visitedAt,
VisitCount = 1,
};
}
+448
View File
@@ -0,0 +1,448 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using RecRoom.Protobuf;
namespace DorkNet.Server.Tests;
/// <summary>
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
/// reads that as "no room data at all": no persistence views, no room-role
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
/// in the scene — so DorkNet needs a switch that gives such a room one.
///
/// The property pinned here: the admin "enable blob" action stamps the
/// synthetic default name, that name flows through the game-client details
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
/// map, and only additionally fetches the blob), the CDN answers that name
/// with the RRO-editable blob (permissive room roles, no objects), and
/// "reset" puts everything back to scene-only.
/// </summary>
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
var roomName = $"Royale{Guid.NewGuid():N}"[..18];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
// Shape of a seeded baked original: system-owned, AG-flagged,
// tagged recroomoriginal, no blob anywhere.
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = roomName,
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal,sport",
CurrentDataBlobName = string.Empty,
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
await db.SaveChangesAsync();
}
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var gameClient = Client("rooms", player);
using var adminClient = Client("admin", admin);
using var cdnClient = Client("cdn");
// ── before: baked scene only ────────────────────────────────────────
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(before.GetProperty("IsRRO").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var row = listed.EnumerateArray().Single();
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
// ── enable: the synthetic name lands on the scene AND the room ─────
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.True(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
// Idempotent: a second enable changes nothing and keeps the name.
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.False(again.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
// The game client now gets the blob name, and IsRRO is untouched —
// that flag is what makes it load the baked map (a false there sends
// it looking for geometry inside the blob and the join fails).
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(after.GetProperty("IsRRO").GetBoolean());
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
// ── the CDN answers that name with the RRO-editable blob ───────────
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
Assert.True(blobResponse.IsSuccessStatusCode,
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
Assert.NotNull(persisted.RoomRoleData);
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
// The March-2023 client rejects a blob past PersistedRoomVersion 16
// (the old stub was stamped 19 and loaded as "no room data"), and a
// real save carries the room's location id as activity_id. Our proto
// doesn't type activity_id, so look for it on the wire.
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
var blobText = System.Text.Encoding.UTF8.GetString(blobBytes);
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText);
// Every real room save carries three GameRoleNode chips (default role
// named after the room, "In-Game", "Eliminated" — well-known GUIDs).
// The 2023 client grants Maker Pen off the player's GameRole, so a
// blob with no role objects leaves even a co-owner with nothing.
Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes));
Assert.Contains("In-Game", blobText);
Assert.Contains("Eliminated", blobText);
Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText);
Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText);
Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText);
Assert.Contains(roomName, blobText);
// ── reset: back to scene-only ───────────────────────────────────────
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
Assert.True(reset.GetProperty("changed").GetBoolean());
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
}
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
/// the entry scene the way the game-client details do, and enable must
/// create the row rather than 404.</summary>
[Fact]
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_670_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"NoScene{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
MaxCapacity = 24,
});
await db.SaveChangesAsync();
}
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var gameClient = Client("rooms", player);
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var virtualRow = listed.EnumerateArray().Single();
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.True(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
// The row now exists with the game-side defaults, and the game client
// sees the blob on its entry scene.
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var real = relisted.EnumerateArray().Single();
Assert.False(real.TryGetProperty("isVirtual", out _));
Assert.Equal("Home", real.GetProperty("name").GetString());
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.True(details.GetProperty("IsRRO").GetBoolean());
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
Assert.True(reset.GetProperty("changed").GetBoolean());
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
}
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
/// in the blob. The stub for those rooms must carry it (a fourth
/// persistence view with the palette tag the client generates).</summary>
[Theory]
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
{
using var setup = Client("rooms");
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = roomName,
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
});
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
using var cdnClient = Client("cdn");
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
Assert.True(blobResponse.IsSuccessStatusCode);
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
}
/// <summary>A real save from any client version can be attached to a baked
/// original: activity id rewritten to the target room, output log /
/// sub-room id / unknown fields dropped, version clamped, and in
/// <c>basics</c> mode only the role + rule objects kept with circuits
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
[Fact]
public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023()
{
using var setup = Client("rooms");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_690_000 + Random.Shared.Next(1, 99_999);
const string location = "253fa009-6e65-4c90-91a1-7137a56a267f";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Upload{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
LocationReplicationId = location,
});
await db.SaveChangesAsync();
}
// A "modern" save: version 138, someone else's activity id, an output
// log, a sub-room id, a newer-only field 37, circuit payloads, and
// three views — a game role (field 50), a game rule (field 41) and a
// plain prop (field 15 only).
var modern = BuildModernSave();
using var adminClient = Client("admin", admin);
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb");
using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form);
var uploadBody = await uploaded.Content.ReadAsStringAsync();
Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}");
var result = JsonDocument.Parse(uploadBody).RootElement;
var blobName = result.GetProperty("dataBlobName").GetString()!;
Assert.StartsWith($"room_{roomId}_upload_", blobName);
var summary = result.GetProperty("summary");
Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32());
Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32());
Assert.Equal(138, summary.GetProperty("versionIn").GetInt32());
Assert.Equal(16, summary.GetProperty("versionOut").GetInt32());
// The game client is now pointed at the uploaded blob …
using var gameClient = Client("rooms", player);
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString());
// … and the CDN serves it rewritten: 2 views, clamped header, our
// activity id, the role overlay applied, no field 37 / output log.
using var cdnClient = Client("cdn");
using var served = await cdnClient.GetAsync($"/room/{blobName}");
Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}");
var bytes = await served.Content.ReadAsByteArrayAsync();
var parsed = PersistedRoomData.Parser.ParseFrom(bytes);
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version);
Assert.NotEmpty(parsed.RoomRoleData.RoomRoles);
Assert.Equal(2, CountTopLevelPersistenceViews(bytes));
var text = System.Text.Encoding.UTF8.GetString(bytes);
Assert.Contains(location, text);
Assert.DoesNotContain("someone-elses-activity", text);
Assert.DoesNotContain("Serialized 3 persistence views", text);
}
private static byte[] BuildModernSave()
{
var ms = new MemoryStream();
void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); }
void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); }
static byte[] View(int markerField)
{
var v = new MemoryStream();
WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray());
WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0);
return v.ToArray();
}
Varint(1, 38);
Bytes(2, View(50)); // game role
Bytes(2, View(41)); // game rule wrapper
Bytes(2, View(15)); // a prop (tagged tool only)
Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000"));
Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views"));
Varint(6, 3991471883933115623UL);
Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
Varint(30, 138);
Varint(31, 1);
Varint(37, 1);
return ms.ToArray();
}
private static void WriteVarint(Stream s, ulong value)
{
while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; }
s.WriteByte((byte)value);
}
[Fact]
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
{
using var setup = Client("rooms");
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
const string saved = "room_saved_by_a_player_v3.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Saved{Guid.NewGuid():N}"[..18],
CreatorPlayerId = 1,
IsAGRoom = true,
TagsCsv = "recroomoriginal",
CurrentDataBlobName = saved,
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
Assert.False(enabled.GetProperty("changed").GetBoolean());
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
}
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
/// walking the wire format — our server proto doesn't type field 2.</summary>
private static int CountTopLevelPersistenceViews(byte[] blob)
{
var pos = 0;
var count = 0;
while (pos < blob.Length)
{
var tag = ReadVarint(blob, ref pos);
var field = (int)(tag >> 3);
var wireType = (int)(tag & 7);
switch (wireType)
{
case 0: ReadVarint(blob, ref pos); break;
case 1: pos += 8; break;
case 2:
var len = (int)ReadVarint(blob, ref pos);
if (field == 2) count++;
pos += len;
break;
case 5: pos += 4; break;
default: throw new InvalidDataException($"wire type {wireType}");
}
}
return count;
}
private static ulong ReadVarint(byte[] input, ref int pos)
{
ulong value = 0;
var shift = 0;
while (true)
{
var b = input[pos++];
value |= (ulong)(b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
shift += 7;
}
}
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, content: null);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string host, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
if (session is not null)
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,271 @@
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Tests;
/// <summary>
/// Guards the two ways a column reaches a database that already exists, because
/// getting either one wrong is invisible until a live server starts returning
/// 500s.
///
/// Every other test in this suite runs against a FRESH database, where
/// <c>EnsureCreated</c> builds the whole schema straight from the entity model —
/// so every column always exists and none of this is exercised. Only an
/// *existing* database can be missing a column, and that is exactly what took
/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at
/// once: three columns were added to the entity model with an EF migration, but
/// the Postgres path never replays migrations, so a deployed database never got
/// them.
///
/// The two mechanisms, and the invariant each test asserts:
///
/// <list type="number">
/// <item><b>SQLite</b> applies committed EF migrations via <c>Database.Migrate()</c>.
/// So the migration history alone must be able to build the current model.</item>
/// <item><b>Postgres</b> is <c>EnsureCreated</c>-only and NEVER replays
/// migrations. A database created before a column existed only gets it from an
/// idempotent <c>Ensure*</c> ALTER in <see cref="DorkNet.Server.Startup"/>'s
/// DatabaseBootstrap. So anything added after the Initial migration must appear
/// there too.</item>
/// </list>
/// </summary>
public sealed class SchemaBootstrapParityTests
{
/// <summary>Every table and column in the entity model must be reachable on
/// a database that already exists — via a committed migration, an
/// idempotent <c>Ensure*</c> patch, or both.
///
/// That union is exactly what boot does: SQLite runs <c>Database.Migrate()</c>
/// and then the <c>Ensure*</c> helpers, and Postgres runs the helpers alone.
/// A property with NEITHER reaches only brand-new databases, and every
/// query touching it throws at runtime on a deployed server.
///
/// The union is asserted rather than migrations alone because a large amount
/// of the schema legitimately predates this rule and lives only in the
/// helpers. Tightening that up is a separate cleanup; what must never happen
/// is a column covered by nothing.</summary>
[Fact]
public async Task Every_model_column_reaches_an_existing_database()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<DorkNetDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new DorkNetDbContext(options);
await db.Database.MigrateAsync();
var actual = await ReadSqliteSchemaAsync(connection);
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false);
var missing = new List<string>();
foreach (var entity in db.Model.GetEntityTypes())
{
var table = entity.GetTableName();
if (string.IsNullOrEmpty(table)) continue;
var migrated = actual.TryGetValue(table, out var columns);
if (!migrated && !patchedTables.Contains(table))
{
missing.Add($"{table} (whole table)");
continue;
}
// A table the helpers create carries its own columns in the same
// CREATE, so only migrated tables need a per-column check.
if (!migrated) continue;
foreach (var property in entity.GetProperties())
{
var column = property.GetColumnName();
if (string.IsNullOrEmpty(column)) continue;
if (columns!.Contains(column)) continue;
if (patchedColumns.Contains($"{table}.{column}")) continue;
missing.Add($"{table}.{column}");
}
}
Assert.True(missing.Count == 0,
$"""
{missing.Count} model table(s)/column(s) are covered by NOTHING.
They exist on a freshly-created database (EnsureCreated builds the
whole model) but never appear on one that already exists, so a
deployed server throws "no such column" / "column does not exist"
on every request that touches them.
Add a migration AND an idempotent ALTER in an Ensure*Async helper in
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
{string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))}
""");
}
/// <summary>Every column added AFTER the Initial migration must also be
/// added by an idempotent Postgres <c>ALTER</c> in DatabaseBootstrap.
///
/// Postgres never replays migrations, so a migration alone reaches only
/// brand-new databases. A production database created before the column
/// existed gets it from the Ensure* helpers or not at all — the exact hole
/// that broke matchmake/dorm (PrivateInstances.RoomCode),
/// club/home/me (Clubs.ClubChatEnabled) and the chat inbox
/// (ChatMessages.ModerationState).</summary>
[Fact]
public void Post_initial_columns_are_patched_for_existing_postgres_databases()
{
var migrations = FindRepoDirectory("DorkNet.Server", "Migrations");
var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true);
// Anything AddColumn'd by a migration other than Initial post-dates the
// baseline, so an existing Postgres database will not have it.
var gaps = new List<string>();
foreach (var file in Directory.GetFiles(migrations, "*.cs"))
{
var name = Path.GetFileNameWithoutExtension(file);
if (name.Contains("Designer", StringComparison.Ordinal)) continue;
if (name.Contains("Initial", StringComparison.Ordinal)) continue;
if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue;
var text = File.ReadAllText(file);
foreach (Match m in Regex.Matches(text,
@"AddColumn<[^>]+>\(\s*name:\s*""(?<column>\w+)""\s*,\s*table:\s*""(?<table>\w+)""",
RegexOptions.Singleline))
{
var table = m.Groups["table"].Value;
var column = m.Groups["column"].Value;
if (patchedTables.Contains(table)) continue; // whole table is created by a patch
if (patchedColumns.Contains($"{table}.{column}")) continue;
gaps.Add($"{table}.{column} (added by {name})");
}
foreach (Match m in Regex.Matches(text,
@"CreateTable\(\s*name:\s*""(?<table>\w+)""",
RegexOptions.Singleline))
{
var table = m.Groups["table"].Value;
if (!patchedTables.Contains(table))
gaps.Add($"{table} (whole table, created by {name})");
}
}
Assert.True(gaps.Count == 0,
$"""
{gaps.Count} schema change(s) reach new databases only.
The Postgres path is EnsureCreated-only and never replays
migrations, so a migration alone does NOT add these to a database
that already exists — a deployed server will throw
"column does not exist" on every request that touches them.
Add an idempotent ALTER/CREATE to an Ensure*Async helper in
DorkNet.Server/Startup/DatabaseBootstrap.cs for:
{string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))}
""");
}
/// <summary>Tables and columns the <c>Ensure*</c> helpers add by raw SQL.
///
/// The helpers are private, so this reads their statements out of the source
/// rather than executing them. In that source the SQL sits inside verbatim
/// strings, so a quoted identifier appears as two literal quote characters
/// (<c>""Playlists""</c>).
///
/// <paramref name="postgresOnly"/> keys off <c>IF NOT EXISTS</c>: the
/// Postgres branch uses it, while the SQLite branch omits it and swallows
/// the duplicate-column error instead. So requiring it isolates the branch
/// that an existing Postgres database actually depends on.</summary>
private static (HashSet<string> Tables, HashSet<string> Columns) ReadBootstrapPatches(
bool postgresOnly)
{
var bootstrap = Path.Combine(
FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs");
Assert.True(File.Exists(bootstrap), $"missing {bootstrap}");
var sql = File.ReadAllText(bootstrap);
// The source mixes both C# string styles, so a quoted identifier shows
// up as either two quote characters (verbatim, @"... ""X"" ...") or one
// (raw, """... "X" ..."""). Accept either.
const string Q = "\"{1,2}";
var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?";
var columns = Regex
.Matches(sql,
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?<column>\w+){Q}",
RegexOptions.IgnoreCase)
.Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var tables = Regex
.Matches(sql,
$@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{Q}(?<table>\w+){Q}",
RegexOptions.IgnoreCase)
.Select(m => m.Groups["table"].Value)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Some helpers loop a column-name array through one interpolated ALTER
// (EnsureGameRewardColumnsAsync does). The statement alone only shows
// "{col}", so pair each such ALTER with the nearest preceding
// `new[] { "A", "B" }` and expand it.
foreach (Match alter in Regex.Matches(sql,
$@"ALTER\s+TABLE\s+{Q}(?<table>\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?<var>\w+)\}}{Q}",
RegexOptions.IgnoreCase))
{
var loop = Regex.Matches(
sql[..alter.Index],
$@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?<items>[^}}]*)\}}")
.Cast<Match>()
.LastOrDefault();
if (loop is null) continue;
foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?<name>\\w+)\""))
columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}");
}
return (tables, columns);
}
private static async Task<Dictionary<string, HashSet<string>>> ReadSqliteSchemaAsync(
SqliteConnection connection)
{
var schema = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
var tables = new List<string>();
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText =
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync()) tables.Add(reader.GetString(0));
}
foreach (var table in tables)
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info(\"{table}\");";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync()) columns.Add(reader.GetString(1));
schema[table] = columns;
}
return schema;
}
/// <summary>Walk up from the test binaries to the repo root, then into the
/// requested source directory.</summary>
private static string FindRepoDirectory(params string[] segments)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray());
Assert.True(Directory.Exists(path), $"missing source directory {path}");
return path;
}
}
@@ -0,0 +1,164 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Max players is a PER SUB-ROOM setting, adjustable from two places:
///
/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
/// "Max Player Count in This Subroom" slider)
/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
///
/// Both write the same <see cref="RoomSceneEntity.MaxPlayers"/>, so a change
/// made in one place has to be visible from the other. That is the property
/// worth pinning: two independent write paths onto one column drift silently,
/// and the symptom (a slider that snaps back) looks like a client bug.
///
/// Kept distinct from the room's own <see cref="RoomEntity.MaxCapacity"/>,
/// which is what the ROOM advertises and what matchmaking hands to
/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
/// would collapse the two settings into one.
/// </summary>
public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Owner_and_admin_edit_the_same_per_subroom_value()
{
using var setup = Client("rooms");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Caps{Guid.NewGuid():N}"[..18],
CreatorPlayerId = owner.PlayerId,
MaxCapacity = 12,
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
await db.SaveChangesAsync();
}
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
// most of its siblings use. Testing with a form would pass while the
// real client's body went unread.
using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync(
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client deserialises the reply as FGCPNAACHIK — the room-details
// shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement
.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray()
.ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
e => e.GetProperty("maxPlayers").GetInt32());
Assert.Equal(24, subRooms[1]);
Assert.Equal(8, subRooms[0]);
using var adminSet = await adminClient.PutAsJsonAsync(
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
Assert.True(adminSet.IsSuccessStatusCode,
$"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
// ── the room's own details carry each sub-room's own figure ─────────
var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
var wire = details.GetProperty("SubRooms").EnumerateArray()
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32());
Assert.Equal(40, wire[0]);
Assert.Equal(24, wire[1]);
// The room-level figure is the room's advertised capacity, NOT a copy
// of sub-room 0 — otherwise the admin edit above would have moved it.
Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
}
[Fact]
public async Task Admin_cap_is_clamped_to_a_joinable_range()
{
using var setup = Client("rooms");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Clamp{Guid.NewGuid():N}"[..18],
CreatorPlayerId = owner.PlayerId,
});
db.RoomScenes.Add(new RoomSceneEntity
{ RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
await db.SaveChangesAsync();
}
using var adminClient = Client("admin", admin);
// 0 would make the sub-room unjoinable.
using var tooLow = await adminClient.PutAsJsonAsync(
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
using var tooHigh = await adminClient.PutAsJsonAsync(
$"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client(string host, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
if (session is not null)
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,67 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Event notifications (cheers) now go through the REAL Message channel
/// (persisted MessageEntity + MessageReceived push with the client's
/// Message DTO shape), instead of the old blank-account misuse of
/// SubscriptionUpdateProfile. A cheer must land in the recipient's message
/// feed as a PlayerCheer (Type 50) from the cheerer.
/// </summary>
public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory>
{
private const int PlayerCheerType = 50;
private readonly DorkNetServerFactory _factory;
public SystemNotificationTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Cheering_a_player_creates_a_PlayerCheer_message_notification()
{
using var setup = ApiClient();
var cheerer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(cheerer);
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
// TargetAccountId/Type, which matched the buggy handler binding but
// NOT the game client — so it passed while cheers were broken live.
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["PlayerIdTo"] = target.PlayerId.ToString(),
["CheerCategory"] = "0",
["Anonymous"] = "false",
}));
resp.EnsureSuccessStatusCode();
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var msg = await db.Messages.FirstOrDefaultAsync(m =>
m.RecipientPlayerId == target.PlayerId && m.Type == PlayerCheerType);
Assert.NotNull(msg);
Assert.Equal(cheerer.PlayerId, msg!.SenderPlayerId);
// No orphan/blank-account row was written anywhere in the flow.
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,251 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace DorkNet.Server.Tests;
/// <summary>
/// QA test cases linked to GitHub issues.
///
/// The link lives in <see cref="TestCaseEntity.JiraBugUrl"/> — the field Rec
/// Room's own tooling used for the bug filed against a failing case — so no
/// column is added and the Postgres "migrations never replay" trap is avoided
/// entirely.
///
/// GitHub itself is faked. These tests are about our behaviour around it:
/// filing once rather than per sweep, following issue state in the one
/// direction that is safe, and degrading rather than 500ing when no token is
/// configured.
/// </summary>
public sealed class TestCaseGitHubIssueTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
private const int StatusClaimed = 1;
private const int StatusFailed = 2;
private const int StatusPassed = 3;
[Fact]
public async Task Filing_is_idempotent_and_stores_the_issue_url()
{
var github = new FakeGitHub();
using var factory = WithGitHub(github);
using var client = await AdminClientAsync(factory);
var caseId = await SeedCaseAsync(factory, StatusFailed);
using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
var firstBody = await first.Content.ReadAsStringAsync();
Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
// The link must be persisted, and in the field the admin UI reads.
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
Assert.Contains("/issues/", tc.JiraBugUrl);
}
// Re-running a sweep over a failing pass must not file the bug again.
// Measured as a DELTA rather than an absolute count: the fake is shared
// with whatever else the server does during the test, and the property
// under test is "this call filed nothing", not "nothing was ever filed".
var filedBefore = github.Created;
using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
var secondBody = await second.Content.ReadAsStringAsync();
Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
"filing twice created a duplicate issue");
Assert.Equal(filedBefore, github.Created);
}
[Fact]
public async Task A_closed_issue_marks_the_failing_case_passed()
{
var github = new FakeGitHub();
using var factory = WithGitHub(github);
using var client = await AdminClientAsync(factory);
var caseId = await SeedCaseAsync(factory, StatusFailed);
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
Assert.True(filed.IsSuccessStatusCode);
github.CloseAll();
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
// A closed issue means the bug is fixed, so the case that was failing
// on it passes.
Assert.Equal(StatusPassed, tc.Status);
}
[Fact]
public async Task A_reopened_issue_marks_the_case_failed_again()
{
var github = new FakeGitHub();
using var factory = WithGitHub(github);
using var client = await AdminClientAsync(factory);
var caseId = await SeedCaseAsync(factory, StatusFailed);
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
Assert.True(filed.IsSuccessStatusCode);
github.CloseAll();
using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
Assert.True(closeSync.IsSuccessStatusCode);
github.ReopenAll();
using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
Assert.True(reopenSync.IsSuccessStatusCode);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
}
[Fact]
public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
{
var github = new FakeGitHub();
using var factory = WithGitHub(github);
using var client = await AdminClientAsync(factory);
var caseId = await SeedCaseAsync(factory, StatusFailed);
using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
Assert.True(filed.IsSuccessStatusCode);
// Someone picks the case up while the issue is still open.
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
await db.SaveChangesAsync();
}
using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
Assert.True(sync.IsSuccessStatusCode);
await using var check = factory.Services.CreateAsyncScope();
var db2 = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
// An open issue against a claimed case says nothing new; flipping it to
// Failed would yank it out from under whoever is testing it. Only
// Failed<->Passed are the reconciler's to move.
Assert.Equal(StatusClaimed, tc.Status);
}
[Fact]
public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
{
// The default fixture has no GitHub configuration, which is what an
// ordinary deployment looks like.
using var client = await AdminClientAsync(_factory);
var caseId = await SeedCaseAsync(_factory, StatusFailed);
using var response = await client.PostAsync(
$"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
private WebApplicationFactory<Program> WithGitHub(IGitHubIssues github) =>
_factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IGitHubIssues>();
services.AddSingleton(github);
}));
private async Task<HttpClient> AdminClientAsync(WebApplicationFactory<Program> factory)
{
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
await db.SaveChangesAsync();
}
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<string> SeedCaseAsync(WebApplicationFactory<Program> factory, int status)
{
var id = $"tc-{Guid.NewGuid():N}";
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.TestCases.Add(new TestCaseEntity
{
Id = id,
Key = $"QA-{Random.Shared.Next(1000, 9999)}",
Title = "Dorm fails to load after cloning a room",
Description = "Clone RecCenter, then return to the dorm.",
RoomName = "Dorm",
Status = status,
TagsJson = """["dorm","regression"]""",
CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
});
await db.SaveChangesAsync();
return id;
}
/// <summary>An in-memory GitHub. Records what was filed so the tests can
/// assert we did not file twice, and lets them flip issue state.</summary>
private sealed class FakeGitHub : IGitHubIssues
{
private readonly Dictionary<int, GitHubIssue> _issues = [];
private int _next = 1;
public bool IsConfigured => true;
public string Repository => "DorkSquadRR/DorkNet";
public int Created { get; private set; }
public Task<GitHubIssue?> CreateAsync(
string title, string body, IEnumerable<string> labels, CancellationToken ct = default)
{
var number = _next++;
var issue = new GitHubIssue(
number, $"https://github.com/{Repository}/issues/{number}", "open", title);
_issues[number] = issue;
Created++;
return Task.FromResult<GitHubIssue?>(issue);
}
public Task<GitHubIssue?> GetAsync(int number, CancellationToken ct = default) =>
Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
public Task<bool> CommentAsync(int number, string body, CancellationToken ct = default) =>
Task.FromResult(_issues.ContainsKey(number));
public void CloseAll() => SetState("closed");
public void ReopenAll() => SetState("open");
private void SetState(string state)
{
foreach (var number in _issues.Keys.ToList())
_issues[number] = _issues[number] with { State = state };
}
}
}
@@ -11,11 +11,11 @@ namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void December_2020_build_is_valid_with_minimal_service_config()
public void March_2023_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
@@ -37,9 +37,9 @@ public sealed class VersionCheckControllerTests
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"december_2020_12_18",
"march_2023_03_21",
},
"december_2020_12_18");
"march_2023_03_21");
return new VersionCheckController(
config,
+110
View File
@@ -0,0 +1,110 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// The 2023 client's watch Shop tab pipeline: rows come from
/// <c>/api/storefronts/v3/giftdropstore/{id}</c> (each row MUST carry a
/// singular <c>GiftDrop</c> object — the 2023 POGBAGAHGIA formatter
/// ignores the 2020 <c>GiftDrops</c> array, and a null inner desc makes
/// StoreItemListModel's filter predicate NRE so the Shop shows nothing),
/// and the featured strip ids come from <c>/v1/toptoday</c> as a BARE
/// int list that must resolve against those rows.
/// </summary>
public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public WatchShop2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Giftdropstore_rows_carry_singular_giftdrop_and_toptoday_ids_resolve()
{
using var client = ApiClient();
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var rows = storefront.GetProperty("StoreItems");
Assert.True(rows.GetArrayLength() > 0, "watch storefront seeded no rows");
var rowIds = new HashSet<int>();
foreach (var row in rows.EnumerateArray())
{
rowIds.Add(row.GetProperty("PurchasableItemId").GetInt32());
Assert.Equal(0, row.GetProperty("Type").GetInt32());
Assert.True(row.GetProperty("Prices").GetArrayLength() > 0);
var giftDrop = row.GetProperty("GiftDrop");
Assert.Equal(JsonValueKind.Object, giftDrop.ValueKind);
Assert.Equal(row.GetProperty("PurchasableItemId").GetInt32(),
giftDrop.GetProperty("GiftDropId").GetInt32());
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("FriendlyName").ValueKind);
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("TagList").ValueKind);
var rarity = giftDrop.GetProperty("Rarity").GetInt32();
Assert.Contains(rarity, new[] { 0, 10, 20, 30, 50 });
// Int32 on the 2023 wire — a descriptor STRING here makes the
// strict Utf8Json reader throw "Malformed Response" for the
// whole storefront payload (expected:'Number Token').
Assert.Equal(JsonValueKind.Number, giftDrop.GetProperty("AvatarItemId").ValueKind);
Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("AvatarItemDesc").ValueKind);
}
// Featured strip: bare int ids, every one resolvable in the rows.
var topToday = await GetJsonAsync(client, "/api/storefronts/v1/toptoday");
Assert.Equal(JsonValueKind.Array, topToday.ValueKind);
Assert.True(topToday.GetArrayLength() > 0, "toptoday returned no featured ids");
foreach (var id in topToday.EnumerateArray())
{
Assert.Equal(JsonValueKind.Number, id.ValueKind);
Assert.Contains(id.GetInt32(), rowIds);
}
}
[Fact]
public async Task Storefront_includes_food_consumables_with_literal_desc()
{
using var client = ApiClient();
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var rows = storefront.GetProperty("StoreItems");
// Food / KO / camera consumables surface with the client's literal
// ConsumableItemDesc and no avatar item — that's what routes them to
// the Shop's Consumables tab and binds the baked prefab. (Hair dyes
// are a hybrid: they also carry an AvatarItemDesc colour guid, so
// they're excluded from the no-avatar-item check.)
var byDesc = new Dictionary<string, JsonElement>();
foreach (var row in rows.EnumerateArray())
{
var gd = row.GetProperty("GiftDrop");
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
if (desc.Length > 0) byDesc[desc] = gd;
}
foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" })
{
Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}");
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
}
}
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
return client;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
@@ -0,0 +1,2815 @@
{
"AALDCAANEMM": [
"CurrencyPurchaseOfferId",
"CurrencyId",
"Order",
"Name",
"CurrencyAmount",
"Price",
"ModifiedAt"
],
"ABJLKAFOPMP": [
"GiftDropIds",
"Message",
"SubscriptionPurchase"
],
"ACKNGEEHHAE": [
"RoomId",
"SubRoomId",
"ClubId",
"Name",
"Description",
"Tags",
"ImageName",
"StartTime",
"EndTime",
"Accessibility",
"IsMultiInstance",
"SupportMultiInstanceRoomChat",
"DefaultBroadcastPermissions",
"CanRequestBroadcastPermissions"
],
"ADAFNGNADFH": [
"InviteeAccountId",
"CreatedAt",
"VerifiedAt"
],
"ADDGMGGOLHM": [
"BlobName",
"Title",
"Description",
"ThumbnailBlobName",
"SourceUrl"
],
"ADMGGPDGKCE": [
"Msg",
"Id"
],
"AECJMONHBHM": [
"ClubId",
"LastReadAnnouncementId",
"Announcements"
],
"AEGADMDIKNE": [
"Value",
"Success",
"Error"
],
"AEGCKMKICFG": [
"AccountId",
"UserName",
"DisplayName",
"DisplayEmoji",
"ProfileImage",
"BannerImage",
"TreatAsJunior",
"HasBirthday",
"PersonalPronouns",
"IdentityFlags",
"CreatedAt",
"IsJunior"
],
"AEHFGHPLNDO": [
"AccountId",
"LastVisitedAt"
],
"AFHCKGCJNFM": [
"Msg",
"Id"
],
"AFJDLEFKNFP": [
"Value",
"Success",
"Error"
],
"AHJKILCAGML": [
"ChallengeId",
"Name",
"Config",
"Description",
"Tooltip",
"Complete"
],
"AIEJFLGKIGN": [
"BalanceUpdates",
"Balance",
"CurrencyType",
"Platform"
],
"AJEDJLBJLFA": [
"AccountId",
"ClubId",
"MembershipType",
"CreatedAt"
],
"AKDFPANIAFL": [
"AnnouncementId",
"AnnouncementType",
"Title",
"Body",
"ImageName",
"LinkType",
"LinkName",
"LinkButtonLabel",
"LinkUri",
"Platform",
"CreatedAt"
],
"AKKPACEJJFK": [
"Msg",
"Id"
],
"ALGKCAKCFAJ": [
"BalanceUpdates",
"Balance",
"CurrencyType",
"Platform"
],
"ALJPHDEAHBK": [
"Result",
"Tags"
],
"AMPFGJPKHOD": [
"Device_code",
"User_code",
"Verification_uri",
"Verification_uri_complete",
"Expires_in",
"Interval"
],
"ANHGLIFGACE": [
"RoomKeyId",
"ReplicationId",
"RoomId",
"Name",
"Description",
"Price",
"PurchaseCurrencyId",
"CreatedAt",
"ImageName"
],
"ANNOJCNMHIG": [
"CustomAvatarItemId",
"CreatorAccountId",
"Name",
"Description",
"Price",
"Accessibility",
"IsFeatured",
"BaseAvatarItemId",
"BaseAvatarItemColor",
"DesignFilename",
"ThumbnailImageFilename",
"CreatedAt",
"ModifiedAt"
],
"ANPJKOOEEBB": [
"Identifier",
"Genuine",
"GenuineCheckAvailable"
],
"AOAACMFGAGK": [
"ChallengeMapId",
"ChallengeThemeString",
"Challenges",
"Gift",
"FallbackGiftName",
"StartAt",
"EndAt",
"ServerTime",
"CompletedRequired"
],
"APOJHADGCNA": [
"Ids",
"CreatedAts",
"ConsumableItemDesc",
"Count",
"InitialCount",
"IsActive",
"ActiveDurationMinutes",
"IsTransferable"
],
"BAEBDMPPKKE": [
"Take",
"ContinuationToken",
"InjectedFeedItems",
"FeedInstanceId"
],
"BBACJMOPFPE": [
"ProgressionEventRewardId",
"GiftDropId",
"ImageName",
"Xp",
"RewardIndex",
"IsBonus"
],
"BBEFAELEANL": [
"BalanceUpdates",
"Balance",
"CurrencyType",
"Platform"
],
"BBFGGPEMLLE": [
"Value",
"Success",
"Error"
],
"BBMJLKMGFGA": [
"Msg",
"Id"
],
"BCEJDGGBBHC": [
"Price",
"CurrencyId"
],
"BCMOCNHAJCL": [
"Status",
"Consumable"
],
"BGFBJCIOIFP": [
"SkuId",
"Name",
"Description",
"ImageName",
"Price",
"OculusSkuId",
"AppleProductId",
"PsnProductLabel",
"XboxProductId",
"XboxStoreId",
"GooglePlaySkuId",
"PicoSkuId",
"IsSingleUse",
"ShouldAppearInTokenStore",
"Data"
],
"BIMHOENEGII": [
"Group",
"IsCompleted",
"ClearedAt"
],
"BJHOKILBIML": [
"FeedItemType",
"FeedItemId"
],
"BKCOBJILFEI": [
"KeepsakeFeatureEnabled",
"KeepsakeRoomLimit",
"SocialXpBoostEnabled"
],
"BKFOGBGPLFP": [
"SavedImageId",
"ImageName",
"Username",
"PlayerId",
"RoomName",
"RoomId"
],
"BLADEIPEPOH": [
"SavedImageId",
"ImageName",
"PlayerId",
"RoomId",
"PlayerEventId",
"ClubId",
"Description",
"Accessibility",
"AccessibilityLocked",
"SavedImageType",
"CreatedAt",
"CheerCount",
"CommentCount"
],
"BLIBJIHOENF": [
"ClubId",
"Name",
"Description",
"MainImageName",
"State",
"CreatorAccountId",
"Category",
"Visibility",
"Joinability",
"AllowJuniors",
"MinLevel",
"MemberCount",
"IsRRO",
"ClubChatEnabled",
"ClubhouseRoomId",
"ClubType"
],
"BLPIIBBPOJM": [
"Action",
"Reason",
"Error"
],
"BLPNLMALDAC": [
"AccountId",
"Bio"
],
"BPAPIKPFDAH": [
"Difficulty",
"EN_US"
],
"BPHCOIBNCDP": [
"ClubId",
"Type",
"EditDetails",
"ApproveMember",
"CreateEvent",
"PostAnnouncement",
"EditPermissionSettings",
"BanUnban"
],
"CAAJPHFCIPK": [
"ReportCategory",
"Reason"
],
"CAEHDIMCGLD": [
"Msg",
"Id"
],
"CAKCKAMAAPP": [
"ClubId",
"LastAnnouncementId",
"LastReadAnnouncementId",
"Announcements"
],
"CBKDKACJDOF": [
"ChatMessageId",
"ChatThreadId",
"SenderPlayerId",
"TimeSent",
"Contents",
"ModerationState"
],
"CBNEDELBFAJ": [
"Clubs",
"TotalClubs",
"ContinuationToken"
],
"CCAGOJFGLLO": [
"FeedItemType",
"FeedItemId",
"FeedAlgorithmVersion"
],
"CCKEEAMMHPJ": [
"UnityAssetId",
"CreatedByAccountId",
"BakedUnityAssets"
],
"CDEDNEPPPND": [
"LevelProgressionMaps",
"DailyObjectives",
"ServerMaintenance",
"AutoMicMutingConfig",
"StorefrontConfig",
"RoomKeyConfig",
"RoomCurrencyConfig",
"ShareBaseUrl"
],
"CEAMINNJFGO": [
"Left",
"Right",
"Spine",
"Pelvis",
"Root",
"Torso",
"Body",
"Hips",
"Neck",
"Chest",
"Head",
"Arm",
"Hand",
"Wrist",
"Elbow",
"Palm",
"Leg",
"Thigh",
"Calf",
"Femur",
"Knee",
"Foot",
"Ankle",
"Hip",
"Tail",
"Eye",
"Dummy",
"Collar",
"Clavicle",
"Finger",
"Index",
"Mid",
"Pinky",
"Thumb",
"Adjust",
"Twist",
"Toe",
"Lid",
"Brow",
"Lash"
],
"CENEMCMGDKG": [
"Tags",
"PlayerEventId",
"CreatorPlayerId",
"RoomId",
"SubRoomId",
"ClubId",
"Name",
"Description",
"ImageName",
"StartTime",
"EndTime",
"AttendeeCount",
"Accessibility",
"IsMultiInstance",
"SupportMultiInstanceRoomChat",
"DefaultBroadcastPermissions",
"CanRequestBroadcastPermissions",
"BroadcastingRoomInstanceId"
],
"CGACOKHBJCB": [
"Msg",
"Id"
],
"CGCKIMIEHNM": [
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"CHEMHPGCJBM": [
"PlayerId",
"Score",
"Rank"
],
"CHKOLCGMCKJ": [
"ReportBudget",
"FilterType",
"SampleRate",
"LogLineCount",
"CaptureNativeCrashes",
"ANRThresholdMs",
"MessageCount",
"MessageRegex",
"VersionRegex"
],
"CICAKEDCJLA": [
"Email",
"Phone",
"Birthday",
"JuniorState",
"ParentAccountId",
"AvailableUsernameChanges",
"AccountId",
"UserName",
"DisplayName",
"DisplayEmoji",
"ProfileImage",
"BannerImage",
"TreatAsJunior",
"HasBirthday",
"PersonalPronouns",
"IdentityFlags",
"CreatedAt",
"IsJunior"
],
"CICEHLNDLDE": [
"RoomConsumableId",
"AccountId",
"Count",
"ConcurrencyCode",
"ModifiedAt",
"Consumable"
],
"CIKAJCJMOFJ": [
"MembershipRequestId",
"AccountId",
"InviterAccountId",
"ClubId",
"MembershipType",
"MembershipStatus",
"CreatedAt"
],
"CJPKAAJBLAJ": [
"Id",
"Name",
"Description",
"StartDate",
"EndDate",
"WasManuallyClosed",
"TestCases",
"Tags",
"NumTestCases",
"NumPassedTestCases",
"NumFailedTestCases"
],
"CLHBPPNOLHP": [
"Version",
"ButtonNumber",
"Override",
"CustomRoomName",
"CustomTitle",
"CustomDescription",
"DefaultRoomName",
"DefaultTitle",
"DefaultDescription"
],
"CLIANCNHNIE": [
"AvatarItemType",
"AvatarItemDesc",
"FriendlyName",
"Tooltip",
"Rarity",
"TagList",
"AvatarItemId",
"IsBaseAvatarItem"
],
"CMBHBADOOAF": [
"Msg",
"Id"
],
"CMHEJKENPBH": [
"TriggerType",
"TriggerId",
"TriggeredAt"
],
"CMLNDGCIPIH": [
"Index",
"Group",
"Progress",
"VisualProgress",
"IsCompleted",
"HasClaimedReward"
],
"CNFFPECMOHE": [
"Value",
"Success",
"Error"
],
"CNNAFLNKDCL": [
"AccountId",
"KeepsakeInstanceId",
"CollectedAt"
],
"CNNEAFEIMPP": [
"Msg",
"Id"
],
"COIELAHKALG": [
"Enabled",
"Key",
"Region"
],
"DCECPNPOEFG": [
"Msg",
"Id"
],
"DDDLCGDEPNH": [
"PlayerEventResponseId",
"PlayerEventId",
"PlayerId",
"CreatedAt",
"Type"
],
"DDFBPFOBIAO": [
"ImageName",
"Accessibility"
],
"DDFNNBDLBKF": [
"AccountId",
"ClubId",
"SubscriberCount"
],
"DEIDIGKKLHE": [
"Results",
"TotalResults"
],
"DFKDBJMNKCO": [
"Event",
"NewSubscription"
],
"DHAPGKPJDKH": [
"Value",
"Success",
"Error"
],
"DHMKNFOLCGM": [
"Value",
"Success",
"Error"
],
"DICIOFKDIEE": [
"Season",
"Name",
"StartAt",
"EndAt",
"CurrencyType",
"EliteUpgrade",
"Tiers",
"PersonalDetails",
"StorefrontType",
"NextUpdate",
"NewUntil"
],
"DIEEOOKELHG": [
"AccountId",
"IsRecentHistoryVisible"
],
"DILNAKMDJOF": [
"RoomId",
"SubRoomId",
"KeepsakeCategory"
],
"DJCLNEEBAJP": [
"Value",
"Success",
"Error"
],
"DJFEOAGKKAK": [
"FreshnessSeconds",
"Url"
],
"DJFIAOIFLDI": [
"Memberships",
"TotalMemberships",
"ContinuationToken"
],
"DJHIABKJLBD": [
"RoomInstanceId",
"PhotonAccessToken",
"Permissions"
],
"DKHBLLDMDHB": [
"Msg",
"Id"
],
"DKMBKPLABAG": [
"Type",
"Score"
],
"DKNKIEOODHK": [
"Value",
"Success",
"Error"
],
"DLJCLHBGADL": [
"Msg",
"Id"
],
"DNBAAJILMPE": [
"RoomId",
"ClubId",
"PlayerCount"
],
"DOINHLNFOCP": [
"Msg",
"Id"
],
"DOJEJNECAAI": [
"Value",
"Success",
"Error"
],
"DOMOBFDIAJA": [
"AccountId",
"CheeredAt"
],
"DPHFOPBAAPN": [
"PlayerId",
"StatChannel",
"RoomId",
"FilterType",
"SortAscending"
],
"DPIECEMDKPL": [
"CreatorPlayerId",
"Data",
"IsValid"
],
"DPKHIGPMCIP": [
"InviteId",
"Name"
],
"DPNLANKOHON": [
"AccountId",
"CurrencyId",
"Balance",
"ModifiedAt"
],
"DatatypeImplementation": [
"ENTITIES",
"ENTITY",
"ID",
"IDREF",
"IDREFS",
"NMTOKEN",
"NMTOKENS",
"NOTATION"
],
"EACKMEGHIGM": [
"AccountId",
"ClubId",
"CreatedAt"
],
"ECEBBLBCFKO": [
"AccountId",
"BannedByAccountId",
"BanStartTime"
],
"ECHJAKNMEEC": [
"KeepsakeRoomListId",
"ProgressionEventId",
"UnlockItemAvatarItemId",
"UnlockItemGiftDropId",
"UnlockItemLockDurationTicks",
"RoomUnlockStartOffsetTicks",
"RoomUnlockIntervalTicks",
"RoomUnlockBatchSize",
"RoomType",
"KeepsakeRooms"
],
"ECIINMENOGP": [
"BalanceAddType",
"Delta",
"Balance",
"Platform",
"CurrencyType"
],
"EDMKBBPGJAB": [
"Balance",
"RoomKeyResponse"
],
"EEAAGIJHLOJ": [
"PlayerEvent",
"Result",
"TagModifyResult"
],
"EHFKGCIDOFL": [
"Results",
"TotalResults"
],
"EHKEOCKBCDH": [
"Items",
"FeedInstanceId",
"FeedContext",
"FeedAlgorithmVersion"
],
"EIFIAMHKFAG": [
"PurchaseReminderId",
"SkuId",
"TokenPrice",
"Title",
"Description",
"ImageName",
"EndDate",
"BonusGiftDrops"
],
"EJEJHJPCBOE": [
"KeepsakeRoomId",
"RoomId",
"KeepsakeRoomListId",
"Type",
"Order"
],
"EJIHDJDKKEC": [
"AccountId",
"RoomKeyId"
],
"EJNFJPNOFFO": [
"Msg",
"Id"
],
"EKEFFNBIOHJ": [
"ChatThreadId",
"LastReadMessageId",
"Messages",
"LatestMessage",
"PlayerIds",
"ChatThreadName",
"SnoozedUntil",
"IsFavorited",
"ClubId"
],
"EKHJKDNHOPB": [
"OutfitSelections",
"OutfitSelectionsV2",
"FaceFeatures",
"SkinColor",
"HairColor",
"CustomAvatarItems"
],
"EKMLNNNABAD": [
"UnityAssetId",
"RoomData",
"SubRoomData",
"InventionUsage",
"PersistenceVersion",
"Description",
"AutoPublish"
],
"EMBCEDNHFLB": [
"GiftDropId",
"FriendlyName",
"Tooltip",
"TagList",
"ConsumableItemDesc",
"AvatarItemDesc",
"AvatarItemType",
"EquipmentPrefabName",
"EquipmentModificationGuid",
"IsQuery",
"Unique",
"SubscribersOnly",
"Rarity",
"AvatarItemId",
"CurrencyType",
"Currency",
"Context",
"ItemSetId",
"ItemSetFriendlyName"
],
"EMJAJEHCIFN": [
"MarketingEmails",
"InviteEmails",
"GameClientTextInvites",
"FriendRequestEmails",
"CreatorPublishedNewRoomEmails",
"MyRoomSummaryEmails",
"NewPhotoTagEmails",
"PlayerEventInviteEmails",
"PlayerEventReminderEmails"
],
"ENJDEONHNMO": [
"Created",
"Responses"
],
"EOFEKNIHNNC": [
"PlayerEventId",
"BroadcastRoomInstanceId"
],
"EOILOIMIJLJ": [
"Value",
"Success",
"Error"
],
"EOIMIBBJBCB": [
"ImageName",
"Title",
"Subtitle"
],
"EPPPACFECMH": [
"Type",
"Reference"
],
"EncodingTable": [
"Big5",
"GB2312",
"IBM850",
"IBM852",
"IBM857",
"IBM861",
"IBM869"
],
"FAMGNALGHPN": [
"Msg",
"Id"
],
"FBNOJCDDBJK": [
"XPAwardStrings",
"TotalXPAwarded",
"NewProgress"
],
"FCIBLPCOODP": [
"RoomConsumableId",
"RoomId",
"Name",
"Description",
"ImageName",
"Price",
"PurchaseCurrencyId",
"ModifiedAt"
],
"FCJNIBGKHDM": [
"ClubId",
"MembershipType"
],
"FDPFICCCJNM": [
"Msg",
"Id"
],
"FEJOMKJBOOE": [
"RoomId",
"EarningsDistributionMapping",
"EarningsDistributionMethod"
],
"FIFFMMLKKCJ": [
"PlayerId",
"Level",
"XP"
],
"FJJIJOGNMCP": [
"ReferralRewardTierId",
"RequiredReferralCount",
"RewardSelectionCount"
],
"FJNBDGFCLOF": [
"Value",
"Success",
"Error"
],
"FKANCKLCCDI": [
"Id",
"FromPlayerId",
"ConsumableItemDesc",
"AvatarItemType",
"AvatarItemDesc",
"EquipmentPrefabName",
"EquipmentModificationGuid",
"CurrencyType",
"Currency",
"Xp",
"GiftContext",
"GiftRarity",
"Message",
"Platform",
"PlatformsToSpawnOn",
"BalanceType"
],
"FKPPDBLFKHJ": [
"Msg",
"Id"
],
"FLEDNKHKIND": [
"InfluencerIds",
"ContinuationToken"
],
"FLELPHJDLNG": [
"Filename",
"Hash",
"OwnershipProof"
],
"FLMCOHEGJEI": [
"RequiresEliteUpgrade",
"GiftDrop",
"SeasonXpBoost"
],
"FMIDLABJCLM": [
"Strings",
"StringPackageVersion"
],
"FMKALNIKMKF": [
"AccountId",
"MembershipType"
],
"FMNDPHMOIIH": [
"Msg",
"Id"
],
"FNDBGOGAIFL": [
"Value",
"Success",
"Error"
],
"FNIDAIDJBBO": [
"PlayerID",
"RelationshipType",
"Muted",
"Ignored",
"Favorited"
],
"FNNAJDFFICN": [
"ReportId",
"Client"
],
"FOIMFMEEGDI": [
"SupportsJoinInProgress",
"UseLevelBasedMatchmaking",
"UseAgeBasedMatchmaking",
"UseRecRoyaleMatchmaking",
"SubRoomId",
"RoomId",
"UnitySceneId",
"Name",
"UnityAssetId",
"UnityAsset",
"UnityAssetHash",
"DataBlob",
"DataBlobHash",
"DataSavedAt",
"IsSandbox",
"MaxPlayers",
"Accessibility"
],
"FOKKDBJEFOO": [
"Value",
"Success",
"Error"
],
"FOOOKJCPBKC": [
"Msg",
"Id"
],
"FPGBBCLENDA": [
"StorefrontType",
"NextUpdate",
"NewUntil"
],
"FPLGJGDJIJG": [
"StorefrontType",
"PurchasableItemId",
"CurrencyType",
"RequestedPrice",
"CouponConsumablePlayerMappingId",
"Gift"
],
"FPNMHECOPPF": [
"UpdateResponse",
"Data"
],
"FPNOOOFJBJF": [
"RankStart",
"RankEnd",
"PlayerId",
"StatChannel",
"RoomId",
"FilterType",
"SortAscending"
],
"FastDualShock4GamepadHID": [
"Dpad",
"Square",
"Cross",
"Circle",
"Triangle"
],
"FastKeyboard": [
"AnyKey",
"Escape",
"Space",
"Enter",
"Tab",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"Shift",
"Alt",
"Backspace",
"Home",
"End",
"Insert",
"Delete",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12"
],
"FastMouse": [
"Pressure",
"Radius",
"Position",
"Delta",
"Scroll",
"Press"
],
"FastTouchscreen": [
"Pressure",
"Radius",
"Position",
"Delta",
"Press"
],
"GACOLGJKHBI": [
"VersionStatus",
"UpdateNotificationStage"
],
"GAGIJHJGFJD": [
"AccountId",
"CurrencyId",
"Balance",
"AmountAwarded",
"AwardedAt"
],
"GAODOHODBDE": [
"FailedInvites",
"Result"
],
"GBPDOLJBABB": [
"Success",
"Message"
],
"GBPHFPMCGHH": [
"Subscription",
"PlatformAccountSubscribedPlayerId"
],
"GCKGIDLDBNH": [
"Error",
"Error_description",
"Access_token",
"Refresh_token",
"Key"
],
"GDGCOAMMCIH": [
"MicSpamVolumeThreshold",
"MicVolumeSampleInterval",
"MicVolumeSampleRollingWindowLength",
"MicSpamSamplePercentageForWarning",
"MicSpamSamplePercentageForWarningToEnd",
"MicSpamSamplePercentageForForceMute",
"MicSpamSamplePercentageForForceMuteToEnd",
"MicSpamWarningStateVolumeMultiplier"
],
"GEEKOKDMAHG": [
"AccountId",
"ApiKey"
],
"GEGADPCDGBO": [
"Tag",
"Type"
],
"GEMBOCLPFGE": [
"AccountId",
"FavoritedAt"
],
"GFCIGEIEPGH": [
"Msg",
"Id"
],
"GFGBAAADKEL": [
"Msg",
"Id"
],
"GFGKDCENJMB": [
"AccountId",
"Xp",
"GameMinutesToday",
"RewardsCollected",
"BonusRewardsCollected",
"XpBoostLastPurchasedAt"
],
"GFHNJMMBHFD": [
"PlayerEventId",
"Type"
],
"GFIHPOOFHLC": [
"AnnouncementId",
"CreatorAccountId",
"ClubId",
"Title",
"Body",
"ImageName",
"CreatedAt",
"Meta"
],
"GFKKOADFJKC": [
"CurrencyId",
"RecipientId",
"Amount",
"TransactionId"
],
"GFMOBMCIOJI": [
"AccountId",
"IsCheerful",
"SelectedCheer",
"CheerCredit",
"CheerGeneral",
"CheerHelpful",
"CheerCreative",
"CheerGreatHost",
"CheerSportsman"
],
"GGFHKCDELJH": [
"Id",
"Link"
],
"GHBIGGJKPFI": [
"Events",
"ContinuationToken"
],
"GIFNHCGDNEM": [
"Platform",
"PlatformId",
"AccountId",
"LastLoginTime",
"RequirePassword",
"RefreshToken"
],
"GIJCKEDKGLH": [
"ReportCategory",
"PlayerEventId",
"Details"
],
"GJGLBNABMNL": [
"CreatorAccountId",
"ClubId",
"MembershipType"
],
"GKFBDCHILOD": [
"Value",
"Success",
"Error"
],
"GLEGPFFPDBE": [
"DataBlob",
"DataBlobHash",
"SubRooms",
"Roles",
"Tags",
"LoadScreens",
"PromoImages",
"PromoExternalContent",
"RoomId",
"IsDorm",
"MaxPlayerCalculationMode",
"MaxPlayers",
"PersistenceVersion",
"CloningAllowed",
"DisableMicAutoMute",
"DisableRoomComments",
"EncryptVoiceChat",
"ToxmodEnabled",
"LoadScreenLocked",
"IsDeveloperOwned",
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"GLENKGBDMAO": [
"Result",
"Code",
"Url",
"RedemptionUrl"
],
"GMBGBPNMGBA": [
"Status",
"Invention",
"InventionVersion"
],
"GMDIDJMOEBB": [
"Objectives",
"ObjectiveGroups"
],
"GMFJIAMOIEO": [
"SubscriptionId",
"RecNetPlayerId",
"PlatformType",
"PlatformId",
"PlatformPurchaseId",
"Level",
"Period",
"ExpirationDate",
"IsAutoRenewing",
"CreatedAt",
"ModifiedAt"
],
"GMNMHECGEGD": [
"CurrencyType",
"BalanceAdds"
],
"GNIPAKCCOOE": [
"ProgressionEventId",
"Name",
"Rewards",
"KeepsakeRoomLists",
"StartTime",
"EndTime",
"CollectionEndTime",
"UsesBoost",
"BoostDailyGameplayMinutesLimit",
"BoostXpMultiplier",
"PurchasableXpBoostId",
"ActiveExperiment",
"ChallengesIconImageName",
"RewardsPipImageName",
"EventInfoImageName"
],
"GNMDEJJAHCJ": [
"SubscriberDiscountPercent",
"StorefrontType",
"NextUpdate",
"NewUntil",
"StoreItems"
],
"GOHGJADGDJF": [
"ReplicationId",
"DataBlob",
"SubRooms",
"Tags",
"UnityAssets",
"RoomId",
"IsDorm",
"MaxPlayerCalculationMode",
"MaxPlayers",
"PersistenceVersion",
"CloningAllowed",
"DisableMicAutoMute",
"DisableRoomComments",
"EncryptVoiceChat",
"ToxmodEnabled",
"LoadScreenLocked",
"IsDeveloperOwned",
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"GUISkin": [
"HorizontalSliderThumbExtent",
"VerticalSliderThumbExtent"
],
"HADOHMCCLEO": [
"Msg",
"Id"
],
"HBKAFCAEFCC": [
"CreatedAt",
"Comment"
],
"HDLOMAGLILM": [
"Msg",
"Id"
],
"HDOJMEBCAGP": [
"Permission",
"Role",
"Override",
"Type",
"Value"
],
"HECILPKCKMK": [
"FeaturedRoomGroupId",
"Name",
"Rooms"
],
"HEIPMIFEFEE": [
"Value",
"Success",
"Error"
],
"HEKMOMJFFGJ": [
"Value",
"Success",
"Error"
],
"HFHNOHAODOF": [
"ValidTill",
"Images"
],
"HFODJDLIJPG": [
"TargetPlayerId",
"RoomName",
"ActionCode"
],
"HGJDGKFIOCK": [
"Msg",
"Id"
],
"HHJLCKPJOIA": [
"PlayerId",
"StatusVisibility",
"DeviceClass",
"VrMovementMode",
"RoomInstance",
"IsOnline",
"AppVersion",
"ClientJoinData"
],
"HHOJLJGGNKN": [
"Msg",
"Id"
],
"HIKOJENMDKE": [
"KeepsakeCategoryId",
"VisualId",
"LimitPerRoom",
"XpValue",
"IconOutlineImageName",
"IconFilledImageName"
],
"HIOJNFKGAOK": [
"Value",
"Success",
"Error"
],
"HIOPBEPNFOO": [
"Value",
"Success",
"Error"
],
"HJKLIIPPPEM": [
"PlayerEvent",
"PlayerEventResponse"
],
"HJOIJBMAIPO": [
"PlaylistId",
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"HJOKFJKKEJM": [
"BalanceUpdateResult",
"TokenBalanceResponse",
"OperationResult"
],
"HJPEABLEEKL": [
"RoomConsumableId",
"RoomId",
"Name",
"Description",
"ImageName",
"PriceAndCurrency"
],
"HKAGNIGNBOJ": [
"Msg",
"Id"
],
"HKIJINJGBGD": [
"Type",
"User_id",
"Identifiers",
"Ip",
"Gameplay_session_id",
"Transaction_id"
],
"HKPKGOPHLHE": [
"RoomInstanceCount",
"PlayerCount"
],
"HLDLKOPNDPF": [
"Value",
"Success",
"Error"
],
"HMDONHHKKGA": [
"PinnedFilters",
"PopularFilters",
"TrendingFilters"
],
"HMGHGBMGNJG": [
"Msg",
"Id"
],
"HMKMGPNMHBM": [
"Instances",
"CollectionRecords",
"KeepsakeProgressionEventIds"
],
"HMMFNEEAFGP": [
"Results",
"TotalResults"
],
"HNLGHDMNMDB": [
"UpdateResponse",
"Data"
],
"HOGIMIPDDGP": [
"ImageName",
"ImageUrl"
],
"HPAKKJFOFDF": [
"Msg",
"Id"
],
"HPEGLGGMNMC": [
"ChatThread",
"ChatResult"
],
"HPMMKFGDAEC": [
"PlayerEventId",
"CreatorPlayerId",
"RoomId",
"SubRoomId",
"ClubId",
"Name",
"Description",
"ImageName",
"StartTime",
"EndTime",
"AttendeeCount",
"Accessibility",
"IsMultiInstance",
"SupportMultiInstanceRoomChat",
"DefaultBroadcastPermissions",
"CanRequestBroadcastPermissions",
"BroadcastingRoomInstanceId"
],
"IACBFJEMJMJ": [
"Value",
"Success",
"Error"
],
"IAPMDDFENDD": [
"PurchasableItemId",
"CouponConsumablePlayerMappingId",
"Count",
"CreatorPlayerId",
"RequestedPrice"
],
"IBAPHBCOBHL": [
"BalanceUpdates",
"Balance",
"CurrencyType",
"Platform"
],
"IBILPLGNAJE": [
"Id",
"ImageName",
"PlayerId",
"RoomId",
"PlayerEventId",
"Accessibility",
"AccessibilityLocked",
"Type",
"CreatedAt",
"TaggedPlayerIds",
"CheerCount",
"CommentCount"
],
"ICMBGNCJMJG": [
"Value",
"Success",
"Error"
],
"ICNLODNGFJJ": [
"Value",
"Success",
"Error"
],
"IDBFKCFEAHM": [
"BalanceUpdateResult",
"CurrencyBalanceResponse",
"OperationResult"
],
"IDBLMMAGACO": [
"Key",
"Value"
],
"IDFEIMCEMCC": [
"Level",
"Period",
"IsAutoRenewing"
],
"IEIKHFIPDMD": [
"CurrencyType",
"Price",
"StorefrontSaleData"
],
"IFFEHJEPKOH": [
"ReferralsVerifiedCount",
"PlayerReferralRewards"
],
"IFLDEEGLKFG": [
"Level",
"RequiredXp",
"GiftDropId"
],
"IGMCJEFBJCF": [
"ServerTime",
"PlayerId",
"StatusVisibility",
"DeviceClass",
"VrMovementMode",
"RoomInstance",
"IsOnline",
"AppVersion",
"ClientJoinData"
],
"IGNECOJDECH": [
"Info",
"ReferrerId"
],
"IHGDKHPLIGH": [
"RoomId",
"RoomName",
"ImageName"
],
"IJFKOMJBJMI": [
"StatChannel",
"RoomId",
"StatValue",
"CurrentStatValue"
],
"ILAHMLLJDLE": [
"Value",
"Success",
"Error"
],
"ILEHDPADJJF": [
"Nouns",
"Adjectives"
],
"ILJOBJDPBNJ": [
"Name",
"Description",
"Price",
"BaseAvatarItemId",
"BaseAvatarItemColor",
"Accessibility"
],
"IMMOJHBENFI": [
"Slot",
"PreviewImageName",
"OutfitSelections",
"OutfitSelectionsV2",
"FaceFeatures",
"SkinColor",
"HairColor",
"CustomAvatarItems"
],
"INLPIKBODME": [
"CreatorAccountId",
"BaseAvatarItemId",
"DesignFilename",
"BaseAvatarItemColor"
],
"IOEPPCKGBFL": [
"InventionId",
"ReplicationId",
"CreatorPlayerId",
"Name",
"Description",
"ImageName",
"CurrentVersionNumber",
"Accessibility",
"ModifiedAt",
"CreatedAt",
"FirstPublishedAt",
"CreationRoomId",
"NumPlayersHaveUsedInRoom",
"NumDownloads",
"CheerCount",
"CreatorPermission",
"GeneralPermission",
"IsAGInvention",
"IsCertifiedInvention",
"AllowTrial",
"Price",
"HideFromPlayer"
],
"IPMGNJMIONC": [
"Revenue_currency",
"Revenue_amount",
"Revenue_type",
"Type",
"User_id",
"Identifiers",
"Ip",
"Gameplay_session_id",
"Transaction_id"
],
"JAFDEPHGOAF": [
"Title",
"Message",
"ImageName",
"ActiveExperiments",
"Context",
"InputType",
"Visibility",
"AllowCycling",
"PlatformMask",
"RestrictToNewUsers",
"RoomNames"
],
"JAHJGFHFKIB": [
"Club",
"CustomTags",
"AdditionalImages",
"CoownerPermissions",
"ModeratorPermissions",
"MemberPermissions",
"MyMembershipType"
],
"JBGKGCLPCHP": [
"Results",
"TotalResults"
],
"JBIEJIKHBMG": [
"SeedRoom",
"Rooms"
],
"JCPFCOEEELK": [
"AccountId",
"CurrencyId",
"Success",
"Error",
"Response"
],
"JDEKAFDPGBE": [
"ReplicationId",
"PersistenceVersion",
"SupportsJoinInProgress",
"UseLevelBasedMatchmaking",
"UseAgeBasedMatchmaking",
"UseRecRoyaleMatchmaking",
"SubRoomId",
"RoomId",
"UnitySceneId",
"Name",
"UnityAssetId",
"UnityAsset",
"UnityAssetHash",
"DataBlob",
"DataBlobHash",
"DataSavedAt",
"IsSandbox",
"MaxPlayers",
"Accessibility"
],
"JEBCHNDADKK": [
"ConcurrencyCodes",
"ExpectedPriceAndCurrency"
],
"JFEKPNOKFBP": [
"Msg",
"Id"
],
"JGJFCFAFGCM": [
"TotalXP",
"Level",
"RankIdx",
"RankName",
"CurrentLevelXPThreshold",
"NextLevelXPThreshold",
"NextLevelAcornReward"
],
"JHNAJMDHFKH": [
"Msg",
"Id"
],
"JICIOMDBPDO": [
"Id",
"ConsumableItemDesc",
"Count",
"InitialCount",
"CreatedAt",
"ActiveDurationMinutes",
"IsActive",
"IsTransferable"
],
"JIOEIEENGNO": [
"Msg",
"Id"
],
"JJEKBGHDCEG": [
"Tier",
"Rewards",
"PurchasableItemId",
"Type",
"Prices",
"SubscriberPrices",
"IsFeatured",
"AvailableAt",
"AvailableUntil",
"NewUntil"
],
"JJGEJHCFEJH": [
"GiftDropId",
"ConsumableItemDesc",
"AvatarItemDesc",
"AvatarItemType",
"EquipmentPrefabName",
"EquipmentModificationGuid",
"StorefrontType",
"Xp",
"Level",
"GiftContext",
"GiftRarity"
],
"JKJHOAKNOCF": [
"CurrentConcurrencyCode",
"NewConcurrencyCode"
],
"JLIBBFMMLFF": [
"CurrencyType",
"BalanceAddType",
"BaseAward",
"BonusAwardMin",
"BonusAwardMax",
"RateLimitType",
"IgnorePartialMultiplier",
"MaxPartialMultiplier",
"RateLimit",
"BalanceInGiftBox"
],
"JLNFEFHNFBP": [
"Title",
"Description"
],
"JLNKMODEKKM": [
"BaseAvatarItemId",
"BaseAvatarItemColor"
],
"JMJLMOJCBCK": [
"UpdateResponse",
"Data"
],
"JNFDPPCIPHG": [
"CategoryId",
"Importance",
"Name",
"Description",
"IsMuteable"
],
"JOMMALIPDGI": [
"RoomId",
"IsDorm",
"MaxPlayerCalculationMode",
"MaxPlayers",
"PersistenceVersion",
"CloningAllowed",
"DisableMicAutoMute",
"DisableRoomComments",
"EncryptVoiceChat",
"ToxmodEnabled",
"LoadScreenLocked",
"IsDeveloperOwned",
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"JPKBLKGJNCA": [
"Msg",
"Id"
],
"JPLIIMKJAEL": [
"AccountId",
"DisallowInAppPurchases"
],
"JPNMBICAMEE": [
"RoomInstanceId",
"RoomId",
"SubRoomId",
"IsFull",
"CreatedAt",
"PlayerIds"
],
"JsonParser_NestedType_JsonValue": [
"False",
"True"
],
"KAONMCEONPF": [
"Name",
"Price",
"ProductId"
],
"KBHFIPFPCMN": [
"Msg",
"Id"
],
"KBLOCDGCGFA": [
"Msg",
"Id"
],
"KBMAAKAIPLE": [
"UpdateResponse",
"Data"
],
"KCMDEFMLDJF": [
"Balance",
"CurrencyType",
"BalanceType",
"Data"
],
"KDAHIOHGPBN": [
"Value",
"Success",
"Error"
],
"KDJILJAIDFG": [
"RoomId",
"Count"
],
"KEOLHAONMIF": [
"Value",
"Success",
"Error"
],
"KFAOOJLFCGC": [
"Msg",
"Id"
],
"KFPHOKPEMOF": [
"ItemType",
"ItemId",
"Name",
"Description",
"ImageName",
"RoomId",
"Price",
"PurchaseCurrencyId",
"CreatedAt",
"ModifiedAt"
],
"KGNFNBHMAFL": [
"Value",
"Success",
"Error"
],
"KHBGNAMFAHK": [
"Msg",
"Id"
],
"KHFKIJEGPDG": [
"AccountId",
"RoomKeyId",
"DoesPlayerOwnRoomKey"
],
"KIANAKFJGCP": [
"FeaturedPlayer",
"FeaturedRoomGroup",
"CurrentAnnouncement",
"InstagramImages",
"Videos"
],
"KILGEFOENFN": [
"BalanceUpdates",
"Balance",
"CurrencyType",
"Platform"
],
"KJEIGGBIIHP": [
"SavedImageId",
"IsCheered"
],
"KKOHONLGIHL": [
"Id",
"FromPlayerId",
"SentTime",
"Type",
"Data",
"RoomId",
"PlayerEventId"
],
"KLHCKBPJKPF_NestedType_DHKPMMHMIJF_NestedType_KKNKAJBPNPC": [
"SerializeUnsafe",
"ToJsonString"
],
"KMBHAKAHNGH": [
"Room",
"SubRoomDataSave"
],
"KMHNOGNHDLO": [
"SubscriptionSeasonMilestoneId",
"SeasonId",
"StartAt",
"GiftDrops"
],
"KNGMIABFJHK": [
"CurrencyBalanceResponse",
"TokenBalanceResponse"
],
"KOEEACLMAFE": [
"Referrals",
"ContinuationToken"
],
"KPEIHOCIGGE": [
"Msg",
"Id"
],
"KPMMGJELBPP": [
"WindowSize",
"PlayerId",
"StatChannel",
"RoomId",
"FilterType",
"SortAscending"
],
"KPNBKKKCOCA": [
"UnlockedAt",
"ClaimedAt",
"Status",
"ReferralRewardTier",
"IsCurrentTier"
],
"KPPINBHEKHE": [
"Products",
"Identifier",
"Genuine",
"GenuineCheckAvailable"
],
"LAIJIAIHGCO": [
"ReportCategory",
"Duration",
"GameSessionId",
"IsHostKick",
"Message",
"PlayerIdReporter",
"IsBan",
"IsVoiceModAutoban",
"IsWarning",
"VoteKickReason",
"TimeoutStartedAt"
],
"LBOGFNBPIKJ": [
"SubRoomId",
"RoomId",
"UnitySceneId",
"Name",
"UnityAssetId",
"UnityAsset",
"UnityAssetHash",
"DataBlob",
"DataBlobHash",
"DataSavedAt",
"IsSandbox",
"MaxPlayers",
"Accessibility"
],
"LDGHHCCJAMC": [
"Status",
"RoomKey"
],
"LEEAJGDIOHI": [
"Value",
"Success",
"Error"
],
"LEINHCHGFON": [
"Msg",
"Id"
],
"LEMIBENHABD": [
"Value",
"Success",
"Error"
],
"LIJHBNDJKNP": [
"RewardSelectionId",
"RewardType",
"Message",
"GiftContext",
"GiftDrop1",
"GiftDrop2",
"GiftDrop3",
"Subscriber_GiftDrop3",
"CreatedAt"
],
"LIODMDILCAJ": [
"Value",
"Success",
"Error"
],
"LKBGCOELLIN": [
"CurrencyId",
"PurchaseOffers"
],
"LKJLPNENINM": [
"RoomKeyResponse",
"BalanceUpdateResponse"
],
"LKJPKMBEPCG": [
"PrefabName",
"ModificationGuid",
"FriendlyName",
"Tooltip",
"Rarity",
"Favorited"
],
"LKLKHEEHEBC": [
"UnityAssetId",
"Target",
"Version",
"Filename",
"Hash"
],
"LKMEPPOFHLK": [
"Msg",
"Id"
],
"LMCCNLLHLCJ": [
"Success",
"Error"
],
"LMEECKCCPHG": [
"AccountId",
"Role",
"LastChangedByAccountId",
"InvitedRole"
],
"LMHKHIJIDEO": [
"SavedImageId",
"Cheer"
],
"LNMAMOCCBAK": [
"Filename",
"UnityAssetId",
"CreatedByAccountId",
"BakedUnityAssets"
],
"LOEHGGMCKAM": [
"ChatResult",
"SnoozedUntil"
],
"LOOLOADLJPG": [
"SubscriptionSeasonId",
"Name",
"ImageName",
"StartAt",
"NextSeasonStart",
"Milestones"
],
"LPAEOGFPGBD": [
"UnitySceneId",
"Name",
"MaxPlayers",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile"
],
"LPFAFIGAIOE": [
"Status",
"InventoryItem"
],
"MACBLLENLFD": [
"Value",
"Success",
"Error"
],
"MADLMAFINGF": [
"KeepsakeInstanceId",
"KeepsakeCategoryConfigId",
"PlacedByAccountId",
"RoomId",
"SubRoomId"
],
"MAHHAAEGEEP": [
"InventionResponse",
"BalanceUpdateResponse"
],
"MBFADJPOGDI": [
"Msg",
"Id"
],
"MCDADMNCDEI": [
"WishlistItemId",
"AccountId",
"PurchasableItemId",
"CreatedAt"
],
"MCMPJPNBJBI": [
"Msg",
"Id"
],
"MEBAFOGDHBB": [
"PurchaseCampaignId",
"PurchaseReminder",
"Name",
"TriggerFlags",
"LastShown",
"ShowCount",
"DaysBetweenPopups",
"MaxPopups"
],
"MEEAEDNAPGI": [
"CommentId",
"RoomId",
"SubRoomId",
"AccountId",
"CreatedAt",
"Message",
"Style",
"Unread",
"PositionX",
"PositionY",
"PositionZ"
],
"MFNBIPCHKHC": [
"MembershipRequests",
"TotalMembershipRequests",
"ContinuationToken"
],
"MGOLBEMMNDA": [
"BalanceAddType",
"BaseAward",
"BonusAward",
"RateLimit",
"CurrentCount",
"Total",
"Platform",
"BalanceInGiftBox"
],
"MIFGPMGEMEK": [
"RefreshesAt",
"RefreshIntervalMinutes",
"Room"
],
"MIHDILLIAFE": [
"Instances",
"CollectionRecords"
],
"MIHJPKMLADF": [
"Msg",
"Id"
],
"MIHKAJFMIPE": [
"RoomId",
"CurrencyId"
],
"MJGHCJDOJLH": [
"Value",
"Success",
"Error"
],
"MJJDCIAIDKI": [
"HasEliteUpgrade",
"HasEliteUpgradePlatformMask",
"CurrentSeasonTier",
"ModifiedAt"
],
"MJKDDBKGNOP": [
"ContactDetail",
"AccountId",
"UserName",
"DisplayName",
"DisplayEmoji",
"ProfileImage",
"BannerImage",
"TreatAsJunior",
"HasBirthday",
"PersonalPronouns",
"IdentityFlags",
"CreatedAt",
"IsJunior"
],
"MJNABIBJDMO": [
"Order",
"Objective",
"Count"
],
"MKAIABAJIAJ": [
"CustomAvatarItemId",
"BodyPart"
],
"MKBLHNGJOBA": [
"Msg",
"Id"
],
"MLHBPDKBABN": [
"CheerCount",
"FavoriteCount",
"VisitorCount",
"VisitCount"
],
"MMDMDIJLIEH": [
"Msg",
"Id"
],
"MMFCMCPCAJP": [
"ChatMessage",
"ChatResult"
],
"MMPLMIAEBLE": [
"Msg",
"Id"
],
"MOJLGDKFECO": [
"InvitedPlayerId",
"Result"
],
"MOLNFPGNEGP": [
"Value",
"Success",
"Error"
],
"MOMDMDMGEOA": [
"ItemType",
"ItemId"
],
"Matchmaking": [
"PlayerId",
"RoomInstanceId"
],
"NAGNGCMPGOP": [
"RoomId",
"KeepsakeInstanceIds"
],
"NAPOKFHELLC": [
"Multiplier",
"BalanceAddType"
],
"NBGPICODFEE": [
"Value",
"Success",
"Error"
],
"NCBBKAMCPBC": [
"Msg",
"Id"
],
"NCHBANKEJMP": [
"Results",
"TotalResults"
],
"NDIIFIGNAAM": [
"Error",
"Identifier",
"Genuine",
"GenuineCheckAvailable"
],
"NEJOPOKDPKE": [
"Msg",
"Id"
],
"NEKEOJFNCCJ": [
"UpdateResponse",
"Data"
],
"NFBJOAGJHME": [
"Value",
"Success",
"Error"
],
"NGCPIKDINEI": [
"Provider",
"Data"
],
"NHGOFGABJIN": [
"RoomInstanceId",
"RoomId",
"SubRoomId",
"Location",
"EventId",
"ClubId",
"RoomCode",
"PhotonRegionId",
"PhotonRoomId",
"Name",
"MaxCapacity",
"IsFull",
"IsPrivate",
"IsInProgress",
"EncryptVoiceChat",
"RoomInstanceType"
],
"NIJPNDPJICF": [
"Id",
"Key",
"Title",
"Description",
"RoomName",
"Status",
"AssignedPlayerNames",
"Tags",
"JiraUrl",
"Comments"
],
"NKMEHOHCKOH": [
"Value",
"Success",
"Error"
],
"NLIAAGLJBNN": [
"Results",
"TotalResults"
],
"NMGHOIMEJIK": [
"Name",
"Description",
"Price",
"Accessibility"
],
"NNAGOPNGJFE": [
"AmplitudeKey",
"RudderStackKey",
"UseRudderStack",
"StatSigKey"
],
"OANOOFEPJCI": [
"Rooms",
"Tags",
"PlaylistId",
"Name",
"Description",
"ImageName",
"WarningMask",
"CustomWarning",
"CreatorAccountId",
"State",
"Accessibility",
"SupportsLevelVoting",
"IsRRO",
"SupportsScreens",
"SupportsWalkVR",
"SupportsTeleportVR",
"SupportsVRLow",
"SupportsQuest2",
"SupportsMobile",
"SupportsJuniors",
"MinLevel",
"CreatedAt",
"Stats",
"RankingContext"
],
"OBACBEGPKAK": [
"ReportCategory",
"Details",
"ReportedPlayerId"
],
"OBGIGMCOMDE": [
"AdCarouselItemId",
"ImageName",
"Title",
"Description",
"PurchasableItemIds",
"PurchaseReminderId"
],
"OBIBPCLDPGA": [
"Message",
"MoreInfoUrl"
],
"OCFNOBAONAP": [
"Cheered",
"Favorited",
"LastVisitedAt"
],
"OCGICPIMNEB": [
"ErrorCode",
"RoomInstance"
],
"ODNKLIIJIOH": [
"Msg",
"Id"
],
"OEHMNIAOAOH": [
"Value",
"Success",
"Error"
],
"OFGIPBHIAGF": [
"ProgressionEventPurchasableXpBoostId",
"Cost",
"XpMultiplier",
"XpCap",
"LookbackDurationTicks",
"CooldownDurationTicks"
],
"OFMNNCMPEPA": [
"SessionId",
"RoomId",
"SubRoomId",
"UnityAssetId",
"RoomData",
"SubRoomData",
"SavedByAccountId"
],
"OGCMIDEFOEE": [
"ChatResult",
"IsFavorited"
],
"OGEDLCFDDIO": [
"Msg",
"Id"
],
"OHNEMFHCBIJ": [
"Value",
"Success",
"Error"
],
"OKHGCKDIILD": [
"ImageName",
"Index"
],
"OLACHOEDKFB": [
"ReportCategory",
"YesVotes",
"NoVotes",
"PlayerIdReported"
],
"OLIHBGPDPHF": [
"Balance",
"CurrencyType",
"BalanceType",
"Data"
],
"OLKCALBDDOI": [
"Msg",
"Id"
],
"OLPHKLCPFEF": [
"InventionId",
"ReplicationId",
"VersionNumber",
"InstantiationCost",
"LightsCost",
"ChipsCost",
"CloudVariablesCost",
"BlobName",
"BlobHash"
],
"OMAPLLOIDNA": [
"ToPlayerIds",
"Message",
"GiftContext"
],
"ONGJBLFBHGC": [
"PurchasableItemId",
"Type",
"Prices",
"SubscriberPrices",
"IsFeatured",
"AvailableAt",
"AvailableUntil",
"NewUntil"
],
"OOGKPMKKJBE": [
"SalePercent",
"SaleStartDate",
"SaleEndDate"
],
"PAKGKIKFGAE": [
"Id",
"TitleOverride",
"UrlOverride"
],
"PCBAACNNDBH": [
"FriendlyImageName",
"ImageName",
"StartTime",
"EndTime"
],
"PCMLHLIBLNJ": [
"Tag",
"Type"
],
"PCODFCJECKL": [
"Msg",
"Id"
],
"PCPOMPIAFMA": [
"Key",
"Value",
"StartTime",
"EndTime"
],
"PDGENCNGCJI": [
"RoomId",
"Ids"
],
"PDIFGFKAMBG": [
"PlayerCount",
"IsFull",
"PlayerEventId",
"CreatorPlayerId",
"RoomId",
"SubRoomId",
"ClubId",
"Name",
"Description",
"ImageName",
"StartTime",
"EndTime",
"AttendeeCount",
"Accessibility",
"IsMultiInstance",
"SupportMultiInstanceRoomChat",
"DefaultBroadcastPermissions",
"CanRequestBroadcastPermissions",
"BroadcastingRoomInstanceId"
],
"PECGEJAAMHB": [
"Balance",
"CurrencyType",
"Platform"
],
"PELEHJLMKJO": [
"SubRoomDataSaveId",
"SubRoomId",
"UnityAssetId",
"DataBlob",
"DataBlobHash",
"SavedByAccountId",
"SavedOnPlatform",
"SavedOnDeviceClass",
"Description",
"CreatedAt"
],
"PEOAOMGOMGC": [
"CurrencyId",
"RoomId",
"Name",
"Description",
"CurrencyType",
"Limit",
"ImageName",
"CreatedAt",
"ModifiedAt"
],
"PGACEMBEGJF": [
"ToPlayerId",
"Message",
"Anonymous",
"GiftContext"
],
"PJPBJKLDMNA": [
"Results",
"TotalResults"
],
"PKCMBJFBHBO": [
"TotalXp",
"SocialBoostXp"
],
"PLLNLPADHCN": [
"Resolution",
"Language",
"Timezone",
"Platform"
],
"POGBAGAHGIA": [
"GiftDrop",
"PurchasableItemId",
"Type",
"Prices",
"SubscriberPrices",
"IsFeatured",
"AvailableAt",
"AvailableUntil",
"NewUntil"
],
"PPHLKNGCGOE": [
"SessionId",
"RoomId",
"SubRoomId",
"UnityAssetId",
"RoomDataFilename",
"RoomDataHash",
"SubRoomDataFilename",
"SubRoomDataHash"
],
"PlayerInput": [
"OnPlayerLeft",
"OnDeviceLost",
"OnDeviceRegained",
"OnControlsChanged"
],
"PlayerInputManager": [
"OnPlayerJoined",
"OnPlayerLeft"
],
"StringHelpers": [
"Mouse",
"Mice",
"Axis",
"Axes"
],
"X509Name": [
"C",
"O",
"T",
"OU",
"CN",
"L",
"ST",
"SERIALNUMBER",
"E",
"DC",
"UID",
"STREET",
"SURNAME",
"GIVENNAME",
"INITIALS",
"GENERATION",
"UniqueIdentifier",
"DN",
"Pseudonym",
"PostalAddress",
"CountryOfCitizenship",
"CountryOfResidence",
"Gender",
"PlaceOfBirth",
"DateOfBirth",
"PostalCode",
"BusinessCategory",
"TelephoneNumber"
],
"XSDSchema": [
"True",
"False",
"Name"
],
"XmlTreeGen": [
"Namespace",
"Unique",
"Attribute"
]
}
@@ -0,0 +1,525 @@
# Every HTTP request the Rec Room 2023-03-21 client can issue.
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
# verb<TAB>route<TAB>subsystem
GET account/bulk identity-account
POST account/bulk identity-account
GET account/isactivecreator/me identity-account
DELETE account/me identity-account
GET account/me identity-account
POST account/me/changepassword identity-account
POST account/me/createlogintoken identity-account
GET account/me/haspassword identity-account
POST account/me/remoteauth identity-account
POST account/recoverpassword identity-account
GET account/search identity-account
GET account/{0}/bio identity-account
GET account/{0}/clubs identity-account
PUT accountprivacysettings/recenthistoryvisibility identity-account
GET accountprivacysettings/{0} identity-account
GET accounts/{0}/receives/{1} identity-account
POST actionlink config-telemetry
GET actionlink/{code} config-telemetry
POST actionlink/{code}/consume config-telemetry
GET announcements/club/{clubId} config-telemetry
POST announcements/club/{clubId} config-telemetry
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
PUT announcements/club/{clubId}/{announcementId} config-telemetry
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
GET announcements/mine config-telemetry
GET announcements/subscription/mine config-telemetry
GET announcements/v2/mine/unread config-telemetry
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
POST api/AppIntegrity/v1/iosproducts commerce-subscription
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
POST api/PlayerCheer/v1/create progression-misc
POST api/PlayerReporting/v1/deviceId moderation-safety
POST api/PlayerReporting/v1/hile moderation-safety
POST api/PlayerReporting/v1/instantKick moderation-safety
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
POST api/PlayerReporting/v3/create moderation-safety
POST api/PlayerReporting/v3/voteToKick moderation-safety
GET api/activities/charades/v1/words/{0} progression-misc
POST api/ageverification/generateCode moderation-safety
GET api/announcement/v1/get config-telemetry
GET api/apple/musicpromotion/active commerce-subscription
GET api/apple/musicpromotion/code commerce-subscription
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
GET api/avatar/v1/defaultunlocked avatar-inventory
GET api/avatar/v1/lockeditems avatar-inventory
GET api/avatar/v2 avatar-inventory
GET api/avatar/v2/gifts avatar-inventory
POST api/avatar/v2/gifts/generate avatar-inventory
POST api/avatar/v2/set avatar-inventory
GET api/avatar/v2/{playerId} avatar-inventory
POST api/avatar/v3/gifts/generate avatar-inventory
GET api/avatar/v3/saved avatar-inventory
POST api/avatar/v3/saved/set avatar-inventory
GET api/avatar/v4/items avatar-inventory
GET api/banappeal/generateCode moderation-safety
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
GET api/challenge/v2/getCurrent progression-misc
POST api/challenge/v2/updateProgress progression-misc
POST api/checklist/v1/complete progression-misc
GET api/checklist/v1/current progression-misc
POST api/clubreporting/v1/report moderation-safety
GET api/communityboard/v2/current progression-misc
GET api/config/v1/amplitude config-telemetry
GET api/config/v1/azurespeech config-telemetry
GET api/config/v1/backtrace config-telemetry
GET api/config/v1/cohortnux/{cohortId} config-telemetry
GET api/config/v1/freegiftbutton config-telemetry
GET api/config/v2 config-telemetry
POST api/consumables/v1/consume avatar-inventory
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
POST api/consumables/v1/transfer avatar-inventory
POST api/consumables/v1/updateActive avatar-inventory
GET api/consumables/v2/getUnlocked avatar-inventory
POST api/customAvatarItems/v1 avatar-inventory
POST api/customAvatarItems/v1/bulk avatar-inventory
DELETE api/customAvatarItems/v1/design avatar-inventory
GET api/customAvatarItems/v1/design avatar-inventory
PUT api/customAvatarItems/v1/design avatar-inventory
GET api/customAvatarItems/v1/featured avatar-inventory
GET api/customAvatarItems/v1/hot avatar-inventory
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
GET api/customAvatarItems/v1/me avatar-inventory
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
DELETE api/customAvatarItems/v1/{id} avatar-inventory
PUT api/customAvatarItems/v1/{id} avatar-inventory
POST api/customAvatarItems/v1/{id}/report avatar-inventory
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
POST api/equipment/v1/update avatar-inventory
GET api/equipment/v2/getUnlocked avatar-inventory
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
POST api/freegifts/v1/sendmultiple progression-misc
GET api/gameconfigs/v1/all config-telemetry
GET api/gamerewards/v1/pending progression-misc
POST api/gamerewards/v1/request progression-misc
POST api/gamerewards/v1/select progression-misc
POST api/gamesight/event events-playlists
POST api/images/v1/cheer inventions-media
POST api/images/v1/deletesaved inventions-media
GET api/images/v1/listsaved inventions-media
POST api/images/v1/modifyaccessibility inventions-media
POST api/images/v1/sendlink inventions-media
GET api/images/v1/slideshow inventions-media
POST api/images/v1/{id}/report inventions-media
GET api/images/v2/named inventions-media
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
POST api/images/v4/uploadsaved inventions-media
GET api/images/v5/bulk inventions-media
GET api/images/v5/cheered/bulk inventions-media
GET api/images/v5/player/{playerId}?sort={int} inventions-media
GET api/images/v6/{id} inventions-media
GET api/images/v6?name={imageName} inventions-media
POST api/incentivizedreferrals/claim progression-misc
GET api/incentivizedreferrals/progress progression-misc
GET api/influencerpartnerprogram/influencer progression-misc
GET api/influencerpartnerprogram/influencers progression-misc
GET api/influencerpartnerprogram/myinfluencer progression-misc
POST api/influencerpartnerprogram/remove progression-misc
POST api/influencerpartnerprogram/support progression-misc
POST api/inventions/v1/cheer inventions-media
GET api/inventions/v1/delete?inventionId={id} inventions-media
GET api/inventions/v1/details?inventionId={id} inventions-media
GET api/inventions/v1/dormskinsfromids inventions-media
GET api/inventions/v1/featured inventions-media
GET api/inventions/v1/featureddormskins inventions-media
GET api/inventions/v1/fromcreators inventions-media
GET api/inventions/v1/fulllineageowner inventions-media
GET api/inventions/v1/personaldetails/{id} inventions-media
POST api/inventions/v1/report inventions-media
GET api/inventions/v1/room?id={roomId} inventions-media
POST api/inventions/v1/settags inventions-media
GET api/inventions/v1/tagfilters inventions-media
GET api/inventions/v1/toptoday inventions-media
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
GET api/inventions/v1/update inventions-media
POST api/inventions/v1/updateprice inventions-media
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
GET api/inventions/v1/versions?inventionId={id} inventions-media
GET api/inventions/v1?inventionId={id} inventions-media
GET api/inventions/v2/batch inventions-media
GET api/inventions/v2/mine inventions-media
GET api/inventions/v2/search?value&skip&take inventions-media
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
POST api/inventions/v4/addversion inventions-media
POST api/inventions/v6/save inventions-media
POST api/keepsakes events-playlists
GET api/keepsakes/categories events-playlists
GET api/keepsakes/events/{progressionEventId} events-playlists
GET api/keepsakes/globalconfig events-playlists
GET api/keepsakes/rooms/{roomId} events-playlists
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
POST api/messages/v1/sendMultiple relationships-messaging
GET api/messages/v2/get relationships-messaging
POST api/messages/v2/send relationships-messaging
POST api/messages/v3/delete relationships-messaging
POST api/objectives/v1/cleargroup progression-misc
POST api/objectives/v1/completegroup progression-misc
GET api/objectives/v1/myprogress progression-misc
POST api/objectives/v1/updateobjective progression-misc
POST api/offlineinvite/v1/send relationships-messaging
GET api/playerevents/v1 events-playlists
GET api/playerevents/v1/all events-playlists
GET api/playerevents/v1/all/{0} events-playlists
POST api/playerevents/v1/broadcast events-playlists
POST api/playerevents/v1/bulk events-playlists
POST api/playerevents/v1/bulkInvite events-playlists
GET api/playerevents/v1/club/{0} events-playlists
GET api/playerevents/v1/clubs events-playlists
POST api/playerevents/v1/deleteResponse events-playlists
POST api/playerevents/v1/report events-playlists
POST api/playerevents/v1/respond events-playlists
GET api/playerevents/v1/room/{0} events-playlists
GET api/playerevents/v1/search events-playlists
GET api/playerevents/v1/searchlive events-playlists
GET api/playerevents/v1/tagfilters events-playlists
GET api/playerevents/v1/{0} events-playlists
GET api/playerevents/v1/{0}/responses events-playlists
POST api/playerevents/v2 events-playlists
POST api/playerevents/v2/delete/{0} events-playlists
POST api/playerevents/v2/{0} events-playlists
PUT api/playerevents/v2/{0}/accessibility events-playlists
PUT api/playerevents/v2/{0}/club events-playlists
PUT api/playerevents/v2/{0}/description events-playlists
PUT api/playerevents/v2/{0}/image events-playlists
PUT api/playerevents/v2/{0}/multiinstance events-playlists
PUT api/playerevents/v2/{0}/name events-playlists
PUT api/playerevents/v2/{0}/room events-playlists
PUT api/playerevents/v2/{0}/tags events-playlists
PUT api/playerevents/v2/{0}/time events-playlists
PUT api/players/v4/current/contact identity-account
POST api/playerwarnings moderation-safety
POST api/playerwarnings/acknowledge moderation-safety
POST api/playstationplus/expire commerce-subscription
GET api/progressionEvents/active events-playlists
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
GET api/progressionEvents/event/{eventId} events-playlists
GET api/progressionEvents/record/{progressionEventId} events-playlists
GET api/progressionEvents/{eventId}/xpboosts events-playlists
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
GET api/quickPlay/v1/getandclear progression-misc
GET api/relationships/mutualfriends relationships-messaging
POST api/relationships/sendfriendintroductions relationships-messaging
GET api/relationships/v1/addfriendwithcode relationships-messaging
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
GET api/relationships/v1/favorite relationships-messaging
POST api/relationships/v1/ignore moderation-safety
POST api/relationships/v1/mute moderation-safety
GET api/relationships/v1/unfavorite relationships-messaging
POST api/relationships/v1/unignore moderation-safety
POST api/relationships/v1/unmute moderation-safety
GET api/relationships/v2/acceptfriendrequest relationships-messaging
GET api/relationships/v2/addfriend relationships-messaging
GET api/relationships/v2/get relationships-messaging
GET api/relationships/v2/removefriend relationships-messaging
GET api/relationships/v2/sendfriendrequest relationships-messaging
POST api/roomCurrencies/v2/purchase room-economy
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
PUT api/roomconsumables/v1/roomConsumable room-economy
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
POST api/roomcurrencies/v1/createCurrency room-economy
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
GET api/roomcurrencies/v1/currencies room-economy
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
GET api/roomcurrencies/v1/getAllBalances room-economy
GET api/roomcurrencies/v1/getBalance room-economy
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
POST api/roomcurrencies/v1/updateCurrency room-economy
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
POST api/roomkeys/v1/create room-economy
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
GET api/roomkeys/v1/mine room-economy
GET api/roomkeys/v1/owns room-economy
POST api/roomkeys/v1/owns/bulk room-economy
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
GET api/roomkeys/v1/room?roomId={id} room-economy
GET api/rooms/v1/filters rooms
POST api/rooms/v1/verifyRole rooms
POST api/rooms/v2/report rooms
GET api/royale/v1/current progression-misc
POST api/royale/v2/matchcomplete progression-misc
POST api/sanitize/v1 identity-account
POST api/sanitize/v1/isPure identity-account
POST api/screensharereports/v1/report moderation-safety
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
GET api/storefronts/v1/adcarouselitems commerce-subscription
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
GET api/storefronts/v1/buyRoomKey commerce-subscription
POST api/storefronts/v1/objectives commerce-subscription
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
GET api/storefronts/v1/toptoday commerce-subscription
POST api/storefronts/v1/trialInvention commerce-subscription
GET api/storefronts/v1/trialInvention/duration commerce-subscription
POST api/storefronts/v2/balance commerce-subscription
POST api/storefronts/v2/buyElite commerce-subscription
GET api/storefronts/v2/buyInvention commerce-subscription
POST api/storefronts/v2/buyItem commerce-subscription
POST api/storefronts/v2/buyTier commerce-subscription
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
GET api/subscriptionseasons/v1/seasons/current progression-misc
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
GET api/testcasemanagement/v1/testpasssummary config-telemetry
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
GET api/versioncheck/v4 identity-account
DELETE cachedlogin/current identity-account
GET cachedlogin/forplatformid/{0}/{1} identity-account
POST cachedlogin/forplatformids identity-account
POST cachedlogin/migrate identity-account
GET club/account/{0}/created social-clubs-chat
GET club/categoryTags social-clubs-chat
POST club/create social-clubs-chat
DELETE club/home/me social-clubs-chat
GET club/home/me social-clubs-chat
PUT club/home/me social-clubs-chat
GET club/mine/created social-clubs-chat
GET club/mine/member social-clubs-chat
GET club/mostactivetoday social-clubs-chat
GET club/search social-clubs-chat
DELETE club/{0} social-clubs-chat
GET club/{0} social-clubs-chat
DELETE club/{0}/additionalimage/{1} social-clubs-chat
PUT club/{0}/additionalimage/{1} social-clubs-chat
PUT club/{0}/clubChatEnabled social-clubs-chat
DELETE club/{0}/clubhouse social-clubs-chat
PUT club/{0}/clubhouse social-clubs-chat
GET club/{0}/details social-clubs-chat
GET club/{0}/hasDisabledClubChat social-clubs-chat
DELETE club/{0}/mainimage social-clubs-chat
PUT club/{0}/mainimage social-clubs-chat
GET club/{0}/members social-clubs-chat
POST club/{0}/members/acceptinvite social-clubs-chat
POST club/{0}/members/acceptrequest social-clubs-chat
POST club/{0}/members/acceptrequests social-clubs-chat
POST club/{0}/members/ban social-clubs-chat
GET club/{0}/members/banned social-clubs-chat
PUT club/{0}/members/changetype social-clubs-chat
POST club/{0}/members/declineinvite social-clubs-chat
POST club/{0}/members/denyrequest social-clubs-chat
POST club/{0}/members/denyrequests social-clubs-chat
POST club/{0}/members/directJoin social-clubs-chat
PUT club/{0}/members/invite social-clubs-chat
POST club/{0}/members/invitemembers social-clubs-chat
POST club/{0}/members/leave social-clubs-chat
POST club/{0}/members/remove social-clubs-chat
GET club/{0}/members/requests social-clubs-chat
GET club/{0}/members/requests/search social-clubs-chat
PUT club/{0}/members/requesttojoin social-clubs-chat
GET club/{0}/members/search social-clubs-chat
POST club/{0}/members/unban social-clubs-chat
GET club/{0}/members/{1} social-clubs-chat
PUT club/{0}/minlevel social-clubs-chat
PUT club/{0}/modify social-clubs-chat
PUT club/{0}/modifydetails social-clubs-chat
PUT club/{0}/permissions/{1} social-clubs-chat
GET clubhousesearch/mostactivenow social-clubs-chat
POST comments/create/{0} social-clubs-chat
DELETE comments/delete/{0} social-clubs-chat
GET comments/get/{0} social-clubs-chat
PUT comments/read/{0}/{1} social-clubs-chat
GET comments/unreadcounts social-clubs-chat
GET config/categories config-telemetry
POST connect/deviceauthorization identity-account
POST connect/token identity-account
POST data/event events-playlists
PUT datalink config-telemetry
GET datalink/{code} config-telemetry
GET eac/challenge identity-account
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
GET econ/customAvatarItems/v1/owned avatar-inventory
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
GET emojiConfig/whitelistedEmojis config-telemetry
GET event/{0}/instances events-playlists
GET featuredrooms/current rooms
POST feed/query config-telemetry
DELETE invite/{0} progression-misc
POST leaderboard/CheckAndSetStat progression-misc
POST leaderboard/GetNearbyScores progression-misc
POST leaderboard/GetPlayerRank progression-misc
POST leaderboard/GetRanks progression-misc
POST matchmake/chatinvite/{0}/{1} matchmaking-session
POST matchmake/club/{0} matchmaking-session
POST matchmake/code/{0}/{1} matchmaking-session
POST matchmake/dorm matchmaking-session
POST matchmake/event/{0} matchmaking-session
POST matchmake/instance/{0} matchmaking-session
POST matchmake/invite/{0} matchmaking-session
POST matchmake/none matchmaking-session
POST matchmake/player/{0} matchmaking-session
POST matchmake/room/{0} matchmaking-session
POST matchmake/room/{0}/{1} matchmaking-session
GET members/bulk social-clubs-chat
GET namegen/options identity-account
POST pageview/consume config-telemetry
GET parentalcontrol/me identity-account
GET platformid/{0} identity-account
GET player/avoidjuniors matchmaking-session
POST player/exclusivelogin matchmaking-session
POST player/heartbeat matchmaking-session
POST player/login matchmaking-session
POST player/logout matchmaking-session
POST player/notifydisconnect matchmaking-session
PUT player/photonregionpings matchmaking-session
PUT player/statusvisibility matchmaking-session
PUT player/vrmovementmode matchmaking-session
POST playlists/bulk events-playlists
GET playlists/cheeredby/me events-playlists
GET playlists/createdby/me events-playlists
GET playlists/favoritedby/me events-playlists
GET playlists/visitedby/me events-playlists
DELETE playlists/{0} events-playlists
GET playlists/{0} events-playlists
PUT playlists/{0}/accessibility events-playlists
PUT playlists/{0}/description events-playlists
PUT playlists/{0}/image events-playlists
GET playlists/{0}/interactionby/me events-playlists
PUT playlists/{0}/interactionby/me/cheer events-playlists
PUT playlists/{0}/interactionby/me/favorite events-playlists
PUT playlists/{0}/levelvoting events-playlists
PUT playlists/{0}/name events-playlists
PUT playlists/{0}/restrictions events-playlists
PUT playlists/{0}/rooms/{1} events-playlists
PUT playlists/{0}/tags events-playlists
PUT playlists/{0}/warning events-playlists
POST purchase/v1/cancelpurchase commerce-subscription
POST purchase/v1/cleanuppending commerce-subscription
POST purchase/v1/completepurchase commerce-subscription
GET purchase/v1/hasspentmoney commerce-subscription
POST purchase/v1/initiatepurchase commerce-subscription
POST purchase/v1/processpurchase commerce-subscription
GET purchasecampaign/allcurrent/v2 commerce-subscription
POST purchasecampaign/shown commerce-subscription
GET reminder/currentTokenBundles/v2 commerce-subscription
POST remote-run/push-to-studio inventions-media
GET role/developer/{0} progression-misc
GET role/moderator/{0} progression-misc
GET room/{0}/instances rooms
PUT roominstance/{0}/inprogress rooms
PUT roominstance/{0}/markprivate rooms
POST roominstance/{0}/reportjoinresult rooms
PUT roominstance/{0}/roomCode rooms
GET rooms/base rooms
GET rooms/bulk rooms
GET rooms/cheeredby/me rooms
GET rooms/contestwinners rooms
GET rooms/createdby/me rooms
GET rooms/createdby/{0} rooms
GET rooms/curated_playlists rooms
GET rooms/favoritedby/me rooms
GET rooms/fromcreators rooms
GET rooms/hot rooms
GET rooms/magic_door rooms
GET rooms/moderatedby/me rooms
GET rooms/ownedby/me rooms
GET rooms/ownedby/{0} rooms
GET rooms/recommendations rooms
GET rooms/rro_ids rooms
GET rooms/search rooms
GET rooms/topcreators rooms
GET rooms/visitedby/me rooms
GET rooms/visitedby/{0} rooms
DELETE rooms/{0} rooms
GET rooms/{0} rooms
PUT rooms/{0}/accessibility rooms
PUT rooms/{0}/allow_new_users rooms
PUT rooms/{0}/automute rooms
GET rooms/{0}/bans rooms
POST rooms/{0}/bans rooms
POST rooms/{0}/bans/import rooms
DELETE rooms/{0}/bans/{1} rooms
POST rooms/{0}/clone rooms
PUT rooms/{0}/cloning rooms
PUT rooms/{0}/comments rooms
PUT rooms/{0}/creator rooms
PUT rooms/{0}/description rooms
PUT rooms/{0}/image rooms
GET rooms/{0}/interactionby/me rooms
DELETE rooms/{0}/interactionby/me/cheer rooms
PUT rooms/{0}/interactionby/me/cheer rooms
DELETE rooms/{0}/interactionby/me/favorite rooms
PUT rooms/{0}/interactionby/me/favorite rooms
DELETE rooms/{0}/loadscreen rooms
PUT rooms/{0}/loadscreen rooms
PUT rooms/{0}/max_player_calculation_mode rooms
PUT rooms/{0}/min_level rooms
PUT rooms/{0}/modify rooms
PUT rooms/{0}/name rooms
GET rooms/{0}/playerdata/me rooms
PUT rooms/{0}/playerdata/me rooms
DELETE rooms/{0}/promo_external rooms
PUT rooms/{0}/promo_external rooms
DELETE rooms/{0}/promo_images rooms
PUT rooms/{0}/promo_images rooms
PUT rooms/{0}/restrictions rooms
GET rooms/{0}/roles rooms
GET rooms/{0}/roles/{1} rooms
PUT rooms/{0}/roles/{1} rooms
PUT rooms/{0}/roles/{1}/invite rooms
GET rooms/{0}/similar rooms
POST rooms/{0}/subrooms rooms
DELETE rooms/{0}/subrooms/{1} rooms
PUT rooms/{0}/subrooms/{1}/accessibility rooms
POST rooms/{0}/subrooms/{1}/clone rooms
POST rooms/{0}/subrooms/{1}/data rooms
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
PUT rooms/{0}/subrooms/{1}/modify rooms
POST rooms/{0}/subrooms/{1}/move rooms
PUT rooms/{0}/subrooms/{1}/name rooms
PUT rooms/{0}/subrooms/{1}/permissions rooms
POST rooms/{0}/subrooms/{1}/publish_save rooms
GET rooms/{0}/subrooms/{1}/saves rooms
PUT rooms/{0}/tags rooms
PUT rooms/{0}/voice_chat_encryption rooms
PUT rooms/{0}/warning rooms
GET settings/partyinvite config-telemetry
GET showcase/{0} inventions-media
GET subscription/details/{accountId:int} commerce-subscription
GET subscription/mine/member commerce-subscription
GET subscription/subscriberCount/{accountId:int} commerce-subscription
GET subscription/top/creators/today commerce-subscription
POST subscription/v1/cancel commerce-subscription
POST subscription/{accountId:int} commerce-subscription
GET thread/club/{0} social-clubs-chat
PUT thread/message/{0}/moderate social-clubs-chat
POST thread/withmembers social-clubs-chat
GET thread/{0} social-clubs-chat
POST thread/{0} social-clubs-chat
PUT thread/{0}/favorite social-clubs-chat
POST thread/{0}/leave social-clubs-chat
POST thread/{0}/member/{1} social-clubs-chat
GET thread/{0}/message social-clubs-chat
POST thread/{0}/message/{1}/read social-clubs-chat
POST thread/{0}/rename social-clubs-chat
POST thread/{0}/snooze social-clubs-chat
GET unity_assets/{0}/{1}/{2} inventions-media
GET voice/config moderation-safety
GET voice/requiresModeration moderation-safety
1 # Every HTTP request the Rec Room 2023-03-21 client can issue.
2 # Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
3 # verb<TAB>route<TAB>subsystem
4 GET account/bulk identity-account
5 POST account/bulk identity-account
6 GET account/isactivecreator/me identity-account
7 DELETE account/me identity-account
8 GET account/me identity-account
9 POST account/me/changepassword identity-account
10 POST account/me/createlogintoken identity-account
11 GET account/me/haspassword identity-account
12 POST account/me/remoteauth identity-account
13 POST account/recoverpassword identity-account
14 GET account/search identity-account
15 GET account/{0}/bio identity-account
16 GET account/{0}/clubs identity-account
17 PUT accountprivacysettings/recenthistoryvisibility identity-account
18 GET accountprivacysettings/{0} identity-account
19 GET accounts/{0}/receives/{1} identity-account
20 POST actionlink config-telemetry
21 GET actionlink/{code} config-telemetry
22 POST actionlink/{code}/consume config-telemetry
23 GET announcements/club/{clubId} config-telemetry
24 POST announcements/club/{clubId} config-telemetry
25 DELETE announcements/club/{clubId}/{announcementId} config-telemetry
26 PUT announcements/club/{clubId}/{announcementId} config-telemetry
27 POST announcements/club/{clubId}/{announcementId}/read config-telemetry
28 GET announcements/mine config-telemetry
29 GET announcements/subscription/mine config-telemetry
30 GET announcements/v2/mine/unread config-telemetry
31 GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
32 POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
33 POST api/AppIntegrity/v1/iosproducts commerce-subscription
34 GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
35 POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
36 POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
37 POST api/PlayerCheer/v1/create progression-misc
38 POST api/PlayerReporting/v1/deviceId moderation-safety
39 POST api/PlayerReporting/v1/hile moderation-safety
40 POST api/PlayerReporting/v1/instantKick moderation-safety
41 GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
42 GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
43 POST api/PlayerReporting/v3/create moderation-safety
44 POST api/PlayerReporting/v3/voteToKick moderation-safety
45 GET api/activities/charades/v1/words/{0} progression-misc
46 POST api/ageverification/generateCode moderation-safety
47 GET api/announcement/v1/get config-telemetry
48 GET api/apple/musicpromotion/active commerce-subscription
49 GET api/apple/musicpromotion/code commerce-subscription
50 GET api/avatar/v1/defaultbaseavataritems avatar-inventory
51 GET api/avatar/v1/defaultunlocked avatar-inventory
52 GET api/avatar/v1/lockeditems avatar-inventory
53 GET api/avatar/v2 avatar-inventory
54 GET api/avatar/v2/gifts avatar-inventory
55 POST api/avatar/v2/gifts/generate avatar-inventory
56 POST api/avatar/v2/set avatar-inventory
57 GET api/avatar/v2/{playerId} avatar-inventory
58 POST api/avatar/v3/gifts/generate avatar-inventory
59 GET api/avatar/v3/saved avatar-inventory
60 POST api/avatar/v3/saved/set avatar-inventory
61 GET api/avatar/v4/items avatar-inventory
62 GET api/banappeal/generateCode moderation-safety
63 GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
64 GET api/challenge/v2/getCurrent progression-misc
65 POST api/challenge/v2/updateProgress progression-misc
66 POST api/checklist/v1/complete progression-misc
67 GET api/checklist/v1/current progression-misc
68 POST api/clubreporting/v1/report moderation-safety
69 GET api/communityboard/v2/current progression-misc
70 GET api/config/v1/amplitude config-telemetry
71 GET api/config/v1/azurespeech config-telemetry
72 GET api/config/v1/backtrace config-telemetry
73 GET api/config/v1/cohortnux/{cohortId} config-telemetry
74 GET api/config/v1/freegiftbutton config-telemetry
75 GET api/config/v2 config-telemetry
76 POST api/consumables/v1/consume avatar-inventory
77 GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
78 POST api/consumables/v1/transfer avatar-inventory
79 POST api/consumables/v1/updateActive avatar-inventory
80 GET api/consumables/v2/getUnlocked avatar-inventory
81 POST api/customAvatarItems/v1 avatar-inventory
82 POST api/customAvatarItems/v1/bulk avatar-inventory
83 DELETE api/customAvatarItems/v1/design avatar-inventory
84 GET api/customAvatarItems/v1/design avatar-inventory
85 PUT api/customAvatarItems/v1/design avatar-inventory
86 GET api/customAvatarItems/v1/featured avatar-inventory
87 GET api/customAvatarItems/v1/hot avatar-inventory
88 GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
89 GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
90 GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
91 GET api/customAvatarItems/v1/me avatar-inventory
92 GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
93 DELETE api/customAvatarItems/v1/{id} avatar-inventory
94 PUT api/customAvatarItems/v1/{id} avatar-inventory
95 POST api/customAvatarItems/v1/{id}/report avatar-inventory
96 GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
97 POST api/equipment/v1/update avatar-inventory
98 GET api/equipment/v2/getUnlocked avatar-inventory
99 POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
100 POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
101 POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
102 POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
103 POST api/freegifts/v1/sendmultiple progression-misc
104 GET api/gameconfigs/v1/all config-telemetry
105 GET api/gamerewards/v1/pending progression-misc
106 POST api/gamerewards/v1/request progression-misc
107 POST api/gamerewards/v1/select progression-misc
108 POST api/gamesight/event events-playlists
109 POST api/images/v1/cheer inventions-media
110 POST api/images/v1/deletesaved inventions-media
111 GET api/images/v1/listsaved inventions-media
112 POST api/images/v1/modifyaccessibility inventions-media
113 POST api/images/v1/sendlink inventions-media
114 GET api/images/v1/slideshow inventions-media
115 POST api/images/v1/{id}/report inventions-media
116 GET api/images/v2/named inventions-media
117 GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
118 POST api/images/v4/uploadsaved inventions-media
119 GET api/images/v5/bulk inventions-media
120 GET api/images/v5/cheered/bulk inventions-media
121 GET api/images/v5/player/{playerId}?sort={int} inventions-media
122 GET api/images/v6/{id} inventions-media
123 GET api/images/v6?name={imageName} inventions-media
124 POST api/incentivizedreferrals/claim progression-misc
125 GET api/incentivizedreferrals/progress progression-misc
126 GET api/influencerpartnerprogram/influencer progression-misc
127 GET api/influencerpartnerprogram/influencers progression-misc
128 GET api/influencerpartnerprogram/myinfluencer progression-misc
129 POST api/influencerpartnerprogram/remove progression-misc
130 POST api/influencerpartnerprogram/support progression-misc
131 POST api/inventions/v1/cheer inventions-media
132 GET api/inventions/v1/delete?inventionId={id} inventions-media
133 GET api/inventions/v1/details?inventionId={id} inventions-media
134 GET api/inventions/v1/dormskinsfromids inventions-media
135 GET api/inventions/v1/featured inventions-media
136 GET api/inventions/v1/featureddormskins inventions-media
137 GET api/inventions/v1/fromcreators inventions-media
138 GET api/inventions/v1/fulllineageowner inventions-media
139 GET api/inventions/v1/personaldetails/{id} inventions-media
140 POST api/inventions/v1/report inventions-media
141 GET api/inventions/v1/room?id={roomId} inventions-media
142 POST api/inventions/v1/settags inventions-media
143 GET api/inventions/v1/tagfilters inventions-media
144 GET api/inventions/v1/toptoday inventions-media
145 GET api/inventions/v1/unpublish?inventionId={id} inventions-media
146 GET api/inventions/v1/update inventions-media
147 POST api/inventions/v1/updateprice inventions-media
148 GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
149 GET api/inventions/v1/versions?inventionId={id} inventions-media
150 GET api/inventions/v1?inventionId={id} inventions-media
151 GET api/inventions/v2/batch inventions-media
152 GET api/inventions/v2/mine inventions-media
153 GET api/inventions/v2/search?value&skip&take inventions-media
154 GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
155 POST api/inventions/v4/addversion inventions-media
156 POST api/inventions/v6/save inventions-media
157 POST api/keepsakes events-playlists
158 GET api/keepsakes/categories events-playlists
159 GET api/keepsakes/events/{progressionEventId} events-playlists
160 GET api/keepsakes/globalconfig events-playlists
161 GET api/keepsakes/rooms/{roomId} events-playlists
162 DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
163 POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
164 GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
165 POST api/messages/v1/sendMultiple relationships-messaging
166 GET api/messages/v2/get relationships-messaging
167 POST api/messages/v2/send relationships-messaging
168 POST api/messages/v3/delete relationships-messaging
169 POST api/objectives/v1/cleargroup progression-misc
170 POST api/objectives/v1/completegroup progression-misc
171 GET api/objectives/v1/myprogress progression-misc
172 POST api/objectives/v1/updateobjective progression-misc
173 POST api/offlineinvite/v1/send relationships-messaging
174 GET api/playerevents/v1 events-playlists
175 GET api/playerevents/v1/all events-playlists
176 GET api/playerevents/v1/all/{0} events-playlists
177 POST api/playerevents/v1/broadcast events-playlists
178 POST api/playerevents/v1/bulk events-playlists
179 POST api/playerevents/v1/bulkInvite events-playlists
180 GET api/playerevents/v1/club/{0} events-playlists
181 GET api/playerevents/v1/clubs events-playlists
182 POST api/playerevents/v1/deleteResponse events-playlists
183 POST api/playerevents/v1/report events-playlists
184 POST api/playerevents/v1/respond events-playlists
185 GET api/playerevents/v1/room/{0} events-playlists
186 GET api/playerevents/v1/search events-playlists
187 GET api/playerevents/v1/searchlive events-playlists
188 GET api/playerevents/v1/tagfilters events-playlists
189 GET api/playerevents/v1/{0} events-playlists
190 GET api/playerevents/v1/{0}/responses events-playlists
191 POST api/playerevents/v2 events-playlists
192 POST api/playerevents/v2/delete/{0} events-playlists
193 POST api/playerevents/v2/{0} events-playlists
194 PUT api/playerevents/v2/{0}/accessibility events-playlists
195 PUT api/playerevents/v2/{0}/club events-playlists
196 PUT api/playerevents/v2/{0}/description events-playlists
197 PUT api/playerevents/v2/{0}/image events-playlists
198 PUT api/playerevents/v2/{0}/multiinstance events-playlists
199 PUT api/playerevents/v2/{0}/name events-playlists
200 PUT api/playerevents/v2/{0}/room events-playlists
201 PUT api/playerevents/v2/{0}/tags events-playlists
202 PUT api/playerevents/v2/{0}/time events-playlists
203 PUT api/players/v4/current/contact identity-account
204 POST api/playerwarnings moderation-safety
205 POST api/playerwarnings/acknowledge moderation-safety
206 POST api/playstationplus/expire commerce-subscription
207 GET api/progressionEvents/active events-playlists
208 POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
209 GET api/progressionEvents/event/{eventId} events-playlists
210 GET api/progressionEvents/record/{progressionEventId} events-playlists
211 GET api/progressionEvents/{eventId}/xpboosts events-playlists
212 GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
213 GET api/quickPlay/v1/getandclear progression-misc
214 GET api/relationships/mutualfriends relationships-messaging
215 POST api/relationships/sendfriendintroductions relationships-messaging
216 GET api/relationships/v1/addfriendwithcode relationships-messaging
217 POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
218 GET api/relationships/v1/favorite relationships-messaging
219 POST api/relationships/v1/ignore moderation-safety
220 POST api/relationships/v1/mute moderation-safety
221 GET api/relationships/v1/unfavorite relationships-messaging
222 POST api/relationships/v1/unignore moderation-safety
223 POST api/relationships/v1/unmute moderation-safety
224 GET api/relationships/v2/acceptfriendrequest relationships-messaging
225 GET api/relationships/v2/addfriend relationships-messaging
226 GET api/relationships/v2/get relationships-messaging
227 GET api/relationships/v2/removefriend relationships-messaging
228 GET api/relationships/v2/sendfriendrequest relationships-messaging
229 POST api/roomCurrencies/v2/purchase room-economy
230 POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
231 PUT api/roomconsumables/v1/roomConsumable room-economy
232 GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
233 GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
234 DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
235 PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
236 GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
237 PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
238 PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
239 POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
240 POST api/roomcurrencies/v1/createCurrency room-economy
241 POST api/roomcurrencies/v1/createPurchaseOffer room-economy
242 GET api/roomcurrencies/v1/currencies room-economy
243 POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
244 GET api/roomcurrencies/v1/getAllBalances room-economy
245 GET api/roomcurrencies/v1/getBalance room-economy
246 GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
247 POST api/roomcurrencies/v1/updateCurrency room-economy
248 POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
249 POST api/roomkeys/v1/create room-economy
250 DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
251 GET api/roomkeys/v1/mine room-economy
252 GET api/roomkeys/v1/owns room-economy
253 POST api/roomkeys/v1/owns/bulk room-economy
254 GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
255 GET api/roomkeys/v1/room?roomId={id} room-economy
256 GET api/rooms/v1/filters rooms
257 POST api/rooms/v1/verifyRole rooms
258 POST api/rooms/v2/report rooms
259 GET api/royale/v1/current progression-misc
260 POST api/royale/v2/matchcomplete progression-misc
261 POST api/sanitize/v1 identity-account
262 POST api/sanitize/v1/isPure identity-account
263 POST api/screensharereports/v1/report moderation-safety
264 POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
265 GET api/storefronts/v1/adcarouselitems commerce-subscription
266 GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
267 POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
268 POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
269 POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
270 GET api/storefronts/v1/buyRoomKey commerce-subscription
271 POST api/storefronts/v1/objectives commerce-subscription
272 GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
273 GET api/storefronts/v1/toptoday commerce-subscription
274 POST api/storefronts/v1/trialInvention commerce-subscription
275 GET api/storefronts/v1/trialInvention/duration commerce-subscription
276 POST api/storefronts/v2/balance commerce-subscription
277 POST api/storefronts/v2/buyElite commerce-subscription
278 GET api/storefronts/v2/buyInvention commerce-subscription
279 POST api/storefronts/v2/buyItem commerce-subscription
280 POST api/storefronts/v2/buyTier commerce-subscription
281 GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
282 GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
283 GET api/subscriptionseasons/v1/seasons/current progression-misc
284 GET api/testcasemanagement/v1/testcase/{id} config-telemetry
285 POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
286 POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
287 POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
288 POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
289 GET api/testcasemanagement/v1/testpass/{id} config-telemetry
290 GET api/testcasemanagement/v1/testpasssummary config-telemetry
291 POST api/ugcPurchasables/v1/items/bulk avatar-inventory
292 GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
293 GET api/versioncheck/v4 identity-account
294 DELETE cachedlogin/current identity-account
295 GET cachedlogin/forplatformid/{0}/{1} identity-account
296 POST cachedlogin/forplatformids identity-account
297 POST cachedlogin/migrate identity-account
298 GET club/account/{0}/created social-clubs-chat
299 GET club/categoryTags social-clubs-chat
300 POST club/create social-clubs-chat
301 DELETE club/home/me social-clubs-chat
302 GET club/home/me social-clubs-chat
303 PUT club/home/me social-clubs-chat
304 GET club/mine/created social-clubs-chat
305 GET club/mine/member social-clubs-chat
306 GET club/mostactivetoday social-clubs-chat
307 GET club/search social-clubs-chat
308 DELETE club/{0} social-clubs-chat
309 GET club/{0} social-clubs-chat
310 DELETE club/{0}/additionalimage/{1} social-clubs-chat
311 PUT club/{0}/additionalimage/{1} social-clubs-chat
312 PUT club/{0}/clubChatEnabled social-clubs-chat
313 DELETE club/{0}/clubhouse social-clubs-chat
314 PUT club/{0}/clubhouse social-clubs-chat
315 GET club/{0}/details social-clubs-chat
316 GET club/{0}/hasDisabledClubChat social-clubs-chat
317 DELETE club/{0}/mainimage social-clubs-chat
318 PUT club/{0}/mainimage social-clubs-chat
319 GET club/{0}/members social-clubs-chat
320 POST club/{0}/members/acceptinvite social-clubs-chat
321 POST club/{0}/members/acceptrequest social-clubs-chat
322 POST club/{0}/members/acceptrequests social-clubs-chat
323 POST club/{0}/members/ban social-clubs-chat
324 GET club/{0}/members/banned social-clubs-chat
325 PUT club/{0}/members/changetype social-clubs-chat
326 POST club/{0}/members/declineinvite social-clubs-chat
327 POST club/{0}/members/denyrequest social-clubs-chat
328 POST club/{0}/members/denyrequests social-clubs-chat
329 POST club/{0}/members/directJoin social-clubs-chat
330 PUT club/{0}/members/invite social-clubs-chat
331 POST club/{0}/members/invitemembers social-clubs-chat
332 POST club/{0}/members/leave social-clubs-chat
333 POST club/{0}/members/remove social-clubs-chat
334 GET club/{0}/members/requests social-clubs-chat
335 GET club/{0}/members/requests/search social-clubs-chat
336 PUT club/{0}/members/requesttojoin social-clubs-chat
337 GET club/{0}/members/search social-clubs-chat
338 POST club/{0}/members/unban social-clubs-chat
339 GET club/{0}/members/{1} social-clubs-chat
340 PUT club/{0}/minlevel social-clubs-chat
341 PUT club/{0}/modify social-clubs-chat
342 PUT club/{0}/modifydetails social-clubs-chat
343 PUT club/{0}/permissions/{1} social-clubs-chat
344 GET clubhousesearch/mostactivenow social-clubs-chat
345 POST comments/create/{0} social-clubs-chat
346 DELETE comments/delete/{0} social-clubs-chat
347 GET comments/get/{0} social-clubs-chat
348 PUT comments/read/{0}/{1} social-clubs-chat
349 GET comments/unreadcounts social-clubs-chat
350 GET config/categories config-telemetry
351 POST connect/deviceauthorization identity-account
352 POST connect/token identity-account
353 POST data/event events-playlists
354 PUT datalink config-telemetry
355 GET datalink/{code} config-telemetry
356 GET eac/challenge identity-account
357 GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
358 GET econ/customAvatarItems/v1/owned avatar-inventory
359 POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
360 GET emojiConfig/whitelistedEmojis config-telemetry
361 GET event/{0}/instances events-playlists
362 GET featuredrooms/current rooms
363 POST feed/query config-telemetry
364 DELETE invite/{0} progression-misc
365 POST leaderboard/CheckAndSetStat progression-misc
366 POST leaderboard/GetNearbyScores progression-misc
367 POST leaderboard/GetPlayerRank progression-misc
368 POST leaderboard/GetRanks progression-misc
369 POST matchmake/chatinvite/{0}/{1} matchmaking-session
370 POST matchmake/club/{0} matchmaking-session
371 POST matchmake/code/{0}/{1} matchmaking-session
372 POST matchmake/dorm matchmaking-session
373 POST matchmake/event/{0} matchmaking-session
374 POST matchmake/instance/{0} matchmaking-session
375 POST matchmake/invite/{0} matchmaking-session
376 POST matchmake/none matchmaking-session
377 POST matchmake/player/{0} matchmaking-session
378 POST matchmake/room/{0} matchmaking-session
379 POST matchmake/room/{0}/{1} matchmaking-session
380 GET members/bulk social-clubs-chat
381 GET namegen/options identity-account
382 POST pageview/consume config-telemetry
383 GET parentalcontrol/me identity-account
384 GET platformid/{0} identity-account
385 GET player/avoidjuniors matchmaking-session
386 POST player/exclusivelogin matchmaking-session
387 POST player/heartbeat matchmaking-session
388 POST player/login matchmaking-session
389 POST player/logout matchmaking-session
390 POST player/notifydisconnect matchmaking-session
391 PUT player/photonregionpings matchmaking-session
392 PUT player/statusvisibility matchmaking-session
393 PUT player/vrmovementmode matchmaking-session
394 POST playlists/bulk events-playlists
395 GET playlists/cheeredby/me events-playlists
396 GET playlists/createdby/me events-playlists
397 GET playlists/favoritedby/me events-playlists
398 GET playlists/visitedby/me events-playlists
399 DELETE playlists/{0} events-playlists
400 GET playlists/{0} events-playlists
401 PUT playlists/{0}/accessibility events-playlists
402 PUT playlists/{0}/description events-playlists
403 PUT playlists/{0}/image events-playlists
404 GET playlists/{0}/interactionby/me events-playlists
405 PUT playlists/{0}/interactionby/me/cheer events-playlists
406 PUT playlists/{0}/interactionby/me/favorite events-playlists
407 PUT playlists/{0}/levelvoting events-playlists
408 PUT playlists/{0}/name events-playlists
409 PUT playlists/{0}/restrictions events-playlists
410 PUT playlists/{0}/rooms/{1} events-playlists
411 PUT playlists/{0}/tags events-playlists
412 PUT playlists/{0}/warning events-playlists
413 POST purchase/v1/cancelpurchase commerce-subscription
414 POST purchase/v1/cleanuppending commerce-subscription
415 POST purchase/v1/completepurchase commerce-subscription
416 GET purchase/v1/hasspentmoney commerce-subscription
417 POST purchase/v1/initiatepurchase commerce-subscription
418 POST purchase/v1/processpurchase commerce-subscription
419 GET purchasecampaign/allcurrent/v2 commerce-subscription
420 POST purchasecampaign/shown commerce-subscription
421 GET reminder/currentTokenBundles/v2 commerce-subscription
422 POST remote-run/push-to-studio inventions-media
423 GET role/developer/{0} progression-misc
424 GET role/moderator/{0} progression-misc
425 GET room/{0}/instances rooms
426 PUT roominstance/{0}/inprogress rooms
427 PUT roominstance/{0}/markprivate rooms
428 POST roominstance/{0}/reportjoinresult rooms
429 PUT roominstance/{0}/roomCode rooms
430 GET rooms/base rooms
431 GET rooms/bulk rooms
432 GET rooms/cheeredby/me rooms
433 GET rooms/contestwinners rooms
434 GET rooms/createdby/me rooms
435 GET rooms/createdby/{0} rooms
436 GET rooms/curated_playlists rooms
437 GET rooms/favoritedby/me rooms
438 GET rooms/fromcreators rooms
439 GET rooms/hot rooms
440 GET rooms/magic_door rooms
441 GET rooms/moderatedby/me rooms
442 GET rooms/ownedby/me rooms
443 GET rooms/ownedby/{0} rooms
444 GET rooms/recommendations rooms
445 GET rooms/rro_ids rooms
446 GET rooms/search rooms
447 GET rooms/topcreators rooms
448 GET rooms/visitedby/me rooms
449 GET rooms/visitedby/{0} rooms
450 DELETE rooms/{0} rooms
451 GET rooms/{0} rooms
452 PUT rooms/{0}/accessibility rooms
453 PUT rooms/{0}/allow_new_users rooms
454 PUT rooms/{0}/automute rooms
455 GET rooms/{0}/bans rooms
456 POST rooms/{0}/bans rooms
457 POST rooms/{0}/bans/import rooms
458 DELETE rooms/{0}/bans/{1} rooms
459 POST rooms/{0}/clone rooms
460 PUT rooms/{0}/cloning rooms
461 PUT rooms/{0}/comments rooms
462 PUT rooms/{0}/creator rooms
463 PUT rooms/{0}/description rooms
464 PUT rooms/{0}/image rooms
465 GET rooms/{0}/interactionby/me rooms
466 DELETE rooms/{0}/interactionby/me/cheer rooms
467 PUT rooms/{0}/interactionby/me/cheer rooms
468 DELETE rooms/{0}/interactionby/me/favorite rooms
469 PUT rooms/{0}/interactionby/me/favorite rooms
470 DELETE rooms/{0}/loadscreen rooms
471 PUT rooms/{0}/loadscreen rooms
472 PUT rooms/{0}/max_player_calculation_mode rooms
473 PUT rooms/{0}/min_level rooms
474 PUT rooms/{0}/modify rooms
475 PUT rooms/{0}/name rooms
476 GET rooms/{0}/playerdata/me rooms
477 PUT rooms/{0}/playerdata/me rooms
478 DELETE rooms/{0}/promo_external rooms
479 PUT rooms/{0}/promo_external rooms
480 DELETE rooms/{0}/promo_images rooms
481 PUT rooms/{0}/promo_images rooms
482 PUT rooms/{0}/restrictions rooms
483 GET rooms/{0}/roles rooms
484 GET rooms/{0}/roles/{1} rooms
485 PUT rooms/{0}/roles/{1} rooms
486 PUT rooms/{0}/roles/{1}/invite rooms
487 GET rooms/{0}/similar rooms
488 POST rooms/{0}/subrooms rooms
489 DELETE rooms/{0}/subrooms/{1} rooms
490 PUT rooms/{0}/subrooms/{1}/accessibility rooms
491 POST rooms/{0}/subrooms/{1}/clone rooms
492 POST rooms/{0}/subrooms/{1}/data rooms
493 PUT rooms/{0}/subrooms/{1}/maxplayers rooms
494 PUT rooms/{0}/subrooms/{1}/modify rooms
495 POST rooms/{0}/subrooms/{1}/move rooms
496 PUT rooms/{0}/subrooms/{1}/name rooms
497 PUT rooms/{0}/subrooms/{1}/permissions rooms
498 POST rooms/{0}/subrooms/{1}/publish_save rooms
499 GET rooms/{0}/subrooms/{1}/saves rooms
500 PUT rooms/{0}/tags rooms
501 PUT rooms/{0}/voice_chat_encryption rooms
502 PUT rooms/{0}/warning rooms
503 GET settings/partyinvite config-telemetry
504 GET showcase/{0} inventions-media
505 GET subscription/details/{accountId:int} commerce-subscription
506 GET subscription/mine/member commerce-subscription
507 GET subscription/subscriberCount/{accountId:int} commerce-subscription
508 GET subscription/top/creators/today commerce-subscription
509 POST subscription/v1/cancel commerce-subscription
510 POST subscription/{accountId:int} commerce-subscription
511 GET thread/club/{0} social-clubs-chat
512 PUT thread/message/{0}/moderate social-clubs-chat
513 POST thread/withmembers social-clubs-chat
514 GET thread/{0} social-clubs-chat
515 POST thread/{0} social-clubs-chat
516 PUT thread/{0}/favorite social-clubs-chat
517 POST thread/{0}/leave social-clubs-chat
518 POST thread/{0}/member/{1} social-clubs-chat
519 GET thread/{0}/message social-clubs-chat
520 POST thread/{0}/message/{1}/read social-clubs-chat
521 POST thread/{0}/rename social-clubs-chat
522 POST thread/{0}/snooze social-clubs-chat
523 GET unity_assets/{0}/{1}/{2} inventions-media
524 GET voice/config moderation-safety
525 GET voice/requiresModeration moderation-safety
@@ -0,0 +1,142 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace DorkNet.Server.Binding;
/// <summary>
/// Binds an action's request DTO from either a form-urlencoded body or a JSON
/// body, whichever the caller actually sent.
///
/// The Rec Room client's HTTP layer (<c>BNDIAONDFFF</c> in RecNet.Runtime)
/// sends nearly every write as <c>application/x-www-form-urlencoded</c>, adding
/// fields one at a time. Handlers written against <c>[FromBody]</c> only accept
/// JSON, so with <c>[ApiController]</c> those requests were rejected with 415
/// Unsupported Media Type before the action body ever ran — this is why every
/// club member-management action (invite, ban, kick, promote, approve, modify)
/// failed on the 2023 client.
///
/// Rather than hand-rolling a form reader per handler, apply this binder to the
/// DTO parameter:
///
/// <code>
/// public Task&lt;IActionResult&gt; MemberBan(
/// long clubId,
/// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req)
/// </code>
///
/// The explicit <c>[ModelBinder]</c> is required: without it <c>[ApiController]</c>
/// infers <c>[FromBody]</c> for complex types and this binder never runs.
///
/// Matching is case-insensitive on both paths, and a scalar property also
/// accepts the singular/plural spellings the client uses interchangeably. A
/// missing or unparseable body yields a default-constructed DTO rather than a
/// 400, matching how the client treats these calls as fire-and-forget.
/// </summary>
public sealed class FormOrJsonModelBinder : IModelBinder
{
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
ArgumentNullException.ThrowIfNull(bindingContext);
var type = bindingContext.ModelType;
var request = bindingContext.HttpContext.Request;
var model = Activator.CreateInstance(type)!;
if (request.HasFormContentType)
{
var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
foreach (var prop in Writable(type))
{
var values = Lookup(form, prop.Name);
if (values.Count == 0) continue;
var converted = Convert(values, prop.PropertyType);
if (converted is not null) prop.SetValue(model, converted);
}
}
else
{
try
{
request.EnableBuffering();
request.Body.Position = 0;
var parsed = await JsonSerializer.DeserializeAsync(
request.Body, type,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
bindingContext.HttpContext.RequestAborted);
request.Body.Position = 0;
if (parsed is not null) model = parsed;
}
catch (JsonException) { /* empty or non-JSON body → defaults */ }
catch (NotSupportedException) { /* unreadable body → defaults */ }
}
bindingContext.Result = ModelBindingResult.Success(model);
}
private static IEnumerable<PropertyInfo> Writable(Type type) =>
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanWrite);
/// <summary>Form keys, case-insensitively, also accepting the other
/// number of the name — the client says <c>accountId</c> where the DTO says
/// <c>AccountIds</c> and vice versa depending on the endpoint.</summary>
private static List<string> Lookup(IFormCollection form, string propertyName)
{
var candidates = new List<string> { propertyName };
if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]);
else candidates.Add(propertyName + "s");
foreach (var candidate in candidates)
{
foreach (var key in form.Keys)
{
if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue;
var values = form[key]
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.ToList();
if (values.Count > 0) return values;
}
}
return [];
}
private static object? Convert(List<string> values, Type target)
{
var underlying = Nullable.GetUnderlyingType(target) ?? target;
if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>))
{
var element = underlying.GetGenericArguments()[0];
var list = (System.Collections.IList)Activator.CreateInstance(underlying)!;
// A repeated field arrives as multiple values; a single field may
// still carry a comma-separated list.
foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))
{
var item = ConvertScalar(raw, element);
if (item is not null) list.Add(item);
}
return list.Count > 0 ? list : null;
}
return ConvertScalar(values[0], underlying);
}
private static object? ConvertScalar(string raw, Type target)
{
try
{
if (target == typeof(string)) return raw;
if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1";
if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null;
if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null;
return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
return null;
}
}
}
@@ -106,9 +106,11 @@ public class AccountApiController(
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
@@ -1,16 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// api.{rec.net,localhost}/api/activities/* — per-activity content
/// (charades words, party-game prompts, etc.). The 2020 watch
/// fetches these as a static list at room-join time; we ship an
/// empty list so the activity's bundled defaults kick in.
/// (charades words, party-game prompts, etc.).
///
/// The March 2023 watch fetches a 3D Charades deck at card-box spawn from
/// <c>api/activities/charades/v1/words/{source}</c>, where <c>{source}</c>
/// is one of three baked <c>CardBox.cardSource</c> slots — <c>Charades</c>,
/// <c>CharadesAprilFoolsDay</c>, or <c>Icebreakers</c> (verified in the
/// 2023.03.21 il2cpp dump). We resolve the admin-bound word list for the
/// requested slot via <see cref="CharadesWordListService"/> and return the
/// wire array of <c>{ EN_US, Difficulty }</c>. The older December route
/// (no <c>{source}</c>) maps to the default Charades slot.
/// </summary>
[ApiController]
public class ActivitiesController : ControllerBase
public class ActivitiesController(CharadesWordListService charades) : ControllerBase
{
[HttpGet("api/activities/charades/v1/words/{source}")]
public async Task<IActionResult> CharadesWords(string source)
=> Ok(await charades.ResolveWireWordsAsync(source));
/// <summary>December 2020 shape — no source segment. Serves the default
/// Charades slot so the older client still gets a populated deck.</summary>
[HttpGet("api/activities/charades/v1/words")]
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
public async Task<IActionResult> CharadesWordsDefault()
=> Ok(await charades.ResolveWireWordsAsync(
nameof(CharadesWordListService.CharadesSlot.Charades)));
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AgeVerification;
[ApiController]
[Route("api/ageverification")]
[Authorize]
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
/// a bare <c>String</c>: the issuing method is
/// <c>FGLDKEJLAKB&lt;IPCJLCNIBEG&lt;System.String&gt;&gt; GKEBPOPAEJC()</c>
/// (:150). Returning a JSON object here made Json.NET throw, so the
/// promise rejected and the code was never shown in the watch.
/// GET stays registered because it costs nothing.</summary>
[HttpPost("generateCode")]
[HttpGet("generateCode")]
public async Task<IActionResult> GenerateCode()
{
var pid = this.RequireCurrentPlayerId();
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Content(JsonSerializer.Serialize(code), "application/json");
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AppIntegrity;
[ApiController]
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/AppIntegrity/v1/iosproducts")]
[AllowAnonymous]
public async Task<IActionResult> IosProducts()
{
var rows = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.Id)
.Take(100)
.Select(i => new
{
ProductId = i.Slug,
i.Price,
i.CurrencyType,
Name = i.DisplayName,
})
.ToListAsync();
return Ok(rows);
}
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
[Authorize]
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
{
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = this.RequireCurrentPlayerId(),
Title = "iOS payment queue failed",
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
Category = "appintegrity",
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Apple;
[ApiController]
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/apple/musicpromotion/active")]
[AllowAnonymous]
public IActionResult Active()
{
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var end = start.AddYears(1);
return Ok(new { Active = true, StartAt = start, EndAt = end });
}
[HttpPost("api/apple/musicpromotion/code")]
[HttpGet("api/apple/musicpromotion/code")]
[Authorize]
public async Task<IActionResult> Code()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
if (row is null)
{
row = new PlayerSettingEntity
{
PlayerId = me,
Key = "applemusic:promotion_code",
Value = $"RR-{Guid.NewGuid():N}"[..16],
};
db.PlayerSettings.Add(row);
await db.SaveChangesAsync();
}
return Ok(new { Code = row.Value, Redeemed = false });
}
}
@@ -32,7 +32,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2;
public class AvatarGiftsController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
LevelService level,
StoreService store,
QuestRewardService questRewards,
PlayerPresenceService presence) : ControllerBase
{
[HttpGet("api/avatar/v2/gifts")]
[HttpGet("api/avatar/v3/gifts")]
@@ -47,26 +50,46 @@ public class AvatarGiftsController(
return Ok(rows.Select(ToWire));
}
public sealed record GenerateGiftRequest(int? GiftContext);
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
/// a random unlocked-avatar item and queues a gift package. The
/// 2020 client posts to this when the user clicks a free-gift
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
/// server first tries the quest's configured item pool, keyed by the
/// RRO room the player is in (the request carries no quest id). If
/// that room has no pool — or for non-game free-gift tiles — it falls
/// back to a random wardrobe item. The rolled item is stamped as a
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
// The 2023 client posts the gift-chest generate to v3 (GET gifts is
// already fanned across v2/v3/v4, but generate only routed v2 — the v3
// POST 404'd, breaking reward chests on that path). Same request body.
[HttpPost("api/avatar/v2/gifts/generate")]
[HttpPost("api/avatar/v3/gifts/generate")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
// Pick a random wardrobe StoreItem (Slug starts with
// "wardrobe-" — that's the convention StoreService uses for
// catalogue rows derived from outfits_assets bundles).
var isGameGift = req?.IsGameGift ?? false;
var rng = new Random();
// Quest chest: pick from this room's configured reward pool.
if (isGameGift && presence.GetRoom(pid) is { RoomId: > 0 } room
&& await questRewards.PickForRoomAsync(room.RoomId, rng.Next()) is { } questItem)
{
var gift = BuildGiftFromItem(pid, questItem, req?.GiftContext ?? 0, rng);
gift.SourceStoreItemId = questItem.Id;
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift));
return Ok(ToWire(gift));
}
// Fallback: random wardrobe StoreItem (Slug "wardrobe-{guid}").
var pool = await db.StoreItems
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
.Select(s => new { s.Slug, s.DisplayName })
.ToListAsync();
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
var rng = new Random();
var pick = pool[rng.Next(pool.Count)];
var guid = pick.Slug["wardrobe-".Length..];
@@ -74,7 +97,7 @@ public class AvatarGiftsController(
// so the watch's parse-and-iterate doesn't blow up in
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
// for the canonical wire-format note.
var gift = new GiftPackageEntity
var randomGift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
@@ -90,15 +113,57 @@ public class AvatarGiftsController(
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
db.GiftPackages.Add(randomGift);
await db.SaveChangesAsync();
// Push so the watch shows the new-gift toast.
await notifications.NotifyAsync(pid,
PushNotificationId.GiftPackageReceived, ToWire(gift));
return Ok(ToWire(gift));
PushNotificationId.GiftPackageReceived, ToWire(randomGift));
return Ok(ToWire(randomGift));
}
/// <summary>Build a gift package for a specific store item, filling
/// the wire payload that matches its kind (avatar item vs equipment
/// vs consumable) so <c>gifts/consume</c> grants the right thing.</summary>
private static GiftPackageEntity BuildGiftFromItem(long pid, GiftPackageItem item, int giftContext, Random rng)
{
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
CurrencyType = 0,
GiftContext = giftContext,
GiftRarity = RollRarity(rng),
Message = $"You earned {item.DisplayName}!",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
if (StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
{
gift.AvatarItemType = avatarItemType;
gift.AvatarItemDescOrHairDyeDesc = avatarItemDesc;
}
else if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var modGuid, out _))
{
gift.EquipmentPrefabName = prefab;
gift.EquipmentModificationGuid = modGuid;
}
else if (StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc))
{
gift.ConsumableItemDesc = consumableDesc;
}
return gift;
}
private static GiftPackageEntity BuildGiftFromItem(long pid, StoreItemEntity item, int giftContext, Random rng) =>
BuildGiftFromItem(pid, new GiftPackageItem(item.Slug, item.DisplayName), giftContext, rng);
private readonly record struct GiftPackageItem(string Slug, string DisplayName);
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
@@ -168,11 +233,30 @@ public class AvatarGiftsController(
if (gift.RecipientPlayerId != pid) return Forbid();
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
// Quest-reward chest: grant the exact backing store item (handles
// avatar / equipment / consumable uniformly, idempotently). The
// per-kind wire-derivation below is skipped for these — the wire
// fields are still populated for the client to render the box.
if (gift.SourceStoreItemId > 0)
{
await store.GrantItemAsync(pid, gift.SourceStoreItemId);
gift.Consumed = true;
gift.ConsumedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(gift));
}
// Append avatar item to player's inventory list (JSON list of
// GUID strings). Defensive parse — corrupted JSON resets to
// empty list rather than 500.
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
{
// Inventory stores the BARE guid ("guid"), but a gift now carries
// the 4-part wire desc ("guid,,,") so its post-consume unlock
// matches api/avatar/v4/items. Normalise to the inventory form
// here or we'd store "guid,,," alongside the bare "guid" a direct
// purchase already added → a duplicate wardrobe row.
var invDesc = StoreService.InventoryAvatarItemDesc(gift.AvatarItemDescOrHairDyeDesc);
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
@@ -183,17 +267,16 @@ public class AvatarGiftsController(
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
if (!list.Contains(invDesc))
{
list.Add(gift.AvatarItemDescOrHairDyeDesc);
list.Add(invDesc);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
new[] { gift.AvatarItemDescOrHairDyeDesc });
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { invDesc });
avatar.UpdatedAt = DateTime.UtcNow;
}
}
@@ -253,17 +336,13 @@ public class AvatarGiftsController(
gift.ConsumedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Profile-update push so the watch's wallet readout refreshes
// without waiting for the next /balance poll. Mirrors what
// /api/admin/v1/players/{id}/gift does after the inbox push so
// the toast number animates up the moment the box closes.
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
{
await notifications.NotifyAsync(pid,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
}
// The wallet readout refreshes off the StorefrontBalanceUpdate that
// GrantCurrencyAsync (above) already pushes for currency gifts. Do
// NOT also push SubscriptionUpdateProfile ("AccountUpdate") with a
// {Reason,Currency,…} blob: the 2023 client deserialises that channel
// as its Account DTO, so a non-account payload decodes to a blank
// account (accountId 0) → "Orphan player account (0) found" spam
// (audited 2026-07-12).
return Ok(ToWire(gift));
}
@@ -288,14 +367,35 @@ public class AvatarGiftsController(
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
// surfaces as "Failed to download gifts: Malformed Response" from
// BootSequence and BLOCKS LOGIN.
public static object ToWire(GiftPackageEntity g) => new
/// <summary>The desc the client matches against api/avatar/v4/items in
/// OutfitManager.UnlockAvatarItemAndMarkNew after the gift box is
/// consumed. That endpoint emits outfit items as the 4-part "{guid},,,"
/// form (a bare guid crashes the client's AvatarItem parser), and the
/// match is an EXACT string compare — so an outfit gift whose desc has
/// fewer than 4 comma-parts (legacy rows stored the bare guid) makes
/// Find() miss and NRE, which strips the item on consume. Pad outfit
/// descs to 4 parts here so BOTH new and already-persisted gifts match.
/// Hair dyes (AvatarItemType 1) match by bare colour guid — leave them;
/// currency-only gifts have an empty desc.</summary>
private static string NormalizeAvatarWireDesc(GiftPackageEntity g)
{
var d = g.AvatarItemDescOrHairDyeDesc ?? string.Empty;
if (d.Length == 0 || g.AvatarItemType == 1) return d;
var parts = d.Split(',').Length;
return parts >= 4 ? d : d + new string(',', 4 - parts);
}
public static object ToWire(GiftPackageEntity g)
{
var avatarDesc = NormalizeAvatarWireDesc(g);
return new
{
g.Id,
g.FromPlayerId,
g.ConsumableItemDesc,
g.AvatarItemType,
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
g.AvatarItemDescOrHairDyeDesc,
AvatarItemDesc = avatarDesc,
AvatarItemDescOrHairDyeDesc = avatarDesc,
g.EquipmentPrefabName,
g.EquipmentModificationGuid,
g.CurrencyType,
@@ -330,5 +430,6 @@ public class AvatarGiftsController(
g.ErrorMessage,
g.SupportsCurrentPlatform,
g.IsGifted,
};
};
}
}
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
string? hairColor = null;
string? skinColor = null;
string? faceFeatures = null;
string? outfitSelectionsV2 = null;
string? customAvatarItems = null;
if (!string.IsNullOrWhiteSpace(raw))
{
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures");
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
// uploads OutfitSelectionsV2 + CustomAvatarItems —
// persist them so remote-player fetches round-trip.
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
}
}
catch (JsonException ex)
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
if (customAvatarItems is not null)
{
// CustomAvatarItems arrives as a JSON ARRAY of
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
// re-encodes arrays as raw JSON. Guard against non-array junk.
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
? customAvatarItems
: "[]";
}
a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(a));
@@ -140,6 +157,25 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
};
}
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
/// remote-player avatar settings fetch. Decompile evidence
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
/// "api/avatar/", accountId) — the account id is a bare path segment
/// directly under v2 (NOT under /equipped/). The response is parsed
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
/// request 404'd with an empty body → "Failed to load Rec Room
/// remote player avatar settings" (Player.log:2597).</summary>
[HttpGet("api/avatar/v2/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
{
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return Ok(ToDto(a));
}
/// <summary>Public lookup of another player's equipped avatar — the
/// client uses this to render remote players' avatars in shared
/// rooms. No auth requirement on read so anonymous calls in the
@@ -174,7 +210,27 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? string.Empty,
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
};
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
/// wire items so the response always carries a well-formed array
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
catch (JsonException)
{
return new();
}
}
}
public class AvatarV2Dto
@@ -190,4 +246,28 @@ public class AvatarV2Dto
[JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty;
/// <summary>2023 client field — HHDLNAPEMGP reader key
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
[JsonPropertyName("OutfitSelectionsV2")]
public string OutfitSelectionsV2 { get; set; } = string.Empty;
/// <summary>2023 client field — HHDLNAPEMGP reader key
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
/// BGNNOMBFMLH items.</summary>
[JsonPropertyName("CustomAvatarItems")]
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
}
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
/// MKAIABAJIAJ.txt:215-258).</summary>
public class CustomAvatarItemRefDto
{
[JsonPropertyName("CustomAvatarItemId")]
public Guid CustomAvatarItemId { get; set; }
[JsonPropertyName("BodyPart")]
public byte BodyPart { get; set; }
}
@@ -62,7 +62,10 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
public class AvatarItemsController(
DorkNetDbContext db,
IConfiguration config,
ServerSettingsService serverSettings) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. Same
@@ -85,16 +88,26 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
// dropped any newly-purchased item once a slot held 4+ entries (the
// "I bought it but it's gone after relog" symptom). The clamp
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
// testing on render-budget headroom (per the prior maintainer note
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
// above this count); raising the clamp ceiling without retesting
// could crash the watch's wardrobe drawer.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
// render-budget headroom — feeding it too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
// has no such limit, and the watch-era cap was silently truncating the
// owned wardrobe to 100 ("most of my items don't show"). Raise the
// ceilings well above the full catalog (882 base items + variants) so
// nothing the player owns is dropped; the config keys still let an
// operator dial it back down for a watch build.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
// "Everyone owns all avatar items" admin toggle (mirrors the
// "everyone is friends" one): report the ENTIRE master catalog plus
// all permanent hair dyes as owned, for every caller, without reading
// or writing any inventory row. Flipping the toggle off instantly
// reverts to the real per-player wardrobe below. Bounded by the same
// maxItems ceiling so a future watch build can't be over-fed.
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -160,10 +173,13 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
AvatarItemType = item.AvatarItemType,
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
AvatarItemId = item.AvatarItemId,
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
TagList = item.TagList,
IsBaseAvatarItem = item.IsBaseAvatarItem,
});
if (result.Count >= maxItems) break;
}
@@ -177,15 +193,96 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
AvatarItemType = AvatarItemType.HairDye,
AvatarItemDesc = hairColorGuid,
AvatarItemId = StableAvatarItemId(hairColorGuid),
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = friendlyName,
Tooltip = string.Empty,
Rarity = 0,
TagList = string.Empty,
IsBaseAvatarItem = false,
});
}
return Ok(result);
}
/// <summary>The full owned set when the "everyone owns all avatar
/// items" toggle is on: every catalog item (already carrying the
/// "{guid},,," desc) plus every permanent hair dye, capped at
/// <paramref name="maxItems"/>. Hair dyes are emitted in the same
/// shape the per-player path uses (AvatarItemType.HairDye, desc =
/// hair-color GUID) so the wardrobe renders them identically.</summary>
private static List<UnlockedAvatarItemDto> BuildEntireCatalogOwned(int maxItems)
{
var catalog = _catalog.Value;
var result = new List<UnlockedAvatarItemDto>(Math.Min(maxItems, catalog.Items.Count + 32));
foreach (var item in catalog.Items.Values)
{
if (result.Count >= maxItems) return result;
result.Add(item);
}
// Colored variants aren't in the base avatar_item_lookup catalog, so
// without this the "everyone owns everything" wardrobe was missing all
// ~1.8k colorways — the player didn't own them AND buying one gifted a
// variant desc ("{guid},{combo},{mask},") that wasn't in this list, so
// the client's post-consume UnlockAvatarItemAndMarkNew Find() missed
// and NRE'd (the item vanished on buy). Emit each variant with its
// verbatim equip descriptor and a UNIQUE id hashed from the FULL
// descriptor (the base-guid hash would collide with the base item and
// every sibling colorway).
foreach (var v in StoreService.ColorVariantCatalog.Values)
{
if (result.Count >= maxItems) return result;
if (string.IsNullOrWhiteSpace(v.descriptor)) continue;
result.Add(new UnlockedAvatarItemDto
{
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = v.descriptor,
AvatarItemId = StableAvatarItemIdFull(v.descriptor),
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = v.item_name ?? string.Empty,
Tooltip = string.Empty,
Rarity = 0,
TagList = string.Empty,
IsBaseAvatarItem = false,
});
}
foreach (var slug in StoreService.AllHairDyeSlugs)
{
if (result.Count >= maxItems) break;
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
StoreService.TryGetHairDyeName(slug, out var friendlyName);
result.Add(new UnlockedAvatarItemDto
{
AvatarItemType = AvatarItemType.HairDye,
AvatarItemDesc = hairColorGuid,
AvatarItemId = StableAvatarItemId(hairColorGuid),
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = friendlyName,
Tooltip = string.Empty,
Rarity = 0,
TagList = string.Empty,
IsBaseAvatarItem = false,
});
}
return result;
}
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous]
public IActionResult DefaultBaseAvatarItems()
{
var rows = _catalog.Value.Items.Values
.OrderBy(i => i.AvatarItemType)
.ThenBy(i => i.FriendlyName)
.Take(100)
.ToList();
return Ok(rows);
}
[HttpGet("api/avatar/v1/defaultunlocked")]
[AllowAnonymous]
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
@@ -213,6 +310,12 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
// When everyone owns everything, nothing is "locked" — the store's
// owned/locked diff must see an empty locked set so every tile reads
// as owned, consistent with the full wardrobe returned by Get().
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(Array.Empty<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
@@ -346,11 +449,17 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
/// inside the wardrobe parser. Stored colored descs are already
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
/// value.</summary>
private static string ToWatchDesc(string storedDesc)
{
var commas = storedDesc.Count(c => c == ',');
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
}
// A stored colored descriptor is the EXACT RecNet equip string
// ({itemGuid},{combinationGuid…}) — the client feeds everything after the
// item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive
// key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the
// 2023 combination model has short-form descriptors ({item},{combo}) whose
// trailing commas are significant — padding "{item},{combo}" to
// "{item},{combo},," corrupts the key and the watch fails to resolve the
// combination (renders a broken/default mesh). Only genuinely bare entries
// (FullDesc == BaseGuid) get the "{guid},,," base form, and those never
// reach here (the caller emits the catalog DTO directly for them).
private static string ToWatchDesc(string storedDesc) => storedDesc;
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
{
@@ -432,10 +541,13 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
// GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,",
AvatarItemId = StableAvatarItemId(row.Guid),
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty,
Tooltip = string.Empty,
Rarity = 0,
TagList = string.Empty,
IsBaseAvatarItem = false,
};
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>();
@@ -446,6 +558,33 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
return new CatalogData(items, safe, bySlot, slotByGuid);
}
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
/// item and stable across restarts. Colored variants reuse their base
/// item's id (a player owns the base OR a variant, never both in the
/// list), so no collision.</summary>
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
{
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
uint h = 2166136261u;
foreach (var c in guid) h = (h ^ c) * 16777619u;
var id = (int)(h & 0x7FFFFFFF);
return id == 0 ? 1 : id;
}
/// <summary>Stable id from the FULL descriptor string (not just the base
/// guid). Colored variants share a base guid but are distinct wardrobe
/// entries, so they must hash the whole "{guid},{combo},{mask}," string
/// to get ids distinct from the base item and each other.</summary>
public static int StableAvatarItemIdFull(string? descriptor)
{
uint h = 2166136261u;
foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u;
var id = (int)(h & 0x7FFFFFFF);
return id == 0 ? 1 : id;
}
private static string PrettyName(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
@@ -544,6 +683,16 @@ public class UnlockedAvatarItemDto
[JsonPropertyName("AvatarItemDesc")]
public string AvatarItemDesc { get; set; } = string.Empty;
/// <summary>The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads
/// <c>AvatarItemId</c> as an <b>Int32</b>. Omitting it left every owned
/// item at id 0; the client keys its wardrobe by AvatarItemId, so a
/// second item collided and the whole unlocked-items list failed to
/// build — the wardrobe showed nothing owned and buying (which refetches
/// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a
/// stable, unique, non-zero id.</summary>
[JsonPropertyName("AvatarItemId")]
public int AvatarItemId { get; set; }
[JsonPropertyName("PlatformMask")]
public int PlatformMask { get; set; }
@@ -555,4 +704,15 @@ public class UnlockedAvatarItemDto
[JsonPropertyName("Rarity")]
public int Rarity { get; set; }
/// <summary>CSV tag string (IFNGBPJGINL.TagList). Empty is fine —
/// AvatarItemMetadata.ParseTagList tolerates it — but the key must be
/// present and a string, never null.</summary>
[JsonPropertyName("TagList")]
public string TagList { get; set; } = string.Empty;
/// <summary>Whether this is a default base-body item vs a cosmetic
/// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items.</summary>
[JsonPropertyName("IsBaseAvatarItem")]
public bool IsBaseAvatarItem { get; set; }
}
@@ -0,0 +1,519 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Comments;
/// <summary>
/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
///
/// Every route here is issued by RecNet.Runtime's room-comment service
/// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
/// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
/// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
/// ordinal moved into <c>rdx</c> immediately before the builder call
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
///
/// <list type="bullet">
/// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
/// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
/// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
/// <item><c>OPELMBNJHNO.txt:1627</c> "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]</item>
/// <item><c>OPELMBNJHNO.txt:1935</c> "comments/delete/{0}" — rdx=4 (DELETE), r8=12</item>
/// </list>
///
/// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
/// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
/// side-table exactly like the PlayerEvent extras
/// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
/// <c>PlayerId</c> = the author, <c>Key = roomcomment:{roomId}:{commentId}</c>
/// and <c>Value</c> = the JSON payload. Per-player read state is a second,
/// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
/// comment id — which is what finally lets
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
/// </summary>
[ApiController]
[Authorize]
public class CommentsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>Key prefix for a stored comment: <c>roomcomment:{roomId}:{commentId}</c>.</summary>
private const string CommentPrefix = "roomcomment:";
/// <summary>Key prefix for per-player read state: <c>roomcommentread:{roomId}</c>,
/// value = last-read comment id. Deliberately a different prefix from
/// <see cref="CommentPrefix"/> (not <c>roomcomment:read:…</c>) so the
/// suffix match used by <see cref="Delete"/> can never hit a read-state row.</summary>
private const string ReadPrefix = "roomcommentread:";
/// <summary>The <c>RecNet.RoomComment</c> members that are not already
/// implied by the storage row's key/owner. <c>Position</c> is three separate
/// nullable floats on the wire, not a Vector3: the client's
/// <c>Nullable&lt;Vector3&gt;</c> property is <c>[IgnoreDataMember]</c> and is
/// rebuilt after deserialisation from three internal
/// <c>Nullable&lt;float&gt;</c> fields (RecNet/RoomComment.txt:495
/// <c>FKDDCNLJOLF</c> reads the HasValue byte at <c>[rbx+0x6C]</c>, i.e. the
/// first of the three at 0x68/0x70/0x78).
///
/// Public (not private) so System.Text.Json's reflection-based
/// (de)serialiser can see the type and its accessors.</summary>
public sealed class CommentPayload
{
public long? SubRoomId { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary><c>RoomComment.EKHGDABMJOJ</c>: Feedback=0, Idea=1, BugReport=2
/// (RecNet/RoomComment.cs enum member order).</summary>
public int Style { get; set; }
public float? X { get; set; }
public float? Y { get; set; }
public float? Z { get; set; }
public DateTime CreatedAt { get; set; }
}
// ── Reads ────────────────────────────────────────────────────────────
/// <summary>Comment list for a room.
///
/// <c>OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)</c>
/// returns <c>FGLDKEJLAKB&lt;List&lt;RecNet.RoomComment&gt;&gt;</c>
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON <b>array</b> — not a
/// paged <c>{Results,TotalResults}</c> container. Verb is GET (rdx=0 at
/// instr 044) and both parameters go on the query string, because
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> only promotes fields to a form body for
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
///
/// <c>minId</c> is a lower bound on the comment id (the only caller,
/// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
/// it is applied inclusively and the newest <c>count</c> matches are
/// returned newest-first — which is the order the paged grid renders.
///
/// Failure toast if this 404s or the body will not parse:
/// "Failed to get room comments for room: {0}" (:537).</summary>
[HttpGet("/comments/get/{roomId:long}")]
public async Task<IActionResult> Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
{
if (count <= 0) count = 100;
if (count > 500) count = 500;
var rows = await CommentRowsForRoomAsync(roomId);
var wire = rows
.Where(r => r.CommentId >= minId)
.OrderByDescending(r => r.CommentId)
.Take(count)
.Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
.ToList();
return Ok(wire);
}
/// <summary>Room-comment unread counts. Client contract (RecNet.Runtime
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection&lt;long&gt;)</c>).
///
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
/// type is <c>Dictionary&lt;long,uint&gt;</c>, but that dictionary is built
/// client-side: OPELMBNJHNO.txt:889 constructs a
/// <c>Func&lt;List&lt;UnreadRoomComments&gt;, Dictionary&lt;long,uint&gt;&gt;</c>
/// projection that runs over the deserialised payload. So the wire type is
/// <c>List&lt;RecNet.UnreadRoomComments&gt;</c> — each element an object with a
/// room id (Int64) and a count (UInt32), per the two properties on
/// RecNet/UnreadRoomComments.txt.
///
/// This previously returned an object map keyed by room id. Json.NET cannot
/// read a JSON object into a <c>List&lt;T&gt;</c>, so every call threw and the
/// client logged "Failed to get unread room comment counts"
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
/// room-comment unread badge never appeared.
///
/// The element's exact property names are attribute-driven and are not
/// recoverable from the ISIL (see the note on key aliasing below), so each
/// element carries the plausible spellings side by side. Json.NET matches
/// case-insensitively and ignores unknown members, so the extra aliases are
/// inert whichever name the DTO actually declares.
///
/// Counts are real now that <see cref="MarkRead"/> persists per-player read
/// state: a room's count is the number of comments authored by somebody
/// else whose id is above the caller's last-read id for that room.
/// </summary>
[HttpPost("/comments/unreadcounts")]
[HttpGet("/comments/unreadcounts")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UnreadCounts()
{
var roomIds = await ReadRoomIdsAsync();
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
var me = Me;
var lastRead = await LastReadAsync(me);
var counts = new Dictionary<long, uint>();
foreach (var id in roomIds) counts[id] = 0u;
// One bounded query per requested room instead of materialising the
// entire comment keyspace: the room filter and the "your own notes
// are never unread" filter both run in SQL; only the numeric
// comment-id suffix still needs parsing here. Key + owner only: the
// payload column is irrelevant to counting.
foreach (var id in roomIds)
{
var prefix = $"{CommentPrefix}{id}:";
var keys = await db.PlayerSettings
.Where(s => s.Key.StartsWith(prefix) && s.PlayerId != me)
.Select(s => s.Key)
.ToListAsync();
var floor = lastRead.GetValueOrDefault(id);
foreach (var key in keys)
{
if (!TrySplitCommentKey(key, out var roomId, out var commentId)) continue;
if (roomId != id) continue;
if (commentId <= floor) continue;
counts[id]++;
}
}
var result = roomIds
.Select(id => new Dictionary<string, object>
{
["RoomId"] = id,
["UnreadCount"] = counts[id],
["Count"] = counts[id],
})
.ToList();
return Ok(result);
}
// ── Writes ───────────────────────────────────────────────────────────
/// <summary>Posts a new comment into a room.
///
/// <c>OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
/// RoomComment/EKHGDABMJOJ style, Nullable&lt;Vector3&gt; position)</c>
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB&lt;RecNet.RoomComment&gt;</c> —
/// a <b>single</b> comment object, not a list and not a status envelope.
///
/// Verb is POST (rdx=2 at instr 069), so every <c>AFGEDDANEKP</c> field is
/// an <c>application/x-www-form-urlencoded</c> body field, never a query
/// param. Field literals, in emission order: "message" (:1234),
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed <b>UInt32</b>),
/// then — only when the <c>Vector3?</c> has a value (the
/// <c>Compare [rdi+12], r12 / JumpIfEqual</c> at :1268) — "positionX"
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
/// <c>Single.ToString(CultureInfo.InvariantCulture)</c> (:1285/1289), which
/// is why the three floats are parsed invariantly here rather than left to
/// model binding.
///
/// Failure toast: "Failed to Create Comment" (:1359).</summary>
[HttpPost("/comments/create/{roomId:long}")]
public async Task<IActionResult> Create(
long roomId,
[FromForm] string? message,
[FromForm] long? subRoomId,
[FromForm] int style = 0,
[FromForm] string? positionX = null,
[FromForm] string? positionY = null,
[FromForm] string? positionZ = null)
{
if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
message = (message ?? string.Empty).Trim();
if (message.Length == 0) return BadRequest();
// PlayerSettingEntity.Value is MaxLength(1024) and also carries the
// rest of the payload; keep well clear of the column width.
if (message.Length > 700) message = message[..700];
var payload = new CommentPayload
{
SubRoomId = subRoomId is > 0 ? subRoomId : null,
Message = message,
Style = style,
X = ParseFloat(positionX),
Y = ParseFloat(positionY),
Z = ParseFloat(positionZ),
CreatedAt = DateTime.UtcNow,
};
// The client only ever sends all three or none; drop a partial set so
// the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
// consistent null.
if (payload.X is null || payload.Y is null || payload.Z is null)
payload.X = payload.Y = payload.Z = null;
var me = Me;
var commentId = await NextCommentIdAsync();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = $"{CommentPrefix}{roomId}:{commentId}",
Value = JsonSerializer.Serialize(payload),
});
await db.SaveChangesAsync();
// Authoring a comment implies you have read up to it.
await SetLastReadAsync(me, roomId, commentId);
await db.SaveChangesAsync();
return Ok(ToWire(commentId, roomId, me, payload, unread: false));
}
/// <summary>Records the newest comment the caller has read in a room, which
/// is what clears the watch's unread badge (and what makes
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
///
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
/// the request carries no body at all.
///
/// Failure toast: "Failed to update latest read comment" (:1656).</summary>
[HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
public async Task<IActionResult> MarkRead(long roomId, long commentId)
{
await SetLastReadAsync(Me, roomId, commentId);
await db.SaveChangesAsync();
return Ok();
}
/// <summary>Deletes a comment.
///
/// <c>OPELMBNJHNO.NDLOEEHIDED(Int64 commentId)</c> (OPELMBNJHNO.txt:1781)
/// returns the status-only <c>LDGADANDBIO</c>. Verb is <b>DELETE</b>
/// (rdx=4 at instr 046) and the host ordinal is the only immediate on the
/// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946.
/// No fields are attached: the comment id is the whole request.
///
/// Because the route carries no room id, the storage key is matched by its
/// <c>:{commentId}</c> suffix; read-state rows live under a different
/// prefix precisely so they cannot collide here. Only the author, the room
/// creator, or an accepted co-owner/moderator of the room may delete —
/// anyone else gets 403 and the client raises its "Failed to delete
/// comment" toast (:1962).</summary>
[HttpDelete("/comments/delete/{commentId:long}")]
public async Task<IActionResult> Delete(long commentId)
{
var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture);
var row = await db.PlayerSettings
.Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix))
.FirstOrDefaultAsync();
if (row is null) return NotFound();
if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound();
var me = Me;
if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid();
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
return Ok();
}
// ── Wire shape ───────────────────────────────────────────────────────
/// <summary>One <c>RecNet.RoomComment</c>. Member list and CLR types are
/// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
/// dump.cs:1241576): <c>long</c> comment id, <c>long</c> room id,
/// <c>long?</c> sub-room id, <c>int?</c> account id, <c>DateTime</c>
/// created-at, <c>string</c> message, the <c>EKHGDABMJOJ</c> style enum, a
/// settable <c>bool</c>, three internal <c>float?</c> position components,
/// and one computed get-only <c>bool</c> that is never on the wire.
///
/// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs
/// in global-metadata.dat and are not recoverable from Cpp2IL output, so
/// the names below are taken from the client's own <b>request</b> field
/// literals for the same values — "message", "subRoomId", "style",
/// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) —
/// which mirror the server DTO they were serialised from. Json.NET matches
/// case-insensitively and ignores unknown members, so the aliases for the
/// two members with no request-side counterpart are inert.
///
/// The settable bool is the unread flag: its only mutator
/// <c>RoomComment.MCCJIBCJEJC</c> clears the byte at <c>[rcx+0x64]</c> and
/// fires the change event (RoomComment.txt:471-493), and the computed
/// get-only bool <c>FOOFBPEMFCB</c> is <c>AccountId == localAccountId ||
/// !flag</c> (:442-466, comparing against
/// <c>OPMAPIOEIFG.GGAEMNHEHLO</c>). Its declared name is unknown, hence the
/// three non-inverting spellings.</summary>
private static Dictionary<string, object?> ToWire(
long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal)
{
["CommentId"] = commentId,
["RoomId"] = roomId,
["SubRoomId"] = p.SubRoomId,
["AccountId"] = (int)authorPlayerId,
["CreatedAt"] = p.CreatedAt,
["Message"] = p.Message,
["Style"] = p.Style,
["PositionX"] = p.X,
["PositionY"] = p.Y,
["PositionZ"] = p.Z,
["IsUnread"] = unread,
["Unread"] = unread,
["IsNew"] = unread,
};
// ── Storage helpers ──────────────────────────────────────────────────
private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread);
/// <summary>Every stored comment for a room, with the caller's unread flag
/// resolved against their read-state row.</summary>
private async Task<List<StoredComment>> CommentRowsForRoomAsync(long roomId)
{
var me = Me;
var prefix = $"{CommentPrefix}{roomId}:";
var rows = await db.PlayerSettings
.Where(s => s.Key.StartsWith(prefix))
.Select(s => new { s.Key, s.PlayerId, s.Value })
.ToListAsync();
var lastRead = await LastReadAsync(me);
var seen = lastRead.GetValueOrDefault(roomId);
var list = new List<StoredComment>(rows.Count);
foreach (var r in rows)
{
if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId))
continue;
CommentPayload? payload;
try { payload = JsonSerializer.Deserialize<CommentPayload>(r.Value); }
catch (JsonException) { continue; }
if (payload is null) continue;
list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen));
}
return list;
}
/// <summary>Comment ids are handed out from the wall clock in milliseconds
/// so they stay globally unique and monotonically increasing across
/// restarts — the client's <c>minId</c> filter and the unread comparison
/// both rely on "higher id == newer".</summary>
private async Task<long> NextCommentIdAsync()
{
var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (var attempt = 0; attempt < 64; attempt++)
{
var suffix = ":" + id.ToString(CultureInfo.InvariantCulture);
var taken = await db.PlayerSettings
.AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix));
if (!taken) break;
id++;
}
return id;
}
/// <summary>Last-read comment id per room for one player.</summary>
private async Task<Dictionary<long, long>> LastReadAsync(long playerId)
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix))
.Select(s => new { s.Key, s.Value })
.ToListAsync();
var map = new Dictionary<long, long>();
foreach (var r in rows)
{
if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue;
if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue;
if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId;
}
return map;
}
/// <summary>Read state only ever moves forward — the watch re-sends older
/// ids as the grid scrolls back up.</summary>
private async Task SetLastReadAsync(long playerId, long roomId, long commentId)
{
var key = $"{ReadPrefix}{roomId}";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = commentId.ToString(CultureInfo.InvariantCulture),
});
return;
}
if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return;
row.Value = commentId.ToString(CultureInfo.InvariantCulture);
}
/// <summary>Room creator, or an accepted co-owner (Role 0) / moderator
/// (Role 1) — the same grant model
/// <see cref="Data.Entities.RoomRoleEntity"/> documents.</summary>
private async Task<bool> CanModerateRoomAsync(long roomId, long playerId)
{
if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1));
}
/// <summary>Splits <c>roomcomment:{roomId}:{commentId}</c>.</summary>
private static bool TrySplitCommentKey(string key, out long roomId, out long commentId)
{
roomId = 0;
commentId = 0;
if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false;
var rest = key[CommentPrefix.Length..];
var sep = rest.IndexOf(':');
if (sep <= 0) return false;
return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)
&& long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId);
}
private static float? ParseFloat(string? s) =>
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
private async Task<HashSet<long>> ReadRoomIdsAsync()
{
var ids = new HashSet<long>();
void AddCsv(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return;
foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
// Query / form: roomIds=1,2,3
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
{
AddCsv(Request.Query[k].ToString());
if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
}
// JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
JsonElement arr = default;
var haveArr = false;
if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
else if (root.ValueKind == JsonValueKind.Object)
{
foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
}
if (haveArr)
foreach (var el in arr.EnumerateArray())
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
}
catch { /* non-JSON / empty body */ }
}
return ids;
}
}
@@ -65,7 +65,6 @@ public class CommunityBoardController(
? new()
: await db.Rooms
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
@@ -76,13 +75,13 @@ public class CommunityBoardController(
FeaturedRoomGroupId = 0L,
Name = group.Name ?? string.Empty,
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
.ToArray(),
// Keep the old key around for any 2020.03 clients hitting prod.
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
? new { RoomName = r.Name, RoomId = r.Id, ImageName = RoomService.ResolveDisplayImageName(r) }
: new { RoomName = string.Empty, RoomId = id, ImageName = RoomService.DefaultRoomImageName })
.ToArray(),
};
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
@@ -12,6 +13,19 @@ namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client GETs this (verb register <c>rdx=0</c>
/// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
/// <c>"api/banappeal/generateCode"</c> literal on :2980) and deserialises
/// the response as a bare <c>String</c> — the issuing method is
/// <c>FGLDKEJLAKB&lt;System.String&gt; HBEPHMAKCDN()</c> (:2759) and the
/// continuation is <c>Func&lt;IPCJLCNIBEG&lt;String&gt;,
/// FGLDKEJLAKB&lt;String&gt;&gt;</c> (:3019).
///
/// It used to be POST-only returning <c>{value: code}</c>, so the client
/// got a 405 and, had it reached the handler, would have thrown parsing a
/// JSON object into a string. Ban-appeal code generation never worked.
/// The body is now the bare JSON string (<c>"BA-123456"</c>).</summary>
[HttpGet("api/banappeal/generateCode")]
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
@@ -25,7 +39,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
return Content(JsonSerializer.Serialize(code), "application/json");
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
@@ -60,19 +74,145 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
.ExecuteDeleteAsync();
}
// 2020.12 deserializer for this response expects `subscription` to be a
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
// fields), NOT a boolean. Sending a bool throws InvalidCastException
// on the client ("Unable to cast object of type 'Boolean' to type
// 'Dictionary`2'"). Send null when there's no active subscription —
// the client's generic-cast helper returns default(T) which is fine.
return Ok(new
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
var now = DateTime.UtcNow;
var subscriptionId = unchecked((pid * 397L) ^ targetId);
var subscription = new Dictionary<string, object?>
{
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
["subscriptionId"] = subscriptionId,
["recNetPlayerId"] = pid,
["platformAccountSubscribedPlayerId"] = targetId,
["isActive"] = active,
["startedAt"] = now,
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
["renewalDate"] = active ? now.AddYears(10) : now,
["SubscriptionId"] = subscriptionId,
["RecNetPlayerId"] = pid,
["PlatformAccountSubscribedPlayerId"] = targetId,
["IsActive"] = active,
["StartedAt"] = now,
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
["RenewalDate"] = active ? now.AddYears(10) : now,
};
return Ok(new Dictionary<string, object?>
{
["subscription"] = subscription,
["platformAccountSubscribedPlayerId"] = targetId,
["Subscription"] = subscription,
["PlatformAccountSubscribedPlayerId"] = targetId,
});
}
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
[AllowAnonymous]
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json");
/// <summary>The 2023-03-21 client POSTs this — the request is built at
/// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal
/// <c>"api/AppIntegrity/v1/iosproducts"</c> goes into <c>r9</c> and the
/// verb register <c>rdx=2</c> = <c>BestHTTP.HTTPMethods.Post</c> (host
/// <c>r8=1</c>). The payload is a RAW JSON body — the serialized list is
/// handed to <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (:138), not the
/// form-parameter helper — holding
/// <c>List&lt;GAAPALMODOL/KOLPHINEHDD&gt;</c>, i.e. a top-level array of
/// {Name:String, Price:Single, ProductId:String} (getters at
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table,
/// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306).
/// The issuing method's return type is
/// <c>FGLDKEJLAKB&lt;PHMHCPEMABG&gt;</c> (:3) and PHMHCPEMABG is
/// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed
/// <c>Success</c>/<c>Message</c> (GBPDOLJBABB.txt:191-218).
///
/// <see cref="DorkNet.Server.Controllers.API.AppIntegrity.AppIntegrityController"/>
/// registers the same
/// path GET-only, so the client's POST hit a 405 and the StoreKit
/// price report was dropped. Registering only the POST verb here leaves
/// that GET (and its consumers) untouched — the two never collide
/// because the method constraints are disjoint.
///
/// The reported catalogue is what Apple's StoreKit resolved on the
/// device, so it is inherently per-account/per-storefront: it is
/// persisted as the reporting player's <c>appintegrity:iosproducts</c>
/// setting (same PlayerSettings convention as the ban-appeal handler
/// above), replacing any earlier report. Only a malformed body answers
/// Success=false; the client shows nothing on success and PHMHCPEMABG's
/// Message is left empty.</summary>
[HttpPost("api/AppIntegrity/v1/iosproducts")]
[Authorize]
public async Task<IActionResult> IosProducts()
{
var pid = this.RequireCurrentPlayerId();
List<IosProduct>? products;
try
{
products = await JsonSerializer.DeserializeAsync<List<IosProduct>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
return IosProductsResult(false, "malformed_product_list");
}
if (products is null) return IosProductsResult(false, "malformed_product_list");
// "{productId}={price}" pairs rather than the raw JSON: the
// localized Name adds nothing a server-side reader needs and
// PlayerSettingEntity.Value caps at 1024 chars, so entries are
// dropped (never truncated mid-pair) once the budget is spent.
var parts = new List<string>();
var length = 0;
foreach (var p in products)
{
var productId = p.ProductId?.Trim();
if (string.IsNullOrEmpty(productId)) continue;
var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}";
var cost = parts.Count == 0 ? pair.Length : pair.Length + 1;
if (length + cost > 1024) break;
parts.Add(pair);
length += cost;
}
const string key = "appintegrity:iosproducts";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = key,
Value = string.Join(';', parts),
});
}
else
{
row.Value = string.Join(';', parts);
}
await db.SaveChangesAsync();
return IosProductsResult(true, string.Empty);
}
/// <summary>PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase
/// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would
/// satisfy it; both are emitted for the same reason the CampusCard
/// payload above does. Dictionary keys go out verbatim — the global
/// serializer sets PropertyNamingPolicy = null
/// (Startup/ServiceCollectionExtensions.cs:381).</summary>
private IActionResult IosProductsResult(bool success, string message)
=> Ok(new Dictionary<string, object?>
{
["Success"] = success,
["Message"] = message,
["success"] = success,
["message"] = message,
});
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
@@ -85,7 +225,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
// Prefix with "[club {id}]" so admins can tell club reports apart
// from player reports in the shared ReportEntity queue.
var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
@@ -130,7 +272,13 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
// The 2023 client names the free-text field "details"
// (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
// the latter stored every club report with an empty body.
Message = form["details"].FirstOrDefault()
?? form["Details"].FirstOrDefault()
?? form["message"].FirstOrDefault()
?? form["Message"].FirstOrDefault(),
};
}
try
@@ -148,10 +296,24 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public long PlatformAccountSubscribedPlayerId { get; set; }
}
/// <summary>One entry of the client's raw JSON array —
/// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single,
/// String in that field order (offsets 0x10/0x18/0x20 in
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes
/// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360).</summary>
private sealed class IosProduct
{
public string? Name { get; set; }
public float Price { get; set; }
public string? ProductId { get; set; }
}
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
/// <summary>The client's spelling of <see cref="Message"/>.</summary>
public string? Details { get; set; }
}
}
@@ -21,6 +21,123 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
/// audio/core initialization. Returning 404 leaves the static backtrace
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
/// crash upload/deobfuscation disabled locally while preserving the
/// object shape and readable config keys the client inspects.
/// </summary>
[HttpGet("backtrace")]
public ActionResult<object> GetBacktrace(
[FromQuery(Name = "platformType")] string? platformType,
[FromQuery(Name = "allocate")] bool? allocate)
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var submissionUrl = $"{baseUrl}/backtrace/submit";
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
var config = new Dictionary<string, object>
{
["Backtrace.SampleRate"] = 0.0,
["Backtrace.ReportBudget"] = 0,
["Backtrace.deobfuscationRate"] = 0.0,
["Backtrace.Deobfuscate"] = false,
["Backtrace.DeobfuscationUrl"] = string.Empty,
["Backtrace.Enabled"] = false,
["Backtrace.PlatformType"] = platformType ?? string.Empty,
["Backtrace.Allocate"] = allocate ?? false,
};
return Ok(new Dictionary<string, object>
{
["MaxMessagesPerMinute"] = 0,
["MaxReportsPerMinute"] = 0,
["SampleRate"] = 0.0,
["ReportBudget"] = 0,
["ErrorReportBudget"] = 0,
["WarningReportBudget"] = 0,
["LogReportBudget"] = 0,
["SubmissionUrl"] = submissionUrl,
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
["ServerUrl"] = submissionUrl,
["DatabasePath"] = "backtrace/database",
["Config"] = config,
["maxMessagesPerMinute"] = 0,
["maxReportsPerMinute"] = 0,
["sampleRate"] = 0.0,
["reportBudget"] = 0,
["errorReportBudget"] = 0,
["warningReportBudget"] = 0,
["logReportBudget"] = 0,
["submissionUrl"] = submissionUrl,
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
["serverUrl"] = submissionUrl,
["databasePath"] = "backtrace/database",
["config"] = config,
});
}
[HttpGet("azurespeech")]
public IActionResult GetAzureSpeech()
{
return Ok(new Dictionary<string, object>
{
["Enabled"] = false,
["SpeechRecognitionEnabled"] = false,
["SpeechSynthesisEnabled"] = false,
["Region"] = string.Empty,
["SubscriptionKey"] = string.Empty,
["TokenEndpoint"] = string.Empty,
["SpeechRecognitionEndpoint"] = string.Empty,
["SpeechSynthesisEndpoint"] = string.Empty,
["enabled"] = false,
["speechRecognitionEnabled"] = false,
["speechSynthesisEnabled"] = false,
["region"] = string.Empty,
["subscriptionKey"] = string.Empty,
["tokenEndpoint"] = string.Empty,
["speechRecognitionEndpoint"] = string.Empty,
["speechSynthesisEndpoint"] = string.Empty,
});
}
[HttpGet("/voice/config")]
public IActionResult GetVoiceConfig()
{
return Ok(new Dictionary<string, object>
{
["ToxMod.Enabled"] = false,
["ToxMod.SampleRate"] = 0.0,
["ToxMod.SessionRecordingEnabled"] = false,
["ToxMod.Endpoint"] = string.Empty,
["ToxMod.ApiKey"] = string.Empty,
["Enabled"] = false,
["enabled"] = false,
["toxModEnabled"] = false,
});
}
/// <summary>GET <c>/voice/requiresModeration</c> — bare Boolean the
/// client reads to decide whether voice needs moderation. ToxMod is
/// disabled server-side, so no moderation is required.</summary>
[HttpGet("/voice/requiresModeration")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
/// <summary>GET <c>/emojiConfig/whitelistedEmojis</c> — the allow-list of
/// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList&lt;string&gt;).
/// Returns a broad default set so the picker is populated.</summary>
[HttpGet("/emojiConfig/whitelistedEmojis")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult WhitelistedEmojis() => Ok(new[]
{
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
});
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
@@ -5,6 +5,7 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/config")]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
@@ -0,0 +1,1229 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController]
[Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")]
[Route("api/customAvatarItems/v2")]
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
DomainConfig domain,
IObjectStorage storage,
ILogger<CustomAvatarItemsController> logger) : ControllerBase
{
/// <summary>Floor price for a publicly listed custom item. The client
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
/// designer, so the number the create/update paths clamp to and the number
/// served on that route have to be the same constant or the client's own
/// validation disagrees with the server's rejection.</summary>
private const int MinPublicItemPrice = 100;
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
.Where(i => i.IsPublic);
if (needle.Length > 0)
{
rows = rows.Where(i =>
i.Name.ToLower().Contains(needle) ||
i.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
/// <summary>POST api/customAvatarItems/v1 — publish a shirt from the
/// in-game designer. The collection route was GET-only, so every
/// create 405'd and the designer's "publish" button could never work.
///
/// The 2023-03-21 client sends <c>multipart/form-data</c> (verb ordinal
/// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
/// param <c>"metadata"</c> carrying JsonConvert.SerializeObject of the
/// DCKDCBDHDKI request object, then TWO file parts, <c>"thumbnailImage"</c>
/// and <c>"design"</c>, both with filename <c>"file.bin"</c>
/// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store
/// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price,
/// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via
/// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission
/// enum we store as ItemType. The metadata JSON KEY names live in
/// attribute metadata the dump doesn't render, so every field is read
/// through an alias list rather than a single guessed key.</summary>
[HttpPost]
[Authorize]
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
public async Task<IActionResult> Create()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
if (name.Length > 128) name = name[..128];
var description = (req.Description ?? string.Empty).Trim();
if (description.Length > 1024) description = description[..1024];
var item = new CustomAvatarItemEntity
{
CreatorPlayerId = pid,
Name = name,
Description = description,
Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice),
ItemType = req.ItemType ?? 0,
BaseAvatarItemId = req.BaseAvatarItemId ?? 0,
Color = Trim(req.Color, 64),
// No boolean lives in DCKDCBDHDKI, so a freshly created item is
// private until the permission enum (ItemType) says otherwise.
IsPublic = req.IsPublic ?? false,
};
// "thumbnailImage" → ImageName (card art), "design" → AssetName (the
// shirt texture itself). Fall back to positional order for any client
// that names the parts differently.
var (thumbnail, asset) = ReadItemFileParts();
if (thumbnail is not null)
item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail");
if (asset is not null)
item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design");
if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256);
if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256);
db.CustomAvatarItems.Add(item);
await db.SaveChangesAsync();
await EnsureOwnershipAsync(pid, item.Id);
logger.LogInformation(
"[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}",
item.PublicId, pid, item.Name, item.ImageName, item.AssetName);
return Ok(ToWire(item));
}
[HttpGet("{id:guid}")]
[AllowAnonymous]
public async Task<IActionResult> Get(Guid id)
{
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
return Ok(ToWire(item));
}
/// <summary>PUT api/customAvatarItems/v1/{id} — edit an existing item's
/// metadata. Only GET was registered on the id route, so the details
/// screen's Save button 405'd ("Unable to update custom avatar item").
///
/// The client builds the URL with String.Format("{0}/v1/{1}") over
/// "api/customAvatarItems" and issues verb ordinal 3 = PUT
/// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON —
/// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to
/// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this
/// reads the body itself rather than model-binding it. MOBHOFPIIIJ
/// carries exactly four properties whose accessors are shared with
/// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?)
/// and the permission enum stored here as ItemType. Every field is
/// optional — absent members leave the stored value alone.</summary>
[HttpPut("{id:guid}")]
[Authorize]
public async Task<IActionResult> Update(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
if (item.CreatorPlayerId != pid) return Forbid();
var req = await ReadDesignRequestAsync();
if (req.Name is { } rawName)
{
var name = rawName.Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
item.Name = name.Length > 128 ? name[..128] : name;
}
if (req.Description is { } rawDescription)
{
var description = rawDescription.Trim();
item.Description = description.Length > 1024 ? description[..1024] : description;
}
if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price);
if (req.ItemType is { } itemType) item.ItemType = itemType;
if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId;
if (req.Color is { } color) item.Color = Trim(color, 64);
if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256);
if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256);
if (req.IsPublic is { } isPublic) item.IsPublic = isPublic;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
/// <summary>DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the
/// same String.Format("{0}/v1/{1}") URL
/// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is
/// fire-and-forget, so the missing route made deleted shirts silently
/// reappear on the next list refresh.
///
/// <see cref="CustomAvatarItemEntity"/> has no IsDeleted column (unlike
/// UgcPurchasableEntity), and adding one is a schema migration, so this
/// is a real delete: the ownership rows go first so nobody keeps the
/// item in their wardrobe, then the item row itself.</summary>
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> Delete(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return Ok();
if (item.CreatorPlayerId != pid) return Forbid();
var ownership = await db.CustomAvatarItemOwnership
.Where(o => o.CustomAvatarItemId == item.Id)
.ToListAsync();
if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership);
db.CustomAvatarItems.Remove(item);
await db.SaveChangesAsync();
logger.LogInformation(
"[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)",
id, pid, ownership.Count);
return Ok();
}
/// <summary>GET/POST api/customAvatarItems/v1/bulk — resolve the custom
/// items other players are wearing. The guid-list parameter key the
/// client uses is the literal <c>"customAvatarItemIds"</c>
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
/// ordinal rdx=2), which no read path here honoured, and the old
/// <c>[FromBody] BulkRequest</c> parameter made ASP.NET reject a
/// form-urlencoded body with 415 before the handler ran. Result: an
/// empty array, remote players' shirts never rendered, and the client
/// re-queried the same ids forever — the same failure the ugcPurchasables
/// bulk endpoint hit. The binding is gone; <see cref="ReadIdsAsync"/>
/// now reads the key from the JSON body, the form and the query.</summary>
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk()
{
var ids = await ReadIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.CustomAvatarItems
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("featured")]
[AllowAnonymous]
public async Task<IActionResult> Featured([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic && i.IsFeatured)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("hot")]
[AllowAnonymous]
public async Task<IActionResult> Hot([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CheerCount)
.ThenByDescending(i => i.PurchaseCount)
.ThenByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("new")]
[AllowAnonymous]
public async Task<IActionResult> New([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("fromCreator/{creatorId:long}")]
[AllowAnonymous]
public async Task<IActionResult> FromCreator(
long creatorId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var query = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
// Paged container, not a bare array — the client deserializes this into
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var createdQuery = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt);
var total = await createdQuery.CountAsync();
var created = await createdQuery
.Skip(skip)
.Take(take)
.ToListAsync();
var ownedIds = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Select(o => o.CustomAvatarItemId)
.ToListAsync();
var owned = ownedIds.Count == 0
? new List<CustomAvatarItemEntity>()
: await db.CustomAvatarItems
.Where(i => ownedIds.Contains(i.Id))
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(new
{
Results = created.Select(ToWire),
TotalResults = total,
Created = created.Select(ToWire),
Owned = owned.Select(ToWire),
});
}
[HttpGet("owned")]
[Authorize]
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(new
{
Results = rows.Select(ToWire),
TotalResults = total,
});
}
[HttpGet("isCreationAllowedForAccount")]
[Authorize]
public async Task<IActionResult> IsCreationAllowedForAccount()
{
var pid = this.RequireCurrentPlayerId();
var player = await db.Players
.Where(p => p.Id == pid)
.Select(p => new { p.IsJunior, p.BannedUntil })
.FirstOrDefaultAsync();
var allowed = player is not null
&& !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
// Bare JSON boolean, like the two sibling gates below: the client's
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
// object body threw and the can-create gate blocked shirt creation.
return Content(allowed ? "true" : "false", "application/json");
}
[HttpGet("isCreationEnabled")]
[AllowAnonymous]
public IActionResult IsCreationEnabled() => Content("true", "application/json");
[HttpGet("isRenderingEnabled")]
[AllowAnonymous]
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
/// ignores the ones that don't match, and a total miss falls back to the
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
[HttpGet("minPriceForPublicItem")]
[AllowAnonymous]
public IActionResult MinPriceForPublicItem() => Ok(new
{
MinPrice = MinPublicItemPrice,
Price = MinPublicItemPrice,
MinimumPrice = MinPublicItemPrice,
MinPriceForPublicItem = MinPublicItemPrice,
});
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
/// saved in-progress shirt design. Verb ordinal 0 = GET
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered,
/// so the designer logged "Unable to get custom avatar design info" every
/// time it opened.
///
/// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String
/// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name
/// map is per-property-name rather than per-member, so those four
/// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same
/// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI,
/// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and
/// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of
/// NOAAFNFJPFB — the creator id and the image name. Hence
/// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys
/// are inert aliases.
///
/// Returns an empty design rather than 404 when nothing is saved so the
/// designer opens clean instead of showing the error toast.</summary>
[HttpGet("design")]
[Authorize]
public async Task<IActionResult> GetDesign()
{
var pid = this.RequireCurrentPlayerId();
var design = await LoadDesignAsync(pid) ?? new StoredDesign();
return Ok(new
{
CreatorPlayerId = (int)pid,
CreatorId = (int)pid,
design.BaseAvatarItemId,
design.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(design.ImageName)
? string.Empty
: $"https://{domain.Sub("cdn")}/{design.ImageName}",
design.Color,
});
}
/// <summary>PUT api/customAvatarItems/v1/design — save the in-progress
/// shirt texture. The route only accepted POST, so the designer could
/// never persist a design ("Unable to save custom avatar design").
///
/// Verb ordinal 3 = PUT with a multipart body: named param
/// <c>"metadata"</c> plus a file part <c>"design"</c> with filename
/// <c>"file.bin"</c>
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The
/// metadata object GIOBJIGEMNG holds just two properties — a
/// Nullable&lt;Int32&gt; base avatar item id at +0x10 and the "#RRGGBB"
/// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the
/// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654.
/// The client's return type is status-only (LDGADANDBIO), so a bare 200
/// is the whole contract.</summary>
[HttpPut("design")]
[Authorize]
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
public async Task<IActionResult> SaveDesign()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var existing = await LoadDesignAsync(pid);
var design = new StoredDesign
{
BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId,
Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty,
ImageName = existing?.ImageName ?? string.Empty,
};
// The texture arrives as the "design" part; accept any single file
// part as a fallback for a client that names it differently.
var file = Request.HasFormContentType
? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault()
: null;
if (file is not null && file.Length > 0)
{
var bytes = await ReadFileBytesAsync(file);
var blobName = DesignImageBlobName(pid);
await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design");
design.ImageName = blobName;
}
await SaveDesignAsync(pid, design);
return Ok();
}
/// <summary>DELETE api/customAvatarItems/v1/design — discard the saved
/// design. Verb ordinal 4 on the same literal
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client
/// logged "Unable to delete custom avatar item design".
///
/// <see cref="IObjectStorage"/> has no delete operation, so the record is
/// tombstoned (a Deleted marker that <see cref="LoadDesignAsync"/> reads
/// as "no design") and the texture object is truncated to zero bytes so
/// the pixels really are gone.</summary>
[HttpDelete("design")]
[Authorize]
public async Task<IActionResult> DeleteDesign()
{
var pid = this.RequireCurrentPlayerId();
await SaveDesignAsync(pid, new StoredDesign { Deleted = true });
await PutBlobAsync(DesignImageBlobName(pid), Array.Empty<byte>(), "image/png", "custom avatar design");
return Ok();
}
[HttpPost("design")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Design()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
if (name.Length > 128) name = name[..128];
var description = (req.Description ?? string.Empty).Trim();
if (description.Length > 1024) description = description[..1024];
CustomAvatarItemEntity item;
if (req.Id is Guid id)
{
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
if (item.Id == 0) db.CustomAvatarItems.Add(item);
}
else
{
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
db.CustomAvatarItems.Add(item);
}
item.Name = name;
item.Description = description;
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
item.ImageName = Trim(req.ImageName, 256);
item.AssetName = Trim(req.AssetName, 256);
item.IsPublic = req.IsPublic ?? item.IsPublic;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await EnsureOwnershipAsync(pid, item.Id);
return Ok(ToWire(item));
}
/// <summary>The 2023-03-21 client buys custom items through the
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
/// — which was never registered, so every custom-shirt purchase 404'd. The
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
/// arrives as a query param and is validated against the item's real price
/// so a tampered client can't buy at its own number.</summary>
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
[HttpPost("{id:guid}/purchase")]
[Authorize]
public async Task<IActionResult> Purchase(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
if (item is null) return NotFound();
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
// requestedPrice is baked into the URL the client formats —
// "{0}/v1/{1}/purchase/?requestedPrice={2}"
// (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was
// SHOWN, so a value below the real price means the listing moved under
// them (or the request was tampered with); reject instead of silently
// charging more than the confirmation dialog said.
var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null;
if (requestedPrice is { } shown && shown < item.Price)
return Ok(new { Success = false, Error = "price_changed", Price = item.Price, Item = ToWire(item) });
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
await EnsureOwnershipAsync(pid, item.Id);
item.PurchaseCount += 1;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
}
/// <summary>POST api/customAvatarItems/v1/{id}/report — file a moderation
/// report against another player's custom shirt. No report route was
/// registered anywhere on the server, so the report dialog's "Submit"
/// button 404'd and the client toasted "Unable to report custom avatar
/// item {0}".
///
/// Verb ordinal <c>dl=2</c> = POST, on the URL
/// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems"
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is
/// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a
/// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256,
/// :4282), NOT form-urlencoded — so the body is read here instead of
/// model-bound, exactly like PUT {id} above.
///
/// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status
/// only. Its continuation chain (0x181F2F300 → 0x1836E6740 →
/// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the
/// DELETE {id} path uses (:2206-2215), and that path is satisfied by a
/// bare 200, so an empty 200 is the whole response contract here too.
///
/// JEHCPEEKMML has exactly three members, pinned by field offset from the
/// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store
/// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the
/// free-text detail, +0x20 a Nullable&lt;Int32&gt;. Their JSON KEY names
/// live in attribute metadata Cpp2IL doesn't render — obfuscated in the
/// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is
/// matched against an alias list, with a positional fallback in
/// declaration order for anything unrecognised (Newtonsoft writes members
/// in declaration order and the DTO has exactly these three).
///
/// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06
/// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1,
/// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is
/// a DIFFERENT numbering from <see cref="ReportEntity.Category"/>'s
/// RoomieReportCategory convention (Harassment=1 … Other=5), so the
/// ordinal is stored verbatim and the readable name is written into the
/// message — otherwise a moderator reading the shared queue cannot tell a
/// CoC_Sexual=1 clothing report from a Harassment=1 player report.
///
/// <see cref="ReportEntity"/> has no custom-avatar-item column and adding
/// one is a migration, so the reported item's guid is carried in the
/// stored text — the same thing the sibling screen-share report handler
/// does with its image/instance evidence. That marker prefix is also what
/// makes the duplicate check below work.</summary>
[HttpPost("{id:guid}/report")]
[Authorize]
public async Task<IActionResult> Report(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
// The report button only renders on someone else's item, so this is an
// impossible UI state; reject rather than pollute the queue.
if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" });
var req = await ReadReportRequestAsync();
// The dialog refuses to submit without a category ("Cannot report
// custom clothing without a report category." —
// Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so
// a missing reason means a non-stock caller; bucket it as Other=4.
var reason = req.Reason ?? 4;
var context = new List<string> { $"reason={ReportReasonName(reason)}({reason})" };
// The only in-game caller passes null for the Nullable<Int32>
// (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and
// the trailing bool), so its meaning is unproven — keep whatever a
// client does send rather than silently dropping evidence.
if (req.Context is { } ctx) context.Add($"ctx={ctx}");
var details = (req.Details ?? string.Empty).Trim();
var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim();
if (message.Length > 1000) message = message[..1000];
// One open report per (reporter, item): the dialog is re-openable and
// the client retries on a failed submit, so without this a single
// annoyed player can flood the moderation queue.
var marker = ReportMarker(id);
var duplicate = await db.Reports.AnyAsync(r =>
r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker));
if (duplicate)
{
logger.LogInformation(
"[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid);
return Ok();
}
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = item.CreatorPlayerId,
Category = reason,
Message = message,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}",
id, item.CreatorPlayerId, pid, ReportReasonName(reason));
return Ok();
}
/// <summary>Prefix every stored custom-item report with the item guid so
/// the row is attributable without a dedicated column, and so the
/// duplicate check can find prior reports with a plain LIKE.</summary>
private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} ";
/// <summary>LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump
/// (dump.cs:1250039-1250044). Index IS the wire ordinal.</summary>
private static readonly string[] ReportReasonNames =
{
"CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling",
"Misleading", "Other", "InappropriateClothing",
};
private static string ReportReasonName(int reason) =>
reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown";
/// <summary>Read the report body. The stock client sends raw JSON, but
/// form encodings are accepted too so an admin/tooling caller can post the
/// same three fields flat.</summary>
private async Task<ReportRequest> ReadReportRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? Field(params string[] keys)
{
foreach (var key in keys)
if (form[key].FirstOrDefault() is { } v) return v;
return null;
}
return new ReportRequest
{
Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason",
"category", "Category", "reportCategory", "ReportCategory", "type", "Type")),
Details = Field("details", "Details", "description", "Description",
"message", "Message", "comment", "Comment"),
Context = int.TryParse(
Field("contextId", "ContextId", "instanceId", "InstanceId",
"roomInstanceId", "RoomInstanceId", "roomId", "RoomId",
"accountId", "AccountId", "playerId", "PlayerId"),
out var ctx)
? ctx
: null,
};
}
using var reader = new StreamReader(Request.Body);
return ParseReportJson(await reader.ReadToEndAsync());
}
/// <summary>Lenient JSON → <see cref="ReportRequest"/>. Alias-matched
/// first; anything left over is assigned positionally in JEHCPEEKMML's
/// declaration order (reason, detail, nullable int) so an unguessed key
/// spelling still lands in the right slot.</summary>
private static ReportRequest ParseReportJson(string json)
{
var req = new ReportRequest();
if (string.IsNullOrWhiteSpace(json)) return req;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
var unmatched = new List<JsonElement>();
foreach (var prop in doc.RootElement.EnumerateObject())
{
switch (prop.Name.ToLowerInvariant())
{
case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype":
req.Reason ??= ParseReportReason(prop.Value);
break;
case "details" or "detail" or "description" or "reportdescription"
or "message" or "comment" or "text":
req.Details ??= AsString(prop.Value);
break;
case "contextid" or "instanceid" or "roominstanceid" or "roomid"
or "accountid" or "playerid" or "reportedplayerid":
req.Context ??= AsInt(prop.Value);
break;
default:
unmatched.Add(prop.Value);
break;
}
}
foreach (var value in unmatched)
{
if (req.Reason is null && ParseReportReason(value) is { } reason)
{
req.Reason = reason;
continue;
}
if (req.Details is null && value.ValueKind == JsonValueKind.String)
{
req.Details = value.GetString();
continue;
}
req.Context ??= AsInt(value);
}
}
catch (JsonException)
{
}
return req;
}
/// <summary>Accept the reason as an ordinal or as one of the enum's own
/// names — Newtonsoft writes an int by default, but a StringEnumConverter
/// on the DTO (an attribute the dump doesn't render) would write the name.</summary>
private static int? ParseReportReason(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
JsonValueKind.String => ParseReportReason(value.GetString()),
_ => null,
};
private static int? ParseReportReason(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (int.TryParse(value, out var ordinal)) return ordinal;
var index = Array.FindIndex(
ReportReasonNames,
n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase));
return index >= 0 ? index : null;
}
private async Task EnsureOwnershipAsync(long playerId, long itemId)
{
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
{
return;
}
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
{
PlayerId = playerId,
CustomAvatarItemId = itemId,
});
await db.SaveChangesAsync();
}
/// <summary>Collect the bulk request's guid list. The 2023 client's key is
/// <c>"customAvatarItemIds"</c> (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242);
/// older callers use ids/itemIds. Which transport the request builder picks
/// for a POST collection param isn't decidable from ISIL, so all three are
/// read: raw JSON body, form fields and query pairs. The body is read
/// manually — a [FromBody] parameter would 415 a form-encoded POST before
/// the handler ran.</summary>
private async Task<List<Guid>> ReadIdsAsync()
{
var result = new List<Guid>();
void AddCsv(string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
}
if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
// Either a bare guid array or an object carrying one under any
// of the accepted key spellings.
if (root.ValueKind == JsonValueKind.Array)
{
AddJsonGuids(root, result);
}
else if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (!IdListKeys.Contains(prop.Name)) continue;
if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result);
else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString());
}
}
}
catch (JsonException)
{
}
}
foreach (var pair in Request.Query)
foreach (var value in pair.Value)
AddCsv(value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var field in form)
{
if (!IdListKeys.Contains(field.Key)) continue;
foreach (var value in field.Value) AddCsv(value);
}
}
return result.Distinct().Take(200).ToList();
}
private static void AddJsonGuids(JsonElement array, List<Guid> into)
{
foreach (var el in array.EnumerateArray())
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id))
into.Add(id);
}
/// <summary>Accepted spellings of the bulk guid-list key. Case-insensitive
/// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land.</summary>
private static readonly HashSet<string> IdListKeys = new(StringComparer.OrdinalIgnoreCase)
{
"customAvatarItemIds", "ids", "itemIds",
};
/// <summary>Read the fields of a create / update / design-save request.
/// Three encodings have to work: the designer's multipart bodies wrap
/// everything in a single <c>"metadata"</c> form field holding JSON
/// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210),
/// PUT {id} sends a raw JSON body, and older/admin callers post flat form
/// fields. The JSON key names are not in the dump — they live in attribute
/// metadata Cpp2IL doesn't render — so each field is matched against an
/// alias list rather than one guessed spelling. Only the field ORDER is
/// proven (from the arg-store sequence); the names are inferred.</summary>
private async Task<DesignRequest> ReadDesignRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(metadata))
return ParseDesignJson(metadata);
string? Field(params string[] keys)
{
foreach (var key in keys)
if (form[key].FirstOrDefault() is { } v) return v;
return null;
}
return new DesignRequest
{
Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null,
Name = Field("name", "Name"),
Description = Field("description", "Description"),
Price = int.TryParse(Field("price", "Price"), out var price) ? price : null,
ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null,
BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null,
Color = Field("color", "Color"),
ImageName = Field("imageName", "ImageName"),
AssetName = Field("assetName", "AssetName"),
IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null,
};
}
using var reader = new StreamReader(Request.Body);
return ParseDesignJson(await reader.ReadToEndAsync());
}
/// <summary>Lenient JSON → <see cref="DesignRequest"/>. Members that are
/// absent stay null so callers can tell "not sent" from "sent empty" —
/// PUT {id} relies on that to leave untouched fields alone.</summary>
private static DesignRequest ParseDesignJson(string json)
{
var req = new DesignRequest();
if (string.IsNullOrWhiteSpace(json)) return req;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
foreach (var prop in doc.RootElement.EnumerateObject())
{
var value = prop.Value;
switch (prop.Name.ToLowerInvariant())
{
case "id" or "customavataritemid":
if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id;
break;
case "name" or "itemname" or "title":
req.Name = AsString(value);
break;
case "description" or "desc":
req.Description = AsString(value);
break;
case "price":
req.Price = AsInt(value);
break;
// The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the
// item's permission (the 06.21 dump names its config row
// CustomAvatarItemPermissionFriendlyNameMapping); this
// schema calls that column ItemType, hence both aliases.
case "itemtype" or "type" or "permission" or "avataritemtype":
req.ItemType = AsInt(value);
break;
case "baseavataritemid" or "baseitemid" or "avataritemid":
req.BaseAvatarItemId = AsInt(value);
break;
case "color" or "colorhex" or "tint":
req.Color = AsString(value);
break;
case "imagename" or "thumbnail" or "thumbnailimage":
req.ImageName = AsString(value);
break;
case "assetname" or "design" or "designname":
req.AssetName = AsString(value);
break;
case "ispublic" or "public":
req.IsPublic = value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null,
_ => null,
};
break;
}
}
}
catch (JsonException)
{
}
return req;
}
private static string? AsString(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
_ => null,
};
private static int? AsInt(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null,
_ => null,
};
/// <summary>Pick out the create request's two file parts. Named lookup
/// first ("thumbnailImage" / "design", both sent with filename
/// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a
/// fallback.</summary>
private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts()
{
if (!Request.HasFormContentType) return (null, null);
var files = Request.Form.Files;
if (files.Count == 0) return (null, null);
var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail");
var asset = files.GetFile("design") ?? files.GetFile("asset");
if (thumbnail is null && asset is null)
{
thumbnail = files[0];
asset = files.Count > 1 ? files[1] : null;
}
return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null);
}
/// <summary>Persist one uploaded image part and return its BlobName. Same
/// shape as the images controller: S3 (or the disk fallback) holds the
/// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the
/// cdn serves it straight off the BlobName.</summary>
private async Task<string> StoreImageBlobAsync(long playerId, IFormFile file, string what)
{
var bytes = await ReadFileBytesAsync(file);
var contentType = SniffImageContentType(bytes);
var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}";
await PutBlobAsync(blobName, bytes, contentType, what);
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = playerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
return blobName;
}
private static async Task<byte[]> ReadFileBytesAsync(IFormFile file)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
return ms.ToArray();
}
/// <summary>The parts are sent with filename "file.bin" and no usable
/// content type, so sniff the magic bytes instead of trusting either.</summary>
private static string SniffImageContentType(byte[] bytes) =>
bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png"
: bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg"
: "image/png";
private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token);
}
catch (Exception ex)
{
logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName);
}
}
// ── In-progress shirt design ────────────────────────────────────────────
// The designer's draft (base item + colour + texture) has no table in this
// schema and adding one is a migration, so it lives in object storage
// under two deterministic per-player blob names: overwrite-in-place on
// save, tombstoned on delete.
private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json";
private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png";
private async Task<StoredDesign?> LoadDesignAsync(long playerId)
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId));
var bytes = await storage.GetAsync(bucket, key, timeout.Token);
if (bytes is null || bytes.Length == 0) return null;
var design = JsonSerializer.Deserialize<StoredDesign>(
bytes,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return design is null || design.Deleted ? null : design;
}
catch (Exception ex) when (ex is JsonException or IOException)
{
logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId);
return null;
}
}
private Task SaveDesignAsync(long playerId, StoredDesign design) =>
PutBlobAsync(
DesignMetaBlobName(playerId),
JsonSerializer.SerializeToUtf8Bytes(design),
"application/json",
"custom avatar design record");
private object ToWire(CustomAvatarItemEntity item) => new
{
Id = item.PublicId,
CustomAvatarItemId = item.PublicId,
CreatorId = (int)item.CreatorPlayerId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.Price,
item.ItemType,
item.BaseAvatarItemId,
item.Color,
item.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
item.AssetName,
item.IsPublic,
item.IsFeatured,
item.CheerCount,
item.PurchaseCount,
item.CreatedAt,
item.UpdatedAt,
Status = item.IsPublic ? 1 : 0,
};
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class BulkRequest
{
public List<Guid>? Ids { get; set; }
public List<Guid>? ItemIds { get; set; }
}
/// <summary>The designer's in-progress draft, as persisted to the
/// per-player <c>caidesign_p&lt;id&gt;.json</c> object (see
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
/// as the separate "design" file part whose stored blob name lands in
/// <see cref="ImageName"/>.
///
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
/// exposes no delete operation, so DELETE design overwrites the record with
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
/// Public properties with setters because the round-trip goes through
/// System.Text.Json.</summary>
public sealed class StoredDesign
{
public int? BaseAvatarItemId { get; set; }
public string Color { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public bool Deleted { get; set; }
}
/// <summary>The three members of the client's report DTO JEHCPEEKMML,
/// in its declaration order: the reason enum (+0x10), the free-text
/// detail (+0x18) and an unidentified Nullable&lt;Int32&gt; (+0x20) that
/// the stock report dialog always leaves null. All nullable so
/// "not sent" is distinguishable from "sent empty".</summary>
public sealed class ReportRequest
{
public int? Reason { get; set; }
public string? Details { get; set; }
public int? Context { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int? Price { get; set; }
public int? ItemType { get; set; }
public int? BaseAvatarItemId { get; set; }
public string? Color { get; set; }
public string? ImageName { get; set; }
public string? AssetName { get; set; }
public bool? IsPublic { get; set; }
}
}
@@ -1,11 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController : ControllerBase
public class EventsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("list")]
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.Select(e => new
{
EventId = e.Id,
PlayerEventId = e.Id,
Name = e.Title,
e.Description,
StartTime = e.StartsAt,
EndTime = e.EndsAt,
CreatorPlayerId = (int)e.CreatorPlayerId,
e.RoomId,
e.Capacity,
})
.ToListAsync();
return Ok(rows.Cast<object>().ToList());
}
}
@@ -0,0 +1,135 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
[ApiController]
[Authorize]
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>Key prefix for "player X redeemed one of player Y's invites".
/// Row layout is <c>PlayerId = the invitee</c>,
/// <c>Key = externalinvite:redeemed:{kind}:{inviterAccountId}</c>,
/// <c>Value = redemption timestamp</c>. The referrer endpoints below read
/// these rows; the invite-claim path is what writes them.</summary>
private const string RedeemedPrefix = "externalinvite:redeemed:";
/// <summary>Records an out-of-game friend invite aimed at a platform (Steam,
/// Oculus, …) account.
///
/// The client only ever sends the single form field <c>platformId</c>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — <c>platform</c> keeps its default
/// so the older callers that do send it still bind.</summary>
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
{
var code = NewInviteCode();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:platform:{code}",
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
var result = Success();
result["InviteCode"] = code;
result["InviteUrl"] = url;
result["Platform"] = platform;
result["PlatformId"] = platformId ?? string.Empty;
return Ok(result);
}
/// <summary>Records an SMS friend invite.
///
/// Form fields are <c>phoneNumber</c>, <c>friendCode</c> and
/// <c>senderName</c> (OMHDDLFIPLP.txt:497/505/515) — there is no "message"
/// field, so the friend code that the text is supposed to carry has to be
/// persisted from <c>friendCode</c> or it is lost.</summary>
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task<IActionResult> SendTextMessageInvite(
[FromForm] string? phoneNumber,
[FromForm] string? friendCode,
[FromForm] string? senderName)
{
var code = NewInviteCode();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:text:{code}",
Value = $"{phoneNumber ?? string.Empty}|{friendCode ?? string.Empty}|{senderName ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var result = Success();
result["InviteCode"] = code;
result["PhoneNumber"] = phoneNumber ?? string.Empty;
return Ok(result);
}
/// <summary>Ids of the accounts that referred the caller through a platform
/// invite.
///
/// The response is a bare array of account ids — the issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;System.Int32&gt;&gt; POJMMCCLNCB()</c>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning
/// objects (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made the strict
/// Int32 reader throw as soon as any row existed, which faulted
/// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers
/// (SessionManager.txt:31439) during the login bootstrap.</summary>
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
=> Ok(await ReferrerIdsAsync("platform"));
/// <summary>Same bare-id-array contract as
/// <see cref="GetPlatformReferrers"/> (OMHDDLFIPLP.txt:998-1071), for
/// text-message invites.</summary>
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
=> Ok(await ReferrerIdsAsync("text"));
/// <summary>Account ids recorded as having referred the caller with an
/// invite of the given kind. The inviter id is the trailing key segment of
/// the redemption row, so no id can leak in from an invite's own payload
/// (a numeric <c>platformId</c> used to be misread as an account id).</summary>
private async Task<List<int>> ReferrerIdsAsync(string kind)
{
var prefix = RedeemedPrefix + kind + ":";
var keys = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.Select(s => s.Key)
.ToListAsync();
return keys
.Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
}
private static string NewInviteCode() => Guid.NewGuid().ToString("N")[..10];
/// <summary>Both write routes await <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt;</c> and
/// hand it to <c>BFDGEKKNAPB.ExpandRecNetResult</c>, whose lambda pops the
/// <c>Error</c> string as an error toast whenever <c>Success</c> is false
/// (BFDGEKKNAPB_NestedType___c.txt:61 &lt;ExpandRecNetResult&gt;b__2_0).
/// Utf8Json leaves absent members at their default, so a response without
/// these two keys reads as Success=false and every successful invite raised
/// an empty error toast. Key names are literal from the generated formatter
/// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the
/// camelCase aliases at :202/:218, and skips members it does not know, so
/// the per-route extras below are inert).</summary>
private static Dictionary<string, object?> Success() => new(StringComparer.Ordinal)
{
["Success"] = true,
["Error"] = string.Empty,
};
}
@@ -0,0 +1,124 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.FreeGifts;
[ApiController]
[Authorize]
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
[HttpPost("api/freegifts/v1/sendmultiple")]
public async Task<IActionResult> SendMultiple()
{
var fields = await ReadFieldsAsync();
var sender = this.RequireCurrentPlayerId();
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
if (recipients.Count == 0) return BadRequest("missing_recipients");
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
var message = ReadString(fields, "message", "Message") ?? string.Empty;
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
foreach (var recipient in recipients.Distinct().Take(100))
{
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = (int)sender,
CurrencyType = currency > 0 ? currencyType : 0,
Currency = currency,
Xp = xp,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = message,
Platform = -1,
PackageVariant = "FreeGift",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
created.Add((recipient, gift));
}
await db.SaveChangesAsync();
foreach (var row in created)
{
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
}
return Ok(new { Success = true, Sent = created.Count });
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(100)
.ToList();
}
@@ -9,12 +9,31 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameRewards;
/// <summary>
/// api.rec.net/api/gamerewards/v1/* — the post-activity "choose 1 of 3"
/// item reward the client's <c>RewardManager</c> shows after a Stunt
/// Runner run (RewardType <c>PostGameActivity=2</c>) or an RRO quest
/// (<c>PostQuestActivity=3</c>). The client POSTs <c>/request</c> with a
/// rewardType + giftContext (no score/rank on the wire — the rank scales
/// the separate <b>currency</b> reward, which arrives via
/// <c>storefronts/v2/balance</c> as a rank-derived multiplier that
/// <see cref="Store.StorefrontsController.ModifyBalance"/> honours), then
/// polls <c>/pending</c> and picks one via <c>/select</c>.
///
/// Each of GiftDrop1/2/3 is a real store item built through
/// <see cref="StoreService.BuildGiftDrop"/> — the SAME wire-verified
/// EFFIEFEFHHB shape the storefront uses (notably AvatarItemId is an
/// Int32), so the strict 2023 reader doesn't reject the selection. On
/// <c>/select</c> the chosen item is granted to inventory for free.
/// </summary>
[ApiController]
[Authorize]
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
public class GameRewardsController(DorkNetDbContext db, LevelService level, StoreService store) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private const int OfferCount = 3;
[HttpGet("api/gamerewards/v1/pending")]
public async Task<IActionResult> Pending()
{
@@ -24,7 +43,8 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
.OrderBy(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(rows.Select(ToWire));
var items = await LoadOfferedItemsAsync(rows);
return Ok(rows.Select(r => ToWire(r, items)));
}
[HttpPost("api/gamerewards/v1/request")]
@@ -32,15 +52,27 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
public async Task<IActionResult> RequestReward()
{
var req = await ReadRewardRequestAsync();
var pid = Me;
var row = new GameRewardSelectionEntity
{
PlayerId = Me,
PlayerId = pid,
RewardType = req.RewardType,
GiftContext = req.GiftContext,
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
Message = string.IsNullOrWhiteSpace(req.Message)
? "Choose a reward"
: req.Message[..Math.Min(req.Message.Length, 256)],
};
db.GameRewardSelections.Add(row);
await db.SaveChangesAsync();
// Pick the three offered items now that the row has an id (used as
// the deterministic seed) and persist them so /pending is stable
// and /select grants exactly what was shown.
var offered = await store.PickRewardItemsAsync(OfferCount, unchecked((int)row.Id));
if (offered.Count > 0) row.Offer1ItemId = offered[0].Id;
if (offered.Count > 1) row.Offer2ItemId = offered[1].Id;
if (offered.Count > 2) row.Offer3ItemId = offered[2].Id;
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
@@ -54,46 +86,61 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
// Map the client's chosen GiftDropId back to one of the offered
// store items. The GiftDropId is the item's PurchasableItemId.
var offeredIds = new[] { row.Offer1ItemId, row.Offer2ItemId, row.Offer3ItemId }
.Where(id => id > 0)
.ToList();
var items = await db.StoreItems.Where(i => offeredIds.Contains(i.Id)).ToListAsync();
var chosen = items.FirstOrDefault(i => StoreService.PurchasableItemIdFor(i) == req.GiftDropId)
?? items.FirstOrDefault();
row.SelectedAt = DateTime.UtcNow;
row.SelectedGiftDropId = req.GiftDropId;
if (chosen is not null)
{
await store.GrantItemAsync(pid, chosen.Id);
row.GrantedItemId = chosen.Id;
}
// Small token + XP kicker on top of the item, matching the
// post-activity "you also banked a little" feel.
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
private static object ToWire(GameRewardSelectionEntity row) => new
private async Task<Dictionary<long, StoreItemEntity>> LoadOfferedItemsAsync(
IEnumerable<GameRewardSelectionEntity> rows)
{
var ids = rows
.SelectMany(r => new[] { r.Offer1ItemId, r.Offer2ItemId, r.Offer3ItemId })
.Where(id => id > 0)
.Distinct()
.ToList();
if (ids.Count == 0) return new();
return await db.StoreItems
.Where(i => ids.Contains(i.Id))
.ToDictionaryAsync(i => i.Id);
}
private static object ToWire(GameRewardSelectionEntity row, IReadOnlyDictionary<long, StoreItemEntity> items) => new
{
RewardSelectionId = row.Id,
row.Message,
row.GiftContext,
row.RewardType,
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
GiftDrop1 = OfferDrop(row.Offer1ItemId, items),
GiftDrop2 = OfferDrop(row.Offer2ItemId, items),
GiftDrop3 = OfferDrop(row.Offer3ItemId, items),
row.CreatedAt,
};
private static object GiftDrop(long id, string name, int currency) => new
private static object? OfferDrop(long itemId, IReadOnlyDictionary<long, StoreItemEntity> items)
{
GiftDropId = id,
FriendlyName = name,
Tooltip = string.Empty,
ConsumableItemDesc = string.Empty,
AvatarItemDesc = string.Empty,
AvatarItemType = 0,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = 0,
CurrencyType = 2,
Currency = currency,
Context = 0,
ItemSetId = 0,
ItemSetFriendlyName = string.Empty,
};
if (itemId <= 0 || !items.TryGetValue(itemId, out var item)) return null;
return StoreService.BuildGiftDrop(item, StoreService.PurchasableItemIdFor(item));
}
private async Task<RewardRequest> ReadRewardRequestAsync()
{
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameSight;
[ApiController]
[AllowAnonymous]
public class GameSightController : ControllerBase
{
[HttpPost("/api/gamesight/event")]
[HttpPost("/data/event")]
[HttpPost("/data/heartbeat")]
public IActionResult Event() => Ok(new { Success = true });
}
@@ -274,6 +274,89 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
public sealed class ImageBulkRequest
{
public List<long>? ImageIds { get; set; }
public List<long>? SavedImageIds { get; set; }
}
[HttpGet("api/images/v5/bulk")]
[HttpPost("api/images/v5/bulk")]
[Authorize]
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
.Concat(Request.Query.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0))
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Photos
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
&& (p.IsPublic || p.UploaderPlayerId == pid))
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>GET <c>api/images/v6?name={blobName}</c> — single-image
/// lookup by name. Client contract (RecNet.Runtime
/// <c>KLJOGJHBONK.INHMKKAJJKO(string)</c>) returns ONE
/// <c>ICOFKEGOGOD</c> ImageDTO, not a list; a bare array crashes its
/// reader. Not found (or private + not the owner) → 404 so the promise
/// rejects cleanly rather than delivering a wrong-shaped body.</summary>
[HttpGet("api/images/v6")]
[AllowAnonymous]
public async Task<IActionResult> ImagesV6([FromQuery] string? name)
{
if (string.IsNullOrWhiteSpace(name)) return NotFound();
var pid = this.CurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.BlobName == name && p.DeletedAt == null &&
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
if (photo is null) return NotFound();
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// <summary>GET <c>api/images/v6/{id}</c> — single photo by numeric id.
/// The 2023-03-21 client builds the path with
/// <c>String.Format("{0}v6/{1}", "api/images/", id)</c>
/// (<c>KLJOGJHBONK.txt:2694-2698</c>) and passes verb 0 = GET into the
/// request-builder ctor (<c>:2702 Move rdx, 0</c> → <c>:2708 Call
/// 0x1830036A0</c>); no query fields are attached.
///
/// The issuing method <c>KLJOGJHBONK.HLANOFILAEO(System.Int64)</c> returns
/// <c>FGLDKEJLAKB&lt;IReadOnlyList&lt;Int32&gt;&gt;</c>, but that is a
/// CLIENT-SIDE projection: a
/// <c>Func&lt;LGLCPNPJCEC, IReadOnlyList&lt;Int32&gt;&gt;</c>
/// (<c>:2756</c>) reduces the wire object to its tagged-player ids. So the
/// body must be ONE <c>LGLCPNPJCEC</c> object — an array breaks the reader —
/// with the 12 keys its generated serializer registers
/// (<c>IBILPLGNAJE.txt:819-1078</c>: Id, ImageName, PlayerId, RoomId,
/// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt,
/// TaggedPlayerIds, CheerCount, CommentCount), all of which
/// <see cref="BuildImageInfo"/> already emits.
///
/// Without this route the request fell through to
/// <c>api/images/{*path}</c>, which rejects "123" as a non-image filename →
/// 404 on every photo-detail open. Visibility matches the by-name lookup:
/// public photos to anyone, private ones only to their uploader.</summary>
[HttpGet("api/images/v6/{id:long}")]
[AllowAnonymous]
public async Task<IActionResult> ImageByIdV6(long id)
{
var pid = this.CurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.Id == id && p.DeletedAt == null &&
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
if (photo is null) return NotFound();
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
@@ -354,19 +437,84 @@ public class ImagesController(
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0)
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] string? sort = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var rows = await db.Photos
var query = db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.AsQueryable();
query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
/// <summary>GET api/images/v{2-5}/room/{roomId} — a room's public
/// photo gallery. The 2023 client sends <c>sort</c>/<c>filter</c> as
/// ENUM NAMES (<c>?sort=CheerCount_Desc&amp;filter=PublicOnly</c>),
/// not ints — binding them as <c>int</c> makes [ApiController]
/// auto-400 and the watch toasts "Could not show images for room".
/// <c>filter</c> is accepted but unused: we only store public,
/// non-deleted photos for this view anyway (PublicOnly semantics).</summary>
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task<IActionResult> GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] string? sort = null,
[FromQuery] string? filter = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
_ = filter;
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>Sort selector shared by the album views. Accepts the
/// 2020 client's ints (0/1/2) and the 2023 client's enum names —
/// the names contain the metric ("CheerCount_Desc", "ViewCount_…");
/// anything unrecognised falls back to newest-first.</summary>
private static int ParseSort(string? sort)
{
if (string.IsNullOrWhiteSpace(sort)) return 0;
if (int.TryParse(sort, out var n)) return n;
if (sort.Contains("Cheer", StringComparison.OrdinalIgnoreCase)) return 1;
if (sort.Contains("View", StringComparison.OrdinalIgnoreCase)) return 2;
return 0;
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
@@ -409,6 +557,65 @@ public class ImagesController(
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/images/v1/{id}/report</c> — file a moderation
/// report against a photo from the in-game photo viewer.
/// <c>KLJOGJHBONK.IDAJEMBKAGF(System.Int64)</c> formats the path as
/// <c>String.Format("{0}v1/{1}/report", "api/images/", id)</c>
/// (<c>KLJOGJHBONK.txt:4010-4014</c>) and moves verb 2 = POST into the
/// builder ctor (<c>:4021 Move rdx, 2</c>, host 1 at <c>:4020</c>).
///
/// The request carries NO fields and NO body — no category, no free text —
/// and the reply is dispatched through <c>BNDIAONDFFF.KDOPJCNKOOK</c>
/// (<c>:4031</c>), which never deserialises it: the call is
/// fire-and-forget, so the client cannot tell a 404 from a 200 and every
/// report was being dropped silently. Everything actionable therefore has
/// to be derived server-side from the photo id, which is why the reported
/// photo's uploader becomes the report target and the photo's room the
/// context. Category 5 = "Other", matching the screenshare reporter's
/// default for reports that arrive without one.</summary>
[HttpPost("api/images/v1/{id:long}/report")]
[Authorize]
public async Task<IActionResult> ReportPhoto(long id)
{
var reporter = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null);
if (photo is null) return NotFound();
// ReportEntity has no TargetPhotoId column, so the photo id + blob name
// ride in the message text the same way club reports carry "[club {id}]"
// — a moderator needs the actual image to act on the report.
//
// One open report per (reporter, photo): the call is fire-and-forget
// and the client can't tell 200 from 404, so retries and re-opens are
// likely — same guard as the custom-avatar-item report path.
var marker = $"[photo {photo.Id} ";
var duplicate = await db.Reports.AnyAsync(r =>
r.ReporterPlayerId == reporter && r.ResolvedAt == null && r.Message.StartsWith(marker));
if (duplicate)
{
logger.LogInformation(
"[images] duplicate report of photo {PhotoId} by {Reporter} ignored", photo.Id, reporter);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer";
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = photo.UploaderPlayerId,
RoomId = photo.RoomId,
Category = 5,
Message = message[..Math.Min(1000, message.Length)],
});
await db.SaveChangesAsync();
logger.LogInformation(
"[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})",
photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest
@@ -417,22 +624,48 @@ public class ImagesController(
public long PhotoId { get; set; }
}
public sealed class BulkCheeredRequest
{
public List<long>? SavedImageIds { get; set; }
public List<long>? ImageIds { get; set; }
}
/// <summary>Field names the cheer-state batch accepts. The 2023-03-21
/// client sends the singular <c>"id"</c>
/// (<c>KLJOGJHBONK.txt:3763 Move rdx, "id"</c>); the older spellings are
/// kept so the 2020.12 watch and our own tooling keep working.</summary>
private static readonly string[] CheerBulkIdKeys =
{ "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" };
/// <summary>GET — and POST once the id list reaches 100 — for
/// <c>api/images/v{4,5}/cheered/bulk</c>: the caller's cheer state for a
/// batch of photos (the hearts on the photo feed).
///
/// VERB: the client picks it at runtime.
/// <c>KLJOGJHBONK.JCLGICHPPGB(IEnumerable&lt;Int64&gt;)</c> calls
/// <c>ALHIJCJOLCB.JIECAFGCODK(count, 100)</c> and moves the result straight
/// into the request-builder's verb register
/// (<c>KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK</c> →
/// <c>:3755 Move rdx, rdi</c> → <c>:3753 Move r9,
/// "api/images/v5/cheered/bulk"</c>) — GET (0) for short lists, POST (2) for
/// long ones. Registering POST only meant the common case fell into the
/// <c>api/images/{*path}</c> byte catch-all and 404'd, so cheer state never
/// loaded.
///
/// REQUEST: one field, <c>"id"</c>, added via
/// <c>BNDIAONDFFF.AFGEDDANEKP("id", ids)</c> (<c>KLJOGJHBONK.txt:3763</c>).
/// The builder emits fields as query values on GET and form values on POST —
/// never as a JSON body — so the ids are read straight off the request
/// instead of through <c>[FromBody]</c>, which would 415 the form POST
/// before the action ran.
///
/// RESPONSE: <c>List&lt;LAKGLIDCEDE&gt;</c>, i.e.
/// <c>[{"SavedImageId":Int64,"IsCheered":Boolean}]</c> — key literals in the
/// generated serializer at <c>KJEIGGBIIHP.txt:215,242</c>. One entry per
/// requested id so the client's cache has no holes.</summary>
[HttpGet("api/images/v4/cheered/bulk")]
[HttpGet("api/images/v5/cheered/bulk")]
[HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
public async Task<IActionResult> CheeredBulk()
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
var ids = await ReadCheerBulkIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers
@@ -447,6 +680,82 @@ public class ImagesController(
}));
}
/// <summary>Collect the cheer-state batch ids from wherever this client
/// put them: query values on the GET path, form values on the POST path
/// (both produced by <c>BNDIAONDFFF</c>'s field list), or a JSON body for
/// non-game callers. Repeated fields and comma-joined values are both
/// accepted because the builder's query assembler emits one
/// <c>&amp;id=</c> per element while our own admin tooling sends a single
/// comma-separated value.</summary>
private async Task<List<long>> ReadCheerBulkIdsAsync()
{
var ids = new List<long>();
void Collect(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return;
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
foreach (var key in CheerBulkIdKeys)
foreach (var value in Request.Query[key])
Collect(value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in CheerBulkIdKeys)
foreach (var value in form[key])
Collect(value);
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted);
Request.Body.Position = 0;
CollectJsonIds(doc.RootElement, Collect);
}
catch (JsonException)
{
// Not JSON — the query/form pass above is authoritative.
}
}
return ids.Distinct().Take(500).ToList();
}
/// <summary>Pull ids out of a JSON body that is either a bare array of
/// numbers or an object keyed by one of <see cref="CheerBulkIdKeys"/>.</summary>
private static void CollectJsonIds(JsonElement root, Action<string?> collect)
{
switch (root.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect);
break;
case JsonValueKind.Object:
foreach (var key in CheerBulkIdKeys)
{
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue;
CollectJsonIds(prop.Value, collect);
}
}
break;
case JsonValueKind.Number:
collect(root.GetRawText());
break;
case JsonValueKind.String:
collect(root.GetString());
break;
}
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
@@ -504,6 +813,16 @@ public class ImagesController(
public long ImageId { get; set; }
public string? ImageName { get; set; }
public bool IsPublic { get; set; }
/// <summary>What the 2023-03-21 client actually sends:
/// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends
/// <see cref="IsPublic"/>, so reading only that flag left every photo
/// private no matter what the player picked.</summary>
public int? Accessibility { get; set; }
/// <summary>Resolved visibility: the enum when present, else the
/// legacy boolean.</summary>
public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic;
}
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
@@ -516,7 +835,8 @@ public class ImagesController(
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm,
[FromForm(Name = "IsPublic")] bool? isPublicForm)
[FromForm(Name = "IsPublic")] bool? isPublicForm,
[FromForm(Name = "Accessibility")] int? accessibilityForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
@@ -524,7 +844,12 @@ public class ImagesController(
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
// Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023
// client sends; IsPublic is the older boolean.
var accessibility = body?.Accessibility ?? accessibilityForm;
var isPublic = accessibility is int a
? a == 1
: body?.IsPublic ?? isPublicForm ?? true;
var photo = photoId > 0
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
[ApiController]
[Authorize]
[Route("api/incentivizedreferrals")]
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("")]
[HttpGet("progress")]
public async Task<IActionResult> Progress()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
var claimed = await db.PlayerSettings
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
return Ok(new
{
ReferralCount = referralCount,
RequiredReferralCount = 1,
CanClaim = referralCount >= 1 && !claimed,
Claimed = claimed,
RewardCurrencyType = 2,
RewardCurrency = 250,
});
}
// Client contract: the claim response deserializes as a top-level
// List<RewardSelection> where each entry is
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
// AFEFBIKADAP/GLOFCEJBIGB). Returning a {Success,...} object crashes the
// strict array reader. When there's nothing to grant, return an empty
// array — a valid empty list, not an error object.
[HttpPost("claim")]
public async Task<IActionResult> Claim()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
var alreadyClaimed = await db.PlayerSettings
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
if (referralCount < 1 || alreadyClaimed)
return Ok(Array.Empty<object>());
await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = "referral:reward_claimed",
Value = DateTime.UtcNow.ToString("O"),
});
await db.SaveChangesAsync();
// One granted reward selection: 250 of currency type 2.
return Ok(new[]
{
new { rewardSelectionId = unchecked(Me * 397L + 2), rewardType = "Currency" },
});
}
}
@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
[ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{
// 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
// shapes verified against the 2023.03.21 ISIL decompile:
//
// influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
// - IBEEFFGHGND.txt:1612 — response callback is
// Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
// deserialized as DTO object FEAJJMACBMF, NOT a bare array
// (a bare array crashes the strict reader:
// "FGNDDNDAFCI: expected:'{', actual:'['").
// - FLEDNKHKIND.txt:215-265 — FEAJJMACBMF's generated reader
// registers keys "InfluencerIds" and "ContinuationToken"
// (Pascal/camel/lower probes, so camelCase is accepted).
// - IBEEFFGHGND_NestedType___c.txt:318-358
// (<GetAllInfluencersInternal>b__12_0) — a non-null
// ContinuationToken triggers a recursive next-page fetch, so it
// must be null once the single page is exhausted.
// - IBEEFFGHGND.txt:1547-1575 (OELDHDEIFGP) — client sends
// take=1000 + continuationToken query params.
[HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 1000);
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct()
.Take(take)
.ToListAsync();
return Ok(new
{
InfluencerIds = creatorIds.Select(id => (int)id).ToArray(),
ContinuationToken = (string?)null,
});
}
// influencer / myinfluencer → bare Int32 scalar, "no influencer" = 0:
// - IBEEFFGHGND.txt:598/951 — both pipelines parse the body as Int32
// and convert via Func<Int32, Nullable<Int32>>.
// - IBEEFFGHGND_NestedType___c.txt:61-111
// (<GetMySupportedInfluencer>b__8_0) — the converter maps
// id == 0 → null, else (int?)id. So the correct "none" response is
// the literal number 0. A "null"/empty body throws
// CHGIJBGGJAG "Response was empty" (Player.log:1947-2031).
[HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
{
var playerId = accountId is long id
? await db.Players.Where(p => p.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
: await db.Players.Where(p => p.Username == username).Select(p => (long?)p.Id).FirstOrDefaultAsync();
return Content(playerId is long pid ? ((int)pid).ToString() : "0", "application/json");
}
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
[Authorize]
public async Task<IActionResult> MyInfluencer()
{
var me = this.RequireCurrentPlayerId();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
return Content(
setting is not null && long.TryParse(setting.Value, out var influencerId)
? ((int)influencerId).ToString()
: "0",
"application/json");
}
[HttpPost("api/influencerpartnerprogram/support")]
[Authorize]
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
{
var me = this.RequireCurrentPlayerId();
var influencer = influencerAccountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (influencer is null) return NotFound();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "influencer:supported",
Value = influencer.Id.ToString(),
});
}
else
{
setting.Value = influencer.Id.ToString();
}
await db.SaveChangesAsync();
// Client return type is LDGADANDBIO (the fire-and-forget POST response
// handle, same as PlayerCheer create) — it only checks success and
// ignores the body, so no typed shape is required here.
return Ok(new { Success = true, Influencer = (int)influencer.Id });
}
[HttpPost("api/influencerpartnerprogram/remove")]
[Authorize]
public async Task<IActionResult> Remove()
{
var me = this.RequireCurrentPlayerId();
await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
.ExecuteDeleteAsync();
return Ok(new { Success = true });
}
}
@@ -40,6 +40,7 @@ public class InventionsController(
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v1/featured")]
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
@@ -52,6 +53,82 @@ public class InventionsController(
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/toptoday</c> — the client fires
/// this as its own no-arg shelf query
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147,
/// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto
/// the all-time popular query rendered the same twelve tiles twice.
/// Ranked by cheers received in the last 24h, then backfilled with
/// all-time popular so a quiet day still fills the shelf.</summary>
[HttpGet("api/inventions/v1/toptoday")]
public async Task<ActionResult> TopToday([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var since = DateTime.UtcNow.AddDays(-1);
var hot = await db.Cheers
.Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since)
.GroupBy(c => c.TargetInventionId)
.Select(g => new { InventionId = g.Key, Score = g.Count() })
.OrderByDescending(x => x.Score)
.Take(take)
.ToListAsync();
var order = hot.Select(h => h.InventionId).ToList();
var hits = order.Count == 0
? new List<InventionEntity>()
: await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id))
.ToListAsync();
var ranked = order
.Select(id => hits.FirstOrDefault(r => r.Id == id))
.Where(r => r is not null)
.Select(r => r!)
.ToList();
if (ranked.Count < take)
{
var have = ranked.Select(r => r.Id).ToList();
var filler = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id))
.OrderByDescending(i => i.CheerCount)
.Take(take - ranked.Count)
.ToListAsync();
ranked.AddRange(filler);
}
return Ok(ranked.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/featureddormskins</c> — separate
/// shelf in the client
/// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147,
/// verb 0, no params) which previously returned generic popular
/// inventions. <see cref="InventionEntity"/> has no dorm-skin column, so
/// the marker lives in <see cref="InventionEntity.TagsCsv"/> — see
/// <see cref="IsDormSkinTags"/>. The filter runs in memory because
/// TagsCsv is a packed list no provider can split.</summary>
[HttpGet("api/inventions/v1/featureddormskins")]
public async Task<ActionResult> FeaturedDormSkins([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
.OrderByDescending(i => i.CheerCount)
.Take(500)
.ToListAsync();
return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire));
}
/// <summary>Dorm-skin marker. There is no IsDormSkin column on
/// <see cref="InventionEntity"/>, so a tag decides: any tag that reads
/// "dormskin" / "dorm skin" (case- and space-insensitive).</summary>
private static bool IsDormSkinTags(string? tagsCsv) =>
!string.IsNullOrEmpty(tagsCsv)
&& tagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(t => t.Replace(" ", string.Empty)
.Equals("dormskin", StringComparison.OrdinalIgnoreCase));
[HttpGet("api/inventions/v3/saved")]
[Authorize]
public async Task<ActionResult> Saved([FromQuery] int take = 50)
@@ -77,9 +154,20 @@ public class InventionsController(
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
/// <summary>The 2023 client sends value + skip + take on every store
/// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs
/// on a verb-0 request). Ignoring skip/take pinned every page to the
/// first 50 rows, so scrolling the store re-showed the same tiles.</summary>
[HttpGet("api/inventions/v1/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
[HttpGet("api/inventions/v2/search")]
public async Task<ActionResult> Search(
[FromQuery] string value = "",
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 200);
if (string.IsNullOrWhiteSpace(value))
return Ok(Array.Empty<object>());
@@ -108,7 +196,8 @@ public class InventionsController(
.Where(i => !i.IsDeleted && i.IsPublished
&& creators.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(byCreator.Select(ToWire));
}
@@ -119,21 +208,167 @@ public class InventionsController(
&& (i.Name.ToLower().Contains(v)
|| i.TagsCsv.ToLower().Contains(v)))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record InventionBatchRequest(List<long> InventionIds);
// ── Id-list endpoints (runtime GET/POST) ─────────────────────────────
//
// The 2023 client picks the verb at RUNTIME for every id-list route:
// ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198)
// is `count < limit ? GET : POST` with limit=100. On a non-GET the
// key/value pairs move out of the query string and into an
// HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the
// verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST
// actions must NOT declare a complex parameter, or [ApiController]
// infers [FromBody] and answers 415 before the handler runs; we read
// query + form by hand instead.
//
// Field names are literal:
// "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228)
// "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330)
// "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913)
[HttpPost("api/inventions/v1/batch")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
/// <summary>Body shape for non-2023 callers, which still send a JSON
/// <c>{"InventionIds":[…]}</c> envelope rather than repeated fields.</summary>
private sealed class InventionIdsBody
{
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = req.InventionIds.Take(200).ToList();
public List<long>? InventionIds { get; set; }
public List<long>? Ids { get; set; }
public List<long>? Id { get; set; }
}
private static readonly System.Text.Json.JsonSerializerOptions IdsJson =
new() { PropertyNameCaseInsensitive = true };
/// <summary>Gather ids from the query string, the url-encoded form and
/// (as a fallback) a JSON id envelope. See the block comment above for
/// why all three have to be accepted on one route.</summary>
private async Task<List<long>> CollectIdsAsync(params string[] keys)
{
var ids = new List<long>();
void Take(string? raw)
{
if (string.IsNullOrEmpty(raw)) return;
foreach (var part in raw.Split(
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
foreach (var key in keys)
{
foreach (var v in Request.Query[key]) Take(v);
if (form is not null)
foreach (var v in form[key]) Take(v);
}
if (ids.Count == 0 && form is null
&& (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false))
{
try
{
var body = await System.Text.Json.JsonSerializer
.DeserializeAsync<InventionIdsBody>(Request.Body, IdsJson);
foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id })
{
if (list is not null) ids.AddRange(list.Where(id => id > 0));
}
}
catch (System.Text.Json.JsonException)
{
// Not an id envelope — treat as "no ids" rather than 400.
}
}
return ids.Distinct().Take(200).ToList();
}
/// <summary>Read one scalar field from the query string, falling back to
/// the url-encoded form body (POST variants — see the block comment
/// above).</summary>
private async Task<string?> ReadFieldAsync(string key)
{
var fromQuery = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrEmpty(fromQuery)) return fromQuery;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var fromForm = form[key].FirstOrDefault();
if (!string.IsNullOrEmpty(fromForm)) return fromForm;
}
return null;
}
private async Task<int?> ReadIntFieldAsync(string key)
=> int.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
private async Task<long?> ReadLongFieldAsync(string key)
=> long.TryParse(await ReadFieldAsync(key), out var v) ? v : null;
[HttpGet("api/inventions/v1/batch")]
[HttpGet("api/inventions/v2/batch")]
[HttpPost("api/inventions/v1/batch")]
[HttpPost("api/inventions/v2/batch")]
public async Task<ActionResult> Batch()
=> await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds"));
/// <summary><c>api/inventions/v1/dormskinsfromids</c> — the client
/// deserialises a BARE <c>List&lt;Int64&gt;</c> here, not a list of
/// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed
/// <c>FGLDKEJLAKB&lt;List`1&lt;Int64&gt;&gt;</c> and is then projected by a
/// <c>Func&lt;List&lt;Int64&gt;, List&lt;Int64&gt;&gt;</c> (:5966). The reply is the
/// SUBSET of the posted ids that are dorm skins; aliasing this onto the
/// generic batch handler fed objects to a number reader and killed
/// dorm-skin filtering outright.</summary>
[HttpGet("api/inventions/v1/dormskinsfromids")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> DormSkinsFromIds()
{
var ids = await CollectIdsAsync("ids", "id", "InventionIds");
if (ids.Count == 0) return Ok(Array.Empty<long>());
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.TagsCsv })
.ToListAsync();
return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList());
}
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
{
if (requestedIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = requestedIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v1/fromcreators")]
[HttpPost("api/inventions/v1/fromcreators")]
public async Task<ActionResult> FromCreators()
{
// Only the literal "id" key carries creator ids. The previous
// SelectMany-over-every-query-field also swallowed the client's
// "skip"/"take" pairs, so take=100 quietly added player 100's
// inventions to the shelf.
var creatorIds = await CollectIdsAsync("id");
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0);
var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
@@ -152,19 +387,59 @@ public class InventionsController(
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.</summary>
/// dorm-load destabilisation we've been hunting.
///
/// The 2023 DTO reads a THIRD list, <c>TrendingFilters</c>
/// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that
/// list got an empty filter row. Popular/Trending are now derived from the
/// tags actually in use (<see cref="InventionEntity.TagsCsv"/>) rather than
/// echoing the pinned array three times; the curated list is the fallback
/// so a fresh server still shows filters. The 2020.12 watch's
/// <c>AEBEPCMAABC</c> reads only Pinned/Popular, so the extra key is
/// inert there.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
public async Task<IActionResult> TagFilters()
{
var tags = new[]
{
string[] pinned =
[
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
};
];
// TagsCsv is a packed list no provider can split, so the frequency count
// runs in memory over a bounded window of the most recently touched
// tagged inventions.
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
.OrderByDescending(i => i.UpdatedAt)
.Select(i => new { i.TagsCsv, i.UpdatedAt })
.Take(2000)
.ToListAsync();
static IEnumerable<string> SplitTags(string csv) => csv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
static List<string> Rank(IEnumerable<string> tags) => tags
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => g.Key)
.Take(12)
.ToList();
var since = DateTime.UtcNow.AddDays(-7);
var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv)));
var trending = Rank(rows.Where(r => r.UpdatedAt >= since)
.SelectMany(r => SplitTags(r.TagsCsv)));
if (popular.Count == 0) popular = [.. pinned];
if (trending.Count == 0) trending = popular;
return Ok(new
{
PinnedFilters = tags,
PopularFilters = tags,
PinnedFilters = pinned,
PopularFilters = popular,
TrendingFilters = trending,
});
}
@@ -285,6 +560,59 @@ public class InventionsController(
return Ok(ToWire(i));
}
/// <summary>POST <c>api/storefronts/v1/trialInvention</c> — start a free
/// trial of a store invention, spawned for the window returned by
/// <c>trialInvention/duration</c>. Hosted here — not in
/// StorefrontsController — so it can reuse the invention wire builder.
/// We record the trial start so it's an auditable action.
///
/// TWO wire corrections. (1) The id is a FORM field, not a query param:
/// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via
/// <c>AFGEDDANEKP("inventionId", …)</c>, and on a non-GET those pairs go
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
/// <c>[FromQuery]</c> bound 0 and every trial 404'd. (2) The reply is the
/// Status/Invention/InventionVersion envelope: the continuation at
/// DCFKEFHJAGC.txt:9086 is a
/// <c>Func&lt;BDNCJIPHHOK, FGLDKEJLAKB&lt;IFJONDCAKKM&gt;&gt;</c> which
/// projects <c>.Invention</c> — a bare invention left that null.</summary>
[HttpPost("api/storefronts/v1/trialInvention")]
[Authorize]
public async Task<ActionResult> TrialInvention()
{
var inventionId = await ReadLongFieldAsync("inventionId") ?? 0;
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
if (CurrentPlayerIdOrNull is long me)
{
var key = $"invention:trial:{inventionId}";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key);
if (row is null)
db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity
{
PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"),
});
else
row.Value = DateTime.UtcNow.ToString("O");
await db.SaveChangesAsync();
}
return Ok(await EnvelopeAsync(i));
}
/// <summary>GET <c>api/inventions/v1/details</c> — the ONLY key either
/// client reads off this response is <c>Tags</c>: the 2023 continuation is
/// <c>Action&lt;OIABGAKJABE&gt;</c> (OEGFNFEAAGO.txt:6312) whose reader takes
/// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's
/// HJPDBNLCGIB does the same. Each element is
/// <c>{Tag:String, Type:Int32}</c> (PCMLHLIBLNJ.txt:038/057). Without the key
/// the invention detail page's tag row was permanently empty. Invention +
/// Versions stay on the wire — unknown members are ignored by both readers.
/// <c>Type</c> is always 0: <see cref="SetTags"/> merges the client's
/// AutoTags and CustomTags into one <see cref="InventionEntity.TagsCsv"/>
/// column, so the auto/custom distinction is not recoverable without a
/// second column.</summary>
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
@@ -298,21 +626,33 @@ public class InventionsController(
.ToListAsync();
return Ok(new
{
Tags = i.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => new { Tag = t, Type = 0 }),
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
/// <summary>GET <c>api/inventions/v1/personaldetails/{id}</c> — the caller's
/// own relationship to the invention. Both clients read a single key here:
/// the 2023 continuation is <c>FGLDKEJLAKB&lt;CEAFHBOOBKL&gt;</c>
/// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering"
/// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical.
/// Without it the detail page never lit the player's own cheer.</summary>
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
var pid = CurrentPlayerId;
return Ok(new
{
IsCheering = await db.Cheers.AnyAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == id),
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
CanEdit = i.CreatorPlayerId == pid,
});
}
@@ -345,6 +685,14 @@ public class InventionsController(
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
/// <summary>RecNet.NewInventionRequestDTO carries <c>chipsCost</c> and
/// <c>cloudVariablesCost</c> too (2023.06 dump.cs:1283258-1283259).
/// <see cref="InventionVersionEntity"/> has no column for either, so
/// they cannot be persisted — but binding them lets the save response
/// echo back the budget the client just reported instead of two
/// zeroes.</summary>
public int ChipsCost { get; set; }
public int CloudVariablesCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
@@ -356,6 +704,7 @@ public class InventionsController(
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[HttpPost("api/inventions/v6/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
{
@@ -417,15 +766,33 @@ public class InventionsController(
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
InventionVersion = ToVersionWire(
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
});
}
/// <summary>The 2023-03-21 client names the blob field
/// <c>inventionDataFilename</c> and sends four extra cost fields plus the
/// creation room and referenced inventions. The record only had
/// <c>BlobName</c>, which the client never sends, so the required-parameter
/// check rejected every publish with 400 and no new invention version could
/// ever be saved.</summary>
public sealed record AddVersionRequest(
long InventionId, string BlobName,
int? InstantiationCost, int? LightsCost);
long InventionId,
string? BlobName,
string? InventionDataFilename,
int? InstantiationCost, int? LightsCost,
int? ChipsCost, int? CloudVariablesCost, int? AiCost,
long? CreationRoomId,
List<long>? ReferencedInventions)
{
public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename)
? InventionDataFilename
: BlobName;
}
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
@@ -433,24 +800,36 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var blobName = req.Blob;
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = req.BlobName;
inv.CurrentBlobName = blobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
var version = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = req.BlobName,
BlobName = blobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
};
db.InventionVersions.Add(version);
await db.SaveChangesAsync();
return Ok(ToWire(inv));
// Envelope, not a bare invention — see EnvelopeAsync. The version we
// just wrote is the one the client wants back, and the two cost fields
// it sent are echoed because no column stores them.
return Ok(new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = ToVersionWire(
version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)),
});
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
@@ -488,7 +867,51 @@ public class InventionsController(
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>Body shape for the POST form of <c>updateprice</c> —
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
/// ctor because <see cref="Binding.FormOrJsonModelBinder"/> constructs the
/// instance itself.</summary>
public sealed class UpdatePriceRequest
{
public long InventionId { get; set; }
public int Price { get; set; }
}
/// <summary>Both clients POST <c>updateprice</c> with a body; only the GET
/// query form was registered, so ASP.NET answered 405 and re-pricing a paid
/// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and
/// :9870-9876 hands <c>JsonUtility.ToJson(UpdatePriceRequest)</c> to
/// <c>FJLLPHFOOJJ</c>, which wraps it in a BestHTTP RawJsonForm
/// (<c>application/json</c>, RawJsonForm.txt:40). December POSTs the same
/// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts
/// both; the GET stays for callers that still use the query string.</summary>
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
=> ApplyPriceAsync(inventionId, price);
[HttpPost("api/inventions/v1/updateprice")]
[Authorize]
public Task<ActionResult> UpdatePricePost(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
=> ApplyPriceAsync(req.InventionId, req.Price);
private async Task<ActionResult> ApplyPriceAsync(long inventionId, int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/delete")]
@@ -504,13 +927,29 @@ public class InventionsController(
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
/// <summary>The 2023 publish call carries FOUR query params, not two:
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add <c>inventionId</c>,
/// <c>permissionLevel</c>, <c>accessibility</c> and a
/// <c>Nullable&lt;Int32&gt;</c> <c>price</c> — the wrapper signature is
/// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private /
/// Unlisted" still went fully public, and a paid publish lost its price.
/// Accessibility constants: Private=0, Public=1, Unlisted=2
/// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no
/// Accessibility column on the entity only the Private/not-Private half can
/// be stored, so Unlisted currently behaves as Public. The param is
/// NULLABLE on purpose — the 2020.12 watch sends only inventionId +
/// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must
/// not be read as Private or December could never publish.</summary>
[HttpGet("api/inventions/v3/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
[FromQuery] long inventionId,
[FromQuery] int permissionLevel,
[FromQuery] int? accessibility,
[FromQuery] int? price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
@@ -518,13 +957,15 @@ public class InventionsController(
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
var access = accessibility ?? 1 /* Public */;
inv.GeneralPermission = perm;
inv.IsPublished = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.IsPublished = access != 0;
if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
@@ -540,7 +981,7 @@ public class InventionsController(
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(await EnvelopeAsync(inv));
}
[HttpGet("api/inventions/v1/download")]
@@ -564,8 +1005,33 @@ public class InventionsController(
// ── POST mutations ───────────────────────────────────────────────────
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
/// alias for older callers. Field names are literal in the 2023.06 dump —
/// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) —
/// and the body is a RAW JSON document
/// (<c>JsonUtility.ToJson</c> → <c>BNDIAONDFFF.FJLLPHFOOJJ</c>,
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.</summary>
public sealed record SetTagsRequest(
long InventionId,
List<string>? AutoTags,
List<string>? CustomTags,
List<string>? PlayerAddedTags);
/// <summary>POST <c>api/inventions/v1/settags</c> — the reply is NOT an
/// invention. The 2023 issuing method is typed
/// <c>FGLDKEJLAKB&lt;PNGLFHEAJIH&gt;</c> (OEGFNFEAAGO.txt:2744) and
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
/// <c>List&lt;String&gt;</c> (PNGLFHEAJIH.txt:3/83) — read from the literals
/// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is
/// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags"
/// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the
/// old bare-invention reply killed every tag edit on December too.
/// <c>Result = 0</c> is success: the enum's message formatter jump-tables on
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
/// <c>Tags</c> is a flat string list — NOT the {Tag,Type} pairs
/// <see cref="Details"/> returns.</summary>
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
@@ -574,16 +1040,39 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
inv.TagsCsv = combined;
var tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
.Where(list => list is not null)
.SelectMany(list => list!)
.Select(t => (t ?? string.Empty).Trim())
.Where(t => t.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
inv.TagsCsv = string.Join(',', tags);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
return Ok(new { Result = 0, Tags = tags });
}
public sealed record CheerRequest(long InventionId);
/// <summary>RecNet.CheerRequest carries a SECOND field — <c>Cheer</c>, the
/// desired state — whose real name survives in the 2023.06 dump
/// (dump.cs:1283338-1283342: <c>long InventionId</c> @0x10,
/// <c>bool Cheer</c> @0x18). Both writes are visible in the ISIL: 2023 sets
/// [rdi+16] and [rdi+24] before <c>JsonUtility.ToJson</c>
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
/// <c>ANJHOOPIAKM(Int64, Boolean)</c> (BBHENFCNLAB.txt:7503). Ignoring the
/// flag made un-cheering a no-op: the button toggled off in the UI and the
/// cheer stayed on the invention forever.</summary>
public sealed record CheerRequest(long InventionId, bool Cheer = true);
/// <summary>POST <c>api/inventions/v1/cheer</c> — answers with the
/// Status/Invention/InventionVersion envelope like every other invention
/// mutation: the 2023 method is <c>FGLDKEJLAKB&lt;BDNCJIPHHOK&gt;</c>
/// (OEGFNFEAAGO.txt:11065) and December's is
/// <c>IPromise&lt;AHEPPAEOLOD&gt;</c> (2020.12 BBHENFCNLAB.txt:7503). The old
/// <c>{Id, CheerCount}</c> reply left Invention null on 2023 and threw
/// KeyNotFoundException on December, so the tile never refreshed its
/// count.</summary>
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
@@ -592,10 +1081,11 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent: one cheer per (player, invention).
// Idempotent in both directions: one cheer row per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (existing is null)
if (req.Cheer && existing is null)
{
db.Cheers.Add(new CheerEntity
{
@@ -611,12 +1101,29 @@ public class InventionsController(
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, inv.CheerCount });
else if (!req.Cheer && existing is not null)
{
db.Cheers.Remove(existing);
inv.CheerCount = Math.Max(0, inv.CheerCount - 1);
await db.SaveChangesAsync();
}
return Ok(await EnvelopeAsync(inv));
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
/// <summary>POST <c>api/inventions/v1/report</c> — the reply is a
/// success/message pair, not a status flag of our own invention. The 2023
/// method is <c>FGLDKEJLAKB&lt;PHMHCPEMABG&gt;</c> (OEGFNFEAAGO.txt:10917)
/// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read
/// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the
/// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals
/// (2020.12 KLAMKCBENEA.txt:85/90). Our old <c>{Reported:true}</c> left
/// Success at default false, so the report UI reported failure even though
/// the row persisted. <c>Message</c> must be a string — December's
/// Util.GetKey casts it — so send "" rather than null.</summary>
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
@@ -634,9 +1141,225 @@ public class InventionsController(
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
return Ok(new { Success = true, Message = string.Empty });
}
// ── Rec Room Studio remote run ───────────────────────────────────────
/// <summary>One uploaded blob as the client describes it: three strings.
/// Wire keys are literal in the generated serializer — "Filename", "Hash",
/// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and
/// all-lowercase aliases registered alongside each). The backing type is
/// <c>CFPEPOJAMHH/IFIBBJDIHJH</c>, three <c>System.String</c> accessors
/// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room
/// save commit sends on <c>rooms/{id}/subrooms/{id}/data</c>; declared here
/// so this controller binds its own body.</summary>
public sealed class StudioBlobRef
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
/// <summary>Body of <c>remote-run/push-to-studio</c> — RecNet type
/// <c>FNAGBPCAGJD</c>. Field order/types come from the request builder
/// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64,
/// +40 String, +48/+56 the two blob refs, +64 a <c>Nullable&lt;Int32&gt;</c>)
/// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for
/// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698:
/// <c>SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData,
/// SavedByAccountId</c> (camel/lower aliases registered too).</summary>
public sealed class PushToStudioRequest
{
public string? SessionId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string? UnityAssetId { get; set; }
public StudioBlobRef? RoomData { get; set; }
public StudioBlobRef? SubRoomData { get; set; }
public int? SavedByAccountId { get; set; }
}
/// <summary>POST <c>remote-run/push-to-studio</c> — hands the room the
/// player just uploaded to the paired Rec Room Studio session, WITHOUT
/// committing it as the room's live save (that is the separate
/// <c>rooms/{id}/subrooms/{id}/data</c> path; the client's two flows are
/// sibling methods OGPDOMCNIFM.txt:271 vs :449).
///
/// <para><b>Verb.</b> POST. The request builder at EHIOLHBGODG.txt:411-417
/// moves the route literal <c>"remote-run/push-to-studio"</c> into r9 and
/// <c>2</c> (BestHTTP HTTPMethods.Post) into rdx before
/// <c>BNDIAONDFFF..ctor(verb, host, route)</c>. No cmov — one verb only.
/// The body is RAW JSON, not form fields: :433 hands the serialized DTO to
/// <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (RawJsonForm, <c>application/json</c>),
/// so this binds via <see cref="Binding.FormOrJsonModelBinder"/> — the
/// explicit [ModelBinder] is required or [ApiController] re-infers
/// [FromBody] and 415s a form-shaped caller.</para>
///
/// <para><b>Response.</b> <c>FGLDKEJLAKB&lt;CEELGOLBHIL&gt;</c>
/// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys,
/// all literal in its generated reader PPHLKNGCGOE.txt:599-786:
/// <c>SessionId</c> (String), <c>RoomId</c> and <c>SubRoomId</c>
/// (<c>Nullable&lt;Int64&gt;</c> — CEELGOLBHIL.txt:25/51), then
/// <c>UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename,
/// SubRoomDataHash</c> (String). Note the response FLATTENS the two blob
/// refs the request nests, and drops OwnershipProof. A failed/malformed
/// reply surfaces the client's "Failed to push to Rec Room Studio" toast
/// (EHIOLHBGODG.txt:445).</para>
///
/// <para><b>What it persists.</b> The pushed blobs were already stored by
/// <c>/upload</c> (every FileType there inserts a
/// <see cref="RoomDataBlobEntity"/>), so the push is a registration step:
/// it stamps the sub-room save blob with the room + sub-room it belongs to
/// — that is what makes it appear in the per-sub-room
/// <c>datahistory</c>/"Restore to old version" list, which the FileType=6
/// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its
/// own — writes the session id into
/// <see cref="RoomEntity.StudioSessionId"/> (the column that exists for
/// exactly this and is echoed in room details), records the pushed Unity
/// asset id on the sub-room's <see cref="RoomSceneEntity"/>, and keeps the
/// whole push under <c>remoterun:{SessionId}</c> in
/// <see cref="PlayerSettingEntity"/> so the details survive for the paired
/// Studio session to resolve. <see cref="RoomEntity.IsRoomLinkedToRecRoomStudio"/>
/// is deliberately NOT flipped: it changes in-room MakerPen UI and the
/// client has no unlink call, so a push must not one-way-toggle it.</para>
///
/// <para><b>Not implemented:</b> live relay of the details to a second
/// logged-in session. The client's receive side
/// (<c>EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)</c> → the
/// <c>Action&lt;CEELGOLBHIL&gt;</c> that MBOJJFBIAGE.txt:366/978 subscribes)
/// has no call site anywhere in the IsilDump, so the notification id that
/// carries it cannot be established from the binary — inventing one would
/// be a guess. The HTTP round-trip is complete and the push is durable
/// either way.</para></summary>
[HttpPost("remote-run/push-to-studio")]
[HttpPost("roomserver/remote-run/push-to-studio")]
[Authorize]
public async Task<ActionResult> PushToStudio(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req)
{
var pid = CurrentPlayerId;
var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100);
if (sessionId.Length == 0)
return BadRequest(new { Error = "missing SessionId" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
// Same gate the room-save upload uses (StorageController.UploadRoomSave):
// creator, admin, or an accepted CoOwner (Role 0).
var canPush = room.CreatorPlayerId == pid
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|| await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0);
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
return BadRequest(new { Error = "missing SubRoomData.Filename" });
// The push names blobs the client uploaded moments ago; if the row is
// absent the bytes are not on this server and Studio could never fetch
// them. Fail loudly rather than answering with a filename that 404s.
var subRoomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename);
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
{
// FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push
// is the first point at which the owning room is known.
var roomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == roomDataFilename);
if (roomBlob is null)
return BadRequest(new { Error = "unknown RoomData.Filename" });
if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id;
}
var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64);
if (unityAssetId.Length == 0)
{
unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
}
else if (scene is not null && scene.StudioUnityAssetId != unityAssetId)
{
scene.StudioUnityAssetId = unityAssetId;
scene.DataModifiedAt = DateTime.UtcNow;
}
var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128);
var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128);
room.StudioSessionId = sessionId;
var record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
if (record.Length > 1024)
{
// PlayerSettingEntity.Value is varchar(1024); truncating JSON would
// store an unparseable fragment, so drop the optional hashes first.
record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
SubRoomDataFilename = subRoomDataFilename,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
}
var key = $"remoterun:{sessionId}";
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
if (setting is null)
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid, Key = key, Value = record,
});
else
setting.Value = record;
await db.SaveChangesAsync();
return Ok(new
{
SessionId = sessionId,
RoomId = (long?)room.Id,
SubRoomId = (long?)subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
});
}
/// <summary>Trim a wire string to a column's length. Every string that
/// reaches a MaxLength column here comes straight off the request.</summary>
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
// ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch
@@ -685,13 +1408,40 @@ public class InventionsController(
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
Price = 0,
// The 2023-03-21 DTO dropped IsPublished and reads "Accessibility"
// instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no
// IsPublished arm at all). Constants come from the nested enum
// AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump
// (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the
// key absent the reader left the field at its default, so EVERY
// published store invention presented as Private in the 2023 UI.
// InventionEntity has no Accessibility column, so only the
// Private/Public bit round-trips; Unlisted needs a real column.
// IsPublished stays on the wire because the 2020.12 watch's
// OBBBPCBIMME reader still requires it.
Accessibility = i.IsPublished ? 1 : 0,
// IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme.
IsCertifiedInvention = false,
i.Price,
HideFromPlayer = false,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new
private static object ToVersionWire(InventionVersionEntity v)
=> ToVersionWire(v, 0, 0);
/// <summary>The 2023 InventionVersion DTO reads NINE keys
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
/// <c>ChipsCost</c>, <c>CloudVariablesCost</c> and <c>BlobHash</c>.
/// <see cref="InventionVersionEntity"/> has no column for any of the three,
/// so stored rows report 0/empty and only the save/addversion round-trip can
/// echo back the costs the client just sent. An empty BlobHash is safe:
/// PLIKEBBPJGI's hash getter (<c>EHENFDMIAIM</c>, OLPHKLCPFEF's owner type
/// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the
/// client only ever writes it — so the key's presence is what matters.</summary>
private static object ToVersionWire(
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
@@ -699,6 +1449,34 @@ public class InventionsController(
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
ChipsCost = chipsCost,
CloudVariablesCost = cloudVariablesCost,
v.BlobName,
BlobHash = string.Empty,
};
/// <summary>Every invention MUTATION route answers with the
/// Status/Invention/InventionVersion envelope, never a bare invention: the
/// 2023 client deserialises <c>BDNCJIPHHOK</c> (key literals at
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
/// <c>AHEPPAEOLOD</c>, whose reader pulls the same three literals
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
/// flat left <c>Invention</c> null on 2023 and threw KeyNotFoundException on
/// December. <c>Status = 0</c> is success —
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
/// does <c>test eax,eax / je &lt;success&gt;</c>.</summary>
private async Task<object> EnvelopeAsync(InventionEntity inv)
{
var current = await db.InventionVersions
.Where(x => x.InventionId == inv.Id)
.OrderByDescending(x => x.VersionNumber)
.FirstOrDefaultAsync();
object? versionWire = current is null ? null : ToVersionWire(current);
return new
{
Status = 0,
Invention = ToWire(inv),
InventionVersion = versionWire,
};
}
}
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -66,13 +67,97 @@ public class InventoryController(
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
// The 2023 client sends Equipped/IsEquipped when a weapon skin is
// equipped (distinct from Favorited). We only have one IsActive flag,
// and for skins "active" == "equipped", so honor whichever the client
// sent. Without this the equip was dropped (only Favorited was read)
// and the skin never stuck.
public bool? Equipped { get; set; }
public bool? IsEquipped { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary>
/// <remarks>The 2023-03-21 client sends a JSON <b>array</b> of equipment
/// updates here, not a single object (it serialises a
/// <c>List&lt;BCINJINBBHG&gt;</c>). Binding <c>[FromBody]</c> onto one
/// object failed model binding and returned an automatic 400, so equip and
/// favourite changes never persisted for that build. The body is now read
/// leniently and both shapes are applied through the same per-item path.
/// </remarks>
[HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateEquipment()
{
var requests = await ReadEquipmentUpdatesAsync();
if (requests.Count == 0) return BadRequest("missing prefab/mod identifier");
IActionResult last = Ok();
foreach (var req in requests)
{
last = await ApplyEquipmentUpdateAsync(req);
// Surface the first rejection rather than pretending the whole
// batch succeeded.
if (last is not OkObjectResult and not OkResult) return last;
}
return last;
}
/// <summary>Accepts a single object, an array of objects, or a form post.</summary>
private async Task<List<EquipmentUpdateRequest>> ReadEquipmentUpdatesAsync()
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = new List<EquipmentUpdateRequest>();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? S(params string[] keys)
{
foreach (var k in keys)
if (form.TryGetValue(k, out var v) && !string.IsNullOrEmpty(v)) return v.ToString();
return null;
}
bool? B(params string[] keys)
=> bool.TryParse(S(keys), out var b) ? b : null;
result.Add(new EquipmentUpdateRequest
{
PrefabName = S("PrefabName", "prefabName"),
ModificationGuid = S("ModificationGuid", "modificationGuid"),
Favorited = B("Favorited", "favorited") ?? false,
Equipped = B("Equipped", "equipped"),
IsEquipped = B("IsEquipped", "isEquipped"),
});
return result;
}
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var el in root.EnumerateArray())
{
var one = el.Deserialize<EquipmentUpdateRequest>(opts);
if (one is not null) result.Add(one);
}
}
else if (root.ValueKind == JsonValueKind.Object)
{
var one = root.Deserialize<EquipmentUpdateRequest>(opts);
if (one is not null) result.Add(one);
}
}
catch (JsonException) { /* unparseable body → treated as empty */ }
return result;
}
private async Task<IActionResult> ApplyEquipmentUpdateAsync(EquipmentUpdateRequest req)
{
var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip
@@ -103,7 +188,27 @@ public class InventoryController(
// to themselves).
return Forbid();
}
row.IsActive = req.Favorited;
// "equipped" is the meaningful state for weapon skins; prefer it,
// fall back to Favorited for plain tool-pin updates.
var active = req.Equipped ?? req.IsEquipped ?? req.Favorited;
// One skin equipped per weapon: when equipping a catalog-backed skin,
// clear the equipped flag on the player's OTHER skins for the SAME
// prefab (slugs share the "{prefix}{prefab}:" head) so the weapon
// doesn't end up with two "equipped" skins.
if (active && !string.IsNullOrWhiteSpace(req.ModificationGuid)
&& !string.IsNullOrWhiteSpace(req.PrefabName))
{
var prefabPrefix = StoreService.EquipmentSkinSlug(req.PrefabName, string.Empty);
var siblings = await db.PlayerInventory
.Where(inv => inv.PlayerId == pid && inv.IsActive
&& inv.ItemSlug != slug
&& inv.ItemSlug.StartsWith(prefabPrefix))
.ToListAsync();
foreach (var sib in siblings) sib.IsActive = false;
}
row.IsActive = active;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
@@ -182,6 +287,119 @@ public class InventoryController(
return Ok(new { row.Id, Count = row.Quantity });
}
/// <remarks>The 2023-03-21 client serialises a <c>TransferConsumableRequest</c>
/// with <c>JsonUtility.ToJson</c> and posts it as a raw JSON body. With
/// <c>[ApiController]</c> plus <c>[FromForm]</c> parameters, an
/// <c>application/json</c> request bound nothing and the transfer was
/// rejected as invalid, so consumable gifting never worked on that build.
/// The JSON body is now read alongside the original form/query paths.</remarks>
[HttpPost("api/consumables/v1/transfer")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> TransferConsumable(
[FromForm] long? id,
[FromForm] long? recipientPlayerId,
[FromForm] int quantity = 1)
{
var pid = Me;
var body = await ReadTransferBodyAsync();
var inventoryId = id
?? body.Id
?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId
?? body.RecipientPlayerId
?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, body.Quantity ?? quantity);
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
return BadRequest("invalid_transfer");
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
if (source is null || source.Quantity < amount) return NotFound();
var target = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
if (target is null)
{
target = new PlayerInventoryEntity
{
PlayerId = recipient,
ItemSlug = source.ItemSlug,
Quantity = 0,
};
db.PlayerInventory.Add(target);
}
source.Quantity -= amount;
target.Quantity += amount;
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
private readonly record struct TransferBody(long? Id, long? RecipientPlayerId, int? Quantity);
/// <summary>Reads the 2023 client's raw JSON transfer body. JsonUtility
/// emits field names verbatim, and the exact casing of that DTO is not
/// recoverable from the ISIL, so both conventions are accepted.</summary>
private async Task<TransferBody> ReadTransferBodyAsync()
{
if (Request.HasFormContentType) return default;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != JsonValueKind.Object) return default;
long? L(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var e) &&
e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var v)) return v;
return null;
}
int? I(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var e) &&
e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v)) return v;
return null;
}
return new TransferBody(
L("Id", "id", "InventoryId", "inventoryId"),
L("RecipientPlayerId", "recipientPlayerId"),
I("Quantity", "quantity", "Count", "count"));
}
catch (JsonException) { return default; }
}
/// <summary>GET <c>api/consumables/v1/getTransferable/{playerId}</c> — the
/// consumables the caller may gift to <c>playerId</c>. No handler existed,
/// so the transfer UI 404'd and could never list anything to send.
/// Returns the same grouped shape as <c>v2/getUnlocked</c>.</summary>
[HttpGet("api/consumables/v1/getTransferable/{playerId:long}")]
public async Task<IActionResult> GetTransferableConsumables(long playerId)
{
var pid = Me;
var rows = await db.PlayerInventory
.Where(p => p.PlayerId == pid && p.Quantity > 0)
.ToListAsync();
// Can't gift to yourself; everything else the caller holds is fair game.
var transferable = playerId == pid ? [] : rows;
return Ok(transferable
.GroupBy(r => r.ItemSlug)
.Select(g => new
{
Consumable = g.Key,
Count = g.Sum(r => r.Quantity),
Id = g.Min(r => r.Id),
})
.ToList());
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
@@ -223,7 +441,11 @@ public class InventoryController(
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
// IsActive is the equipped/active flag; surface it under every key
// the 2023 client reads so the equipped skin shows on reload.
Favorited = inv.IsActive,
Equipped = inv.IsActive,
IsEquipped = inv.IsActive,
};
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists;
/// <summary>
/// api.rec.net/api/itemWishlists — consumable/gift-drop wishlist
/// (RecNet.Runtime BPAIBFBKKCN). Wire contract verified against the
/// 2023.03.21 ISIL decompile:
///
/// GET api/itemWishlists/v1/wishlist/{accountId|me}
/// - BPAIBFBKKCN.txt:700-763 (ELPBIBPOBJF) — URL is
/// String.Concat("api/itemWishlists/v1/wishlist/", idOrMe) where
/// idOrMe is "me" for the local account, else the numeric account
/// id as a PATH SEGMENT (not a query param). Missing this route
/// 404s with an empty body → CHGIJBGGJAG "Response was empty" →
/// "Failed to get wishlist for player" (Player.log:2832-2872).
/// - Return type FGLDKEJLAKB&lt;List&lt;BFJNGMGONED&gt;&gt; with no converter
/// Func → the body is a BARE ARRAY of BFJNGMGONED.
/// - BFJNGMGONED = { Guid WishlistItemId, Int32 AccountId,
/// Int32 PurchasableItemId, DateTime CreatedAt }
/// (property types BFJNGMGONED.txt:3-97; JSON keys registered by
/// reader MCDADMNCDEI.txt:343-434, Pascal/camel/lower probes).
///
/// PUT api/itemWishlists/v1/wishlist/me/{purchasableItemId}
/// - BPAIBFBKKCN.txt:161-215 (OOPPFFPOAFH) — format string
/// "{0}/v1/wishlist/me/{1}", verb field = 3 = PUT
/// (verb table HNLCIDLIIBO.txt:878-903). Response is parsed as the
/// AECMPGPHAII&lt;BFJNGMGONED&gt; envelope {"Value","Success","Error"}
/// (envelope reader keys LEEAJGDIOHI.txt:243-286).
///
/// DELETE api/itemWishlists/v1/wishlist/me/{purchasableItemId}
/// - BPAIBFBKKCN.txt:375-419 (IBLKDCAIODF) — same URL, verb 4 =
/// DELETE, fire-and-forget (LDGADANDBIO) — body ignored.
///
/// Storage: rows live in ItemWishlists with the purchasable item id
/// serialized into the existing ItemKey column (no schema change);
/// WishlistItemId is derived deterministically from the row id.
/// </summary>
[ApiController]
[Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/itemWishlists/v1/wishlist/me")]
public async Task<IActionResult> Mine() => await ForPlayer(Me);
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
public async Task<IActionResult> ForPlayer(long accountId)
{
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == accountId)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
}
[HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
[HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> Add(int purchasableItemId)
{
var key = purchasableItemId.ToString();
var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
w.PlayerId == Me && w.ItemKey == key);
if (row is null)
{
row = new ItemWishlistEntity
{
PlayerId = Me,
ItemKey = key,
ItemType = 0,
};
db.ItemWishlists.Add(row);
await db.SaveChangesAsync();
}
// AECMPGPHAII<BFJNGMGONED> envelope: {"Value","Success","Error"}
// (LEEAJGDIOHI.txt:243-286).
return Ok(new
{
Value = ToWire(row),
Success = true,
Error = (string?)null,
});
}
[HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public async Task<IActionResult> Remove(int purchasableItemId)
{
var key = purchasableItemId.ToString();
await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key)
.ExecuteDeleteAsync();
// Fire-and-forget on the client (LDGADANDBIO) — body ignored.
return Ok(new { Success = true });
}
/// <summary>BFJNGMGONED wire item. Rows whose ItemKey isn't a
/// purchasable-item integer (legacy free-form keys) are skipped.</summary>
private static object? ToWire(ItemWishlistEntity row)
{
if (!int.TryParse(row.ItemKey, out var purchasableItemId)) return null;
return new
{
WishlistItemId = StableGuid(row.Id, row.PlayerId),
AccountId = (int)row.PlayerId,
PurchasableItemId = purchasableItemId,
row.CreatedAt,
};
}
/// <summary>Deterministic per-row Guid — the client only uses
/// WishlistItemId as an opaque identity, but it must stay stable
/// across requests.</summary>
private static Guid StableGuid(long rowId, long playerId)
{
Span<byte> input = stackalloc byte[16];
BitConverter.TryWriteBytes(input, rowId);
BitConverter.TryWriteBytes(input[8..], playerId);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(input, hash);
return new Guid(hash[..16]);
}
}
@@ -0,0 +1,660 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Keepsakes;
[ApiController]
[Route("api/keepsakes")]
[Authorize]
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>Marker category for collection rows; never surfaced as a keepsake instance.</summary>
private const string CollectionCategory = "collection";
/// <summary>
/// Mirrors <c>KeepsakeGlobalConfigDTO.SocialXpBoostEnabled</c> below. The collect handler has
/// to agree with what <c>globalconfig</c> advertises, so the flag lives in one place.
/// </summary>
private const bool SocialXpBoostEnabled = false;
private const int KeepsakeRoomLimit = 64;
[HttpGet]
public async Task<IActionResult> Mine()
{
await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category != CollectionCategory)
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
// KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue,
// IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key
// names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160
// - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases).
// KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1,
// Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this
// list into a Dictionary<PIHCLHIKEPH, KeepsakeCategoryConfigDTO> (NCCLEJPIABA.DHMFBMMAGCL,
// NCCLEJPIABA.txt:4204) that every placed instance is looked up in. The previous three-entry
// list keyed by our internal account/event/room buckets covered ids 0-2 only, so six of the
// nine placeable categories had no config - and, since collect prices the reward from XpValue,
// no reward either.
private static readonly (int Id, string VisualId)[] CategoryConfigs =
[
(0, "Explore"),
(1, "GreenPowerCore"),
(2, "Present"),
(3, "PurplePowerCore"),
(4, "UnnamedKeepsakeNumber1"),
(5, "UnnamedKeepsakeNumber2"),
(6, "UnnamedKeepsakeNumber3"),
(7, "UnnamedKeepsakeNumber4"),
(8, "UnnamedKeepsakeNumber5"),
];
/// <summary>
/// XP a single keepsake collection is worth. This is the one number both
/// <c>api/keepsakes/categories</c> (as <c>XpValue</c>) and
/// <c>api/keepsakes/{id}/collect</c> (as <c>TotalXp</c>) are derived from, so the toast the
/// client renders after a collect matches the config it already downloaded.
/// </summary>
private const int KeepsakeXpValue = 25;
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var results = CategoryConfigs
.Select(c => new
{
KeepsakeCategoryId = c.Id,
c.VisualId,
LimitPerRoom = KeepsakeRoomLimit,
XpValue = KeepsakeXpValue,
IconOutlineImageName = string.Empty,
IconFilledImageName = string.Empty,
})
.ToArray();
return Ok(new
{
Results = results,
TotalResults = results.Length,
});
}
[HttpGet("events")]
public async Task<IActionResult> Events()
{
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category == "event")
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("events/{eventId:long}")]
public async Task<IActionResult> EventInstances(long eventId)
{
var eventKey = eventId.ToString(CultureInfo.InvariantCulture);
// NOT filtered by PlayerId: Instances is the whole event's placement set (the hunt map),
// CollectionRecords is only what the caller has picked up.
var rows = await db.Keepsakes
.Where(k => k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
// RecNet.KeepsakeProgressionEventInstancesDTO has exactly two members, both
// List<KeepsakeRoomInstanceIdsDTO> (= {Int64, List<Guid>}) - dump.cs:1234798-1234810,
// ctor(List<KeepsakeRoomInstanceIdsDTO>, List<KeepsakeRoomInstanceIdsDTO>) in
// IsilDump/RecNet.Runtime/RecNet/KeepsakeProgressionEventInstancesDTO.txt:14.
// Emitting the flat keepsake rows here left RoomId=0 / KeepsakeInstanceIds=null per entry.
// Key names follow the same RecNet naming the room endpoint below already uses; the literal
// JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are
// unverified - Json.NET matching is case-insensitive and ignores unknown members.
//
// CollectionRecords is what the CALLER has picked up, which is a different set from what the
// caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows).
var eventInstanceIds = rows
.Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id))
.ToHashSet();
var collected = (await LoadMyCollectionsAsync())
.Where(c => eventInstanceIds.Contains(c.InstanceId))
.GroupBy(c => c.RoomId)
.Select(group => (object)new
{
RoomId = group.Key,
KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
})
.ToList();
return Ok(new
{
Instances = GroupInstanceIdsByRoom(rows),
CollectionRecords = collected,
});
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId)
{
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
if (!roomExists) return NotFound();
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.Category == "room"
&& (k.EventKey == $"room:{roomIdText}"
|| k.EventKey == $"room/{roomIdText}"
|| k.EventKey == roomIdText
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
// KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime
// CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter
// (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries
// AccountId so the client can filter, but returning only the caller's rows is correct under
// either reading and keeps other players' pickups out of the payload.
var collectPrefix = $"collect:{roomIdText}:";
var me = Me;
var collectionRecords = await db.Keepsakes
.Where(k => k.PlayerId == me
&& k.Category == CollectionCategory
&& EF.Functions.Like(k.EventKey, collectPrefix + "%"))
.ToListAsync();
return Ok(new
{
Instances = instances,
CollectionRecords = collectionRecords
.Where(k => TryParseCollectionKey(k.EventKey, out _, out _))
.Select(k =>
{
TryParseCollectionKey(k.EventKey, out _, out var instanceId);
return (object)new
{
AccountId = k.PlayerId is > 0 and <= int.MaxValue ? (int)k.PlayerId : 0,
KeepsakeInstanceId = instanceId.ToString("D"),
CollectedAt = k.EarnedAt,
};
})
.ToList(),
KeepsakeProgressionEventIds = Array.Empty<long>(),
});
}
/// <summary>
/// Removes a placed keepsake instance. The 2023 client issues
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
/// ("Must be at least a room co-owner to modify keepsakes.",
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
/// </summary>
[HttpDelete("{keepsakeInstanceId:guid}")]
public async Task<IActionResult> Delete(Guid keepsakeInstanceId)
{
var instance = await FindInstanceAsync(keepsakeInstanceId);
if (instance is null) return NotFound();
var roomId = TryParseInstanceKey(instance.EventKey, out var parsedRoomId, out _, out _, out _)
? parsedRoomId
: 0;
if (!await CanModifyInstanceAsync(instance, roomId, Me)) return Forbid();
// Drop the collection rows for this instance too, or they linger as records pointing at a
// keepsake nobody can see and keep suppressing a future re-placement's collect.
var suffix = keepsakeInstanceId.ToString("N");
var orphanedCollections = await db.Keepsakes
.Where(k => k.Category == CollectionCategory && EF.Functions.Like(k.EventKey, $"%:{suffix}"))
.ToListAsync();
db.Keepsakes.Remove(instance);
db.Keepsakes.RemoveRange(orphanedCollections
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
await db.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Records that the caller picked up a placed keepsake and reports the XP it was worth. The
/// 2023 client issues <c>POST api/keepsakes/{guid}/collect</c> -
/// <c>NCCLEJPIABA.HFGKFAHFELM(System.Guid)</c> formats <c>"{0}/{1}/collect"</c> over
/// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 -
/// <c>Move rdx, 2</c>), with no request body. The response type is
/// <c>FGLDKEJLAKB&lt;DHNBKMHDANK&gt;</c>; DHNBKMHDANK is a two-Int32 object whose Utf8Json
/// formatter writes "TotalXp" and "SocialBoostXp" (RecNet.Runtime/PKCMBJFBHBO.txt:42,69,100,121
/// - reader also accepts totalXp/socialBoostXp). The consumer computes
/// <c>TotalXp - SocialBoostXp</c> for the base figure and shows the boost separately
/// (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99), so TotalXp is the whole award, boost
/// included - not a running account total.
/// </summary>
[HttpPost("{keepsakeInstanceId:guid}/collect")]
public async Task<IActionResult> Collect(Guid keepsakeInstanceId)
{
var instance = await FindInstanceAsync(keepsakeInstanceId);
if (instance is null) return NotFound();
var parsed = TryParseInstanceKey(instance.EventKey, out var roomId, out _, out var categoryConfigId, out _);
if (!parsed)
{
roomId = 0;
categoryConfigId = CategoryId(instance.Category);
}
var me = Me;
var collectKey = BuildCollectionKey(roomId, keepsakeInstanceId);
var alreadyCollected = await db.Keepsakes
.AnyAsync(k => k.PlayerId == me && k.Category == CollectionCategory && k.EventKey == collectKey);
// Re-collecting is a no-op award rather than an error: the client only ever renders the two
// numbers, and paying out twice for one instance would be a fabricated reward.
if (alreadyCollected) return Ok(new { TotalXp = 0, SocialBoostXp = 0 });
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = me,
Category = CollectionCategory,
EventKey = collectKey,
EarnedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new
{
TotalXp = XpValueFor(categoryConfigId),
// SocialBoostXp is the boost slice *of* TotalXp - the client renders
// TotalXp - SocialBoostXp as the base figure - and globalconfig above advertises
// SocialXpBoostEnabled=false, so there is no slice and the whole award is base XP.
SocialBoostXp = 0,
});
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit,
SocialXpBoostEnabled,
});
[HttpPost]
public async Task<IActionResult> Create()
{
var req = await ReadRequestAsync();
// 2023 client: FGLDKEJLAKB<System.Guid> OPPHCOJHEJI(HMECHOKOCBB category)
// (NCCLEJPIABA.txt:1146) POSTs a RecNet.AddKeepsakeInstanceRequest
// {Int64 RoomId, Int64? SubRoomId, Int32 KeepsakeCategory} - dump.cs:1234509-1234525,
// field offsets 0x10/0x18/0x28 in AddKeepsakeInstanceRequest.txt - and deserialises the
// response as a BARE Guid, not an object. The request is refused client-side for offline
// rooms ("Cannot add keepsakes in offline rooms", NCCLEJPIABA.txt:1533), so RoomId is
// always a real room here; its presence is what selects this path over the legacy DTO.
if (req.RoomId is > 0)
{
var roomId = req.RoomId.Value;
if (!await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId)) return NotFound();
var instanceId = Guid.NewGuid();
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = Me,
Category = "room",
EventKey = BuildRoomInstanceKey(roomId, req.SubRoomId, req.KeepsakeCategory ?? 0, instanceId),
EarnedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(instanceId);
}
var key = Trim(req.EventKey, 128);
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
var category = Trim(req.Category, 64);
if (category.Length == 0) category = "event";
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
k.PlayerId == Me && k.EventKey == key);
if (existing is null)
{
existing = new KeepsakeEntity
{
PlayerId = Me,
EventKey = key,
};
db.Keepsakes.Add(existing);
}
existing.Category = category;
existing.Title = Trim(req.Title, 128);
existing.Description = Trim(req.Description, 1024);
existing.ImageName = Trim(req.ImageName, 256);
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(existing));
}
private async Task EnsureLoginKeepsakeAsync(long playerId)
{
const string key = "first-login";
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
return;
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = playerId,
Category = "account",
EventKey = key,
Title = "First Login",
Description = "Joined this server.",
});
await db.SaveChangesAsync();
}
private async Task<KeepsakeRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new KeepsakeRequest
{
RoomId = long.TryParse(form["RoomId"].FirstOrDefault() ?? form["roomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formRoom) ? formRoom : null,
SubRoomId = long.TryParse(form["SubRoomId"].FirstOrDefault() ?? form["subRoomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formSubRoom) ? formSubRoom : null,
KeepsakeCategory = int.TryParse(form["KeepsakeCategory"].FirstOrDefault() ?? form["keepsakeCategory"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formCategory) ? formCategory : null,
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
};
}
// The 2023 client sends a RAW JSON body, not a form: NCCLEJPIABA.txt:1458-1465 serialises the
// whole AddKeepsakeInstanceRequest in one call and hands the string to BNDIAONDFFF's body
// setter. Read it as a document rather than binding a DTO so the numeric KeepsakeCategory and
// the legacy string Category can share one wire slot, and so key-name aliases are cheap.
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
return new KeepsakeRequest
{
RoomId = ReadInt64(root, "RoomId"),
SubRoomId = ReadInt64(root, "SubRoomId"),
// "Category" is listed as an alias only for the numeric form; when it carries the
// legacy string ("event"/"room") ReadInt64 declines it and ReadString picks it up.
KeepsakeCategory = (int?)ReadInt64(root, "KeepsakeCategory", "KeepsakeCategoryConfigId", "Category"),
Category = ReadString(root, "Category"),
EventKey = ReadString(root, "EventKey"),
Title = ReadString(root, "Title"),
Description = ReadString(root, "Description"),
ImageName = ReadString(root, "ImageName"),
EarnedAt = DateTime.TryParse(ReadString(root, "EarnedAt"), out var earnedAt) ? earnedAt : null,
};
}
catch (JsonException)
{
return new KeepsakeRequest();
}
}
private static bool TryFindMember(JsonElement root, string[] names, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
foreach (var name in names)
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
}
value = default;
return false;
}
private static long? ReadInt64(JsonElement root, params string[] names)
{
if (!TryFindMember(root, names, out var el)) return null;
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var number)) return number;
if (el.ValueKind == JsonValueKind.String
&& long.TryParse(el.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed;
return null;
}
private static string? ReadString(JsonElement root, params string[] names)
=> TryFindMember(root, names, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
private object ToWire(KeepsakeEntity row) => new
{
row.Id,
row.PlayerId,
row.Category,
row.EventKey,
row.Title,
row.Description,
row.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
row.EarnedAt,
};
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
{
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
? (int)row.PlayerId
: 0;
var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId);
return new
{
KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"),
// KeepsakeCategory is the visual config id (enum PIHCLHIKEPH: Explore=0, GreenPowerCore=1,
// Present=2, PurplePowerCore=3, ... - dump.cs:1199524), NOT our account/event/room bucket,
// so replay what the client sent; CategoryId() is only the fallback for legacy rows.
KeepsakeCategoryConfigId = parsed ? categoryConfigId : CategoryId(row.Category),
PlacedByAccountId = placedByAccountId,
RoomId = parsed ? keyRoomId : roomId,
SubRoomId = subRoomId,
};
}
private static List<object> GroupInstanceIdsByRoom(IEnumerable<KeepsakeEntity> rows) => rows
.Select(row => TryParseInstanceKey(row.EventKey, out var roomId, out _, out _, out var instanceId)
? (RoomId: roomId, InstanceId: instanceId)
: (RoomId: 0L, InstanceId: StableKeepsakeInstanceId(row.Id)))
.GroupBy(x => x.RoomId)
.Select(group => (object)new
{
RoomId = group.Key,
KeepsakeInstanceIds = group.Select(x => x.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
})
.ToList();
// KeepsakeEntity has no room/sub-room/instance-id columns, so a placed instance is encoded into
// the existing 128-char EventKey. Layouts:
// room:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} (client placements)
// event:{eventId}:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N}
// Both stay inside the LIKE prefixes the room/event queries above already use. Legacy short keys
// ("room:{roomId}", "event:{eventId}", "first-login") fail to parse and fall back to the
// MD5-derived instance id, which is what those rows were already reported with.
private static string BuildRoomInstanceKey(long roomId, long? subRoomId, int categoryConfigId, Guid instanceId)
{
var room = roomId.ToString(CultureInfo.InvariantCulture);
var subRoom = subRoomId?.ToString(CultureInfo.InvariantCulture) ?? "-";
var category = categoryConfigId.ToString(CultureInfo.InvariantCulture);
return $"room:{room}:{subRoom}:{category}:{instanceId:N}";
}
private static bool TryParseInstanceKey(string? eventKey, out long roomId, out long? subRoomId, out int categoryConfigId, out Guid instanceId)
{
roomId = 0;
subRoomId = null;
categoryConfigId = 0;
instanceId = Guid.Empty;
var parts = (eventKey ?? string.Empty).Split(':');
int i;
if (parts.Length == 5 && string.Equals(parts[0], "room", StringComparison.OrdinalIgnoreCase)) i = 1;
else if (parts.Length == 6 && string.Equals(parts[0], "event", StringComparison.OrdinalIgnoreCase)) i = 2;
else return false;
if (!Guid.TryParseExact(parts[i + 3], "N", out instanceId)) return false;
if (!long.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false;
if (long.TryParse(parts[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sub)) subRoomId = sub;
if (!int.TryParse(parts[i + 2], NumberStyles.Integer, CultureInfo.InvariantCulture, out categoryConfigId)) categoryConfigId = 0;
return true;
}
// A collection record is stored as its own keepsake row so no schema change is needed:
// Category = "collection", EventKey = "collect:{roomId}:{instanceGuid:N}"
// The room id is baked in so the room endpoint can filter by prefix and the event endpoint can
// group by room without re-reading the instance rows. "collect:" shares no prefix with the
// "room:"/"event:" instance keys, so collection rows never leak into an instance query, and
// Mine() filters the category out of the legacy keepsake list.
private static string BuildCollectionKey(long roomId, Guid instanceId)
=> $"collect:{roomId.ToString(CultureInfo.InvariantCulture)}:{instanceId:N}";
private static bool TryParseCollectionKey(string? eventKey, out long roomId, out Guid instanceId)
{
roomId = 0;
instanceId = Guid.Empty;
var parts = (eventKey ?? string.Empty).Split(':');
if (parts.Length != 3 || !string.Equals(parts[0], "collect", StringComparison.OrdinalIgnoreCase)) return false;
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false;
return Guid.TryParseExact(parts[2], "N", out instanceId);
}
private async Task<List<(long RoomId, Guid InstanceId)>> LoadMyCollectionsAsync()
{
var me = Me;
var rows = await db.Keepsakes
.Where(k => k.PlayerId == me && k.Category == CollectionCategory)
.Select(k => k.EventKey)
.ToListAsync();
var results = new List<(long RoomId, Guid InstanceId)>(rows.Count);
foreach (var key in rows)
{
if (TryParseCollectionKey(key, out var roomId, out var instanceId))
results.Add((roomId, instanceId));
}
return results;
}
/// <summary>
/// Resolves the guid the client holds (from <c>api/keepsakes/rooms/{roomId}</c>, or the guid
/// this server handed back from POST <c>api/keepsakes</c>) to the row that encodes it.
/// </summary>
private async Task<KeepsakeEntity?> FindInstanceAsync(Guid keepsakeInstanceId)
{
var suffix = keepsakeInstanceId.ToString("N");
var candidates = await db.Keepsakes
.Where(k => (k.Category == "room" || k.Category == "event")
&& EF.Functions.Like(k.EventKey, $"%:{suffix}"))
.ToListAsync();
var match = candidates.FirstOrDefault(k =>
TryParseInstanceKey(k.EventKey, out _, out _, out _, out var id) && id == keepsakeInstanceId);
if (match is not null) return match;
// Rows written before the instance id was encoded into EventKey are reported to the client
// with the MD5-derived StableKeepsakeInstanceId, which no LIKE can match. Resolve those by
// id only - the projection keeps it to one column over the short-key rows.
var legacyIds = await db.Keepsakes
.Where(k => (k.Category == "room" || k.Category == "event")
&& !EF.Functions.Like(k.EventKey, "%:%:%:%"))
.Select(k => k.Id)
.ToListAsync();
var legacyId = legacyIds.FirstOrDefault(id => StableKeepsakeInstanceId(id) == keepsakeInstanceId);
return legacyId == 0 ? null : await db.Keepsakes.FirstOrDefaultAsync(k => k.Id == legacyId);
}
/// <summary>
/// Placement and removal are gated client-side on room co-ownership
/// ("Must be at least a room co-owner to modify keepsakes.", PDFJLLECNBE.txt:3363); the placer
/// and server admins are allowed through as well.
/// </summary>
private async Task<bool> CanModifyInstanceAsync(KeepsakeEntity instance, long roomId, long playerId)
{
if (instance.PlayerId == playerId) return true;
if (roomId > 0)
{
var creatorId = await db.Rooms.AsNoTracking()
.Where(r => r.Id == roomId)
.Select(r => (long?)r.CreatorPlayerId)
.FirstOrDefaultAsync();
if (creatorId == playerId) return true;
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
if (isCoOwner) return true;
}
return await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
}
private static int XpValueFor(int keepsakeCategoryConfigId)
=> CategoryConfigs.Any(c => c.Id == keepsakeCategoryConfigId) ? KeepsakeXpValue : 0;
private static int CategoryId(string? category) =>
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
0;
private static Guid StableKeepsakeInstanceId(long id)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
return new Guid(hash);
}
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class KeepsakeRequest
{
// RecNet.AddKeepsakeInstanceRequest (2023 client).
public long? RoomId { get; set; }
public long? SubRoomId { get; set; }
public int? KeepsakeCategory { get; set; }
public string? Category { get; set; }
public string? EventKey { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public DateTime? EarnedAt { get; set; }
}
}
@@ -131,6 +131,106 @@ public class LeaderboardController(
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>/leaderboard/CheckAndSetStat</c> — the compare-and-set
/// form of <see cref="SetStat"/>, and the ONLY stat-write route the
/// 2023-03-21 client has: that build contains no <c>SetStat</c> literal at
/// all. Nothing registered this route, so every leaderboard score written
/// by a 2023 client fell through to the catch-all and was lost.
///
/// The request is <c>RecNet.SetStatRequestDTO</c>, whose property names are
/// NOT obfuscated in the dump: <c>StatChannel</c> (Int32), <c>RoomId</c>
/// (Int64), <c>StatValue</c> (Int32) and the extra
/// <c>CurrentStatValue</c> (Int32?) that distinguishes this route from
/// plain SetStat.
///
/// <c>CurrentStatValue</c> is the value the client believes is currently
/// stored. It is an optimistic-concurrency guard: the write applies only if
/// the server agrees, which stops a stale session from clobbering a newer
/// score. A null means "no expectation" and writes unconditionally, and a
/// mismatch is reported rather than silently dropped so the client can
/// re-read.
///
/// As with SetStat, the client has already decided the new value should win
/// (it applies the per-channel higher/lower-is-better rule locally), so the
/// server does not second-guess the direction.</summary>
[HttpPost("/leaderboard/CheckAndSetStat")]
[HttpPost("/api/leaderboard/CheckAndSetStat")]
[Authorize]
public async Task<IActionResult> CheckAndSetStat([FromBody] CheckAndSetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (req.CurrentStatValue is int expected && row is not null && row.Value != expected)
{
return Ok(new
{
success = false,
error = "stat_value_mismatch",
StatValue = row.Value,
CurrentStatValue = row.Value,
});
}
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
RoomId = roomId,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = req.StatValue;
row.UpdatedAt = DateTime.UtcNow;
}
await EnsureChannelMetaAsync(roomId, req.StatChannel);
await db.SaveChangesAsync();
return Ok(new
{
success = true,
error = "",
StatValue = row.Value,
CurrentStatValue = row.Value,
});
}
public sealed class CheckAndSetStatRequest
{
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int StatValue { get; set; }
/// <summary>The value the client believes is stored. Null = write
/// unconditionally.</summary>
public int? CurrentStatValue { get; set; }
}
/// <summary>Stamp a placeholder channel-meta row the first time a channel
/// reports, so the admin SPA's per-room Leaderboards tab surfaces it.</summary>
private async Task EnsureChannelMetaAsync(long roomId, int statChannel)
{
if (roomId <= 0) return;
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel);
if (meta is not null) return;
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
{
Channel = statChannel,
RoomId = roomId,
Name = $"Channel {statChannel}",
LowerIsBetter = false,
ValueFormat = "count",
});
}
public sealed class GetPlayerRankRequest
{
public int PlayerId { get; set; }
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -105,9 +106,23 @@ public class InGameModerationController(
// ── /v1/instantKick — admin one-shot ─────────────────────────────
/// <summary>
/// Admin/dev one-shot kick. The watch posts either
/// Admin/dev one-shot kick. The 2020.12 watch posts either
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
/// or a list body for bulk kicks (3590). Either form lands here.
/// or a list body for bulk kicks (3590).
///
/// The 2023-03-21 client posts neither: it serialises a
/// <c>RecNet.KickPlayerDTO</c> with <c>JsonUtility.ToJson</c> and sends it
/// as a RAW JSON body (RecNet.Runtime/FPIBGPIAOBI.txt:5012 JsonUtility.ToJson,
/// :5018 BNDIAONDFFF.FJLLPHFOOJJ raw-body setter, :5034 verb rdx=2).
/// KickPlayerDTO's fields are not obfuscated — <c>public long GameSessionId</c>
/// and <c>public List&lt;int&gt; PlayerIds</c> — so the body is exactly
/// <c>{"GameSessionId":123,"PlayerIds":[456]}</c>. Unity's JsonUtility emits
/// field names verbatim.
///
/// None of the query/form parameters bound against that body, so the target
/// list came back empty and every 2023 instant-kick returned
/// "missing_target" and kicked nobody.
///
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
/// <see cref="PlayerEntity.IsDeveloper"/>.
/// </summary>
@@ -130,6 +145,7 @@ public class InGameModerationController(
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
ids.AddRange(await ReadKickPlayerDtoIdsAsync());
ids = ids.Distinct().ToList();
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
@@ -143,6 +159,47 @@ public class InGameModerationController(
return Ok(new { success = true, error = "" });
}
/// <summary>Read the 2023 client's raw <c>KickPlayerDTO</c> JSON body.
/// Returns an empty list for a form post or an unparseable body so the
/// older query/form paths are unaffected.</summary>
private async Task<List<long>> ReadKickPlayerDtoIdsAsync()
{
var ids = new List<long>();
if (Request.HasFormContentType) return ids;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != JsonValueKind.Object) return ids;
foreach (var name in new[] { "PlayerIds", "playerIds" })
{
if (!doc.RootElement.TryGetProperty(name, out var arr) ||
arr.ValueKind != JsonValueKind.Array) continue;
foreach (var el in arr.EnumerateArray())
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0)
ids.Add(v);
break;
}
foreach (var name in new[] { "PlayerId", "playerId" })
{
if (doc.RootElement.TryGetProperty(name, out var one) &&
one.ValueKind == JsonValueKind.Number &&
one.TryGetInt64(out var v) && v > 0)
{
ids.Add(v);
break;
}
}
}
catch (JsonException) { /* not a JSON body */ }
return ids;
}
// ── /v1/deviceId — log mismatched device id ──────────────────────
[HttpPost("api/PlayerReporting/v1/deviceId")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
@@ -192,11 +249,21 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push a ModerationRoomBan notification so the target watch
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
await notifications.NotifyAsync(
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
// registers only 22, "ModerationUnkick" and 23
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
// for 24, so that push landed nowhere and the banned player kept playing
// until their next login. The 22 handler reads Msg.Reason as a
// ModerationBlockDetail, which ModerationKickPayload already produces.
await notifications.KickPlayerAsync(
target,
Models.Notification.PushNotificationId.ModerationRoomBan);
// `reason` is the numeric report category; DisplayReason is the
// moderator's text and is what the ban dialog shows.
string.IsNullOrWhiteSpace(displayReason)
? "You have been banned from this room."
: displayReason.Trim(),
isBan: true);
return Ok(new { success = true, error = "" });
}
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -28,7 +30,6 @@ namespace DorkNet.Server.Controllers.API.Photos;
public class PhotosController(
DorkNetDbContext db,
PlayerPresenceService presence,
NotificationService notifications,
LevelService level,
DomainConfig domain) : ControllerBase
{
@@ -83,12 +84,11 @@ public class PhotosController(
// Posting a photo gives a small XP bump — encourages camera use.
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
// Push to tagged players so their "Photos of me" feed refreshes.
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
await notifications.NotifyAsync(taggedId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
// No real-time push for photo tags: there is no matching client
// message type, and the old SubscriptionUpdateProfile
// {Reason:"TaggedInPhoto"} blob decoded to a blank account
// ("Orphan player account (0)") without refreshing anything. The
// "Photos of me" feed refreshes on the tagged player's next fetch.
return Ok(ToDto(photo));
}
@@ -217,9 +217,9 @@ public class PhotosController(
{
await level.AwardXpAsync(photo.UploaderPlayerId,
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
await notifications.NotifyAsync(photo.UploaderPlayerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "PhotoCheer", PhotoId = id, From = me });
// No real-time push: no client message type for photo cheers, and
// the old SubscriptionUpdateProfile blob only spawned a blank
// "Orphan player account (0)". Count refreshes on next fetch.
}
return Ok(new { cheered = true, count = photo.CheerCount });
}
@@ -242,6 +242,320 @@ public class PhotosController(
return Ok(new { deleted = id });
}
// ── 2023 home feed (Discovery host) ───────────────────────────────
/// <summary>The only <c>FeedItemType</c> (client enum
/// <c>DAAEHNDDCCH</c>) the 2023 feed list can render: an image.
/// Two independent confirmations in the binary — the list spawner
/// builds one <c>RRUI.Data.ImageFeedItemModel</c> per returned item
/// (<c>Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294</c>
/// "044 Call ImageFeedItemModel.Set"), and the share deep-link
/// handler maps the path segment "image" + an Int64 id onto an
/// injected feed item constructed with the enum literal 0
/// (<c>Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646</c>
/// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call
/// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on
/// DorkNet is <see cref="PhotoEntity.Id"/> — the same id space
/// <c>api/images/v5/bulk</c> resolves.</summary>
private const int FeedItemTypeImage = 0;
/// <summary>Ranking revision stamped on the envelope and on every
/// item. The client never interprets it: it copies the Int16 off the
/// response into the list model and replays it in the impression
/// telemetry it later posts back (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c>
/// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then
/// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering
/// below changes so the telemetry can tell the cohorts apart.</summary>
private const short FeedAlgoVersion = 1;
/// <summary>POST /feed/query — the 2023 client's personalised home
/// feed. Host is Discovery (host enum 19) and the verb is POST:
/// <c>RecNet.Runtime/KGLCPCEGCFF.txt:166-178</c> loads
/// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the
/// shared BNDIAONDFFF request ctor. The body is a RAW JSON document
/// (the issuing method takes an already-serialised
/// <c>System.String</c> and hands it to BNDIAONDFFF.FJLLPHFOOJJ),
/// NOT form fields — so it is read straight off Request.Body.
///
/// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in
/// PascalCase/camelCase/lowercase): Take:Int32,
/// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType,
/// FeedItemId}], FeedInstanceId:String?.
///
/// Response is the generic result envelope AECMPGPHAII&lt;T&gt;
/// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping
/// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext,
/// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything
/// else — including a 404 — rejects the promise and the home tab
/// renders its failure state.
///
/// FeedContext is the continuation cursor, not a display string: the
/// RefreshPage continuation reads field +32 (FeedContext) as the
/// third element of its (count, items, nextToken) tuple and feeds it
/// back as the next request's ContinuationToken
/// (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c> "062 Move r9,
/// [rdi+32]"). An empty FeedContext therefore ends pagination.
///
/// Anonymous-safe like the other feed reads; when the caller is
/// authenticated the ordering is personalised (friends/subscriptions
/// first) and blocked/ignored uploaders are filtered out.</summary>
[HttpPost("/feed/query")]
[AllowAnonymous]
public async Task<ActionResult> FeedQuery()
{
var take = 20;
string? continuationToken = null;
string? requestedInstanceId = null;
var requestedInjected = new List<long>();
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
var root = doc.RootElement;
if (TryFeedProp(root, "Take", out var takeEl) &&
takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t))
take = t;
if (TryFeedProp(root, "ContinuationToken", out var ctEl) &&
ctEl.ValueKind == JsonValueKind.String)
continuationToken = ctEl.GetString();
if (TryFeedProp(root, "FeedInstanceId", out var fiEl) &&
fiEl.ValueKind == JsonValueKind.String)
requestedInstanceId = fiEl.GetString();
if (TryFeedProp(root, "InjectedFeedItems", out var injEl) &&
injEl.ValueKind == JsonValueKind.Array)
{
foreach (var entry in injEl.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object) continue;
// Non-image injected items can't be rendered by the
// 2023 list, so echoing them back would produce a
// blank card. Drop them.
var type = TryFeedProp(entry, "FeedItemType", out var tyEl) &&
tyEl.ValueKind == JsonValueKind.Number &&
tyEl.TryGetInt32(out var ty)
? ty : FeedItemTypeImage;
if (type != FeedItemTypeImage) continue;
if (TryFeedProp(entry, "FeedItemId", out var idEl) &&
idEl.ValueKind == JsonValueKind.Number &&
idEl.TryGetInt64(out var id) && id > 0)
requestedInjected.Add(id);
}
}
}
}
catch (JsonException)
{
// Unparseable/empty body — treat as a default first-page pull
// rather than 400, so a malformed retry still shows content.
}
take = Math.Clamp(take, 1, 50);
var me = this.CurrentPlayerId();
// Uploaders the caller can't see: anyone they blocked or ignored,
// plus anyone who blocked them.
var hidden = new List<long>();
var affinity = new List<long>();
if (me is long meId)
{
hidden = await db.Relationships
.Where(r => (r.RequesterId == meId &&
(r.Status == RelationshipStatus.Blocked || r.Ignored)) ||
(r.TargetId == meId && r.Status == RelationshipStatus.Blocked))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.Distinct()
.ToListAsync();
var friends = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == meId || r.TargetId == meId))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.ToListAsync();
var follows = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == meId)
.Select(s => s.TargetPlayerId)
.ToListAsync();
affinity = friends.Concat(follows)
.Where(id => id > 0 && !hidden.Contains(id))
.Distinct()
.ToList();
}
var resumed = TryDecodeFeedCursor(continuationToken,
out var phase, out var cursorTicks, out var cursorId, out var carriedInjected);
// Injected items are pinned to the head of the FIRST page only, in
// the order the client asked for them (they come from a share
// deep-link the player just followed). Their ids ride along in the
// cursor so later pages don't repeat them organically.
var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray();
var itemIds = new List<long>();
if (!resumed && injectedIds.Length > 0)
{
var pinned = await VisibleFeedPhotos(hidden)
.Where(p => injectedIds.Contains(p.Id))
.Select(p => p.Id)
.ToListAsync();
itemIds.AddRange(injectedIds.Where(pinned.Contains));
}
// Utc kind explicitly: the ticks came from a UtcNow-derived CreatedAt,
// and Npgsql refuses to write an Unspecified DateTime to timestamptz.
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
? new DateTime(cursorTicks, DateTimeKind.Utc)
: (DateTime?)null;
var cutoffId = resumed ? cursorId : 0L;
if (phase < 0) phase = 0;
// Phase 0 = photos from people the caller follows or is friends
// with; phase 1 = everyone else. Both are newest-first, and the
// cursor is (CreatedAt, Id) so a photo posted mid-scroll can never
// shift a row onto a page the client already consumed.
while (itemIds.Count < take && phase <= 1)
{
if (phase == 0 && affinity.Count == 0)
{
phase = 1;
cutoffAt = null;
cutoffId = 0;
continue;
}
var need = take - itemIds.Count;
var q = VisibleFeedPhotos(hidden);
if (injectedIds.Length > 0)
q = q.Where(p => !injectedIds.Contains(p.Id));
q = phase == 0
? q.Where(p => affinity.Contains(p.UploaderPlayerId))
: q.Where(p => !affinity.Contains(p.UploaderPlayerId));
if (cutoffAt is DateTime at)
{
var atId = cutoffId;
q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId));
}
var batch = await q
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(need)
.Select(p => new { p.Id, p.CreatedAt })
.ToListAsync();
itemIds.AddRange(batch.Select(b => b.Id));
if (batch.Count < need)
{
phase++;
cutoffAt = null;
cutoffId = 0;
}
else
{
cutoffAt = batch[^1].CreatedAt;
cutoffId = batch[^1].Id;
}
}
// Empty FeedContext = end of the feed; the client stops paging.
var nextContext = phase > 1
? string.Empty
: EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds);
return Ok(new
{
Success = true,
Error = (string?)null,
Value = new
{
Items = itemIds.Select(id => new
{
FeedItemType = FeedItemTypeImage,
FeedItemId = id,
FeedAlgorithmVersion = FeedAlgoVersion,
}).ToList(),
// The client sends the instance id it generated for this
// scroll session and stores whatever comes back; echoing it
// keeps its impression telemetry stitched to one session.
FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId)
? Guid.NewGuid().ToString("N")
: requestedInstanceId,
FeedContext = nextContext,
FeedAlgorithmVersion = FeedAlgoVersion,
},
});
}
/// <summary>Photos eligible for the public home feed: live, public,
/// and not from an uploader the caller blocked/ignored.</summary>
private IQueryable<PhotoEntity> VisibleFeedPhotos(List<long> hidden)
{
var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null);
if (hidden.Count > 0)
q = q.Where(p => !hidden.Contains(p.UploaderPlayerId));
return q;
}
/// <summary>Case-insensitive property lookup — the 2023 generated
/// writers emit PascalCase but their readers register all three
/// casings, so tolerate whatever the client sends.</summary>
private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Opaque base64url cursor handed to the client as
/// FeedContext. Carries the phase, the (CreatedAt, Id) keyset
/// position within that phase, and the pinned injected ids so they
/// stay suppressed on later pages.</summary>
private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList<long> injected)
{
var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static bool TryDecodeFeedCursor(string? token, out int phase,
out long ticks, out long lastId, out long[] injected)
{
phase = 0;
ticks = 0;
lastId = 0;
injected = Array.Empty<long>();
if (string.IsNullOrWhiteSpace(token)) return false;
try
{
var b64 = token.Replace('-', '+').Replace('_', '/');
b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '=');
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|');
if (parts.Length != 5 || parts[0] != "1") return false;
if (!int.TryParse(parts[1], out phase)) return false;
if (!long.TryParse(parts[2], out ticks)) return false;
if (!long.TryParse(parts[3], out lastId)) return false;
injected = parts[4]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToArray();
return true;
}
catch (FormatException)
{
// Garbage/stale cursor — restart the feed from the top instead
// of failing the whole pull.
return false;
}
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
@@ -114,7 +114,18 @@ public class PlatformLoginController(
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public IActionResult RemoveCachedLogin() => Ok(new { });
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
{
var targetId = accountId ?? this.CurrentPlayerId();
if (targetId is not long playerId) return Unauthorized();
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return NotFound();
player.LastPlatform = 0;
player.LastPlatformId = string.Empty;
await db.SaveChangesAsync();
return Ok(new { Success = true, AccountId = (int)player.Id });
}
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
[ApiController]
[Authorize]
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playstationplus/membership")]
public async Task<IActionResult> Membership()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
? parsed
: DateTime.MinValue;
var active = expires > DateTime.UtcNow;
return Ok(new
{
IsMember = active,
MembershipActive = active,
ExpiresAt = active ? expires : (DateTime?)null,
});
}
[HttpPost("api/playstationplus/expire")]
public async Task<IActionResult> Expire()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "playstationplus:expires",
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
});
}
else
{
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
}
await db.SaveChangesAsync();
return Ok(new { Success = true, IsMember = false });
}
}
@@ -20,46 +20,83 @@ namespace DorkNet.Server.Controllers.API.PlayerCheer;
[Authorize]
public class PlayerCheerController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
/// the target player. <c>Type</c> matches the same enum as
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
/// category click doesn't inflate the count.</summary>
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
/// target player. Request fields (verified from RecNet.Runtime
/// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
/// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
/// <c>CheerCategory</c> (the cheer enum), <c>RoomId</c> (optional room
/// context), <c>Anonymous</c> (bool). The OLD binding read
/// <c>TargetAccountId</c>/<c>Type</c>, which never matched — the target
/// bound to null, the handler reported failure, and the watch surfaced
/// "Failed to cheer player — Something went wrong". The client parses the
/// response into PHMHCPEMABG { bool, string } and gates on the bool, so
/// the body must report success. Idempotent on (caller, target, room,
/// category).</summary>
[HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "Type")] int type = 0)
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "CheerCategory")] int cheerCategory = 0,
[FromForm(Name = "RoomId")] long? roomId = null,
[FromForm(Name = "Anonymous")] bool anonymous = false)
{
if (targetAccountId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
if (playerIdTo is not long target || target <= 0)
return CheerResult(false, "Invalid cheer target.");
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
return CheerResult(false, "You can't cheer yourself.");
var targetRoomId = roomId ?? 0;
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { success = true, already_cheered = true });
c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
if (existing is not null)
// Already cheered — still a success from the client's POV (the
// string carries the client's own "very recently" phrasing).
return CheerResult(true, "You have already cheered this player very recently.");
db.Cheers.Add(new CheerEntity
{
FromPlayerId = Me,
TargetPlayerId = target,
Type = type,
TargetRoomId = targetRoomId,
Type = cheerCategory,
});
await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
await notifications.NotifyAsync(target,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = Me, Type = type });
return Ok(new { success = true, error = "" });
// Real notification: a PlayerCheer message the recipient renders as
// "X cheered you". Anonymous cheers use the anonymous message type and
// don't reveal the cheerer.
await systemNotifications.SendAsync(target,
anonymous
? SystemNotificationService.MessageType.PlayerCheerAnonymous
: SystemNotificationService.MessageType.PlayerCheer,
fromPlayerId: anonymous ? 0 : Me);
return CheerResult(true, "");
}
/// <summary>The create response deserializes into PHMHCPEMABG
/// { bool GJLFIFEJDEH, string ABGLLJPIMIO } (KFJKDMGHKHE.txt maps the raw
/// body via Func&lt;PHMHCPEMABG, LDGADANDBIO&gt;). The JSON keys are
/// obfuscated with no literals in the dump, so emit the success bool and
/// message under the common key spellings via a Dictionary (literal keys
/// bypass the camelCase policy; LitJson ignores the ones it doesn't
/// read).</summary>
private IActionResult CheerResult(bool success, string message) =>
Ok(new Dictionary<string, object?>
{
["Success"] = success,
["success"] = success,
["Message"] = message,
["message"] = message,
["Error"] = success ? string.Empty : message,
["error"] = success ? string.Empty : message,
});
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -10,25 +12,49 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
/// broadcast for player-scheduled gatherings.
///
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
/// nearly every handler here:
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
/// mutation deserialises this wrapper, so a flat event body leaves the
/// client's <c>PlayerEvent</c> null after a save.
/// </summary>
[ApiController]
[Authorize]
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
NotificationService notifications,
PlayerPresenceService presence) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
/// does not count towards AttendeeCount.</summary>
private const int NotGoingResponse = 2;
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
// ── Browse ───────────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
/// events list, a BARE JSON array of PlayerEvent.
///
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
/// returns <c>List&lt;HPIOAGDJHDH&gt;</c> and dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
/// whole tab 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
[HttpGet("api/playerevents/v1")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
@@ -40,22 +66,221 @@ public class PlayerEventsController(
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
/// MDCBEPJCJPO, query keys at
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
/// formatters skip unknown keys — so we always emit the superset and no
/// branch is needed.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
return Ok(await ToWireOneAsync(ev));
}
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int? sort = null,
[FromQuery] int? scheduleFilter = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
if (needle.Length > 0)
{
rows = rows.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var result = await ApplySort(rows, sort)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(await ToWireManyAsync(result));
}
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
/// Separate handler from <see cref="Search"/> because the response element
/// type differs: FIKHCEPDIBF returns <c>List&lt;CLDDIKOJMAM&gt;</c>, which
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
/// Head-count comes from the room's live GameSessions rows (their RoomId is
/// the numeric room id as a string, see
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> SearchLive(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
take = Math.Clamp(take, 1, 100);
var now = DateTime.UtcNow;
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
if (needle.Length > 0)
{
live = live.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
var wire = await ToWireManyAsync(rows);
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
var sessions = await db.GameSessions
.Where(s => roomKeys.Contains(s.RoomId))
.GroupBy(s => s.RoomId)
.Select(g => new
{
RoomId = g.Key,
Players = g.Sum(s => s.PlayerCount),
Capacity = g.Sum(s => s.MaxCapacity),
})
.ToListAsync();
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
for (var i = 0; i < rows.Count; i++)
{
var players = 0;
var capacity = 0;
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
{
players = agg.Players;
capacity = agg.Capacity;
}
wire[i]["PlayerCount"] = players;
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
}
return Ok(wire);
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
public async Task<ActionResult> ForRoom(long roomId)
{
var rows = await db.PlayerEvents
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
/// returns IOKLNPFOLGI — an OBJECT
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
/// <c>take</c> / <c>continuationToken</c> query params
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
/// just the next skip offset; empty string means "no more pages" (the
/// client's field is a plain String, so never emit null).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(
long clubId,
[FromQuery] int? take = null,
[FromQuery] string? continuationToken = null)
{
var pageSize = Math.Clamp(take ?? 50, 1, 100);
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
// Fetch one extra row to find out whether another page exists.
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
var hasMore = rows.Count > pageSize;
if (hasMore) rows.RemoveAt(rows.Count - 1);
return Ok(new
{
Events = await ToWireManyAsync(rows),
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
});
}
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
/// returned every club's events and the watch could not bucket the list.
/// </summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubIds = new List<long>();
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
return Ok(await ToWireManyAsync(rows));
}
public sealed class EventBulkRequest
{
public List<long>? PlayerEventIds { get; set; }
public List<long>? EventIds { get; set; }
public List<long>? Ids { get; set; }
}
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
/// 2020 client, which puts the ids in the query string.</summary>
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task<ActionResult> Bulk()
{
var ids = await ReadEventIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(await ToWireManyAsync(rows));
}
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
/// page NREs.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
@@ -63,66 +288,572 @@ public class PlayerEventsController(
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
return await AllForPlayerAsync(me);
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
=> AllForPlayerAsync(accountId);
private async Task<ActionResult> AllForPlayerAsync(long playerId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.Where(e => e.CreatorPlayerId == playerId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.Take(200)
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.Where(r => r.PlayerId == playerId)
.OrderByDescending(r => r.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
// Each response entry embeds its own event, which may not be one the
// caller created — pull those in too so PlayerEvent is never null.
var createdIds = created.Select(e => e.Id).ToHashSet();
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
var extraEvents = new List<PlayerEventEntity>();
if (missingIds.Count > 0)
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
var all = created.Concat(extraEvents).ToList();
var wire = await ToWireMapAsync(all);
return Ok(new
{
Created = created.Select(e => wire[e.Id]).ToList(),
Responses = responses
.Where(r => wire.ContainsKey(r.EventId))
.Select(r => new
{
PlayerEvent = wire[r.EventId],
PlayerEventResponse = ResponseWire(r),
})
.ToList(),
});
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
/// <c>List&lt;JPIKCIGABBI&gt;</c>; the path was registered POST-only, so
/// the list 405'd.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
public async Task<ActionResult> ResponsesForEvent(long eventId)
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
var rows = await db.PlayerEventResponses
.Where(r => r.EventId == eventId)
.OrderBy(r => r.CreatedAt)
.Take(500)
.ToListAsync();
return Ok(rows.Select(ResponseWire).ToList());
}
// ── Event instance browser ───────────────────────────────────────────
/// <summary>GET <c>event/{eventId}/instances</c> — the "which instance of
/// this event are my friends in" browser behind the event card's join
/// button. Served from the MATCHMAKING-HOST root (no <c>api/</c> prefix),
/// which is why it lives on an absolute route here even though the feature
/// is a player-event one.
///
/// Binary evidence: <c>Matchmaking+&lt;GetEventInstanceBrowser&gt;b__0</c>
/// (<c>Matchmaking_NestedType_CBMHJMNIHNN.txt:14</c>) formats the literal
/// <c>"event/{0}/instances"</c> at <c>:296</c> with <c>[rdi+16]</c> — the
/// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up
/// BNDIAONDFFF with <c>rdx = 0</c> at <c>:124</c>, i.e.
/// <c>BestHTTP.HTTPMethods.Get</c>. The declared return type is
/// <c>FGLDKEJLAKB&lt;List&lt;PNDCMIMEJLD&gt;&gt;</c>, so the body is a BARE
/// JSON ARRAY. PNDCMIMEJLD's getters (<c>PNDCMIMEJLD.txt:3-197</c>) are, in
/// order, Int64/Int64/Int64/Boolean/DateTime/List&lt;Int32&gt;/String/Int32/
/// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt,
/// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId —
/// byte-for-byte the SimpleRoomInstance the <c>room/{id}/instances</c>
/// browser already returns (<c>Matchmaking.txt:14237</c> issues the room
/// variant through the identical BNDIAONDFFF/verb-0 sequence). The last
/// four are filled in locally on the watch and are not read back off the
/// wire, so we emit the same six camelCase keys
/// (<c>Controllers/Match/MatchController.cs:545-553</c>); the 2023 Utf8Json
/// formatters carry a camelCase variant per key, so both client
/// generations parse it.
///
/// The event itself only supplies the room to browse: an event is pinned to
/// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body
/// (persisted in <see cref="EventExtras"/>). Sub-room hops allocate their
/// own instance ids and photon rooms, so when the event names a sub-room we
/// only surface instances sitting in it.</summary>
[AllowAnonymous]
[HttpGet("/event/{eventId:long}/instances")]
public async Task<IActionResult> EventInstances(long eventId)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
// instances are merged in so the host can still see the instance they
// created before anyone has joined it. Another player's private
// PhotonRoomId is never listed.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List<PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid)
.ToListAsync();
var byKey = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId))
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
if (subRoomId is long want && room.SubRoomId != want) continue;
byKey[room.PhotonRoomId] = new
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
// Presence rows are a TTL cache with no creation timestamp —
// there is no stored "instance opened at" for a live instance,
// so report the observation time. Private-instance rows below
// do carry a real CreatedAt and use it.
createdAt = DateTime.UtcNow,
playerIds = playerIds.Select(p => (int)p).ToArray(),
};
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
if (subRoomId is long want && p.SubRoomId != want) continue;
byKey[p.PhotonRoomId] = new
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(),
};
}
return Ok(byKey.Values.ToList());
}
// ── Wire shape ───────────────────────────────────────────────────────
/// <summary>The PlayerEvent fields the 2023 client carries that
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
/// side-table: one JSON row per event under
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
/// row that keeps club lookups a single indexed query.
///
/// Public (not private) purely so System.Text.Json's reflection-based
/// (de)serialiser can see the type and its accessors.</summary>
public sealed class EventExtras
{
public long? SubRoomId { get; set; }
public long? ClubId { get; set; }
public string ImageName { get; set; } = string.Empty;
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
/// recoverable from the ISIL (enums carry no method bodies), so events
/// that never had accessibility set keep the historical default of 1
/// rather than guessing a new one; once the client PUTs a value we
/// echo exactly what it sent.</summary>
public int Accessibility { get; set; } = 1;
public bool IsMultiInstance { get; set; }
public bool SupportMultiInstanceRoomChat { get; set; }
public int DefaultBroadcastPermissions { get; set; }
public int CanRequestBroadcastPermissions { get; set; }
public long? BroadcastingRoomInstanceId { get; set; }
public List<string> Tags { get; set; } = [];
}
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
/// <summary>Wire shape verified against the client's Utf8Json formatter
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
/// IsMultiInstance, SupportMultiInstanceRoomChat,
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
private static Dictionary<string, object?> ToWire(
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
{
["PlayerEventId"] = ev.Id,
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
["RoomId"] = ev.RoomId,
["SubRoomId"] = extras.SubRoomId,
["ClubId"] = extras.ClubId,
["Name"] = ev.Title,
["Description"] = ev.Description,
["ImageName"] = extras.ImageName,
["StartTime"] = ev.StartsAt,
["EndTime"] = ev.EndsAt,
["AttendeeCount"] = attendeeCount,
["Accessibility"] = extras.Accessibility,
["IsMultiInstance"] = extras.IsMultiInstance,
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
// formatter skips unknown keys, so emitting Tags unconditionally is
// inert there and saves branching on includeDetails. Type is the
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
};
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
/// <c>JPIKCIGABBI.txt</c>.</summary>
private static object ResponseWire(PlayerEventResponseEntity r) => new
{
PlayerEventResponseId = r.Id,
PlayerEventId = r.EventId,
PlayerId = (int)r.PlayerId,
r.CreatedAt,
Type = r.Response,
};
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
/// </summary>
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
{
PlayerEvent = ToWire(ev, extras, attendeeCount),
Result = 0, // BNCFHOOCHAI success
TagModifyResult = new { Result = 0, Tags = extras.Tags },
};
private async Task<object> WrapperAsync(PlayerEventEntity ev)
{
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
var counts = await AttendeeCountsAsync(new[] { ev.Id });
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
}
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
{
if (rows.Count == 0) return [];
var extras = await LoadExtrasAsync(rows);
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
return rows
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
.ToList();
}
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
IReadOnlyList<PlayerEventEntity> rows)
{
var wire = await ToWireManyAsync(rows);
var map = new Dictionary<long, Dictionary<string, object?>>();
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
return map;
}
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
=> (await ToWireManyAsync(new[] { ev }))[0];
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
{
var map = new Dictionary<long, EventExtras>();
foreach (var ev in events) map[ev.Id] = new EventExtras();
if (map.Count == 0) return map;
var keys = map.Keys.Select(ExtrasKey).ToList();
var rows = await db.PlayerSettings
.AsNoTracking()
.Where(s => keys.Contains(s.Key))
.Select(s => new { s.Key, s.Value })
.ToListAsync();
foreach (var row in rows)
{
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
var parsed = DeserializeExtras(row.Value);
if (parsed is not null) map[id] = parsed;
}
return map;
}
private static long? EventIdFromKey(string key)
{
var parts = key.Split(':');
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
}
private static EventExtras? DeserializeExtras(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
return JsonSerializer.Deserialize<EventExtras>(json);
}
catch (JsonException)
{
return null;
}
}
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
{
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
// long one can never truncate the JSON blob into unparseable garbage.
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
JsonSerializer.Serialize(extras));
// Flat index row so "events for club X" stays one indexed lookup
// instead of a scan through the JSON blobs.
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private async Task UpsertSettingAsync(long playerId, string key, string value)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = value,
});
}
else
{
row.Value = value;
}
}
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
{
if (eventIds.Count == 0) return new Dictionary<long, int>();
var ids = eventIds.ToList();
var rows = await db.PlayerEventResponses
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
.GroupBy(r => r.EventId)
.Select(g => new { EventId = g.Key, Count = g.Count() })
.ToListAsync();
return rows.ToDictionary(r => r.EventId, r => r.Count);
}
// ── Query shaping ────────────────────────────────────────────────────
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
/// query-key literal survives in the ISIL — enums have no method bodies to
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
/// the others still returns a valid, non-empty event list rather than an
/// error.</summary>
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
{
var now = DateTime.UtcNow;
return scheduleFilter switch
{
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
_ => rows.Where(e => e.EndsAt > now),
};
}
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
private static IQueryable<PlayerEventEntity> ApplySort(
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
{
1 => rows.OrderByDescending(e => e.CreatedAt),
2 => rows.OrderBy(e => e.EndsAt),
_ => rows.OrderBy(e => e.StartsAt),
};
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
/// nothing carries one we fall back to the historical heuristic of "events
/// in rooms owned by club members" so pre-existing data still populates the
/// tab.</summary>
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
IReadOnlyCollection<long> clubIds, int skip, int take)
{
var explicitIds = await EventIdsForClubsAsync(clubIds);
IQueryable<PlayerEventEntity> q;
if (explicitIds.Count > 0)
{
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
}
else
{
var roomIds = await ClubRoomIdsAsync(clubIds);
if (roomIds.Count == 0) return [];
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
}
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
}
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
{
if (clubIds.Count == 0) return [];
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
var keys = await db.PlayerSettings
.AsNoTracking()
.Where(s => wanted.Contains(s.Value)
&& s.Key.StartsWith("playerevent:")
&& s.Key.EndsWith(":club"))
.Select(s => s.Key)
.ToListAsync();
return keys
.Select(EventIdFromKey)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
}
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
{
var memberships = db.ClubMemberships.AsQueryable();
if (clubIds.Count > 0)
{
var ids = clubIds.ToList();
memberships = memberships.Where(m => ids.Contains(m.ClubId));
}
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
if (memberIds.Count == 0) return [];
return await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
}
// ── Create / edit (v2) ───────────────────────────────────────────────
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
/// StartTime, EndTime, Accessibility, IsMultiInstance,
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
/// registered on this path, so creating an event 405'd.</summary>
[HttpPost("api/playerevents/v2")]
public async Task<IActionResult> CreateV2()
{
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
if (name.Length == 0) return BadRequest("missing_name");
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
var evt = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
Title = Clamp(name, 128),
Description = Clamp(description, 2000),
StartsAt = start,
EndsAt = end > start ? end : start.AddHours(1),
};
db.PlayerEvents.Add(evt);
await db.SaveChangesAsync();
var extras = new EventExtras();
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
/// GET-only, so editing 405'd.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}")]
public async Task<IActionResult> EditV2(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
if (description is not null) evt.Description = Clamp(description, 2000);
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
if (roomId is long room && room > 0) evt.RoomId = room;
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
return Ok(await WrapperAsync(evt));
}
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
/// serves both create and edit.</summary>
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
{
var fields = await ReadBodyAsync();
if (fields.Fields.ContainsKey("SubRoomId"))
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
if (fields.Fields.ContainsKey("ClubId"))
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
extras.Accessibility = accessibility;
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
extras.IsMultiInstance = multi;
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"supportsMultiInstanceRoomChat") is bool chat)
extras.SupportMultiInstanceRoomChat = chat;
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
extras.DefaultBroadcastPermissions = def;
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
extras.CanRequestBroadcastPermissions = req;
var tags = await ReadStringListFieldAsync("Tags", "tags");
if (tags is not null) extras.Tags = tags;
}
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
@@ -145,11 +876,7 @@ public class PlayerEventsController(
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
return Ok(await ToWireOneAsync(ev));
}
public sealed record RsvpRequest(int Response);
@@ -178,25 +905,45 @@ public class PlayerEventsController(
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
// Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here.
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the 2020 watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
/// state. Two client generations share this route with different
/// contracts:
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
/// an object throws InvalidCastException there — see
/// <c>recroom-2020-client-response-contracts.md</c>.
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
/// (<c>GJJGDENPOAP.txt:139-150</c>).
/// Branch the response shape on the body encoding the caller actually
/// used — that is the only signal that distinguishes them.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
public async Task<IActionResult> RespondForm()
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
@@ -204,33 +951,237 @@ public class PlayerEventsController(
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
/// caller's RSVP row. Same two-generation split as
/// <see cref="RespondForm"/>. The 2023 body is
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
/// is name-preserved: <c>public long PlayerEventId</c>
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
public async Task<IActionResult> DeleteResponseForm()
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
var legacyForm = Request.HasFormContentType;
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
return RespondResult(legacyForm, 0 /*Success*/);
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
private IActionResult RespondResult(bool legacyForm, int code)
=> legacyForm ? Ok(code) : Ok(new { Result = code });
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
/// DELETE stays for the 2020 client.</summary>
[HttpPost("api/playerevents/v2/delete/{id:long}")]
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
// Snapshot the wire shape before the rows go away — the wrapper still
// has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
return Ok(Wrapper(evt, extras, attendees));
}
// ── Field mutations (v2) ─────────────────────────────────────────────
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
// fields arrive as x-www-form-urlencoded, and every one returns the
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
// null right after a successful save. POST is kept alongside for the 2020
// client.
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
public async Task<IActionResult> UpdateDescription(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
/// <c>roomId</c> AND an optional <c>subRoomId</c>
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
/// latter meant the event never pointed at the sub-room the host picked.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
public async Task<IActionResult> UpdateTime(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
public async Task<IActionResult> UpdateImage(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
public async Task<IActionResult> UpdateAccessibility(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
/// send form fields — it serialises <c>List&lt;String&gt;</c> straight into
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
/// reader only looked inside JSON objects, so every save stored an empty
/// string and silently cleared the tags. The wrapper's
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
/// </summary>
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
/// sends FOUR form fields — <c>isMultiInstance</c>,
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
/// <c>canRequestBroadcastPermissions</c>
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
/// to look for a field named <c>multiInstance</c>, which the client never
/// sends, so the toggle always stored false and the other three were
/// dropped. Note the request spells it "supports…" while the response key
/// is "Support…" — both spellings are accepted here.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.IsMultiInstance =
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
extras.SupportMultiInstanceRoomChat =
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"SupportMultiInstanceRoomChat") ?? false;
extras.DefaultBroadcastPermissions =
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
extras.CanRequestBroadcastPermissions =
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
/// <c>Nullable&lt;Int64&gt;</c> and boxes it into the single form field
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
/// null therefore arrives as an absent/empty field, which is the client's
/// only way to DETACH the club. Rejecting that with 400 made detaching
/// impossible.</summary>
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is long id && id > 0)
{
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
extras.ClubId = id;
}
else
{
extras.ClubId = null;
}
await SaveExtrasAsync(evt, extras);
return Ok(await WrapperAsync(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
@@ -245,10 +1196,13 @@ public class PlayerEventsController(
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
// ── Bulk-invite ──────────────────────────────────────────────────────
public sealed class BulkInviteRequest
{
@@ -256,12 +1210,17 @@ public class PlayerEventsController(
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
/// defaults. Error paths return the same JSON shape rather than a
/// NotFound()/Forbid() body the client cannot parse.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
@@ -269,8 +1228,11 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
// bodies to disassemble); any non-zero code reads as "failed" on the
// client, which is all the invite sheet branches on.
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
var failed = new List<object>();
var sender = Me;
@@ -280,7 +1242,7 @@ public class PlayerEventsController(
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue;
}
db.Messages.Add(new MessageEntity
@@ -297,7 +1259,47 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
/// room instance an event is being broadcast from. JBHPMONELAO posts
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
/// This used to be implemented as a text-message blast to every RSVP,
/// which both spammed attendees and returned an unparseable body.</summary>
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast()
{
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long id) return BadRequest("missing_event");
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
if (evt is null) return NotFound();
if (evt.CreatorPlayerId != Me) return Forbid();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
// them to re-fetch instead of sending a chat message.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
return Ok(await WrapperAsync(evt));
}
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
{
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
}
// ── Report ───────────────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
@@ -327,4 +1329,213 @@ public class PlayerEventsController(
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
// ── Request parsing ──────────────────────────────────────────────────
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
var body = await ReadBodyAsync();
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
{
if (body.Repeated.TryGetValue(key, out var values))
foreach (var value in values) AddDelimitedIds(ids, value);
}
if (body.RootArray is not null)
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
foreach (var name in names)
if (body.Fields.TryGetValue(name, out var value))
return value;
return null;
}
/// <summary>Reads a list-valued field. Handles all three encodings the
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
/// comma-joined form values.</summary>
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
{
var body = await ReadBodyAsync();
if (body.RootArray is not null) return body.RootArray;
foreach (var name in names)
{
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
if (!body.Fields.TryGetValue(name, out var raw)) continue;
raw = raw.Trim();
if (raw.Length == 0) return new List<string>();
if (raw.StartsWith('['))
{
var parsed = ParseJsonStringArray(raw);
if (parsed is not null) return parsed;
}
return raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
return null;
}
private static List<string>? ParseJsonStringArray(string raw)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
return ElementToStrings(doc.RootElement);
}
catch (JsonException)
{
return null;
}
}
private static List<string> ElementToStrings(JsonElement array)
{
var list = new List<string>();
foreach (var item in array.EnumerateArray())
{
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
}
return list;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
if (bool.TryParse(value, out var parsed)) return parsed;
// Form-encoded booleans arrive as "True"/"False" from the client's
// boxed-bool ToString, but a JSON body may carry 1/0.
return value switch { "1" => true, "0" => false, _ => null };
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
// DateTime.ToString() on an already-UTC value). Force the parse to land
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
? parsed
: null;
}
/// <summary>Everything the request carries, read once and cached — the body
/// stream can only be consumed a single time.</summary>
private sealed class ParsedBody
{
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Set when the whole body is a bare JSON array — the shape
/// the tags PUT sends.</summary>
public List<string>? RootArray { get; set; }
}
private const string BodyItemKey = "__playerevent_body";
private async Task<ParsedBody> ReadBodyAsync()
{
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
return existing;
var body = new ParsedBody();
foreach (var pair in Request.Query)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
{
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
}
}
else if ((Request.ContentLength ?? 0) > 0)
{
// Read the raw text rather than gating on Content-Type: the client's
// RawJsonForm does set application/json, but the JSON path is also
// the fallback for anything else that isn't a form.
using var reader = new StreamReader(
Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var raw = await reader.ReadToEndAsync();
try
{
using var doc = JsonDocument.Parse(raw);
switch (doc.RootElement.ValueKind)
{
case JsonValueKind.Object:
foreach (var prop in doc.RootElement.EnumerateObject())
{
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
if (prop.Value.ValueKind == JsonValueKind.Array)
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
}
break;
case JsonValueKind.Array:
body.RootArray = ElementToStrings(doc.RootElement);
break;
}
}
catch (JsonException)
{
// Empty or non-JSON body → query/form values only.
}
}
HttpContext.Items[BodyItemKey] = body;
return body;
}
}
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
@@ -202,7 +202,7 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new { success = true });
if (pid is not long me) return HileResult();
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
@@ -215,7 +215,22 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
await db.SaveChangesAsync();
return Ok(new { success = true });
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
}
return HileResult();
}
private static IActionResult HileResult() => new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = StatusCodes.Status200OK,
};
}
@@ -29,13 +29,14 @@ namespace DorkNet.Server.Controllers.API.PlayerState;
[ApiController]
public class PlayerStateController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level) : ControllerBase
{
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
[HttpGet("api/players/v2/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId)
{
var p = await db.Players
@@ -51,12 +52,25 @@ public class PlayerStateController(
}
[HttpPost("api/players/v1/progression/bulk")]
[HttpPost("api/players/v2/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
if (idList.Count == 0) return Ok(new List<Progression>());
return Ok(await BuildProgressionBulkAsync(idList));
}
[HttpGet("api/players/v2/progression/bulk")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildProgressionBulkAsync(idList));
}
private async Task<List<Progression>> BuildProgressionBulkAsync(IReadOnlyCollection<long> idList)
{
if (idList.Count == 0) return new List<Progression>();
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
@@ -72,26 +86,40 @@ public class PlayerStateController(
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return Ok(rows);
return rows;
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
[HttpGet("api/playerReputation/v2/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[HttpPost("api/playerReputation/v2/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
[HttpGet("api/playerReputation/v2/bulk")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulkV2Get()
{
var idList = ParseQueryIds().ToList();
return Ok(await BuildReputationBulkAsync(idList));
}
private async Task<List<Reputation>> BuildReputationBulkAsync(IReadOnlyCollection<long> idList)
{
var result = new List<Reputation>(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return Ok(result);
return result;
}
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
@@ -122,9 +150,10 @@ public class PlayerStateController(
// Reward the cheered player with a small XP bump.
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = me, Type = type });
// Real PlayerCheer notification (persisted + pushed as a Message),
// replacing the old blank-account SubscriptionUpdateProfile misuse.
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: me);
return Ok();
}
@@ -216,6 +245,23 @@ public class PlayerStateController(
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
private IEnumerable<long> ParseQueryIds()
{
foreach (var (key, values) in Request.Query)
{
if (long.TryParse(key, out var keyId) && keyId > 0)
yield return keyId;
foreach (var value in values)
{
if (long.TryParse(value, out var valueId) && valueId > 0)
yield return valueId;
foreach (var part in value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var partId) && partId > 0)
yield return partId;
}
}
}
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
@@ -20,8 +20,7 @@ namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
[Route("api/[controller]/v1")]
[Authorize]
public class PlayerSubscriptionsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
@@ -45,6 +44,51 @@ public class PlayerSubscriptionsController(
return Ok(subs);
}
/// <summary>GET <c>/subscription/top/creators/today</c> — the
/// subscriptions tab's "top creators" carousel. Client contract
/// (RecNet.Runtime <c>JDBPOMCAIIG()</c>) is a bare
/// <c>List&lt;creator&gt;</c> of <c>{ accountId:int, subscriberCount:long,
/// … }</c>. Ranks creators by total subscriber count. The leading "/"
/// makes this route absolute, bypassing the class's
/// <c>api/[controller]/v1</c> prefix.</summary>
[HttpGet("/subscription/top/creators/today")]
[AllowAnonymous]
public async Task<ActionResult> TopCreatorsToday([FromQuery] int? take)
{
var limit = Math.Clamp(take ?? 20, 1, 100);
var top = await db.Subscriptions
.GroupBy(s => s.TargetPlayerId)
.Select(g => new { AccountId = g.Key, SubscriberCount = (long)g.Count() })
.OrderByDescending(x => x.SubscriberCount)
.Take(limit)
.ToListAsync();
// A day-window boundary for "today"'s new-subscriber tallies.
var since = DateTime.UtcNow.Date;
var todayByTarget = await db.Subscriptions
.Where(s => s.CreatedAt >= since)
.GroupBy(s => s.TargetPlayerId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
// Dictionaries preserve literal key casing (anonymous objects would be
// forced to camelCase by the server policy); the creator DTO's keys
// are obfuscated in the dump, so emit both cases to be safe.
return Ok(top.Select(c =>
{
var newToday = todayByTarget.GetValueOrDefault(c.AccountId);
return new Dictionary<string, object?>
{
["AccountId"] = (int)c.AccountId,
["accountId"] = (int)c.AccountId,
["SubscriberCount"] = c.SubscriberCount,
["subscriberCount"] = c.SubscriberCount,
["NewSubscribersToday"] = newToday,
["newSubscribersToday"] = newToday,
};
}).ToList());
}
[HttpPost("subscribe/{targetId:long}")]
public async Task<ActionResult> Subscribe(long targetId)
{
@@ -61,11 +105,10 @@ public class PlayerSubscriptionsController(
});
await db.SaveChangesAsync();
// Tell the target their subscriber count changed so the watch
// refreshes their profile page if open.
await notifications.NotifyAsync(targetId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "NewSubscriber", From = Me });
// No real-time push: no client message type for new subscribers, and
// the old SubscriptionUpdateProfile {Reason:"NewSubscriber"} blob
// decoded to a blank account ("Orphan player account (0)") without
// refreshing anything. The subscriber count refreshes on next fetch.
return Ok();
}
@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayerWarnings;
[ApiController]
[Authorize]
public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playerwarnings")]
public async Task<IActionResult> Warnings()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows
.Select(ToWarningWire)
.Where(w => !w.Acknowledged)
.ToList());
}
/// <summary>Moderator issues a warning against a player. The 2023-03-21
/// client POSTs this (verb register <c>rdx=2</c> at
/// RecNet.Runtime/FPIBGPIAOBI.txt:3455) with the form keys
/// <c>WarnedPlayerId</c>, <c>ReportCategory</c>, <c>DisplayReason</c> and
/// <c>ModeratorNote</c> (:3468-3520), matching the issuing method
/// <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt; OEJIMKKDMBI(Int32, BDOGOIGCKMK,
/// String, String)</c> (:3102).
///
/// Only GET was registered here, so every warning the moderator tools
/// tried to create came back 405 and silently failed.</summary>
[HttpPost("api/playerwarnings")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> CreateWarning(
[FromForm(Name = "WarnedPlayerId")] long? warnedPlayerId,
[FromForm(Name = "ReportCategory")] int? reportCategory,
[FromForm(Name = "DisplayReason")] string? displayReason,
[FromForm(Name = "ModeratorNote")] string? moderatorNote)
{
var me = this.RequireCurrentPlayerId();
var moderator = await db.Players
.Where(p => p.Id == me)
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
.FirstOrDefaultAsync();
if (moderator != true)
return Ok(new { Success = false, Message = "not_authorized" });
var target = warnedPlayerId
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
if (!await db.Players.AnyAsync(p => p.Id == target))
return Ok(new { Success = false, Message = "player_not_found" });
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
// GET list handler parses in ToWarningWire.
var reason = (displayReason ?? string.Empty).Trim();
var note = (moderatorNote ?? string.Empty).Trim();
var message = string.IsNullOrEmpty(note) ? reason : $"{reason} ({note})";
var id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
{
PlayerId = target,
Key = $"warning:{id}",
Value = string.Join('|',
message.Replace('|', '/'),
(reportCategory ?? 0).ToString(),
DateTime.UtcNow.ToString("O"),
"false"),
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
}
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
/// the issuing method is <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt; DOFADJPFBMK()</c>
/// (FPIBGPIAOBI.txt:3524) and it POSTs an empty body (:3590-3592). It means
/// "acknowledge my pending warning".
///
/// Demanding a warningId made every call 400, so the modal was never marked
/// read and re-appeared on each login. With no id supplied we now
/// acknowledge the newest unacknowledged warning, and acknowledging nothing
/// is a success rather than an error.</summary>
[HttpPost("api/playerwarnings/acknowledge")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
{
var me = this.RequireCurrentPlayerId();
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
DorkNet.Server.Data.Entities.PlayerSettingEntity? row;
if (string.IsNullOrWhiteSpace(id))
{
// Acknowledged state lives inside the packed Value string, so the
// filter has to run client-side after materialising.
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
if (row is null) return Ok(new { Success = true, Message = string.Empty });
}
else
{
row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return Ok(new { Success = true, Message = string.Empty });
}
var parts = row.Value.Split('|');
row.Value = string.Join('|', parts.Take(3).Append("true"));
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
}
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new WarningWire
{
WarningId = row.Key["warning:".Length..],
Message = parts.ElementAtOrDefault(0) ?? string.Empty,
Category = int.TryParse(parts.ElementAtOrDefault(1), out var category) ? category : 0,
CreatedAt = parts.ElementAtOrDefault(2) ?? string.Empty,
Acknowledged = bool.TryParse(parts.ElementAtOrDefault(3), out var acknowledged) && acknowledged,
};
}
private sealed class WarningWire
{
public string WarningId { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public int Category { get; set; }
public string CreatedAt { get; set; } = string.Empty;
public bool Acknowledged { get; set; }
}
}
@@ -21,6 +21,23 @@ public class PlayersController(PlayerService playerService, NotificationService
return Ok(PlayerService.ToProfile(player));
}
// The 2023 client PUTs this right after setting an email; GET-only meant a
// 405 on every contact-preferences write.
[HttpPut("/api/players/v4/current/contact")]
[HttpGet("/api/players/v4/current/contact")]
public async Task<IActionResult> CurrentContact()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(new
{
AccountId = (int)player.Id,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
CanReceiveInvites = player.CanReceiveInvites,
});
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
@@ -37,20 +37,30 @@ public class PlaylistsController(
// ── Create / delete ──────────────────────────────────────────────
public sealed class CreatePlaylistRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
}
/// <summary>POST <c>/playlists</c> — create. The 2023-03-21 client posts a
/// form with a single field <c>name</c> (verb 2 at
/// <c>NLDBPDCNNCF.txt:11139-11140</c>, field literal in
/// <c>NLDBPDCNNCF_NestedType_NEECJOENHPI.txt</c> — its only
/// <c>Move rdx, "…"</c> is <c>"name"</c>). The old non-optional
/// <c>[FromBody]</c> parameter made <c>[ApiController]</c> reject the
/// form content type with 415 before the action ran, so creating a
/// playlist from the watch was impossible. Fields are read from form,
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
public async Task<IActionResult> Create()
{
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
var name = await ReadFieldAsync("name", "Name");
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" });
var description = await ReadFieldAsync("description", "Description");
var imageName = await ReadFieldAsync("imageName", "ImageName");
var tags = await ReadFieldsAsync("tag", "Tag", "tags", "Tags");
var tagsCsv = tags.Count > 0
? string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase))
: null;
var created = await playlists.CreateAsync(Me, name.Trim(), description, imageName, tagsCsv);
return await BuildDetailsResponseAsync(created.Id);
}
@@ -68,61 +78,206 @@ public class PlaylistsController(
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
public sealed class StringFieldRequest { public string? Value { get; set; } }
public sealed class IntFieldRequest { public int? Value { get; set; } }
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
// The 2023-03-21 client sends these as form-urlencoded PUTs. A
// non-optional [FromBody] parameter made [ApiController] demand JSON and
// reject every one with 415 before the action ran, so renaming, re-imaging,
// re-tagging and publishing a playlist all failed silently. Values are now
// read from form, query or JSON body, whichever arrived.
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
return await ApplyMutation(playlistId, p => p.Name = (value ?? p.Name).Trim());
}
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
return await ApplyMutation(playlistId, p => p.Description = value ?? p.Description);
}
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
return await ApplyMutation(playlistId, p => p.ImageName = value ?? p.ImageName);
}
/// <summary>The client sends REPEATED form fields <c>tag</c> and
/// <c>autoTag</c>, not a single CSV <c>Tags</c> value.</summary>
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
return await ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
if (tags.Count > 0)
p.TagsCsv = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
});
}
/// <summary>Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.</summary>
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPut("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
public Task<IActionResult> PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
"accessibility", "Accessibility", "visibility", "Visibility", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(value, out var v)) p.Accessibility = v;
});
}
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(value, out var v)) p.SupportsLevelVoting = v;
});
}
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
var screens = await ReadFieldAsync("supportsScreens", "SupportsScreens");
var teleport = await ReadFieldAsync("supportsTeleportVR", "SupportsTeleportVR");
var walk = await ReadFieldAsync("supportsWalkVR", "SupportsWalkVR");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(juniors, out var j)) p.SupportsJuniors = j;
if (bool.TryParse(screens, out var c)) p.SupportsScreens = c;
if (bool.TryParse(teleport, out var t)) p.SupportsTeleportVR = t;
if (bool.TryParse(walk, out var w)) p.SupportsWalkVR = w;
});
}
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
var custom = await ReadFieldAsync("customWarning", "CustomWarning");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(mask, out var m)) p.WarningMask = m;
if (custom is not null) p.CustomWarning = custom.Length > 512 ? custom[..512] : custom;
});
}
/// <summary>First value present under any of the given names, looking at
/// the form, the query string, then a JSON body. Null means "not sent",
/// which every caller treats as "leave this field alone".</summary>
private async Task<string?> ReadFieldAsync(params string[] names)
{
var all = await ReadFieldsAsync(names);
return all.Count > 0 ? all[0] : null;
}
/// <summary>Every value present under any of the given names — the client
/// repeats a field name to send a list.</summary>
private async Task<List<string>> ReadFieldsAsync(params string[] names)
{
var found = new List<string>();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var n in names)
foreach (var v in form[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
}
foreach (var n in names)
foreach (var v in Request.Query[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
if (found.Count > 0) return found;
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
var root = doc.RootElement;
// The tags endpoint sends a bare JSON array of strings.
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in root.EnumerateArray())
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
found.Add(el.GetString()!);
return found;
}
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var n in names)
{
if (!root.TryGetProperty(n, out var v)) continue;
if (v.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in v.EnumerateArray()) found.Add(el.ToString());
}
else if (v.ValueKind != System.Text.Json.JsonValueKind.Null)
{
found.Add(v.ToString());
}
if (found.Count > 0) break;
}
}
}
catch (System.Text.Json.JsonException) { /* no usable body */ }
}
return found;
}
/// <summary>KMKPEOGJDFK union entry with the per-playlist settings this
/// controller persists spliced back in.
/// <see cref="RoomsController.BuildPlaylistUnionEntry"/> still emits
/// hardcoded defaults for exactly the fields the client mutates here
/// (<c>Accessibility</c>=1, <c>WarningMask</c>=0,
/// <c>SupportsLevelVoting</c>=false, <c>Supports*</c>=true), so a creator
/// who published or restricted a playlist got the default echoed straight
/// back and the watch's toggle snapped to its old position. Key names are
/// the client's own setter fields —
/// <c>NLDBPDCNNCF_NestedType_{BHAHEJIEIMF,PJFBAKCHIHI,GNCDFBNCADM,AIDDFKACPLN}.txt</c>
/// — matched to the PascalCase read keys at
/// <c>MKAMHOIHOJK.txt:516-612</c>.</summary>
private static IDictionary<string, object> UnionEntry(PlaylistEntity p)
{
var entry = RoomsController.BuildPlaylistUnionEntry(p);
entry["Accessibility"] = p.Accessibility;
entry["WarningMask"] = p.WarningMask;
entry["CustomWarning"] = p.CustomWarning;
entry["SupportsLevelVoting"] = p.SupportsLevelVoting;
entry["SupportsJuniors"] = p.SupportsJuniors;
entry["SupportsScreens"] = p.SupportsScreens;
entry["SupportsTeleportVR"] = p.SupportsTeleportVR;
entry["SupportsWalkVR"] = p.SupportsWalkVR;
return entry;
}
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
@@ -205,7 +360,7 @@ public class PlaylistsController(
public async Task<IActionResult> CreatedByMe()
{
var rows = await playlists.CreatedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
return Ok(rows.Select(UnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
@@ -213,7 +368,7 @@ public class PlaylistsController(
public async Task<IActionResult> CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
return Ok(rows.Select(UnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
@@ -221,36 +376,122 @@ public class PlaylistsController(
public async Task<IActionResult> FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
return Ok(rows.Select(UnionEntry).ToList());
}
/// <summary>GET <c>/playlists/visitedby/me</c> — we don't track
/// per-player playlist visits yet (visits are tracked at the Room
/// level via RoomVisitEntity). Return empty so the watch's
/// playlists tab renders cleanly; populated once we add a
/// PlaylistVisitEntity or derive from member-room visits.</summary>
/// <summary>GET <c>/playlists/visitedby/me</c> — bare
/// <c>List&lt;CNIPGJIIFJF&gt;</c> (return type at
/// <c>NLDBPDCNNCF.txt:4312</c>, URL at <c>:4355</c>).
///
/// We have no PlaylistVisitEntity, but a playlist is nothing but an
/// ordered set of rooms, so "visited" is derivable exactly: the player
/// has visited a playlist iff they've visited any of its member rooms.
/// Ordered by the most recent member-room visit so the tab reads as a
/// history list. Previously hardcoded to an empty array, which left the
/// watch's recently-visited playlists tab permanently blank.
///
/// Aggregation happens in memory rather than in SQL: a single player's
/// RoomVisits row set is small, and this keeps the query provider-neutral
/// (SQLite in dev, Postgres in prod).</summary>
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
public async Task<IActionResult> VisitedByMe()
{
var me = Me;
var visits = await db.RoomVisits
.Where(v => v.PlayerId == me)
.Select(v => new { v.RoomId, v.LastVisitAt })
.ToListAsync();
if (visits.Count == 0) return Ok(Array.Empty<object>());
var visitedRoomIds = visits.Select(v => v.RoomId).Distinct().ToList();
var links = await db.PlaylistRooms
.Where(l => visitedRoomIds.Contains(l.RoomId))
.Select(l => new { l.PlaylistId, l.RoomId })
.ToListAsync();
if (links.Count == 0) return Ok(Array.Empty<object>());
var lastVisitByRoom = visits
.GroupBy(v => v.RoomId)
.ToDictionary(g => g.Key, g => g.Max(v => v.LastVisitAt));
var orderedIds = links
.GroupBy(l => l.PlaylistId)
.Select(g => new { PlaylistId = g.Key, LastVisitAt = g.Max(l => lastVisitByRoom[l.RoomId]) })
.OrderByDescending(x => x.LastVisitAt)
.Select(x => x.PlaylistId)
.ToList();
// BulkAsync preserves caller order, so the recency sort survives.
var rows = await playlists.BulkAsync(orderedIds);
return Ok(rows.Select(UnionEntry).ToList());
}
// ── Bulk lookup ──────────────────────────────────────────────────
/// <summary>GET <c>/playlists/bulk?id=A&amp;id=B</c> — bulk-by-id
/// lookup for the watch's playlist cache warmup. Bare list of
/// KMKPEOGJDFK entries, one per resolved id (missing ids
/// silently dropped).</summary>
/// <summary>
/// <c>playlists/bulk</c> — bulk hydration for the watch's playlist cache.
/// The client has TWO overloads on this one URL, and both pick their verb
/// at runtime:
/// <list type="bullet">
/// <item>by id — <c>NLDBPDCNNCF.txt:3408-3597</c>; repeated field
/// <c>id</c> (<c>NLDBPDCNNCF_NestedType_IOAECIJDKDD.txt:64</c>);
/// <c>Move rsi, 2</c> then <c>cmovl esi,ecx</c> against 100 at
/// <c>:3579-3581</c> — GET below 100 ids, POST at 100+.</item>
/// <item>by name — <c>NLDBPDCNNCF.txt:3624-3813</c>; repeated field
/// <c>name</c> (<c>NLDBPDCNNCF_NestedType_MOBALIODDDF.txt:64</c>); same
/// cmov against 64 at <c>:3794-3797</c> — GET below 64 names, POST at
/// 64+.</item>
/// </list>
/// GET-only meant 405 on every large cache warmup, and query-id-only meant
/// the by-name overload silently resolved nothing. Both verbs and both key
/// sets are accepted here; the response is a bare
/// <c>List&lt;CNIPGJIIFJF&gt;</c> either way (return type at
/// <c>NLDBPDCNNCF.txt:3408</c>), missing entries silently dropped.
/// </summary>
[HttpGet("/playlists/bulk")]
[HttpPost("/playlists/bulk")]
public async Task<IActionResult> Bulk()
{
var ids = Request.Query["id"].Concat(Request.Query["ids"])
.SelectMany(v => (v ?? string.Empty).Split(',',
var ids = (await ReadFieldsAsync("id", "ids", "Id", "Ids"))
.SelectMany(v => v.Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await playlists.BulkAsync(ids);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
// Names are NOT comma-split — the client repeats the field once per
// name, and playlist names may legally contain commas.
var names = (await ReadFieldsAsync("name", "names", "Name", "Names"))
.Select(v => v.Trim())
.Where(v => v.Length > 0)
.ToList();
if (ids.Count == 0 && names.Count == 0) return Ok(Array.Empty<object>());
var results = new List<PlaylistEntity>();
var seen = new HashSet<long>();
if (ids.Count > 0)
foreach (var p in await playlists.BulkAsync(ids))
if (seen.Add(p.Id)) results.Add(p);
if (names.Count > 0)
{
var lowered = names.Select(s => s.ToLowerInvariant()).Distinct().ToList();
var rows = await db.Playlists
.Where(e => lowered.Contains(e.Name.ToLower()))
.ToListAsync();
var byName = new Dictionary<string, PlaylistEntity>(StringComparer.OrdinalIgnoreCase);
foreach (var r in rows) byName.TryAdd(r.Name, r);
foreach (var wanted in names)
if (byName.TryGetValue(wanted, out var hit) && seen.Add(hit.Id)) results.Add(hit);
}
return Ok(results.Select(UnionEntry).ToList());
}
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
@@ -296,7 +537,7 @@ public class PlaylistsController(
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
var result = RoomsController.BuildPlaylistUnionEntry(p);
var result = UnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
@@ -0,0 +1,459 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController]
public class ProgressionEventsController(
DorkNetDbContext db,
ServerSettingsService serverSettings,
IConfiguration config) : ControllerBase
{
/// <summary>CurrencyType enum value the rest of the server uses for
/// RecCenter tokens (mirrors <c>ProgressionController.RecCenterTokens</c>).</summary>
private const int RecCenterTokens = 2;
[HttpGet("api/progressionEvents")]
[Authorize]
public async Task<IActionResult> Progress()
{
var me = this.RequireCurrentPlayerId();
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("progressionEvent:"))
.OrderByDescending(o => o.ClearedAt)
.Take(100)
.Select(o => new
{
EventKey = o.Key,
o.IsCompleted,
o.ClearedAt,
})
.ToListAsync();
return Ok(rows);
}
[HttpGet("api/progressionEvents/event/{eventId:long}")]
[AllowAnonymous]
public async Task<IActionResult> Event(long eventId)
{
var activeId = CurrentProgressionEventId();
if (eventId != activeId)
return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var (start, end) = CurrentWeekWindow();
// The boost fields must agree with api/progressionEvents/{id}/xpboosts:
// ProgressionEventsPurchasableXpBoostModel.Set() takes the guid the
// event advertises here and looks it up in the xpboosts list, and
// ProgressionEventsModel.GetPurchasableXpBoostId() gates the whole
// boost sheet on PurchasableXpBoostId being non-null. Deriving both
// from one catalogue keeps them from drifting.
var boosts = BoostCatalogue(activeId);
var headline = boosts.Count > 0 ? boosts[0] : null;
return Ok(new
{
ProgressionEventId = activeId,
Name = "Weekly Progression",
Rewards = BuildRewards(weekly.Reward, activeId),
KeepsakeRoomLists = Array.Empty<object>(),
StartTime = start,
EndTime = end,
CollectionEndTime = end,
UsesBoost = headline is not null,
BoostDailyGameplayMinutesLimit = 0,
// ProgressionEventDTO.BoostXpMultiplier is a float at +0x50
// (dump.cs:1198957) — emit a real number, not an integer token.
BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1),
PurchasableXpBoostId = headline?.Id,
ActiveExperiment = string.Empty,
ChallengesIconImageName = string.Empty,
RewardsPipImageName = string.Empty,
EventInfoImageName = string.Empty,
});
}
[HttpGet("api/progressionEvents/active")]
[AllowAnonymous]
public IActionResult Active() => Ok(CurrentProgressionEventId());
/// <summary>
/// The current player's progress record for a given progression event.
/// The 2023 client fetches this during InitialRoomLoad
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
/// with a NullReferenceException and traps the client in a dorm
/// matchmaking loop, so we always return a non-null record.
/// </summary>
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
[Authorize]
public async Task<IActionResult> Record(long progressionEventId)
{
var me = this.RequireCurrentPlayerId();
// Count the reward-claim sentinels this player owns FOR THIS EVENT
// (written by Collect below). The previous query counted every
// `progressionEvent:` row, which also matches the xpBoost purchase
// sentinels StorefrontsBuyController writes — a boost purchase
// silently advanced the claimed-reward pip.
var claimPrefix = RewardClaimPrefix(progressionEventId);
var collected = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == me
&& o.IsCompleted
&& o.Key.StartsWith(claimPrefix));
return Ok(new
{
ProgressionEventId = progressionEventId,
Xp = 0,
ClaimedRewardIndex = collected > 0 ? collected - 1 : -1,
PurchasedXpBoostCount = 0,
DailyBoostGameplayMinutes = 0,
XpBoostExpiresAt = (DateTime?)null,
});
}
// ── Reward collection ────────────────────────────────────────────────
/// <summary>
/// POST <c>api/progressionEvents/collect/{eventId}/{rewardIndex}</c> —
/// claim one reward chest off the progression track.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1208</c>
/// (<c>GKANPKEGBGE(Int64 eventId, Int32 rewardIndex)</c>) formats
/// <c>"{0}/collect/{1}/{2}"</c> against <c>"api/progressionEvents"</c>
/// (ISIL 041/043) and moves <c>2</c> = <c>HTTPMethods.Post</c> into the
/// verb argument at ISIL 055. No request body is passed — the two route
/// segments are the whole request.
///
/// The return type is <c>FGLDKEJLAKB&lt;FFFIMAGLKEG/FHMABOHAEED&gt;</c>,
/// and <c>FHMABOHAEED</c> is the client's GiftPackage DTO — the very
/// same object <c>GiftManager.RunConsumeGift</c> /
/// <c>GiftManager.DequeueGift</c> take
/// (<c>Assembly-CSharp/GiftManager.txt:3226,3395</c>), whose wire keys are
/// Id / FromPlayerId / ConsumableItemDesc / AvatarItemType /
/// AvatarItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
/// CurrencyType / Currency / Xp / GiftContext / GiftRarity / Message /
/// Platform / PlatformsToSpawnOn / BalanceType
/// (<c>RecNet.Runtime/FKANCKLCCDI.txt:1051-1390</c>).
///
/// That means the client will POST the returned <c>Id</c> back to
/// <c>api/avatar/v2/gifts/consume</c>, so this MUST persist a real
/// <see cref="GiftPackageEntity"/> rather than synthesise a wire blob —
/// an unpersisted Id 404s on consume and the player loses the reward.
/// The payload itself comes from the admin-configured weekly reward
/// (<see cref="WeeklyChallengeReward"/>), i.e. exactly what
/// <see cref="Event"/> advertises on the track, so the chest hands over
/// what the UI promised. XP / tokens / level ride on the package and are
/// granted by the existing consume path, not here — granting in both
/// places would double-pay.
///
/// Idempotent: the claim sentinel is an <see cref="ObjectiveProgressEntity"/>
/// whose Key embeds the created gift id, so a retried or duplicated POST
/// returns the original package instead of minting a second one.
/// No <c>GiftPackageReceived</c> push is sent (unlike
/// <c>avatar/v2/gifts/generate</c>): the RRUI reward flow already spawns
/// the box from this response, and a push would spawn a second one.
/// </summary>
[HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")]
[Authorize]
public async Task<IActionResult> Collect(long eventId, int rewardIndex)
{
var me = this.RequireCurrentPlayerId();
// Only the live event's track is claimable — the client only ever
// renders the active event, and letting an arbitrary id through
// would let a replayed request re-roll last week's chest.
var activeId = CurrentProgressionEventId();
if (eventId != activeId) return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var rewards = BuildRewards(weekly.Reward, activeId);
if (rewardIndex < 0 || rewardIndex >= rewards.Count) return NotFound();
// Already claimed? The sentinel key carries the gift id so we can
// hand back the same package (the client may retry after a dropped
// response, and it needs a consumable Id either way).
var sentinelPrefix = $"{RewardClaimPrefix(eventId)}{rewardIndex}:gift:";
var sentinel = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key.StartsWith(sentinelPrefix));
if (sentinel is not null)
{
if (long.TryParse(sentinel.Key[sentinelPrefix.Length..], out var priorId))
{
var prior = await db.GiftPackages
.FirstOrDefaultAsync(g => g.Id == priorId && g.RecipientPlayerId == me);
if (prior is not null)
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(prior));
}
// Sentinel exists but its package was purged — drop the stale
// marker and fall through to re-issue, otherwise the player is
// permanently locked out of a reward they never received.
db.ObjectiveProgress.Remove(sentinel);
await db.SaveChangesAsync();
}
var reward = weekly.Reward;
var gift = new GiftPackageEntity
{
RecipientPlayerId = me,
FromPlayerId = null,
// The weekly reward config carries a raw avatar desc, which is
// always an outfit item (AvatarItemType 0); hair dyes are
// configured through ConsumableItemDesc instead. Leave the type
// null when there is no avatar payload so the client doesn't
// render an empty outfit tile.
AvatarItemType = string.IsNullOrWhiteSpace(reward.AvatarItemDesc) ? (int?)null : 0,
AvatarItemDescOrHairDyeDesc = reward.AvatarItemDesc,
ConsumableItemDesc = reward.ConsumableItemDesc,
EquipmentPrefabName = reward.EquipmentPrefabName,
EquipmentModificationGuid = reward.EquipmentModificationGuid,
CurrencyType = reward.Tokens > 0 ? RecCenterTokens : 0,
Currency = reward.Tokens,
Xp = reward.Xp,
Level = reward.Level,
GiftContext = reward.GiftContext,
GiftRarity = reward.GiftRarity,
Message = $"Progression event reward {rewardIndex + 1}",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
// The sentinel key needs the generated gift id, so this takes two
// saves — but they must commit together: a crash between them would
// leave a gift with no sentinel, and the next Collect would mint the
// reward a second time.
await using var tx = await db.Database.BeginTransactionAsync();
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = me,
Key = $"{sentinelPrefix}{gift.Id}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
await tx.CommitAsync();
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
}
// ── Purchasable XP boosts ────────────────────────────────────────────
/// <summary>
/// GET <c>api/progressionEvents/{eventId}/xpboosts</c> — the boost
/// offers the client's purchase sheet renders.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1465</c>
/// (<c>HKMBEFABIBD(Int64 eventId)</c>) formats
/// <c>"{0}/{1}/xpboosts"</c> against <c>"api/progressionEvents"</c>
/// (ISIL 026/028) and moves <c>0</c> = <c>HTTPMethods.Get</c> into the
/// verb argument at ISIL 034. The declared return type is
/// <c>List&lt;ProgressionEventPurchasableXpBoostDTO&gt;</c>, i.e. a bare
/// JSON array — no paged container.
///
/// Element keys and their order come from the generated Utf8Json
/// formatter <c>RecNet.Runtime/OFGIPBHIAGF.txt:605-698</c>:
/// ProgressionEventPurchasableXpBoostId (Guid), Cost (Int32),
/// XpMultiplier (Int32 — <c>get_XpMultiplierFromBoost</c> reads the DTO's
/// +0x24 int field, NOT a float), XpCap (Int32), LookbackDurationTicks
/// (Int64), CooldownDurationTicks (Int64).
///
/// The boost id must be STABLE across calls: the client caches it from
/// here, passes it to <c>previewEarnedXp</c>, and posts it as
/// <c>purchasableXpBoostId</c> to
/// <c>api/storefronts/v1/buyProgressionEventXpBoost</c> (which keys its
/// purchase sentinel on it). See <see cref="OfferId"/>.
/// </summary>
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts")]
[AllowAnonymous]
public IActionResult XpBoosts(long eventId) =>
Ok(BoostCatalogue(eventId).Select(b => new
{
ProgressionEventPurchasableXpBoostId = b.Id,
b.Cost,
b.XpMultiplier,
b.XpCap,
b.LookbackDurationTicks,
b.CooldownDurationTicks,
}));
/// <summary>
/// GET <c>api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp</c>
/// — how much XP buying this boost right now would hand the caller.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1612</c>
/// (<c>GBNFGMOGLNG(Int64 eventId, Guid boostId)</c>) formats
/// <c>"{0}/{1}/xpboosts/{2}/previewEarnedXp"</c> against
/// <c>"api/progressionEvents"</c> (ISIL 036/038) and moves <c>0</c> =
/// <c>HTTPMethods.Get</c> into the verb argument at ISIL 049. The return
/// type is <c>FGLDKEJLAKB&lt;System.Int32&gt;</c> — a BARE integer body,
/// not an object; the value lands in
/// <c>ProgressionEventsPurchasableXpBoostModel.get_XpEarnedFromBoost</c>
/// and is echoed back as the <c>expectedXp</c> form field of
/// <c>buyProgressionEventXpBoost</c>, which grants exactly that much.
///
/// Cooldown is real state: <c>buyProgressionEventXpBoost</c> stamps
/// <c>progressionEvent:{eventId}:xpBoost:{guid:N}</c> in ObjectiveProgress
/// on every purchase, so a boost still inside its
/// <c>CooldownDurationTicks</c> window previews as 0 and the client's
/// <c>get_XPBoostIsAvailableForPurchase</c> keeps the button disabled.
///
/// Fidelity note: live Rec Room multiplied the XP the player actually
/// earned during <c>LookbackDurationTicks</c>. DorkNet has no timestamped
/// XP ledger (<see cref="LevelService.AwardXpAsync"/> mutates
/// <c>PlayerEntity.XP</c> in place), so a boost here pays its configured
/// <c>XpCap</c> — the cap is the payout, and <c>XpMultiplier</c> is the
/// figure the sheet renders as "Nx".
/// </summary>
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")]
[Authorize]
public async Task<IActionResult> PreviewEarnedXp(long eventId, Guid boostId)
{
var me = this.RequireCurrentPlayerId();
var offer = BoostCatalogue(eventId).FirstOrDefault(b => b.Id == boostId);
if (offer is null) return Ok(0);
// Same key StorefrontsBuyController.BuyProgressionEventXpBoost writes.
var purchaseKey = $"progressionEvent:{eventId}:xpBoost:{boostId:N}";
var lastPurchase = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key == purchaseKey)
.Select(o => o.ClearedAt)
.FirstOrDefaultAsync();
if (offer.CooldownDurationTicks > 0
&& lastPurchase is DateTime at
&& DateTime.UtcNow - at < TimeSpan.FromTicks(offer.CooldownDurationTicks))
{
return Ok(0);
}
return Ok(offer.XpCap);
}
// ── Shared shape helpers ─────────────────────────────────────────────
/// <summary>One pip on the progression track. Field types are taken from
/// <c>ProgressionEventRewardDTO</c> (dump.cs:1199338): ProgressionEventRewardId
/// is Int64 at +0x10 but <b>GiftDropId is Int32</b> at +0x18 (the audit doc
/// says Int64 — the binary disagrees), then string ImageName, Int32 Xp,
/// Int32 RewardIndex, bool IsBonus.</summary>
public sealed record EventReward(
long ProgressionEventRewardId,
int GiftDropId,
string ImageName,
int Xp,
int RewardIndex,
bool IsBonus);
/// <summary>The progression track's reward list. One source of truth for
/// <see cref="Event"/> (which renders the pips) and <see cref="Collect"/>
/// (which bounds-checks the claimed index), so the two can't drift into a
/// state where the client can see a chest the server refuses to open.</summary>
private static IReadOnlyList<EventReward> BuildRewards(WeeklyChallengeReward reward, long activeId) =>
[
new EventReward(
ProgressionEventRewardId: activeId * 1000L,
// yyyyMMdd fits Int32 comfortably, so the event id doubles as a
// stable placeholder drop id when no reward drop is configured.
GiftDropId: reward.GiftDropId != 0 ? reward.GiftDropId : (int)activeId,
ImageName: string.Empty,
Xp: reward.Xp,
RewardIndex: 0,
IsBonus: false),
];
private static string RewardClaimPrefix(long eventId) => $"progressionEvent:{eventId}:reward:";
// ── XP-boost catalogue ───────────────────────────────────────────────
public sealed record XpBoostOffer(
Guid Id,
int Cost,
int XpMultiplier,
int XpCap,
long LookbackDurationTicks,
long CooldownDurationTicks);
/// <summary>The boosts this deployment sells for a given event.
///
/// Configured under <c>ProgressionEvents:XpBoosts</c> as an array of
/// <c>{Cost, XpMultiplier, XpCap, LookbackMinutes, CooldownMinutes}</c>;
/// with nothing configured we fall back to a single built-in offer (same
/// pattern as <see cref="ServerSettingsService.DefaultWeeklyChallenges"/>
/// and the built-in NUX checklist). Set
/// <c>ProgressionEvents:EnableXpBoosts</c> to false to sell none — the
/// event then reports <c>UsesBoost=false</c> and the sheet stays shut.</summary>
private IReadOnlyList<XpBoostOffer> BoostCatalogue(long eventId)
{
if (!config.GetValue("ProgressionEvents:EnableXpBoosts", true))
return [];
var configured = config.GetSection("ProgressionEvents:XpBoosts").GetChildren().ToList();
if (configured.Count == 0)
{
return
[
new XpBoostOffer(
Id: OfferId(eventId, 0),
Cost: 250,
XpMultiplier: 2,
XpCap: 500,
LookbackDurationTicks: TimeSpan.FromHours(1).Ticks,
CooldownDurationTicks: TimeSpan.FromHours(24).Ticks),
];
}
var offers = new List<XpBoostOffer>(configured.Count);
for (var i = 0; i < configured.Count; i++)
{
var section = configured[i];
var cap = Math.Max(0, section.GetValue("XpCap", 500));
offers.Add(new XpBoostOffer(
Id: OfferId(eventId, i),
Cost: Math.Max(0, section.GetValue("Cost", 250)),
XpMultiplier: Math.Max(1, section.GetValue("XpMultiplier", 2)),
XpCap: cap,
LookbackDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("LookbackMinutes", 60))).Ticks,
CooldownDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("CooldownMinutes", 1440))).Ticks));
}
return offers;
}
/// <summary>Deterministic per-(event, slot) boost id. The client caches
/// the guid from <see cref="XpBoosts"/> and replays it to
/// <see cref="PreviewEarnedXp"/> and to
/// <c>storefronts/v1/buyProgressionEventXpBoost</c>, whose purchase /
/// cooldown sentinel is keyed on it — a freshly generated guid per request
/// would make every preview miss its own catalogue entry and reset the
/// cooldown on every call. Derived rather than stored because the boost
/// catalogue itself lives in configuration, not the database.</summary>
private static Guid OfferId(long eventId, int index) =>
new(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"dorknet:progressionEventXpBoost:{eventId}:{index}"))
.AsSpan(0, 16));
private static long CurrentProgressionEventId()
{
var (start, _) = CurrentWeekWindow();
return long.Parse(start.ToString("yyyyMMdd"));
}
private static (DateTime Start, DateTime End) CurrentWeekWindow()
{
var start = DateTime.UtcNow.Date.AddDays(-(int)DateTime.UtcNow.DayOfWeek);
return (start, start.AddDays(7));
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.PurchaseCampaign;
/// <summary>
/// Purchase-campaign impression telemetry. Client contract (RecNet.Runtime
/// <c>JHAEJJEGPKJ(int)</c>) is a fire-and-forget POST carrying the campaign
/// id; the return type is the response handle whose body the client ignores.
/// DorkNet doesn't run ad/purchase campaigns, so there's nothing to record —
/// we acknowledge the impression so the client's promise resolves instead of
/// 404-rejecting. This is a genuine no-op telemetry sink, not a stub masking
/// an unimplemented feature.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseCampaignController : ControllerBase
{
[HttpPost("/purchasecampaign/shown")]
[HttpPost("/purchasecampaign/shown/{campaignId:int}")]
public IActionResult Shown() => Ok();
}
@@ -25,15 +25,29 @@ public class QuickPlayController(DorkNetDbContext db) : ControllerBase
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
/// Correct behaviour: return the full no-target launch DTO unless
/// we actually have a queued match for the caller. The /set endpoint
/// is what queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(new { });
public IActionResult GetAndClear() => Ok(QuickPlayLaunchTarget.Empty);
private sealed class QuickPlayLaunchTarget
{
public static readonly QuickPlayLaunchTarget Empty = new();
public bool OKCGKFJELAC { get; init; }
public string INMKAFBAOPC { get; init; } = string.Empty;
public int? FIOPJIIOCGA { get; init; }
public string ICKOEFDNOPM { get; init; } = string.Empty;
public int? KLDCJBLLDKN { get; init; }
public bool OFBMKDAONNE { get; init; }
public object? FMKHKNCEBCN { get; init; }
public long? EEFNKAFLPLG { get; init; }
}
public sealed class SetQuickPlayRequest
{
@@ -451,8 +451,76 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
return Ok(new { Imported = added });
}
[HttpGet("api/relationships/mutualfriends")]
public async Task<IActionResult> MutualFriends([FromQuery] long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(Array.Empty<object>());
var mine = await FriendIdsAsync(me);
var theirs = await FriendIdsAsync(playerId);
var mutualIds = mine.Intersect(theirs).Take(100).ToList();
var players = await db.Players
.Where(p => mutualIds.Contains(p.Id))
.Select(p => new
{
AccountId = (int)p.Id,
p.Username,
p.DisplayName,
ProfileImage = p.ProfileImageName ?? string.Empty,
})
.ToListAsync();
return Ok(players);
}
[HttpPost("api/relationships/sendfriendintroductions")]
public async Task<IActionResult> SendFriendIntroductions(
[FromForm] long playerId,
[FromForm] long introducedPlayerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || introducedPlayerId <= 0)
return BadRequest("missing_player");
db.Messages.Add(new MessageEntity
{
SenderPlayerId = me,
RecipientPlayerId = playerId,
Type = 30,
Body = $"Friend introduction:{introducedPlayerId}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
[HttpPost("api/relationships/v1/addfriendwithcode")]
[HttpGet("api/relationships/v1/addfriendwithcode")]
public async Task<ActionResult> AddFriendWithCode([FromQuery] string? code, [FromForm] string? formCode)
{
var raw = (code ?? formCode ?? string.Empty).Trim();
if (raw.Length == 0) return Ok(EmptyRelationship(0));
long? id = long.TryParse(raw, out var parsed) ? parsed : null;
if (id is null)
{
id = await db.Players
.Where(p => p.Username == raw)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync();
}
if (id is not long playerId) return Ok(EmptyRelationship(0));
return await AddFriend(playerId);
}
// ── helpers ──────────────────────────────────────────────────────
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{
var rows = await db.Relationships
.Where(r => r.Status == EntityStatus.Friend
&& (r.RequesterId == playerId || r.TargetId == playerId))
.Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId)
.ToListAsync();
return rows.ToHashSet();
}
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
@@ -72,6 +72,30 @@ public class RoleController(DorkNetDbContext db) : ControllerBase
return Ok(isAdmin ?? false);
}
/// <summary>The March-2023 client probes this one in addition to
/// <c>role/developer</c>. Both calls funnel through the same helper
/// (<c>OPMAPIOEIFG.ILHILCFCNFF</c>, which formats <c>"role/{0}/{1}"</c>
/// and issues it with HTTPMethods.GET); the two call sites differ only
/// in the role literal they pass — <c>"developer"</c> at
/// RecNet.Runtime/OPMAPIOEIFG.txt:9402 and <c>"moderator"</c> at
/// :9471. The 2020 build never referenced the moderator probe, so it
/// used to 404 for every remote player on the 2023 client.
///
/// DorkNet has no dedicated moderator bit, so admins and community-team
/// members answer true — they are the accounts that actually hold
/// moderation powers here.</summary>
[HttpGet("/role/moderator/{accountId:long}")]
[HttpGet("/api/role/moderator/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsModerator(long accountId)
{
var isMod = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)(p.IsAdmin || p.IsCommunityTeam))
.FirstOrDefaultAsync();
return Ok(isMod ?? false);
}
/// <summary>Future-proofing for a hypothetical
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
/// build, but mirrors the developer shape so a patched watch can
@@ -0,0 +1,598 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomConsumables;
/// <summary>
/// api.rec.net/api/roomconsumables/v1 — the 2023 client's in-room shop
/// (creator-made consumables, <c>RecRoom.Systems.RoomConsumablesManager</c>).
///
/// Wire shapes verified against the 2023.03.21 ISIL dump
/// (RecNet.Runtime formatters CICEHLNDLDE / HJPEABLEEKL / JEBCHNDADKK /
/// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON):
///
/// desc = { RoomConsumableId, RoomId, Name, Description, ImageName,
/// Price, PurchaseCurrencyId, ModifiedAt }
/// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode,
/// ModifiedAt, Consumable: desc }
///
/// The desc price is FLAT on the way OUT even though it is NESTED on the way
/// IN. The response formatter FCIBLPCOODP's key map is exactly the eight keys
/// above — Price at index 5, PurchaseCurrencyId at 6, ModifiedAt at 7, and no
/// PriceAndCurrency key at all (FCIBLPCOODP.txt:670/686/710). See ToDescWire.
/// The inventory key map is likewise exact (CICEHLNDLDE.txt:447-562).
///
/// <c>Consumable</c> must never be null on an inventory row — the
/// client's RoomConsumablesManager dereferences it unconditionally while
/// processing the room-join inventory fetch and NREs otherwise (that NRE
/// is an unobserved task exception that kills the shop and uploads a
/// crash report).
///
/// Purchase requests carry the client-generated inventory concurrency
/// token: { ConcurrencyCodes: { CurrentConcurrencyCode,
/// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }.
/// The server must store NewConcurrencyCode verbatim, because the client
/// adopts the code it sent regardless of what comes back — anything else
/// makes the next consume fail with ConcurrencyCodeMismatch.
///
/// Both purchase responses derive from the same base DTO (OKHNOPLBOFP),
/// whose sole field <c>OperationResult</c> is the object
/// <c>{ Status, InventoryItem }</c> — see TokenResponse below.
/// </summary>
[ApiController]
public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
/// <summary>RoomConsumableStatus (client enum DLDNGEDFMOJ).</summary>
private static class Status
{
public const int Success = 0;
public const int RoomConsumableNotFound = 4;
public const int PlayerDoesntHavePermission = 7;
public const int MaxConsumablesInRoom = 13;
public const int RoomIdMissing = 15;
public const int PriceOrCurrencyMissing = 17;
public const int CurrencyNotFound = 18;
public const int PriceTooLowTokens = 21;
public const int PriceTooHighTokens = 22;
public const int NameTooShort = 23;
public const int NameTooLong = 24;
public const int DescriptionTooLong = 27;
public const int ConcurrencyCodeMismatch = 32;
public const int PlayerDoesNotOwnConsumable = 33;
public const int PurchaseFailed = 35;
public const int RoomNotFound = 36;
public const int RequestedPriceDoesNotMatch = 38;
public const int RequestedCurrencyDoesNotMatch = 39;
public const int ConsumableCannotBePurchasedWithRoomCurrency = 40;
public const int ConsumableCannotBePurchasedWithTokens = 41;
}
/// <summary>Token-purchase OperationResult (client enum CABBDKFODEC).</summary>
private static class TokenOp
{
public const int OK = 0;
public const int NotEnoughCredit = 2;
public const int RequestedPriceDoesNotMatch = 6;
}
/// <summary>Currency-purchase OperationResult (client enum GACBLALELBP).</summary>
private static class CurrencyOp
{
public const int Success = 0;
public const int NotEnoughCredit = 1;
}
private const int TokenCurrencyType = 2; // internal RecCenterTokens row
private const int MaxConsumablesPerRoom = 50;
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/room/{roomId}</c>
/// — every consumable for sale in a room; the shop UI's catalog.</summary>
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}")]
[AllowAnonymous]
public async Task<IActionResult> ListForRoom(long roomId)
{
var rows = await db.RoomConsumables
.Where(c => c.RoomId == roomId && !c.IsDeleted)
.OrderBy(c => c.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToDescWire));
}
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/room/{roomId}/me</c>
/// — the caller's consumable inventory for a room, fetched on every
/// room join.</summary>
[HttpGet("api/roomconsumables/v1/roomConsumable/room/{roomId:long}/me")]
[Authorize]
public async Task<IActionResult> MineForRoom(long roomId)
{
var me = this.RequireCurrentPlayerId();
var rows = await (from own in db.RoomConsumableOwnership
join item in db.RoomConsumables on own.RoomConsumableId equals item.Id
where own.PlayerId == me && item.RoomId == roomId && !item.IsDeleted
orderby item.CreatedAt
select new { own, item })
.Take(200)
.ToListAsync();
return Ok(rows.Select(r => ToInventoryWire(r.own, r.item)));
}
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/{id}</c> —
/// single consumable desc.</summary>
[HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
[AllowAnonymous]
public async Task<IActionResult> GetOne(Guid publicId)
{
var item = await FindAsync(publicId);
return item is null ? NotFound() : Ok(ToDescWire(item));
}
/// <summary>GET <c>api/roomconsumables/v1/roomConsumable/{id}/isOwned</c>
/// — "do players other than the creator hold this in inventory";
/// the client asks before edits/deletes to warn about invalidating
/// sold stock. Bare boolean body.</summary>
[HttpGet("api/roomconsumables/v1/roomConsumable/{publicId:guid}/isOwned")]
[Authorize]
public async Task<IActionResult> IsOwned(Guid publicId)
{
var item = await FindAsync(publicId);
if (item is null) return Content("false", "application/json");
var owned = await db.RoomConsumableOwnership.AnyAsync(o =>
o.RoomConsumableId == item.Id && o.Count > 0 && o.PlayerId != item.CreatorPlayerId);
return Content(owned ? "true" : "false", "application/json");
}
/// <summary>POST/PUT <c>api/roomconsumables/v1/roomConsumable</c> —
/// create (no <c>RoomConsumableId</c> in the body) or update (id
/// present) a consumable. Body is the desc DTO itself; response is
/// <c>{ Status, Consumable }</c>.</summary>
[HttpPost("api/roomconsumables/v1/roomConsumable")]
[HttpPut("api/roomconsumables/v1/roomConsumable")]
[Authorize]
public async Task<IActionResult> CreateOrUpdate()
{
var me = this.RequireCurrentPlayerId();
var body = await ReadBodyAsync();
var name = (ReadString(body, "Name") ?? string.Empty).Trim();
var description = (ReadString(body, "Description") ?? string.Empty).Trim();
var imageName = (ReadString(body, "ImageName") ?? string.Empty).Trim();
var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body);
var publicId = ReadGuid(body, "RoomConsumableId");
var existing = publicId is Guid id && id != Guid.Empty ? await FindAsync(id) : null;
if (existing is null)
{
var roomId = ReadLong(body, "RoomId");
if (roomId is not long rid || rid <= 0) return Ok(EditResponse(Status.RoomIdMissing));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return Ok(EditResponse(Status.RoomNotFound));
if (!await CanManageRoomAsync(room, me)) return Ok(EditResponse(Status.PlayerDoesntHavePermission));
if (ValidateFields(name, description, hasPrice, price) is int fieldError)
return Ok(EditResponse(fieldError));
if (currencyId is Guid cur
&& !await db.RoomCurrencies.AnyAsync(c => c.PublicId == cur && c.RoomId == rid && !c.IsDeleted))
return Ok(EditResponse(Status.CurrencyNotFound));
if (await db.RoomConsumables.CountAsync(c => c.RoomId == rid && !c.IsDeleted) >= MaxConsumablesPerRoom)
return Ok(EditResponse(Status.MaxConsumablesInRoom));
var created = new RoomConsumableEntity
{
RoomId = rid,
CreatorPlayerId = me,
Name = name,
Description = description,
ImageName = imageName,
Price = price,
CurrencyId = currencyId,
};
db.RoomConsumables.Add(created);
await db.SaveChangesAsync();
return Ok(EditResponse(Status.Success, created));
}
var owningRoom = await db.Rooms.FirstOrDefaultAsync(r => r.Id == existing.RoomId);
if (owningRoom is null || !await CanManageRoomAsync(owningRoom, me))
return Ok(EditResponse(Status.PlayerDoesntHavePermission, existing));
if (ValidateFields(name, description, hasPrice, price) is int updateError)
return Ok(EditResponse(updateError, existing));
if (currencyId is Guid updateCur
&& !await db.RoomCurrencies.AnyAsync(c => c.PublicId == updateCur && c.RoomId == existing.RoomId && !c.IsDeleted))
return Ok(EditResponse(Status.CurrencyNotFound, existing));
existing.Name = name;
existing.Description = description;
existing.ImageName = imageName;
existing.Price = price;
existing.CurrencyId = currencyId;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(EditResponse(Status.Success, existing));
}
/// <summary>DELETE <c>api/roomconsumables/v1/roomConsumable/{id}</c> —
/// soft-delete. The client deserialises the response body as a bare
/// RoomConsumableStatus int.</summary>
[HttpDelete("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}")]
[Authorize]
public async Task<IActionResult> Delete(Guid publicId)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
if (item is null) return StatusBody(Status.RoomConsumableNotFound);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, me))
return StatusBody(Status.PlayerDoesntHavePermission);
item.IsDeleted = true;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return StatusBody(Status.Success);
}
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens</c>
/// — buy one stack unit with tokens. Response is
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// TokenBalanceResponse: { CurrencyType, Balance, Platform } }</c>.</summary>
// The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route
// construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a
// POST-only mapping returned 405 and token-priced consumables were
// unbuyable. Both verbs are accepted now.
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
[Authorize]
public async Task<IActionResult> PurchaseTokens(Guid publicId)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
// Failures answer 200 + the DTO rather than a bare status code: the
// client only ever parses this route's body through HJOKFJKKEJM, so a
// 404/400 surfaces as the opaque "Failed to purchase room consumable".
if (item is null) return Ok(TokenResponse(null, Status.RoomConsumableNotFound, 0));
var body = await ReadBodyAsync();
var (price, _, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
var newCode = ReadConcurrency(body).NewCode;
var balance = await level.GetBalanceAsync(me, TokenCurrencyType);
// A token purchase is only valid for a token-priced item at the
// price the client showed the buyer.
if (item.CurrencyId is not null)
return Ok(TokenResponse(null, Status.ConsumableCannotBePurchasedWithTokens, balance));
if (!hasPrice || price != item.Price)
return Ok(TokenResponse(
TokenOp.RequestedPriceDoesNotMatch, Status.RequestedPriceDoesNotMatch, balance));
if (balance < item.Price)
return Ok(TokenResponse(TokenOp.NotEnoughCredit, Status.PurchaseFailed, balance));
var newBalance = item.Price == 0
? balance
: await level.GrantCurrencyAsync(me, TokenCurrencyType, -item.Price, $"roomConsumable:{item.PublicId}");
if (item.Price > 0 && item.CreatorPlayerId != me)
await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price,
$"sellRoomConsumable:{item.PublicId}");
var own = await AddToInventoryAsync(me, item, newCode);
await db.SaveChangesAsync();
return Ok(TokenResponse(TokenOp.OK, Status.Success, newBalance, ToInventoryWire(own, item)));
}
/// <summary>PUT <c>api/roomconsumables/v1/roomconsumable/{id}/purchase/currency</c>
/// — buy with a room currency. Response is
/// <c>{ OperationResult: { Status, InventoryItem }, BalanceUpdateResult,
/// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance,
/// ModifiedAt } }</c>.</summary>
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
// POST-only returned 405 and currency-priced consumables were unbuyable.
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
[Authorize]
public async Task<IActionResult> PurchaseCurrency(Guid publicId)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
// As with the token route, every rejection is a 200 + DTO carrying a
// real RoomConsumableStatus; the old BadRequest("price_mismatch")
// bodies were not DTOs at all, so the client could only report the
// generic purchase failure.
if (item is null)
return Ok(CurrencyResponse(null, Status.RoomConsumableNotFound, me, Guid.Empty, 0));
if (item.CurrencyId is not Guid currencyPublicId)
return Ok(CurrencyResponse(
null, Status.ConsumableCannotBePurchasedWithRoomCurrency, me, Guid.Empty, 0));
var currency = await db.RoomCurrencies
.FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted);
if (currency is null)
return Ok(CurrencyResponse(null, Status.CurrencyNotFound, me, currencyPublicId, 0));
var body = await ReadBodyAsync();
var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency");
var newCode = ReadConcurrency(body).NewCode;
var balanceRow = await db.RoomCurrencyBalances
.FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id);
var balance = balanceRow?.Balance ?? 0;
if (!hasPrice || price != item.Price)
return Ok(CurrencyResponse(
null, Status.RequestedPriceDoesNotMatch, me, currencyPublicId, balance));
if (requestedCurrency is Guid reqCur && reqCur != currencyPublicId)
return Ok(CurrencyResponse(
null, Status.RequestedCurrencyDoesNotMatch, me, currencyPublicId, balance));
if (balance < item.Price)
return Ok(CurrencyResponse(
CurrencyOp.NotEnoughCredit, Status.PurchaseFailed, me, currencyPublicId, balance));
if (item.Price > 0)
{
balanceRow!.Balance -= item.Price;
balanceRow.UpdatedAt = DateTime.UtcNow;
}
var own = await AddToInventoryAsync(me, item, newCode);
await db.SaveChangesAsync();
return Ok(CurrencyResponse(CurrencyOp.Success, Status.Success, me, currencyPublicId,
balanceRow?.Balance ?? 0, ToInventoryWire(own, item)));
}
/// <summary>PUT <c>api/roomconsumables/v1/roomConsumable/{id}/consume</c>
/// — spend one unit. Body is <c>{ CurrentConcurrencyCode,
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
/// On a code mismatch the current row is returned so the client can
/// resync.</summary>
// PUT per the client (route format "{0}/v1/roomConsumable/{1}/consume"
// with verb literal 3 at RecNet.Runtime/LPNHMEFDAAG.txt:1449-1460);
// POST-only returned 405, so no consumable could ever be consumed.
[HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
[Authorize]
public async Task<IActionResult> Consume(Guid publicId)
{
var me = this.RequireCurrentPlayerId();
var item = await FindAsync(publicId);
if (item is null) return Ok(new { Status = Status.RoomConsumableNotFound, InventoryItem = (object?)null });
var own = await db.RoomConsumableOwnership
.FirstOrDefaultAsync(o => o.PlayerId == me && o.RoomConsumableId == item.Id);
if (own is null || own.Count <= 0)
return Ok(new
{
Status = Status.PlayerDoesNotOwnConsumable,
InventoryItem = own is null ? null : ToInventoryWire(own, item),
});
var (currentCode, newCode) = ReadConcurrency(await ReadBodyAsync());
if (currentCode is Guid current && current != own.ConcurrencyCode)
return Ok(new { Status = Status.ConcurrencyCodeMismatch, InventoryItem = ToInventoryWire(own, item) });
own.Count -= 1;
own.ConcurrencyCode = newCode ?? Guid.NewGuid();
own.ModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Status = Status.Success, InventoryItem = ToInventoryWire(own, item) });
}
private async Task<RoomConsumableEntity?> FindAsync(Guid publicId) =>
await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
/// <summary>Grant one unit and hand back the row, so the purchase
/// responses can embed it as <c>OperationResult.InventoryItem</c>.</summary>
private async Task<RoomConsumableOwnershipEntity> AddToInventoryAsync(
long playerId, RoomConsumableEntity item, Guid? newCode)
{
var own = await db.RoomConsumableOwnership
.FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id);
if (own is null)
{
own = new RoomConsumableOwnershipEntity { PlayerId = playerId, RoomConsumableId = item.Id };
db.RoomConsumableOwnership.Add(own);
}
own.Count += 1;
// The client adopts the NewConcurrencyCode it sent regardless of what
// the response echoes back — store it verbatim or the next consume
// hits ConcurrencyCodeMismatch.
if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code;
own.ModifiedAt = DateTime.UtcNow;
return own;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private static int? ValidateFields(string name, string description, bool hasPrice, long price)
{
if (name.Length == 0) return Status.NameTooShort;
if (name.Length > 128) return Status.NameTooLong;
if (description.Length > 1024) return Status.DescriptionTooLong;
if (!hasPrice) return Status.PriceOrCurrencyMissing;
if (price < 0) return Status.PriceTooLowTokens;
if (price > 1_000_000) return Status.PriceTooHighTokens;
return null;
}
private IActionResult StatusBody(int status) => Content(status.ToString(), "application/json");
private static object EditResponse(int status, RoomConsumableEntity? item = null) => new
{
Status = status,
Consumable = item is null ? null : ToDescWire(item),
};
/// <summary>Token-purchase response (client type MJKFEHPIDME, formatter
/// HJOKFJKKEJM).
///
/// <c>OperationResult</c> is an OBJECT, not a scalar. MJKFEHPIDME only
/// declares BalanceUpdateResult (+24) and TokenBalanceResponse (+32)
/// itself (MJKFEHPIDME.txt:3,23); OperationResult (+16) comes from its
/// base OKHNOPLBOFP, whose single field is typed BGHBAILNNJJ
/// (OKHNOPLBOFP.txt:3) — the same <c>{ Status, InventoryItem }</c> pair
/// the consume route returns (BGHBAILNNJJ.txt:3,83). HJOKFJKKEJM's key
/// map puts "OperationResult" at index 2 (HJOKFJKKEJM.txt:330) and its
/// reader resolves index 2 through JECENNBIMEI&lt;BGHBAILNNJJ&gt;
/// (HJOKFJKKEJM.txt:1104). A bare int there made the generated reader
/// hit a number where it verifies '{', faulting the whole request with
/// "Failed to purchase room consumable" (DCFKEFHJAGC.txt:8294) — so
/// purchases were dead even once the PUT verb was accepted.
///
/// The scalar operation enum belongs in <c>BalanceUpdateResult</c>
/// (Nullable&lt;CABBDKFODEC&gt;, index 0) — that is what the client turns
/// into the "Not enough …" / "The price for this … has changed" toast
/// (DCFKEFHJAGC.txt:12735-12760). It is null when the balance was never
/// touched.</summary>
private static object TokenResponse(
int? balanceUpdateResult, int status, long balance, object? inventoryItem = null) => new
{
OperationResult = new { Status = status, InventoryItem = inventoryItem },
BalanceUpdateResult = balanceUpdateResult,
TokenBalanceResponse = new
{
CurrencyType = TokenCurrencyType,
Balance = balance,
Platform = 0,
},
};
/// <summary>Currency-purchase response (client type DEILBLCDNEA,
/// formatter IDBFKCFEAHM). Same base type as the token response, so
/// <c>OperationResult</c> is the same <c>{ Status, InventoryItem }</c>
/// object; index 2 resolves through JECENNBIMEI&lt;BGHBAILNNJJ&gt;
/// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is
/// Nullable&lt;GACBLALELBP&gt; — an enum with only Success=0 and
/// NotEnoughCredit=1, so every other failure has to be reported through
/// OperationResult.Status and leave this null.</summary>
private static object CurrencyResponse(
int? balanceUpdateResult, int status, long playerId, Guid currencyId, long balance,
object? inventoryItem = null) => new
{
OperationResult = new { Status = status, InventoryItem = inventoryItem },
BalanceUpdateResult = balanceUpdateResult,
CurrencyBalanceResponse = new
{
AccountId = (int)playerId,
CurrencyId = currencyId,
Balance = balance,
ModifiedAt = DateTime.UtcNow,
},
};
/// <summary>Consumable descriptor as the client READS it.
///
/// The price is FLAT here — the client's response formatter FCIBLPCOODP
/// reads <c>Price</c>, <c>PurchaseCurrencyId</c> and <c>ModifiedAt</c>
/// directly off the descriptor (RecNet.Runtime/FCIBLPCOODP.txt:670-710).
/// Emitting a nested <c>PriceAndCurrency</c> object left every item in
/// every room shop reading back Price=0 with a null currency, so the whole
/// catalogue looked token-priced and free.
///
/// Note the asymmetry: REQUEST bodies really do nest PriceAndCurrency, and
/// ReadBodyAsync below still flattens that on the way in. Only the response
/// shape changed.</summary>
private static object ToDescWire(RoomConsumableEntity c) => new
{
RoomConsumableId = c.PublicId,
c.RoomId,
c.Name,
c.Description,
c.ImageName,
c.Price,
PurchaseCurrencyId = c.CurrencyId,
ModifiedAt = DateTime.SpecifyKind(c.UpdatedAt, DateTimeKind.Utc),
};
private static object ToInventoryWire(RoomConsumableOwnershipEntity own, RoomConsumableEntity item) => new
{
RoomConsumableId = item.PublicId,
AccountId = (int)own.PlayerId,
Count = Math.Max(0, own.Count),
own.ConcurrencyCode,
ModifiedAt = DateTime.SpecifyKind(own.ModifiedAt, DateTimeKind.Utc),
Consumable = ToDescWire(item),
};
/// <summary>Flatten the JSON body one level: nested objects
/// (<c>ExpectedPriceAndCurrency</c>, <c>ConcurrencyCodes</c>,
/// <c>PriceAndCurrency</c>) contribute their inner keys, so readers
/// can be shape-agnostic about the exact nesting.</summary>
private async Task<Dictionary<string, JsonElement>> ReadBodyAsync()
{
var fields = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
if (Request.ContentLength is 0) return fields;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return fields;
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.Clone();
if (prop.Value.ValueKind == JsonValueKind.Object)
foreach (var inner in prop.Value.EnumerateObject())
fields.TryAdd(inner.Name, inner.Value.Clone());
}
}
catch (JsonException)
{
}
return fields;
}
private static string? ReadString(Dictionary<string, JsonElement> fields, string name) =>
fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static long? ReadLong(Dictionary<string, JsonElement> fields, string name)
{
if (!fields.TryGetValue(name, out var v)) return null;
return v.ValueKind switch
{
JsonValueKind.Number when v.TryGetInt64(out var n) => n,
JsonValueKind.String when long.TryParse(v.GetString(), out var n) => n,
_ => null,
};
}
private static Guid? ReadGuid(Dictionary<string, JsonElement> fields, string name) =>
fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String
&& Guid.TryParse(v.GetString(), out var guid)
? guid
: null;
/// <summary>Read <c>{ Price, CurrencyId }</c> from the body, whether it
/// arrives nested under <paramref name="wrapper"/> /
/// <c>PriceAndCurrency</c> or flattened at the root.</summary>
private static (long Price, Guid? CurrencyId, bool HasPrice) ReadPriceAndCurrency(
Dictionary<string, JsonElement> fields, string wrapper = "PriceAndCurrency")
{
var scope = fields;
if (fields.TryGetValue(wrapper, out var nested) && nested.ValueKind == JsonValueKind.Object)
{
scope = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in nested.EnumerateObject()) scope[prop.Name] = prop.Value;
}
var price = ReadLong(scope, "Price");
return (price ?? 0, ReadGuid(scope, "CurrencyId"), price is not null);
}
private static (Guid? CurrentCode, Guid? NewCode) ReadConcurrency(Dictionary<string, JsonElement> fields)
{
var scope = fields;
if (fields.TryGetValue("ConcurrencyCodes", out var nested) && nested.ValueKind == JsonValueKind.Object)
{
scope = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in nested.EnumerateObject()) scope[prop.Name] = prop.Value;
}
return (ReadGuid(scope, "CurrentConcurrencyCode"), ReadGuid(scope, "NewConcurrencyCode"));
}
}
@@ -0,0 +1,718 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.RoomCurrencies;
[ApiController]
[Authorize]
public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/roomcurrencies/v1/currencies")]
[AllowAnonymous]
public async Task<IActionResult> Currencies([FromQuery] long roomId)
{
var rows = await db.RoomCurrencies
.Where(c => !c.IsDeleted && (roomId <= 0 || c.RoomId == roomId))
.OrderBy(c => c.Name)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomcurrencies/v1/createCurrency")]
public async Task<IActionResult> CreateCurrency()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
if (!await CanManageRoomAsync(room, Me)) return Forbid();
var name = Trim(ReadString(fields, "name", "Name") ?? "Room Currency", 64);
var currency = new RoomCurrencyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = name,
Description = Trim(ReadString(fields, "description", "Description"), 512),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
// "Limit" is an Int64 on the wire — the client boxes a System.Int64
// immediately before pushing the param (MIHKAJFMIPE.txt:757-768).
// ReadInt would fail to parse anything above int.MaxValue and quietly
// store 0, so parse as long and clamp into the int column.
DailyLimit = ClampToInt(ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0),
};
db.RoomCurrencies.Add(currency);
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpPost("api/roomcurrencies/v1/updateCurrency")]
[HttpPut("api/roomcurrencies/v1/updateCurrency")]
public async Task<IActionResult> UpdateCurrency()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
// Int64 on the wire (MIHKAJFMIPE.txt:983 block, same boxing as createCurrency).
if (ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is long dailyLimit) currency.DailyLimit = ClampToInt(dailyLimit);
currency.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(currency));
}
[HttpGet("api/roomcurrencies/v1/getBalance")]
public async Task<IActionResult> GetBalance()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
// The client sends "accountId" and can ask about ANOTHER player
// (circuits read other players' balances); reading only playerId made
// every such query silently answer for the caller instead.
var playerId = ReadLong(fields, "accountId", "AccountId", "playerId", "PlayerId") ?? Me;
var balance = await GetBalanceAsync(playerId, currency.Id);
return Ok(new
{
AccountId = (int)playerId,
CurrencyId = currency.PublicId,
Balance = balance,
ModifiedAt = DateTime.UtcNow,
// legacy aliases
PlayerId = (int)playerId,
RoomCurrencyId = currency.PublicId,
});
}
[HttpGet("api/roomcurrencies/v1/getAllBalances")]
public async Task<IActionResult> GetAllBalances([FromQuery] long? roomId = null)
{
var playerId = Me;
var query = db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId)
.Join(db.RoomCurrencies, b => b.RoomCurrencyId, c => c.Id, (b, c) => new { b, c })
.Where(x => !x.c.IsDeleted);
if (roomId is long rid && rid > 0)
query = query.Where(x => x.c.RoomId == rid);
var rows = await query
.OrderBy(x => x.c.RoomId)
.ThenBy(x => x.c.Name)
.ToListAsync();
return Ok(rows.Select(x => new
{
AccountId = (int)playerId,
CurrencyId = x.c.PublicId,
x.b.Balance,
ModifiedAt = DateTime.SpecifyKind(x.b.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
PlayerId = (int)playerId,
RoomCurrencyId = x.c.PublicId,
}));
}
/// <summary>Bulk currency award — the CircuitsV2 "award currency" chip.
///
/// The client body is a BARE JSON ARRAY, not an object: the issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;KBPKAAIDCIJ&gt;&gt; FGBCMAINCLD(List&lt;KOEIABFLEPM&gt;)</c>
/// (MIHKAJFMIPE.txt:1073); it POSTs (verb 2, MIHKAJFMIPE.txt:1262) to the route
/// at MIHKAJFMIPE.txt:1260 and pushes the serialised list straight into the
/// body via <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (MIHKAJFMIPE.txt:1284). Every
/// element carries its OWN CurrencyId/RecipientId/Amount/TransactionId
/// (request formatter GFKKOADFJKC.txt:331-398) — this is NOT "one currency,
/// one amount, many players", which is what the old handler assumed. The old
/// handler also went through <see cref="ReadFieldsAsync"/>, which only walks
/// an OBJECT root, so an array body produced zero fields and every award
/// 404'd.
///
/// A per-element failure is reported in-band rather than as an HTTP error so
/// one bad row cannot void a whole batch — the response formatter
/// (JCPFCOEEELK.txt:371-454) reads exactly AccountId/CurrencyId/Success/
/// Error/Response, and the nested Response (GAGIJHJGFJD.txt:395-486) reads
/// AccountId/CurrencyId/Balance/AmountAwarded/AwardedAt.</summary>
[HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")]
[HttpPut("api/roomcurrencies/v1/awardCurrency/bulk")]
public async Task<IActionResult> AwardCurrencyBulk()
{
// The body stream is single-read: pull the raw text once and hand it to
// the field reader for the legacy fallback below.
var body = await ReadJsonBodyAsync();
var awards = ParseAwards(body);
if (awards.Count == 0)
{
// Legacy DorkNet/admin shape: one currency + playerIds + a single amount.
var fields = await ReadFieldsAsync(body);
var legacyCurrency = await FindCurrencyAsync(fields);
var legacyAmount = ReadLong(fields, "amount", "Amount") ?? 0;
if (legacyCurrency is not null && legacyAmount != 0)
foreach (var playerId in ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds"))
awards.Add(new AwardRequest(Guid.Empty, playerId, legacyAmount, legacyCurrency.PublicId));
}
if (awards.Count == 0) return BadRequest("missing_awards");
var results = new List<object>();
var currencyCache = new Dictionary<Guid, RoomCurrencyEntity?>();
var manageCache = new Dictionary<long, bool>();
var awardedAt = DateTime.UtcNow;
foreach (var award in awards)
{
if (!currencyCache.TryGetValue(award.CurrencyId, out var currency))
{
currency = await db.RoomCurrencies
.FirstOrDefaultAsync(c => c.PublicId == award.CurrencyId && !c.IsDeleted);
currencyCache[award.CurrencyId] = currency;
}
if (currency is null)
{
results.Add(AwardFailure(award, "currency_not_found"));
continue;
}
if (!manageCache.TryGetValue(currency.RoomId, out var canManage))
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
canManage = room is not null && await CanManageRoomAsync(room, Me);
manageCache[currency.RoomId] = canManage;
}
if (!canManage)
{
results.Add(AwardFailure(award, "forbidden"));
continue;
}
var balance = await AddBalanceAsync(award.RecipientId, currency.Id, award.Amount);
results.Add(new
{
AccountId = (int)award.RecipientId,
CurrencyId = currency.PublicId,
Success = true,
Error = (string?)null,
Response = new
{
AccountId = (int)award.RecipientId,
CurrencyId = currency.PublicId,
Balance = balance,
AmountAwarded = award.Amount,
AwardedAt = awardedAt,
},
});
}
await db.SaveChangesAsync();
return Ok(results);
}
[HttpPost("api/roomcurrencies/v1/createPurchaseOffer")]
public async Task<IActionResult> CreatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
var offer = new RoomCurrencyPurchaseOfferEntity
{
RoomCurrencyId = currency.Id,
Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64),
// Amount/Price are Int64 on the wire (MIHKAJFMIPE.txt:2351-2374 boxes
// System.Int64 for both) — parse long, clamp into the int columns.
Amount = Math.Max(1, ClampToInt(ReadLong(fields, "amount", "Amount") ?? 1)),
Price = ClampToInt(ReadLong(fields, "price", "Price") ?? 0),
// The client never sends currencyType for an offer (its params are
// CurrencyId/Name/Amount/Price/Order, MIHKAJFMIPE.txt:2336-2383); the
// default 2 = tokens is what the v2/purchase path debits.
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
};
db.RoomCurrencyPurchaseOffers.Add(offer);
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/updatePurchaseOffer")]
[HttpPut("api/roomcurrencies/v1/updatePurchaseOffer")]
public async Task<IActionResult> UpdatePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64);
// Int64 on the wire, as in createPurchaseOffer.
if (ReadLong(fields, "amount", "Amount") is long amount) offer.Amount = Math.Max(1, ClampToInt(amount));
if (ReadLong(fields, "price", "Price") is long price) offer.Price = ClampToInt(price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType);
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
[HttpPost("api/roomcurrencies/v1/deletePurchaseOffer")]
[HttpDelete("api/roomcurrencies/v1/deletePurchaseOffer")]
public async Task<IActionResult> DeletePurchaseOffer()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId);
if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid();
offer.IsDeleted = true;
offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency));
}
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
/// runtime from the id count (<c>ALHIJCJOLCB.JIECAFGCODK(ids, 100)</c>,
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:210-212) — GET under 100 ids, POST
/// at 100+.</summary>
[HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")]
[AllowAnonymous]
public async Task<IActionResult> GetPurchaseOffersBatch()
{
var fields = await ReadFieldsAsync();
var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds");
// The client calls the list param plainly "ids"
// (MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228) and it holds ROOM CURRENCY
// ids — the response is grouped per currency. Without this alias both
// filters stayed empty and the handler dumped 200 arbitrary offers.
var currencyIds = ReadGuidList(fields, "ids", "Ids", "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds");
var query = db.RoomCurrencyPurchaseOffers
.Where(o => !o.IsDeleted)
.Join(db.RoomCurrencies, o => o.RoomCurrencyId, c => c.Id, (o, c) => new { o, c })
.Where(x => !x.c.IsDeleted);
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
// The client reads an "Order" key (AALDCAANEMM.txt:550) but there is no
// column to persist it into yet, so ToOfferWire reports 0 for every pack
// and the client keeps the order it received them in — order by insertion
// id so that fallback is at least stable across fetches.
var rows = await query.OrderBy(x => x.o.Id).Take(200).ToListAsync();
// The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter
// LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list
// deserialised into rows with CurrencyId=Guid.Empty and
// PurchaseOffers=null, so the "This Room" pack list was always empty.
var groups = rows
.GroupBy(x => x.c.PublicId)
.ToDictionary(g => g.Key, g => g.Select(x => ToOfferWire(x.o, x.c)).ToList());
// Answer for every requested currency, including ones with no offers, so
// the client can cache a definite "no packs" instead of retrying.
var keys = currencyIds.Count > 0 ? currencyIds : groups.Keys.ToList();
return Ok(keys.Select(id => new
{
CurrencyId = id,
PurchaseOffers = groups.TryGetValue(id, out var offers) ? offers : new List<object>(),
}));
}
[HttpPost("api/roomCurrencies/v2/purchase")]
[HttpGet("api/roomCurrencies/v2/purchase")]
public async Task<IActionResult> Purchase()
{
var fields = await ReadFieldsAsync();
var offer = await FindOfferAsync(fields);
if (offer is null) return NotFound();
var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId);
var pid = Me;
// The client states the price and amount it agreed to — the request pushes
// PurchaseOfferId (Guid), RequestedAmount (Int64) and RequestedPrice
// (Int64) in that order (DCFKEFHJAGC.txt:7810, :7822, :7834). Treat them
// as a price agreement so an offer edited mid-purchase cannot overcharge.
var requestedPrice = ReadLong(fields, "requestedPrice", "RequestedPrice");
var requestedAmount = ReadLong(fields, "requestedAmount", "RequestedAmount");
if ((requestedPrice is long rp && rp != offer.Price)
|| (requestedAmount is long ra && ra != offer.Amount))
return Conflict(new { Error = "offer_changed" });
var balance = await level.GetBalanceAsync(pid, offer.CurrencyType);
// The success DTO has room for exactly CurrencyBalanceResponse and
// TokenBalanceResponse (formatter KNGMIABFJHK.txt:215/242) — there is no
// Success flag to carry a failure — so failures must go out as non-2xx and
// take the client's own error path ("Failed to purchase currency offer",
// DCFKEFHJAGC.txt:7850). A 200 carrying {Success=false,...} deserialised
// to two null balances and looked like a successful, free purchase.
if (balance < offer.Price)
return Conflict(new { Error = "insufficient_funds" });
var newBalance = offer.Price == 0
? balance
: await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}");
var customBalance = await AddBalanceAsync(pid, currency.Id, offer.Amount);
await db.SaveChangesAsync();
return Ok(new
{
// Nested shapes: DPNLANKOHON.txt:331-398 {AccountId, CurrencyId,
// Balance, ModifiedAt} and PECGEJAAMHB.txt:255-298 {Balance,
// CurrencyType, Platform}.
CurrencyBalanceResponse = new
{
AccountId = (int)pid,
CurrencyId = currency.PublicId,
Balance = customBalance,
ModifiedAt = DateTime.UtcNow,
},
TokenBalanceResponse = new
{
Balance = newBalance,
offer.CurrencyType,
Platform = 0,
},
});
}
private async Task<RoomCurrencyEntity?> FindCurrencyAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "roomCurrencyId", "RoomCurrencyId", "currencyId", "CurrencyId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted);
var localId = ReadLong(fields, "roomCurrencyInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencies.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted);
return null;
}
private async Task<RoomCurrencyPurchaseOfferEntity?> FindOfferAsync(Dictionary<string, string> fields)
{
var guid = ReadGuid(fields, "purchaseOfferId", "PurchaseOfferId", "offerId", "OfferId", "id", "Id");
if (guid is Guid publicId)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.PublicId == publicId && !o.IsDeleted);
var localId = ReadLong(fields, "purchaseOfferInternalId", "internalId");
if (localId is long id)
return await db.RoomCurrencyPurchaseOffers.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted);
return null;
}
private async Task<long> GetBalanceAsync(long playerId, long currencyId) =>
await db.RoomCurrencyBalances
.Where(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
.Select(b => b.Balance)
.FirstOrDefaultAsync();
private async Task<long> AddBalanceAsync(long playerId, long currencyId, long amount)
{
// Check the change tracker first: a bulk award can name the same
// (player, currency) pair twice, and an unsaved row is invisible to a
// fresh query — without this the second hit would insert a duplicate
// balance row and lose the first award.
var row = db.RoomCurrencyBalances.Local
.FirstOrDefault(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId)
?? await db.RoomCurrencyBalances
.FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId);
if (row is null)
{
row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId };
db.RoomCurrencyBalances.Add(row);
}
row.Balance = Math.Max(0, row.Balance + amount);
row.UpdatedAt = DateTime.UtcNow;
return row.Balance;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
/// <param name="jsonBody">Pre-read request body. The body stream can only be
/// consumed once, so a caller that already had to look at the raw text
/// (<see cref="AwardCurrencyBulk"/> needs to see its ARRAY root) hands it back
/// in here instead of letting this re-read <c>Request.Body</c>.</param>
private async Task<Dictionary<string, string>> ReadFieldsAsync(string? jsonBody = null)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = Flatten(pair.Value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = Flatten(pair.Value);
return fields;
}
var raw = jsonBody ?? await ReadJsonBodyAsync();
if (string.IsNullOrWhiteSpace(raw)) return fields;
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
return fields;
}
/// <summary>Collapses a multi-valued query/form key into the comma-joined form
/// <see cref="ReadGuidList"/>/<see cref="ReadLongList"/> already split on.
///
/// getPurchaseOffersBatch pushes its currency-id list through the generic batch
/// param helper (<c>0x181F2DCD0</c> with <c>"ids"</c>,
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper
/// every other RecNet batch route uses to REPEAT the key once per id
/// (<c>?ids=a&amp;ids=b</c>). Taking only <c>FirstOrDefault()</c> silently
/// dropped every id after the first, so a multi-currency room fetched packs for
/// exactly one of its currencies.</summary>
private static string Flatten(Microsoft.Extensions.Primitives.StringValues values) =>
values.Count <= 1
? values.FirstOrDefault() ?? string.Empty
: string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)));
private async Task<string?> ReadJsonBodyAsync()
{
if ((Request.ContentLength ?? 0) <= 0) return null;
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) != true) return null;
using var reader = new StreamReader(Request.Body, leaveOpen: true);
return await reader.ReadToEndAsync();
}
/// <summary>One element of the bulk-award array
/// (request formatter GFKKOADFJKC.txt:331-398: CurrencyId, RecipientId,
/// Amount, TransactionId). TransactionId is the client's idempotency token;
/// DorkNet has no transaction ledger to dedupe against, so it is parsed and
/// echoed nowhere — the response formatter does not read it back either.</summary>
private sealed record AwardRequest(Guid TransactionId, long RecipientId, long Amount, Guid CurrencyId);
/// <summary>Parses the bare JSON array the client posts. Also tolerates an
/// <c>{"awards":[...]}</c> envelope for DorkNet's own tooling.</summary>
private static List<AwardRequest> ParseAwards(string? body)
{
var awards = new List<AwardRequest>();
if (string.IsNullOrWhiteSpace(body)) return awards;
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (JsonProp(root, "awards", "Awards") is { ValueKind: JsonValueKind.Array } envelope)
root = envelope;
else
return awards;
}
if (root.ValueKind != JsonValueKind.Array) return awards;
foreach (var element in root.EnumerateArray())
if (ToAward(element) is { } award)
awards.Add(award);
}
catch (JsonException)
{
}
return awards;
}
private static AwardRequest? ToAward(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object) return null;
if (JsonGuid(element, "CurrencyId", "currencyId", "RoomCurrencyId", "roomCurrencyId") is not Guid currencyId)
return null;
if (JsonLong(element, "RecipientId", "recipientId", "AccountId", "accountId", "PlayerId", "playerId") is not long recipientId
|| recipientId <= 0)
return null;
if (JsonLong(element, "Amount", "amount") is not long amount || amount == 0) return null;
return new AwardRequest(
JsonGuid(element, "TransactionId", "transactionId") ?? Guid.Empty,
recipientId,
amount,
currencyId);
}
private static object AwardFailure(AwardRequest award, string error) => new
{
AccountId = (int)award.RecipientId,
CurrencyId = award.CurrencyId,
Success = false,
Error = error,
Response = (object?)null,
};
// JsonDocument property lookup is case-sensitive, so every alias is listed in
// both casings the client could plausibly emit.
private static JsonElement? JsonProp(JsonElement element, params string[] names)
{
foreach (var name in names)
if (element.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null)
return value;
return null;
}
private static Guid? JsonGuid(JsonElement element, params string[] names) =>
JsonProp(element, names) is { ValueKind: JsonValueKind.String } value
&& Guid.TryParse(value.GetString(), out var guid)
? guid
: null;
private static long? JsonLong(JsonElement element, params string[] names) => JsonProp(element, names) switch
{
{ ValueKind: JsonValueKind.Number } number => number.TryGetInt64(out var value) ? value : null,
{ ValueKind: JsonValueKind.String } text => long.TryParse(text.GetString(), out var value) ? value : null,
_ => null,
};
/// <summary>Several wire fields are Int64 while the backing columns are int;
/// clamp instead of failing the parse and silently storing 0.</summary>
private static int ClampToInt(long value) => (int)Math.Clamp(value, 0, int.MaxValue);
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
/// <summary>Room currency as the client READS it (formatter PEOAOMGOMGC):
/// <c>CurrencyId</c>, <c>RoomId</c>, <c>Name</c>, <c>Description</c>,
/// <c>CurrencyType</c>, <c>Limit</c>, <c>ImageName</c>, <c>CreatedAt</c>,
/// <c>ModifiedAt</c>.
///
/// The previous names were server inventions (<c>RoomCurrencyId</c>,
/// <c>DailyLimit</c>, <c>UpdatedAt</c>) that shared almost no keys with
/// what the client reads, so every currency came back with an empty
/// CurrencyId and a zero limit — the room currency HUD could not match a
/// balance to a currency. The legacy aliases are kept alongside the correct
/// names because unknown members are ignored client-side and other DorkNet
/// surfaces (admin UI) still read them.</summary>
private static object ToWire(RoomCurrencyEntity currency) => new
{
CurrencyId = currency.PublicId,
currency.RoomId,
currency.Name,
currency.Description,
CurrencyType = 300,
Limit = (long)currency.DailyLimit,
currency.ImageName,
CreatedAt = DateTime.SpecifyKind(currency.CreatedAt, DateTimeKind.Utc),
ModifiedAt = DateTime.SpecifyKind(currency.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
RoomCurrencyId = currency.PublicId,
Id = currency.PublicId,
InternalId = currency.Id,
CreatorPlayerId = (int)currency.CreatorPlayerId,
currency.DailyLimit,
currency.UpdatedAt,
};
/// <summary>Purchase offer as the client READS it (formatter
/// AALDCAANEMM.txt:499-638): exactly <c>CurrencyPurchaseOfferId</c>,
/// <c>CurrencyId</c>, <c>Order</c>, <c>Name</c>, <c>CurrencyAmount</c>,
/// <c>Price</c>, <c>ModifiedAt</c>. <c>CurrencyType</c>/<c>CreatedAt</c> are
/// not read by the client and are kept only for DorkNet's admin UI, as are
/// the legacy aliases (see <see cref="ToWire"/>).
///
/// <c>Order</c> (Int32, sent by the client on createPurchaseOffer —
/// MIHKAJFMIPE.txt:2379-2383) has no column on
/// <c>RoomCurrencyPurchaseOfferEntity</c> yet, so it always reports 0 and the
/// pack list falls back to insertion order. Persisting it needs a schema
/// change in Data/Entities/RoomEconomyEntities.cs.</summary>
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
{
CurrencyPurchaseOfferId = offer.PublicId,
CurrencyId = currency.PublicId,
Order = 0,
offer.Name,
CurrencyAmount = offer.Amount,
offer.Price,
offer.CurrencyType,
CreatedAt = DateTime.SpecifyKind(offer.CreatedAt, DateTimeKind.Utc),
ModifiedAt = DateTime.SpecifyKind(offer.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
PurchaseOfferId = offer.PublicId,
RoomCurrencyPackageId = offer.PublicId,
Id = offer.PublicId,
InternalId = offer.Id,
RoomCurrencyId = currency.PublicId,
offer.Amount,
offer.UpdatedAt,
};
}
@@ -0,0 +1,201 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.RoomEarningsDistributions;
/// <summary>
/// Co-owner earnings-split configuration for a room. NOT gross-earnings
/// totals — this is how a room's token earnings are divided among its
/// co-owners. Client contract (RecNet.Runtime HPEBLAHNBLC / FEJOMKJBOOE):
/// <list type="bullet">
/// <item>GET <c>api/roomEarningsDistributions/v1/earningsDistribution/{roomId}</c>
/// → a SINGLE object <c>{ RoomId, EarningsDistributionMapping:
/// Dictionary&lt;accountId,percent&gt;, EarningsDistributionMethod: 0|1 }</c>
/// (0 = Equal, 1 = Custom).</item>
/// <item>POST the bare <c>.../v1/earningsDistribution</c> with that object
/// to save.</item>
/// </list>
/// Persisted as JSON on the room creator's settings row, keyed per room.
/// </summary>
[ApiController]
[Authorize]
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
{
private const int MethodEqual = 0;
private const int MethodCustom = 1;
private static string SettingKey(long roomId) => $"room:earningsdist:{roomId}";
/// <summary>GET the split config for one room (client's per-room GET).</summary>
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")]
public async Task<IActionResult> GetDistribution(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var (method, mapping) = await LoadAsync(room.CreatorPlayerId, roomId, room.CreatorPlayerId);
return Ok(BuildDto(roomId, method, mapping));
}
/// <summary>Bare-URL GET kept for compatibility: returns the caller's
/// own rooms' split configs as a list. The 2023 client doesn't hit this
/// (it GETs the per-room path above), but tooling may.</summary>
[HttpGet("api/roomEarningsDistributions")]
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
public async Task<IActionResult> ListMine([FromQuery] long? roomId = null)
{
var me = this.RequireCurrentPlayerId();
var q = db.Rooms.Where(r => r.CreatorPlayerId == me);
if (roomId is long rid && rid > 0) q = q.Where(r => r.Id == rid);
var rooms = await q.Select(r => new { r.Id, r.CreatorPlayerId }).ToListAsync();
var result = new List<object>();
foreach (var r in rooms)
{
var (method, mapping) = await LoadAsync(me, r.Id, r.CreatorPlayerId);
result.Add(BuildDto(r.Id, method, mapping));
}
return Ok(result);
}
/// <summary>POST the bare URL to save a room's split config (client's
/// save method). Only the room creator may change it.</summary>
[HttpPost("api/roomEarningsDistributions/v1/earningsDistribution")]
[HttpPut("api/roomEarningsDistributions/v1/earningsDistribution")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SaveDistribution()
{
var me = this.RequireCurrentPlayerId();
var (roomId, method, mapping) = await ReadBodyAsync();
if (roomId <= 0) return BadRequest(new { error = "missing_room_id" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != me) return Forbid();
var payload = JsonSerializer.Serialize(new StoredDistribution
{
Method = method,
Mapping = mapping,
});
var key = SettingKey(roomId);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == room.CreatorPlayerId && s.Key == key);
if (row is null)
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = room.CreatorPlayerId, Key = key, Value = payload,
});
else
row.Value = payload;
await db.SaveChangesAsync();
return Ok(BuildDto(roomId, method, mapping));
}
// ── helpers ──────────────────────────────────────────────────────────
private async Task<(int Method, Dictionary<long, int> Mapping)> LoadAsync(
long ownerId, long roomId, long creatorId)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == ownerId && s.Key == SettingKey(roomId));
if (row is not null)
{
try
{
var stored = JsonSerializer.Deserialize<StoredDistribution>(row.Value);
if (stored is not null)
return (stored.Method, stored.Mapping ?? new Dictionary<long, int>());
}
catch { /* fall through to default */ }
}
// Default: Equal method, creator takes 100% (solo room).
return (MethodEqual, new Dictionary<long, int> { [creatorId] = 100 });
}
private static object BuildDto(long roomId, int method, Dictionary<long, int> mapping)
{
// Mapping is keyed by accountId (as a string in JSON) → percent.
var mappingWire = mapping.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value);
// A Dictionary preserves literal key casing (unlike anonymous objects,
// which the server serializes camelCase). The client dump carries BOTH
// "EarningsDistributionMapping"/"earningsDistributionMapping" (and the
// Method variants), so emit both cases to be safe.
return new Dictionary<string, object?>
{
["RoomId"] = roomId,
["roomId"] = roomId,
["EarningsDistributionMethod"] = method,
["earningsDistributionMethod"] = method,
["EarningsDistributionMapping"] = mappingWire,
["earningsDistributionMapping"] = mappingWire,
};
}
private async Task<(long RoomId, int Method, Dictionary<long, int> Mapping)> ReadBodyAsync()
{
long roomId = 0;
int method = MethodEqual;
var mapping = new Dictionary<long, int>();
if (Request.HasFormContentType)
{
if (long.TryParse(Request.Form["RoomId"], out var rf) ||
long.TryParse(Request.Form["roomId"], out rf)) roomId = rf;
if (int.TryParse(Request.Form["EarningsDistributionMethod"], out var mf) ||
int.TryParse(Request.Form["earningsDistributionMethod"], out mf)) method = mf;
}
else
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
roomId = ReadLong(root, "RoomId", "roomId") ?? 0;
method = (int)(ReadLong(root, "EarningsDistributionMethod", "earningsDistributionMethod") ?? MethodEqual);
if (TryGetProp(root, out var map, "EarningsDistributionMapping", "earningsDistributionMapping")
&& map.ValueKind == JsonValueKind.Object)
{
foreach (var p in map.EnumerateObject())
if (long.TryParse(p.Name, out var acc) && p.Value.TryGetInt32(out var pct))
mapping[acc] = pct;
}
}
}
catch { /* non-JSON / empty */ }
}
if (roomId == 0 && long.TryParse(Request.Query["roomId"], out var rq)) roomId = rq;
if (mapping.Count == 0 && method == MethodCustom) method = MethodEqual;
return (roomId, method, mapping);
}
private static long? ReadLong(JsonElement root, params string[] names)
{
if (TryGetProp(root, out var v, names) &&
v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n)) return n;
return null;
}
private static bool TryGetProp(JsonElement root, out JsonElement value, params string[] names)
{
foreach (var n in names)
if (root.TryGetProperty(n, out value)) return true;
value = default;
return false;
}
private sealed class StoredDistribution
{
public int Method { get; set; }
public Dictionary<long, int>? Mapping { get; set; }
}
}
@@ -31,17 +31,42 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(rows.Select(ToWire));
}
/// <summary>The watch key ring — keys the caller HOLDS. This used to return
/// keys the caller CREATED, which is a different set: a creator never buys
/// their own key, so players saw none of the keys they had actually
/// purchased. Keys the caller created are unioned in so room owners still
/// see their own.</summary>
[HttpGet("api/roomkeys/v1/mine")]
public async Task<IActionResult> Mine()
{
var pid = Me;
var purchasedIds = await db.RoomKeyPurchases
.Where(p => p.PlayerId == pid)
.Select(p => p.RoomKeyId)
.ToListAsync();
var rows = await db.RoomKeys
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
.Where(k => !k.IsDeleted && (purchasedIds.Contains(k.Id) || k.CreatorPlayerId == pid))
.OrderByDescending(k => k.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys")]
[HttpGet("api/roomkeys/v1")]
public async Task<IActionResult> List([FromQuery] long? roomId = null)
{
var rows = db.RoomKeys.Where(k => !k.IsDeleted);
if (roomId is long id && id > 0)
rows = rows.Where(k => k.RoomId == id);
var result = await rows
.OrderBy(k => k.RoomId)
.ThenBy(k => k.Price)
.Take(200)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
@@ -77,6 +102,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
/// <summary>The 2023-03-21 client does not call <c>update</c>. It builds
/// <c>"api/roomkeys/v1/" + action</c> with verb PUT and a <c>RoomKeyId</c>
/// field, where action is one of <c>updateAll</c>, <c>updateName</c>,
/// <c>updateDescription</c> or <c>updatePrice</c>
/// (RecNet.Runtime/AHMBBJNANBP.txt: prefix concat at instr 058, verb 3 at
/// 067, <c>"RoomKeyId"</c> at 079; action literals at :1345, :1559, :1772,
/// :2043). None were registered, so every room-key edit 405'd.
///
/// All four share this handler: the request DTO's fields are already
/// optional, so a partial update naturally leaves the others untouched.</summary>
[HttpPut("api/roomkeys/v1/updateAll")]
[HttpPost("api/roomkeys/v1/updateAll")]
[HttpPut("api/roomkeys/v1/updateName")]
[HttpPost("api/roomkeys/v1/updateName")]
[HttpPut("api/roomkeys/v1/updateDescription")]
[HttpPost("api/roomkeys/v1/updateDescription")]
[HttpPut("api/roomkeys/v1/updatePrice")]
[HttpPost("api/roomkeys/v1/updatePrice")]
[HttpPut("api/roomkeys/v1/update")]
[HttpPost("api/roomkeys/v1/update")]
public async Task<IActionResult> Update()
{
@@ -124,20 +168,120 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
}
/// <summary>"Has anyone bought this key?" — the creator calls this before a
/// destructive edit. It previously answered "has the CALLER bought it",
/// which is always false for the creator, so the guard never fired and
/// creators could silently invalidate keys players had paid for.</summary>
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
public async Task<IActionResult> Purchased(long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
var anyPurchase = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId);
return Content(anyPurchase ? "true" : "false", "application/json");
}
/// <summary>The client sends both <c>roomKeyId</c> and <c>playerId</c> — key
/// doors evaluate OTHER players' ownership, not just the caller's. Ignoring
/// playerId meant a door asked about someone else and got the caller's
/// answer.</summary>
[HttpGet("api/roomkeys/v1/owns")]
public async Task<IActionResult> Owns([FromQuery] long roomKeyId, [FromQuery] long? playerId = null)
{
var subject = playerId is long p && p > 0 ? p : Me;
var owns = await db.RoomKeyPurchases.AnyAsync(x => x.RoomKeyId == roomKeyId && x.PlayerId == subject);
return Content(owns ? "true" : "false", "application/json");
}
/// <summary>Bulk key-ownership probe, used by key doors to gate a whole
/// room of players at once.
///
/// The 2023-03-21 client POSTs a JSON <b>array</b> of
/// <c>{RoomKeyId, AccountId}</c> pairs as a raw body
/// (RecNet.Runtime/AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065),
/// so each element names the player it is asking about. The old
/// query/form-only reader saw an empty id list and answered for the caller,
/// meaning key doors evaluated the wrong player — or nobody at all.</summary>
[HttpGet("api/roomkeys/v1/owns/bulk")]
[HttpPost("api/roomkeys/v1/owns/bulk")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> OwnsBulk()
{
var pairs = await ReadOwnershipPairsAsync();
if (pairs.Count == 0)
{
// Legacy query/form path: ids only, always about the caller.
var ids = await ReadRoomKeyIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
pairs = ids.Select(id => (RoomKeyId: id, AccountId: Me)).ToList();
}
var keyIds = pairs.Select(p => p.RoomKeyId).Distinct().ToList();
var accountIds = pairs.Select(p => p.AccountId).Distinct().ToList();
var owned = (await db.RoomKeyPurchases
.Where(p => keyIds.Contains(p.RoomKeyId) && accountIds.Contains(p.PlayerId))
.Select(p => new { p.RoomKeyId, p.PlayerId })
.ToListAsync())
.Select(p => (p.RoomKeyId, p.PlayerId))
.ToHashSet();
return Ok(pairs.Select(p => new
{
p.RoomKeyId,
AccountId = (int)p.AccountId,
DoesPlayerOwnRoomKey = owned.Contains((p.RoomKeyId, p.AccountId)),
// legacy alias
Owns = owned.Contains((p.RoomKeyId, p.AccountId)),
}).ToList());
}
/// <summary>Parse the 2023 client's JSON array of
/// <c>{RoomKeyId, AccountId}</c> pairs. Returns empty for a form post or a
/// body that isn't an array, so the legacy path still applies.</summary>
private async Task<List<(long RoomKeyId, long AccountId)>> ReadOwnershipPairsAsync()
{
var pairs = new List<(long, long)>();
if (Request.HasFormContentType) return pairs;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return pairs;
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
long? Read(params string[] names)
{
foreach (var n in names)
if (el.TryGetProperty(n, out var v) &&
v.ValueKind == System.Text.Json.JsonValueKind.Number &&
v.TryGetInt64(out var x)) return x;
return null;
}
var keyId = Read("RoomKeyId", "roomKeyId");
if (keyId is not long k || k <= 0) continue;
pairs.Add((k, Read("AccountId", "accountId", "PlayerId", "playerId") ?? Me));
}
}
catch (System.Text.Json.JsonException) { /* not a JSON array body */ }
return pairs;
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
RoomKey = key is null ? null : ToWire(key),
};
/// <summary>The 2023-03-21 room-key formatter reads nine members —
/// RoomKeyId, ReplicationId, RoomId, Name, Description, Price,
/// PurchaseCurrencyId, CreatedAt, ImageName
/// (RecNet.Runtime/ANHGLIFGACE.txt:627-814 name table, :829-993 reader).
/// CreatedAt was missing and silently defaulted to DateTime.MinValue in the
/// key ring. PurchaseCurrencyId (Nullable&lt;Guid&gt;) and ImageName have no
/// column on RoomKeyEntity yet, so they are still omitted rather than
/// emitted as permanent nulls — both are absent-tolerant on the client.</summary>
public static object ToWire(RoomKeyEntity key) => new
{
RoomKeyId = key.Id,
@@ -148,6 +292,7 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
key.Name,
key.Description,
key.Price,
key.CreatedAt,
};
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
@@ -173,50 +318,101 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
.FirstOrDefaultAsync();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
private async Task<List<long>> ReadRoomKeyIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (typeof(T) == typeof(NewRoomKeyRequest))
foreach (var key in new[] { "roomKeyIds", "RoomKeyIds", "ids", "Ids" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
return ids.Distinct().Take(200).ToList();
}
/// <summary>Read a room-key write request.
///
/// The 2023-03-21 client puts every scalar of create/update in the QUERY
/// STRING, not in a body: <c>BNDIAONDFFF.AFGEDDANEKP</c> only appends to the
/// param dictionary at field +0x20 (BNDIAONDFFF.txt:492-506), and the send
/// path joins that dictionary with <c>"&amp;"</c> and glues it onto the URL
/// behind <c>"?"</c> (BNDIAONDFFF.txt:3196, 3238, 3248-3251). So
/// <c>POST api/roomkeys/v1/create</c> and
/// <c>PUT api/roomkeys/v1/update*</c> arrive with an EMPTY body — reading
/// form/JSON only left Name null and RoomId 0, i.e. every create answered
/// NameTooShort and every edit DoesNotExist.
///
/// Query wins, form second, and a JSON body is only parsed when neither
/// carried anything (older clients and the admin UI still post JSON).</summary>
private async Task<T> ReadBodyAsync<T>() where T : new()
{
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
string? Field(params string[] names)
{
foreach (var name in names)
{
var q = Request.Query[name].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(q)) return q;
if (form is not null)
{
var f = form[name].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(f)) return f;
}
}
return null;
}
if (typeof(T) == typeof(NewRoomKeyRequest))
{
var roomId = Field("roomId", "RoomId");
var name = Field("name", "Name");
var description = Field("description", "Description");
var price = Field("price", "Price");
if (roomId is not null || name is not null || description is not null || price is not null)
{
object req = new NewRoomKeyRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: 0,
RoomId = long.TryParse(roomId, out var parsedRoomId) ? parsedRoomId : 0,
Name = name,
Description = description,
Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : 0,
};
return (T)req;
}
if (typeof(T) == typeof(UpdateRoomKeyRequest))
}
else if (typeof(T) == typeof(UpdateRoomKeyRequest))
{
// updateName sends only Name, updatePrice only Price, etc. — every
// field the client omitted stays null here and the handler keeps the
// stored value (AHMBBJNANBP.txt:1300-1345, 1550-1559, 1763-1772,
// 2018-2043).
var roomKeyId = Field("roomKeyId", "RoomKeyId");
var name = Field("name", "Name");
var description = Field("description", "Description");
var price = Field("price", "Price");
if (roomKeyId is not null || name is not null || description is not null || price is not null)
{
object req = new UpdateRoomKeyRequest
{
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
? roomKeyId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: null,
RoomKeyId = long.TryParse(roomKeyId, out var parsedKeyId) ? parsedKeyId : 0,
Name = name,
Description = description,
Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : null,
};
return (T)req;
}
return new T();
}
if (form is not null) return new T();
try
{
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
@@ -242,6 +438,8 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
public sealed class UpdateRoomKeyRequest
{
// The 2023 client spells this "RoomKeyId"; System.Text.Json is
// configured case-insensitively so both land on this property.
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }

Some files were not shown because too many files have changed in this diff Show More