Compare commits

...

73 Commits

Author SHA1 Message Date
Alexa 94781fd11a fix(auth): don't read the provider's userinfo endpoint by default
Cloudflare Access answers userinfo without a Content-Type, which the
OpenID Connect handler rejects ("Unknown response type"), so every
sign-in failed after the code exchange. The ID token already carries
sub and email; DorkNet:SignIn:UseUserInfoEndpoint turns the call back
on for providers that need it.
2026-10-25 17:24:25 +02:00
Alexa 99f8c2e016 Merge pull request #73 from DorkSquadRR/feat/external-sign-in
feat(auth): external sign-in for the website and admin panel
2026-10-25 17:00:04 +02:00
Alexa d4ce92f1b4 test(auth): give sign-in test players sequential ids 2026-10-25 16:55:57 +02:00
Alexa c7a087eea1 fix(site): parse return paths as URLs; refresh account state after changes
- localPath resolves the path against this origin, so control characters
  or backslashes can't turn it into another site's address.
- link, sign-out and unlink drop the cached account answer.
2026-10-25 15:56:45 +02:00
Alexa 5bfa463528 fix(auth): show only known sign-in error codes
Sign-in errors arrive in the query string, so unknown codes get a fixed
message instead of being echoed into the banner.
2026-10-25 15:54:51 +02:00
Alexa 891ec89899 fix(auth): encrypt stored cookie keys; tighten the link and sign-out flows
- Data-protection keys are encrypted (AES-256-GCM, key derived from the
  server secret) before they reach the DataProtectionKeys table.
- /link only follows local return paths and keeps the form when a
  signed-in visitor has an identity waiting to be linked.
- A failed sign-out says so instead of reloading as if it worked.
2026-10-25 15:52:29 +02:00
Alexa f0e4679887 feat(auth): external sign-in for the website and admin panel
Players sign in to the website, and admins to the admin panel, with an
OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the
provider identity (issuer + subject) to a DorkNet account with that
account's password; after that the provider sign-in is enough.

- Cookie sessions per host, keys persisted in the database so redeploys
  keep people signed in; unlink, deletion or a ban ends the session.
- Link attempts limited to 5 failures per 15 minutes per identity and
  per username.
- Admin panel: "Sign in with ..." exchanges the session for the usual
  admin tokens via GET /api/admin/v1/sso (IsAdmin still required).
- Downloads: optional DorkNet:Downloads:RequireSignIn serves the
  manifest and files only to signed-in players; the Downloads and Quest
  pages offer sign-in instead.
- Website: sign-in in the header, /link and /account pages.

The game client's login is unchanged.
2026-10-25 15:47:56 +02:00
Alexa 260f6605fc fix(admin-ui): derive the apex and subdomain style from the admin host
The admin SPA special-cased one deployment's hostnames to choose between
dot (img.<apex>) and hyphen (img-<apex>) subdomains. The server always
mounts the SPA on its own admin subdomain, so read both from that:
admin.<apex> means dot style, admin-<apex> hyphen style.
2026-10-25 14:42:10 +02:00
Alexa 12da61407d Merge pull request #72 from DorkSquadRR/ci/client-only-push-builds
ci: build on push only for client-facing changes
2026-10-25 14:32:39 +02:00
Alexa 5b93af4ba9 Merge pull request #71 from DorkSquadRR/chore/dotnet-10
chore: move the server, services and tools to .NET 10
2026-10-25 14:32:17 +02:00
Alexa 51104464f0 Merge pull request #70 from DorkSquadRR/fix/2023-room-instances-publish-permissions
fix: 2023 room instances, publish, role permissions, co-owner invites and sub-rooms
2026-10-25 14:21:44 +02:00
Alexa 35768c05ed Merge pull request #69 from DorkSquadRR/fix/postgres-bootstrap
fix: Postgres bootstrap on a fresh database and with replicas booting together
2026-10-25 14:21:21 +02:00
Alexa 0a5c3a9a64 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into chore/dotnet-10
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:07:30 +02:00
Alexa fd93b4b028 Merge remote-tracking branch 'origin/fix/postgres-bootstrap' into fix/2023-room-instances-publish-permissions
# Conflicts:
#	DorkNet.Server/Startup/DatabaseBootstrap.cs
2026-10-25 14:03:23 +02:00
Alexa 8be0f90dbc fix: sub-room id sequence, restore sync, presence index TTL, permission races
From the local CodeRabbit review:
- Scene ids: the repair and room clone assigned max + 1, which never
  advanced the Postgres identity sequence (live: sequence 3412, max id 3466),
  so the next add / clone sub-room would collide on the primary key. The
  repair now moves the sequence past the highest id at startup, and both
  let the database assign ids.
- Room-level restores (datahistory/restore and the admin restore) now also
  restore the entry sub-room, whose blob room details serve first.
- TouchRoom refreshes the room's presence index too, so players kept alive
  only by heartbeats no longer drop out of the instance browser after 20 min.
- Sub-room permission saves match cells exactly (as the unique index does)
  and retry when a concurrent editor inserted the same new cell.
2026-10-25 13:59:30 +02:00
Alexa d0d26c9821 fix: wait for the bootstrap lock without a command timeout; safe unlock
From the local CodeRabbit review:
- A replica waiting for the lock used the default 30 s command timeout and
  failed to boot while another replica seeded a fresh database. Wait with
  no timeout, logging that it is waiting.
- An unlock failure in the finally (e.g. the connection already broken)
  replaced the real bootstrap error and skipped closing the connection.
  Log it instead, and always close.
2026-10-25 13:52:20 +02:00
Alexa 503d838fd1 ci: treat .markdown like .md in push builds
From the local CodeRabbit review: a Markdown-only push using the .markdown
extension still rebuilt Quest. Exclude both extensions in the push planner
and in the documented job path filter.
2026-10-25 13:51:26 +02:00
Alexa bb22675a91 fix: hold the Postgres bootstrap lock across schema creation and seeding
Booting several replicas against one fresh Postgres database left all but
one dead on startup: the advisory lock only covered EnsureCreated, so the
seeders (system account, rooms, playlists, store, ...) raced and hit
duplicate keys. Take a session-level advisory lock for the whole bootstrap
and release it explicitly (pooled connections would otherwise keep it).
The upgrades and seeds both providers run move into
RunUpgradesAndSeedsAsync.
2026-10-25 13:01:43 +02:00
Alexa c1000c82aa fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 13:00:16 +02:00
Alexa 9076fb9fae fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:56:13 +02:00
Alexa cf7a021898 fix: repair a missing entry sub-room under the bootstrap lock
From the local CodeRabbit review:
- Repair rooms that lack an OrderIndex 0 (entry) sub-room even when they
  have other sub-rooms, not only rooms with none.
- Every service runs the database bootstrap at startup, so on Postgres
  replicas booting together could hand out the same scene ids (max + 1)
  and one would fail startup on the primary key. Run the repair in a
  transaction holding the bootstrap's advisory lock.
2026-10-25 12:52:53 +02:00
Alexa e29707deeb ci: keep the checkout so the path filter can skip non-client pushes
The job's path filter needs the last build's checkout to compare against.
The pipeline deleted the whole workspace after each run, so Jenkins polled
with "Working Directory does not exist", assumed changes, and started a
run for every push (builds #6 and #7: zero changed files). Delete only
out/ and quest-work/ instead.
2026-10-25 12:49:09 +02:00
Alexa 3b332a6476 fix: let EF own the Postgres bootstrap transaction (EF Core 10)
EF Core 10 runs EnsureCreated through the migration executor, which begins
its own transaction unless EF already has one. The bootstrap held the
advisory lock in a transaction begun on the raw connection, invisible to
EF, so a fresh Postgres database failed at startup with "A transaction is
already in progress". Begin it through db.Database and take the lock with
ExecuteSqlRaw so EnsureCreated runs inside it.
2026-10-25 12:32:09 +02:00
Alexa d295fd5c37 chore: Swashbuckle 10.3.1 for ASP.NET Core 10
Swashbuckle 7.3.1 is built against Microsoft.OpenApi 1.x; ASP.NET Core 10
brings Microsoft.OpenApi 2.x, and SwaggerGenerator.GetSwagger then fails to
load (TypeLoadException), which broke 105 of the server tests.
2026-10-25 12:22:38 +02:00
Alexa 8e12db660b chore: move the server, services and tools to .NET 10
.NET 8 and 9 reach end of support on 2026-11-10; .NET 10 is the LTS release
(supported until November 2028).

- net9.0 -> net10.0 for every server, service, gateway, test and tool project
- ASP.NET Core / EF Core / SignalR Redis 10.0.13, Npgsql EF 10.0.3,
  Serilog.AspNetCore 10.0.0
- Dockerfile / Dockerfile.service: sdk and aspnet 10.0 images
- release workflow: setup-dotnet 10.0.x
- Jenkins build agent: dotnet-sdk-10.0 (was 8.0); the ClientMod still
  targets net6.0, which MelonLoader loads
- README / architecture docs, .gitignore data paths

DorkNet.AdminMobile (MAUI) stays on net9.0 for now: building it needs the
Android/iOS workloads.
2026-10-25 12:12:57 +02:00
Alexa bafb380fc8 ci: build on push only for client-facing changes
Push builds used to rebuild both platforms for any Jenkinsfile or ci/jenkins/
change, and every build publishes to the downloads page. Now only
DorkNet.ClientMod/ and tools/build_pc.py (PC) or DorkNet.Quest/ (Quest)
changes rebuild, and Markdown never counts; CI-only changes take a manual
run. The README gives the matching Git path filter for the job, so other
pushes don't start a run at all.
2026-10-25 12:11:08 +02:00
Alexa 78c64d117a Merge remote-tracking branch 'origin/march-2023-03-21' into fix/2023-room-instances-publish-permissions 2026-10-25 11:59:05 +02:00
Alexa e7c0b076f2 Merge pull request #68 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:58:51 +02:00
Alexa 0a3bad7c6c Merge pull request #67 from DorkSquadRR/feat/quest-install-latest-build 2026-10-25 11:57:05 +02:00
Alexa 1718912a07 Merge pull request #66 from DorkSquadRR/ci/downloads-page 2026-10-25 11:56:51 +02:00
Alexa 5acad4c9e2 Merge pull request #65 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:56:38 +02:00
Alexa ec88a39750 ci: leave the downloads page to the DorkNet site
The server now offers the published builds on its own site (Downloads page,
Latest build on the Quest page), reading the bucket on the internal network.
Drop the standalone index.html and DORKNET_SITE_URL; the bucket needs no
public route or access app of its own.
2026-10-25 11:53:23 +02:00
Alexa 6e5b011059 ci: publish client builds to a downloads page under player-facing names
Each run now stages what it built as DorkNet-<release>-PC-Full.zip,
-PC-Patch.zip, -Quest.xapk and -Quest-Installer.zip, writes their sizes and
SHA-256 to a manifest, and uploads them with a static page to an S3 bucket
served read-only (ci/downloads). The build scripts keep their own names,
which the launcher depends on; only the published copies are renamed.

PUBLISH_DOWNLOADS (on by default) turns the upload off per run. Jenkins
archives the same renamed files instead of the raw build output.
2026-10-25 11:53:23 +02:00
Alexa 2e09042344 ci: run the agent with an init process; document the controller upgrade
- init: true on the agent so processes left behind by build tools are reaped,
  as the inbound-agent image asks for.
- Redeploying an older controller from docker-compose.yml upgrades Jenkins
  and moves it from Java 17 to 25: say to back up jenkins_home first, update
  plugins that fail to load, and run the job once before relying on it.
2026-10-25 11:53:21 +02:00
Alexa 9c5b574d6e ci: keep docker-compose.yml as the controller; move the agent to its own file
Turning ci/jenkins/docker-compose.yml from the controller into the agent meant
that redeploying a Jenkins set up from the old file would replace the
controller with an agent. Keep that file as the controller instead: the same
jenkins service and jenkins_home volume, now on the stock Java 25 image (its
old build context, ci/jenkins/Dockerfile, is the agent image now). The agent
moves to docker-compose.agent.yml. This replaces the previous commit's warning.
2026-10-25 11:50:09 +02:00
Alexa 29aca0e88c docs(ci): warn against redeploying an old controller app from the agent compose
ci/jenkins/docker-compose.yml used to define the Jenkins controller and now
defines only the agent. Redeploying an application that was set up from the
old file would replace the controller with an agent. Say so in the file, and
add an upgrade path to the README: move the controller to its own service on
the same data volume first, then deploy the agent as a new application.
2026-10-25 11:47:49 +02:00
Alexa eb73f3c484 fix: serve downloads only on the site host and select the clicked Quest build
Review follow-ups:
- The downloads routes now answer only on the apex (the site's host) and 404
  on every other accepted host, so the game's open API/CDN subdomains can't
  serve the builds.
- Install on Quest passes its own file to the Quest page instead of always
  picking the newest release.
- Waiting for the bucket's headers is limited to 30 s; the body still streams
  without a limit.
- Dispose the upstream request message; use LINQ in FindFile.
2026-10-25 11:06:43 +02:00
Alexa cc7f77b7de feat(site): Downloads page and Quest install from the latest build
When the server is pointed at the bucket CI publishes builds to
(DorkNet:Downloads:BaseUrl), the site lists them on a new Downloads page and
the Quest page offers "Latest build" next to "Choose file".

The server reads manifest.json and the files over HTTP on the internal network
and streams them through with Range support (DownloadsController); only files
the manifest lists are served. The xapk reader now reads from a byte source,
either the picked File or the server URL via Range requests, so the browser
installer streams the APK and OBB to the headset without downloading the
package first. Unset, both features stay hidden.
2026-10-25 10:55:43 +02:00
Alexa 19402459a9 ci: deploy the agent with base directory ci/jenkins
The base directory is the compose build context; from the repo root the
build used the server's Dockerfile instead of ci/jenkins/Dockerfile.
2026-10-25 01:21:07 +03:00
Alexa 3266ce47f3 ci: keep the agent's Jenkins URL and node secret runtime-only
Coolify interpolates the compose file during the image build with only
build-time variables. Marking JENKINS_URL and JENKINS_SECRET required
(${VAR:?}) made the build fail when they are runtime-only, and making them
build-time would pass the node secret as a build argument.
2026-10-25 01:18:35 +03:00
Alexa cf3e6ea025 ci: run client builds on a Java 25 build agent instead of the controller
The toolchain image becomes a Jenkins inbound agent (jenkins/inbound-agent,
Java 25) labelled dorknet-builder. Jenkins itself stays on the stock image
and runs no builds, which also clears the built-in-node warning.

- Dockerfile: inbound-agent base; restic cache on the agent's own volume
- docker-compose.yml: the agent as a Coolify Docker Compose application,
  reaching Jenkins over WebSocket on the predefined Coolify network
- Jenkinsfile: agent { label 'dorknet-builder' }
- README: Java 25 for both sides (Java 21 support ends Sep 2027, Java 17
  is unsupported), restart policy, node setup and Coolify deployment
2026-10-25 01:14:17 +03:00
Alexa bf0f09ce84 fix: answer 2023 room and playlist mutations with the success/value/error envelope
Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.

RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
2026-10-24 23:48:00 +03:00
Alexa fd2153d82b fix: 2023 room instances, publish, role permissions and co-owner invites
- Room roles: list the creator as Creator (255), not CoOwner (30). The
  client takes the local role from the Roles list and only lets a player
  edit roles below their own (Creator excepted), so the creator could not
  edit Co-Owner and Creator permissions. Enforce the role ladder on the
  server (RoomRoleRules), let invitees accept (PUT roles/{me} role=30) and
  reject (PUT roles/{me}/invite role=0), send message Types 62/60/61, and
  push RoomUpdate after every role change.
- Permissions: persist PUT subrooms/{sub}/permissions cells in a new
  RoomRolePermissions table, serve them from photon_access_token (plus a
  roomserver/ twin) and push PhotonAccessToken to the sub-room.
- Publish: player-owned rooms are never IsRRO on the wire; the client
  hides Publish and every room setting for an RRO.
- Sub-rooms: wire SubRoomId = OrderIndex + 1 (the client will not load a
  sub-room with id 0), new rooms always get a named Home sub-room, and a
  startup pass repairs rooms without one.
- Instance browser: per-room Redis presence index, live players only,
  private instances for the people allowed in, real IsFull.
- Room/sub-room settings: creator or accepted co-owner everywhere.
2026-10-24 23:22:53 +03:00
Alexa 0839d932ce Merge pull request #64 from DorkSquadRR/fix/login-sign-in-instead-of-auto-create
fix: open the sign-in page instead of creating a new account on a fresh client
2026-10-24 23:21:12 +03:00
Alexa 9523525cb8 fix: register the title-screen login patch so it actually applies on PC
GetPatchMethod only resolves patch methods from a fixed list of holder
classes; TitleScreenLoginPatches was missing, so the prefix failed with
[patch-fail] and a fresh PC client still looped on account creation.
Also log the real exception behind a failed sign-in redirect instead of
the TargetInvocationException wrapper.
2026-10-24 22:50:54 +03:00
Alexa 3880745efd fix: open the sign-in page instead of creating a new account on a fresh client
With no remembered account, the 2023 title screen creates a brand-new
account (grant_type create_account). On a server with sign-ups off that
fails and the title screen retries forever. PC (ClientMod) and Quest
now open the sign-in page instead, so players log into their existing
DorkNet account. AutoCreateAccount = true keeps Rec Room's behaviour.

Also replaces a literal NUL byte in the Quest config loader with '\0'.
2026-10-24 22:45:57 +03:00
Alexa 31d5a022c8 Merge pull request #62 from DorkSquadRR/ci/jenkins-client-builds
Build the PC and Quest clients on Jenkins
2026-10-24 20:37:05 +03:00
Alexa 83b896ffaf fix: drop an unused import and make the build scripts' exits explicit 2026-10-24 20:30:40 +03:00
Alexa fcc734726a Merge pull request #61 from DorkSquadRR/fix/notify-2023-contract
Notification pushes now match what the 2023 client reads
2026-10-24 20:28:51 +03:00
Alexa b1c1239165 ci: build the PC and Quest clients on Jenkins
A Jenkinsfile builds, on push (only what changed) or on demand:
- the PC patcher zip (MelonLoader, the mod, config template, Steam stub),
- the full PC client (stock build + patcher + config),
- the Quest APK and .xapk.

Stock builds come out of the restic build archive on the FTP: restic
through its rclone backend, picked by snapshot tag per run and cached.
The FTP login and restic password are Jenkins credentials, and the
repository folders are environment settings, so nothing about the
archive is fixed in the repo.

ci/jenkins/Dockerfile is the Jenkins image with the toolchain added
(.NET SDK, Android NDK and build-tools, apktool, Python with LIEF,
restic, rclone), for the existing Jenkins service.

The build scripts move from PowerShell to Python so they run the same
on Windows, Linux and in CI:
- DorkNet.Quest/scripts/build.py replaces build.ps1 + package.ps1; it
  can use a committed hook table (checked against the APK's
  libil2cpp.so) instead of dump.cs, and takes a persistent keystore.
- tools/build_pc.py replaces build-patcher-zip.ps1 (now with the
  2023 install layout) and adds the full-client build.

Setup steps are in ci/jenkins/README.md.
2026-10-24 20:26:42 +03:00
Alexa afad9bbc5a fix: address review on the notification pushes
- Force-join's RoomUpdate carries the room's role grants, so it no
  longer wipes them from the client's cached room.
- The consumable push reports IsTransferable the same as the inventory
  list does.
- A rejected private-instance join sends the owner at most one "wants
  to join" message per requester and instance every 5 minutes.
- SubscribeToPlayers ignores sets over 5,000 ids and keeps the current
  subscriptions.
- The subscriber exclusion list looks up online friends' connections in
  one pipelined Redis batch instead of one call per friend.
2026-10-24 20:25:51 +03:00
Alexa e33145307f fix: notification pushes now match what the 2023 client reads
Most pushes were still shaped for the 2020 watch, so the 2023 client
parsed them as empty or dropped them without any error.

- RelationshipChanged carries the relationship row, to both players, on
  friend request / accept / remove / add and on favorite / mute / ignore.
  Friend requests and accepts also land in the notifications inbox.
- ModerationKick uses the client's flat moderation block, with the
  reason in VoteKickReason (the only field the dialog shows).
- Bans, account deletion, password resets and second-session takeover
  are a Logout with a numeric reason instead of a kick to the dorm; the
  old string reason failed the client's parse.
- SubscribeToPlayers is honoured: each connection joins a presence group
  per subscribed player, and presence (only presence) reaches subscribers
  as well as friends. The hub allows 180 s of client silence, since the
  client reconnects at most 3 times per login.
- Admin broadcast sends its text as a coach message and can start the
  client's own maintenance countdown; ServerMaintenance only reads
  StartsInMinutes.
- ConsumableMapping, StorefrontBalance, gift, player event, level-up,
  RoomUpdate, profile-image and PlayerLeftChat pushes carry the client's
  DTOs. "Join rejected" is the client's RequestGameInvite message.

Contract in docs/recroom-2023-notifications.md; push frames are checked
by NotificationContractTests through a capturing hub context.
2026-10-24 19:50:05 +03:00
Alexa 38335c06a9 Merge pull request #60 from DorkSquadRR/fix/room-clone-envelope
Creating a room no longer fails with a blank "Failed to copy room"
2026-10-17 20:21:43 +03:00
Alexa 5ab28b1d4f fix: creating a room failed with a blank "Failed to copy room"
The 2023 client sends rooms/{id}/clone through the same RecNet rooms
helper as "save room" and "restore subroom save", which reads the
{success, value, error} envelope. The clone handler answered with the
bare room details, so the client found no success flag and rejected the
reply with an empty error, even though the room had been created.

Clone now returns the envelope with the full room details in value, and
refusals (unknown source, cloning not allowed) are a 200 with
success=false and a message instead of a bodyless 404/403.
2026-10-17 20:21:54 +03:00
Alexa d60b0642a8 Merge pull request #59 from DorkSquadRR/feat/quest-web-installer
Install the Quest build from the website
2026-10-16 02:19:31 +03:00
Alexa a7879d7ede feat: install the Quest build from the website
A Quest page on the public site (/quest) installs a DorkNet .xapk from
Chrome or Edge over WebUSB, with no adb, SideQuest or scripts: pick the
.xapk, connect the headset, install. It installs the APK, copies the OBB
to its install path (skipped when an identical one is already there) and
launches the app, with progress bars and plain-language errors for an
unsupported browser, a headset held by adb/SideQuest, a disconnect and a
copy signed with a different key.

The .xapk is read from the player's own disk: the APK and OBB are stored
uncompressed, so they stream straight out of the file without loading
1.4 GB into memory, and the server never hosts game files.

Uses Tango (@yume-chan/adb 2.6.4 and friends, MIT), pinned and bundled;
the page is lazy-loaded so the rest of the site doesn't grow. Credited in
NOTICE; README, DorkNet.Quest/README.md, docs/quest.md and the packaged
README.txt point players at the page.
2026-10-15 22:34:25 +03:00
Alexa 6272844445 Merge pull request #58 from DorkSquadRR/fix/base-avatar-items
Owned items showed a price: serve only the real base avatar items
2026-10-15 22:09:23 +03:00
Alexa 2dbd03b9ae fix: owned items showed a price because they were served as base avatar items
defaultbaseavataritems returned the first 100 catalog items. The 2023
client flags every row of that list as a custom-clothing template
(AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips
templates when it rebuilds the owned outfit-menu entries, so those 100
ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even
when owned and equipped, while the cart (which checks the owned set)
said "owned".

- defaultbaseavataritems returns only the UGC templates, UGCTee_Shirt
  (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), as the real
  service served them
- those two keep the same ids in every list and carry IsBaseAvatarItem
- they're no longer sold (existing shop rows are removed on seed) or
  listed as owned clothing
2026-10-15 22:08:59 +03:00
Alexa f4b4836375 Merge pull request #57 from DorkSquadRR/fix/owned-item-metadata
Owned avatar items use their shop tile's name, tooltip and rarity
2026-10-15 21:45:40 +03:00
Alexa ee7d614262 fix: owned avatar items use their shop tile's name, tooltip and rarity
The owned list (v4/items, defaultunlocked, lockeditems) took names from
the catalog dump, so colour variants read as asset names like
"(Shirt_Winter)", every rarity was 0 and every tooltip empty. 1,864 of
the 2,719 items in both lists disagreed with their shop tile.

Each owned row now takes FriendlyName, Tooltip and Rarity from the store
row with the same AvatarItemDesc (exact match; colour-variant combination
ids are case-sensitive). Rows with no shop tile keep the catalog name.
Rarity uses the same GiftRarity enum on both DTOs.
2026-10-15 21:44:49 +03:00
Alexa 93b4e43316 Merge pull request #56 from DorkSquadRR/feat/cv2-network-limits
Free default avatar items + DorkNet.Quest client patcher
2026-10-15 20:33:18 +03:00
Alexa 975753deac feat: DorkNet.Quest, a sideload patcher for the 2023 Quest client
Patches the Quest build of Rec Room 2023.03.21 (20230317 / 2128210, the
same appVersion as the PC client) to connect to a DorkNet server, using
one native hook library loaded via DT_NEEDED on libmain.so.

- host rewrite onto <svc>.<ServerHost> or a {service}-dev.example.com template
- Photon app ids and region injection (PhotonRegion must match PC)
- TLS / pinning / image-signature bypass, integrity callbacks neutralised
- no-Meta-account build; the server treats platform id 0/1/blank as a
  placeholder and keys those accounts by device id
- build.ps1 -Obb packages a DorkNet-<ver>.xapk with one-command
  installers for Windows and macOS/Linux; app is named via AppName
- README, docs/quest.md and NOTICE updated; credits RecFlare and
  And64InlineHook (MIT)

Tested on a Quest 2: all hooks arm, requests reach the dev server.
2026-10-15 20:29:32 +03:00
Alexa cebe7ae3ee fix: free default avatar items showed a price in the 2023 outfit menu
The 2023 client replaces its unlocked-item set with every list it
downloads (OutfitManager clears and refills it) and never merges its own
default list, so the server has to include the free
default_unlocked_variants items. v4/items only returned the player's
inventory, so items like Beard Close showed as buyable.

- v4/items returns the player's items plus the 239 default items, deduped,
  with room kept for them under MaxUnlockedItems
- defaultunlocked returns the default items before login
- lockeditems never reports a default item as locked
2026-10-15 20:29:32 +03:00
Alexa 8373e592f4 Merge pull request #55 from DorkSquadRR/feat/cv2-network-limits
2023 client: presence, profile, outfits, RecNetResult writes, CircuitsV2 limits
2026-10-15 14:10:25 +03:00
Alexa 0c899a1001 Merge #52 (March 2023 README fixes), superseded by the 2023 README rewrite
The same corrections (2023 client, march_2023_03_21 key, accepted build
ids 20230317/20230321, clone branch, Late2020 plugin name) are already in
this branch's README; keeping this branch's version of the file.
2026-10-15 14:10:02 +03:00
Alexa 34d4c9d115 Merge remote-tracking branch 'origin/march-2023-03-21' into feat/cv2-network-limits 2026-10-15 14:08:17 +03:00
Alexa 6ee1d6f9b3 fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.

Presence
- matchmake/none means "online, in no room" (its only caller is
  TryGoToOfflineRoom, which ignores the reply). The server used to keep the
  old room or invent a dorm, so the next heartbeat looked out of sync and
  the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
  it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
  joins, matchmake/none, logout, failed joins, visibility changes, SignalR
  connect/disconnect. isOnline is sent on its own; pushes never go to the
  subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
  is stored and honoured by GET /player and by the live pushes.

Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
  404; added with the client's real form keys and persisted (new Players
  columns plus migration). Every account read now returns them, and edits
  push AccountUpdate to everyone.

Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
  slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
  client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
  form bodies; lockeditems returns the full owned-item shape; colourways no
  longer share their base item's AvatarItemId.

RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
  An empty 200 showed "Failed to ..." after the server applied the change,
  and a body without Success threw "Error message cannot be null". Clubs,
  announcements, keepsakes, comments/read, subscriptions, purchase offers
  and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
  device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
  GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
  Route inventory and coverage test extended to match.

README now describes the 2023 branch.
2026-10-15 14:08:11 +03:00
Alexa 36e64e99ba feat: lift the CircuitsV2 limits, Rec Royale night mode, RRO room blobs
CircuitsV2 limits are served as UGC.CircuitsV2.Limit.* gameconfig keys and
are editable from admin Settings. Retail drops any send over 5 RPCs/frame
or 1024 bytes/60 frames; both network budgets now default to 1,000,000,000.
The creation limits are raised too, each against the hazard the client
binary shows for it:

- Chips 3000 -> 30000: the chip-culling manager preallocates four
  List<Chip>(Chips), so the 1M clamp stays.
- Compute 50000 -> 500000: a per-frame budget with no allocation; the 10M
  cap keeps its counters from wrapping.
- CloudValues 100 -> 1000, InkPerChip 15 -> 0: InkPerChip is an ink COST,
  multiplied by min(chips, CloudValues), so lowering it is the increase.
  The clamps tighten to 10000 / 30000 to keep that product from overflowing
  (the room ink cap is a hard-coded 30000).
- UnculledChips / VisibleChips 500 -> 3000: render-cost counts only.

Room save uploads may now be up to 100 MB (Kestrel's 30 MB default would
413 a big circuits room). Contract and evidence:
docs/recroom-2023-circuitsv2-limits.md.

Also includes the Rec Royale night mode client feature
(docs/recroom-2023-scene-moods.md) and the admin blob enable/reset for
baked RRO sub-rooms.
2026-10-15 14:07:54 +03:00
tyl-droid f2e7532853 docs: prevent March 2023 setup from selecting December 2026-09-10 00:33:33 +01:00
Alexa 1b3edf008d Merge pull request #49 from DorkSquadRR/deps/frontend-security-bumps
chore(deps): clear all Dependabot alerts (React 19, react-router 8, Vite 7)
2026-08-02 01:17:52 +03:00
Alexa e6e5c9990f chore(deps): pin Node 22.22+ for frontend builds
CodeRabbit on PR #49: the Docker build stages still used node:20-alpine,
which vite 7 (>=20.19/22.12) and react-router 8 (>=22.22) no longer
support. Bump NODE_VERSION to 22 in both Dockerfiles and declare
engines.node >=22.22.0 in both app manifests so local installs on an
old Node fail loudly instead of at build time.
2026-08-02 01:05:49 +03:00
Alexa 1d7801b3c0 Merge remote-tracking branch 'origin/march-2023-03-21' into deps/frontend-security-bumps 2026-08-02 01:03:57 +03:00
Alexa bb04782e11 chore(deps): clear Dependabot alerts — React 19, react-router 8, Vite 7
Bumps both frontend apps (site, admin-ui):
- vite 5.4 -> 7.3.6 (pulls esbuild 0.28, patched dev-server CVEs)
- @vitejs/plugin-react 4 -> 5 (babel 7.29.7)
- postcss 8.4 -> 8.5.25
- react-router-dom 6.27 -> react-router 8.3.0 (package renamed
  upstream in v8; imports swapped in src). Fixes the open-redirect
  advisories with no patched 6.x, plus the RSC CSRF advisory
  (GHSA-qwww-vcr4-c8h2) that only 8.3.0 patches.
- react/react-dom 18.3 -> 19.2 (required by react-router 8)

npm audit: 0 vulnerabilities in both apps. tsc + vite build clean.
2026-08-02 00:57:34 +03:00
205 changed files with 35311 additions and 2582 deletions
@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 9.0.x dotnet-version: 10.0.x
- name: Install protobuf compiler - name: Install protobuf compiler
run: | run: |
@@ -40,7 +40,7 @@ jobs:
- name: Start server - name: Start server
run: | run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 & dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid" echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness - name: Wait for server readiness
@@ -57,7 +57,7 @@ jobs:
- name: Run endpoint contract - name: Run endpoint contract
run: | run: |
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \ dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \ --server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \ --base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json" --report "$RUNNER_TEMP/endpoint-contract-report.json"
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9.0.x' dotnet-version: '10.0.x'
# ── Server self-contained publish ──────────────────────────────── # ── Server self-contained publish ────────────────────────────────
- name: Publish DorkNet.Server (win-x64, self-contained) - name: Publish DorkNet.Server (win-x64, self-contained)
@@ -43,10 +43,10 @@ jobs:
Compress-Archive -Path publish/server/* ` Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip -DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ─── # ── Client patcher zip (built via tools/build_pc.py) ─────────────
- name: Build client patcher zip - name: Build client patcher zip
run: | run: |
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }} python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ─────────────────────────────── # ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets - name: Upload Release assets
+2 -2
View File
@@ -12,8 +12,8 @@ dist/
# Local SQLite + uploaded blobs only — DON'T match the source-tree # Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on # DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
# case-insensitive filesystems. Use the precise runtime data dirs. # case-insensitive filesystems. Use the precise runtime data dirs.
DorkNet.Server/bin/Debug/net9.0/data/ DorkNet.Server/bin/Debug/net10.0/data/
DorkNet.Server/bin/Release/net9.0/data/ DorkNet.Server/bin/Release/net10.0/data/
# appsettings.Production.json is intentionally versioned — contains # appsettings.Production.json is intentionally versioned — contains
# only the Postgres provider switch + Kestrel bind address. All real # only the Postgres provider switch + Kestrel bind address. All real
# secrets come from Coolify env vars at runtime. # secrets come from Coolify env vars at runtime.
+5 -5
View File
@@ -1,15 +1,15 @@
# DorkNet.Server container image — Coolify deploy target. # DorkNet.Server container image — Coolify deploy target.
# ──────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the # Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:9.0 with only the published output. # slimmer aspnet:10.0 with only the published output.
# #
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible # Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the # to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the # repo root keeps the bin/, obj/, and other dev junk out of the
# context. # context.
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=22
# ── admin SPA build stage ────────────────────────────────────────── # ── admin SPA build stage ──────────────────────────────────────────
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a # The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
@@ -76,13 +76,13 @@ WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for # curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so # its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:9.0 base image strips both by # both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small. # default to keep the surface area small.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \ && apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654. # Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID. # Coolify mounts persistent volumes with write permission for this UID.
USER app USER app
+2 -2
View File
@@ -1,5 +1,5 @@
ARG DOTNET_VERSION=9.0 ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20 ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS admin-ui FROM node:${NODE_VERSION}-alpine AS admin-ui
WORKDIR /ui WORKDIR /ui
+155 -3
View File
@@ -6,8 +6,9 @@
// 2. 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) // Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
// needed so a modded client isn't kicked, and two opt-in features that // 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). // 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 // This is the STABLE, shipping mod: it carries no diagnostic/tracing
// code. All of that lives in the separate DorkNet.DebugMod (drop it in // code. All of that lives in the separate DorkNet.DebugMod (drop it in
@@ -109,6 +110,60 @@ public class Mod : MelonMod
// generic "Quest" config is intentionally NOT in the default list. // generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms = public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" }; { "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 = "";
// A client with no remembered account for this machine. false: the
// title screen opens the sign-in page so the player logs into their
// existing account. true: Rec Room's own behaviour, which creates a
// new account automatically -- only useful when the server allows
// sign-ups (otherwise the title screen keeps retrying and failing).
// See TitleScreenLoginPatches.
public static bool AutoCreateAccount = false;
} }
public override void OnInitializeMelon() public override void OnInitializeMelon()
{ {
@@ -178,6 +233,27 @@ public class Mod : MelonMod
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix)); postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
} }
// Title-screen auto-login: with no remembered account, open the
// sign-in page instead of creating a new account.
if (!Cfg.AutoCreateAccount)
{
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
}
// 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))
{
_recRoyaleNightArmed = true;
RecRoyaleNight.Register(HarmonyInstance);
}
}
private bool _recRoyaleNightArmed;
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
{
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
} }
private bool PatchFileHashCheckerCallback() private bool PatchFileHashCheckerCallback()
{ {
@@ -359,6 +435,8 @@ public class Mod : MelonMod
} }
public override void OnUpdate() public override void OnUpdate()
{ {
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300) if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{ {
_antiTamperCallbackRetryFrame = 0; _antiTamperCallbackRetryFrame = 0;
@@ -395,6 +473,7 @@ public class Mod : MelonMod
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{ {
@@ -404,6 +483,33 @@ public class Mod : MelonMod
rooms.Add(e.GetString()!); rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
} }
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 names = new List<string>();
foreach (var e in v.EnumerateArray())
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();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -573,7 +679,7 @@ public class Mod : MelonMod
private MethodInfo GetPatchMethod(string name) private MethodInfo GetPatchMethod(string name)
{ {
// Look in the core + feature patch holder classes. // 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) }) foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
{ {
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m; if (m is not null) return m;
@@ -814,6 +920,52 @@ internal static class FileHashCheckerPatches
return false; return false;
} }
} }
internal static class TitleScreenLoginPatches
{
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
// auto-login: the title screen downloads this machine's remembered
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
// grant_type create_account). A server with sign-ups off refuses that, the
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
// "Log in" button does). ManuallyLogin carries the player into the game
// from there on its own, so the boot step gets a promise that never
// settles (MAMJDBJDFAF, the game's non-generic promise).
private static Type? _promiseType;
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
{
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
try
{
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
if (_promiseType is null || goToLogin is null)
{
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return true;
}
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
// its own class, so hand back the new promise's native object
// wrapped in that type.
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
var returnType = ((MethodInfo)__originalMethod).ReturnType;
var result = Activator.CreateInstance(returnType, pending.Pointer);
goToLogin.Invoke(__instance, null);
__result = result;
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
return false;
}
catch (Exception ex)
{
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
return true;
}
}
}
internal static class ToxModPatches internal static class ToxModPatches
{ {
public static bool CanInitializeToxMod_Prefix(ref bool __result) public static bool CanInitializeToxMod_Prefix(ref bool __result)
+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,6 +1,7 @@
{ {
"ServerHost": "{HOST}", "ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}, "EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"AutoCreateAccount": false,
"EnableDebugConsole": false, "EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote", "DebugConsoleToggleKey": "BackQuote",
"DevCommands": [], "DevCommands": [],
@@ -11,5 +12,18 @@
"GoldenTrophy", "GoldenTrophy",
"IsleOfLostSkulls", "IsleOfLostSkulls",
"TheRiseofJumbotron" "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": ""
} }
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
+37
View File
@@ -88,6 +88,36 @@ public class RecNetAccount
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response". // Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
[JsonPropertyName("createdAt")] [JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
// AEGCKMKICFG) registers these keys in three casings each, e.g.
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
// Missing keys default on the client, which is why profile emoji,
// banner, pronouns and identity flags never rendered for anyone.
private string displayEmoji = string.Empty;
private string bannerImage = string.Empty;
[JsonPropertyName("displayEmoji")]
public string DisplayEmoji
{
get => displayEmoji;
set => displayEmoji = value ?? string.Empty;
}
[JsonPropertyName("bannerImage")]
public string BannerImage
{
get => bannerImage;
set => bannerImage = value ?? string.Empty;
}
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
[JsonPropertyName("personalPronouns")]
public int PersonalPronouns { get; set; }
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
[JsonPropertyName("identityFlags")]
public int IdentityFlags { get; set; }
} }
/// <summary> /// <summary>
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
[JsonPropertyName("parentAccountId")] [JsonPropertyName("parentAccountId")]
public int? ParentAccountId { get; set; } = null; public int? ParentAccountId { get; set; } = null;
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
// defaults to 0, which the settings UI renders as "no username changes
// left". DorkNet does not rate-limit username changes, so report a
// generous allowance.
[JsonPropertyName("availableUsernameChanges")]
public int AvailableUsernameChanges { get; set; } = 99;
} }
/// <summary> /// <summary>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
*.cmd text eol=crlf
+9
View File
@@ -0,0 +1,9 @@
# Operator secrets and build outputs — never commit.
dorknet-quest.cfg
*.keystore
*.so
build/
*_generated.h
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
# only the generated C header is ignored.
+237
View File
@@ -0,0 +1,237 @@
# DorkNet.Quest
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
MelonLoader, so everything is done in **one injected native library**
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
loader maps it before Unity, then it installs arm64 inline hooks at addresses
resolved from the build's own `dump.cs`.
This build reports appVersion **`20230317`** — the *same* version the PC client
reports — so a Quest player patched with this tool shares rooms, presence and
Photon shards with PC DorkNet players with no server-side version change.
> This ships **no** Rec Room code or assets. You supply your own extracted store
> APK and OBB. It disables the client's certificate pinning and image-signature
> checks so a self-hosted server works; do not redistribute game binaries.
## What it does
| Hook | Effect | PC equivalent |
| --- | --- | --- |
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
### Two builds, selected by `OculusAppId`
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
app you registered; the platform hooks stay disarmed. Players need a Meta
account, and your package + signing cert must be registered with that app.
- **Blank** — the *no-Meta-account build*: the platform path is steered past
Meta's checks to Rec Room's own username/password screen. It authenticates
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
server treats platform id `1` (and `0`/blank) as a non-identity and keys
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
players are **not** merged into one account and are never offered each other's
"remembered logins". **Never** add a server rule that accepts platform id `1`
as an identity.
## Prerequisites
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
`dump.cs` for the target APK (see below).
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
and its matching **OBB**.
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
> keep the work dir outside protected folders.
## Build
```powershell
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
dotnet Il2CppDumper.dll `
<extracted>\lib\arm64-v8a\libil2cpp.so `
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
<scratch>\dump
# 2. Configure:
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
# 3. Build the signed APK (and, with --obb, the release package):
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
py -3 DorkNet.Quest\scripts\build.py build `
--apk C:\path\to\RecRoom-store-2128210.apk `
--dump-cs <scratch>\dump\dump.cs `
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
--keystore C:\path\to\dorknet-debug.keystore
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
```
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
For a build whose target table is already committed under `targets/`, pass
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
Jenkins job does (`ci/jenkins/README.md`).
With `--obb`, the build also writes a release folder ready to hand to players:
| File | Purpose |
| --- | --- |
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
| `install.sh` | one-command install on macOS / Linux |
| `README.txt` | player instructions |
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
The package contains game binaries from your store copy, so only share it with
people who own the game.
`build.py build` chains: resolve targets → generate headers → compile
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
keystore in the work dir on first run. **Keep that keystore** and pass it with
`--keystore` from then on — Meta ties tokens to app id + package
+ signing cert, so re-signing with a different key breaks any Meta registration.
It is never committed.
### Rebasing onto a different build
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
the PC build of the same release, so the PC decompile is the oracle. For a new
build:
1. Dump it with Il2CppDumper.
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
(cross-check against the PC decompile first).
3. Commit the new `targets/<v>-<c>.json`.
The library refuses to arm any target whose 20-byte fingerprint does not match
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
## Install
**From the browser (easiest).** Open the **Quest** page of your DorkNet site
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
the headset, then **Install**. The page talks to the headset over WebUSB
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
path (skipped if an identical one is already there) and launches the app. The
`.xapk` never leaves the player's computer; the server hosts no game files.
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
only one program can hold the headset's USB connection. Firefox, Safari and
phones don't support WebUSB.
**From a release folder**, one command does everything (finds adb and the headset,
installs the APK, copies the OBB to the right path, skips the OBB if it is
already there, and launches the app):
```powershell
.\install.cmd # Windows (or double-click it)
./install.sh # macOS / Linux
```
Both take an optional package path, `-Serial`/`--serial` for a specific headset
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
Library > Unknown Sources.
By hand:
```sh
PKG=net.dorknet.RecRoom # your PackageName
VC=2128210
adb uninstall $PKG 2>/dev/null
adb install -r dorknet-recroom-20230317.apk
adb shell mkdir -p /sdcard/Android/obb/$PKG
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
```
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
Unity rebuilds that exact path at runtime, so the source file's own name is
irrelevant, only the destination is.
## Check it on the headset
**Wear the headset** — off-head it sleeps within seconds and never finishes
init, which looks like a crash.
```sh
adb logcat -c
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
adb logcat -d -s DORKNET
```
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
`photon appsettings: RT=... Voice=... Chat=...`.
| Symptom | Likely cause |
| --- | --- |
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
| boots to an empty world | OBB missing or named for the wrong versionCode |
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
| multiplayer never connects | Photon app ids not set in the cfg |
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
## Layout
| Path | Purpose |
| --- | --- |
| `src/dorknet_quest.cpp` | all hooks + init |
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
| `targets/spec.json` | target signatures (class header + method), build-independent |
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
## Credits
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
showed the way this patcher is built: one native hook library loaded
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
hooking. They target a newer build (20230414), so every hook target here
was re-derived from the 20230317 build; no RecFlare code is included.
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
vendored under `src/third_party/`.
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
+80
View File
@@ -0,0 +1,80 @@
# DorkNet.Quest build configuration.
#
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
# these into libdorknet.so at compile time. Omit a key to keep the built-in
# default. This same file can be pushed to
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
# to override on-device without a rebuild.
# ---- deployment ----
# The apex your DorkNet server is reachable at. The client's service hosts are
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
# then supplies every other service URL. Your DNS must resolve each subdomain
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
# them. No default -- required.
#
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
ServerHost = example.com
# Android package name for the patched build, so it coexists with the store
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
# package + signing cert, and the runtime-config path derives from it.
PackageName = net.dorknet.RecRoom
# Name shown in the headset library (Unknown Sources). Build-time only.
AppName = DorkNet
# ---- Meta (Oculus) platform ----
# Your registered Meta app id. Set it for the Meta build: real entitlement and
# Meta login run against an app your players own. Leave BLANK for the
# no-Meta-account build -- the platform entitlement/login path is steered to the
# client's own username/password screen. Blank sends platform id 1 from every
# headset; DorkNet's server treats that as a placeholder and keys accounts by
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
# merged. Never add a server rule that accepts platform id 1 as an identity.
OculusAppId =
# ---- Photon ----
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
# reject external clients. Leave an id blank to keep the client's own (dead)
# value -- multiplayer will not connect until these are set. No defaults.
PhotonAppId =
PhotonVoiceAppId =
PhotonChatAppId =
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
PhotonNameServer =
PhotonNameServerPort = 0
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
# config), or Quest and PC players connect to different masters and never meet.
PhotonRegion = us
# ---- client behaviour ----
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
# Required for a self-hosted server; turn off only if you front it with a cert
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
EnableTlsTrustBypass = true
# Accept image Content-Signatures the server signs with its own key.
AcceptUnsignedImages = true
# A headset with no remembered account. false: open the sign-in page so the
# player logs into their existing DorkNet account. true: Rec Room's own
# behaviour, which creates a new account automatically -- only useful when the
# server allows sign-ups (otherwise the game keeps retrying and failing).
AutoCreateAccount = false
# ---- diagnostics (adb logcat -s DORKNET) ----
LogHttp = true
# WARNING: Debug logs request/response BODIES, including auth tokens and
# passwords. Leave off except when debugging, and don't share the logs.
Debug = false
LogHttpBodyMax = 10000
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
From a pristine store APK, end to end:
1. hook targets resolve them from an Il2CppDumper dump.cs
(resolve_targets.py), or use a committed table checked
against the APK's libil2cpp.so
2. headers targets_generated.h + config_generated.h
3. compile libdorknet.so (arm64) with the Android NDK
4. apktool decode
5. rename package rename, launcher category, app label
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
7. sign apktool build -> zipalign -> apksigner
8. package with --obb: .xapk + one-command installers (or run
`package` on an APK you already built)
Nothing game-derived is written into the repo. You supply the store APK and,
for a release package, the matching OBB. The output contains game binaries
from your own store copy: share it only with people who own the game.
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
keytool), the Android NDK, Android build-tools and apktool.
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import NoReturn
from xml.sax.saxutils import escape as xml_escape
SCRIPTS = Path(__file__).resolve().parent
REPO = SCRIPTS.parent # DorkNet.Quest/
OLD_PKG = "com.AgainstGravity.RecRoom"
SO_NAME = "libdorknet.so"
ON_WINDOWS = os.name == "nt"
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def run(*cmd: object, cwd: Path | None = None) -> None:
args = [str(c) for c in cmd]
r = subprocess.run(args, cwd=cwd)
if r.returncode:
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
def capture(*cmd: object) -> str:
args = [str(c) for c in cmd]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode:
sys.stderr.write(r.stderr)
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
return r.stdout
def run_py(script: str, *args: object) -> None:
"""Run a sibling helper script with this interpreter (isolated mode)."""
run(sys.executable, "-I", SCRIPTS / script, *args)
def exe(name: str) -> str:
return name + (".exe" if ON_WINDOWS else "")
# ---- tool resolution ------------------------------------------------------
def sdk_root() -> Path | None:
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
return Path(v) if v else None
def newest_subdir(parent: Path) -> Path | None:
if not parent.is_dir():
return None
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
return dirs[0] if dirs else None
def resolve_build_tools(arg: str | None) -> Path:
if arg:
return need(arg, "build-tools dir")
root = sdk_root()
found = newest_subdir(root / "build-tools") if root else None
if not found:
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
return found
def resolve_ndk(arg: str | None) -> Path:
if arg:
return need(arg, "NDK")
if os.environ.get("ANDROID_NDK"):
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
root = sdk_root()
found = newest_subdir(root / "ndk") if root else None
if not found:
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
return found
def ndk_bin(ndk: Path) -> Path:
if ON_WINDOWS:
tag = "windows-x86_64"
elif sys.platform == "darwin":
tag = "darwin-x86_64"
else:
tag = "linux-x86_64"
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
def resolve_apktool(arg: str | None) -> Path:
if arg:
return need(arg, "apktool.jar")
if os.environ.get("APKTOOL_JAR"):
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
root = sdk_root()
if root and (root / "apktool.jar").exists():
return root / "apktool.jar"
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
def cfg_value(config: Path, key: str) -> str | None:
for line in config.read_text(encoding="utf-8").splitlines():
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
if m:
return m.group(1).split("#", 1)[0].strip()
return None
# ---- build ----------------------------------------------------------------
def cmd_build(a: argparse.Namespace) -> None:
ndk = resolve_ndk(a.ndk_root)
bin_dir = ndk_bin(ndk)
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
"NDK aarch64 clang++")
objdump = bin_dir / exe("llvm-objdump")
build_tools = resolve_build_tools(a.build_tools_dir)
zipalign = need(build_tools / exe("zipalign"), "zipalign")
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
apktool = resolve_apktool(a.apktool_jar)
apk = need(a.apk, "store APK").resolve()
config = Path(a.config) if a.config else (
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
need(config, "config")
print(f"Using config: {config}")
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
app_name = cfg_value(config, "AppName") or "DorkNet"
work = Path(a.work_dir)
gen, project, extract = work / "gen", work / "project", work / "extract"
for d in (work, gen, extract):
d.mkdir(parents=True, exist_ok=True)
# 1. extract + targets
step("extracting libil2cpp.so + global-metadata.dat")
with zipfile.ZipFile(apk) as z:
for entry in ("lib/arm64-v8a/libil2cpp.so",
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
"lib/arm64-v8a/libmain.so"):
try:
data = z.read(entry)
except KeyError:
fail(f"APK missing {entry}")
(extract / Path(entry).name).write_bytes(data)
il2cpp = extract / "libil2cpp.so"
ver_name, ver_code = "20230317", "2128210"
if a.targets_json:
# A committed, already-resolved table. It is only valid for the exact
# libil2cpp.so it was resolved against, so check that first.
table = need(a.targets_json, "target table")
step(f"using target table {table}")
data = json.loads(table.read_text(encoding="utf-8"))
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
want = str(data.get("il2cppSha256") or "").lower()
if want and sha != want:
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
f"for ({want}). Resolve a table for it with --dump-cs.")
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
else:
if not a.dump_cs:
# Dumping is left to Il2CppDumper (see README) to keep this
# script toolchain-light.
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
"resolved table (see DorkNet.Quest/README.md).")
need(a.dump_cs, "dump.cs")
step("resolving hook targets")
# Written into the work dir (Controlled Folder Access can block writes
# under Documents). The repo keeps a committed copy under targets/.
table = work / f"{ver_name}-{ver_code}.json"
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
"--version-name", ver_name, "--version-code", ver_code,
"--objdump", objdump, "--out", table)
# 2. headers
step("generating headers")
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
# 3. compile
step("compiling libdorknet.so (arm64)")
src = REPO / "src"
so = work / SO_NAME
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
f"-I{gen}", f"-I{src}",
src / "dorknet_quest.cpp", src / "config.cpp",
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
"-llog", "-o", so)
need(so, "libdorknet.so")
# 4. decode
step("apktool decode")
if project.exists():
shutil.rmtree(project)
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
# 5. package rename + manifest + label
step(f"renaming package {OLD_PKG} -> {pkg_name}")
manifest = project / "AndroidManifest.xml"
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
if "android.intent.category.LAUNCHER" not in m:
m = m.replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/>'
'<category android:name="android.intent.category.LAUNCHER"/>')
manifest.write_text(m, encoding="utf-8", newline="")
# The launcher label comes from @string/app_name; set it in every locale.
step(f"naming the app '{app_name}'")
# XML entities for & < > " ' (the PowerShell version used
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
label = xml_escape(app_name, {'"': "&quot;", "'": "&apos;"})
for sx in (project / "res").rglob("strings.xml"):
s = sx.read_text(encoding="utf-8")
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
if n != s:
sx.write_text(n, encoding="utf-8", newline="")
# 6. inject
step(f"injecting {SO_NAME}")
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
# 7. build + align + sign
step("apktool build")
unsigned = work / "unsigned.apk"
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
if a.keystore:
keystore = need(a.keystore, "keystore")
else:
keystore = work / "dorknet-debug.keystore"
if not keystore.exists():
step("generating a fresh debug keystore (kept in the work dir, never committed)")
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
"-dname", "CN=DorkNet Quest, O=DorkNet")
step("zipalign")
aligned = work / "aligned.apk"
run(zipalign, "-f", "-p", "4", unsigned, aligned)
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
out_apk.parent.mkdir(parents=True, exist_ok=True)
step(f"apksigner -> {out_apk}")
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
"--out", out_apk, aligned)
capture(apksigner, "verify", "--print-certs", out_apk)
print()
print(f"DONE. Patched APK: {out_apk}")
print(f"Package: {pkg_name} versionCode: {ver_code}")
if a.obb:
print()
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
else:
print("Install it, then push the matching OBB to:")
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
print("(or pass --obb to bundle both into an installable .xapk)")
# ---- package --------------------------------------------------------------
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > {name}.
The app installs as {pkg} alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"""
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
"""Bundle a patched APK and its OBB into an .xapk plus installers.
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
The APK and OBB are stored uncompressed: they are already compressed, and
storing them keeps packaging and install fast."""
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
step("reading APK metadata")
badging = capture(aapt2, "dump", "badging", apk).splitlines()
def badge(pattern: str) -> str | None:
for line in badging:
mm = re.search(pattern, line)
if mm:
return mm.group(1)
return None
pkg = badge(r"^package: name='([^']+)'")
ver_code = badge(r"versionCode='([^']+)'")
ver_name = badge(r"versionName='([^']+)'")
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
label = badge(r"^application-label:'([^']*)'")
if not pkg or not ver_code:
fail("could not read package name / versionCode from the APK")
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
icon_path = None
with zipfile.ZipFile(apk) as z:
names = set(z.namelist())
if icons:
ref = icons[0][1]
if ref.endswith(".png") and ref in names:
icon_path = ref
else:
base = Path(ref).stem
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
for kind in ("mipmap", "drawable"):
cand = f"res/{kind}-{d}/{base}.png"
if icon_path is None and cand in names:
icon_path = cand
icon_bytes = z.read(icon_path) if icon_path else None
print(f" {pkg} version {ver_name} ({ver_code})")
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
out_dir.mkdir(parents=True, exist_ok=True)
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
xapk.unlink(missing_ok=True)
manifest = {
"xapk_version": 2,
"package_name": pkg,
"name": label or "DorkNet",
"version_code": ver_code,
"version_name": ver_name,
"min_sdk_version": min_sdk,
"target_sdk_version": tgt_sdk,
"permissions": perms,
"total_size": apk.stat().st_size + obb.stat().st_size,
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
}
if icon_bytes:
manifest["icon"] = "icon.png"
step(f"writing {xapk} (the OBB is large; this takes a minute)")
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
if icon_bytes:
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
step("adding installers")
for f in ("install.ps1", "install.cmd", "install.sh"):
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
(out_dir / "install.sh").chmod(0o755)
(out_dir / "README.txt").write_text(
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
print()
print(f"DONE. Package folder: {out_dir}")
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
print(f"Install with: {out_dir / 'install.cmd'}")
return out_dir
def cmd_package(a: argparse.Namespace) -> None:
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
resolve_build_tools(a.build_tools_dir))
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
b.set_defaults(func=cmd_build)
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
k.add_argument("--apk", required=True)
k.add_argument("--obb", required=True)
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
k.add_argument("--build-tools-dir")
k.set_defaults(func=cmd_package)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Generate config_generated.h from dorknet-quest.cfg.
Bakes the operator's config into the library at compile time. Keys absent from
the cfg are simply not emitted, so the built-in defaults in config.h apply.
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
committed file.
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
"""
import sys
from pathlib import Path
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
KEYS = {
"ServerHost": ("DN_SERVER_HOST", "str"),
"OculusAppId": ("DN_OCULUS_APPID", "str"),
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
"LogHttp": ("DN_LOG_HTTP", "bool"),
"Debug": ("DN_DEBUG_HTTP", "bool"),
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
}
TRUE = {"true", "1", "yes", "on"}
FALSE = {"false", "0", "no", "off"}
def c_string(v):
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
def main():
if len(sys.argv) != 4:
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
f"#define DN_PACKAGE {c_string(package)}", ""]
if cfg.exists():
for raw in cfg.read_text(encoding="utf-8").splitlines():
line = raw.split("#", 1)[0].strip()
if not line or "=" not in line or line.startswith("["):
continue
key, _, val = line.partition("=")
key, val = key.strip(), val.strip()
if key not in KEYS or val == "":
continue
macro, kind = KEYS[key]
if kind == "str":
lines.append(f"#define {macro} {c_string(val)}")
elif kind == "int":
lines.append(f"#define {macro} {int(val)}")
elif kind == "bool":
low = val.lower()
if low in TRUE:
lines.append(f"#define {macro} true")
elif low in FALSE:
lines.append(f"#define {macro} false")
else:
raise SystemExit(f"{key}: not a boolean: {val}")
else:
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
lines += ["", "#endif"]
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {out}")
if __name__ == "__main__":
main()
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Generate targets_generated.h from a resolved target table.
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
named struct field, plus the build's version. The hook library checks the
fingerprint against the running libil2cpp.so before arming, so a table can only
ever be applied to the build it was resolved from.
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
"""
import json
import sys
from pathlib import Path
def main():
if len(sys.argv) != 3:
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
ids = list(table["targets"].keys())
lines = [
"// Generated by scripts/gen_targets_header.py -- do not edit.",
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
"#include <stdint.h>",
"",
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
f"#define DN_HOOK_BYTES {table['hookBytes']}",
"",
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
"",
"typedef struct {",
]
for tid in ids:
lines.append(f" DnTarget {tid};")
lines.append("} DnTargets;")
lines.append("")
lines.append("static const DnTargets dn_targets = {")
for tid in ids:
e = table["targets"][tid]
length = e["length"] if e["length"] is not None else 0
lines.append(
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
lines.append("};")
lines.append("")
lines.append("#endif")
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
libdorknet.so before Unity starts.
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
DT_NEEDED; adding one more makes the loader map our hook library first. This is
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
"""
import sys
import lief
def main():
if len(sys.argv) != 3:
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
path, soname = sys.argv[1], sys.argv[2]
binary = lief.ELF.parse(path)
if binary is None:
raise SystemExit(f"could not parse {path}")
existing = [e.name for e in binary.dynamic_entries
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
if soname in existing:
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
return
binary.add_library(soname)
binary.write(path)
print(f"added DT_NEEDED {soname} to {path}")
check = lief.ELF.parse(path)
names = [e.name for e in check.dynamic_entries
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
if soname not in names:
raise SystemExit("injection did not stick")
print("verified:", ", ".join(names))
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
@echo off
rem One-click installer: runs install.ps1 next to this file.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
echo.
pause
+145
View File
@@ -0,0 +1,145 @@
<#
.SYNOPSIS
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
.DESCRIPTION
Installs the APK, copies the OBB to the path the game expects, then launches
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
arguments from the package folder, or double-click install.cmd.
.PARAMETER Xapk
The package to install. Default: the only .xapk next to this script.
.PARAMETER Serial
adb serial of the headset when more than one device is connected.
.PARAMETER NoLaunch
Install only; don't start the app afterwards.
#>
[CmdletBinding()]
param(
[string]$Xapk,
[string]$Serial,
[switch]$NoLaunch
)
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
# that as a failure, so exit codes are checked by hand instead.
$ErrorActionPreference = 'Continue'
Set-StrictMode -Version 2
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
# ---- find the package ----------------------------------------------------
if (-not $Xapk) {
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
$Xapk = $found[0].FullName
}
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
# ---- find adb -----------------------------------------------------------
$adb = $null
$cmd = Get-Command adb -ErrorAction SilentlyContinue
if ($cmd) { $adb = $cmd.Source }
if (-not $adb) {
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
foreach ($r in $roots) {
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
if (Test-Path $c) { $adb = $c; break }
}
if ($adb) { break }
}
}
if (-not $adb) {
Fail ("adb not found. Install Android platform-tools from " +
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
"(or unzip platform-tools next to this script).")
}
# ---- pick the headset ----------------------------------------------------
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
if ($Serial) {
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
} elseif ($ready.Count -eq 1) {
$Serial = $ready[0]
} elseif ($ready.Count -gt 1) {
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
} elseif ($unauth.Count -gt 0) {
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
} else {
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
}
function Adb { & $adb -s $Serial @args }
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
Say "headset: $model ($Serial)"
# ---- read the package ----------------------------------------------------
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmp | Out-Null
try {
$me = $zip.GetEntry('manifest.json')
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
$sr = New-Object System.IO.StreamReader($me.Open())
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
$sr.Dispose()
$pkg = $manifest.package_name
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
function Extract($entry) {
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
return $out
}
# ---- APK -------------------------------------------------------------
$apkEntry = $zip.GetEntry("$pkg.apk")
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
if (-not $apkEntry) { Fail 'no APK inside the package.' }
Say 'installing the app'
$apkFile = Extract $apkEntry
$res = (Adb install -r $apkFile 2>&1 | Out-String)
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
$ans = Read-Host 'Uninstall it and continue? [y/N]'
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
Adb uninstall $pkg | Out-Null
$res = (Adb install -r $apkFile 2>&1 | Out-String)
}
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
Remove-Item -Force $apkFile
# ---- OBB (expansion files) ------------------------------------------
foreach ($x in @($manifest.expansions)) {
if (-not $x) { continue }
$entry = $zip.GetEntry($x.file)
if (-not $entry) { Fail "package is missing $($x.file)." }
$dest = '/sdcard/' + $x.install_path
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
$f = Extract $entry
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
Adb push $f $dest
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
Remove-Item -Force $f
}
} finally {
$zip.Dispose()
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
Write-Host ''
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
if (-not $NoLaunch) {
Say 'launching (put the headset on)'
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
}
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
#
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
#
# Needs adb (Android platform-tools) and unzip.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
xapk="" serial="" launch=1
while [ $# -gt 0 ]; do
case "$1" in
--serial) serial="$2"; shift 2 ;;
--no-launch) launch=0; shift ;;
*) xapk="$1"; shift ;;
esac
done
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
if [ -z "$xapk" ]; then
set -- "$here"/*.xapk
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
xapk="$1"
fi
[ -f "$xapk" ] || fail "package not found: $xapk"
command -v unzip >/dev/null || fail "unzip is required."
adb="$(command -v adb || true)"
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
"$here/platform-tools/adb"; do
[ -n "$adb" ] && break
[ -x "$c" ] && adb="$c"
done
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
if [ -z "$serial" ]; then
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
count=$(printf '%s' "$ready" | grep -c . || true)
if [ "$count" -eq 0 ]; then
if "$adb" devices | grep -q unauthorized; then
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
fi
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
fi
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
serial="$ready"
fi
a() { "$adb" -s "$serial" "$@"; }
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
pkg="$(field package_name)"
[ -n "$pkg" ] || fail "manifest.json has no package_name."
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
say "installing the app"
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
echo "Your account lives on the server, but local settings on the headset are lost."
read -r -p "Uninstall it and continue? [y/N] " ans
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
a uninstall "$pkg" >/dev/null || true
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
fi
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
rm -f "$tmp/$pkg.apk"
# Expansion files: every "install_path" in the manifest.
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
while read -r path; do
dest="/sdcard/$path"
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
say "copying game data - this takes a few minutes"
unzip -q -o "$xapk" "$path" -d "$tmp"
a shell "mkdir -p '$(dirname "$dest")'"
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
rm -f "$tmp/$path"
done
echo
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
if [ "$launch" -eq 1 ]; then
say "launching (put the headset on)"
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
fi
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
class header and method signature, and verifies each against the build's
libil2cpp.so:
* exactly one method matches the signature,
* the method body is at least 20 bytes (the inline hook rewrites the first
five instructions),
* the first instructions are disassembled (llvm-objdump, when provided) so
the prologue can be eyeballed before a build.
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
bytes. The hook library refuses to arm a target whose fingerprint does not
match the running libil2cpp.so, so a table can never be applied to the wrong
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
Usage:
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
--version-name 20230317 --version-code 2128210 \
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
"""
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
HOOK_BYTES = 20
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
def fnv1a64(data):
h = 0xCBF29CE484222325
for b in data:
h ^= b
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
return h
def sha256(path):
d = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
d.update(chunk)
return d.hexdigest()
def scan_dump(dump, spec):
"""One pass over dump.cs collecting (rva, offset) for each spec match and
for the NEXT method after it (to bound the body length)."""
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
hits = {} # id -> {rva, offset, ...}
all_rvas = set() # every method RVA, for next-greater length bounding
rva = off = None
cur_class = None
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
for ln in lines:
m = RVA_RE.match(ln)
if m:
rva, off = int(m.group(1), 16), int(m.group(2), 16)
if rva:
all_rvas.add(rva)
continue
if TYPE_RE.match(ln):
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
continue
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
body = ln.strip()
body = body.split("// RVA")[0].rstrip()
if body.endswith("{ }"):
body = body[:-3].rstrip()
elif body.endswith(";"):
body = body[:-1].rstrip()
for tid, (cls, meth) in wanted.items():
if tid in hits:
continue
# Class header may carry a base/interface list ("X : Base, IFoo").
# Match the exact class, or that class followed by " : <bases>",
# but never a nested "X.Y" (which continues with "." not " ").
spec_class = cls.rstrip()
class_ok = cur_class is not None and (
cur_class == spec_class or cur_class.startswith(spec_class + " "))
if class_ok and body == meth.rstrip():
if rva is None:
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
"class": cls.rstrip()}
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
# lists methods in declaration order, so the following *line* is not the
# following *function* in memory.
ordered = sorted(all_rvas)
import bisect
for h in hits.values():
i = bisect.bisect_right(ordered, h["rva"])
h["end_rva"] = ordered[i] if i < len(ordered) else None
return hits
def objdump_prologue(objdump, il2cpp, rva, count=6):
try:
out = subprocess.run(
[objdump, "-d", f"--start-address={rva:#x}",
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
capture_output=True, text=True, check=True).stdout
except (OSError, subprocess.CalledProcessError) as e:
return f"(objdump failed: {e})"
return "\n".join(l for l in out.splitlines() if ":\t" in l)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dump", required=True, type=Path)
ap.add_argument("--il2cpp", required=True, type=Path)
ap.add_argument("--version-name", required=True)
ap.add_argument("--version-code", required=True)
ap.add_argument("--objdump")
ap.add_argument("--spec", type=Path,
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
ap.add_argument("--out", type=Path)
args = ap.parse_args()
spec = json.loads(args.spec.read_text(encoding="utf-8"))
hits = scan_dump(args.dump, spec)
code = args.il2cpp.read_bytes()
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
if missing:
raise SystemExit("targets not found in dump: " + ", ".join(missing))
entries = {}
ok = True
for t in spec["targets"]:
tid = t["id"]
h = hits[tid]
rva = h["rva"]
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
length = (h["end_rva"] - rva) if h["end_rva"] else None
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
short = length is not None and length < HOOK_BYTES
if short:
ok = False
entries[tid] = {
"rva": rva,
"length": length,
"fingerprint": f"{fp:#018x}",
"why": t["why"],
}
flag = " TOO SHORT" if short else ""
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
if args.objdump:
print(objdump_prologue(args.objdump, args.il2cpp, rva))
table = {
"versionName": args.version_name,
"versionCode": args.version_code,
"il2cppSha256": sha256(args.il2cpp),
"hookBytes": HOOK_BYTES,
"targets": entries,
}
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
print(f"\nwrote {out}")
if not ok:
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
// Runtime config loader: applies an optional on-device cfg over the baked-in
// defaults. The cfg format is "Key = Value", one per line, '#' comments and
// [section] headers ignored -- the same keys DorkNet.ClientMod uses on PC, so a
// PC config reads here too.
#include "config.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
#define CLOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
static void trim(char *s) {
char *p = s;
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
if (p != s) memmove(s, p, strlen(p) + 1);
size_t n = strlen(s);
while (n && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n'))
s[--n] = '\0';
}
static bool as_bool(const char *v, bool dflt) {
if (!v || !*v) return dflt;
if (!strcasecmp(v, "true") || !strcasecmp(v, "1") || !strcasecmp(v, "yes") || !strcasecmp(v, "on"))
return true;
if (!strcasecmp(v, "false") || !strcasecmp(v, "0") || !strcasecmp(v, "no") || !strcasecmp(v, "off"))
return false;
return dflt;
}
// Accept a bare host or a full URL; store just the host.
static void set_host(char *out, size_t cap, const char *val) {
const char *scheme = strstr(val, "://");
const char *host = scheme ? scheme + 3 : val;
size_t i = 0;
for (; host[i] && host[i] != '/' && i + 1 < cap; i++) out[i] = host[i];
out[i] = '\0';
}
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
char *oculus_appid, size_t oculus_appid_cap,
char *photon_realtime, char *photon_voice, char *photon_chat,
char *photon_server, size_t photon_server_cap, int *photon_port,
char *photon_region, size_t photon_region_cap,
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
bool *log_http, bool *debug_http, int *body_max) {
char path[256];
snprintf(path, sizeof(path), "/sdcard/Android/data/%s/files/%s", DN_PACKAGE, DN_RUNTIME_CFG_NAME);
FILE *f = fopen(path, "r");
if (!f) { CLOGI("no runtime config at %s; using baked-in defaults", path); return; }
char line[256];
while (fgets(line, sizeof(line), f)) {
char *hash = strchr(line, '#');
if (hash) *hash = '\0';
char *eq = strchr(line, '=');
if (!eq) continue;
*eq = '\0';
char *key = line, *val = eq + 1;
trim(key);
trim(val);
if (!strcasecmp(key, "ServerHost") && *val) set_host(server_host, server_host_cap, val);
else if (!strcasecmp(key, "OculusAppId")) {
oculus_appid[0] = '\0';
strncpy(oculus_appid, val, oculus_appid_cap - 1);
}
else if (!strcasecmp(key, "PhotonAppId") && *val) strncpy(photon_realtime, val, 63);
else if (!strcasecmp(key, "PhotonVoiceAppId") && *val) strncpy(photon_voice, val, 63);
else if (!strcasecmp(key, "PhotonChatAppId") && *val) strncpy(photon_chat, val, 63);
else if (!strcasecmp(key, "PhotonNameServer")) set_host(photon_server, photon_server_cap, val);
else if (!strcasecmp(key, "PhotonNameServerPort")) *photon_port = atoi(val);
else if (!strcasecmp(key, "PhotonRegion") && *val) { photon_region[0] = '\0'; strncpy(photon_region, val, photon_region_cap - 1); }
else if (!strcasecmp(key, "EnableTlsTrustBypass")) *tls_bypass = as_bool(val, *tls_bypass);
else if (!strcasecmp(key, "AcceptUnsignedImages")) *accept_images = as_bool(val, *accept_images);
else if (!strcasecmp(key, "AutoCreateAccount")) *auto_create_account = as_bool(val, *auto_create_account);
else if (!strcasecmp(key, "LogHttp")) *log_http = as_bool(val, *log_http);
else if (!strcasecmp(key, "Debug")) *debug_http = as_bool(val, *debug_http);
else if (!strcasecmp(key, "LogHttpBodyMax") && *val) *body_max = atoi(val);
}
fclose(f);
CLOGI("runtime config applied from %s", path);
}
+93
View File
@@ -0,0 +1,93 @@
// Compile-time configuration for libdorknet.so.
//
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
// (put on the include path by the build), baking the operator's values into the
// APK. Any key left out of the cfg falls back to the built-in default below.
// The same cfg can also be pushed to
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
// to override at runtime without a rebuild (dn_load_runtime_config).
#ifndef DORKNET_QUEST_CONFIG_H
#define DORKNET_QUEST_CONFIG_H
#include <stdbool.h>
#include <stddef.h>
#if defined(__has_include)
# if __has_include("config_generated.h")
# include "config_generated.h"
# endif
#endif
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
// build with no server configured would do nothing useful.
#ifndef DN_SERVER_HOST
# define DN_SERVER_HOST ""
#endif
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
// no-Meta-account build (platform entitlement/login are steered past).
#ifndef DN_OCULUS_APPID
# define DN_OCULUS_APPID ""
#endif
// Photon app ids: per-deployment, no default. Empty leaves the client's own
// (dead) value in place, so multiplayer will not connect until set.
#ifndef DN_PHOTON_REALTIME
# define DN_PHOTON_REALTIME ""
#endif
#ifndef DN_PHOTON_VOICE
# define DN_PHOTON_VOICE ""
#endif
#ifndef DN_PHOTON_CHAT
# define DN_PHOTON_CHAT ""
#endif
#ifndef DN_PHOTON_SERVER
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
#endif
#ifndef DN_PHOTON_PORT
# define DN_PHOTON_PORT 0
#endif
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
// PhotonCloudRegion), or Quest and PC players land on different masters.
#ifndef DN_PHOTON_REGION
# define DN_PHOTON_REGION "us"
#endif
#ifndef DN_TLS_TRUST_BYPASS
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
#endif
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
# define DN_ACCEPT_UNSIGNED_IMAGES true
#endif
// A headset with no remembered account: false opens the sign-in page, true
// keeps Rec Room's behaviour of creating a new account automatically.
#ifndef DN_AUTO_CREATE_ACCOUNT
# define DN_AUTO_CREATE_ACCOUNT false
#endif
#ifndef DN_LOG_HTTP
# define DN_LOG_HTTP true
#endif
#ifndef DN_DEBUG_HTTP
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
#endif
#ifndef DN_LOG_BODY_MAX
# define DN_LOG_BODY_MAX 10000
#endif
#ifndef DN_RUNTIME_CFG_NAME
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
#endif
// Package the runtime cfg is read from; the build's gen step overrides this.
#ifndef DN_PACKAGE
# define DN_PACKAGE "net.dorknet.RecRoom"
#endif
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
// baked defaults. Each out pointer already holds the compiled-in value.
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
char *oculus_appid, size_t oculus_appid_cap,
char *photon_realtime, char *photon_voice, char *photon_chat,
char *photon_server, size_t photon_server_cap, int *photon_port,
char *photon_region, size_t photon_region_cap,
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
bool *log_http, bool *debug_http, int *body_max);
#endif // DORKNET_QUEST_CONFIG_H
+772
View File
@@ -0,0 +1,772 @@
// DorkNet.Quest native hook library (libdorknet.so).
//
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
// baked into targets_generated.h at compile time. It is the Quest counterpart
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
//
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
// response then supplies every other service URL) -- like ClientMod's
// UriPatches,
// * inject the operator's Photon app ids -- like the PC byte patch
// tools/patch-client.ps1,
// * optionally set the operator's Meta (Oculus) app id,
// * accept the self-hosted TLS chain and the image signature the operator's
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
// * neutralise the integrity callbacks that would reject a re-signed,
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
// * when no Meta app id is set, steer the Oculus platform path past its
// entitlement/login checks to the client's own credential screen.
//
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
// table before it is hooked, so the wrong build is refused rather than
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
// but shared with the PC build of the same release; this build (20230317 /
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
//
// No Rec Room code or assets are included. Third-party inline hooker in
// src/third_party/And64InlineHook (MIT, (c) Rprop).
#include <dlfcn.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include "third_party/And64InlineHook/And64InlineHook.hpp"
#include "config.h"
#include "targets_generated.h"
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
// --------------------------------------------------------------- config -----
static const char *kOfficialSuffix = ".rec.net";
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
static char g_photon_voice[64] = DN_PHOTON_VOICE;
static char g_photon_chat[64] = DN_PHOTON_CHAT;
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
static int g_photon_port = DN_PHOTON_PORT;
static char g_photon_region[16] = DN_PHOTON_REGION;
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
static bool g_log_http = DN_LOG_HTTP;
static bool g_debug_http = DN_DEBUG_HTTP;
static int g_body_max = DN_LOG_BODY_MAX;
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
static const size_t kAppIdRealtimeOff = 0x10;
static const size_t kAppIdChatOff = 0x18;
static const size_t kAppIdVoiceOff = 0x20;
static const size_t kUseNameServerOff = 0x30; // bool
static const size_t kFixedRegionOff = 0x38;
static const size_t kServerOff = 0x48;
static const size_t kPortOff = 0x50; // int
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
// HTTPRequest / HTTPResponse field offsets (dump.cs).
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
// Il2CppArray data layout on 64-bit.
static const size_t kArrayLengthOff = 0x18;
static const size_t kArrayDataOff = 0x20;
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
static const unsigned kMsgUserGetUserProof = 578880643u;
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
static const unsigned long long kNoPlatformUserId = 1ULL;
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
static void *const kPlaceholderUser = (void *)0x2;
static const size_t kLogChunk = 1500;
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
static const size_t kErrorStateCodeOff = 0x4;
// --------------------------------------------------------------- il2cpp ------
typedef void *(*il2cpp_object_get_class_t)(void *obj);
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
typedef void *(*il2cpp_object_new_t)(void *klass);
typedef void *(*il2cpp_string_new_t)(const char *str);
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
typedef void *(*il2cpp_domain_get_t)();
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
typedef const char *(*il2cpp_image_get_name_t)(void *image);
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
static il2cpp_object_get_class_t il2cpp_object_get_class;
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
static il2cpp_object_new_t il2cpp_object_new;
static il2cpp_string_new_t il2cpp_string_new;
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
static il2cpp_domain_get_t il2cpp_domain_get;
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
static il2cpp_image_get_name_t il2cpp_image_get_name;
static il2cpp_class_from_name_t il2cpp_class_from_name;
static uintptr_t g_il2cpp_base;
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
static void str_to_utf8(void *s, char *out, size_t cap) {
out[0] = '\0';
if (!s || cap == 0) return;
int32_t len = *(int32_t *)((char *)s + 0x10);
if (len < 0) return;
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
size_t i = 0;
for (; i < (size_t)len && i + 1 < cap; i++)
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
out[i] = '\0';
}
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
*len = 0;
if (!obj) return nullptr;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return nullptr;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return nullptr;
void *exc = nullptr;
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
if (!arr || exc) return nullptr;
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
return (const unsigned char *)((char *)arr + kArrayDataOff);
}
static bool invoke_void(void *obj, const char *method) {
if (!obj) return false;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return false;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return false;
void *exc = nullptr;
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
return !exc;
}
static void *find_class(const char *image_name, const char *ns, const char *name) {
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
size_t n = 0;
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
for (size_t i = 0; assemblies && i < n; i++) {
void *image = il2cpp_assembly_get_image(assemblies[i]);
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
}
return nullptr;
}
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
out[0] = '\0';
if (!obj) return;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return;
void *exc = nullptr;
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
if (s && !exc) str_to_utf8(s, out, cap);
}
// --------------------------------------------------------------- logging ----
static void log_chunked(const char *prefix, const char *text) {
size_t n = strlen(text);
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
char buf[kLogChunk + 1];
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
memcpy(buf, text + off, take);
buf[take] = '\0';
LOGI("%s[%zu] %s", prefix, part, buf);
}
}
static bool looks_binary(const unsigned char *d, size_t n) {
if (n == 0) return false;
size_t sample = n < 4096 ? n : 4096, nontext = 0;
for (size_t i = 0; i < sample; i++) {
unsigned char b = d[i];
if (b == 0) return true;
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
}
return nontext * 100 / sample > 10;
}
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
size_t limit = (size_t)g_body_max;
if (limit > cap - 64) limit = cap - 64;
if (!looks_binary(d, n)) {
size_t take = n < limit ? n : limit;
memcpy(out, d, take);
out[take] = '\0';
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
return;
}
int w = snprintf(out, cap, "<binary %zu bytes>", n);
out[w < 0 ? 0 : (size_t)w] = '\0';
}
// --------------------------------------------------------------- hooks -------
static void *(*orig_SendRequest)(void *request);
static void *hk_SendRequest(void *request) {
if (!request) return orig_SendRequest(request);
void *uri = *(void **)((char *)request + kUriFieldOffset);
if (!uri) return orig_SendRequest(request);
void *uri_class = il2cpp_object_get_class(uri);
if (!uri_class) return orig_SendRequest(request);
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
if (!m_abs) return orig_SendRequest(request);
void *exc = nullptr;
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
if (!s || exc) return orig_SendRequest(request);
char url[2048];
str_to_utf8(s, url, sizeof(url));
if (g_log_http) {
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
LOGI("[http] %s %s", mname, url);
if (g_debug_http) {
size_t blen = 0;
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
if (body && blen) {
size_t cap = (size_t)g_body_max + 128;
char *text = (char *)malloc(cap);
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
}
}
}
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
const char *scheme_end = strstr(url, "://");
if (!scheme_end) return orig_SendRequest(request);
const char *host = scheme_end + 3;
const char *host_end = host;
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
size_t host_len = (size_t)(host_end - host);
size_t suf = strlen(kOfficialSuffix);
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
return orig_SendRequest(request);
char newurl[1024];
size_t head = (size_t)(host - url); // "https://"
size_t service_len = host_len - suf; // "api", "auth", ...
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
// template with "{service}" for servers on DorkNet's hyphen host style
// ("{service}-dev.example.com" -> "ns-dev.example.com").
static const char kServiceToken[] = "{service}";
const char *token = strstr(g_server_host, kServiceToken);
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
: service_len + 1 + strlen(g_server_host);
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
return orig_SendRequest(request);
memcpy(newurl, url, head);
size_t p = head;
if (token) {
size_t before = (size_t)(token - g_server_host);
memcpy(newurl + p, g_server_host, before);
p += before;
memcpy(newurl + p, host, service_len);
p += service_len;
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
} else {
memcpy(newurl + p, host, service_len);
p += service_len;
newurl[p++] = '.';
strcpy(newurl + p, g_server_host);
}
strcat(newurl + head, host_end);
void *new_str = il2cpp_string_new(newurl);
void *new_uri = il2cpp_object_new(uri_class);
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
void *args[1] = {new_str};
exc = nullptr;
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
if (exc) return orig_SendRequest(request);
*(void **)((char *)request + kUriFieldOffset) = new_uri;
LOGI("intercepted %s -> %s", url, newurl);
return orig_SendRequest(request);
}
static void (*orig_CallCallback)(void *thiz, void *method);
static void hk_CallCallback(void *thiz, void *method) {
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
char url[2048];
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
int state = *(int *)((char *)thiz + kReqStateOffset);
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
if (!resp) {
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
if (ex) {
char msg[900];
invoke_str(ex, "ToString", msg, sizeof(msg));
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
} else {
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
}
} else {
int status = *(int *)((char *)resp + kRespStatusOffset);
LOGI("[http<-] %d %s", status, url);
if (g_debug_http || status < 200 || status >= 300) {
size_t blen = 0;
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
size_t cap = (size_t)g_body_max + 128;
char *text = (char *)malloc(cap);
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
}
}
orig_CallCallback(thiz, method);
}
static void *hk_get_AppID(void *method) {
(void)method;
return il2cpp_string_new(g_oculus_appid);
}
static void *(*orig_Photon)(void *thiz, void *method);
static void *hk_Photon(void *thiz, void *method) {
void *s = orig_Photon(thiz, method);
if (s) {
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
*(int *)((char *)s + kProtocolOff) = 0;
if (g_photon_server[0]) {
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
}
static int logged = 0;
if (logged < 2) {
logged++;
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
g_photon_voice[0] ? g_photon_voice : "(unset)",
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
if (!g_photon_realtime[0])
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
}
}
return s;
}
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
uint32_t r = orig_UnityTls(thiz, chain, es, method);
if (r != kUnityTlsSuccess) {
static int logged = 0;
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
}
return kUnityTlsSuccess;
}
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
(void)thiz; (void)cert; (void)method;
}
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
// report is ever built and the client never quits).
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
static bool hk_image_true(void *data, void *sig, void *method) {
(void)data; (void)sig; (void)method;
return true;
}
// void callbacks the client runs on an integrity rejection -- no-op them so a
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
// on PC). The quit funnels and the report are left to run but are logged.
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
static bool hk_toxmod_false(void *method) { (void)method; return false; }
static const char *cheat_reason(int r) {
switch (r) {
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
case 6: return "ImageSignature"; case 7: return "AvatarHack";
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
default: return "?";
}
}
static void log_caller(const char *what, void *ret) {
uintptr_t a = (uintptr_t)ret;
if (g_il2cpp_base && a > g_il2cpp_base)
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
else
LOGI("[caller] %s <- %p", what, ret);
}
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
char text[512];
str_to_utf8(msg, text, sizeof(text));
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
log_caller("report", __builtin_return_address(0));
return orig_CheatReport(reason, msg, code, method);
}
static void (*orig_FatalQuit)(int code, void *reason, void *method);
static void hk_FatalQuit(int code, void *reason, void *method) {
char msg[512];
str_to_utf8(reason, msg, sizeof(msg));
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
log_caller("fatal quit", __builtin_return_address(0));
orig_FatalQuit(code, reason, method);
}
static void (*orig_TryQuit)(int code, void *method);
static void hk_TryQuit(int code, void *method) {
LOGI("[exit] TryApplicationQuit(%d)", code);
log_caller("try quit", __builtin_return_address(0));
orig_TryQuit(code, method);
}
// ------------------------------------------------------- oculus platform -----
typedef bool (*ovr_IsError_t)(void *m);
typedef unsigned (*ovr_GetType_t)(void *m);
static ovr_IsError_t orig_ovr_IsError;
static ovr_GetType_t ovr_GetType;
static bool is_bypassed_type(unsigned t) {
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
t == kMsgUserGetLoggedInUser;
}
// The SDK's Message constructor checks IsError twice: on the message, and --
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
// message carries the platform's refusal too but doesn't report the outer
// type, so remember the inner handle of a bypassed message (same thread, the
// constructor asks about it immediately) and pass it as well.
static __thread void *t_bypassed_native;
static __thread unsigned t_bypassed_type;
static bool hk_ovr_IsError(void *m) {
bool real = orig_ovr_IsError(m);
if (!real) return real;
if (m && m == t_bypassed_native) return false;
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
return real;
}
typedef void *(*ovr_GetNativeMessage_t)(void *m);
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
static void *hk_ovr_GetNativeMessage(void *m) {
void *native = orig_ovr_GetNativeMessage(m);
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
t_bypassed_type = t;
return native;
}
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
// Oculus user account". A headset that can't reach Meta gets a cached user
// and never hits this. Answer a refused request with a placeholder user whose
// ID is kNoPlatformUserId -- the same placeholder the server already treats
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
static void *(*orig_ovr_Message_GetUser)(void *m);
static void *hk_ovr_Message_GetUser(void *m) {
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
if (u) return u;
// The SDK reads the user from the inner native message
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
if (!logged_in_user) return u;
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
return kPlaceholderUser;
}
// Accessors the SDK's User model reads. Real users pass straight through; the
// placeholder gets empty strings and zero values.
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
static void *(*orig_ovr_GetUserProof)(void *m);
static void *hk_ovr_GetUserProof(void *m) {
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
return p ? p : (void *)0x1;
}
static const char *(*orig_ovr_GetNonce)(void *proof);
static const char *hk_ovr_GetNonce(void *proof) {
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
const char *n = orig_ovr_GetNonce(proof);
if (n && n[0]) return n;
}
return kPlaceholderNonce;
}
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
static unsigned long long hk_ovr_GetLoggedInUserID() {
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
return real ? real : kNoPlatformUserId;
}
static unsigned long long (*orig_ovr_User_GetID)(void *u);
static unsigned long long hk_ovr_User_GetID(void *u) {
if (u == kPlaceholderUser) return kNoPlatformUserId;
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
return real ? real : kNoPlatformUserId;
}
// ------------------------------------------------------------ title screen --
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
// the title screen downloads the headset's remembered accounts and logs into
// one; with none (null) it creates a brand-new account instead
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
// refuses that, the error handler shows the connection-error page and the
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
// does). ManuallyLogin carries the player into the game from there on its own,
// so the boot step gets a promise that never settles.
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
static void *new_pending_promise() {
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
// a fresh one stays pending.
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
if (!cls) return nullptr;
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
if (!ctor) return nullptr;
void *p = il2cpp_object_new(cls);
void *exc = nullptr;
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
return exc ? nullptr : p;
}
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
void *pending = new_pending_promise();
if (!pending || !invoke_void(thiz, "GoToLogin")) {
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return orig_TitleAutoLogin(thiz, cached, method);
}
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
return pending;
}
// ----------------------------------------------------------------- init ------
static uintptr_t module_base(const char *name) {
FILE *f = fopen("/proc/self/maps", "r");
if (!f) return 0;
char line[512];
uintptr_t base = 0;
while (fgets(line, sizeof(line), f))
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
fclose(f);
return base;
}
static uint64_t fnv1a(const unsigned char *d, size_t n) {
uint64_t h = 0xCBF29CE484222325ULL;
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
return h;
}
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
static bool arm(const DnTarget *t, void *hook, void **orig) {
void *addr = (void *)(g_il2cpp_base + t->rva);
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
if (fp != t->fingerprint) {
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
t->id, (unsigned long)t->rva, (unsigned long long)fp,
(unsigned long long)t->fingerprint);
return false;
}
void *dummy = nullptr;
A64HookFunction(addr, hook, orig ? orig : &dummy);
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
return true;
}
static bool resolve_il2cpp(void *h) {
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
SYM(il2cpp_object_get_class)
SYM(il2cpp_class_get_method_from_name)
SYM(il2cpp_object_new)
SYM(il2cpp_string_new)
SYM(il2cpp_runtime_invoke)
#undef SYM
// Optional: only the title-screen sign-in redirect needs these.
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
return true;
}
static void arm_oculus_platform_bypass() {
void *ovr = nullptr;
for (int i = 0; i < 600; i++) {
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
if (ovr) break;
usleep(100 * 1000);
}
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
void *isErr = dlsym(ovr, "ovr_Message_IsError");
if (ovr_GetType && isErr) {
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
} else {
LOGE("ovr symbols missing; platform bypass not armed");
return;
}
void *a;
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
if ((a = dlsym(ovr, "ovr_User_GetID")))
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
DN_HOOK_USER(ovr_User_GetDisplayName)
DN_HOOK_USER(ovr_User_GetOculusID)
DN_HOOK_USER(ovr_User_GetImageUrl)
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
DN_HOOK_USER(ovr_User_GetInviteToken)
DN_HOOK_USER(ovr_User_GetPresence)
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
DN_HOOK_USER(ovr_User_GetPresenceStatus)
#undef DN_HOOK_USER
}
static void *init_thread(void *) {
void *h = nullptr;
for (int i = 0; i < 600; i++) {
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
if (h) break;
usleep(100 * 1000);
}
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
g_il2cpp_base = module_base("libil2cpp.so");
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
if (!resolve_il2cpp(h)) return nullptr;
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
g_photon_realtime, g_photon_voice, g_photon_chat,
g_photon_server, sizeof(g_photon_server), &g_photon_port,
g_photon_region, sizeof(g_photon_region),
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
if (g_log_http)
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
if (g_oculus_appid[0]) {
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
LOGI("oculus app id override: %s", g_oculus_appid);
}
if (g_tls_bypass) {
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
}
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
if (g_accept_images)
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
// Integrity callbacks that would reject a re-signed, hook-loaded client.
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
// Log-only diagnostics.
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
LOGI("DorkNet.Quest init complete");
return nullptr;
}
__attribute__((constructor)) static void on_load() {
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
pthread_t t;
pthread_create(&t, nullptr, init_thread, nullptr);
pthread_detach(t);
}
@@ -0,0 +1,596 @@
/*
* @date : 2018/04/18
* @author : Rprop (r_prop@outlook.com)
* https://github.com/Rprop/And64InlineHook
*/
/*
MIT License
Copyright (c) 2018 Rprop (r_prop@outlook.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#define __STDC_FORMAT_MACROS
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>
#include <android/log.h>
#if defined(__aarch64__)
#include "And64InlineHook.hpp"
#define A64_MAX_INSTRUCTIONS 5
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
#define A64_NOP 0xd503201fu
#define A64_JNIEXPORT __attribute__((visibility("default")))
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
#ifndef NDEBUG
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
#else
# define A64_LOGI(...) ((void)0)
#endif // NDEBUG
typedef uint32_t *__restrict *__restrict instruction;
struct context
{
struct fix_info
{
uint32_t *bp;
uint32_t ls; // left-shift counts
uint32_t ad; // & operand
};
struct insns_info
{
union
{
uint64_t insu;
int64_t ins;
void *insp;
};
fix_info fmap[A64_MAX_REFERENCES];
};
int64_t basep;
int64_t endp;
insns_info dat[A64_MAX_INSTRUCTIONS];
public:
inline bool is_in_fixing_range(const int64_t absolute_addr) {
return absolute_addr >= this->basep && absolute_addr < this->endp;
}
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
}
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
this->dat[current_idx].insp = outp;
return current_idx;
}
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
this->dat[idx].insp = outp;
}
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
for (auto &f : this->dat[idx].fmap) {
if (f.bp == NULL) {
f.bp = bp;
f.ls = ls;
f.ad = ad;
return;
} //if
}
// What? GGing..
}
void process_fix_map(const intptr_t idx) {
for (auto &f : this->dat[idx].fmap) {
if (f.bp == NULL) break;
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
f.bp = NULL;
}
}
};
//-------------------------------------------------------------------------
#define __intval(p) reinterpret_cast<intptr_t>(p)
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
#define __ptr(p) reinterpret_cast<void *>(p)
#define __page_size 4096
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
#define __align_down(x, n) ((x) & -(n))
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
PROT_READ | PROT_WRITE | PROT_EXEC)
//-------------------------------------------------------------------------
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
{
static constexpr uint32_t mbits = 6u;
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
const uint32_t ins = *(*inpp);
const uint32_t opc = ins & mask;
switch (opc) {
case op_b:
case op_bl:
{
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
// whether the branch should be converted to absolute jump
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
if (opc == op_b) {
if (b_aligned != true) {
(*outpp)[0] = A64_NOP;
ctxp->reset_current_ins(current_idx, ++(*outpp));
} //if
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
(*outpp)[1] = 0xd61f0220u; // BR X17
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
*outpp += 4;
} else {
if (b_aligned == true) {
(*outpp)[0] = A64_NOP;
ctxp->reset_current_ins(current_idx, ++(*outpp));
} //if
(*outpp)[0] = 0x58000071u; // LDR X17, #12
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
(*outpp)[2] = 0xd61f0220u; // BR X17
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
*outpp += 5;
} //if
} else {
if (special_fix_type) {
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
if (ref_idx <= current_idx) {
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
} else {
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
new_pc_offset = 0;
} //if
} //if
(*outpp)[0] = opc | (new_pc_offset & ~mask);
++(*outpp);
} //if
++(*inpp);
return ctxp->process_fix_map(current_idx), true;
}
}
return false;
}
//-------------------------------------------------------------------------
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
{
static constexpr uint32_t lsb = 5u;
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
const uint32_t ins = *(*inpp);
uint32_t lmask = lmask01;
if ((ins & mask0) != op_bc) {
uint32_t opc = ins & mask1;
if (opc != op_cbz && opc != op_cbnz) {
opc = ins & mask2;
if (opc != op_tbz && opc != op_tbnz) {
return false;
} //if
lmask = lmask2;
} //if
} //if
const uint32_t msb = __builtin_clz(~lmask);
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
(*outpp)[0] = A64_NOP;
ctxp->reset_current_ins(current_idx, ++(*outpp));
} //if
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
(*outpp)[1] = 0x14000005u; // B #0x14
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
(*outpp)[3] = 0xd61f0220u; // BR X17
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
*outpp += 6;
} else {
if (special_fix_type) {
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
if (ref_idx <= current_idx) {
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
} else {
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
new_pc_offset = 0;
} //if
} //if
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
++(*outpp);
} //if
++(*inpp);
return ctxp->process_fix_map(current_idx), true;
}
//-------------------------------------------------------------------------
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
{
const uint32_t ins = *(*inpp);
// memory prefetch("prfm"), just skip it
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
if ((ins & 0xff000000u) == 0xd8000000u) {
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
++(*inpp);
return true;
} //if
static constexpr uint32_t msb = 8u;
static constexpr uint32_t lsb = 5u;
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
uint32_t mask = mask_ldr;
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
if ((ins & mask_ldr) != op_ldr) {
mask = mask_ldrv;
if (faligned != 7u)
faligned = (ins & mask_31) ? 15u : 3u;
if ((ins & mask_ldrv) != op_ldrv) {
if ((ins & mask_ldrsw) != op_ldrsw) {
return false;
} //if
mask = mask_ldrsw;
faligned = 7u;
} //if
} //if
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
// special_fix_type may encounter issue when there are mixed data and code
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
*(*outpp)++ = A64_NOP;
}
ctxp->reset_current_ins(current_idx, *outpp);
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
(*outpp)[1] = 0x14000001u + ns; // B #0xc
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
*outpp += 2 + ns;
} else {
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
while ((new_pc_offset & faligned) != 0) {
*(*outpp)++ = A64_NOP;
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
}
ctxp->reset_current_ins(current_idx, *outpp);
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
++(*outpp);
} //if
++(*inpp);
return ctxp->process_fix_map(current_idx), true;
}
//-------------------------------------------------------------------------
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
{
// Load a PC-relative address into a register
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
static constexpr uint32_t msb = 8u;
static constexpr uint32_t lsb = 5u;
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
const uint32_t ins = *(*inpp);
intptr_t current_idx;
switch (ins & mask) {
case op_adr:
{
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
(*outpp)[0] = A64_NOP;
ctxp->reset_current_ins(current_idx, ++(*outpp));
} //if
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
(*outpp)[1] = 0x14000003u; // B #0xc
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
*outpp += 4;
} else {
if (special_fix_type) {
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
if (ref_idx <= current_idx) {
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
} else {
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
new_pc_offset = 0;
} //if
} //if
// the lsb_bytes will never be changed, so we can use lmask to keep it
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
++(*outpp);
} //if
}
break;
case op_adrp:
{
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
if (ctxp->is_in_fixing_range(absolute_addr)) {
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
if (ref_idx > current_idx) {
// the bottom 12 bits of absolute_addr are masked out,
// so ref_idx must be less than or equal to current_idx!
A64_LOGE("ref_idx must be less than or equal to current_idx!");
} //if
// *absolute_addr may be changed due to relocation fixing
A64_LOGI("What is the correct way to fix this?");
*(*outpp)++ = ins; // 0x90000000u;
} else {
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
(*outpp)[0] = A64_NOP;
ctxp->reset_current_ins(current_idx, ++(*outpp));
} //if
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
(*outpp)[1] = 0x14000003u; // B #0xc
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
*outpp += 4;
} //if
}
break;
default:
return false;
}
ctxp->process_fix_map(current_idx);
++(*inpp);
return true;
}
//-------------------------------------------------------------------------
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
{
context ctx;
ctx.basep = reinterpret_cast<int64_t>(inp);
ctx.endp = reinterpret_cast<int64_t>(inp + count);
memset(ctx.dat, 0, sizeof(ctx.dat));
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
"please use A64_MAX_INSTRUCTIONS!");
#ifndef NDEBUG
if (count > A64_MAX_INSTRUCTIONS) {
A64_LOGE("too many fixing instructions!");
} //if
#endif // NDEBUG
uint32_t *const outp_base = outp;
while (--count >= 0) {
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
// without PC-relative offset
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
*(outp++) = *(inp++);
}
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
auto callback = reinterpret_cast<int64_t>(inp);
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
if (llabs(pc_offset) >= (mask >> 1)) {
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
outp[0] = A64_NOP;
++outp;
} //if
outp[0] = 0x58000051u; // LDR X17, #0x8
outp[1] = 0xd61f0220u; // BR X17
*reinterpret_cast<int64_t *>(outp + 2) = callback;
outp += 4;
} else {
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
++outp;
} //if
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
__flush_cache(outp_base, total); // necessary
}
//-------------------------------------------------------------------------
extern "C" {
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
//-------------------------------------------------------------------------
class A64HookInit
{
public:
A64HookInit()
{
__make_rwx(__insns_pool, sizeof(__insns_pool));
A64_LOGI("insns pool initialized.");
}
};
static A64HookInit __init;
//-------------------------------------------------------------------------
static uint32_t *FastAllocateTrampoline()
{
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
static volatile int32_t __index = -1;
int32_t i = __atomic_increase(&__index);
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
return __insns_pool[i];
} //if
A64_LOGE("failed to allocate trampoline!");
return NULL;
}
//-------------------------------------------------------------------------
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
void *const rwx, const uintptr_t rwx_size)
{
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
if (llabs(pc_offset) >= (mask >>1)) {
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
if (trampoline) {
if (rwx_size < count * 10u) {
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
return NULL;
} //if
__fix_instructions(original, count, trampoline);
} //if
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
if (count == 5) {
original[0] = A64_NOP;
++original;
} //if
original[0] = 0x58000051u; // LDR X17, #0x8
original[1] = 0xd61f0220u; // BR X17
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
__flush_cache(symbol, 5 * sizeof(uint32_t));
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
symbol, replace, 5 * sizeof(uint32_t));
} else {
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
errno, original, 5 * sizeof(uint32_t));
trampoline = NULL;
} //if
} else {
if (trampoline) {
if (rwx_size < 1u * 10u) {
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
return NULL;
} //if
__fix_instructions(original, 1, trampoline);
} //if
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
__flush_cache(symbol, 1 * sizeof(uint32_t));
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
symbol, replace, 1 * sizeof(uint32_t));
} else {
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
errno, original, 1 * sizeof(uint32_t));
trampoline = NULL;
} //if
} //if
return trampoline;
}
//-------------------------------------------------------------------------
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
{
void *trampoline = NULL;
if (result != NULL) {
trampoline = FastAllocateTrampoline();
*result = trampoline;
if (trampoline == NULL) return;
} //if
// fix Android 10 .text segment is read-only by default
__make_rwx(symbol, 5 * sizeof(size_t));
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
if (trampoline == NULL && result != NULL) {
*result = NULL;
} //if
}
}
#endif // defined(__aarch64__)
@@ -0,0 +1,42 @@
/*
* @date : 2018/04/18
* @author : Rprop (r_prop@outlook.com)
* https://github.com/Rprop/And64InlineHook
*/
/*
MIT License
Copyright (c) 2018 Rprop (r_prop@outlook.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#pragma once
#define A64_MAX_BACKUPS 256
#ifdef __cplusplus
extern "C" {
#endif
void A64HookFunction(void *const symbol, void *const replace, void **result);
void *A64HookFunctionV(void *const symbol, void *const replace,
void *const rwx, const uintptr_t rwx_size);
#ifdef __cplusplus
}
#endif
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 RLib
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+122
View File
@@ -0,0 +1,122 @@
{
"versionName": "20230317",
"versionCode": "2128210",
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
"hookBytes": 20,
"targets": {
"http_send_request": {
"rva": 108025384,
"length": 456,
"fingerprint": "0x8cd03afe41eecdef",
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
},
"http_call_callback": {
"rva": 108061608,
"length": 424,
"fingerprint": "0x98ed61ac83527269",
"why": "Logs each finished request's status (and the body of non-2xx responses)."
},
"oculus_get_app_id": {
"rva": 166405008,
"length": 32,
"fingerprint": "0xaa5a50a8d4246c46",
"why": "Returns the operator's Meta app id when OculusAppId is set."
},
"oculus_get_mobile_app_id": {
"rva": 166380836,
"length": 32,
"fingerprint": "0x5f5d3bc64dedb8de",
"why": "Same as get_AppID; the Quest build reads this getter."
},
"photon_app_settings": {
"rva": 69268008,
"length": 196,
"fingerprint": "0xc8050d166579e43b",
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
},
"tls_unity_verify": {
"rva": 159789160,
"length": 884,
"fingerprint": "0xf8d4a696f15e6f0e",
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
},
"tls_bc_notify_server_certificate": {
"rva": 103301824,
"length": 284,
"fingerprint": "0x7f061bfc843e881f",
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
},
"pin_nameserver": {
"rva": 96989180,
"length": 332,
"fingerprint": "0x659c928fe96e1008",
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
},
"pin_https": {
"rva": 103249928,
"length": 1032,
"fingerprint": "0x943a238f04d54362",
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
},
"pin_bc_verifier": {
"rva": 82659376,
"length": 1128,
"fingerprint": "0x60415d1d29a7315d",
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
},
"image_signature_verify": {
"rva": 97302848,
"length": 732,
"fingerprint": "0xe1549ef6f4691a33",
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
},
"hile_warning_callback": {
"rva": 75750004,
"length": 352,
"fingerprint": "0xccf510feeada40d9",
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
},
"unknown_dll_callback": {
"rva": 57998052,
"length": 112,
"fingerprint": "0x84e1fed3a70e7560",
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
},
"file_hash_callback": {
"rva": 52872472,
"length": 1052,
"fingerprint": "0x03a8b6f1b373e600",
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
},
"toxmod_can_initialize": {
"rva": 55928080,
"length": 1040,
"fingerprint": "0xf72dd6f5dbb16216",
"why": "Third-party voice moderation service; forced false, as on PC."
},
"cheat_report": {
"rva": 106127656,
"length": 2248,
"fingerprint": "0x9c1e0ef53e5a3ec8",
"why": "Log-only: names any integrity report so a new one is visible in logcat."
},
"fatal_application_quit": {
"rva": 55748824,
"length": 348,
"fingerprint": "0x8cd03afe41eecdef",
"why": "Log-only: prints the quit reason and caller."
},
"try_application_quit": {
"rva": 55748412,
"length": 412,
"fingerprint": "0xb12b0044ac4f844f",
"why": "Log-only: prints the quit code and caller."
},
"title_auto_login": {
"rva": 58892048,
"length": 668,
"fingerprint": "0x83bebb9a30983b33",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"_comment": [
"Hook targets, identified by the class header and method signature exactly as",
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
"release and shared with the PC build of the same release; when rebasing, re-check",
"each one against the PC decompile and update the signature here."
],
"targets": [
{
"id": "http_send_request",
"class": "public static class HTTPManager ",
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
},
{
"id": "http_call_callback",
"class": "public sealed class HTTPRequest ",
"method": "internal void CallCallback()",
"why": "Logs each finished request's status (and the body of non-2xx responses)."
},
{
"id": "oculus_get_app_id",
"class": "public sealed class PlatformSettings ",
"method": "public static string get_AppID()",
"why": "Returns the operator's Meta app id when OculusAppId is set."
},
{
"id": "oculus_get_mobile_app_id",
"class": "public sealed class PlatformSettings ",
"method": "public static string get_MobileAppID()",
"why": "Same as get_AppID; the Quest build reads this getter."
},
{
"id": "photon_app_settings",
"class": "internal class NFOMFIAOKFO ",
"method": "public AppSettings LBKJOHGFHOO()",
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
},
{
"id": "tls_unity_verify",
"class": "internal class UnityTlsContext ",
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
},
{
"id": "tls_bc_notify_server_certificate",
"class": "public class LegacyTlsAuthentication ",
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
},
{
"id": "pin_nameserver",
"class": "public class PAPLNIPKAMG ",
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
},
{
"id": "pin_https",
"class": "public static class LJEHIHEHMCN ",
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
},
{
"id": "pin_bc_verifier",
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
},
{
"id": "image_signature_verify",
"class": "public class KLJOGJHBONK ",
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
},
{
"id": "hile_warning_callback",
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
},
{
"id": "unknown_dll_callback",
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
"method": "internal void <OnUnknownDLLDetected>b__0()",
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
},
{
"id": "file_hash_callback",
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
},
{
"id": "toxmod_can_initialize",
"class": "public class ToxModVoiceComponent ",
"method": "public static bool CanInitializeToxMod()",
"why": "Third-party voice moderation service; forced false, as on PC."
},
{
"id": "cheat_report",
"class": "public class FPIBGPIAOBI ",
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
"why": "Log-only: names any integrity report so a new one is visible in logcat."
},
{
"id": "fatal_application_quit",
"class": "public class SessionManager ",
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
"why": "Log-only: prints the quit reason and caller."
},
{
"id": "try_application_quit",
"class": "public class SessionManager ",
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
"why": "Log-only: prints the quit code and caller."
},
{
"id": "title_auto_login",
"class": "public class TitleScreenFlowModel ",
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
}
]
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DorkNet.Server.Tests;
/// <summary>
/// Stand-in for the notify hub that records every push instead of sending it,
/// so tests can assert on the exact frames the game client would receive.
/// A frame is the hub method plus its single string argument, parsed back into
/// the <c>{Id, Msg}</c> envelope.
/// </summary>
public sealed class CapturingHubContext : IHubContext<NotifyHub>
{
private readonly ConcurrentQueue<SentFrame> _sent = new();
public IHubClients Clients => new CapturingClients(this);
public IGroupManager Groups { get; } = new NoopGroups();
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
/// order, optionally filtered by envelope <c>Id</c>.</summary>
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
{
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
public JsonElement Msg => Envelope.GetProperty("Msg");
}
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
{
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
new CapturingProxy(owner, target, excluded);
public IClientProxy All => Proxy("all");
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy($"group:{groupName}", excludedConnectionIds);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
public IClientProxy User(string userId) => Proxy($"user:{userId}");
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
}
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
owner.Record(target, excluded, method, args);
return Task.CompletedTask;
}
}
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
}
private sealed class NoopGroups : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,254 @@
using DorkNet.Server.Services;
using System.Net.Http.Json;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// The March-2023 client budgets every CircuitsV2 network send against
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
/// frame; past that the send is dropped and the room save is flagged
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
/// these tests pin both the wire shape the client's Newtonsoft reader
/// binds and the clamps that keep a hand-edited value from crashing it.
/// </summary>
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Gameconfigs_endpoint_serves_the_network_budget()
{
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
"api/gameconfigs/v1/all");
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
// A server that never saved limits (empty column) serves the
// current defaults, not the retail values.
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
}
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
ServerSettingsService.ToGameConfigurations(
ServerSettingsService.DefaultRecCenterDoors(),
ServerSettingsService.DefaultDiscoveredGameConfigs(),
limits);
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
rows.Single(r => r.Key == key).Value;
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
[Fact]
public void Defaults_lift_the_rpc_and_byte_budgets()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
Assert.Equal(JsonValueKind.Array, network.ValueKind);
var entry = Assert.Single(network.EnumerateArray());
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
// budget never actually got lifted.
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Network_json_uses_the_field_names_the_client_binds()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
.EnumerateArray().First();
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
Assert.True(entry.TryGetProperty("Bytes", out _));
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
Assert.True(entry.TryGetProperty("Rpcs", out _));
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
}
[Fact]
public void Scalar_limits_serialize_invariant_without_separators()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Chips = 123456,
CloudValuesAutosaveSeconds = 300f,
});
// The client parses these with int/float.TryParse under
// InvariantCulture, so a grouped or comma-decimal value silently
// falls back to its baked default.
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Defaults_raise_the_room_size_limits_past_retail()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
// Retail: Compute 50000, Chips 3000, CloudValues 100,
// Unculled/VisibleChips 500, InkPerChip 15.
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
// InkPerChip is a cost, so the lift is downward.
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
// A save cadence, not a capacity limit: left at retail.
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
}
[Fact]
public void Default_visible_chips_do_not_exceed_the_chip_cap()
{
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
// The add-chip path only charges InkPerChip when Chips < VisibleChips
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
// defaults keep that path off as well as zeroing the cost.
Assert.True(limits.VisibleChips <= limits.Chips);
Assert.True(limits.UnculledChips <= limits.Chips);
}
[Fact]
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
{
InkPerChip = int.MaxValue,
CloudValues = int.MaxValue,
});
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
// also makes a single chip unaffordable.
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
Assert.Equal(30_000, ink);
Assert.Equal(10_000, cloud);
Assert.True((long)ink * cloud < int.MaxValue / 2);
}
[Fact]
public void Negative_ink_cost_is_floored_at_zero()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
}
[Fact]
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
{
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
// throws, and int.MaxValue is an out-of-memory kill.
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
}
[Fact]
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
{
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
}
[Fact]
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
}).EnumerateArray().First();
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
}
[Fact]
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
{
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
}).EnumerateArray().First();
// Each window frame is one int in a client-side ring buffer, so an
// unbounded window is an out-of-memory kill at circuit init.
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
}
[Fact]
public void Lowest_entry_is_forced_to_activate_at_zero_players()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(20, 4096, 60, 50, 1),
new(8, 2048, 60, 25, 1),
],
});
// Without an entry at 0 the client rejects the whole array and
// falls back to the retail 5-RPC budget.
var entries = network.EnumerateArray().ToList();
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
// The rewrite keeps that entry's own budget.
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
}
[Fact]
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network =
[
new(16, 4096, 60, 50, 1),
new(0, 1024, 60, 10, 1),
new(16, 8192, 60, 99, 1),
],
});
var thresholds = network.EnumerateArray()
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
.ToList();
Assert.Equal([0, 16], thresholds);
}
[Fact]
public void Empty_network_list_falls_back_to_the_default_budget()
{
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
{
Network = [],
});
var entry = Assert.Single(network.EnumerateArray());
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" /> <PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using DorkNet.Server.Controllers.Site;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
/// <summary>The site's downloads proxy: off without config, only on the site's
/// own host, serves only files the manifest lists, and passes Range through so
/// the browser Quest installer can read an .xapk in place.</summary>
public sealed class DownloadsControllerTests
{
private const string Apex = "dorknet.example.com";
private const string Manifest = """
{"releases":[{"id":"2023.03","title":"March 2023","files":[
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
""";
[Fact]
public async Task Without_a_base_url_the_manifest_is_404()
{
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
}
[Theory]
[InlineData("api.dorknet.example.com")]
[InlineData("cdn.dorknet.example.com")]
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
{
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
ctx.Request.Host = new HostString(host);
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
}
[Fact]
public void Only_listed_paths_are_found()
{
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
}
[Fact]
public async Task Unlisted_file_is_404_and_never_fetched()
{
var requested = new List<string>();
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
{
requested.Add(req.RequestUri!.AbsolutePath);
return Json(Manifest);
});
await controller.GetFile("index.html", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
Assert.Equal(["/manifest.json"], requested);
}
[Fact]
public async Task Range_is_forwarded_and_the_partial_response_streamed()
{
HttpRequestMessage? fileRequest = null;
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
{
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
fileRequest = req;
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
return res;
}, host: "downloads.example.com");
ctx.Request.Headers.Range = "bytes=2-5";
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.NotNull(fileRequest);
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
}
private static (DownloadsController, HttpContext) Create(
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DorkNet:Downloads:BaseUrl"] = baseUrl,
["DorkNet:Downloads:Host"] = host,
}).Build();
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
var ctx = new DefaultHttpContext();
ctx.Request.Host = new HostString(Apex);
ctx.Response.Body = new MemoryStream();
var controller = new DownloadsController(service, new DomainConfig(Apex))
{
ControllerContext = new ControllerContext { HttpContext = ctx },
};
return (controller, ctx);
}
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
+264
View File
@@ -0,0 +1,264 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace DorkNet.Server.Tests;
/// <summary>
/// Website / admin-panel sign-in with an external OpenID Connect provider.
/// The provider round-trip can't run in-process, so each test starts where the
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
/// provider identity, encrypted with the app's own cookie format. Everything
/// from there (link, session, admin SSO, unlink, limits) is the real code.
/// </summary>
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
{
private const string Issuer = "https://idp.example.com";
// Test player ids: sequential, in a range no other test uses.
private static long _nextPlayerId = 9_600_000;
private readonly DorkNetServerFactory _factory;
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
{
var (player, password) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
var session = SessionCookie(link);
Assert.NotNull(session);
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.True(me.GetProperty("signedIn").GetBoolean());
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
// Next time the provider identity alone is enough.
await using var scope = _factory.Services.CreateAsyncScope();
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
var stored = await signIn.FindAsync(Issuer, subject);
Assert.Equal(player.Id, stored?.PlayerId);
}
[Fact]
public async Task Wrong_passwords_are_refused_then_rate_limited()
{
var (player, _) = await PlayerAsync();
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
using var site = Client();
for (var i = 0; i < 5; i++)
{
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "wrong-" + i }, external);
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
}
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password = "still-wrong" }, external);
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
}
[Fact]
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
{
var (first, firstPassword) = await PlayerAsync();
var (second, secondPassword) = await PlayerAsync();
var subject = Guid.NewGuid().ToString("N");
using var site = Client();
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
}
[Fact]
public async Task Admin_sso_issues_tokens_only_to_admins()
{
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
var (regular, regularPassword) = await PlayerAsync();
using var admins = Client("admin");
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task Unlinking_ends_the_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
// The old cookie is still in the browser, but the link behind it is gone.
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
}
[Fact]
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
{
var (player, password) = await PlayerAsync();
using var site = Client();
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.Players.Where(p => p.Id == player.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
}
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
Assert.False(me.GetProperty("signedIn").GetBoolean());
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
}
[Fact]
public async Task Cookie_keys_are_stored_encrypted()
{
var (player, password) = await PlayerAsync();
using var site = Client();
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
Assert.NotEmpty(stored);
Assert.All(stored, xml =>
{
Assert.Contains("dorknetEncryptedKey", xml);
Assert.DoesNotContain("masterKey", xml);
});
}
[Fact]
public void Key_encryption_round_trips_and_rejects_another_secret()
{
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
}
[Fact]
public async Task Downloads_can_require_sign_in()
{
await using var factory = _factory.WithWebHostBuilder(b =>
{
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
});
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
using var manifest = await site.GetAsync("/api/site/v1/downloads");
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
}
// ── helpers ────────────────────────────────────────────────────────────
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
{
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
var player = new PlayerEntity
{
Id = Interlocked.Increment(ref _nextPlayerId),
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
DisplayName = "SSO test",
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
IsAdmin = isAdmin,
};
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Players.Add(player);
await db.SaveChangesAsync();
return (player, password);
}
/// <summary>The cookie the provider callback leaves behind: the provider
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
private string ExternalCookie(string subject, string email)
{
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
.Get(ExternalSignIn.ExternalScheme);
var identity = new ClaimsIdentity(new[]
{
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
}, "test");
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
}
private static string? SessionCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var values)
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
: null;
private HttpClient Client(string? subdomain = null)
{
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
}
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
{
var request = new HttpRequestMessage(method, path);
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
{
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
if (cookie is not null) request.Headers.Add("Cookie", cookie);
return client.SendAsync(request);
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
{
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
}
@@ -0,0 +1,80 @@
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>
/// <c>GET api/incentivizedreferrals/referrals?take=&amp;continuationToken=</c> is
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
/// so it never showed up as a route literal and had no handler. The reply is the
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
/// </summary>
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
{
using var setup = Client();
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
// Same row LinkController.CreditReferralAsync writes on the invitee.
foreach (var invitee in new[] { inviteeA, inviteeB })
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = invitee.PlayerId,
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
Value = DateTime.UtcNow.ToString("O"),
});
await db.SaveChangesAsync();
}
using var client = Client(referrer);
var first = JsonDocument.Parse(
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
var firstPage = Get(first, "Referrals");
Assert.Equal(1, firstPage.GetArrayLength());
var token = Get(first, "ContinuationToken").GetString();
Assert.False(string.IsNullOrEmpty(token));
var second = JsonDocument.Parse(await client.GetStringAsync(
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
var secondPage = Get(second, "Referrals");
Assert.Equal(1, secondPage.GetArrayLength());
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
var ids = new[] { firstPage[0], secondPage[0] }
.Select(e => Get(e, "InviteeAccountId").GetInt64())
.OrderBy(x => x)
.ToArray();
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
// CreatedAt is a non-nullable DateTime on the client: it must be present.
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
}
// The client formatter registers PascalCase, camelCase and lowercase.
private static JsonElement Get(JsonElement e, string pascal) =>
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
private HttpClient Client(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
if (session is not null)
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -0,0 +1,198 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Inventory;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Push frames checked against the 2023-03-21 client's notification readers
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
/// JSON string <c>{"Id":"&lt;key&gt;","Msg":{…}}</c>; the client looks the Id
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
/// wrong key or shape drops the push without any error.
/// </summary>
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, sender);
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
.EnsureSuccessStatusCode();
// RelationshipChanged "1" = the Relationship row from each side's view.
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
Assert.Equal("Notification", toTarget.Method);
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
// MessageReceived "2" — the "X sent you a friend request" inbox row.
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
using var targetClient = ApiClient(factory, target);
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
.EnsureSuccessStatusCode();
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
}
[Fact]
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, me);
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
}
[Fact]
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
var msg = frame.Msg;
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
}
[Theory]
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
[InlineData(NotificationService.LogoutReason.Banned, 4)]
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.LogoutAsync(43, reason);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
var value = frame.Msg.GetProperty("Reason");
Assert.Equal(JsonValueKind.Number, value.ValueKind);
Assert.Equal(wire, value.GetInt32());
}
[Fact]
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
excludeConnectionIds: ["self-conn", "friend-conn"]);
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
}
[Theory]
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
[InlineData("""[6,7]""", new long[] { 6, 7 })]
[InlineData("""{"PlayerIds":[]}""", new long[0])]
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
{
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
}
[Fact]
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
{
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
}
[Fact]
public void Consumable_mapping_push_has_exactly_the_clients_keys()
{
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
{
Id = 9,
ItemSlug = "some-consumable",
Quantity = 3,
}));
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
Assert.Equal(
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
keys);
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
}
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IHubContext<NotifyHub>>();
services.AddSingleton<IHubContext<NotifyHub>>(hub);
}));
return (factory, hub);
}
private HttpClient ApiClient(WebApplicationFactory<Program> factory, 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;
}
}
+222
View File
@@ -0,0 +1,222 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Avatar.V4;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
/// the locked-item lookup, the owned list's per-entry ids, and the gift
/// generate call the menu's gift flow makes.
/// </summary>
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Creation_allowed_gate_is_a_success_error_envelope()
{
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
// "Failed to query if local account can create Custom Avatar Items".
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
Assert.Equal(JsonValueKind.Object, body.ValueKind);
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
}
[Fact]
public async Task Sibling_gates_and_min_price_are_bare_scalars()
{
using var client = ApiClient();
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
Assert.Equal(JsonValueKind.Number, min.ValueKind);
Assert.True(min.GetInt32() > 0);
}
[Fact]
public async Task Saved_outfit_round_trips_every_client_key()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var shirt = Guid.NewGuid();
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
var outfit = new Dictionary<string, object?>
{
["Slot"] = 3,
["PreviewImageName"] = "preview-3.jpg",
["OutfitSelections"] = "",
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
["FaceFeatures"] = "{\"ver\":3}",
["SkinColor"] = "skin-guid",
["HairColor"] = "hair-guid",
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
};
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
var items = saved.GetProperty("CustomAvatarItems");
Assert.Equal(1, items.GetArrayLength());
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
// Overwriting the slot replaces it rather than adding a second row.
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
outfit["CustomAvatarItems"] = Array.Empty<object>();
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
}
[Fact]
public async Task Gift_generate_accepts_the_clients_form_body()
{
// Live trace: this exact form body was answered 415 under [FromBody].
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["GiftContext"] = "12000",
["IsGameGift"] = "True",
["Message"] = "Activity completed!",
}));
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
}
[Fact]
public async Task Locked_items_carry_the_full_owned_item_shape()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
HashSet<string> owned;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
.Select(a => a.InventoryJson).FirstAsync();
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
var notOwned = AvatarItemsController.Catalog.Keys
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
Assert.Equal(3, notOwned.Count);
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
Assert.Equal(3, rows.GetArrayLength());
var ids = new List<int>();
foreach (var row in rows.EnumerateArray())
{
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
}
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
// Two colorways of one base item that the runtime catalog knows.
var group = StoreService.ColorVariantCatalog.Values
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
var baseGuid = group.Key;
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
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();
inv.Add(baseGuid);
inv.AddRange(variants);
avatar.InventoryJson = JsonSerializer.Serialize(inv);
await db.SaveChangesAsync();
}
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
var sameBase = items.EnumerateArray()
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.Equal(3, sameBase.Count);
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().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;
}
private static async Task PostJsonAsync(HttpClient client, string path, object body)
{
using var response = await client.PostAsync(path,
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
}
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();
}
}
@@ -215,6 +215,124 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
} }
} }
[Fact]
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
using var client = ApiClient(player);
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
{
var drop = row.GetProperty("GiftDrop");
var desc = drop.GetProperty("AvatarItemDesc").GetString();
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
}
var matched = 0;
foreach (var item in owned.EnumerateArray())
{
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
matched++;
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
}
Assert.True(matched > 0, "no owned item matched a shop tile");
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
[Fact]
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
{
// The client flags every defaultbaseavataritems row as a custom-
// clothing template and leaves it out of the owned rebuild, so a
// normal item listed here showed a price even when owned.
using var anonymous = ApiClient();
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
var byDesc = rows.EnumerateArray().ToDictionary(
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
r => r);
Assert.Equal(2, byDesc.Count);
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
// Templates aren't sold or listed as owned clothing.
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
await using (var scope = _factory.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(true);
try
{
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
// Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
[Fact]
public async Task Default_unlocked_items_are_owned_by_every_player()
{
// The client replaces its unlocked set with each list it downloads,
// so v4/items must carry the free default items or they show a price.
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");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
// A default item the starter inventory also holds appears once.
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.Equal(ids.Count, ids.Distinct().Count());
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
Assert.Equal(0, locked.GetArrayLength());
}
[Fact]
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
{
using var anonymous = ApiClient();
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
Assert.Equal(
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
descs.Count);
}
private HttpClient ApiClient(GameClientSession? session = null) private HttpClient ApiClient(GameClientSession? session = null)
{ {
var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -0,0 +1,289 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Services;
namespace DorkNet.Server.Tests;
/// <summary>
/// Presence must follow the player through every transition, for the player
/// themselves (the heartbeat) and for everyone else (bulk /player and the
/// PresenceUpdate push).
///
/// The heartbeat side is decided by the client's compare
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
/// and anything else makes the client cancel what it is doing and chase the
/// server's room. matchmake/none is how the client tells us it went to that
/// offline room, so after it the heartbeat must stay null.
/// </summary>
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
{
using var client = await SignedInMatchClientAsync();
// Enter the dorm for real: matchmake, then report the join.
var dormInstance = await MatchmakeDormAsync(client);
await ReportJoinAsync(client, dormInstance);
var before = await HeartbeatAsync(client);
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
using var none = await client.PostAsync("/matchmake/none", Form());
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
// Twice: the first beat must not re-seed a dorm the second one returns.
for (var i = 0; i < 2; i++)
{
var beat = await HeartbeatAsync(client);
var room = RoomInstanceOf(beat);
Assert.True(room.ValueKind == JsonValueKind.Null,
$"""
After matchmake/none the client holds its offline room (instance -1)
and is only in sync with a null roomInstance. The heartbeat answered
with a room, which makes the client cancel the offline load and
chase it:
{beat.GetRawText()}
""");
}
}
[Fact]
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
{
using var client = await SignedInMatchClientAsync();
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
// back to the offline room before reporting a result.
await MatchmakeDormAsync(client);
using var none = await client.PostAsync("/matchmake/none", Form());
Assert.True(none.IsSuccessStatusCode);
var beat = await HeartbeatAsync(client);
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
}
[Fact]
public async Task Matchmaking_again_after_leaving_restores_the_room()
{
using var client = await SignedInMatchClientAsync();
using var none = await client.PostAsync("/matchmake/none", Form());
Assert.True(none.IsSuccessStatusCode);
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
var dormInstance = await MatchmakeDormAsync(client);
await ReportJoinAsync(client, dormInstance);
var beat = await HeartbeatAsync(client);
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
}
[Fact]
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
{
using var subject = await SignedInMatchClientAsync();
using var viewer = await SignedInMatchClientAsync();
var subjectId = await PlayerIdOfAsync(subject);
var dormInstance = await MatchmakeDormAsync(subject);
await ReportJoinAsync(subject, dormInstance);
using var none = await subject.PostAsync("/matchmake/none", Form());
Assert.True(none.IsSuccessStatusCode);
var row = await BulkPresenceAsync(viewer, subjectId);
Assert.True(row.GetProperty("isOnline").GetBoolean(),
$"a player sitting in the offline room is online: {row.GetRawText()}");
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
}
[Fact]
public async Task Logging_out_shows_the_player_offline_and_roomless()
{
using var subject = await SignedInMatchClientAsync();
using var viewer = await SignedInMatchClientAsync();
var subjectId = await PlayerIdOfAsync(subject);
var dormInstance = await MatchmakeDormAsync(subject);
await ReportJoinAsync(subject, dormInstance);
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
using var logout = await subject.PostAsync("/player/logout", Form());
Assert.True(logout.IsSuccessStatusCode);
var row = await BulkPresenceAsync(viewer, subjectId);
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
}
[Fact]
public async Task Bulk_presence_reports_the_stored_status_visibility()
{
using var subject = await SignedInMatchClientAsync();
using var viewer = await SignedInMatchClientAsync();
var subjectId = await PlayerIdOfAsync(subject);
using var put = await subject.PutAsync("/player/statusvisibility",
Form(("statusVisibility", "2")));
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
var row = await BulkPresenceAsync(viewer, subjectId);
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
// And the player's own heartbeat agrees.
var beat = await HeartbeatAsync(subject);
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
}
[Fact]
public void Presence_push_carries_online_state_independently_of_the_room()
{
var room = new RoomInstanceDto
{
RoomInstanceId = 42,
RoomId = 7,
Name = "DormRoom",
PhotonRoomId = "^dormroom_p1",
};
// Online, no room: the offline-room state. Used to be sent as OFFLINE
// because isOnline was derived from the room.
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
// Offline never leaks a room, even if one is still cached.
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
Assert.False(offline.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
// Online in a room: redacted join coordinates, visibility passed through,
// appVersion a STRING.
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
static JsonElement Msg(string payload)
{
var root = JsonDocument.Parse(payload).RootElement;
// String-keyed channel: the client registers "PresenceUpdate".
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
return root.GetProperty("Msg");
}
}
[Fact]
public void Presence_pushes_honour_the_players_status_visibility()
{
// Live PresenceUpdate frames must hide a player exactly as GET /player
// does, or "appear offline" leaks every room change to friends.
long[] friends = [10, 11, 12];
var favorited = new HashSet<long> { 11 };
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
Assert.Equal(friends, pub.VisibleTo);
Assert.Empty(pub.HiddenFrom);
// Every recipient is already a friend, so FriendsOnly hides nobody.
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
Assert.Equal(friends, friendsOnly.VisibleTo);
Assert.Empty(friendsOnly.HiddenFrom);
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
Assert.Equal([11L], favoritesOnly.VisibleTo);
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
Assert.Empty(offline.VisibleTo);
Assert.Equal(friends, offline.HiddenFrom);
}
// ── helpers ──────────────────────────────────────────────────────
private async Task<HttpClient> SignedInMatchClientAsync()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<long> PlayerIdOfAsync(HttpClient client)
{
var beat = await HeartbeatAsync(client);
return beat.GetProperty("playerId").GetInt64();
}
private static async Task<string> MatchmakeDormAsync(HttpClient client)
{
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
return id!;
}
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
{
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
}
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
{
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
{
using var resp = await viewer.GetAsync($"/player?{subjectId}");
var body = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
{
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
}
Assert.Fail($"no row for {subjectId}: {body}");
return default;
}
private static JsonElement RoomInstanceOf(JsonElement presence)
{
Assert.True(presence.TryGetProperty("roomInstance", out var room),
$"no roomInstance key: {presence.GetRawText()}");
return room;
}
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
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;
}
}
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
var text = await resp.Content.ReadAsStringAsync(); var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64(); var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope(); await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>(); var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
@@ -0,0 +1,257 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Tests;
/// <summary>
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
/// urlencoded field whose name differs from the suffix (displayEmoji,
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
/// every edit failed with "Failed to modify &lt;suffix&gt;", and no account
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
/// visibility (PUT player/statusvisibility) was persisted but GET /player
/// always reported Public and never hid the player.
/// </summary>
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
{
var (owner, viewer) = await TwoPlayersAsync();
using var ownerClient = AccountsClient(owner);
using var viewerClient = AccountsClient(viewer);
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
var me = await GetJsonAsync(ownerClient, "/account/me");
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
// Empty clears it.
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
me = await GetJsonAsync(ownerClient, "/account/me");
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
}
[Fact]
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
{
var (owner, _) = await TwoPlayersAsync();
using var client = AccountsClient(owner);
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
// "✌" without U+FE0F resolves to the list's "✌️".
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
var me = await GetJsonAsync(client, "/account/me");
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
}
[Fact]
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
{
var (owner, viewer) = await TwoPlayersAsync();
using var ownerClient = AccountsClient(owner);
using var viewerClient = AccountsClient(viewer);
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
void AssertProfile(JsonElement a)
{
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
}
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
}
[Fact]
public async Task Flag_edits_reject_non_numeric_values()
{
var (owner, _) = await TwoPlayersAsync();
using var client = AccountsClient(owner);
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
}
[Fact]
public async Task Self_account_reports_username_changes_remaining()
{
var (owner, _) = await TwoPlayersAsync();
using var client = AccountsClient(owner);
var me = await GetJsonAsync(client, "/account/me");
// Absent/0 makes the settings UI say no username changes are left.
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
}
[Fact]
public async Task Bio_edit_round_trips_through_public_bio_lookup()
{
var (owner, viewer) = await TwoPlayersAsync();
using var ownerClient = AccountsClient(owner);
using var viewerClient = AccountsClient(viewer);
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
}
[Fact]
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
{
var (owner, viewer) = await TwoPlayersAsync();
PutOnline(owner.PlayerId);
using var ownerMatch = MatchClient(owner);
using var viewerMatch = MatchClient(viewer);
// Baseline: Public → viewer sees owner online.
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
Assert.True(before.GetProperty("isOnline").GetBoolean());
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
// Self keeps its own choice (the dialog reads the cached self presence).
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
Assert.True(self.GetProperty("isOnline").GetBoolean());
// Others see an offline player with no room.
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
Assert.False(other.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
}
[Fact]
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
{
var (owner, friend) = await TwoPlayersAsync();
using var setup = AccountsClient();
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
PutOnline(owner.PlayerId);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Relationships.Add(new RelationshipEntity
{
RequesterId = owner.PlayerId,
TargetId = friend.PlayerId,
Status = RelationshipStatus.Friend,
});
await db.SaveChangesAsync();
}
using var ownerMatch = MatchClient(owner);
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
using var friendMatch = MatchClient(friend);
using var strangerMatch = MatchClient(stranger);
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
}
[Fact]
public void Presence_pref_key_matches_status_visibility_writer()
{
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
var field = typeof(MatchPlayerController).GetField(
"PrefStatusVisibility",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert.NotNull(field);
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
}
// ── helpers ──────────────────────────────────────────────────────────
private void PutOnline(long playerId)
{
using var scope = _factory.Services.CreateScope();
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
presence.SetRoom(playerId, new RoomInstanceDto
{
RoomInstanceId = 900_000 + playerId,
RoomId = 1,
SubRoomId = 0,
PhotonRoomId = $"^profiletest{playerId}",
PhotonRegionId = "us",
MaxCapacity = 8,
});
presence.MarkActive(playerId);
}
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
{
var list = await GetJsonAsync(client, $"/player?{playerId}");
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
}
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
{
using var setup = AccountsClient();
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
return (a, b);
}
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
{
using var response = await PutFormAsync(client, path, field, value);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
}
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
private HttpClient Client(string host, GameClientSession? session)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_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);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
using var document = JsonDocument.Parse(body);
return document.RootElement.Clone();
}
}
@@ -0,0 +1,133 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Services;
namespace DorkNet.Server.Tests;
/// <summary>
/// A Quest client patched without its own Meta app has no platform session,
/// so every headset reports the same placeholder platform user id ("1").
/// Accounts must stay keyed by device id: two headsets get two accounts, a
/// headset gets its own account back on the next boot, and the placeholder is
/// never stored or matched by the remembered-logins lookup — otherwise every
/// Quest player would be offered every other Quest player's account.
/// </summary>
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
{
// RecNet platform value the 2023 Quest client sends on /connect/token.
// The fix does not depend on it; any platform with a placeholder id is
// handled the same way.
private const string QuestPlatform = "1";
private const string PlaceholderPlatformId = "1";
private readonly DorkNetServerFactory _factory;
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData(" ", true)]
[InlineData("0", true)]
[InlineData("1", true)]
[InlineData(" 1 ", true)]
[InlineData("10", false)]
[InlineData("2173678582678296", false)]
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
[Fact]
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
{
using var client = Client("auth");
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
var a = await CreateAccountAsync(client, headsetA);
var b = await CreateAccountAsync(client, headsetB);
Assert.NotEqual(a, b);
// Same headset, next boot: its own account comes back.
var aAgain = await CreateAccountAsync(client, headsetA);
Assert.Equal(a, aAgain);
}
[Fact]
public async Task Placeholder_platform_id_never_lists_remembered_logins()
{
using var client = Client("auth");
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
using var response = await client.GetAsync(
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
using var json = JsonDocument.Parse(body);
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
Assert.Equal(0, json.RootElement.GetArrayLength());
}
[Fact]
public async Task A_real_platform_id_still_lists_its_remembered_login()
{
using var client = Client("auth");
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
using var response = await client.GetAsync(
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
Assert.Contains(id.ToString(), body);
}
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
// client_id, platform, platform_id, device_id, ...). No username/password,
// so the server takes the device-id path.
private async Task<long> CreateAccountAsync(
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
{
var form = new Dictionary<string, string>
{
["grant_type"] = "create_account",
["client_id"] = "recroom",
["platform"] = QuestPlatform,
["platform_id"] = platformId,
["device_id"] = deviceId,
};
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
{
Content = new FormUrlEncodedContent(form),
};
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
using var json = JsonDocument.Parse(body);
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfo);
userInfoResponse.EnsureSuccessStatusCode();
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
}
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,278 @@
using System.Net;
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>
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
///
/// The failure modes are silent server-side: an empty 200 parses to null and
/// null-derefs, so the player sees "Failed to …" after the change was applied;
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
/// So the assertion that matters is the body, read the way the client's
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
/// </summary>
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
{
using var setup = Client("clubs");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var clubId = await SeedOpenClubAsync(owner.PlayerId);
using var joinerClient = Client("clubs", joiner);
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
using var join = await joinerClient.PutAsync(
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
await AssertRecNetSuccessAsync(join, "requesttojoin");
using var leave = await joinerClient.PostAsync(
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
await AssertRecNetSuccessAsync(leave, "leave");
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
using var ownerClient = Client("clubs", owner);
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
}
[Fact]
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
{
using var setup = Client("api");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = Client("api", player);
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
using var accounts = Client("accounts", player);
using var privacy = await accounts.PutAsync(
"/accountprivacysettings/recenthistoryvisibility",
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
}
[Fact]
public async Task A_refused_warning_carries_an_error_message()
{
using var setup = Client("api");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = Client("api", player);
using var response = await client.PostAsync("/api/playerwarnings",
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.False(ReadSuccess(root));
// Without Error the client's Promise.Rejected(null) throws instead of
// surfacing the refusal.
Assert.False(string.IsNullOrEmpty(ReadError(root)));
}
[Fact]
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
{
using var setup = Client("auth");
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var auth = Client("auth", player);
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
var raw = await mint.Content.ReadAsStringAsync();
Assert.True(mint.IsSuccessStatusCode, raw);
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
var token = JsonSerializer.Deserialize<string>(raw);
Assert.False(string.IsNullOrEmpty(token));
using var anon = Client("auth");
var form = new Dictionary<string, string>
{
["grant_type"] = "login_token",
["account_id"] = player.PlayerId.ToString(),
["login_token"] = token!,
};
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
}
[Fact]
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
{
using var anon = Client("auth");
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
var deviceCode = started.GetProperty("device_code").GetString()!;
var userCode = started.GetProperty("user_code").GetString()!;
var poll = new Dictionary<string, string>
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
["device_code"] = deviceCode,
};
// Before approval the client must be told to keep polling: a 400
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
{
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
}
using var approverClient = Client("auth", approver);
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
new FormUrlEncodedContent([new("code", "NOPE0000")])))
{
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
Assert.False(ReadSuccess(root));
Assert.False(string.IsNullOrEmpty(ReadError(root)));
}
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
await AssertRecNetSuccessAsync(approve, "remoteauth");
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
var grantedBody = await granted.Content.ReadAsStringAsync();
Assert.True(granted.IsSuccessStatusCode, grantedBody);
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
// Single use.
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
}
[Fact]
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
{
using var client = Client("api");
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
var row = rows.EnumerateArray().FirstOrDefault(r =>
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
// the entry is logged as "Failed to parse priority mapping".
var entries = Prop(row, "Value")!.Split(';');
Assert.All(entries, e =>
{
var parts = e.Split('=');
Assert.Equal(2, parts.Length);
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
});
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
}
[Fact]
public async Task Server_strings_download_answers_an_empty_table()
{
using var client = Client("strings");
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, body);
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
}
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
[Theory]
[InlineData("RoomOwners", 256)]
[InlineData("All", int.MaxValue)]
[InlineData("None", 0)]
[InlineData("256", 256)]
[InlineData("roomowners, None", 256)]
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
Assert.Equal(expected,
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
[Fact]
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
.ParseBroadcastPermissions("SomethingElse"));
private async Task<long> SeedOpenClubAsync(long ownerId)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var club = new ClubEntity
{
Name = $"Env{Guid.NewGuid():N}"[..16],
CreatorPlayerId = ownerId,
Joinability = 0, // Open
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
return club.Id;
}
private async Task<long> SubjectOfAsync(string tokenBody)
{
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
using var client = Client("auth");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
return long.Parse(info.GetProperty("sub").GetString()!);
}
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
{
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
var root = JsonDocument.Parse(body).RootElement;
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
}
// The client formatter registers exactly these spellings.
private static bool ReadSuccess(JsonElement root) =>
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
&& s.ValueKind == JsonValueKind.True;
private static string? ReadError(JsonElement root) =>
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
: null;
private static string? Prop(JsonElement row, string name) =>
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
? v.GetString()
: null;
private HttpClient Client(string host, GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
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;
}
}
+31 -7
View File
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests; namespace DorkNet.Server.Tests;
/// <summary> /// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the /// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns /// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares /// as "save room"), whose <c>value</c> is <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>, /// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
/// with the route literal on :4802). /// with the route literal on :4802). A bare room with no <c>success</c> is
/// rejected with an empty error.
/// ///
/// So the two responses must be shape-identical. When they drift, the server /// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
/// still answers 200 and the client throws while reading it — and because the /// 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 /// 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 /// thread seconds later as a bare "Failed to copy room: Exception of type
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
} }
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}"); var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
// The client reads clone through the {success, value, error} envelope.
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
var cloneBody = cloneEnvelope.GetProperty("value");
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("RoomId").GetInt64(); var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope()) await using (var scope = factory.Services.CreateAsyncScope())
{ {
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}")); Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
} }
[Fact]
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// No room has this id, so the clone is refused; the client shows
// `error`, which a bare 404 would leave empty.
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
Assert.False(envelope.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
}
private static async Task<JsonElement> ReadJsonAsync( private static async Task<JsonElement> ReadJsonAsync(
HttpClient client, HttpMethod method, string path, HttpContent? content = null) HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{ {
@@ -0,0 +1,133 @@
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;
/// <summary>
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
/// typed <c>Task&lt;FGCPNAACHIK&gt;</c>. The button only exists when
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
/// not already public. A clone of a Rec Room Original template used to be
/// advertised as IsRRO and so could never be published.
/// </summary>
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Only_system_owned_originals_are_RRO_on_the_wire()
{
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
}
[Fact]
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
{
using var setup = Client("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var templateId = 9_100_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 = templateId,
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
CreatorPlayerId = PlayerService.SystemAccountId,
IsAGRoom = true,
Accessibility = 1,
CloningAllowed = false,
ImageName = "image_RecCenter.png",
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
await db.SaveChangesAsync();
}
using var ownerRooms = Client("rooms", owner);
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
Assert.True(template.GetProperty("IsRRO").GetBoolean());
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
var cloneText = await cloneResp.Content.ReadAsStringAsync();
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
var cloneId = clone.GetProperty("RoomId").GetInt64();
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
await db.SaveChangesAsync();
}
// Publish as the owner: full room details back, now public, still not RRO.
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
Assert.False(published.GetProperty("IsRRO").GetBoolean());
Assert.True(published.TryGetProperty("SubRooms", out _));
// A moderator may not change accessibility; a co-owner may.
using var modRooms = Client("rooms", moderator);
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
{
// Refusals are 200 {success:false, error} so the client can show them.
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
using var coRooms = Client("rooms", coOwner);
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
}
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
{
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
// "change room accessibility" goes through the {success, value, error}
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
return doc.RootElement.GetProperty("value").Clone();
}
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 subdomain, GameClientSession? session = null)
{
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;
}
}
+528
View File
@@ -0,0 +1,528 @@
using System.Net;
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 roles, the co-owner invite flow and the sub-room role-permission
/// matrix, against the client contract:
/// <list type="bullet">
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
/// the client takes the local role from the Roles list and only lets a
/// player edit roles strictly below their own, Creator excepted
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
/// invitee gets message Type 62 with RoomId; accepts with
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
/// <c>GET photon_access_token</c>.</item>
/// </list>
/// </summary>
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
// ── pure rules ──────────────────────────────────────────────────────
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
[Fact]
public void Creator_is_255_and_listed_once()
{
var room = Room(creator: 7);
var wires = RoomRoleRules.BuildWires(room, new[]
{
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
Row(8, RoomRoleRules.StoredModerator),
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
});
Assert.Single(wires, w => w.AccountId == 7);
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
var invited = wires.Single(w => w.AccountId == 9);
Assert.Equal(0, invited.Role);
Assert.Equal(30, invited.InvitedRole);
}
[Fact]
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
{
var room = Room(creator: 7);
var rows = Array.Empty<RoomRoleEntity>();
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
}
[Fact]
public void Co_owner_may_edit_only_roles_below_their_own()
{
var room = Room(creator: 7);
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
// A moderator edits nothing.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
// Nor does a stranger.
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
}
[Fact]
public void Role_changes_follow_the_ladder()
{
var room = Room(creator: 7);
var rows = new List<RoomRoleEntity>
{
Row(8, RoomRoleRules.StoredCoOwner),
Row(9, RoomRoleRules.StoredModerator),
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
};
// Creator: anything on anyone but themselves.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
// Co-owner: only below 30, on targets below 30.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
// …and cannot remove a peer co-owner.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
// Moderator and strangers manage nobody.
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
// Self-service: accept exactly the invited role, decline, leave.
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
}
[Fact]
public void Permission_matrix_merges_stored_cells_over_defaults()
{
var merged = RoomPermissionMatrix.Merge(new[]
{
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
});
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
}
[Fact]
public void Permission_body_accepts_numbers_strings_and_enum_names()
{
var cells = RoomPermissionMatrix.Parse(
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
Assert.NotNull(cells);
Assert.Equal(3, cells!.Count);
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
}
// ── over HTTP ───────────────────────────────────────────────────────
[Fact]
public async Task Roles_list_reports_the_creator_as_Creator_255()
{
var (owner, _, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, me.GetProperty("Role").GetInt32());
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var detailsRole = details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
Assert.Equal(255, single.GetProperty("Role").GetInt32());
}
[Fact]
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var inviteeRow = RoleOf(invited, invitee.PlayerId);
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
long messageId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var msg = await db.Messages.SingleAsync(m =>
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
Assert.Null(msg.ReadAt);
messageId = msg.Id;
}
// Accept: the invitee PUTs their own role to 30.
using var inviteeRooms = Client("rooms", invitee);
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
Assert.True(await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
}
}
[Fact]
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
{
var (owner, invitee, stranger) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = Client("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
using var inviteeRooms = Client("rooms", invitee);
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
// With the invite gone, the same accept is refused.
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
await AssertRefusedAsync(again);
// A stranger can neither promote themselves nor invite anyone.
using var strangerRooms = Client("rooms", stranger);
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
await AssertRefusedAsync(self);
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(other);
}
[Fact]
public async Task Co_owner_manages_roles_below_but_not_co_owners()
{
var (owner, coOwner, target) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var coRooms = Client("rooms", coOwner);
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
await AssertRefusedAsync(inviteCo);
// The creator can directly add and remove a co-owner.
using var ownerRooms = Client("rooms", owner);
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
// …and now the co-owner can't touch their peer.
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
await AssertRefusedAsync(demote);
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
}
[Fact]
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
{
var (owner, coOwner, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
using var ownerRooms = Client("rooms", owner);
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
{
var text = await put.Content.ReadAsStringAsync();
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
// {success, value: full room details, error}
var value = Envelope(text);
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
}
// The owner enters the room, then the matrix comes back with the token.
using var match = Client("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
using var coRooms = Client("rooms", coOwner);
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
await AssertRefusedAsync(denied);
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await allowed.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
Assert.Equal(3, stored.Count);
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
}
[Fact]
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
}));
HttpClient FactoryClient(string sub, GameClientSession? session = null)
{
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return c;
}
using var setup = FactoryClient("api");
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var roomId = await SeedRoomAsync(owner.PlayerId);
using var ownerRooms = FactoryClient("rooms", owner);
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
using var match = FactoryClient("match", owner);
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Envelope(await put.Content.ReadAsStringAsync());
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
}
[Fact]
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
{
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
Assert.True(ok.GetProperty("success").GetBoolean());
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
Assert.Equal("", ok.GetProperty("error").GetString());
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
Assert.False(forbid.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
// An envelope the handler built itself is left alone.
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
}
[Fact]
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
// The exact request the client sent in production (bare path, form role=30).
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
foreach (var (path, key, value) in new[]
{
($"/rooms/{roomId}/description", "description", "hello"),
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
})
{
var details = await PutFormAsync(rooms, path, (key, value));
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
}
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
{
using var setup = Client("api");
return (
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
}
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
{
var roomId = 9_300_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 = $"Roles{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 0,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
foreach (var (pid, role) in roles)
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
await db.SaveChangesAsync();
return roomId;
}
private static JsonElement RoleOf(JsonElement details, long playerId) =>
details.GetProperty("Roles").EnumerateArray()
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
{
using var response = await PutFormRawAsync(client, path, fields);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
return Envelope(body);
}
/// <summary>Every room mutation answers the {success, value, error}
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC&lt;FGCPNAACHIK&gt;): assert success and
/// return the room details in <c>value</c>.</summary>
private static JsonElement Envelope(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.True(root.GetProperty("success").GetBoolean(), body);
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
return root.GetProperty("value").Clone();
}
/// <summary>A refusal is HTTP 200 with success=false and a message: the
/// client shows the message, while a 403/404 surfaces only as the
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
private static async Task AssertRefusedAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(body);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
}
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
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 subdomain, GameClientSession? session = null)
{
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;
}
}
+4 -2
View File
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var root = json.RootElement; // {success, value, error} envelope around the full room-details
// Full room-details shape (FGCPNAACHIK), not a status wrapper. // shape (FGCPNAACHIK).
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
var root = json.RootElement.GetProperty("value");
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" }) foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}"); Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64(); return root.GetProperty("RoomId").GetInt64();
+449
View File
@@ -0,0 +1,449 @@
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;
}
}
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
private static JsonElement SubRoomZero(JsonElement details) =>
details.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
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;
}
}
+15 -8
View File
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
// ── in-game: the owner's slider on sub-room 1 only ────────────────── // ── in-game: the owner's slider on the second sub-room only ─────────
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64, // Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is // Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding // "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
// real client's body went unread. // real client's body went unread.
using var ownerClient = Client("rooms", owner); using var ownerClient = Client("rooms", owner);
using var slider = await ownerClient.PutAsJsonAsync( using var slider = await ownerClient.PutAsJsonAsync(
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 }); $"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
var sliderBody = await slider.Content.ReadAsStringAsync(); var sliderBody = await slider.Content.ReadAsStringAsync();
Assert.True(slider.IsSuccessStatusCode, Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}"); $"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client deserialises the reply as FGCPNAACHIK — the room-details // The client reads the reply through the {success, value, error}
// shape — so the slider's own response has to carry the new value. // helper with value = FGCPNAACHIK, so the slider's own response has to
var echoed = JsonDocument.Parse(sliderBody).RootElement // carry the new value inside the envelope.
var envelope = JsonDocument.Parse(sliderBody).RootElement;
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray() .GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1); .First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32()); Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
// ── admin: reads what the owner just set, then edits sub-room 0 ───── // ── admin: reads what the owner just set, then edits sub-room 0 ─────
// (the admin API addresses sub-rooms by OrderIndex)
using var adminClient = Client("admin", admin); using var adminClient = Client("admin", admin);
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
var subRooms = listed.EnumerateArray() var subRooms = listed.EnumerateArray()
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(), .ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
e => e.GetProperty("MaxPlayers").GetInt32()); e => e.GetProperty("MaxPlayers").GetInt32());
Assert.Equal(40, wire[0]); // Wire ids are OrderIndex + 1.
Assert.Equal(24, wire[1]); Assert.Equal(40, wire[1]);
Assert.Equal(24, wire[2]);
// The room-level figure is the room's advertised capacity, NOT a copy // 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. // of sub-room 0 — otherwise the admin edit above would have moved it.
@@ -0,0 +1,277 @@
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>
/// Sub-room ids and the room instance browser for the 2023 client.
/// <list type="bullet">
/// <item>The client will not load a sub-room whose id is not positive
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId &gt; 0 &amp;&amp; SubroomId &gt; 0</c>),
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
/// room details, sub-room routes, matchmaking and the instance browser.</item>
/// <item>A new room always owns a real, named entry sub-room, and a startup
/// pass repairs rooms that lack one.</item>
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
/// players, including the private instance of a private room for the people
/// allowed into it.</item>
/// </list>
/// </summary>
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public void Wire_sub_room_ids_are_positive_and_round_trip()
{
Assert.Equal(1, RoomSceneIds.ToWire(0));
Assert.Equal(3, RoomSceneIds.ToWire(2));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
// 0 (cached client state, bare matchmake) is the entry sub-room.
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
}
[Fact]
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
using var rooms = Client("rooms", owner);
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
using var match = Client("match", owner);
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
}
[Fact]
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
{
var owner = await PlayerAsync();
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
using var rooms = Client("rooms", owner);
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, text);
using var json = JsonDocument.Parse(text);
var value = json.RootElement.GetProperty("value");
var cloneId = value.GetProperty("RoomId").GetInt64();
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
Assert.Equal("Home", sub.GetProperty("Name").GetString());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
Assert.Equal(0, scene.OrderIndex);
Assert.Equal("Home", scene.Name);
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
}
[Fact]
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
{
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
await using var scope = _factory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, renamed) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
Assert.True(renamed >= 2);
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
// Idempotent.
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Room_level_restore_also_restores_the_entry_sub_room()
{
var owner = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
await db.SaveChangesAsync();
}
using var api = Client("api", owner);
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
{
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
}));
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
await using var check = _factory.Services.CreateAsyncScope();
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
// The entry sub-room follows the restore; other sub-rooms keep theirs.
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
}
[Fact]
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
{
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
var (added, _) = await service.RepairRoomScenesAsync();
Assert.True(added >= 1);
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
}
[Fact]
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
{
var owner = await PlayerAsync();
var coOwner = await PlayerAsync();
var stranger = await PlayerAsync();
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
});
await db.SaveChangesAsync();
}
using var ownerMatch = Client("match", owner);
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
foreach (var viewer in new[] { owner, coOwner })
{
using var match = Client("match", viewer);
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
var row = list.EnumerateArray().Single();
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
Assert.False(row.GetProperty("isFull").GetBoolean());
Assert.Equal(new[] { (int)owner.PlayerId },
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
}
// Someone with no way into the private room is not shown it.
using var strangerMatch = Client("match", stranger);
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
Assert.Empty(hidden.EnumerateArray());
}
[Fact]
public void Private_instance_visibility_rule()
{
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<GameClientSession> PlayerAsync()
{
using var setup = Client("api");
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
}
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
{
var roomId = 9_200_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 = $"Subs{Guid.NewGuid():N}"[..18],
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = accessibility,
CloningAllowed = true,
IsAGRoom = isAg,
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
});
for (var i = 0; i < scenes.Length; i++)
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
await db.SaveChangesAsync();
return roomId;
}
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
{
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
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 subdomain, GameClientSession? session = null)
{
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;
}
}
@@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat
GET unity_assets/{0}/{1}/{2} inventions-media GET unity_assets/{0}/{1}/{2} inventions-media
GET voice/config moderation-safety GET voice/config moderation-safety
GET voice/requiresModeration moderation-safety GET voice/requiresModeration moderation-safety
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
# email/phone/confirmphone POST=2).
GET api/playerReputation/v2/bulk identity-account
POST api/playerReputation/v2/bulk identity-account
GET api/players/v2/progression/bulk identity-account
POST api/players/v2/progression/bulk identity-account
GET strings config-telemetry
GET config/{0} config-telemetry
GET data/{0} config-telemetry
GET sigs/{0} config-telemetry
DELETE subscription/{0} commerce-subscription
PUT account/me/username identity-account
PUT account/me/displayname identity-account
PUT account/me/bio identity-account
PUT account/me/birthday identity-account
POST account/me/email identity-account
POST account/me/phone identity-account
PUT account/me/emoji identity-account
PUT account/me/personalpronouns identity-account
PUT account/me/identityflags identity-account
PUT account/me/bannerimage identity-account
POST account/me/confirmphone identity-account
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
GET api/itemWishlists/v1/wishlist/me avatar-inventory
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
GET api/incentivizedreferrals/referrals progression-misc
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
POST account/bulk/email identity-account
POST account/bulk/phone identity-account
POST api/avatar/v2/gifts/consume avatar-inventory
GET video/{0} config-telemetry
Can't render this file because it contains an unexpected character in line 532 and column 73.
@@ -57,17 +57,7 @@ public class AccountApiController(
{ {
Success = true, Success = true,
Error = string.Empty, Error = string.Empty,
Account = new RecNetAccount Account = AccountDtos.Public(player),
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
},
}); });
} }
@@ -77,22 +67,7 @@ public class AccountApiController(
{ {
var player = await GetCurrentPlayerAsync(); var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized(); if (player is null) return Unauthorized();
return Ok(new RecNetSelfAccount return Ok(AccountDtos.Self(player));
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = player.Email ?? string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
});
} }
/// <summary> /// <summary>
@@ -50,7 +50,18 @@ public class AvatarGiftsController(
return Ok(rows.Select(ToWire)); return Ok(rows.Select(ToWire));
} }
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message); /// <summary>Body keys are the client's AFGEDDANEKP param literals
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
/// with settable properties, not a positional record, because
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
/// fills it.</summary>
public sealed class GenerateGiftRequest
{
public int? GiftContext { get; set; }
public bool? IsGameGift { get; set; }
public int? AlternateGiftContext { get; set; }
public string? Message { get; set; }
}
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of- /// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
/// activity gift box (chest). For a game/quest gift (IsGameGift) the /// activity gift box (chest). For a game/quest gift (IsGameGift) the
@@ -64,8 +75,13 @@ public class AvatarGiftsController(
// POST 404'd, breaking reward chests on that path). Same request body. // POST 404'd, breaking reward chests on that path). Same request body.
[HttpPost("api/avatar/v2/gifts/generate")] [HttpPost("api/avatar/v2/gifts/generate")]
[HttpPost("api/avatar/v3/gifts/generate")] [HttpPost("api/avatar/v3/gifts/generate")]
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
// was [FromBody]. The form-or-JSON binder accepts either encoding.
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req) public async Task<IActionResult> Generate(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var isGameGift = req?.IsGameGift ?? false; var isGameGift = req?.IsGameGift ?? false;
@@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Avatar.V2;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V3; namespace DorkNet.Server.Controllers.API.Avatar.V3;
/// <summary> /// <summary>
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire /// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction): /// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string), /// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>. /// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
/// CustomAvatarItems is a list of BGNNOMBFMLH
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
/// settings (<c>api/avatar/v2</c>) use.
/// ///
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as /// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by /// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
} }
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot. /// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary> /// Verb ordinal 2 = POST with a raw JSON body of one serialised
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
///
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
/// OutfitSelections only when V2 is empty). Loading a saved slot
/// therefore restored a stale V1 selection (or nothing) and lost every
/// custom shirt in it.</summary>
[HttpPost("api/avatar/v3/saved/set")] [HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")] [HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body) public async Task<IActionResult> Set()
{ {
string raw;
using (var reader = new StreamReader(Request.Body))
raw = await reader.ReadToEndAsync();
var body = ParseOutfit(raw);
if (body is null) return BadRequest("missing body"); if (body is null) return BadRequest("missing body");
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null) if (avatar is null)
@@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player"); if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
list.Add(body); list.Add(body);
} }
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list); avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow; avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
return Ok(); return Ok();
} }
/// <summary>Read one outfit object, matching keys case-insensitively
/// (the client reader accepts Pascal/camel/lower spellings, so any of
/// them is a valid write). String fields tolerate null; a nested
/// object/array is kept as its raw JSON text, mirroring how
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
/// that is not a JSON object.</summary>
internal static SavedOutfitDto? ParseOutfit(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return null;
var dto = new SavedOutfitDto();
foreach (var prop in root.EnumerateObject())
{
switch (prop.Name.ToLowerInvariant())
{
case "slot":
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
break;
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
case "haircolor": dto.HairColor = AsString(prop.Value); break;
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
}
}
return dto;
}
catch (JsonException)
{
return null;
}
}
private static string AsString(JsonElement v) => v.ValueKind switch
{
JsonValueKind.String => v.GetString() ?? string.Empty,
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
_ => v.GetRawText(),
};
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
/// dropped instead of failing the whole save.</summary>
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
{
var result = new List<CustomAvatarItemRefDto>();
if (v.ValueKind != JsonValueKind.Array) return result;
foreach (var el in v.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
Guid? id = null;
byte bodyPart = 0;
foreach (var p in el.EnumerateObject())
{
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
&& p.Value.ValueKind == JsonValueKind.String
&& Guid.TryParse(p.Value.GetString(), out var g))
id = g;
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
&& p.Value.ValueKind == JsonValueKind.Number
&& p.Value.TryGetByte(out var b))
bodyPart = b;
}
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
}
return result;
}
private static List<SavedOutfitDto> ParseList(string? json) private static List<SavedOutfitDto> ParseList(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) return new(); if (string.IsNullOrWhiteSpace(json)) return new();
try try
{ {
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new(); var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
// Rows written before CustomAvatarItems existed carry no key;
// keep the wire array non-null.
foreach (var o in list) o.CustomAvatarItems ??= new();
return list;
} }
catch { return new(); } catch { return new(); }
} }
@@ -97,7 +197,14 @@ public sealed class SavedOutfitDto
[JsonPropertyName("Slot")] public int Slot { get; set; } [JsonPropertyName("Slot")] public int Slot { get; set; }
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty; [JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty; [JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
/// <summary>The selection the 2023 client actually wears when non-empty
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty; [JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty; [JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty; [JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
/// <summary>Custom shirts worn in this slot. Always an array on the wire
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
} }
@@ -59,7 +59,15 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// makes every store item appear owned. We instead intersect the /// makes every store item appear owned. We instead intersect the
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master /// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns. /// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return []. ///
/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with
/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70,
/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that
/// field). It never merges in its own baked default list, so the server
/// must include the free starter items (avatar_item_lookup.json
/// <c>default_unlocked_variants</c>) in every response. Without them a
/// default item such as Beard Close showed a price in the outfit menu.
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class AvatarItemsController( public class AvatarItemsController(
@@ -107,10 +115,10 @@ public class AvatarItemsController(
// reverts to the real per-player wardrobe below. Bounded by the same // reverts to the real per-player wardrobe below. Bounded by the same
// maxItems ceiling so a future watch build can't be over-fed. // maxItems ceiling so a future watch build can't be over-fed.
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(BuildEntireCatalogOwned(maxItems)); return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId(); var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>()); if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var avatar = await db.Avatars var avatar = await db.Avatars
.Where(a => a.PlayerId == me) .Where(a => a.PlayerId == me)
@@ -126,7 +134,7 @@ public class AvatarItemsController(
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>()); if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
var catalog = _catalog.Value; var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
@@ -142,7 +150,10 @@ public class AvatarItemsController(
.ThenByDescending(x => x.OriginalIndex) .ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Entry); .Select(x => x.Entry);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); // Room is kept for the default-unlocked items so a large wardrobe
// can't push them out (they'd show as buyable again).
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count);
var emittedBase = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var perSlot = new Dictionary<int, int>(); var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems)); var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var entry in ordered) foreach (var entry in ordered)
@@ -156,12 +167,15 @@ public class AvatarItemsController(
// miss and silently drop every owned colored item (the contest // miss and silently drop every owned colored item (the contest
// hat "I bought it but it never shows in my wardrobe" bug). // hat "I bought it but it never shows in my wardrobe" bug).
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue; if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count); perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
perSlot[slot] = count + 1; perSlot[slot] = count + 1;
if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase))
emittedBase.Add(entry.BaseGuid);
// Preserve the bought color: a bare entry (FullDesc == BaseGuid) // Preserve the bought color: a bare entry (FullDesc == BaseGuid)
// emits the catalog DTO as-is ("{guid},,,"); a colored entry // emits the catalog DTO as-is ("{guid},,,"); a colored entry
// overrides AvatarItemDesc with the stored swatch/mask desc so // overrides AvatarItemDesc with the stored swatch/mask desc so
@@ -173,7 +187,12 @@ public class AvatarItemsController(
{ {
AvatarItemType = item.AvatarItemType, AvatarItemType = item.AvatarItemType,
AvatarItemDesc = ToWatchDesc(entry.FullDesc), AvatarItemDesc = ToWatchDesc(entry.FullDesc),
AvatarItemId = item.AvatarItemId, // Hash the FULL desc, as the toggle-on path does: a
// player can own the base item and several colorways of
// it at once (entries dedupe by FullDesc), and reusing the
// base id made them collide in the client's id-keyed
// wardrobe.
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
PlatformMask = item.PlatformMask, PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName, FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip, Tooltip = item.Tooltip,
@@ -184,6 +203,12 @@ public class AvatarItemsController(
if (result.Count >= maxItems) break; if (result.Count >= maxItems) break;
} }
foreach (var guid in catalog.DefaultUnlocked)
{
if (result.Count >= maxItems) break;
if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]);
}
foreach (var slug in ownedHairDyes) foreach (var slug in ownedHairDyes)
{ {
if (result.Count >= maxItems) break; if (result.Count >= maxItems) break;
@@ -202,7 +227,42 @@ public class AvatarItemsController(
IsBaseAvatarItem = false, IsBaseAvatarItem = false,
}); });
} }
return Ok(result); return await WithStoreInfoAsync(result);
}
/// <summary>Give each owned row the same FriendlyName, Tooltip and
/// Rarity its shop tile shows. The client keeps this list's metadata
/// per item, and the catalog dump only carries asset names (e.g.
/// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so
/// the owned list disagreed with the shop for most items. Rows with no
/// shop tile keep the catalog name. Catalog DTOs are shared, so matched
/// rows are copied, never mutated.</summary>
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> rows)
{
var info = await StoreService.LoadAvatarItemStoreInfoAsync(db);
if (info.Count == 0) return rows;
return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s)
? new UnlockedAvatarItemDto
{
AvatarItemType = r.AvatarItemType,
AvatarItemDesc = r.AvatarItemDesc,
AvatarItemId = r.AvatarItemId,
PlatformMask = r.PlatformMask,
FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName,
Tooltip = s.Tooltip,
Rarity = s.Rarity,
TagList = r.TagList,
IsBaseAvatarItem = r.IsBaseAvatarItem,
}
: r).ToList();
}
/// <summary>The free items every player owns
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
{
var catalog = _catalog.Value;
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
} }
/// <summary>The full owned set when the "everyone owns all avatar /// <summary>The full owned set when the "everyone owns all avatar
@@ -218,6 +278,8 @@ public class AvatarItemsController(
foreach (var item in catalog.Items.Values) foreach (var item in catalog.Items.Values)
{ {
if (result.Count >= maxItems) return result; if (result.Count >= maxItems) return result;
// Custom-clothing templates aren't ownable clothing.
if (item.IsBaseAvatarItem) continue;
result.Add(item); result.Add(item);
} }
// Colored variants aren't in the base avatar_item_lookup catalog, so // Colored variants aren't in the base avatar_item_lookup catalog, so
@@ -267,14 +329,33 @@ public class AvatarItemsController(
return result; return result;
} }
/// <summary>The custom-clothing templates ("base avatar items") and
/// their RecNet AvatarItemIds, as the real service served them. The
/// client flags every row of <c>defaultbaseavataritems</c> as a base
/// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in
/// OutfitManager.AODPBDNAGII) and leaves base items out of the owned
/// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This
/// endpoint used to return the first 100 catalog items, which turned
/// ordinary items (80sHipHopJacket, Beard Close, ...) into templates
/// that showed a price even when owned and equipped.</summary>
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt
["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt
};
public static bool IsBaseAvatarItemGuid(string? guid) =>
!string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid);
[HttpGet("api/avatar/v1/defaultbaseavataritems")] [HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult DefaultBaseAvatarItems() public IActionResult DefaultBaseAvatarItems()
{ {
var rows = _catalog.Value.Items.Values var items = _catalog.Value.Items;
.OrderBy(i => i.AvatarItemType) var rows = BaseAvatarItemIds.Keys
.ThenBy(i => i.FriendlyName) .Where(items.ContainsKey)
.Take(100) .Select(g => items[g])
.ToList(); .ToList();
return Ok(rows); return Ok(rows);
} }
@@ -290,6 +371,10 @@ public class AvatarItemsController(
/// data against the same safe list.</summary> /// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids; public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <summary>Base GUIDs of the free items every player owns
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
/// <summary>Master catalog of all renderable items, keyed by /// <summary>Master catalog of all renderable items, keyed by
/// GUID. Used by callers that need item metadata (slot type, /// GUID. Used by callers that need item metadata (slot type,
/// name, hair-color flags) outside of the per-player ownership /// name, hair-color flags) outside of the per-player ownership
@@ -327,23 +412,24 @@ public class AvatarItemsController(
foreach (var guid in ParseInventory(invJson)) foreach (var guid in ParseInventory(invJson))
owned.Add(guid); owned.Add(guid);
} }
// Free default items are owned by everyone, logged in or not.
owned.UnionWith(_catalog.Value.DefaultUnlocked);
// The client parses this as List<IFNGBPJGINL> — the SAME element as
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
// CLIANCNHNIE which registers TagList / AvatarItemId /
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
// every locked row at AvatarItemId 0 with a null TagList, so ship
// the full catalog DTO.
var catalog = _catalog.Value.Items; var catalog = _catalog.Value.Items;
var rows = requested var rows = requested
.Where(guid => !owned.Contains(guid)) .Where(guid => !owned.Contains(guid))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null) .Where(item => item is not null)
.Select(item => new .Select(item => item!)
{ .ToList();
item!.AvatarItemType, return Ok(await WithStoreInfoAsync(rows));
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
} }
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot. /// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
@@ -481,7 +567,8 @@ public class AvatarItemsController(
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items, IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids, IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot, IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> SlotByGuid); IReadOnlyDictionary<string, int> SlotByGuid,
IReadOnlyList<string> DefaultUnlocked);
private static CatalogData LoadCatalog() private static CatalogData LoadCatalog()
{ {
@@ -508,7 +595,8 @@ public class AvatarItemsController(
new Dictionary<string, UnlockedAvatarItemDto>(), new Dictionary<string, UnlockedAvatarItemDto>(),
new HashSet<string>(), new HashSet<string>(),
new Dictionary<int, List<string>>(), new Dictionary<int, List<string>>(),
new Dictionary<string, int>()); new Dictionary<string, int>(),
Array.Empty<string>());
using var fs = System.IO.File.OpenRead(path); using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs, var dump = JsonSerializer.Deserialize<LookupFile>(fs,
@@ -541,13 +629,13 @@ public class AvatarItemsController(
// GUID as a color GUID and log "Can not find hair dye color". // GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit, AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,", AvatarItemDesc = row.Guid + ",,,",
AvatarItemId = StableAvatarItemId(row.Guid), AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
PlatformMask = unchecked((int)0xFFFFFFFF), PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty, FriendlyName = pretty,
Tooltip = string.Empty, Tooltip = string.Empty,
Rarity = 0, Rarity = 0,
TagList = string.Empty, TagList = string.Empty,
IsBaseAvatarItem = false, IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
}; };
if (!bySlot.TryGetValue(row.OutfitType, out var lst)) if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>(); bySlot[row.OutfitType] = lst = new List<string>();
@@ -555,15 +643,23 @@ public class AvatarItemsController(
slotByGuid[row.Guid] = row.OutfitType; slotByGuid[row.Guid] = row.OutfitType;
} }
return new CatalogData(items, safe, bySlot, slotByGuid); // Default-unlocked entries are all plain base items (no swatch, mask
// or decal) in the March dump; only keep ones the catalog can render.
var defaults = dump.DefaultUnlockedVariants
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
.Select(v => v.Item)
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
} }
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned /// <summary>Stable, deterministic, non-zero 31-bit id for an owned
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client /// 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 /// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
/// item and stable across restarts. Colored variants reuse their base /// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
/// item's id (a player owns the base OR a variant, never both in the /// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
/// list), so no collision.</summary>
public static int StableAvatarItemId(string? avatarItemDescOrGuid) public static int StableAvatarItemId(string? avatarItemDescOrGuid)
{ {
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0]; var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
@@ -618,6 +714,19 @@ public class AvatarItemsController(
public List<string> SwatchGuids { get; set; } = new(); public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")] [JsonPropertyName("decal_guids")]
public List<string> DecalGuids { get; set; } = new(); public List<string> DecalGuids { get; set; } = new();
// The client config's defaultUnlockedVariants: free items every
// player owns.
[JsonPropertyName("default_unlocked_variants")]
public List<LookupVariant> DefaultUnlockedVariants { get; set; } = new();
}
private sealed class LookupVariant
{
[JsonPropertyName("item")] public string Item { get; set; } = string.Empty;
[JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty;
[JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty;
[JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty;
} }
private sealed class LookupItem private sealed class LookupItem
@@ -268,8 +268,11 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else). /// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
/// ///
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c> /// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a /// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b> /// it is built from the body parsed as a RecNetResult: the continuation
/// <c>&lt;UpdateLatestReadComment&gt;b__9_1(DJMHAFPGLLN)</c>
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments /// (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 /// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
/// the request carries no body at all. /// the request carries no body at all.
@@ -280,7 +283,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
{ {
await SetLastReadAsync(Me, roomId, commentId); await SetLastReadAsync(Me, roomId, commentId);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return RecNetResults.Ok();
} }
/// <summary>Deletes a comment. /// <summary>Deletes a comment.
@@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase
/// Returns a broad default set so the picker is populated.</summary> /// Returns a broad default set so the picker is populated.</summary>
[HttpGet("/emojiConfig/whitelistedEmojis")] [HttpGet("/emojiConfig/whitelistedEmojis")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous] [Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult WhitelistedEmojis() => Ok(new[] public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
/// <summary>The emoji allow-list served above. Also the validation set
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
/// the client's own picker offered.</summary>
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
{ {
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘", "😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯", "😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌", "😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨", "🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆", "🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
}); };
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the /// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean. /// 2020.12 client deserializes this endpoint as a raw Boolean.
@@ -371,11 +371,22 @@ public class CustomAvatarItemsController(
var allowed = player is not null var allowed = player is not null
&& !player.IsJunior && !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow); && (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
// Bare JSON boolean, like the two sibling gates below: the client's // NOT a bare boolean, unlike the two sibling gates below. The issuing
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK() // method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an // :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
// object body threw and the can-create gate blocked shirt creation. // (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
return Content(allowed ? "true" : "false", "application/json"); // and its success continuation HONEEPBHNLB reads the bool at +0x10
// (:9155-9216).
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
// failed that parse, so the client logged "Failed to query if local
// account can create Custom Avatar Items: An error occurred" and
// treated the account as unable to create shirts.
return Ok(new Dictionary<string, object?>
{
["Success"] = allowed,
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
});
} }
[HttpGet("isCreationEnabled")] [HttpGet("isCreationEnabled")]
@@ -386,22 +397,17 @@ public class CustomAvatarItemsController(
[AllowAnonymous] [AllowAnonymous]
public IActionResult IsRenderingEnabled() => Content("true", "application/json"); public IActionResult IsRenderingEnabled() => Content("true", "application/json");
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one /// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere /// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF&lt;Int32&gt;
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are /// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT /// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft /// continues with <c>&lt;GetPricingConfig&gt;b__69_0(Int32 minimumPrice)</c>
/// ignores the ones that don't match, and a total miss falls back to the /// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary> /// parse, so the designer's public-price floor never loaded.</summary>
[HttpGet("minPriceForPublicItem")] [HttpGet("minPriceForPublicItem")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult MinPriceForPublicItem() => Ok(new public IActionResult MinPriceForPublicItem()
{ => Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
MinPrice = MinPublicItemPrice,
Price = MinPublicItemPrice,
MinimumPrice = MinPublicItemPrice,
MinPriceForPublicItem = MinPublicItemPrice,
});
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's /// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
/// saved in-progress shirt design. Verb ordinal 0 = GET /// saved in-progress shirt design. Verb ordinal 0 = GET
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
await db.SaveChangesAsync(); await db.SaveChangesAsync();
foreach (var row in created) foreach (var row in created)
{ {
// The full gift package, as GET api/avatar/v2/gifts lists it: the
// client consumes a gift by its Id, which the old hand-built
// payload didn't carry.
await notifications.NotifyAsync(row.RecipientPlayerId, await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived, PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message }); DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
} }
return Ok(new { Success = true, Sent = created.Count }); return Ok(new { Success = true, Sent = created.Count });
@@ -1295,39 +1295,14 @@ public class ImagesController(
.Select(s => long.TryParse(s, out var v) ? v : 0L) .Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0); .Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player) /// <summary>Push the new profile image through the same account pushes
{ /// as every other profile edit: the owner's SelfAccountUpdate and an
var account = new DorkNet.Models.Auth.RecNetAccount /// AccountUpdate to everyone. A hand-built account here left out the
{ /// emoji, banner, pronouns, identity flags and creation date, so an image
AccountId = (int)player.Id, /// change wiped them from the owner's cached account, and nobody else saw
RawUsername = player.Username, /// the new picture.</summary>
Username = player.Username, private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
DisplayName = player.DisplayName ?? player.Username, notifications.AccountChanged(player);
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static string NormaliseTaggedIds(string? csv, long exceptId) private static string NormaliseTaggedIds(string? csv, long exceptId)
{ {
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
}); });
} }
/// <summary>The caller's referred players, paged.
///
/// <c>FGLDKEJLAKB&lt;GPJIMAHMAIO&gt; EGEEFJHAGAM(Int32? take, String continuationToken)</c>
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
/// <c>take</c> and <c>continuationToken</c> query params at :716/:737) — which
/// is why the plain route-literal sweep never saw it — and issues a GET
/// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238:
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
/// DateTime, VerifiedAt: DateTime?}</c>.
///
/// A referral is recorded when a brand-new player redeems the caller's
/// referral code (LinkController.CreditReferralAsync), and that redemption
/// is what counts it, so it is verified at the moment it is created. The
/// continuation token is the next offset; null once the list is exhausted.</summary>
[HttpGet("referrals")]
public async Task<IActionResult> Referrals([FromQuery] int? take, [FromQuery] string? continuationToken)
{
var size = Math.Clamp(take ?? 20, 1, 100);
var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
var prefix = $"referral:credited:{Me}:";
var rows = await db.PlayerSettings
.Where(s => s.Key.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.Skip(offset)
.Take(size + 1)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
var page = rows.Take(size).Select(r =>
{
var at = DateTime.TryParse(r.Value, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var credited)
? credited
: DateTime.UnixEpoch;
return new
{
InviteeAccountId = (int)r.PlayerId,
CreatedAt = at,
VerifiedAt = (DateTime?)at,
};
}).ToList();
return Ok(new
{
Referrals = page,
ContinuationToken = rows.Count > size ? (offset + size).ToString() : null,
});
}
// Client contract: the claim response deserializes as a top-level // Client contract: the claim response deserializes as a top-level
// List<RewardSelection> where each entry is // List<RewardSelection> where each entry is
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime // { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
@@ -1095,11 +1095,9 @@ public class InventionsController(
}); });
inv.CheerCount += 1; inv.CheerCount += 1;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// No push: InventionModerationStateChanged ("100") means "your
if (inv.CreatorPlayerId != pid) // inventions' moderation state changed" and takes no payload; sent
await notifications.NotifyAsync(inv.CreatorPlayerId, // on every cheer it only made the creator refetch for nothing.
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
} }
else if (!req.Cheer && existing is not null) else if (!req.Cheer && existing is not null)
{ {
@@ -1256,8 +1254,10 @@ public class InventionsController(
if (!canPush) return Forbid(); if (!canPush) return Forbid();
var subRoomId = req.SubRoomId; var subRoomId = req.SubRoomId;
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
var scene = await db.RoomScenes var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId); .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128); var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0) if (subRoomDataFilename.Length == 0)
@@ -1271,7 +1271,7 @@ public class InventionsController(
if (subRoomBlob is null) if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" }); return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id; if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId; subRoomBlob.SubRoomId ??= sceneOrderIndex;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128); var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0) if (roomDataFilename.Length > 0)
@@ -279,11 +279,11 @@ public class InventoryController(
if (row.Quantity == 0) db.PlayerInventory.Remove(row); if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// A consume always changes an existing mapping, so it is always
// ConsumableMappingRemoved (71) carrying the count that is left;
// Added (70) would make the client treat it as a new stack.
await notifications.NotifyAsync(pid, await notifications.NotifyAsync(pid,
row.Quantity > 0 PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity }); return Ok(new { row.Id, Count = row.Quantity });
} }
@@ -332,8 +332,7 @@ public class InventoryController(
if (source.Quantity == 0) db.PlayerInventory.Remove(source); if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient, await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded, PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
} }
@@ -449,6 +448,26 @@ public class InventoryController(
}; };
} }
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
/// as a mapping with no item and a count of 0.</summary>
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
{
inv.Id,
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
Count = inv.Quantity,
InitialCount = inv.Quantity, // the original purchase count isn't tracked
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
inv.IsActive,
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
// what the inventory list says.
IsTransferable = false,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item) private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{ {
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
@@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds /// <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 /// <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 /// 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 /// the non-generic <c>LDGADANDBIO</c> promise, but that promise comes from
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner /// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
/// See <see cref="RecNetResults"/>. 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.", /// ("Must be at least a room co-owner to modify keepsakes.",
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side. /// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
/// </summary> /// </summary>
@@ -235,7 +237,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
db.Keepsakes.RemoveRange(orphanedCollections db.Keepsakes.RemoveRange(orphanedCollections
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId)); .Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return RecNetResults.Ok();
} }
/// <summary> /// <summary>
@@ -152,8 +152,6 @@ public class InGameModerationController(
foreach (var target in ids) foreach (var target in ids)
{ {
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects:
// Msg.Reason must be a ModerationBlockDetail object.
await notifications.KickPlayerAsync(target, "Instant kick by moderator."); await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
} }
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
@@ -249,21 +247,14 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}", "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason); caller, target, reason, p.BannedUntil, displayReason);
// Push the ban as a ModerationKick (22) with IsBan set, NOT as // Sign the banned player out with Logout "6" (Reason Banned). Not
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager // ModerationRoomBan (24): the 2023-03-21 client registers no handler
// registers only 22, "ModerationUnkick" and 23 // for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler // 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
// for 24, so that push landed nowhere and the banned player kept playing // ModerationKick either: a kick only sends the player to their dorm
// until their next login. The 22 handler reads Msg.Reason as a // and they kept playing until their next login. The ban check refuses
// ModerationBlockDetail, which ModerationKickPayload already produces. // that next login.
await notifications.KickPlayerAsync( await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
target,
// `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 = "" }); return Ok(new { success = true, error = "" });
} }
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
public class PlayerEventsController( public class PlayerEventsController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
PlayerPresenceService presence) : ControllerBase PlayerPresenceService presence) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
@@ -393,7 +394,11 @@ public class PlayerEventsController(
if (evt is null) return NotFound(); if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null; // The event stores the client's wire SubRoomId; presence and private
// instances hold the OrderIndex (RoomSceneIds).
var subRoomId = extras.SubRoomId is long sub && sub > 0
? RoomSceneIds.ToOrderIndex(sub)
: (long?)null;
// Same sourcing as the room browser: presence rows are the live truth // Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private // (one instance per distinct PhotonRoomId), and the caller's OWN private
@@ -416,7 +421,7 @@ public class PlayerEventsController(
{ {
roomInstanceId = room.RoomInstanceId, roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId, roomId = room.RoomId,
subRoomId = room.SubRoomId, subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
// MaxCapacity is the per-instance cap the matchmaker handed // MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false. // out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity, isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
@@ -437,7 +442,7 @@ public class PlayerEventsController(
{ {
roomInstanceId = p.Id, roomInstanceId = p.Id,
roomId = p.RoomId, roomId = p.RoomId,
subRoomId = p.SubRoomId, subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
isFull = false, isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(), playerIds = Array.Empty<int>(),
@@ -817,7 +822,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras); await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -901,10 +906,7 @@ public class PlayerEventsController(
// Tell the creator someone RSVP'd. They can ignore but the // Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card. // watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me) await NotifyResponseChangedAsync(ev, row);
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 // Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead), // (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here. // so there is no 2023 DTO to match here.
@@ -947,10 +949,15 @@ public class PlayerEventsController(
var existing = await db.PlayerEventResponses var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null) if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }); {
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
db.PlayerEventResponses.Add(existing);
}
else else
existing.Response = response; existing.Response = response;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
await NotifyResponseChangedAsync(responded, existing);
return RespondResult(legacyForm, 0 /*Success*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -968,9 +975,18 @@ public class PlayerEventsController(
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses var deleted = await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt) .Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
if (deleted > 0)
{
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
if (creator is long c && c != Me)
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
new { PlayerEventId = evt });
}
return RespondResult(legacyForm, 0 /*Success*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -993,6 +1009,7 @@ public class PlayerEventsController(
// has to echo the event that was deleted. // has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id]; var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id); var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
var notify = await AttendeeIdsAsync(evt);
db.PlayerEvents.Remove(evt); db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
@@ -1000,6 +1017,7 @@ public class PlayerEventsController(
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id)) .Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventDeletedAsync(id, notify);
return Ok(Wrapper(evt, extras, attendees)); return Ok(Wrapper(evt, extras, attendees));
} }
@@ -1021,6 +1039,7 @@ public class PlayerEventsController(
evt.Title = Clamp(value.Trim(), 128); evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1034,6 +1053,7 @@ public class PlayerEventsController(
evt.Description = Clamp(value.Trim(), 2000); evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1056,6 +1076,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId"); extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1072,6 +1093,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time"); if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1085,6 +1107,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128); extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1099,6 +1122,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility; extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1120,6 +1144,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags; extras.Tags = tags;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1145,12 +1170,20 @@ public class PlayerEventsController(
extras.SupportMultiInstanceRoomChat = extras.SupportMultiInstanceRoomChat =
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat", await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
"SupportMultiInstanceRoomChat") ?? false; "SupportMultiInstanceRoomChat") ?? false;
extras.DefaultBroadcastPermissions = // These two are boxed enums (typeof(AKFCIMLMAKA) at
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0; // CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string
extras.CanRequestBroadcastPermissions = // field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110),
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0; // so they arrive as member NAMES. int.TryParse failed on every one and
// the settings were always stored as 0.
extras.DefaultBroadcastPermissions = ParseBroadcastPermissions(
await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions"))
?? extras.DefaultBroadcastPermissions;
extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions(
await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions"))
?? extras.CanRequestBroadcastPermissions;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1181,6 +1214,7 @@ public class PlayerEventsController(
} }
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1190,6 +1224,7 @@ public class PlayerEventsController(
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound(); if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid(); if (ev.CreatorPlayerId != Me) return Forbid();
var notify = await AttendeeIdsAsync(ev);
db.PlayerEvents.Remove(ev); db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade // Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually. // configured here so do it manually.
@@ -1199,6 +1234,7 @@ public class PlayerEventsController(
await db.PlayerSettings await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId)) .Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await NotifyEventDeletedAsync(eventId, notify);
return Ok(); return Ok();
} }
@@ -1237,6 +1273,7 @@ public class PlayerEventsController(
var failed = new List<object>(); var failed = new List<object>();
var sender = Me; var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
var eventWire = await ToWireOneAsync(ev);
foreach (var rid in req.InvitedPlayerIds.Distinct()) foreach (var rid in req.InvitedPlayerIds.Distinct())
{ {
var exists = await db.Players.AnyAsync(p => p.Id == rid); var exists = await db.Players.AnyAsync(p => p.Id == rid);
@@ -1245,17 +1282,15 @@ public class PlayerEventsController(
failed.Add(new { InvitedPlayerId = rid, Result = 1 }); failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue; continue;
} }
db.Messages.Add(new MessageEntity // The invite as a text message from the host, stored and pushed
{ // live (it used to be stored only, so it appeared after a relog).
SenderPlayerId = sender, await systemNotifications.SendAsync(rid,
RecipientPlayerId = rid, SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
Body = inviteText, // And the event itself, so it shows in the invitee's event list:
}); // PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
await notifications.NotifyAsync(rid, // the same object GET api/playerevents/v2/{id} returns.
PushNotificationId.PlayerEventCreated, await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
} }
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 }); return Ok(new { FailedInvites = failed, Result = 0 });
} }
@@ -1282,21 +1317,56 @@ public class PlayerEventsController(
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId"); "BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge // Attendees' event cards key off BroadcastingRoomInstanceId, so send
// them to re-fetch instead of sending a chat message. // them the updated event.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) /// <summary>Everyone who answered the event with anything but NotGoing,
{ /// except its creator.</summary>
var recipients = await db.PlayerEventResponses private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
(await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId) .Select(r => r.PlayerId)
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync())
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) .Where(p => p != evt.CreatorPlayerId)
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); .ToList();
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
/// cached event with an empty name and default times, and the
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
/// at all.</summary>
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
{
var recipients = await AttendeeIdsAsync(evt);
if (recipients.Count == 0) return;
var wire = await ToWireOneAsync(evt);
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
}
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
/// were attending, collected before the rows were removed.</summary>
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
{
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
new { PlayerEventId = eventId });
}
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
/// object the event and response REST routes return.</summary>
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
{
if (evt.CreatorPlayerId == response.PlayerId) return;
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
} }
// ── Report ─────────────────────────────────────────────────────────── // ── Report ───────────────────────────────────────────────────────────
@@ -1437,6 +1507,33 @@ public class PlayerEventsController(
: null; : null;
} }
/// <summary>Broadcast-permission mode as a number. Members per the
/// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by
/// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0,
/// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON
/// bodies send numbers), a member name, or a comma-joined flags string.
/// Null when the value is absent or unrecognised, so the stored setting is
/// kept rather than silently zeroed.</summary>
public static int? ParseBroadcastPermissions(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n;
var total = 0;
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int? value = part.ToLowerInvariant() switch
{
"none" => 0,
"roomowners" => 256,
"all" => int.MaxValue,
_ => null,
};
if (value is null) return null;
total |= value.Value;
}
return total;
}
private async Task<int?> ReadIntFieldAsync(params string[] names) private async Task<int?> ReadIntFieldAsync(params string[] names)
{ {
var value = await ReadStringFieldAsync(names); var value = await ReadStringFieldAsync(names);
@@ -48,14 +48,18 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
.Where(p => p.Id == me) .Where(p => p.Id == me)
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam)) .Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
// Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter,
// RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A
// failure without Error reached Promise.Rejected(null) and threw
// "Error message cannot be null" instead of showing the reason.
if (moderator != true) if (moderator != true)
return Ok(new { Success = false, Message = "not_authorized" }); return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" });
var target = warnedPlayerId var target = warnedPlayerId
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0); ?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" }); if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" });
if (!await db.Players.AnyAsync(p => p.Id == target)) if (!await db.Players.AnyAsync(p => p.Id == target))
return Ok(new { Success = false, Message = "player_not_found" }); return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" });
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the // Same "warning:{id}|message|category|createdAt|acknowledged" layout the
// GET list handler parses in ToWarningWire. // GET list handler parses in ToWarningWire.
@@ -75,7 +79,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
"false"), "false"),
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty, WarningId = id }); return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id });
} }
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments — /// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
@@ -104,19 +108,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
.OrderByDescending(s => s.Id) .OrderByDescending(s => s.Id)
.ToListAsync(); .ToListAsync();
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged); row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
if (row is null) return Ok(new { Success = true, Message = string.Empty }); if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
} }
else else
{ {
row = await db.PlayerSettings row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}"); .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return Ok(new { Success = true, Message = string.Empty }); if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
} }
var parts = row.Value.Split('|'); var parts = row.Value.Split('|');
row.Value = string.Join('|', parts.Take(3).Append("true")); row.Value = string.Join('|', parts.Take(3).Append("true"));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] }); return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
} }
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row) private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.</summary> /// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")] [HttpPost("/playlists")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> Create() public async Task<IActionResult> Create()
{ {
var name = await ReadFieldAsync("name", "Name"); var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")] [HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")] [HttpPut("/playlists/{playlistId:long}/name")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistName(long playlistId) public async Task<IActionResult> PlaylistName(long playlistId)
{ {
var value = await ReadFieldAsync("name", "Name", "value", "Value"); var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")] [HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")] [HttpPut("/playlists/{playlistId:long}/description")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistDescription(long playlistId) public async Task<IActionResult> PlaylistDescription(long playlistId)
{ {
var value = await ReadFieldAsync("description", "Description", "value", "Value"); var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")] [HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")] [HttpPut("/playlists/{playlistId:long}/image")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistImage(long playlistId) public async Task<IActionResult> PlaylistImage(long playlistId)
{ {
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value"); var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")] [HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")] [HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistTags(long playlistId) public async Task<IActionResult> PlaylistTags(long playlistId)
{ {
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags"); var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")] [HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")] [HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistAccessibility(long playlistId) public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{ {
var value = await ReadFieldAsync( var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")] [HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")] [HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId) public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{ {
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value"); var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")] [HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")] [HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistRestrictions(long playlistId) public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{ {
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors"); var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")] [HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")] [HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistWarning(long playlistId) public async Task<IActionResult> PlaylistWarning(long playlistId)
{ {
var mask = await ReadFieldAsync("warningMask", "WarningMask"); var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")] [HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")] [HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddRoom(long playlistId, long roomId) public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{ {
try try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")] [HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId) public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{ {
try try
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase public class RelationshipsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
ServerSettingsService settings) : ControllerBase
{ {
// RelationshipType — int values match Relationship.RelationshipType // RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs. // in the patched dump.cs.
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me); await PushPairAsync(me, id);
// The "X sent you a friend request" row in the target's notifications.
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
return Ok(BuildRelationship(row, me)); return Ok(BuildRelationship(row, me));
} }
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
rel.UpdatedAt = DateTime.UtcNow; rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me); await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(rel, me)); return Ok(BuildRelationship(rel, me));
} }
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
db.Relationships.RemoveRange(removable); db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me); await PushPairAsync(me, id);
var remaining = pair.Except(removable).ToList(); var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining)); return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(existing, me)); return Ok(BuildRelationship(existing, me));
} }
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
if (ignored is bool i) row.Ignored = i; if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// The HTTP reply is only an ack; the updated row reaches the client
// through RelationshipChanged. A preference is the caller's own bit,
// so only the caller is told.
await PushPairAsync(me, otherId, notifyOther: false);
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty }); return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
} }
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// ── helpers ────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
/// <paramref name="other"/>) after a change, each side getting its OWN view
/// of the relationship. Without this a friend request, accept or unfriend
/// only showed up after the friends list was refetched, and the client
/// never re-subscribed to the new friend's presence.</summary>
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
{
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me))
.ToListAsync();
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
if (notifyOther)
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
}
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
/// <c>v2/get</c> would list it.</summary>
private static Dictionary<string, object> ViewOf(
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
{
if (globalFriends
&& other != RelationshipQueries.SystemAccountId
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
}
private async Task<HashSet<long>> FriendIdsAsync(long playerId) private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{ {
var rows = await db.Relationships var rows = await db.Relationships
@@ -284,7 +284,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
offer.IsDeleted = true; offer.IsDeleted = true;
offer.UpdatedAt = DateTime.UtcNow; offer.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(ToOfferWire(offer, currency)); // MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD
// (ExpandRecNetResult): it is a {success,error} RecNetResult, not the
// offer. The offer wire has no Success key, so it parsed as a failure
// with a null Error and the delete toast threw instead of closing.
return RecNetResults.Ok();
} }
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at /// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
@@ -35,6 +35,7 @@ public class RoomsController(
DomainConfig domain, DomainConfig domain,
NotificationService notifications, NotificationService notifications,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
SystemNotificationService systemNotifications,
ILogger<RoomsController> logger) : ControllerBase ILogger<RoomsController> logger) : ControllerBase
{ {
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -574,6 +575,7 @@ public class RoomsController(
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")] [HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId) public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var blobs = await db.RoomDataBlobs var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId .Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null)) && (b.SubRoomId == subRoomId || b.SubRoomId == null))
@@ -586,7 +588,7 @@ public class RoomsController(
.Take(50) .Take(50)
.Select(b => new .Select(b => new
{ {
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
DataBlob = b.BlobName, DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId, SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt, CreatedAt = b.UploadedAt,
@@ -613,6 +615,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget, [FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion) [FromQuery] int? unityAssetVersion)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -661,6 +664,7 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> SubRoom(long roomId, long subRoomId) public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -689,6 +693,7 @@ public class RoomsController(
[FromQuery] int? unityAssetTarget, [FromQuery] int? unityAssetTarget,
[FromQuery] int? unityAssetVersion) [FromQuery] int? unityAssetVersion)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -731,6 +736,7 @@ public class RoomsController(
long roomId, long subRoomId, long roomId, long subRoomId,
[FromQuery] int skip = 0, [FromQuery] int take = 50) [FromQuery] int skip = 0, [FromQuery] int take = 50)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found", roomId }); if (room is null) return NotFound(new { error = "room_not_found", roomId });
@@ -763,7 +769,7 @@ public class RoomsController(
saves.Add(new saves.Add(new
{ {
SubRoomDataSaveId = currentSaveId, SubRoomDataSaveId = currentSaveId,
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -809,7 +815,7 @@ public class RoomsController(
var modified = scene?.DataModifiedAt ?? room.UpdatedAt; var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
return new return new
{ {
SubRoomId = (long)orderIndex, SubRoomId = RoomSceneIds.ToWire(orderIndex),
RoomId = room.Id, RoomId = room.Id,
Name = scene?.Name ?? "Home", Name = scene?.Name ?? "Home",
DataBlob = dataBlob, DataBlob = dataBlob,
@@ -824,7 +830,7 @@ public class RoomsController(
CurrentSave = new CurrentSave = new
{ {
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L, SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
SubRoomId = (long)orderIndex, SubRoomId = RoomSceneIds.ToWire(orderIndex),
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty, SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -871,7 +877,7 @@ public class RoomsController(
return new return new
{ {
SubRoomDataSaveId = saveId, SubRoomDataSaveId = saveId,
SubRoomId = subRoomId, SubRoomId = RoomSceneIds.ToWire(subRoomId),
UnityAssetId = unityAssetId, UnityAssetId = unityAssetId,
SubRoomUnityAssetId = unityAssetId, SubRoomUnityAssetId = unityAssetId,
ReferencedUnityAssetIds = Array.Empty<string>(), ReferencedUnityAssetIds = Array.Empty<string>(),
@@ -916,13 +922,14 @@ public class RoomsController(
public async Task<IActionResult> SubRoomRestoreData( public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename) long roomId, long subRoomId, [FromForm] string? filename)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
if (string.IsNullOrWhiteSpace(filename)) if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" }); return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" }); if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var blob = await db.RoomDataBlobs var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId); .FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
@@ -1399,6 +1406,7 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); .SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so // Push the same SubscriptionUpdateRoom the save flow sends so
@@ -1472,10 +1480,27 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName) .SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); .SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName }); return Ok(new { restored = blob.BlobName });
} }
/// <summary>A room-level restore also restores the entry sub-room
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
/// would leave the old version in place. The per-sub-room restore keeps
/// the two in step the other way round. Dorms keep their blob per
/// player (DormState) instead.</summary>
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
{
var now = DateTime.UtcNow;
return db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.DataBlobName, blobName)
.SetProperty(x => x.DataModifiedAt, now));
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> — /// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified /// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c> /// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
@@ -2001,7 +2026,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false, UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false, UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false, UseRecRoyaleMatchmaking = false,
SubRoomId = (long)s.OrderIndex, SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
RoomId = room.Id, RoomId = room.Id,
Name = s.Name, Name = s.Name,
UnityAssetId = s.StudioUnityAssetId, UnityAssetId = s.StudioUnityAssetId,
@@ -2027,7 +2052,7 @@ public class RoomsController(
UseLevelBasedMatchmaking = false, UseLevelBasedMatchmaking = false,
UseAgeBasedMatchmaking = false, UseAgeBasedMatchmaking = false,
UseRecRoyaleMatchmaking = false, UseRecRoyaleMatchmaking = false,
SubRoomId = 0L, SubRoomId = RoomSceneIds.ToWire(0),
RoomId = room.Id, RoomId = room.Id,
Name = "Home", Name = "Home",
UnityAssetId = string.Empty, UnityAssetId = string.Empty,
@@ -2046,11 +2071,10 @@ public class RoomsController(
}, },
}; };
var wireRoles = new List<object> // The creator is Creator (255), never CoOwner (30): the client reads
{ // the local player's role from this list and only lets a player edit
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30), // roles strictly below their own (see RoomRoleRules).
}; var wireRoles = RoomRoleRules.BuildWires(room, roleList);
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
var tags = string.IsNullOrEmpty(room.TagsCsv) var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>() ? Array.Empty<object>()
@@ -2075,7 +2099,9 @@ public class RoomsController(
State = room.State, State = room.State,
Accessibility = room.Accessibility, Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting, SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom, // Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
// the client refuses publish and room settings for an RRO.
IsRRO = RoomService.WireIsRRO(room),
SupportsScreens = room.SupportsScreens, SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR, SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR, SupportsTeleportVR = room.SupportsTeleportVR,
@@ -2904,6 +2930,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/name")] [HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameRoom( public async Task<IActionResult> RenameRoom(
long roomId, long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
@@ -2922,9 +2949,9 @@ public class RoomsController(
{ {
// RoomRoles co-owner check — same authorisation as the // RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too. // existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr => // Room settings are creator / co-owner only; moderators and
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); // hosts moderate players, they don't edit the room.
if (!coOwner) return Forbid(); if (!await CanEditRoomAsync(room, me)) return Forbid();
} }
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118). // Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
@@ -2959,11 +2986,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameSubroom( public async Task<IActionResult> RenameSubroom(
long roomId, long roomId,
long subRoomId, long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var newName = body?.Name; var newName = body?.Name;
if (string.IsNullOrWhiteSpace(newName)) if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" }); return BadRequest(new { Result = 1, error = "empty_name" });
@@ -2976,9 +3005,9 @@ public class RoomsController(
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" }); if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me) if (room.CreatorPlayerId != me)
{ {
var coOwner = await db.RoomRoles.AnyAsync(rr => // Room settings are creator / co-owner only; moderators and
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1)); // hosts moderate players, they don't edit the room.
if (!coOwner) return Forbid(); if (!await CanEditRoomAsync(room, me)) return Forbid();
} }
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
@@ -3607,6 +3636,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms")] [HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddSubRoom( public async Task<IActionResult> AddSubRoom(
long roomId, long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
@@ -3614,7 +3644,7 @@ public class RoomsController(
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var name = (req.Name ?? string.Empty).Trim(); var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene"; if (name.Length == 0) name = "NewScene";
@@ -3649,12 +3679,14 @@ public class RoomsController(
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] [HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId) public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await db.RoomScenes var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
@@ -3710,20 +3742,6 @@ public class RoomsController(
/// <summary>One row of the per-subroom role-permission matrix the 2023 /// <summary>One row of the per-subroom role-permission matrix the 2023
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the /// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary> /// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
public sealed class SubRoomPermissionEntry
{
// Field order + CLR types come from the BNLPBMJJOMM constructor
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
// members serialize as numbers (the role writer boxes them
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
public string? Permission { get; set; }
public int? Role { get; set; }
public bool? Override { get; set; }
public int? Type { get; set; }
public string? Value { get; set; }
}
public sealed class PublishSubRoomSaveRequest public sealed class PublishSubRoomSaveRequest
{ {
public long? SubRoomDataSaveId { get; set; } public long? SubRoomDataSaveId { get; set; }
@@ -3746,6 +3764,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize] [Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomMaxPlayers( public Task<IActionResult> SubRoomMaxPlayers(
long roomId, long roomId,
long subRoomId, long subRoomId,
@@ -3763,6 +3782,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize] [Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomAccessibility( public Task<IActionResult> SubRoomAccessibility(
long roomId, long roomId,
long subRoomId, long subRoomId,
@@ -3773,45 +3793,120 @@ public class RoomsController(
s.CanMatchmakeInto = a != AccessibilityPrivate; s.CanMatchmakeInto = a != AccessibilityPrivate;
}); });
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the /// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
/// role-permission matrix. Client: /// subroom permissions", the role-permission matrix. Client:
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c> /// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList&lt;BNLPBMJJOMM&gt;)</c>
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT /// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> /// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string /// Unlike its siblings the body is NOT form-encoded: the closure calls
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw /// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON /// JSON array of cells. The editor sends one changed cell per call
/// deserialize into the List). /// (OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
/// sent are left alone.
/// ///
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no /// <para>Read back through <c>GET photon_access_token</c> (its
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no /// <c>Permissions</c> list), and pushed to everyone in that sub-room as
/// column for it and there is no permissions table. Adding one means an /// the <c>PhotonAccessToken</c> hub message so the change takes effect
/// entity + DbContext + migration, which is outside this file. Until then /// without a rejoin.</para>
/// the matrix is accepted, logged, and the room details echoed back so the ///
/// client's promise resolves instead of 415-ing; the values do not survive /// <para>Authorization mirrors the client's own rule
/// a round-trip. The old handler silently bound an unrelated DTO whose keys /// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
/// the client never sends, which was strictly worse.</para></summary> /// Co-Owner and Creator; a co-owner only the roles below their own;
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize] [Authorize]
public async Task<IActionResult> SubRoomPermissions( [RecNetEnvelope]
long roomId, public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
var cells = RoomPermissionMatrix.Parse(body);
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
if (denied.Count > 0)
{
logger.LogWarning(
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
return Forbid();
}
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
await db.SaveChangesAsync();
logger.LogWarning( var now = DateTime.UtcNow;
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)", scene.DataModifiedAt = now;
roomId, subRoomId, pid, req?.Count ?? 0); for (var attempt = 1; ; attempt++)
{
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
foreach (var cell in cells)
{
// Exact match, like the unique index on
// (RoomId, SubRoomId, Role, Permission).
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
try
{
await db.SaveChangesAsync();
break;
}
catch (DbUpdateException) when (attempt < 3)
{
// Another editor inserted the same new cell between our read
// and our insert (unique index). Forget our pending rows and
// apply the cells again, now as updates of theirs.
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
entry.State = EntityState.Detached;
}
}
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
roomId, subRoomId, pid, cells.Count);
// Everyone in that sub-room gets the new matrix; the client's
// PhotonAccessToken handler re-initialises its permissions from it.
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
{
if (instance.SubRoomId != subRoomId) continue;
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
foreach (var other in players)
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
}
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
} }
@@ -3840,12 +3935,14 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId) public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
@@ -3919,12 +4016,14 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId) public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var src = await db.RoomScenes var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId); .FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound(); if (src is null) return NotFound();
@@ -3971,6 +4070,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize] [Authorize]
[RecNetEnvelope]
public Task<IActionResult> ModifySubRoom( public Task<IActionResult> ModifySubRoom(
long roomId, long roomId,
long subRoomId, long subRoomId,
@@ -4010,11 +4110,13 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize] [Authorize]
[RecNetEnvelope]
public async Task<IActionResult> MoveSubRoom( public async Task<IActionResult> MoveSubRoom(
long roomId, long roomId,
long subRoomId, long subRoomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req) [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
@@ -4105,6 +4207,7 @@ public class RoomsController(
[Authorize] [Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId) public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var body = await ReadSaveRoomSceneRequestAsync(); var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId; body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true); return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
@@ -4165,10 +4268,11 @@ public class RoomsController(
/// successful mutation surface as a client-side parse failure.</summary> /// successful mutation surface as a client-side parse failure.</summary>
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator) private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{ {
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid(); if (!await CanEditRoomAsync(room, pid)) return Forbid();
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound(); if (scene is null) return NotFound();
mutator(scene); mutator(scene);
@@ -4219,12 +4323,7 @@ public class RoomsController(
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync(); var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1) return Ok(RoomRoleRules.BuildWires(room, rows));
{
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
};
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
return Ok(list);
} }
private static string CurrentOrSyntheticDataBlobName(RoomEntity room) private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
@@ -4339,30 +4438,38 @@ public class RoomsController(
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row /// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>, /// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task&lt;EFHPLDPNGIM&gt;</c>,
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list /// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
/// above.</summary> /// above. The creator answers as Creator (255).</summary>
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId) public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId) var rows = await db.RoomRoles
return Ok(BuildRoomAccountRoleWire(playerId, 30)); .Where(r => r.RoomId == roomId && r.PlayerId == playerId)
var row = await db.RoomRoles .ToListAsync();
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId); var wire = RoomRoleRules.BuildWires(room, rows)
if (row is null) return NotFound(); .FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
return Ok(BuildRoomRoleGrantWire(row)); if (wire is null) return NotFound();
return Ok(wire);
} }
public sealed class GrantRoleRequest { public int? Role { get; set; } } /// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants /// PUT, single form key <c>role</c> boxed as Int32 —
/// a role to a player. Auto-accepted (no invite step). /// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role", /// <c>FGCPNAACHIK</c> details. Three callers:
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a /// <list type="bullet">
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full /// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
/// <c>FGCPNAACHIK</c> details — which this already does. Only the /// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
/// roomserver/ twin was missing.</summary> /// role 0;</item>
/// <item>the invitee accepting a co-owner invite (message Type 62,
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
/// with role 30;</item>
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
/// their own id with role 0.</item>
/// </list>
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
@@ -4372,45 +4479,20 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize] [Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId) [RecNetEnvelope]
{ public Task<IActionResult> GrantRole(long roomId, long playerId)
var me = this.RequireCurrentPlayerId(); => ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) /// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
return BadRequest(new { error = "invalid_role", role }); /// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
var existing = await db.RoomRoles /// Only Co-Owner goes through an invite in this client: the owner's button
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); /// sends role 30 here, the invitee is told with a Type 62
if (existing is null) /// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
{ /// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); /// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
db.RoomRoles.Add(new RoomRoleEntity /// which clears the pending invite but leaves any role they already
{ /// hold.</summary>
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
@@ -4420,62 +4502,168 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize] [Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId) [RecNetEnvelope]
public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
{ {
var me = this.RequireCurrentPlayerId(); var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(); if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); var requested = await ReadClientRoomRoleAsync();
var role = await ReadClientRoomRoleAsync(); if (requested is null) return BadRequest(new { error = "missing_role" });
if (role is null) return BadRequest(new { error = "missing_role" }); if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
if (role.Value == 0) return NotFound(new { error = "unknown_player" });
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
return BadRequest(new { error = "invalid_role", role }); var error = RoomRoleRules.CheckChange(
room, rows, me, playerId, requested.Value, isInvite, out var kind);
if (error is not null)
{
logger.LogWarning(
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
roomId, me, playerId, requested.Value, isInvite, error);
return error == "cannot_change_creator_role" || error == "invalid_role"
? BadRequest(new { error, role = requested.Value })
: Forbid();
}
var existing = await db.RoomRoles var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
if (existing is null) var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
switch (kind)
{
case RoomRoleRules.ChangeKind.Clear when isInvite:
// Reject / withdraw an invite: only the pending rows go.
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
break;
case RoomRoleRules.ChangeKind.Clear:
db.RoomRoles.RemoveRange(targetRows);
break;
case RoomRoleRules.ChangeKind.Invite:
{
// Already holding the role: nothing to invite to. Otherwise
// replace any other pending invite but keep the role the
// player holds until they accept.
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
if (pending is null)
{ {
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity db.RoomRoles.Add(new RoomRoleEntity
{ {
RoomId = roomId, PlayerId = playerId, Role = roomRole, RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = false, GrantedByPlayerId = me, Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
}); });
} }
else else
{ {
existing.Accepted = false; pending.GrantedByPlayerId = me;
pending.GrantedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); break;
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
} }
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId) case RoomRoleRules.ChangeKind.AcceptInvite:
case RoomRoleRules.ChangeKind.Grant:
{ {
if (room.CreatorPlayerId == playerId) // One role per account: the new one replaces the rest
return BadRequest(new { error = "cannot_change_creator_role" }); // (an accepted invite replaces the role held meanwhile).
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
if (keep is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = storedRole,
Accepted = true,
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
: me,
GrantedAt = DateTime.UtcNow,
});
}
else
{
keep.Accepted = true;
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
keep.GrantedAt = DateTime.UtcNow;
}
break;
}
}
var rows = await db.RoomRoles await db.SaveChangesAsync();
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
logger.LogInformation(
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
// The invitee's notifications are how the role reaches them: the
// 2023 client renders message Types 60/61/62 (factory
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
// only RoomId from them. Self-service changes (accept / reject /
// leave) notify nobody.
if (playerId != me)
{
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
if (!wasCoOwner && isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
if (wasCoOwner && !isCoOwnerNow)
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
}
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
await PushRoomUpdateAsync(roomId, details, playerId, me);
return Ok(details);
}
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
/// must stop offering Accept/Reject in the inbox.</summary>
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
{
var stale = await db.Messages
.Where(m => m.RecipientPlayerId == playerId
&& m.RoomId == roomId
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
&& m.ReadAt == null)
.ToListAsync(); .ToListAsync();
if (rows.Count > 0) if (stale.Count == 0) return;
db.RoomRoles.RemoveRange(rows); foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
} }
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId) /// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
/// IBEOONPEELF.txt:507, Action&lt;FGCPNAACHIK&gt;) to everyone currently
/// in the room plus the players whose view changed, so the role list,
/// the role-gated buttons and the local player's own role refresh
/// without leaving the room.</summary>
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
{ {
if (room.CreatorPlayerId == playerId) return true; var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
return await db.RoomRoles.AnyAsync(r => .SelectMany(i => i.PlayerIds)
r.RoomId == room.Id .Concat(alsoNotify)
&& r.PlayerId == playerId .Where(pid => pid > 0)
&& r.Accepted .Distinct()
&& r.Role == 0); .ToList();
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
} }
private async Task<int?> ReadClientRoomRoleAsync() private async Task<int?> ReadClientRoomRoleAsync()
@@ -4486,6 +4674,8 @@ public class RoomsController(
var form = await Request.ReadFormAsync(); var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
return parsed;
} }
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed; if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
@@ -4495,10 +4685,20 @@ public class RoomsController(
{ {
try try
{ {
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>( using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body, if (doc.RootElement.ValueKind == JsonValueKind.Object)
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); {
if (body?.Role is int bodyRole) return bodyRole; foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
return parsed;
if (prop.Value.ValueKind == JsonValueKind.String
&& (int.TryParse(prop.Value.GetString(), out parsed)
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
return parsed;
}
}
} }
catch (JsonException) catch (JsonException)
{ {
@@ -4509,64 +4709,34 @@ public class RoomsController(
return null; return null;
} }
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole) /// <summary>OMMBGJMJJPN member names, for callers that box the enum
/// itself (Enum.ToString) instead of an Int32.</summary>
internal static bool TryParseRoleName(string? raw, out int role)
{ {
var rows = await db.RoomRoles role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{ {
roomRole = clientRole switch "none" => RoomRoleRules.WireNone,
{ "banned" => RoomRoleRules.WireBanned,
30 => 0, // CoOwner "host" => RoomRoleRules.WireHost,
20 => 1, // Moderator "moderator" => RoomRoleRules.WireModerator,
10 => 2, // Host "coowner" => RoomRoleRules.WireCoOwner,
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
"creator" => RoomRoleRules.WireCreator,
_ => -1, _ => -1,
}; };
return roomRole >= 0; return role >= 0;
} }
private static object BuildRoomRoleGrantWire(RoomRoleEntity role) /// <summary>Creator or accepted co-owner (see
=> BuildRoomAccountRoleWire( /// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
role.PlayerId, private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
ToClientRoomRole(role.Role),
role.Accepted,
role.GrantedByPlayerId);
private static object BuildRoomAccountRoleWire(
long accountId,
int role,
bool accepted = true,
long? lastChangedByAccountId = null)
=> new
{ {
AccountId = ToClientAccountId(accountId), if (room.CreatorPlayerId == playerId) return true;
Role = accepted ? role : 0, var rows = await db.RoomRoles
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId), .Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
InvitedRole = accepted ? 0 : role, .ToListAsync();
}; return RoomRoleRules.CanEditRoom(room, rows, playerId);
}
private static int ToClientRoomRole(int roomRole) => roomRole switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
private static int ToClientAccountId(long accountId)
=> accountId > int.MaxValue
? int.MaxValue
: accountId < int.MinValue
? int.MinValue
: (int)accountId;
private static int? ToClientAccountId(long? accountId)
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId) private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{ {
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")] [HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")] [HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareDescription(long roomId) public async Task<IActionResult> BareDescription(long roomId)
{ {
// RawValue, not the whitespace-skipping reader: clearing the // RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")] [HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")] [HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareImage(long roomId) public async Task<IActionResult> BareImage(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")] [HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")] [HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareTags(long roomId) public async Task<IActionResult> BareTags(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")] [HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAccessibility(long roomId) public async Task<IActionResult> BareAccessibility(long roomId)
{ {
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value"); var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")] [HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")] [HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCloning(long roomId) public async Task<IActionResult> BareCloning(long roomId)
{ {
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value"); var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")] [HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")] [HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAutomute(long roomId) public async Task<IActionResult> BareAutomute(long roomId)
{ {
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value"); var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")] [HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")] [HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareRestrictions(long roomId) public async Task<IActionResult> BareRestrictions(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")] [HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")] [HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareWarning(long roomId) public async Task<IActionResult> BareWarning(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")] [HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")] [HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCreator(long roomId) public async Task<IActionResult> BareCreator(long roomId)
{ {
var creatorId = await ReadBareLongAsync( var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")] [HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")] [HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAllowNewUsers(long roomId) public async Task<IActionResult> BareAllowNewUsers(long roomId)
{ {
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value"); var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/min_level")] [HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")] [HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMinLevel(long roomId) public async Task<IActionResult> BareMinLevel(long roomId)
{ {
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value"); var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] [HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")] [HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId) public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{ {
var value = await ReadBareIntAsync( var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")] [HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")] [HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreen(long roomId) public async Task<IActionResult> BareLoadScreen(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")] [HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")] [HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreenRemove(long roomId) public async Task<IActionResult> BareLoadScreenRemove(long roomId)
{ {
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value") var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")] [HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")] [HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesAdd(long roomId) public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{ {
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")] [HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")] [HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesRemove(long roomId) public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{ {
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value"); var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")] [HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")] [HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalAdd(long roomId) public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")] [HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")] [HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalRemove(long roomId) public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")] [HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")] [HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAck(long roomId) => public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ }); await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")] [HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")] [HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareModify(long roomId) public async Task<IActionResult> BareModify(long roomId)
{ {
var payload = await ReadPayloadAsync(); var payload = await ReadPayloadAsync();
@@ -1375,13 +1395,13 @@ public class RoomsModerationController(
{ {
var newName = await ReadCloneNameAsync(); var newName = await ReadCloneNameAsync();
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound(); if (source is null) return CloneResult(null, "That room doesn't exist.");
// RRO/AG rooms are first-party templates meant to be cloned — the // RRO/AG rooms are first-party templates meant to be cloned — the
// room-creation "base room" picker starts a new build by cloning one // room-creation "base room" picker starts a new build by cloning one
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false, // (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
// so without the IsAGRoom exception every base-room create 403s. // so without the IsAGRoom exception every base-room create is refused.
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me) if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
return Forbid(); return CloneResult(null, "You can't clone this room!");
if (string.IsNullOrWhiteSpace(newName)) if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy"; newName = $"{source.Name}Copy";
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
// it editable. Safe for admin: the purge gate and "RR Original" // it editable. Safe for admin: the purge gate and "RR Original"
// badge both key on CreatorPlayerId == SystemAccountId, so a // badge both key on CreatorPlayerId == SystemAccountId, so a
// user-owned clone is still shown as Custom and stays purgeable. // user-owned clone is still shown as Custom and stays purgeable.
//
// The WIRE IsRRO is a different thing: it is false for every
// player-owned room (RoomService.WireIsRRO). The 2023 client's
// room-load pipeline never reads IsRRO (the baked scene comes
// from the sub-room's UnitySceneId, the blob step from DataBlob),
// while its owner UI refuses publish and every room setting on
// an RRO, which is what kept cloned rooms from being published.
IsAGRoom = source.IsAGRoom, IsAGRoom = source.IsAGRoom,
IsDormRoom = false, IsDormRoom = false,
CloningAllowed = source.CloningAllowed, CloningAllowed = source.CloningAllowed,
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
CanMatchmakeInto = s.CanMatchmakeInto, CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = DateTime.UtcNow, DataModifiedAt = DateTime.UtcNow,
}).ToList(); }).ToList();
// A template with no scene rows still has to give the new room a
// real entry sub-room (named "Home", from the clone's own location,
// blob and capacity). Without it the details served a synthesised
// placeholder and Room Settings > Subrooms showed an empty row.
if (clonedScenes.Count == 0)
{
var home = Services.RoomService.NewHomeScene(clone);
home.Id = ++nextSceneId;
clonedScenes.Add(home);
}
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
if (clonedScenes.Count > 0) if (clonedScenes.Count > 0)
{ {
db.RoomScenes.AddRange(clonedScenes); db.RoomScenes.AddRange(clonedScenes);
@@ -1517,17 +1556,22 @@ public class RoomsModerationController(
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me); await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // The value is the FULL room-details object (FGCPNAACHIK), the exact
// deserializes the response as the FULL room-details object // shape GET rooms/{id} returns for the 2023 client — not the slim
// (FGCPNAACHIK — the same type get-by-id / rename return on the // list-room shape, which made the strict reader fail.
// roomserver host), NOT the slim list-room shape. This is the return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
// roomserver-family path (rooms/{id}/clone), so use
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
// made the strict reader fail → "Failed to copy room: Failed to clone
// room".
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
} }
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
/// clone through the same RecNet rooms helper as "save room" and "restore
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
/// has no <c>success</c>, so the client rejects it with an empty error:
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
/// though the room was created. Refusals are 200 with success=false and a
/// message the client shows.</summary>
private OkObjectResult CloneResult(object? room, string error = "") =>
Ok(new { success = error.Length == 0, value = room, error });
/// <summary>Give a freshly-cloned room its OWN copy of the source's save /// <summary>Give a freshly-cloned room its OWN copy of the source's save
/// data instead of pointing its scenes at the source's blob. /// data instead of pointing its scenes at the source's blob.
/// ///
@@ -1575,8 +1619,11 @@ public class RoomsModerationController(
// The scene was created as a shallow copy, so its DataBlobName is // The scene was created as a shallow copy, so its DataBlobName is
// currently the SOURCE's. Either a copy replaces it or it gets // currently the SOURCE's. Either a copy replaces it or it gets
// cleared — it must never survive as-is. // cleared — it must never survive as-is.
// A synthesised Home scene (template had no rows) has no source
// scene; it starts from the room-level blob it was built with.
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
var copied = await CopyBlobForCloneAsync( var copied = await CopyBlobForCloneAsync(
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId); sourceBlob, clone.Id, target.OrderIndex, callerId);
target.DataBlobName = copied ?? string.Empty; target.DataBlobName = copied ?? string.Empty;
target.DataModifiedAt = DateTime.UtcNow; target.DataModifiedAt = DateTime.UtcNow;
@@ -1887,7 +1934,13 @@ public class RoomsModerationController(
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no /// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict /// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every /// reader's dispose-walk NRE'd on the missing nested lists and every
/// mutation reported failure even though it had persisted.</summary> /// mutation reported failure even though it had persisted.
///
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;</c>, the
/// <c>{success, value, error}</c> envelope reader, so the details are the
/// envelope's <c>value</c>: every caller carries
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room) private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{ {
var scenes = await db.RoomScenes var scenes = await db.RoomScenes
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes. // Push the buyer's balance update so their wallet UI refreshes.
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
// is the absolute new balance, Delta only feeds the client's log.
await notifications.NotifyAsync(pid, await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase, PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug }); new
{
BalanceAddType = 0,
Delta = (int)-item.Price,
Balance = newBalance,
Platform = 0,
req.CurrencyType,
});
if (isGift) if (isGift)
{ {
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts // GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself // and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory. // appears next time the recipient opens their inventory.
// GiftPackageReceived "30" carries the client's full gift package
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
// platform), so the box could count as spawning nowhere.
await notifications.NotifyAsync(recipient, await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived, PushNotificationId.GiftPackageReceived,
new DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
} }
// For gift purchases the buyer doesn't get a box — their flow // For gift purchases the buyer doesn't get a box — their flow
@@ -177,7 +177,10 @@ public class SubscriptionsController(
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); // The client reads this body as DJMHAFPGLLN, the {success,error}
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
// instr 098) — answer in that shape rather than an empty 200.
return RecNetResults.Ok();
} }
/// <summary> /// <summary>
@@ -61,7 +61,7 @@ public class AccountsController(
{ {
var player = await GetCurrentPlayerAsync(); var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized(); if (player is null) return Unauthorized();
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); return Ok(AccountDtos.Self(player));
} }
/// <summary> /// <summary>
@@ -154,7 +154,7 @@ public class AccountsController(
playerId, username, summary); playerId, username, summary);
} }
await notifications.KickPlayerAsync(playerId, "This account has been deleted."); await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
presence.Clear(playerId); presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); return Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -194,7 +194,7 @@ public class AccountsController(
{ {
var player = await playerService.GetByIdAsync(accountId); var player = await playerService.GetByIdAsync(accountId);
if (player is null) return NotFound(); if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); return Ok(AccountDtos.Public(player));
} }
// Accounts.GetAccountByUsername // Accounts.GetAccountByUsername
@@ -203,7 +203,7 @@ public class AccountsController(
{ {
var player = await playerService.GetByUsernameAsync(username); var player = await playerService.GetByUsernameAsync(username);
if (player is null) return NotFound(); if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); return Ok(AccountDtos.Public(player));
} }
// Accounts.CreateAccount — actual URL observed in server logs: // Accounts.CreateAccount — actual URL observed in server logs:
@@ -263,7 +263,7 @@ public class AccountsController(
{ {
Success = true, Success = true,
Error = string.Empty, Error = string.Empty,
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName), Account = AccountDtos.Public(player),
}); });
} }
@@ -286,7 +286,7 @@ public class AccountsController(
var clamped = Math.Clamp(take, 1, 50); var clamped = Math.Clamp(take, 1, 50);
var matches = await playerService.SearchAsync(term.Trim(), clamped); var matches = await playerService.SearchAsync(term.Trim(), clamped);
return Ok(matches return Ok(matches
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)) .Select(p => AccountDtos.Public(p))
.ToList()); .ToList());
} }
@@ -302,6 +302,12 @@ public class AccountsController(
[HttpPost("/account/v1/emails")] [HttpPost("/account/v1/emails")]
[HttpPost("/account/v1/phones")] [HttpPost("/account/v1/phones")]
[HttpPost("/account/v1/byplatformid")] [HttpPost("/account/v1/byplatformid")]
// 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864)
// POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the
// values as repeated form fields of the same name, and reads the same account
// list (List<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
[HttpPost("/account/bulk/email")]
[HttpPost("/account/bulk/phone")]
public async Task<IActionResult> Bulk() public async Task<IActionResult> Bulk()
{ {
// The watch calls this route family with payloads keyed by // The watch calls this route family with payloads keyed by
@@ -401,7 +407,7 @@ public class AccountsController(
{ {
var p = await playerService.GetByIdAsync(id); var p = await playerService.GetByIdAsync(id);
if (p is not null) if (p is not null)
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)); accounts.Add(AccountDtos.Public(p));
} }
return Ok(accounts); return Ok(accounts);
} }
@@ -433,8 +439,11 @@ public class AccountsController(
} }
// Phone-confirm is the one mutation we don't persist — there's no // Phone-confirm is the one mutation we don't persist — there's no
// SMS provider in the loop. Acknowledge silently. // SMS provider in the loop. Acknowledge silently. The 2023 client
// composes the route as "account/me/" + "confirmphone" with POST
// (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body.
[HttpPost("/account/v1/confirmphone")] [HttpPost("/account/v1/confirmphone")]
[HttpPost("/account/me/confirmphone")]
public IActionResult ConfirmPhone() => public IActionResult ConfirmPhone() =>
Ok(new RecNetResult { Success = true, Error = string.Empty }); Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -629,6 +638,126 @@ public class AccountsController(
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
// ── 2023 profile edits ────────────────────────────────────────────
// All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds
// "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and
// reports "Failed to modify <suffix>" on any non-2xx (:8609). Every call
// site passes verb 3 = PUT. The form field name is NOT the suffix — each
// comes from the lambda that fills the request:
// emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57)
// personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32)
// identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32)
// bannerimage → "imageName" (…_NJKHNGEPODH.txt:57)
// The response body is never parsed (LDGADANDBIO handle), so the new
// value reaches the editor's UI and other players only through the
// AccountUpdate / SelfAccountUpdate push in NotificationService.
// None of these routes existed before, so every edit 404'd.
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
/// Empty clears the emoji; anything else must be on the
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
/// from, and is stored in that list's canonical spelling.</summary>
[HttpPut("/account/me/emoji")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var submitted = displayEmoji?.Trim() ?? string.Empty;
string stored;
if (submitted.Length == 0)
{
stored = string.Empty;
}
else
{
var canonical = ResolveWhitelistedEmoji(submitted);
if (canonical is null)
return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" });
stored = canonical;
}
player.DisplayEmoji = stored;
await db.SaveChangesAsync();
await notifications.AccountChanged(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>Whitelist match that ignores U+FE0F variation selectors, so
/// "❤" and "❤️" resolve to the same entry. Returns the list's spelling
/// (the client highlights the current pick by string equality against
/// the list it fetched).</summary>
internal static string? ResolveWhitelistedEmoji(string value)
{
static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal);
var needle = Strip(value);
foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList)
if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e;
return null;
}
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
[HttpPut("/account/me/personalpronouns")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
if (!TryParseFlags(pronounFlags, out var flags))
return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" });
player.PersonalPronouns = flags;
await db.SaveChangesAsync();
await notifications.AccountChanged(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>PUT <c>/account/me/identityflags</c> — form
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
[HttpPut("/account/me/identityflags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
if (!TryParseFlags(identityFlags, out var flags))
return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" });
player.IdentityFlags = flags;
await db.SaveChangesAsync();
await notifications.AccountChanged(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
/// values are not valid bitmasks for either enum.</summary>
private static bool TryParseFlags(string? raw, out int flags)
=> int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out flags)
&& flags >= 0;
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
/// the storage name of an image the player already uploaded (the
/// portfolio-banner picker offers the player's own photos). Empty clears
/// the banner.</summary>
[HttpPut("/account/me/bannerimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeBannerImage([FromForm(Name = "imageName")] string? imageName)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var name = imageName?.Trim() ?? string.Empty;
if (name.Length > 256)
return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" });
player.BannerImageName = name.Length == 0 ? null : name;
await db.SaveChangesAsync();
await notifications.AccountChanged(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys: // ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
// accountId (int, GetKey) // accountId (int, GetKey)
// disallowInAppPurchases (bool, GetKey) // disallowInAppPurchases (bool, GetKey)
@@ -680,63 +809,56 @@ public class AccountsController(
var token = Guid.NewGuid().ToString("N"); var token = Guid.NewGuid().ToString("N");
var expiresAt = DateTime.UtcNow.AddMinutes(10); var expiresAt = DateTime.UtcNow.AddMinutes(10);
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}"); await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}");
return Ok(new // FGLDKEJLAKB<System.String> LGKLFFPACGL() (RecNet.Runtime/
{ // LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string.
Success = true, // The old {Success, Token, ...} object hit the string reader's '"'
Error = string.Empty, // check and every "open on the web" link failed with "Failed to
Token = token, // CreateLoginToken". The token is redeemed by connect/token
LoginToken = token, // grant_type=login_token (AuthController.Token).
ExpiresAt = expiresAt, return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json");
});
} }
/// <summary>PlayerSettings key of the one-shot token minted above.
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
public const string RemoteLoginTokenKey = "remote_login_token";
/// <summary>In-game approval of another device's login code.
///
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
/// POSTs the single form field <c>code</c> and reads the reply through
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
/// (failure toast "Failed to authorize login"). The code is the
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
/// device, which meanwhile polls <c>connect/token</c> with
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
/// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the
/// pending authorization to the CALLER, and that device's next poll is
/// issued the caller's tokens (<see cref="DeviceAuthorizations"/>).
///
/// This used to look the code up among createlogintoken tokens — which a
/// user_code never matches, so every approval 401'd — and answered with a
/// token pair, which is not a RecNetResult either.</summary>
[HttpPost("/account/me/remoteauth")] [HttpPost("/account/me/remoteauth")]
[HttpGet("/account/me/remoteauth")] [HttpGet("/account/me/remoteauth")]
public async Task<IActionResult> RemoteAuth() public async Task<IActionResult> RemoteAuth()
{ {
var token = Request.Query["token"].FirstOrDefault() var approver = await GetCurrentPlayerAsync();
?? Request.Query["loginToken"].FirstOrDefault() if (approver is null) return Unauthorized();
?? Request.Query["code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType) var code = Request.Query["code"].FirstOrDefault()
?? Request.Query["user_code"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType)
{ {
var form = await Request.ReadFormAsync(); var form = await Request.ReadFormAsync();
token = form["token"].FirstOrDefault() code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault();
?? form["loginToken"].FirstOrDefault()
?? form["code"].FirstOrDefault();
} }
if (string.IsNullOrWhiteSpace(code))
return RecNetResults.Fail("missing_code");
if (string.IsNullOrWhiteSpace(token)) return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow)
{ ? RecNetResults.Ok()
var player = await GetCurrentPlayerAsync(); : RecNetResults.Fail("invalid_or_expired_code");
if (player is null) return Unauthorized();
var pair = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = pair.AccessToken,
RefreshToken = pair.RefreshToken,
});
}
var prefix = token.Trim() + "|";
var row = await db.PlayerSettings
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
.OrderByDescending(s => s.Id)
.FirstOrDefaultAsync();
if (row is null) return Unauthorized();
var rawExpiry = row.Value[prefix.Length..];
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
return Unauthorized();
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
} }
[HttpPost("/account/recoverpassword")] [HttpPost("/account/recoverpassword")]
@@ -953,8 +1075,15 @@ public class AccountsController(
var me = this.RequireCurrentPlayerId(); var me = this.RequireCurrentPlayerId();
var visible = await ReadRecentHistoryVisibleBodyAsync(); var visible = await ReadRecentHistoryVisibleBodyAsync();
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false"); await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
// OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD
// (ExpandRecNetResult) — it is read as {Success, Error}. Without a
// Success key the toggle parsed as a failure with a null Error, which
// throws "Error message cannot be null" instead of saving quietly.
// The settings keys stay for any reader of the old shape.
return Ok(new return Ok(new
{ {
Success = true,
Error = string.Empty,
AccountId = (int)me, AccountId = (int)me,
IsRecentHistoryVisible = visible, IsRecentHistoryVisible = visible,
}); });
@@ -995,35 +1124,4 @@ public class AccountsController(
// Previously had a /{*path} catch-all that returned [] / {}. Removed — // Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire // unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints. // them properly rather than silently masking missing endpoints.
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
};
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
};
} }
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs; using DorkNet.Server.Hubs;
using DorkNet.Server.Services; using DorkNet.Server.Services;
using RecRoom.Protobuf;
namespace DorkNet.Server.Controllers.Admin; namespace DorkNet.Server.Controllers.Admin;
@@ -28,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
public class AdminController( public class AdminController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level, LevelService level,
CommunityBoardService communityBoard, CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer, RoomBlobNormalizerService roomBlobNormalizer,
@@ -159,7 +161,9 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? ""); await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned."); // A ban must sign the player out (a kick only sends them to the dorm);
// the next login is refused by the ban check.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
playerPresence.Clear(id); playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil }); return Ok(new { player.Id, player.BannedUntil });
@@ -242,7 +246,7 @@ public class AdminController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin."); await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
playerPresence.Clear(id); playerPresence.Clear(id);
return Ok(new return Ok(new
@@ -974,7 +978,8 @@ public class AdminController(
double? HotScore, double? HotScore,
int? State, int? State,
string? ImageName, string? ImageName,
int? MaxCapacity); int? MaxCapacity,
bool? IsAGRoom);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial /// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the /// update of room properties. Only the non-null fields in the
@@ -1004,6 +1009,20 @@ public class AdminController(
room.MaxCapacity = Math.Clamp(cap, 1, 80); room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}"); changes.Add($"maxCapacity={room.MaxCapacity}");
} }
if (body.IsAGRoom is bool ag)
{
// IsAGRoom is what the client reads as IsRRO. True: load the
// baked scene from the client's own assets AND consult the
// client-baked AGRoomRuntimeConfig table (which, per original,
// decides e.g. whether creation is allowed — Rec Center yes, Rec
// Royale no). False: treat the room as a custom room (roles and
// blob decide everything). Flipping a CLONE of a baked original
// to false is the server-side experiment for "editable Rec
// Royale"; whether the client still loads the baked scene for a
// non-original is what that experiment answers.
room.IsAGRoom = ag;
changes.Add($"isAGRoom={ag}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true }); if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -1117,6 +1136,36 @@ public class AdminController(
.OrderBy(s => s.OrderIndex) .OrderBy(s => s.OrderIndex)
.ToListAsync(); .ToListAsync();
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
// Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at
// all; the game-client details builder synthesises a "Home" entry
// scene for them (RoomsController.BuildSubRoomWire with a null scene)
// and the mutation handlers create the row on first write. Mirror
// that here so the SPA has something to act on — the blob/enable
// action below creates the real row.
if (scenes.Count == 0)
{
return Ok(new[]
{
new
{
id = "0",
subRoomId = 0,
name = "Home",
maxPlayers = room.MaxCapacity,
canMatchmakeInto = true,
isSandbox = false,
dataBlobName = string.Empty,
effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal),
isBakedOriginal = bakedOriginal,
isVirtual = true,
dataModifiedAt = room.UpdatedAt,
},
});
}
return Ok(scenes.Select(s => new return Ok(scenes.Select(s => new
{ {
// Ids go out as strings: the admin SPA is JavaScript and silently // Ids go out as strings: the admin SPA is JavaScript and silently
@@ -1128,10 +1177,260 @@ public class AdminController(
canMatchmakeInto = s.CanMatchmakeInto, canMatchmakeInto = s.CanMatchmakeInto,
isSandbox = s.IsSandbox, isSandbox = s.IsSandbox,
dataBlobName = s.DataBlobName, dataBlobName = s.DataBlobName,
// What the client will actually be told to download for this
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
// scene's own blob, else the room's, else a synthetic default —
// except baked Rec Room Originals, which get NO blob until an
// admin enables one (see EnableSubRoomBlob).
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
isBakedOriginal = bakedOriginal,
dataModifiedAt = s.DataModifiedAt, dataModifiedAt = s.DataModifiedAt,
}).ToList()); }).ToList());
} }
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal)
{
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName;
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (bakedOriginal) return string.Empty;
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
}
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
/// editing: the synthetic default for the entry scene (the same name the
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
subRoomId == 0
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
/// — give a baked Rec Room Original sub-room a data blob so the client
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
///
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
/// builder + matchmaking resolver turns that into an empty
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
/// no persistence views, no room-role overlay, no Maker Pen, no room
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
/// it, not in the scene). Stamping the synthetic default name is enough:
/// every resolver returns it verbatim, and <c>CdnController</c> already
/// answers that name for AG rooms with the RRO-editable blob (permissive
/// room roles, no objects) when nothing has been saved yet. The first
/// in-game save then replaces the name with a real upload.
///
/// No-op when the sub-room already has a blob. Idempotent.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
var now = DateTime.UtcNow;
if (scene is null)
{
// Seeded baked originals carry no RoomScenes row; create the
// entry scene with the same defaults the game-side mutation
// path uses (RoomsController.GetOrCreateSceneForMutationAsync).
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
await db.SaveChangesAsync();
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
/// — clear the sub-room's data blob reference. For a baked Rec Room
/// Original that puts the room back to "scene only, no blob" (the escape
/// hatch if a save left the room unplayable); for any other room the
/// resolvers fall back to the synthetic default, i.e. an empty room.
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
/// the in-game restore list can bring an old save back.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
var previousScene = scene?.DataBlobName ?? string.Empty;
var previousRoom = room.CurrentDataBlobName;
var changed = false;
var now = DateTime.UtcNow;
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
{
scene.DataBlobName = string.Empty;
scene.DataModifiedAt = now;
changed = true;
}
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
{
room.CurrentDataBlobName = string.Empty;
room.UpdatedAt = now;
changed = true;
}
if (changed)
{
await LogAsync("reset_subroom_blob", "room", id,
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
await db.SaveChangesAsync();
}
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
/// Rewrites it for this room (activity id, 2023 version clamp, drops
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
/// only the game-role and game-rule objects and drops the circuit
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> name, records
/// the RoomDataBlobs row and points the sub-room (and, for sub-room 0,
/// the room) at it. The CDN's RRO role overlay and version clamp then
/// apply on serve exactly as for Rec Center's saved blob.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
[RequestSizeLimit(64 * 1024 * 1024)]
public async Task<ActionResult> UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode)
{
if (file is null || file.Length == 0) return BadRequest("missing file");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
if (scene is null && subRoomId != 0) return NotFound();
byte[] raw;
using (var ms = new MemoryStream((int)file.Length))
{
await file.CopyToAsync(ms);
raw = ms.ToArray();
}
try { _ = PersistedRoomData.Parser.ParseFrom(raw); }
catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); }
var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase);
var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics);
var now = DateTime.UtcNow;
var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat";
var (bucket, key) = BlobRouter.Route(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, "application/octet-stream");
}
catch (Exception ex)
{
adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName);
return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message });
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = id,
BlobName = blobName,
SubRoomId = subRoomId,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = now,
});
if (scene is null)
{
scene = new RoomSceneEntity
{
RoomId = room.Id,
OrderIndex = 0,
Name = "Home",
RoomSceneLocationId = room.LocationReplicationId,
MaxPlayers = room.MaxCapacity,
IsSandbox = false,
CanMatchmakeInto = true,
};
db.RoomScenes.Add(scene);
}
scene.DataBlobName = blobName;
scene.DataModifiedAt = now;
if (subRoomId == 0)
{
room.CurrentDataBlobName = blobName;
room.UpdatedAt = now;
}
await LogAsync("upload_subroom_blob", "room", id,
$"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}");
await db.SaveChangesAsync();
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
return Ok(new
{
id = scene.Id.ToString(),
subRoomId = scene.OrderIndex,
name = scene.Name,
dataBlobName = scene.DataBlobName,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
bytes = bytes.Length,
// Explicit camelCase: the API's naming policy is "as declared",
// and the rest of this payload is camelCase.
summary = new
{
viewsIn = summary.ViewsIn,
viewsKept = summary.ViewsKept,
roleViews = summary.RoleViews,
gameRuleViews = summary.GameRuleViews,
versionIn = summary.VersionIn,
versionOut = summary.VersionOut,
circuitsStripped = summary.CircuitsStripped,
activityId = summary.ActivityId,
},
});
}
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
{
id = (scene?.Id ?? 0).ToString(),
subRoomId = scene?.OrderIndex ?? 0,
name = scene?.Name ?? "Home",
dataBlobName = scene?.DataBlobName ?? string.Empty,
roomCurrentDataBlobName = room.CurrentDataBlobName,
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
isBakedOriginal = bakedOriginal,
changed,
};
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers); public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c> /// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
@@ -1274,10 +1573,13 @@ public class AdminController(
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
/// <summary>Push fresh RoomDetails after role/owner mutations. The /// <summary>Push fresh room details after role/owner mutations, to everyone
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts, /// in the room plus the player whose role changed. The client caches the
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom /// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
/// stores a replacement.</summary> /// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
/// wrapper parsed as RoomId 0 with no roles, so role changes never
/// reached anyone until they re-entered the room.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason) private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1291,7 +1593,7 @@ public class AdminController(
.ToListAsync(); .ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles); .BuildRoomServerDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds() var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId) .Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
@@ -1443,7 +1745,7 @@ public class AdminController(
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason); await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
playerPresence.Clear(pid); playerPresence.Clear(pid);
kicked.Add(pid); kicked.Add(pid);
} }
@@ -1959,6 +2261,22 @@ public class AdminController(
return Ok(settings); return Ok(settings);
} }
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
[HttpGet("settings/circuits-limits")]
public async Task<ActionResult> GetCircuitsV2Limits()
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
[HttpPost("settings/circuits-limits")]
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
{
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ───────────────────────────────────────────────────── // ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code /// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
@@ -2084,7 +2402,9 @@ public class AdminController(
var reason = string.IsNullOrWhiteSpace(body?.Reason) var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin." ? "Kicked by an admin."
: body!.Reason!.Trim(); : body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason); // The room instance lets the client announce the kick to that room.
await notifications.KickPlayerAsync(id, reason,
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
playerPresence.Clear(id); playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -3016,10 +3336,11 @@ public class AdminController(
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, ""); await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get // Log out any session still on the old password; the client has to
// dropped — the watch re-authenticates with the new password. // sign in again with the new one. The reason must be the numeric
await notifications.NotifyAsync(id, PushNotificationId.Logout, // enum: the string "AdminPasswordReset" failed the client's parse and
new { Reason = "AdminPasswordReset" }); // the push was dropped.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
return Ok(new { id }); return Ok(new { id });
} }
@@ -3188,22 +3509,26 @@ public class AdminController(
playerPresence.SetRoom(id, roomInstance); playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the // The move itself comes from the presence set above: the client's
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance // next heartbeat sees it is out of sync and joins that instance.
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom // RoomInstanceUpdate only refreshes the client's cached copy of the
// refreshes the watch's RoomDetails cache so the room-info // instance, and RoomUpdate its cached room details, which must be
// panel doesn't show stale data after the warp. // the full room (FGCPNAACHIK) the roomserver GET returns.
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance); PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
var forceJoinScenes = await db.RoomScenes
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
// With every role grant: the client replaces its cached room with
// this, so a payload without them would drop everyone's roles.
var forceJoinRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom, PushNotificationId.SubscriptionUpdateRoom,
new DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
{ room, forceJoinScenes, roles: forceJoinRoles));
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id, await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}"); $"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
@@ -3220,20 +3545,46 @@ public class AdminController(
// ── System ─────────────────────────────────────────────────────────── // ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message); public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
/// <summary>Push a server-maintenance broadcast to every connected /// <summary>Broadcast to every connected player.
/// player. The MOTD config value is changed via appsettings.json /// <list type="bullet">
/// (server restart required); this is for ad-hoc announcements.</summary> /// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
/// the system account) in each online player's notifications —
/// the client's channel for free text from the server.</item>
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
/// (ServerMaintenance "25", which reads only
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
/// </list>
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
/// client never reads: the text was never shown, and StartsInMinutes 0
/// cancelled any countdown instead.</summary>
[HttpPost("broadcast")] [HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body) public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{ {
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message"); var message = body.Message?.Trim() ?? string.Empty;
if (message.Length == 0 && body.StartsInMinutes is null)
return BadRequest("empty message");
if (body.StartsInMinutes is < 0)
return BadRequest("StartsInMinutes must be 0 or more");
var recipients = onlinePresence.OnlinePlayerIds().ToList();
if (message.Length > 0)
{
foreach (var pid in recipients)
await systemNotifications.SendAsync(pid,
SystemNotificationService.MessageType.CoachMessage,
fromPlayerId: PlayerService.SystemAccountId,
data: message);
}
if (body.StartsInMinutes is int minutes)
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance, await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message }); new { StartsInMinutes = minutes });
await LogAsync("broadcast", "system", 0, body.Message);
await LogAsync("broadcast", "system", 0,
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok(new { recipients = recipients.Count });
} }
// ── Store catalog management ───────────────────────────────────────── // ── Store catalog management ─────────────────────────────────────────
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data; using DorkNet.Server.Data;
@@ -41,6 +42,27 @@ public class AdminLoginController(
if (!player.IsAdmin) if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" }); return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
/// the external provider) for the same token pair <c>login</c> returns.
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
[HttpGet("sso")]
public async Task<ActionResult> Sso()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Unauthorized(new { error = "not_signed_in" });
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
return TokenResponse(player);
}
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
{
var (access, refresh) = authService.GenerateTokenPair(player.Id); var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new return Ok(new
{ {
@@ -101,7 +101,9 @@ public class AuthController(
[FromForm] int platform = 0, [FromForm] int platform = 0,
[FromForm] string? platformId = null, [FromForm] string? platformId = null,
[FromForm(Name = "platform_id")] string? platform_id_snake = null, [FromForm(Name = "platform_id")] string? platform_id_snake = null,
[FromForm] long? account_id = null) [FromForm] long? account_id = null,
[FromForm] string? device_code = null,
[FromForm] string? login_token = null)
{ {
// The client mixes camelCase and snake_case parameter naming // The client mixes camelCase and snake_case parameter naming
// depending on grant type — accept both and merge. // depending on grant type — accept both and merge.
@@ -139,6 +141,55 @@ public class AuthController(
return TokenOk(acc1, refr1); return TokenOk(acc1, refr1);
} }
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
// The client keeps polling on HTTP 400 with error authorization_pending
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
// error ends the attempt. This grant used to fall through to the
// device-id fallback, which signed the polling device straight in as
// whatever account its device id mapped to — no approval involved.
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
{
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
switch (status)
{
case DeviceAuthorizations.RedeemStatus.Pending:
return BadRequest(new { error = "authorization_pending" });
case DeviceAuthorizations.RedeemStatus.Expired:
return BadRequest(new { error = "expired_token" });
case DeviceAuthorizations.RedeemStatus.Invalid:
return BadRequest(new { error = "invalid_grant" });
}
var approver = await playerService.GetByIdAsync(approverId);
if (approver is null) return BadRequest(new { error = "invalid_grant" });
await TryAwardLoginXpAsync(approver.Id);
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
return TokenOk(accD, refrD);
}
// One-shot token from account/me/createlogintoken, redeemed with the
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
// account_id, login_token). Also used to fall through to the device-id
// fallback, ignoring both account_id and the token.
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
{
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
var prefix = login_token.Trim() + "|";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
s.PlayerId == tokenAccountId
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
&& s.Value.StartsWith(prefix));
if (row is null
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|| tokenExpiry < DateTime.UtcNow)
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
return TokenOk(accL, refrL);
}
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase)) if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
{ {
if (string.IsNullOrWhiteSpace(refresh_token)) if (string.IsNullOrWhiteSpace(refresh_token))
@@ -364,26 +415,20 @@ public class AuthController(
[HttpGet("/connect/deviceauthorization")] [HttpGet("/connect/deviceauthorization")]
public async Task<IActionResult> DeviceAuthorization() public async Task<IActionResult> DeviceAuthorization()
{ {
var deviceCode = Guid.NewGuid().ToString("N"); var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
var userCode = deviceCode[..8].ToUpperInvariant();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = RelationshipQueries.SystemAccountId,
Key = $"deviceauthorization:{deviceCode}",
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
});
await db.SaveChangesAsync();
return Ok(new return Ok(new
{ {
device_code = deviceCode, device_code = created.DeviceCode,
user_code = userCode, user_code = created.UserCode,
verification_uri = $"https://{domain.Sub("auth")}/device", verification_uri = $"https://{domain.Sub("auth")}/device",
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}", verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
expires_in = 900, expires_in = created.ExpiresInSeconds,
interval = 5, interval = 5,
}); });
} }
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
{ {
Platform = platform, Platform = platform,
+45 -16
View File
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Cdn; namespace DorkNet.Server.Controllers.Cdn;
@@ -557,11 +558,18 @@ public class CdnController(
return new FileContentResult(TransparentPng, "image/png"); return new FileContentResult(TransparentPng, "image/png");
} }
if (await ShouldOverlayRroRoomRoleDataAsync(fileName)) var rroRoom = await ResolveRroRoomAsync(fileName);
if (rroRoom is not null)
{ {
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob", // Per-room stub: a real save's skeleton at the 2023 version
Request.Host.Host, fileName); // ceiling with the room's own activity_id. The old generic stub
var rroBlob = roomDataBlob.GetRroEditableBlob(); // (version 19, roles only) was fetched and silently rejected by
// the client, which is why an enabled baked room still had no
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
Response.Headers.CacheControl = "public, max-age=60"; Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, rroBlob); signatures.AddContentSignature(Response, rroBlob);
return new FileContentResult(rroBlob, "application/octet-stream"); return new FileContentResult(rroBlob, "application/octet-stream");
@@ -569,14 +577,34 @@ public class CdnController(
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob", logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
Request.Host.Host, fileName); Request.Host.Host, fileName);
var fallbackBlob = roomDataBlob.GetDefaultBlob(); var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
signatures.AddContentSignature(Response, fallbackBlob); signatures.AddContentSignature(Response, fallbackBlob);
return new FileContentResult(fallbackBlob, "application/octet-stream"); return new FileContentResult(fallbackBlob, "application/octet-stream");
} }
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) /// <summary>Same 2023 version clamp the S3-hit path applies, for the
/// synthesised blobs on the miss path — a stub past the client's ceiling
/// is rejected exactly like a modern export would be.</summary>
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
{ {
if (string.IsNullOrWhiteSpace(fileName)) return false; if (!IsRoomDataName(fileName)) return bytes;
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
if (changed)
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
return clamped;
}
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
await ResolveRroRoomAsync(fileName) is not null;
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
/// the per-room stub on misses.</summary>
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
// MakerPen across ALL Rec Room Originals: overlay the permissive // MakerPen across ALL Rec Room Originals: overlay the permissive
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO // room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
@@ -587,9 +615,9 @@ public class CdnController(
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable- // blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
// RoleData) — the game-rule circuit data and GameRoleData are left // RoleData) — the game-rule circuit data and GameRoleData are left
// intact — so quest rooms keep working, exactly as Stunt Runner does. // intact — so quest rooms keep working, exactly as Stunt Runner does.
if (await db.Rooms.AsNoTracking().AnyAsync(r => var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
r.IsAGRoom && r.CurrentDataBlobName == fileName)) r.IsAGRoom && r.CurrentDataBlobName == fileName);
return true; if (byCurrentBlob is not null) return byCurrentBlob;
// Synthetic/default room-data blob names for rooms with no saved blob: // Synthetic/default room-data blob names for rooms with no saved blob:
// the CURRENT form is room_{id}_dorknet_v8.dat // the CURRENT form is room_{id}_dorknet_v8.dat
@@ -603,18 +631,19 @@ public class CdnController(
{ {
var rest = fileName["room_".Length..]; var rest = fileName["room_".Length..];
var underscore = rest.IndexOf('_'); var underscore = rest.IndexOf('_');
if (underscore > 0 && if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
long.TryParse(rest[..underscore], out var roomId) && {
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom)) var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
return true; if (byId is not null) return byId;
}
} }
return await ( return await (
from scene in db.RoomScenes.AsNoTracking() from scene in db.RoomScenes.AsNoTracking()
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
where room.IsAGRoom && scene.DataBlobName == fileName where room.IsAGRoom && scene.DataBlobName == fileName
select scene.Id select room
).AnyAsync(); ).FirstOrDefaultAsync();
} }
/// <summary> /// <summary>
@@ -460,6 +460,20 @@ public class ChatController(
var removed = await db.ChatThreadMembers var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
if (removed > 0)
{
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
// the handler only reads ChatThreadId and SenderPlayerId (the
// player who left) and drops them from that thread's members.
// Sent to the remaining members, whose member lists otherwise
// stayed stale, and to the leaver's own other sessions.
var thread = await EnsureThreadRowAsync(chatThreadId);
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
var recipients = await ThreadRecipientsAsync(chatThreadId);
recipients.Add(Me);
foreach (var rid in recipients.Distinct())
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
}
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
} }
@@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// 2023 client sends the target club in a form-urlencoded /// 2023 client sends the target club in a form-urlencoded
/// <c>clubId</c> field (boxed <c>Nullable&lt;Int64&gt;</c> pushed through /// <c>clubId</c> field (boxed <c>Nullable&lt;Int64&gt;</c> pushed through
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>) /// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
/// and parses NO response DTO — the issuing method is /// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable&lt;Int64&gt;)</c>.
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable&lt;Int64&gt;)</c>, the status-only /// Despite the untyped promise the body IS parsed: both branches hand
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT) /// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
/// is the HasValue branch of the same method at :16911; the null branch /// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
/// is the DELETE below (:16838).</summary> /// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
/// HasValue branch of the same method at :16911; the null branch is the
/// DELETE below (:16838).</summary>
[HttpPut("/club/home/me")] [HttpPut("/club/home/me")]
[Authorize] [Authorize]
public async Task<IActionResult> HomeMeSet() public async Task<IActionResult> HomeMeSet()
@@ -116,11 +118,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
clubId = fromQuery; clubId = fromQuery;
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" }); if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound(); if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
return Ok(); return RecNetResults.Ok();
} }
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room". /// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route /// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home /// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary> /// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
[HttpDelete("/club/home/me")] [HttpDelete("/club/home/me")]
@@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
public async Task<IActionResult> HomeMeClear() public async Task<IActionResult> HomeMeClear()
{ {
await clubs.ClearHomeClubAsync(Me); await clubs.ClearHomeClubAsync(Me);
return Ok(); return RecNetResults.Ok();
} }
[HttpGet("/club/mine/member")] [HttpGet("/club/mine/member")]
@@ -347,7 +349,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
{ {
var pid = Me; var pid = Me;
await clubs.MarkAnnouncementReadAsync(pid, announcementId); await clubs.MarkAnnouncementReadAsync(pid, announcementId);
return Ok(new { Read = true }); return RecNetResults.Ok();
} }
// ── Single-club reads ───────────────────────────────────────────── // ── Single-club reads ─────────────────────────────────────────────
@@ -369,8 +371,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// the club NAME used only for the client-side confirmation copy and is /// the club NAME used only for the client-side confirmation copy and is
/// never transmitted; the request is a bare DELETE with no body /// never transmitted; the request is a bare DELETE with no body
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error /// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only /// string "Failed to Delete Club"). The promise is built by
/// promise so an empty 200 is the whole contract. /// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
/// client report "Failed to Delete Club" after the club was gone. See
/// <see cref="RecNetResults"/>.
/// ///
/// Owner-only, and a soft delete — see /// Owner-only, and a soft delete — see
/// <see cref="ClubService.DisbandAsync"/>.</summary> /// <see cref="ClubService.DisbandAsync"/>.</summary>
@@ -382,7 +387,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
{ {
var disbanded = await clubs.DisbandAsync(clubId, Me); var disbanded = await clubs.DisbandAsync(clubId, Me);
if (disbanded is null) return NotFound(); if (disbanded is null) return NotFound();
return Ok(); return RecNetResults.Ok();
} }
catch (UnauthorizedAccessException) { return Forbid(); } catch (UnauthorizedAccessException) { return Forbid(); }
} }
@@ -1144,7 +1149,15 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary> /// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
private static int RequestStatusFor(ClubMembershipEntity row) => 1; private static int RequestStatusFor(ClubMembershipEntity row) => 1;
// ── Member mutations (void / IPromise) ─────────────────────────── // ── Member mutations ─────────────────────────────────────────────
//
// Every one of these goes through the client's shared club-write helper
// IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the
// response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the
// RecNetResult reader. They are not status-only: an empty 200 parses to
// null and the client shows "Failed to request to join club" (etc.) after
// the server has applied the change. Success bodies are
// RecNetResults.Ok().
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller /// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
/// asks to join. For Open clubs we promote straight to Member; for /// asks to join. For Open clubs we promote straight to Member; for
@@ -1169,7 +1182,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
}; };
if (perms == -1) return Forbid(); if (perms == -1) return Forbid();
await clubs.UpsertMembershipAsync(clubId, Me, perms); await clubs.UpsertMembershipAsync(clubId, Me, perms);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as /// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
@@ -1183,7 +1196,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var club = await clubs.GetByIdAsync(clubId); var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound(); if (club is null) return NotFound();
await clubs.UpsertMembershipAsync(clubId, Me, 0); await clubs.UpsertMembershipAsync(clubId, Me, 0);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves /// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
@@ -1198,7 +1211,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (club.CreatorPlayerId == Me) if (club.CreatorPlayerId == Me)
return BadRequest(new { error = "owner_must_transfer_first" }); return BadRequest(new { error = "owner_must_transfer_first" });
await clubs.RemoveMembershipAsync(clubId, Me); await clubs.RemoveMembershipAsync(clubId, Me);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites /// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
@@ -1220,7 +1233,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var target = req.PlayerId ?? req.AccountId ?? 0; var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk /// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
@@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids) foreach (var pid in ids)
await clubs.UpsertMembershipAsync(clubId, pid, 128); await clubs.UpsertMembershipAsync(clubId, pid, 128);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller /// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
@@ -1254,7 +1267,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (row is null) return NotFound(); if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" }); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller /// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
@@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
public async Task<IActionResult> MemberDeclineInvite(long clubId) public async Task<IActionResult> MemberDeclineInvite(long clubId)
{ {
await clubs.RemoveMembershipAsync(clubId, Me); await clubs.RemoveMembershipAsync(clubId, Me);
return Ok(); return RecNetResults.Ok();
} }
public sealed class MemberTargetRequest public sealed class MemberTargetRequest
@@ -1288,7 +1301,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (row is null) return NotFound(); if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" }); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
await clubs.UpsertMembershipAsync(clubId, target, 0); await clubs.UpsertMembershipAsync(clubId, target, 0);
return Ok(); return RecNetResults.Ok();
} }
public sealed class BulkTargetRequest public sealed class BulkTargetRequest
@@ -1309,7 +1322,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (row is null || (row.Permissions & 128) == 0) continue; if (row is null || (row.Permissions & 128) == 0) continue;
await clubs.UpsertMembershipAsync(clubId, pid, 0); await clubs.UpsertMembershipAsync(clubId, pid, 0);
} }
return Ok(); return RecNetResults.Ok();
} }
[HttpPost("/club/{clubId:long}/members/denyrequest")] [HttpPost("/club/{clubId:long}/members/denyrequest")]
@@ -1320,7 +1333,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var target = req.PlayerId ?? req.AccountId ?? 0; var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target); await clubs.RemoveMembershipAsync(clubId, target);
return Ok(); return RecNetResults.Ok();
} }
[HttpPost("/club/{clubId:long}/members/denyrequests")] [HttpPost("/club/{clubId:long}/members/denyrequests")]
@@ -1331,7 +1344,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids) foreach (var pid in ids)
await clubs.RemoveMembershipAsync(clubId, pid); await clubs.RemoveMembershipAsync(clubId, pid);
return Ok(); return RecNetResults.Ok();
} }
[HttpPost("/club/{clubId:long}/members/remove")] [HttpPost("/club/{clubId:long}/members/remove")]
@@ -1346,7 +1359,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (target == club.CreatorPlayerId) if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_remove_owner" }); return BadRequest(new { error = "cannot_remove_owner" });
await clubs.RemoveMembershipAsync(clubId, target); await clubs.RemoveMembershipAsync(clubId, target);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent /// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
@@ -1364,7 +1377,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (target == club.CreatorPlayerId) if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_ban_owner" }); return BadRequest(new { error = "cannot_ban_owner" });
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
return Ok(); return RecNetResults.Ok();
} }
[HttpPost("/club/{clubId:long}/members/unban")] [HttpPost("/club/{clubId:long}/members/unban")]
@@ -1375,7 +1388,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var target = req.PlayerId ?? req.AccountId ?? 0; var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" }); if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target); await clubs.RemoveMembershipAsync(clubId, target);
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote / /// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
@@ -1396,7 +1409,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
if (target == club.CreatorPlayerId) if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_change_owner_role" }); return BadRequest(new { error = "cannot_change_owner_role" });
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType)); await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
return Ok(); return RecNetResults.Ok();
} }
// ── members/bulk + clubreporting ───────────────────────────────── // ── members/bulk + clubreporting ─────────────────────────────────
@@ -1513,7 +1526,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId) public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{ {
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid(); if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
return Ok(); return RecNetResults.Ok();
} }
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT /// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
@@ -1529,7 +1542,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var (title, body, imageName) = await ReadAnnouncementFieldsAsync(); var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName)) if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
return Forbid(); return Forbid();
return Ok(); return RecNetResults.Ok();
} }
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an /// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
@@ -39,8 +39,10 @@ public class GoToController(
RoomService rooms, RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db, DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
ILogger<GoToController> logger) : ControllerBase ILogger<GoToController> logger) : ControllerBase
{ {
[HttpPost("/goto/room/{roomName}")] [HttpPost("/goto/room/{roomName}")]
@@ -315,6 +317,10 @@ public class GoToController(
[FromForm(Name = "JoinMode")] int joinMode = 0, [FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null) [FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{ {
// The client sends the wire SubRoomId it read from the room details
// (OrderIndex + 1, see RoomSceneIds); everything below works in
// OrderIndex.
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) if (room is null)
{ {
@@ -876,11 +882,15 @@ public class GoToController(
logger.LogWarning( logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId); callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in. // Tell the OWNER someone tried to follow them in, as the
// Helps surface the rejection so the owner knows there's // client's own "wants to join" message (RequestGameInvite,
// an interested party — they can choose to send an invite. // from the caller), so they can send an invite. At most once
await notifications.PrivateInstanceJoinRejected( // per requester, owner and instance per cooldown, so retrying
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0); // doesn't fill the owner's inbox.
if (callerId is long requester
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto return Ok(new MatchmakingResponseDto
{ {
ErrorCode = 26, // RoomInstanceIsPrivate ErrorCode = 26, // RoomInstanceIsPrivate
@@ -1294,15 +1304,14 @@ public class GoToController(
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
{ {
// Honors the global-friends toggle: with it on, every other player is // Honors the global-friends toggle (every other player is a "friend"
// a "friend" and gets the presence ping (so the whole small server // when it is on). Goes through the shared fan-out so the frame
// sees each other's room moves). // carries the player's real status visibility and the same
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId); // online/room state every other presence transition reports.
logger.LogInformation( logger.LogInformation(
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)", "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
playerId, room.RoomId, room.Name, friendIds.Count); playerId, room.RoomId, room.Name);
if (friendIds.Count == 0) return; await presenceFanout.PublishCurrentAsync(playerId);
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
} }
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName) private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
@@ -1547,9 +1556,25 @@ public class RoomInstanceDto
[JsonPropertyName("roomId")] [JsonPropertyName("roomId")]
public long RoomId { get; set; } public long RoomId { get; set; }
[JsonPropertyName("subRoomId")] /// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
/// (0 = the room's first sub-room). Server code reads and writes this;
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
[JsonIgnore]
public long SubRoomId { get; set; } public long SubRoomId { get; set; }
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
/// <see cref="Services.RoomSceneIds"/>). It must equal the
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
/// current sub-room up by it. Presence round-trips through this property
/// too, so stored JSON stays consistent.</summary>
[JsonPropertyName("subRoomId")]
public long WireSubRoomId
{
get => Services.RoomSceneIds.ToWire(SubRoomId);
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
}
// STRING RoomSceneLocationId GUID — foreign-keys to // STRING RoomSceneLocationId GUID — foreign-keys to
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via // AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default // AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
@@ -1668,3 +1693,35 @@ public class RoomInstanceDto
}; };
} }
} }
/// <summary>Rate limit for "wants to join" messages: one per (requester,
/// owner, room instance) per <see cref="Window"/>. Later retries and other
/// instances still go through. Per replica, which is enough to stop a
/// retry loop from flooding an inbox.</summary>
internal static class JoinRequestCooldown
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
/// <summary>True when no request for this key was sent within the window
/// (and records this one); false while it is cooling down.</summary>
public static bool TryStart(long requester, long owner, long roomInstanceId)
{
var now = DateTime.UtcNow;
if (Last.Count > 10_000)
foreach (var (key, at) in Last)
if (now - at > Window) Last.TryRemove(key, out _);
var k = (requester, owner, roomInstanceId);
while (true)
{
if (!Last.TryGetValue(k, out var prev))
{
if (Last.TryAdd(k, now)) return true;
continue;
}
if (now - prev < Window) return false;
if (Last.TryUpdate(k, now, prev)) return true;
}
}
}
@@ -22,6 +22,7 @@ public class MatchController(
PlayerPresenceService presence, PlayerPresenceService presence,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
PresenceBroadcastService presenceFanout,
RoomService rooms, RoomService rooms,
DorkNetDbContext db, DorkNetDbContext db,
ILogger<MatchController> logger) : ControllerBase ILogger<MatchController> logger) : ControllerBase
@@ -67,6 +68,12 @@ public class MatchController(
completed.Value.TargetRoom.Name, completed.Value.TargetRoom.Name,
completed.Value.TargetRoom.RoomInstanceId, completed.Value.TargetRoom.RoomInstanceId,
completed.Value.DeferPresenceCommit); completed.Value.DeferPresenceCommit);
// A deferred join (matchmake/dorm, private-instance hop)
// commits HERE, not at matchmake time, so this is the only
// point friends can learn about the move. Immediate commits
// were already announced by /goto.
if (completed.Value.DeferPresenceCommit)
await presenceFanout.PublishCurrentAsync(pid);
} }
else else
{ {
@@ -88,99 +95,93 @@ public class MatchController(
return Ok(); return Ok();
} }
/// <summary>POST <c>matchmake/none</c> — "leave room". The 2023 client's
/// only caller is TryGoToOfflineRoom
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already
/// switched its own cached presence to the local offline instance
/// (RoomInstanceId -1) before posting this, and it discards the reply
/// (<c>&lt;TryGoToOfflineRoom&gt;b__119_0</c> returns a constant).
///
/// So the player is now online in no room, and that is what presence must
/// say. This used to re-commit the previous room (or the dorm) instead:
/// the next heartbeat then answered with a room while the client held the
/// offline instance, the client logged "presence heartbeat response
/// indicates local presence is out-of-sync", cancelled its offline load
/// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the
/// server's room — and friends kept seeing the room the player had left.
///
/// A join still pending is abandoned too: the client only goes offline
/// after giving up on it (fallback chain after a Photon timeout), and
/// echoing its target from the heartbeat would pull the player back.</summary>
[HttpPost("/matchmake/none")] [HttpPost("/matchmake/none")]
public async Task<IActionResult> MatchmakeNone() public async Task<IActionResult> MatchmakeNone()
{ {
var pid = this.CurrentPlayerId() ?? 0; var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized(); if (pid == 0) return Unauthorized();
var previous = presence.GetRoom(pid);
var pending = joinTimeouts.GetPending(pid); var pending = joinTimeouts.GetPending(pid);
if (pending is { DeferPresenceCommit: true }) joinTimeouts.Cancel(pid, "matchmake-none");
{ presence.SetNoRoom(pid);
presence.MarkActive(pid); presence.MarkActive(pid);
presence.EndBootDormGrace(pid);
logger.LogInformation( logger.LogInformation(
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence", "[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
pid, pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
pending.Value.TargetRoom.RoomId,
pending.Value.TargetRoom.RoomInstanceId); await presenceFanout.PublishCurrentAsync(pid);
// The client ignores this body; keep a well-formed instance in it so
// the shared MatchmakingResponse reader never sees a null object.
return Ok(new MatchmakingResponseDto return Ok(new MatchmakingResponseDto
{ {
ErrorCode = 0, ErrorCode = 0,
RoomInstance = pending.Value.TargetRoom, RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid),
});
}
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, current);
presence.MarkActive(pid);
logger.LogInformation("[matchmake-none] player={Player}", pid);
return Ok(new MatchmakingResponseDto
{
ErrorCode = 0,
RoomInstance = current,
}); });
} }
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
/// rooms client, hence the <c>roomserver/</c> twin).
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
/// player is in: the defaults plus whatever the room's editors saved with
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
/// Permissions the client falls into its persisted-role migration path,
/// which NREs during first spawn.</summary>
[HttpGet("/photon_access_token")] [HttpGet("/photon_access_token")]
public IActionResult PhotonAccessToken() [HttpGet("/roomserver/photon_access_token")]
public async Task<IActionResult> PhotonAccessToken()
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var appId = config["Photon:AppId"] ?? string.Empty; var appId = config["Photon:AppId"] ?? string.Empty;
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty; var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
var currentRoom = presence.GetRoom(pid); var currentRoom = presence.GetRoom(pid);
var permissions = BuildPhotonPermissionSeed(); var permissions = currentRoom is null
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
logger.LogInformation( logger.LogInformation(
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}", "[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length); pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
return Ok(new Dictionary<string, object>
{
// 2023 deserializes this endpoint as ALIIIAGAPND before room
// permissions initialization. If Permissions is missing/empty,
// OMJKHJLFOCO falls into the persisted-role migration path that
// currently NREs during first spawn.
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
["PhotonAccessToken"] = string.Empty,
["Permissions"] = permissions,
// Existing custom-auth/app-id shape kept for Photon auth callers. var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
["ResultCode"] = 1, // Existing custom-auth/app-id shape kept for Photon auth callers; the
["Message"] = string.Empty, // ALIIIAGAPND reader ignores keys it does not register.
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture), body["ResultCode"] = 1;
["PhotonAppId"] = appId, body["Message"] = string.Empty;
["PhotonVoiceAppId"] = voiceAppId, body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
["AppId"] = appId, body["PhotonAppId"] = appId;
["VoiceAppId"] = voiceAppId, body["PhotonVoiceAppId"] = voiceAppId;
["CloudRegion"] = region, body["AppId"] = appId;
["Region"] = region, body["VoiceAppId"] = voiceAppId;
["Token"] = string.Empty, body["CloudRegion"] = region;
}); body["Region"] = region;
body["Token"] = string.Empty;
return Ok(body);
} }
private static object[] BuildPhotonPermissionSeed()
=>
[
BuildPhotonPermission(13, 30), // Can Use Maker Pen
BuildPhotonPermission(20, 30), // Can Spawn Inventions
BuildPhotonPermission(21, 30), // Can Spawn Consumables
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
BuildPhotonPermission(13, 255), // Creator alias
BuildPhotonPermission(20, 255),
BuildPhotonPermission(21, 255),
BuildPhotonPermission(22, 255),
BuildPhotonPermission(23, 255),
];
private static object BuildPhotonPermission(int permission, int role) => new
{
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
Role = role,
Override = true,
Type = 0,
Value = "true",
};
[HttpPost("/matchmake/dorm")] [HttpPost("/matchmake/dorm")]
public async Task<IActionResult> MatchmakeDorm() public async Task<IActionResult> MatchmakeDorm()
{ {
@@ -471,97 +472,125 @@ public class MatchController(
return Ok(session); return Ok(session);
} }
/// <summary>GET <c>match.*/room/{id}/instances</c> — the /// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
/// "RoomInstanceBrowser" the watch shows when you tap a room tile. /// browser.
/// Returns the currently-active instances so the player can see
/// which one their friends are in (and join it) instead of being
/// matchmade into a fresh empty room. Verified URL at
/// Matchmaking.txt:6286; response is a
/// <c>List&lt;SimpleRoomInstance&gt;</c> per
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
/// HasModPresent, HashedInstanceId.
/// ///
/// We derive instances from PlayerPresenceService's per-player /// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
/// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId /// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
/// is one instance, with the players currently presenced there. /// GET on the matchmaking host) returns <c>List&lt;PNDCMIMEJLD&gt;</c>, a bare
/// PrivateInstances are surfaced as well so the inviter and /// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
/// invitee see the same room in the browser.</summary> /// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
/// HasModPresent from the room's Roles. The screen
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
/// is given, current instance first then oldest CreatedAt first, and
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
///
/// <para><b>What is listed.</b> Every live instance of the room: each
/// distinct Photon room in which at least one player is presenced with a
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
/// Private instances are included only for callers allowed into them —
/// the room creator, accepted role holders, the instance owner, its
/// invitees, and players already inside — so another room's invite-only
/// match never leaks. Empty instances are not listed: there is nothing to
/// join and they are what made the old list stale.</para>
///
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
/// all, so the browser was empty on every multi-replica deployment;
/// private instances (every user room is born private and has one
/// instance per sub-room) were dropped from presence and re-added only
/// for their owner with an empty PlayerIds, so they showed 0 players or
/// nothing; expired or crashed presences still counted; IsFull was always
/// false; and SubRoomId used the OrderIndex, which does not match the
/// room details' wire ids.</para></summary>
[HttpGet("/room/{roomId:long}/instances")] [HttpGet("/room/{roomId:long}/instances")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> RoomInstances(long roomId) public async Task<IActionResult> RoomInstances(long roomId)
{ {
var presenced = presence.EnumerateActiveInstances(roomId).ToList();
// Only surface the caller's OWN private instances — we never
// leak someone else's PhotonRoomId into the browser. The Photon
// join check still requires CanJoinAsync, but redacting at the
// list level matches the existing RoomInstanceDto.Redact policy.
var pid = this.CurrentPlayerId() ?? 0; var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0 var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
? new List<Data.Entities.PrivateInstanceEntity>() if (room is null) return Ok(Array.Empty<object>());
: await db.PrivateInstances
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
.ToListAsync();
// Merge: presence rows are authoritative (player currently var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
// there); add any owned private instance the inviter created
// but nobody's joined yet so the inviter can still see it. var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
var byKey = new Dictionary<string, SimpleInstance>(StringComparer.Ordinal); var privateRows = privateIds.Count == 0
foreach (var (room, playerIds) in presenced) ? new Dictionary<long, PrivateInstanceEntity>()
: await db.PrivateInstances.AsNoTracking()
.Where(p => privateIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id);
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
var invitedTo = pid == 0 || privateIds.Count == 0
? new HashSet<long>()
: (await db.PrivateInstanceInvitees.AsNoTracking()
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
.Select(i => i.PrivateInstanceId)
.ToListAsync()).ToHashSet();
var rows = new List<RoomInstanceRow>();
foreach (var (inst, playerIds) in live)
{ {
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
byKey[room.PhotonRoomId] = new SimpleInstance( if (inst.IsPrivate && !CanSeePrivateInstance(
RoomInstanceId: room.RoomInstanceId, pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
RoomId: room.RoomId, continue;
SubRoomId: room.SubRoomId,
IsFull: false, var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
CreatedAt: DateTime.UtcNow, rows.Add(new RoomInstanceRow(
PlayerIds: playerIds.Select(p => (int)p).ToList(), RoomInstanceId: inst.RoomInstanceId,
SubroomName: room.Name ?? string.Empty, RoomId: inst.RoomId,
PlayerCount: playerIds.Count, SubRoomId: inst.SubRoomId,
HasModPresent: false, IsFull: capacity > 0 && playerIds.Count >= capacity,
HashedInstanceId: room.PhotonRoomId); CreatedAt: privateRow is not null
} ? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
foreach (var p in privates) : DateTime.UtcNow,
{ PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
byKey[p.PhotonRoomId] = new SimpleInstance(
RoomInstanceId: p.Id,
RoomId: p.RoomId,
SubRoomId: p.SubRoomId,
IsFull: false,
CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
PlayerIds: new List<int>(),
SubroomName: p.Name,
PlayerCount: 0,
HasModPresent: false,
HashedInstanceId: p.PhotonRoomId);
} }
// Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE logger.LogInformation(
// keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt "[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
// lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull, roomId, pid, live.Count, rows.Count);
// createdAt, playerIds}). SubroomName / PlayerCount / return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
// HasModPresent / HashedInstanceId are populated locally on the
// watch (PlayerIds.Count, local hash, etc.) — not read from
// JSON, so we don't send them. Earlier PascalCase response made
// every field default to 0/null on the watch, breaking the
// browser.
return Ok(byKey.Values.Select(i => new
{
roomInstanceId = i.RoomInstanceId,
roomId = i.RoomId,
subRoomId = i.SubRoomId,
isFull = i.IsFull,
createdAt = i.CreatedAt,
playerIds = i.PlayerIds.ToArray(),
}));
} }
private sealed record SimpleInstance( /// <summary>Who may see (and so be offered a join into) a private
/// instance in the browser.</summary>
public static bool CanSeePrivateInstance(
long callerId,
bool callerHasRoomRole,
IReadOnlyCollection<long> playersInside,
PrivateInstanceEntity? privateRow,
bool callerInvited)
{
if (callerId == 0) return false;
if (callerHasRoomRole) return true;
if (playersInside.Contains(callerId)) return true;
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
return callerInvited;
}
internal sealed record RoomInstanceRow(
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull, long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
DateTime CreatedAt, List<int> PlayerIds, string SubroomName, DateTime CreatedAt, List<long> PlayerIds);
int PlayerCount, bool HasModPresent, string HashedInstanceId);
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
/// SubRoomId is the wire id the room details advertise
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
/// is what this route has always sent.</summary>
internal static object ToWire(RoomInstanceRow r) => new
{
roomInstanceId = r.RoomInstanceId,
roomId = r.RoomId,
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
isFull = r.IsFull,
createdAt = r.CreatedAt,
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
};
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs /// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method /// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
@@ -24,6 +24,7 @@ public class MatchPlayerController(
RoomService rooms, RoomService rooms,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
PresenceBroadcastService presenceFanout,
IConfiguration config, IConfiguration config,
DorkNetDbContext db, DorkNetDbContext db,
ILogger<MatchPlayerController> log) : ControllerBase ILogger<MatchPlayerController> log) : ControllerBase
@@ -68,17 +69,15 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push", "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
logScope, playerId, takeOverExclusiveSession); logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet // The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's // (negotiate fires AFTER /player/login on the boot
// boot sequence — verified in the live trace), so the // sequence — verified in the live trace), so the Logout
// ModerationKick push hits only the OLDER session's // push (Reason SessionTakeover) reaches only the OLDER
// existing connections. They'll show the "you've been // session, which shows the client's own "signed in
// kicked" dialog and gracefully disconnect via the // elsewhere" message and returns to the boot scene.
// watch's LocalModerationKickSelf path. await notifications.LogoutStaleSession(playerId);
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
} }
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
@@ -87,13 +86,20 @@ public class MatchPlayerController(
/// <summary> /// <summary>
/// POST /player/logout — fires when the client gives up on a session /// POST /player/logout — fires when the client gives up on a session
/// (e.g. Photon connection couldn't be established, client aborts). /// (e.g. Photon connection couldn't be established, client aborts).
/// Clear the cached presence so the next /goto starts fresh. /// Clear the cached presence so the next /goto starts fresh, and tell
/// friends right away — otherwise they keep seeing the player in their
/// last room until a bulk poll happens to notice.
/// </summary> /// </summary>
[HttpPost("/player/logout")] [HttpPost("/player/logout")]
public IActionResult PlayerLogout() public async Task<IActionResult> PlayerLogout()
{ {
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (playerId != 0) presence.Clear(playerId); if (playerId != 0)
{
joinTimeouts.Cancel(playerId, "logout");
presence.Clear(playerId);
await presenceFanout.PublishOfflineAsync(playerId);
}
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -178,7 +184,15 @@ public class MatchPlayerController(
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value) public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
{ {
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString()); if (value is int v)
{
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
// Friends only. The client does not apply this response to its own
// cached presence, and a PresenceUpdate about the local player runs
// the same apply-and-rejoin path as an out-of-sync heartbeat
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
await presenceFanout.PublishCurrentAsync(playerId);
}
var room = await GetPresenceRoomForResponseAsync(playerId); var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto return Ok(new PresenceDto
{ {
@@ -212,7 +226,7 @@ public class MatchPlayerController(
// back in the response and was then dropped, so the setting reverted on the // back in the response and was then dropped, so the setting reverted on the
// next heartbeat and never survived a restart. They live in PlayerSettings // next heartbeat and never survived a restart. They live in PlayerSettings
// (no schema change needed) and are read back wherever presence is built. // (no schema change needed) and are read back wherever presence is built.
private const string PrefStatusVisibility = "pref:statusVisibility"; private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
private const string PrefVrMovementMode = "pref:vrMovementMode"; private const string PrefVrMovementMode = "pref:vrMovementMode";
private const string PrefAvoidJuniors = "pref:avoidJuniors"; private const string PrefAvoidJuniors = "pref:avoidJuniors";
@@ -395,6 +409,21 @@ public class MatchPlayerController(
if (room is null) if (room is null)
{ {
// The player went to the offline room (matchmake/none). Their
// client now holds RoomInstanceId -1, which the heartbeat compare
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
// sync with a null roomInstance. The dorm fallback below would
// read as out-of-sync and drag the player out of the offline room.
if (presence.IsInNoRoom(playerId))
{
presence.TouchNoRoom(playerId);
log.LogInformation(
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
playerId);
return null;
}
if (presence.IsBootDormGraceActive(playerId)) if (presence.IsBootDormGraceActive(playerId))
{ {
log.LogInformation( log.LogInformation(
@@ -426,6 +455,7 @@ public class MatchPlayerController(
playerId, fallback.RoomId, fallback.DataBlob); playerId, fallback.RoomId, fallback.DataBlob);
presence.SetRoom(playerId, fallback); presence.SetRoom(playerId, fallback);
presence.MarkActive(playerId); presence.MarkActive(playerId);
await presenceFanout.PublishCurrentAsync(playerId);
return fallback; return fallback;
} }
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId); log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match; namespace DorkNet.Server.Controllers.Match;
@@ -36,10 +38,19 @@ namespace DorkNet.Server.Controllers.Match;
/// </summary> /// </summary>
[ApiController] [ApiController]
public class PlayerPresenceController( public class PlayerPresenceController(
PlayerPresenceService presence) : ControllerBase PlayerPresenceService presence,
PresenceBroadcastService presenceFanout,
JoinTimeoutService joinTimeouts,
DorkNetDbContext db,
ServerSettingsService settings) : ControllerBase
{ {
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
/// round-trip is pinned by a regression test).</summary>
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
[HttpGet("/player")] [HttpGet("/player")]
public ActionResult<List<PlayerPresenceDto>> GetPresences() public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
{ {
// The watch sends two URL flavours for this endpoint: // The watch sends two URL flavours for this endpoint:
// 1. Bulk: <c>/player?{id1}&amp;{id2}&amp;{id3}</c> — bare keys, // 1. Bulk: <c>/player?{id1}&amp;{id2}&amp;{id3}</c> — bare keys,
@@ -81,31 +92,39 @@ public class PlayerPresenceController(
// mid-coroutine. Only redact for OTHER players where the // mid-coroutine. Only redact for OTHER players where the
// privacy concern actually applies. // privacy concern actually applies.
var callerId = this.CurrentPlayerId(); var callerId = this.CurrentPlayerId();
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null; var callerRoom = callerId is long cid ? SelfRoom(cid) : null;
var visibility = await LoadVisibilityAsync(ids);
var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility);
var result = ids.Select(id => var result = ids.Select(id =>
{ {
var room = presence.GetRoom(id);
// Other players need both a room presence and an active
// live signal. Use the recent-heartbeat marker as the
// truth source: SignalR can drop while the player is still
// heartbeating, or linger after the game is gone. Presence
// keys intentionally live for minutes to survive long room
// loads, so using them alone leaves friends stuck online
// after a graceful disconnect.
var isSelf = callerId == id; var isSelf = callerId == id;
var active = isSelf || presence.IsRecentlyActive(id); // The caller's own row must say exactly what their heartbeat
var isOnline = room is not null && active; // says (pending-join target, or null in the offline room): the
// client compares its cached self presence field by field
// (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as
// out-of-sync.
var room = isSelf ? callerRoom : presence.GetRoom(id);
// Online = a live signal (recent heartbeat, or a SignalR
// connection), NOT "has a room". Presence keys live for minutes
// to survive long room loads, so a room alone would leave friends
// stuck online after the game is gone; and a player in the offline
// room after matchmake/none has no room but is very much online
// (the 2023 DTO carries isOnline on its own; see
// NotificationService.BuildPresenceUpdatePayload).
// A player who chose "appear offline" (or friends/favorites
// only, when the caller doesn't qualify) is reported exactly
// like a genuinely offline player: no room, isOnline=false.
var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id);
if (!isOnline) room = null;
return new PlayerPresenceDto return new PlayerPresenceDto
{ {
PlayerId = (int)id, PlayerId = (int)id,
// StatusVisibility is the player's *preference* for who may // The player's stored online-status visibility. Self MUST
// see them online. The enum is Everyone=0 / FriendsOnly=1 / // see its own stored value: the profile dialog's current
// Nobody=2 — so the previous value 1 (mislabeled "Visible") // selection is read from the cached self presence, and a
// actually meant FriendsOnly, and anyone who wasn't your // hard-coded 0 here snapped it back to Public after every
// friend saw you as OFFLINE. On a private server everyone // presence refresh.
// should see everyone, so report Everyone (0). IsOnline=false StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public),
// still gates the "Online" badge for offline players.
StatusVisibility = 0, // Everyone
DeviceClass = 0, // Desktop / unknown — not VR DeviceClass = 0, // Desktop / unknown — not VR
VrMovementMode = 0, VrMovementMode = 0,
// Full instance details are safe, and necessary, when the // Full instance details are safe, and necessary, when the
@@ -124,6 +143,58 @@ public class PlayerPresenceController(
return Ok(result); return Ok(result);
} }
/// <summary>The caller's own room as their heartbeat reports it.</summary>
private RoomInstanceDto? SelfRoom(long playerId)
{
if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending)
return pending.TargetRoom;
if (presence.IsInNoRoom(playerId)) return null;
return presence.GetRoom(playerId);
}
private Task<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
/// <summary>Ids whose online status the caller may not see. The caller
/// always sees itself; anonymous callers only see Public players.</summary>
private async Task<HashSet<long>> ResolveHiddenAsync(
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
{
var hidden = new HashSet<long>();
var restricted = visibility
.Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId)
.ToList();
if (restricted.Count == 0) return hidden;
if (callerId is not long me)
{
foreach (var kv in restricted) hidden.Add(kv.Key);
return hidden;
}
HashSet<long>? friends = null;
HashSet<long>? favoritedMe = null;
foreach (var (target, vis) in restricted)
{
if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; }
friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet();
if (!friends.Contains(target)) { hidden.Add(target); continue; }
if (vis == PresenceVisibility.FavoriteFriendsOnly)
{
// Favorited lives on the favoriter's own row
// (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it.
favoritedMe ??= (await db.Relationships
.Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId))
.Select(r => r.RequesterId)
.ToListAsync()).ToHashSet();
if (!favoritedMe.Contains(target)) hidden.Add(target);
}
}
return hidden;
}
private static bool ShouldExposeFullRoom( private static bool ShouldExposeFullRoom(
long? callerId, long? callerId,
RoomInstanceDto? callerRoom, RoomInstanceDto? callerRoom,
@@ -151,8 +222,8 @@ public class PlayerPresenceDto
[JsonPropertyName("playerId")] [JsonPropertyName("playerId")]
public int PlayerId { get; set; } public int PlayerId { get; set; }
/// <summary>PlayerStatusVisibility enum (int): /// <summary>Online-status visibility enum (int, 2023 client):
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary> /// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
[JsonPropertyName("statusVisibility")] [JsonPropertyName("statusVisibility")]
public int StatusVisibility { get; set; } public int StatusVisibility { get; set; }
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
/// envelope the 2023 rooms client reads for every room and playlist mutation.
///
/// <para><b>Client contract.</b> All of these calls go through one generic
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;(string opName, HTTPMethods,
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
/// called as <c>Call 0x1835E50A0</c> 47 times in
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
/// same <c>BDBHCEGKGDC&lt;FGCPNAACHIK&gt;</c> instantiation token
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
/// clone is proven in game to need <c>{success, value, error}</c>
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
/// <c>success</c>, so the helper rejects it with no message and the player sees
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
/// applied the change. The routes are: rooms/{0}/{accessibility,
/// allow_new_users, automute, cloning, comments, creator, description, image,
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
/// name, restrictions, rooms/{1}, tags, warning}.</para>
///
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
/// with a message the client can show, because a non-2xx reply also surfaces
/// only as the message-less CEMNLBKJABA.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.Result = Wrap(context.Result);
base.OnResultExecuting(context);
}
public static IActionResult Wrap(IActionResult result)
{
switch (result)
{
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
return IsEnvelope(obj.Value)
? result
: Success(obj.Value);
case ObjectResult obj:
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
case ForbidResult or ChallengeResult or UnauthorizedResult:
return Failure(DefaultError(403));
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
return Success(null);
case StatusCodeResult code:
return Failure(DefaultError(code.StatusCode));
case EmptyResult:
return Success(null);
default:
return result;
}
}
public static OkObjectResult Success(object? value) =>
new(new { success = true, value, error = string.Empty });
public static OkObjectResult Failure(string error) =>
new(new { success = false, value = (object?)null, error });
private static string DefaultError(int status) => status switch
{
400 => "That request was not valid.",
401 or 403 => "You don't have permission to do that.",
404 => "That room doesn't exist.",
409 => "That name is already taken.",
_ => "Something went wrong.",
};
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
private static string? ErrorText(object? value)
{
if (value is null) return null;
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
try
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.String)
{
var text = prop.Value.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
}
catch (NotSupportedException)
{
}
return null;
}
private static bool IsEnvelope(object? value)
{
if (value is null) return false;
var props = value.GetType().GetProperties();
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return Has("success") && Has("value") && Has("error");
}
}
@@ -0,0 +1,36 @@
using DorkNet.Models.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
///
/// A lot of the client's fire-and-forget writes look status-only from their
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
/// the <c>&lt;ExpandRecNetResult&gt;b__2_0</c> lambda
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
/// <list type="bullet">
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
/// shows "Failed to …" although the server applied it;</item>
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
/// null</c> (seen in Player.log as an unobserved task exception).</item>
/// </list>
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
/// success, and carry a non-null <c>error</c> on a 200 failure.
/// </summary>
public static class RecNetResults
{
public static IActionResult Ok() =>
new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty });
public static IActionResult Fail(string error) =>
new OkObjectResult(new RecNetResult { Success = false, Error = error });
}
@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// The client builds CI publishes, for the site's Downloads page and the
/// Quest page's "latest build" install. Files are streamed from the downloads
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
/// installer can read an .xapk's zip directory and entries without fetching
/// the whole file. Only files the manifest lists are served.
///
/// The builds contain Rec Room's own game files, so the routes answer only on
/// the site's own host (the apex) and 404 on every other host the server
/// accepts: the game's API/CDN subdomains are typically left open for the
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1/downloads")]
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
{
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
/// signed-in, linked player may read the builds.</summary>
private async Task<bool> SignedInOrOpenAsync() =>
!downloads.RequireSignIn
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
[HttpGet]
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
.ToJsonString(), "application/json");
}
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
/// streamed; honours Range (206).</summary>
[HttpGet("files/{**path}")]
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
Response.StatusCode = (int)upstream.StatusCode;
if (!upstream.IsSuccessStatusCode) return;
var content = upstream.Content.Headers;
if (content.ContentLength is long length) Response.ContentLength = length;
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
Response.Headers.CacheControl = "private, no-cache";
Response.ContentType = "application/octet-stream";
var name = (string?)entry["name"] ?? Path.GetFileName(path);
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
await body.CopyToAsync(Response.Body, 1 << 16, ct);
}
}
@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
/// for the website and the admin panel. The game client's login is separate.
///
/// <para>Flow: <c>login</c> challenges the provider; its callback
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
/// middleware) stores the provider identity in the short-lived
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
/// website's <c>/link</c> page, where the player proves the account with its
/// password once (<c>account/link</c>).</para>
///
/// <para>Each host signs in on its own (website and admin panel), because the
/// hyphen host style puts them under different cookie domains.</para>
/// </summary>
[ApiController]
[AllowAnonymous]
public class ExternalSignInController(
IOptions<ExternalSignInOptions> options,
ExternalSignInService signIn,
LinkAttemptLimiter limiter,
DomainConfig domain,
ILogger<ExternalSignInController> log) : ControllerBase
{
private ExternalSignInOptions Options => options.Value;
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
[HttpGet(ExternalSignIn.LoginPath)]
public IActionResult Login([FromQuery] string? returnUrl)
{
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
}
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
[HttpGet(ExternalSignIn.CompletePath)]
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
{
returnUrl = LocalUrl(returnUrl);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
var (issuer, subject, email) = identity;
var link = await signIn.FindAsync(issuer, subject);
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
if (link is not null && player is not null)
{
await signIn.TouchAsync(link, email);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
return Redirect(returnUrl);
}
if (link is not null)
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
// Not linked yet. Linking happens on the website; the admin panel
// tells its user to link there first.
if (IsAdminHost())
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
/// provider identity is waiting to be linked.</summary>
[HttpGet("api/site/v1/account/me")]
public async Task<IActionResult> Me()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
return Ok(new
{
signInAvailable = Options.IsConfigured,
provider = Options.DisplayName,
signedIn = web.Succeeded,
player = web.Succeeded
? new
{
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
username = web.Principal!.Identity?.Name,
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
}
: null,
pendingLink = pending is { } p ? new { email = p.Email } : null,
});
}
public sealed record LinkRequest(string? Username, string? Password);
/// <summary>POST /api/site/v1/account/link {username, password} — link the
/// pending provider identity to the account these credentials prove.</summary>
[HttpPost("api/site/v1/account/link")]
public async Task<IActionResult> Link([FromBody] LinkRequest body)
{
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
return Unauthorized(new { error = "no_pending_identity" });
var (issuer, subject, email) = identity;
var username = body.Username?.Trim() ?? string.Empty;
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
return BadRequest(new { error = "missing_credentials" });
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
if (limiter.IsBlocked(keys))
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
switch (outcome)
{
case LinkOutcome.Linked:
limiter.Reset(keys);
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
return Ok(new { linked = true, username = player.Username });
case LinkOutcome.InvalidCredentials:
limiter.RecordFailure(keys);
return Unauthorized(new { error = "invalid_credentials" });
case LinkOutcome.Banned:
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
default:
return Conflict(new { error = "identity_linked_to_another_account" });
}
}
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
/// from the account and sign out.</summary>
[HttpPost("api/site/v1/account/unlink")]
public async Task<IActionResult> Unlink()
{
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
return Unauthorized(new { error = "not_signed_in" });
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
await signIn.UnlinkAsync(playerId, issuer);
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
return Ok(new { unlinked = true });
}
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
[HttpPost("api/site/v1/account/logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
return Ok(new { signedOut = true });
}
/// <summary>(issuer, subject, email) from the provider's claims. The
/// subject is the account key; the email is display only.</summary>
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
{
var subject = p?.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(subject)) return null;
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
}
private string LocalUrl(string? url) =>
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
private static string WithQuery(string url, string key, string value) =>
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
private bool IsAdminHost() =>
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
}
@@ -65,6 +65,12 @@ public class StorageController(
[HttpPost("/upload", Order = -2000)] [HttpPost("/upload", Order = -2000)]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
// a big circuits room now that the served CircuitsV2 chip limit is 30000
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
// import endpoints and Cloudflare's own per-request ceiling.
[RequestSizeLimit(100_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
public async Task<IActionResult> Upload() public async Task<IActionResult> Upload()
{ {
// Form parsing — ASP.NET Core deserialises the multipart body // Form parsing — ASP.NET Core deserialises the multipart body
@@ -15,4 +15,21 @@ public class StringsController : ControllerBase
[HttpGet("/strings/v1/{locale}")] [HttpGet("/strings/v1/{locale}")]
public ActionResult<Dictionary<string, string>> GetLocale(string locale) public ActionResult<Dictionary<string, string>> GetLocale(string locale)
=> Ok(new Dictionary<string, string>()); => Ok(new Dictionary<string, string>());
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
/// Two GETs share the path <c>/strings</c> on the strings host:
/// <list type="bullet">
/// <item><c>?locale=&amp;scopePrefix=client.&amp;eTag=</c> (FMLBOAOBKPM, :531) →
/// <c>Dictionary&lt;string, Dictionary&lt;long,string&gt;&gt;</c>, scope → id → text;</item>
/// <item><c>?locale=&amp;scope=client.{name}&amp;eTag=</c> (BNEELKAKCEN, :3) →
/// <c>Dictionary&lt;long,string&gt;</c>.</item>
/// </list>
/// The localisation service stores the first answer
/// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup
/// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty
/// table for a scope the server did not send. DorkNet keeps no server-side string
/// overrides, so the truthful answer is an empty JSON object for either query —
/// the lookups then fall back to the bundled text. The path used to 404.</summary>
[HttpGet("/strings")]
public IActionResult GetServerStrings() => Content("{}", "application/json");
} }
+21 -1
View File
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Data; namespace DorkNet.Server.Data;
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options) public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
{ {
public DbSet<PlayerEntity> Players => Set<PlayerEntity>(); public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>(); public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
@@ -11,6 +12,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>(); public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
public DbSet<RoomEntity> Rooms => Set<RoomEntity>(); public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>(); public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>(); public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>(); public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>(); public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
@@ -87,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>(); public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>(); public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>(); public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
// database so a redeploy or a second replica doesn't sign everyone out.
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<ExternalLoginEntity>(e =>
{
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
e.HasIndex(x => x.PlayerId);
});
// SQLite-specific NOCASE collation works via UseCollation; Postgres // SQLite-specific NOCASE collation works via UseCollation; Postgres
// doesn't have NOCASE. Provider-conditional helper applied to the // doesn't have NOCASE. Provider-conditional helper applied to the
// three string columns the wire API matches case-insensitively // three string columns the wire API matches case-insensitively
@@ -134,6 +146,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique(); e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
}); });
// One cell per (room, sub-room, role, permission): the sub-room
// permissions PUT upserts the cells it was sent.
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
{
e.HasKey(p => p.Id);
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
});
modelBuilder.Entity<ClubAdditionalImageEntity>(e => modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
{ {
e.HasKey(i => i.Id); e.HasKey(i => i.Id);
@@ -0,0 +1,29 @@
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// An identity from the external sign-in provider (OpenID Connect) linked to a
/// DorkNet account. One row per (Issuer, Subject): the provider's stable user
/// id, never the email, which can change or be reused. A player proves they own
/// the account once, with its password, when the link is made
/// (<c>ExternalSignInController.Link</c>); after that the provider alone signs
/// them in to the site and, for admins, the admin panel.
/// </summary>
public class ExternalLoginEntity
{
public long Id { get; set; }
public long PlayerId { get; set; }
/// <summary>The provider's issuer URL (the <c>iss</c> claim).</summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>The provider's user id (the <c>sub</c> claim).</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>The email the provider reported when the link was made or last
/// used. Shown to the player; never used to find the account.</summary>
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
}
@@ -116,6 +116,29 @@ public class PlayerEntity
[MaxLength(32)] [MaxLength(32)]
public string? Phone { get; set; } public string? Phone { get; set; }
// ── 2023 profile fields ──────────────────────────────────────────────
// Written by PUT account/me/{emoji,bannerimage,personalpronouns,
// identityflags} and read back on every account DTO (account/me,
// account/{id}, account/bulk, search, AccountUpdate push) under the
// keys displayEmoji / bannerImage / personalPronouns / identityFlags.
/// <summary>Emoji shown beside the display name ("" = none).</summary>
[MaxLength(32)]
public string DisplayEmoji { get; set; } = string.Empty;
/// <summary>Profile banner image name (a storage image the player
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
[MaxLength(256)]
public string? BannerImageName { get; set; }
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
public int PersonalPronouns { get; set; } = 0;
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
/// Transgender=2, Bisexual=4, …).</summary>
public int IdentityFlags { get; set; } = 0;
// Navigation // Navigation
public AvatarEntity? Avatar { get; set; } public AvatarEntity? Avatar { get; set; }
public List<PlayerSettingEntity> Settings { get; set; } = []; public List<PlayerSettingEntity> Settings { get; set; } = [];
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace DorkNet.Server.Data.Entities;
/// <summary>
/// One cell of a sub-room's role-permission matrix, as written by
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
///
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
///
/// The editor sends only the cells it changed (the room-permissions runtime
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.&lt;LocalRequestModifyRolePermission&gt;g__ChangeAndRefresh</c>),
/// so the write is an upsert per (room, sub-room, role, permission) and never
/// a replace of the whole matrix.
/// </summary>
public class RoomRolePermissionEntity
{
public long Id { get; set; }
public long RoomId { get; set; }
/// <summary>The wire sub-room id from the route (the same value the room
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
public long SubRoomId { get; set; }
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
public int Role { get; set; }
/// <summary>The permission id exactly as the client sent it.</summary>
[MaxLength(128)]
public string Permission { get; set; } = string.Empty;
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
public int Type { get; set; }
public bool Override { get; set; }
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
[MaxLength(4096)]
public string Value { get; set; } = string.Empty;
public long UpdatedByPlayerId { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -108,5 +108,14 @@ public class ServerSettingsEntity
/// admins curate the base-room list from the SPA without a redeploy.</summary> /// admins curate the base-room list from the SPA without a redeploy.</summary>
public string BaseRoomNamesJson { get; set; } = string.Empty; public string BaseRoomNamesJson { get; set; } = string.Empty;
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
/// client as the <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. The
/// network entry carries the per-frame RPC and byte budgets a circuit
/// may spend; DorkNet ships them effectively unlimited, where retail
/// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so
/// new limit keys don't need a table migration.
/// See <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+13 -1
View File
@@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
| Verb | URL | Status | Handler | | Verb | URL | Status | Handler |
|---|---|---|---| |---|---|---|---|
| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. | | GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). |
### `api/groups/` ### `api/groups/`
@@ -391,6 +391,18 @@ These all live on `match.rec.net` (locally: `match.localhost`).
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it | | POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case | | POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
2023 additions on the auth / accounts / strings hosts:
| Verb | URL | Status | Handler |
|---|---|---|---|
| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use |
| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use |
| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB<String>`) |
| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult |
| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list |
| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 |
| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` |
### CDN host (`cdn.rec.net` / `storage` / `data`) ### CDN host (`cdn.rec.net` / `storage` / `data`)
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host). Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).

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