Compare commits

..

32 Commits

Author SHA1 Message Date
Alexa 96628baebe Migrate auth to Open.IdentityServer 2026-06-30 15:47:26 +03:00
Alexa df4842ba55 Handle signing certificate decode failures 2026-06-30 14:45:16 +03:00
Alexa 8cb0db87f7 Harden signing certificate base64 parsing 2026-06-30 14:35:09 +03:00
Alexa 8bb73dcdb1 Use native game auth client only 2026-06-30 14:29:55 +03:00
Alexa 995c3a90dd Support native game auth client 2026-06-30 14:16:33 +03:00
Alexa 37be87ed85 Restore device password grant compatibility 2026-06-30 14:06:00 +03:00
Alexa e9d13069e2 Support env-based signing certificate 2026-06-30 13:54:56 +03:00
Alexa 78669ee11b Merge latest March 2020 base 2026-06-30 13:36:22 +03:00
Alexa c0e7254aa1 Migrate server auth to IdentityServer 2026-06-30 13:27:54 +03:00
Alexa baa17c0fd6 Fix March admin API path in easy mode
Fixes #22 by keeping admin API calls under the /__dn/admin prefix when the March admin panel is served through easy mode.
2026-06-22 12:20:46 +03:00
Alexa 307ce93646 ci(release): grant contents:write to the release workflow (#17)
The workflow creates a GitHub release and uploads assets, which needs the contents:write token scope. Declaring it per-workflow (matching release-launcher.yml on main) lets the repo keep a read-only default GITHUB_TOKEN.
2026-06-16 11:56:35 +03:00
Alexa ecaf17b764 Merge pull request #2 from DorkSquadRR/modernize-march-startup
Update project links
2026-06-15 14:07:36 +03:00
Alexa 4985bb09d4 Update project links 2026-06-15 11:50:13 +03:00
Alexa a5b00980a5 Merge pull request #1 from DorkSquadRR/modernize-march-startup
Modernize March server startup
2026-06-15 11:44:06 +03:00
Alexa 32cd60792f Modernize March server startup 2026-06-15 11:36:36 +03:00
Alexa 955246c6e4 feat: Refactor admin UI pages to support embedded mode and improve navigation
- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts.
- Introduced a new Activity page consolidating audit logs and player request logs with tab navigation.
- Added Content page for community board and loading tips with tab navigation.
- Created Moderation page to unify player directory, bans, and reports under a single interface.
- Implemented SettingsHome page to manage server settings and signup codes with tab navigation.
- Enhanced the Join page to include password creation for new accounts.
- Added global friends feature in Settings to allow all accounts to be friends without database entries.
- Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle.
- Added WireDates for consistent date handling in serialized data.
- Created reusable Tabs component for consistent tab navigation across various pages.
2026-06-06 16:39:19 +03:00
Alexa 34506e8294 admin: change a player's username from the Players panel
Add POST players/{id}/username + a Username field in the player detail's
Profile tab. Changes only the unique @handle (not the display name) and
rejects a collision outright rather than auto-suffixing, so the admin
gets the exact handle they typed. Same 2-24 char letters/digits/_/-
rule as the account API; logged to the audit trail as old -> new.
2026-05-31 20:52:11 +03:00
Alexa 11377b46d5 signup codes: admin-issued invite codes + site /join redemption
Port the December signup-code feature to the 2020.03 branch.

While account creation is disabled, the only way in is now an
admin-issued single-use signup code, redeemed on the public site's /join
page. Because the 2020.03 watch login is device-id-bound with no code
field, a new device refused at the signups-disabled gate (PlatformLogin /
account create) is stashed in a PendingDevices table keyed by the real
client IP (CF-Connecting-IP aware). The /join page shows the player the
device their own client just reported; they enter code + username and the
account is minted bound to that device, so the next launch logs straight
in. No watch changes.

- SignupCodeEntity + PendingDeviceEntity; tables created via idempotent
  CREATE TABLE IF NOT EXISTS at boot (Program.cs Postgres patch block +
  SQLite path) since they post-date the migration chain.
- SignupCodeService: generate / list / revoke / redeem + pending-device
  capture (single-use, expiry, revoke validation).
- Admin: GET/POST signup-codes + revoke endpoints and a Signup codes
  admin-ui page (generate with descriptor/expiry, status table, revoke).
- Public: anonymous /api/site/v1/join pending-devices + redeem, and a
  /join site page (+ header link).
- README documents the flow.
2026-05-31 20:08:33 +03:00
Alexa a840355489 site: load images from own img.{apex}; add Bowling/Paintball art
The public site built room/profile image URLs against a hardcoded
img.rec.net fallback for any non-localhost host, so thumbnails loaded
from real Rec Room (Azure-fronted — hence the TiPMix / x-ms-routing-name
cookies the browser rejected) and DorkNet custom-room images could never
display. Derive the CDN apex from the live host instead (shared
imageApex helper in types.ts, used by profileImageUrl and Rooms.tsx) so
images come from this server's own img.{apex}.

Add the two RR-Original thumbnails as static CDN files in data/images so
img.{apex} serves them directly: image_BowlingAlley.png and
image_PaintballKeyArt.png (both real PNGs).
2026-05-31 15:47:12 +03:00
Alexa b26ae93014 inventions: resolve real creator ids for a room (creatorIds)
api/inventions/v1/creatorIds was a stub returning an empty list, so the
2020.03 watch's GetCreatorIdsOfInventionsInRoom never learned which
creators it must request invention permissions for before spawning a
room's invention-based geometry. Resolve the real set from
InventionEntity.CreationRoomId via a shared ResolveRoomInventionsAsync
helper: direct matches (created in-place) unioned with the imported
cluster (most-frequent non-local CreationRoomId among the room creator's
inventions, since zip-imported inventions keep their original Rec Room
room id). Returns List<int> of distinct creator account ids.
2026-05-31 15:32:16 +03:00
Alexa f6a4fdc5fb weekly challenges: admin-configurable slate + working skin rewards
Port the admin-editable weekly-challenge feature to the 2020.03 branch
and fix the skin-reward bug it had on December: rewards now actually
land in the player's inventory instead of only granting XP + tokens.

Server config: add WeeklyChallenges* columns to ServerSettings (Postgres
post-EnsureCreated patches + SQLite column patches), and port the
ServerSettingsService weekly methods/records. The reward record carries
the store item Slug — the reliable handle December lacked — so the grant
can resolve the real item.

ProgressionController: CurrentChallenge + updateProgress now read the
configured slate and reward instead of a hardcoded one, keeping the
2020.03 ChallengeMap/ChallengeGift wire shapes (verified against the
march dump: ChallengeGift TypeDefIndex 11455). The Gift now sends
AvatarItemType (Outfit when an AvatarItemDesc is set) so the watch's
gift-card render binds the avatar preview. On completing the week's
challenges the configured skin/consumable is granted via the new
StoreService.GrantItemFreeBySlugAsync (same inventory path as a purchase,
no charge), gated behind the once-only reward sentinel.

Admin: GET/POST settings/weekly-challenges + reward-options (avatar +
consumable only — 2020.03 StoreService has no equipment payload helper),
and the admin-ui Server settings page to edit the slate + reward. The
reward picker keys on item slug rather than December's fragile
descriptor-string match.

Docs: README no longer lists weekly challenges as December-only;
url-coverage notes the config-driven challenge endpoints + skin grant.
2026-05-31 14:47:12 +03:00
Alexa 1eba8d9345 site/admin-ui: relative asset base for single-origin path prefix
Use base './' so built index.html references ./assets/... instead of
/assets/..., letting the SPAs work under the Easy Launcher's
single-origin path prefix (https://<apex>/__dn/admin/) as well as the
subdomain layout. Root-absolute paths would skip the prefix and 404.
2026-05-31 12:33:16 +03:00
Alexa a9518b08ab clientmod: parameterize EnableTlsTrustBypass via template placeholder
Render the flag from {ENABLE_TLS_TRUST_BYPASS} instead of hardcoding
true, so the launcher's opt-in toggle controls it. Off by default
since Localtunnel/Tunnelto serve valid certs.
2026-05-31 12:33:16 +03:00
Alexa 82c6f72219 Remove BepInEx client patcher (DorkNet.ClientPatch)
MelonLoader (DorkNet.ClientMod) is the sole client patcher now:

- delete the DorkNet.ClientPatch project + tools/install-plugin.ps1
- drop the project from DorkNet.sln
- README / deploy docs: present DorkNet.ClientMod (install-melon.ps1)
  as the patcher instead of the BepInEx plugin
- drop the dead install-plugin.ps1 reference from Program.cs's JWT
  error and install-melon.ps1's DRM-fallback hint
- tidy stray 'BepInEx port' comments

Legacy-cleanup paths that REMOVE an old BepInEx install
(install-legacy-client.ps1, build-patcher-zip.ps1's old_plugin_paths)
are kept intentionally.
2026-05-30 02:12:00 +03:00
Alexa 80cc4ce0bd Eager Photon AppId override via OnUpdate poll
The harmony hook on PhotonNetwork.ConnectUsingSettings registers in
March's IL2CPP build but never actually fires — the watch's connect
path goes through a different (mangled) entry point. Log confirms
[patch-ok] PhotonNetwork.ConnectUsingSettings but no
[photon-appid] override line, ever.

Fix moves the override out of the prefix-only path:

- New PhotonPatches.TryApplyOverride(reason) does the
  PhotonServerSettings.AppID / VoiceAppID / region mutation and
  flips an OverrideApplied flag once it succeeds. Idempotent.
- OnUpdate polls TryApplyOverride every 6th frame (~10 Hz) until
  it succeeds, then stops. The "type not found yet" log is
  throttled to once on first miss + every 300th miss after, so
  the file doesn't drown if PhotonServerSettings stays unloaded.
- Mod.ResolveType made internal (was private) so PhotonPatches
  + SavePatches can use the broader assembly-scan fallback —
  the local FindGameType copies were too narrow and missed
  IL2CPP-mangled type names. Both FindGameType helpers collapse
  to one-line delegates to Mod.ResolveType.
- Prefix on ConnectUsingSettings kept as a belt-and-braces hook
  (passes reason="prefix"); harmless if it fires, fine if it
  doesn't.
2026-05-28 13:46:39 +03:00
Alexa cd9d10586c Park Photon CustomAuth injector
- Photon dashboard has CustomAuth disabled on the AppId now, so the
  watch never needs to attach userid/LoginLock to its Authenticate
  ops. Removed the harmony hook on NetworkingPeer.CallAuthenticate,
  the AuthValuesInjector_Prefix method, the GetCurrentLockToken
  helper, and the InjectAuthValues Cfg field + config load. Bumped
  the [config] log line to drop the InjectAuthValues field.
- Both dorknet-clientmod.json.template copies (source +
  local-releases) lose the "InjectAuthValues" key.
- Code moved verbatim to DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic
  with a header explaining why it was parked and how to restore it.
  The .cs.attic suffix keeps the SDK's auto-globber from compiling it.
2026-05-28 13:21:34 +03:00
Alexa 46e2bb15e4 Single-origin URL rewriting; configurable SQLite path; default InjectAuthValues=false
- ClientMod: add SingleOriginBaseUrl config + URI rewrite path so the
  client can talk to one https://<apex>/__dn/<service>/... base
  instead of demanding wildcard subdomains. ServerHost rewrite still
  the fallback when SingleOriginBaseUrl is empty.
- ClientMod config: InjectAuthValues now defaults to false. The
  server's PhotonCustomAuthController already fail-opens
  (Photon:RequireCustomAuth=false), so anonymous bootstrap is the
  cleaner default and avoids Photon's CustomAuthFailed loop when a
  WAF returns 403 to Photon's probe.
- Server: SQLite path now reads Database:SqlitePath config
  (Database__SqlitePath env). Falls back to <bin>/data/dorknet.db
  so existing Docker / standalone deploys are unchanged. Lets the
  Windows launcher put the db under %APPDATA%\DorkNet.
- Server: DomainConfig + ConfigService updates for the single-origin
  apex.
- .gitignore: exclude local-releases.zip.
2026-05-28 01:39:54 +03:00
Alexa 676dcd09a7 Support local domains and bundled client patching 2026-05-27 18:27:09 +03:00
Alexa ecbf192e47 patcher: install mod to <root>/Mods, mirrors main f9a40ba
MelonLoader 0.6.x scans <install root>\Mods\ for plugin DLLs and
ignores MelonLoader\Mods\, so 'plugin_dest' = 'MelonLoader/Mods'
in the manifest meant DorkNet.ClientMod.dll was never loaded —
Player.log showed '0 Mods loaded.' and the watch booted unpatched.
2026-05-27 13:13:32 +03:00
Alexa bc8bcd99d0 patcher: bundle Goldberg steam_api64.dll stub + steam_appid
Stripping SteamStub DRM with Steamless is necessary but not
sufficient — Rec Room's Plugins/steam_api64.dll still tries to
talk to Steam at runtime and bails when Steam isn't loaded. The
working test installs swap in Goldberg Steam Emulator (~11 MB),
which fakes a logged-in user without needing Steam running.

- DorkNet.ClientMod/steam-stub/ holds the stub DLL + a 7-byte
  steam_appid.txt (Rec Room is Steam app 471710)
- build-patcher-zip.ps1 copies both into the zip and writes a new
  steam_stub block in manifest.json so the launcher knows where to
  install them
- Manifest schema is back-compat: launchers that don't know the
  steam_stub block just ignore it

Without this, the launcher's Steamless step produced a runnable
exe that exited within a second of launch because of the Steam
API failure. With it, the unwrapped exe starts up cleanly.
2026-05-27 13:03:43 +03:00
Alexa 1f407ef7b3 clientmod: build without a local Rec Room install
The mod resolves every game-side type at runtime via
AccessTools.TypeByName, so the IL2CPP interop assemblies
(Assembly-CSharp, Photon3Unity3D, UnityEngine.*) in the csproj were
dead references. Dropped them entirely — only MelonLoader's own
DLLs are actually needed at compile time.

tools/build-patcher-zip.ps1 now downloads MelonLoader BEFORE the
dotnet build and points MelonLoaderDir at the extracted contents,
so a fresh contributor checkout builds the patcher zip cold with
no Rec Room install required. (Previously the csproj defaulted
RecRoomDir to a hardcoded user path and failed for anyone else.)

Verified by deleting bin/obj and running the script from scratch
on a machine without RecRoomDir set — produces a 20 MB zip with
the same manifest/DLL/MelonLoader.zip layout as before.
2026-05-27 11:47:25 +03:00
Alexa d471b418b9 Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
2026-05-27 01:06:12 +03:00
523 changed files with 38732 additions and 244004 deletions
@@ -1,78 +0,0 @@
# Disabled template. GitHub ignores files outside .github/workflows/.
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
name: Endpoint Contract
on:
workflow_dispatch:
jobs:
endpoint-contract:
runs-on: ubuntu-latest
env:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://127.0.0.1:18080
Database__Provider: sqlite
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
S3__Endpoint: ""
S3__AccessKey: ""
S3__SecretKey: ""
PROTOBUF_PROTOC: /usr/bin/protoc
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Install protobuf compiler
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
- name: Build server
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
- name: Build endpoint contract runner
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
- name: Start server
run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness
run: |
for attempt in {1..60}; do
if curl --fail --silent http://127.0.0.1:18080/healthz; then
exit 0
fi
sleep 2
done
cat "$RUNNER_TEMP/dorknet-server.log"
exit 1
- name: Run endpoint contract
run: |
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json"
- name: Upload endpoint contract artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: endpoint-contract
path: |
${{ runner.temp }}/endpoint-contract-report.json
${{ runner.temp }}/dorknet-server.log
- name: Stop server
if: always()
run: |
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
fi
+5 -5
View File
@@ -1,4 +1,4 @@
name: Release (december-2020-12-18)
name: Release (march-2020-03-10)
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
# GitHub Release on tag push. The launcher (on the public main
@@ -9,7 +9,7 @@ name: Release (december-2020-12-18)
on:
push:
tags:
- 'v1-december-*'
- 'v1-march-*'
permissions:
contents: write # create the GitHub release + upload assets
@@ -19,7 +19,7 @@ jobs:
runs-on: windows-latest
env:
VERSION_KEY: december_2020_12_18
VERSION_KEY: march_2020_03_10
steps:
- uses: actions/checkout@v4
@@ -43,10 +43,10 @@ jobs:
Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
- name: Build client patcher zip
run: |
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets
+2 -26
View File
@@ -8,6 +8,7 @@ build/
.vscode/
*.csproj.bak
dist/
local-releases/
## Data / secrets
# Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
@@ -48,7 +49,6 @@ dist$ZIPNAME.zip
## time someone rebuilds. Keep the source committed, regenerate output.
DorkNet.Server/wwwroot/admin/index.html
DorkNet.Server/wwwroot/admin/assets/
DorkNet.Server/wwwroot/admin/brand/
DorkNet.Server/wwwroot/admin/*.map
DorkNet.Server/admin-ui/node_modules/
DorkNet.Server/admin-ui/dist/
@@ -59,33 +59,9 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
## Same reasoning as admin-ui above: hashed output, regenerate per build.
DorkNet.Server/wwwroot/site/index.html
DorkNet.Server/wwwroot/site/assets/
DorkNet.Server/wwwroot/site/brand/
DorkNet.Server/wwwroot/site/*.map
DorkNet.Server/site/node_modules/
DorkNet.Server/site/dist/
DorkNet.Server/site/.vite/
DorkNet.Server/site/tsconfig.tsbuildinfo
steamless/
## Local recovery / cache-mining artifacts produced by tools/recover-inventions.ps1
## and friends. These can run hundreds of MB to multi-GB (HTTPCache snapshots,
## raw .inv bytes staged for re-import, ready-to-import zip bundles). Per-room,
## per-run, regeneratable from the source room zip + a live walkthrough — never
## belongs in source control.
*-cache-snapshot/
*-cache-snapshot-*/
*-recovered-inv/
*-recovered.zip
DorkSquad-recovered/
ProficioVII-recovered/
Recovered.zip
Recovered/
Recovered(*)/
local-releases/**
DorkNet.Models/bin-verify/**
DorkNet.Models/bin-verify2/**
identityserver-signing.pfx
local-releases.zip
+5 -5
View File
@@ -1,15 +1,15 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# 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
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=22
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
# 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
# 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.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& 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.
USER app
-55
View File
@@ -1,55 +0,0 @@
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS admin-ui
WORKDIR /ui
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/admin-ui/ ./
RUN mkdir -p /wwwroot/admin \
&& npm run build
FROM node:${NODE_VERSION}-alpine AS site
WORKDIR /ui
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/site/ ./
RUN mkdir -p /wwwroot/site \
&& npm run build
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG PROJECT_PATH
WORKDIR /src
COPY . .
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
RUN dotnet publish "${PROJECT_PATH}" -c Release \
-p:SkipAdminUIBuild=true \
-p:DorkNetServiceContainerPublish=true \
-o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
ARG APP_DLL
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
USER app
COPY --from=build --chown=app:app /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
APP_DLL=${APP_DLL}
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
-39
View File
@@ -1,39 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Pages;
namespace DorkNet.AdminMobile;
public class App : Application
{
public App(LoginPage loginPage)
{
UserAppTheme = AppTheme.Dark;
MainPage = new NavigationPage(loginPage)
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowAdmin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<AdminTabbedPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowLogin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<LoginPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
}
-120
View File
@@ -1,120 +0,0 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;
namespace DorkNet.AdminMobile.Design;
public static class AppDesign
{
public static readonly Color Canvas = Color.FromArgb("#0f1115");
public static readonly Color Surface = Color.FromArgb("#171a20");
public static readonly Color SurfaceLifted = Color.FromArgb("#20252d");
public static readonly Color RowSurface = Color.FromArgb("#14171d");
public static readonly Color Stroke = Color.FromArgb("#2a3039");
public static readonly Color Text = Color.FromArgb("#f4f6fa");
public static readonly Color Muted = Color.FromArgb("#aeb7c6");
public static readonly Color Subtle = Color.FromArgb("#778294");
public static readonly Color Accent = Color.FromArgb("#64b5ff");
public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72");
public static readonly Color Danger = Color.FromArgb("#ff6f82");
public static Brush PageBackground => new SolidColorBrush(Canvas);
public static Color PlatformAccent =>
#if ANDROID
AndroidAccent;
#else
Accent;
#endif
public static Label Title(string text) => new()
{
Text = text,
TextColor = Text,
FontSize = 22,
FontAttributes = FontAttributes.Bold,
LineBreakMode = LineBreakMode.TailTruncation,
};
public static Label Section(string text) => new()
{
Text = text.ToUpperInvariant(),
TextColor = Subtle,
FontSize = 12,
FontAttributes = FontAttributes.Bold,
Margin = new Thickness(0, 12, 0, 0),
};
public static Border GlassCard(View content, Thickness? padding = null) => new()
{
Padding = padding ?? new Thickness(14),
Stroke = Stroke,
StrokeThickness = 1,
BackgroundColor = Surface,
StrokeShape = new RoundRectangle { CornerRadius = 10 },
Content = content,
};
public static Button PrimaryButton(string text) => new()
{
Text = text,
BackgroundColor = PlatformAccent,
TextColor = Colors.Black,
CornerRadius = 10,
HeightRequest = 44,
FontAttributes = FontAttributes.Bold,
Padding = new Thickness(14, 0),
};
public static Button SecondaryButton(string text) => new()
{
Text = text,
BackgroundColor = SurfaceLifted,
TextColor = Text,
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(14, 0),
};
public static Button DangerButton(string text) => new()
{
Text = text,
BackgroundColor = Color.FromArgb("#4c1d28"),
TextColor = Color.FromArgb("#ffd5dc"),
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(12, 0),
};
public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new()
{
Placeholder = placeholder,
IsPassword = secret,
TextColor = Text,
PlaceholderColor = Subtle,
BackgroundColor = Color.FromArgb("#19202b"),
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
Keyboard = keyboard ?? Keyboard.Text,
HeightRequest = 44,
};
public static Label Caption(string text) => new()
{
Text = text,
TextColor = Muted,
FontSize = 13,
LineBreakMode = LineBreakMode.WordWrap,
};
public static BoxView Divider() => new()
{
HeightRequest = 1,
Color = Stroke,
Opacity = 0.7,
};
public static Label StatusLabel() => new()
{
TextColor = Muted,
FontSize = 13,
};
}
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<RuntimeIdentifiers>android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<PublishTrimmed Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</PublishTrimmed>
<RunAOTCompilation Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</RunAOTCompilation>
<AndroidLinkMode Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">None</AndroidLinkMode>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0B0F14" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0B0F14" BaseSize="256,256" />
<MauiImage Include="Resources\Brand\dorknet.svg" BaseSize="256,256" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
</ItemGroup>
</Project>
-34
View File
@@ -1,34 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
using DorkNet.AdminMobile.Pages;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton<SecureAdminSettings>();
builder.Services.AddSingleton<AdminApiClient>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<AdminTabbedPage>();
builder.Services.AddTransient<DashboardPage>();
builder.Services.AddTransient<PlayersPage>();
builder.Services.AddTransient<RoomsPage>();
builder.Services.AddTransient<SettingsPage>();
var app = builder.Build();
AppServices.Services = app.Services;
return app;
}
}
public static class AppServices
{
public static IServiceProvider Services { get; set; } = default!;
}
-114
View File
@@ -1,114 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.AdminMobile.Models;
public sealed class AdminLoginResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
[JsonPropertyName("account_id")]
public long AccountId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = string.Empty;
}
public sealed class PlayerSummary
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Email { get; set; }
public bool IsAdmin { get; set; }
public bool IsDeveloper { get; set; }
public bool IsVerified { get; set; }
public bool IsJunior { get; set; }
public bool Online { get; set; }
public int Level { get; set; }
public int XP { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? BannedUntil { get; set; }
}
public sealed class RoomSummary
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public long CreatorPlayerId { get; set; }
public int BlobCount { get; set; }
public bool IsAGRoom { get; set; }
public bool IsDormRoom { get; set; }
}
public sealed class InstanceSummary
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public string RoomName { get; set; } = string.Empty;
public string PhotonRoomId { get; set; } = string.Empty;
public string PhotonRegionId { get; set; } = string.Empty;
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = [];
}
public sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsMaster { get; set; }
}
public sealed class ServerSettingsDto
{
public bool SignupsDisabled { get; set; }
public bool GlobalFriendsEnabled { get; set; }
public bool WeeklyChallengesCompletedRequired { get; set; }
public DateTime UpdatedAt { get; set; }
}
public sealed class AdminStats
{
public PlayerStats Players { get; set; } = new();
public RoomStats Rooms { get; set; } = new();
public int Inventions { get; set; }
public PhotoStats Photos { get; set; } = new();
public ModerationStats Moderation { get; set; } = new();
public DateTime ServerTime { get; set; }
}
public sealed class PlayerStats
{
public int Total { get; set; }
public int OnlineNow { get; set; }
public int BannedNow { get; set; }
public int NewToday { get; set; }
}
public sealed class RoomStats
{
public int Total { get; set; }
public long TotalVisits { get; set; }
public long TotalCheers { get; set; }
public int ActiveSessionCount { get; set; }
public int InGamePlayerCount { get; set; }
}
public sealed class PhotoStats
{
public int Today { get; set; }
}
public sealed class ModerationStats
{
public int OpenReports { get; set; }
public int ActiveIpBans { get; set; }
}
@@ -1,23 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
namespace DorkNet.AdminMobile.Pages;
public sealed class AdminTabbedPage : TabbedPage
{
public AdminTabbedPage(
DashboardPage dashboard,
PlayersPage players,
RoomsPage rooms,
SettingsPage settings)
{
BarBackgroundColor = AppDesign.Canvas;
BarTextColor = AppDesign.Text;
SelectedTabColor = AppDesign.PlatformAccent;
UnselectedTabColor = AppDesign.Subtle;
Children.Add(dashboard);
Children.Add(players);
Children.Add(rooms);
Children.Add(settings);
}
}
@@ -1,91 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class DashboardPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Label status = AppDesign.StatusLabel();
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
public DashboardPage(AdminApiClient api)
{
this.api = api;
Title = "Dashboard";
Background = AppDesign.PageBackground;
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Operations"),
refresh,
metrics,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (metrics.Children.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
var s = await api.GetStatsAsync();
DrawMetrics(s);
status.Text = $"Server time {s.ServerTime:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
private void DrawMetrics(AdminStats s)
{
metrics.Children.Clear();
metrics.RowDefinitions.Clear();
metrics.ColumnDefinitions.Clear();
metrics.ColumnDefinitions.Add(new ColumnDefinition());
metrics.ColumnDefinitions.Add(new ColumnDefinition());
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
}
private void AddMetric(int row, int col, string label, string value, string caption)
{
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
var tile = AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 4,
Children =
{
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
},
}, new Thickness(14));
metrics.Add(tile, col, row);
}
}
-137
View File
@@ -1,137 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class LoginPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
private readonly Entry username = AppDesign.Entry("Admin username");
private readonly Entry password = AppDesign.Entry("Password", secret: true);
private readonly Label status = AppDesign.StatusLabel();
private readonly ActivityIndicator busy = new() { IsVisible = false };
private bool loaded;
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "DorkNet Admin";
Background = AppDesign.PageBackground;
var login = AppDesign.PrimaryButton("Sign in");
login.Clicked += OnLogin;
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
continueSaved.Clicked += async (_, _) =>
{
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
else status.Text = "No saved DorkNet admin session is stored.";
};
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = new Thickness(20, 28),
Spacing = 14,
Children =
{
new Image
{
Source = "dorknet.svg",
WidthRequest = 96,
HeightRequest = 96,
HorizontalOptions = LayoutOptions.Start,
},
AppDesign.Title("Admin mobile"),
new Label
{
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
TextColor = AppDesign.Muted,
FontSize = 14,
},
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("Connection"),
baseUrl,
cfClientId,
cfClientSecret,
cfJwt,
},
}),
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("DorkNet admin login"),
username,
password,
login,
continueSaved,
},
}),
busy,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (loaded) return;
loaded = true;
try
{
var saved = await settings.LoadConnectionAsync();
baseUrl.Text = saved.BaseUrl;
cfClientId.Text = saved.CloudflareAccessClientId;
cfClientSecret.Text = saved.CloudflareAccessClientSecret;
cfJwt.Text = saved.CloudflareAccessJwt;
}
catch (Exception ex)
{
baseUrl.Text = "https://admin.rec.net";
status.Text = $"Secure storage was reset: {ex.Message}";
}
}
private async void OnLogin(object? sender, EventArgs e)
{
busy.IsVisible = true;
busy.IsRunning = true;
status.Text = string.Empty;
try
{
await settings.SaveConnectionAsync(new AdminConnectionSettings(
baseUrl.Text ?? string.Empty,
cfClientId.Text ?? string.Empty,
cfClientSecret.Text ?? string.Empty,
cfJwt.Text ?? string.Empty));
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
App.ShowAdmin();
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
busy.IsRunning = false;
busy.IsVisible = false;
}
}
}
-222
View File
@@ -1,222 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class PlayersPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Entry search = AppDesign.Entry("Search players");
private readonly CollectionView list = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private List<PlayerSummary> players = [];
public PlayersPage(AdminApiClient api)
{
this.api = api;
Title = "Players";
Background = AppDesign.PageBackground;
refresh.Clicked += async (_, _) => await LoadAsync();
search.Completed += async (_, _) => await LoadAsync();
list.SelectionMode = SelectionMode.Single;
list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
list.SelectionChanged += async (_, e) =>
{
if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player)
{
list.SelectedItem = null;
await PlayerActionsAsync(player);
}
};
list.EmptyView = new Label
{
Text = "No players loaded",
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
list.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold };
online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online));
online.Text = "ONLINE";
var chevron = new Label
{
Text = ">",
TextColor = AppDesign.Subtle,
FontSize = 18,
VerticalTextAlignment = TextAlignment.Center,
};
var row = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
},
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
RowSpacing = 2,
ColumnSpacing = 10,
BackgroundColor = AppDesign.RowSurface,
};
row.Add(name, 0, 0);
row.Add(online, 1, 0);
row.Add(handle, 0, 1);
row.Add(detail, 0, 2);
row.Add(chevron, 2, 0);
Grid.SetRowSpan(chevron, 3);
return new VerticalStackLayout
{
Spacing = 0,
Children = { row, AppDesign.Divider() },
};
});
var searchRow = new Grid
{
ColumnSpacing = 8,
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
};
searchRow.Add(search, 0, 0);
searchRow.Add(refresh, 1, 0);
var grid = new Grid
{
Padding = 16,
RowSpacing = 12,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition(),
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Players"), 0, 0);
grid.Add(searchRow, 0, 1);
grid.Add(list, 0, 2);
grid.Add(status, 0, 3);
Content = grid;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (players.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
players = await api.GetPlayersAsync(search.Text);
list.ItemsSource = players;
status.Text = $"{players.Count} players loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
private async Task PlayerActionsAsync(PlayerSummary player)
{
var action = await DisplayActionSheet(
$"@{player.Username}",
"Cancel",
"Remove account",
"Kick",
"Reset avatar");
switch (action)
{
case "Kick":
await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin."));
break;
case "Reset avatar":
await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id));
break;
case "Remove account":
await RemoveAccountAsync(player);
break;
}
}
private async Task ConfirmAction(string message, Func<Task> action)
{
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
try
{
await action();
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Request failed", ex.Message, "OK");
}
}
private async Task RemoveAccountAsync(PlayerSummary player)
{
var username = await DisplayPromptAsync(
"Verify username",
$"Type {player.Username} exactly to remove @{player.Username}.",
"Next",
"Cancel",
maxLength: Math.Max(32, player.Username.Length),
keyboard: Keyboard.Text);
if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return;
var phrase = $"DELETE {player.Id}";
var typedPhrase = await DisplayPromptAsync(
"Verify removal",
$"Type {phrase} exactly. This cannot be undone.",
"Remove",
"Cancel",
maxLength: phrase.Length,
keyboard: Keyboard.Text);
if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return;
if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return;
try
{
status.Text = $"Removing @{player.Username}...";
await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin.");
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Removal failed", ex.Message, "OK");
}
}
}
-129
View File
@@ -1,129 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class RoomsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly CollectionView rooms = new();
private readonly CollectionView instances = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private bool loaded;
public RoomsPage(AdminApiClient api)
{
this.api = api;
Title = "Rooms";
Background = AppDesign.PageBackground;
rooms.SelectionMode = SelectionMode.None;
rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
rooms.EmptyView = EmptyList("No recent rooms");
rooms.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
instances.SelectionMode = SelectionMode.None;
instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
instances.EmptyView = EmptyList("No active instances");
instances.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}"));
var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta, count },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
refresh.Clicked += async (_, _) => await LoadAsync();
var grid = new Grid
{
Padding = 16,
RowSpacing = 10,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(2, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Rooms"), 0, 0);
grid.Add(refresh, 0, 1);
grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2);
grid.Add(instances, 0, 3);
grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4);
grid.Add(rooms, 0, 5);
grid.Add(status, 0, 6);
Content = grid;
}
private static Label EmptyList(string text) => new()
{
Text = text,
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
protected override async void OnAppearing()
{
base.OnAppearing();
if (!loaded)
{
loaded = true;
await LoadAsync();
}
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
var instanceRows = await api.GetInstancesAsync();
var roomRows = await api.GetRoomsAsync();
instances.ItemsSource = instanceRows;
rooms.ItemsSource = roomRows;
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
}
-119
View File
@@ -1,119 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class SettingsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Switch signupsDisabled = new();
private readonly Switch globalFriends = new();
private readonly Label status = AppDesign.StatusLabel();
private bool loading;
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "Settings";
Background = AppDesign.PageBackground;
signupsDisabled.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
};
globalFriends.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
};
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
var logout = AppDesign.DangerButton("Clear saved session");
logout.Clicked += (_, _) =>
{
settings.ClearSession();
App.ShowLogin();
};
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Settings"),
refresh,
Row("Disable new account creation", signupsDisabled),
Row("Global friends mode", globalFriends),
logout,
status,
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadAsync();
}
private async Task LoadAsync()
{
try
{
loading = true;
status.Text = "Loading...";
var s = await api.GetSettingsAsync();
signupsDisabled.IsToggled = s.SignupsDisabled;
globalFriends.IsToggled = s.GlobalFriendsEnabled;
status.Text = $"Updated {s.UpdatedAt:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
loading = false;
}
}
private async Task SaveToggleAsync(Func<Task> save)
{
try
{
status.Text = "Saving...";
await save();
status.Text = "Saved";
}
catch (Exception ex)
{
status.Text = ex.Message;
await LoadAsync();
}
}
private static View Row(string label, View control)
{
var grid = new Grid
{
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
};
grid.Add(new Label
{
Text = label,
TextColor = AppDesign.Text,
VerticalTextAlignment = TextAlignment.Center,
}, 0, 0);
grid.Add(control, 1, 0);
return AppDesign.GlassCard(grid, new Thickness(0));
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="false"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:label="DorkNet Admin"
android:supportsRtl="true" />
</manifest>
@@ -1,23 +0,0 @@
using Android.App;
using Android.Content.PM;
using Microsoft.Maui;
namespace DorkNet.AdminMobile;
[Activity(
Label = "DorkNet Admin",
Icon = "@mipmap/appicon",
RoundIcon = "@mipmap/appicon_round",
MainLauncher = true,
Exported = true,
Theme = "@style/Maui.SplashTheme",
ConfigurationChanges =
ConfigChanges.ScreenSize |
ConfigChanges.Orientation |
ConfigChanges.UiMode |
ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize |
ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
@@ -1,13 +0,0 @@
using Android.App;
using Android.Runtime;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Application]
public class MainApplication(nint handle, JniHandleOwnership ownership)
: MauiApplication(handle, ownership)
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,11 +0,0 @@
using Foundation;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>DorkNet Admin</string>
<key>CFBundleIdentifier</key>
<string>net.dorknet.adminmobile</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>14.2</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1,11 +0,0 @@
using UIKit;
namespace DorkNet.AdminMobile;
public static class Program
{
public static void Main(string[] args)
{
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
-45
View File
@@ -1,45 +0,0 @@
# DorkNet Admin Mobile
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
In the microservices deployment, point the app at the public admin host
for the stack, usually `https://admin.<domain>`. Cloudflare and
`DorkNet.Gateway` keep the public URL stable. Requests made through the
admin host are handled by the `web` service's same-origin admin route;
the `/api/admin/*` path family is also exposed by the moderation slice
for non-admin-host routing.
The browser admin page, route map, and troubleshooting flow are
documented in [`../docs/admin.md`](../docs/admin.md).
## Authentication
The app stores sensitive values in platform secure storage:
- DorkNet admin JWT from `POST /api/admin/v1/login`
- Cloudflare Access service-token client id
- Cloudflare Access service-token client secret
- Optional Cloudflare Access JWT assertion
Every API request includes:
```http
Authorization: Bearer <dorknet-admin-jwt>
CF-Access-Client-Id: <service-token-client-id>
CF-Access-Client-Secret: <service-token-client-secret>
CF-Access-Jwt-Assertion: <optional-jwt>
```
The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access.
## Build
Install the .NET MAUI workloads, then build per platform:
```powershell
dotnet workload install maui
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0
```
The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS.
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,147 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.AdminMobile.Models;
namespace DorkNet.AdminMobile.Services;
public sealed class AdminApiClient(SecureAdminSettings settings)
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient http = new()
{
Timeout = TimeSpan.FromSeconds(20),
};
public async Task<AdminLoginResponse> LoginAsync(string username, string password)
{
var result = await SendAsync<AdminLoginResponse>(
HttpMethod.Post,
"login",
new { Username = username, Password = password },
includeJwt: false);
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
return result;
}
public Task<AdminStats> GetStatsAsync() =>
SendAsync<AdminStats>(HttpMethod.Get, "stats");
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
{
var suffix = string.IsNullOrWhiteSpace(query)
? "players?take=100"
: $"players?take=100&query={Uri.EscapeDataString(query)}";
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
}
public Task<List<RoomSummary>> GetRoomsAsync() =>
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
public Task<List<InstanceSummary>> GetInstancesAsync() =>
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
public Task<ServerSettingsDto> GetSettingsAsync() =>
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
public Task KickPlayerAsync(long playerId, string reason) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
public Task ResetAvatarAsync(long playerId) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) =>
SendAsync<object>(
HttpMethod.Delete,
$"players/{playerId}",
new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason });
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
object? body = null,
bool includeJwt = true)
{
var connection = await settings.LoadConnectionAsync();
var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}";
using var request = new HttpRequestMessage(method, url);
AddCloudflareAccessHeaders(request, connection);
if (includeJwt)
{
var jwt = await settings.GetJwtAsync();
if (!string.IsNullOrWhiteSpace(jwt))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
}
if (body is not null)
{
var json = JsonSerializer.Serialize(body, Json);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase));
if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
return default!;
return JsonSerializer.Deserialize<T>(text, Json)
?? throw new AdminApiException(0, "Server returned an empty response.");
}
private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection)
{
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) &&
!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret))
{
request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId);
request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret);
}
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt))
request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt);
}
private static string ExtractError(string text, string? fallback)
{
if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed.";
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty("error", out var error))
return error.GetString() ?? fallback ?? "Request failed.";
if (doc.RootElement.TryGetProperty("message", out var message))
return message.GetString() ?? fallback ?? "Request failed.";
}
catch { }
return text.Length > 300 ? text[..300] : text;
}
}
public sealed class AdminApiException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
internal static class HeaderExtensions
{
public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value)
{
headers.Remove(name);
headers.Add(name, value);
}
}
@@ -1,61 +0,0 @@
using Microsoft.Maui.Storage;
namespace DorkNet.AdminMobile.Services;
public sealed record AdminConnectionSettings(
string BaseUrl,
string CloudflareAccessClientId,
string CloudflareAccessClientSecret,
string CloudflareAccessJwt);
public sealed class SecureAdminSettings
{
private const string BaseUrlKey = "admin.baseUrl";
private const string JwtKey = "admin.jwt";
private const string RefreshKey = "admin.refresh";
private const string CfClientIdKey = "cf.access.clientId";
private const string CfClientSecretKey = "cf.access.clientSecret";
private const string CfJwtKey = "cf.access.jwt";
public async Task<AdminConnectionSettings> LoadConnectionAsync()
{
try
{
var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net";
var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty;
var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty;
var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty;
return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt);
}
catch
{
SecureStorage.Remove(BaseUrlKey);
SecureStorage.Remove(CfClientIdKey);
SecureStorage.Remove(CfClientSecretKey);
SecureStorage.Remove(CfJwtKey);
return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty);
}
}
public async Task SaveConnectionAsync(AdminConnectionSettings settings)
{
await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/'));
await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim());
await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim());
await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim());
}
public Task<string?> GetJwtAsync() => SecureStorage.GetAsync(JwtKey);
public async Task SaveSessionAsync(string accessToken, string refreshToken)
{
await SecureStorage.SetAsync(JwtKey, accessToken);
await SecureStorage.SetAsync(RefreshKey, refreshToken);
}
public void ClearSession()
{
SecureStorage.Remove(JwtKey);
SecureStorage.Remove(RefreshKey);
}
}
+1009 -602
View File
@@ -3,17 +3,9 @@
// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. TLS trust bypass — make BouncyCastle never reject server certs
//
// Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash)
// needed so a modded client isn't kicked, and three opt-in features that
// are OFF by default (Desktop Screen-Share FPS, RRO quest party size,
// Rec Royale night mode).
//
// This is the STABLE, shipping mod: it carries no diagnostic/tracing
// code. All of that lives in the separate DorkNet.DebugMod (drop it in
// the Mods folder to debug); the dev-menu / dev-debug-tools tooling
// lives outside the public repo entirely.
// 2. Photon AppId — override the baked-in Photon Cloud AppId
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
// 4. TLS trust bypass — make BouncyCastle never reject server certs
//
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
// install-melon.ps1 wrapper writes that file on install; you can also
@@ -30,22 +22,13 @@
// Il2CppAssemblies directory just to write the source.
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MelonLoader;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
@@ -56,13 +39,14 @@ public class Mod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static Mod? Instance;
internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _antiTamperCallbackPatchesComplete;
private int _antiTamperCallbackRetryFrame;
private bool _toxModPatchesComplete;
private int _toxModRetryFrame;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame;
private int _photonOverridePollFrame;
// Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this
@@ -70,321 +54,157 @@ public class Mod : MelonMod
public static class Cfg
{
public static string ServerHost = "localhost";
public static string SingleOriginBaseUrl = "";
public static string PhotonAppId = "";
public static string PhotonVoiceAppId = "";
// Forced PhotonServerSettings.PreferredRegion (with HostType
// switched to PhotonCloud) so two watches don't auto-ping
// themselves into different "best" regions and end up in
// parallel Photon rooms sharing only a name. PhotonPatches
// .ApplyForcedRegion prefers the server-told region from
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
// and falls back to this config value on initial boot before
// any /goto has returned. Default matches the server's
// appsettings.json Photon:CloudRegion ("eu").
public static string PhotonCloudRegion = "eu";
public static bool EnableTlsTrustBypass = true;
// ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
// the shared desktop image. Its cadence is a baked [SerializeField]
// float screenShareImageRefreshFrequency — the broadcast tick gates
// on (Time.time - lastSend >= 1f / frequency), verified at
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
// and its editor-only [Range] is NOT enforced at runtime. Set a
// target here to override it (default prefab value is low, ~5).
// 0 = leave the prefab value alone (patch not even registered).
public static float DesktopScreenShareFps = 0f;
// The captured frame ships via the gadget's IPunObservable
// OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
// still delivers ~10 unless we raise it. This lifts SendRate +
// SerializationRate to >= the FPS target. GLOBAL: affects every
// PhotonView's sync traffic, so turn off if it lags a busy room.
public static bool DesktopScreenShareRaisePhotonRate = true;
// Optional per-frame size knobs (0 = leave the prefab value). Higher
// FPS needs smaller frames to fit the Photon serialization budget;
// lower these if 30 fps saturates bandwidth (chunked frames stall).
public static int DesktopScreenShareResolution = 0; // horizontal px
public static int DesktopScreenShareQuality = 0; // JPEG quality
// Raise RRO quest party size past the baked 4. The cap lives in each
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
// ScriptableObject (baked in the client bundle; no server lever). The
// quest scoreboard + party HUD build rows dynamically, so they
// auto-scale once the team cap is raised. 0 = off (leave at 4).
// Only single-team, quest-named configs are touched, so PvP team
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
public static int QuestMaxTeamSize = 0;
// Only these RRO quest GameConfigurationAssets get their team size
// bumped (matched loosely: case/space-insensitive, filler words like
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
// generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
// ── Rec Royale night mode (client-side visual) ──
// Re-skins Rec Royale with one of the game's own shipped night
// MoodSetting assets (sky dome, sun, fog, bloom, colour grading)
// through SceneMoodSettings' apply path, and unlocks the weapon
// flashlight prefab while it is on. Purely local: the networked room
// mood index is never touched. See RecRoyaleNight.
public static bool RecRoyaleNightMode = false;
// Resources asset name of the MoodSetting to apply. Shipped options:
// Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood,
// Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood.
public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood";
// Unity scene names (substring, case-insensitive) that count as Rec
// Royale. 2023-03-21 has a single map: RecRoyale_Frontier.
public static string[] RecRoyaleNightScenes = { "RecRoyale" };
// Force WeaponFlashlightController.FlashlightAllowed while night is on.
public static bool RecRoyaleNightFlashlights = true;
// Apply the mood's fog block too. Off = the mood's FogSettings are
// swapped for a block captured from the scene, so fog stays as-is.
public static bool RecRoyaleNightFog = false;
// Darkening knobs applied to the mood data before it is used. Baked
// lightmaps can't be dimmed, so post-exposure (EV, added to the mood's
// ColorGradingSettings.exposure) is what actually darkens the whole
// frame; the sun/ambient multipliers tone down realtime lighting.
public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default)
public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged)
public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged)
// Post-exposure only renders if the scene Volume is on and the camera
// renders post-processing; force both while night mode is active.
public static bool RecRoyaleNightForcePostProcessing = true;
// The engine only drives RenderSettings.sun (Frontier: the sun-disc
// light). Copy its rotation/colour onto every other directional light
// and scale their intensity by RecRoyaleNightSunScale.
public static bool RecRoyaleNightMatchSunLights = true;
// Turn fog off entirely while night is on (RenderSettings.fog=false and
// the mood's fog block pushed out of range). Ignored when
// RecRoyaleNightFog is true.
public static bool RecRoyaleNightDisableFog = false;
// Renderers whose GameObject or material name contains any of these
// (case-insensitive) are hidden while night is on. Frontier's daytime
// cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat.
public static string[] RecRoyaleNightHideObjects = { "Cloud" };
// Optional UnityEngine.KeyCode name to toggle night at runtime (e.g.
// "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = "";
// 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;
// Photon Custom Auth injector was parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig();
Log.Msg($"DorkNet ClientMod loaded — host={Cfg.ServerHost}, tlsBypass={Cfg.EnableTlsTrustBypass}");
RegisterNetworkPatches();
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
}
public override void OnLateInitializeMelon()
{
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ===");
RegisterNetworkPatches();
TryPatchByName("RecRoom.AntiCheat.EACManager",
"GenerateChallengeResponse",
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
// NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
// injector) parked — see attic/AuthValuesInjector.cs.attic.
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
// Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push
// nor SubscriptionUpdatePresence pushes the server fires after
// save propagate into this field. So the PostSaveReloading
// sub-state's MasterReloadRoomAsync deserializes the cached
// bytes from the OLD blob name (the one /goto returned at boot),
// wiping any MakerPen object the user just placed even though
// the new blob is already on S3.
//
// Verified live: after a dorm save with object placement, server
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
// set_RoomDataBlobName trace immediately after the save shows
// old="v(N)" new="v(N)" — no propagation, no fresh download,
// no respawn. Leave+return triggers /goto which DOES update
// RoomDataBlobName, so the fix is to do the same write
// ourselves: when entering the SavingRoomSuccess state on the
// RoomPersistenceManager, read the just-pushed
// Rooms.LocalRoomScene.DataBlobName and force-set
// __instance.RoomDataBlobName to it. That fires the real
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
// for the new blob → PostSaveReloading picks up the fresh
// bytes and respawns the MakerPen objects.
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
"set_RoomDataBlobName",
args: new[] { typeof(string) },
prefix: nameof(AntiCheatPatches.GenerateChallengeResponse_Prefix));
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
// Even when the prefix's return value is "let original run", the
// detour itself still has to be JIT-installed when BouncyCastle
// does its first handshake, and MonoMod's CompileMethodHook
// fatal-CLRs (0x80131506) installing trampolines into these
// third-party interface implementations. Only register the
// detours if the bypass is actually wanted; on a setup with
// valid certs (the default) skip the patch entirely.
if (Cfg.EnableTlsTrustBypass)
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
// stash the just-arrived Scenes[0].DataBlobName into a static
// so the set_RoomDataBlobName hijack can use it — otherwise
// LocalRoomDetails / LocalRoomScene never get the new blob
// name on the second-or-later push for the same (room, sub)
// tuple (= every post-save push in a dorm).
TryPatchByName("RecNet.Rooms",
"OnSubscriptionUpdateRoom",
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
// Force AccountExtensions.CanLocalPlayerChat to return true. The
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
// Coroutine d__53 offset 040) gates on this — if false, the
// coroutine bails before raising the Photon RPC, so the sender
// doesn't even see their own message. The check ANDs three
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
// 0x40), a PlatformManager flag, and a ModerationManager
// singleton field. At least one returns the wrong value against
// our server. Force-allow — single-tenant private server, text
// moderation isn't a concern.
TryPatchByName("RecNet.AccountExtensions",
"CanLocalPlayerChat",
args: Type.EmptyTypes,
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
// font asset's character table via TMP_FontAsset.HasCharacters
// to strip glyphs the font can't render. On 2020 Rec Room the
// chat font field can be null at the moment this fires (font
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
// inside Unity's TMP — which propagates out through the chat
// send callback and aborts the RPC. The watch's own ISIL has
// "skipping invalid character check" log paths for missing
// assets but doesn't actually short-circuit on the null field
// we hit. Skip the method entirely; chat lines will just keep
// any glyphs the font can't render (most common: emoji).
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RemoveInvalidCharactersFromMessage",
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
// Trace hooks so we can see exactly where chat dies. Send path:
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
// → PlayerEmotes.SendChatEmote(msg, true)
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
// → b__0 callback (error, cleanVersion) — fires Photon RPC
// AND local-echoes via ProcessNewChatMessageReceived
// → on each receiver (including self): RpcChatEmote(msg) →
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
// the chat log buffer.
TryPatchByName("RecRoom.Players.PlayerEmotes",
"SendChatEmote",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"ProcessNewChatMessageReceived",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RpcChatEmote",
args: new[] { typeof(string) },
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
// Debug console: skip CheatManager's tamper detectors so the
// console's movement/time commands don't trip a dorm-drop. The
// console UI toggle itself is driven from OnUpdate (hotkey) and
// resolved lazily — only the anti-cheat skip needs a patch.
if (Cfg.EnableDebugConsole)
{
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
}
// Anti-tamper / ToxMod / file-hash-checker are anti-cheat BYPASS
// patches — core, always on, or the modded client gets kicked.
RegisterAntiTamperCallbackPatches(logMisses: true);
RegisterToxModPatches(logMisses: true);
PatchFileHashCheckerCallback();
// Desktop Screen Sharing FPS override. Only register when a target
// is set; the postfix on the gadget's Awake rewrites the baked
// refresh-frequency field (and, if enabled, raises the Photon
// serialization rate so the higher capture rate actually transmits).
if (Cfg.DesktopScreenShareFps > 0f)
{
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
"Awake", args: Type.EmptyTypes,
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
// RRO quest party-size bump. Prefix the runtime config-apply path so
// we can read the config's Name (scope to the allowlisted quests) and
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
// team manager / pre-game roster reads it. Only when a target is set.
if (Cfg.QuestMaxTeamSize > 0)
{
// The runtime team cap is built from the baked GameConfigurationAsset
// (a readable ScriptableObject) by GameConfigurationAsset.FIFOMGAOOHH()
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
// struct-array) into the protobuf TeamSize. Prefix that builder and
// bump the source array IN PLACE before it converts, scoped by
// asset.Name. This avoids the obfuscated protobuf entirely (its
// generic RepeatedField getter isn't emitted by Il2CppInterop).
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "FIFOMGAOOHH",
prefix: nameof(QuestTeamSize.Build_Prefix));
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
TryPatchByName("GameSpawnManager", "KMPAOJGOOCH",
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);
}
RegisterDiagnostics();
Log.Msg("=== Client patches registered ===");
}
private bool _recRoyaleNightArmed;
public override void OnSceneWasLoaded(int buildIndex, string sceneName)
{
if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName);
}
private bool PatchFileHashCheckerCallback()
{
var prefixMethod = GetPatchMethod(nameof(FileHashCheckerPatches.InitializeCallback_Prefix));
var patched = 0;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
if (!MatchesFileHashCheckerCallbackType(type)) continue;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (!MatchesFileHashCheckerCallbackMethod(method)) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] file hash checker callback");
return false;
}
return true;
}
private void RegisterAntiTamperCallbackPatches(bool logMisses)
{
var complete = true;
complete &= PatchCallbackMethod(
"hile warning callback",
type => TypeNameContains(type, "FPIBGPIAOBI") && TypeNameContains(type, "KHIIHOIFDMP"),
method => MethodNameContains(method, "CreateHileWarning") && MethodNameContains(method, "b__0"),
nameof(AntiTamperPatches.CreateHileWarningCallback_Prefix),
logMisses);
complete &= PatchCallbackMethod(
"unknown DLL callback",
type => TypeNameContains(type, "CheatManager") && TypeNameContains(type, "PNHDGEKCJCH"),
method => MethodNameContains(method, "OnUnknownDLLDetected") && MethodNameContains(method, "b__0"),
nameof(AntiTamperPatches.UnknownDllDetectedCallback_Prefix),
logMisses);
_antiTamperCallbackPatchesComplete = complete;
}
private void RegisterToxModPatches(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("ToxMod.ToxModVoiceComponent",
"CanInitializeToxMod",
args: Type.EmptyTypes,
prefix: nameof(ToxModPatches.CanInitializeToxMod_Prefix),
logMiss: logMisses);
_toxModPatchesComplete = complete;
}
private bool PatchCallbackMethod(string label,
Func<Type, bool> typePredicate,
Func<MethodInfo, bool> methodPredicate,
string prefix,
bool logMiss)
{
if (_diagnosticPatchLabels.Contains(label))
return true;
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
if (!typePredicate(type)) continue;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (!methodPredicate(method)) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {label} {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {label}");
return false;
}
_diagnosticPatchLabels.Add(label);
return true;
}
private static bool TypeNameContains(Type type, string value)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains(value, StringComparison.Ordinal);
}
private static bool MethodNameContains(MethodInfo method, string value)
=> method.Name.Contains(value, StringComparison.Ordinal);
private static bool MatchesFileHashCheckerCallbackType(Type type)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains("BEPEAHOOLDB", StringComparison.Ordinal)
&& fullName.Contains("PBENKLNHBHL", StringComparison.Ordinal);
}
private static bool MatchesFileHashCheckerCallbackMethod(MethodInfo method)
{
if (method.ReturnType != typeof(void)) return false;
var parameters = method.GetParameters();
if (parameters.Length != 1) return false;
var name = method.Name;
if (name == "<InitializeFileHashChecker>b__0") return true;
if (name == "_InitializeFileHashChecker_b__0") return true;
return name.Contains("InitializeFileHashChecker", StringComparison.Ordinal)
&& name.Contains("b__0", StringComparison.Ordinal);
}
private void RegisterNetworkPatches()
{
if (_networkPatchesRegistered) return;
@@ -405,6 +225,7 @@ public class Mod : MelonMod
_networkPatchesRegistered = complete;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
@@ -431,130 +252,89 @@ public class Mod : MelonMod
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
return true;
}
public override void OnUpdate()
{
if (_recRoyaleNightArmed) RecRoyaleNight.Tick();
// Photon AppId override poller. The harmony hook on
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
// doesn't actually fire in this build (verified in the
// MelonLoader log — [patch-ok] appears but [photon-appid]
// never does), so we poll until PhotonServerSettings is loaded
// and write the AppId then. Stops calling once the override
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
// the failure log doesn't drown the file when PhotonServerSettings
// isn't loaded yet.
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
PhotonPatches.TryApplyOverride(reason: "update-poll");
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{
_antiTamperCallbackRetryFrame = 0;
RegisterAntiTamperCallbackPatches(logMisses: false);
}
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
{
_toxModRetryFrame = 0;
RegisterToxModPatches(logMisses: false);
}
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
if ((_diagnosticRetryFrame % 60) != 0) return;
var logMisses = (_diagnosticRetryFrame % 300) == 0;
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
if (_diagnosticGameComplete)
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
}
public override void OnApplicationQuit()
{
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
}
// ── Config ────────────────────────────────────────────────────────
private static void LoadConfig()
{
try
{
var userData = ResolveUserDataDirectory();
// Compute the UserData path ourselves rather than calling
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
// and shifts again in 0.7.x). Anchoring off the mod DLL's
// location keeps this stable across versions: the DLL lives
// in <game>/Mods/, so two dirname()'s reach the game root,
// then MelonLoader/UserData/ is right next to it.
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
var rooms = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
rooms.Add(e.GetString()!);
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();
}
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
// "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
}
}
private static string ResolveUserDataDirectory()
{
foreach (var typeName in new[]
{
"MelonLoader.MelonEnvironment, MelonLoader",
"MelonLoader.MelonUtils, MelonLoader",
})
{
var type = Type.GetType(typeName);
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
return value;
}
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
return Path.Combine(gameDir, "MelonLoader", "UserData");
}
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
// ── Patch dispatch helpers ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null,
bool logMiss = true)
Type[]? args, string? prefix = null, string? postfix = null)
{
try
{
@@ -564,17 +344,13 @@ public class Mod : MelonMod
: AccessTools.Method(target, methodName, args));
if (method is null)
{
// `logMiss` is throttled by the retry-loop caller — once per
// 5 seconds in OnUpdate, every time in the one-shot initial
// RegisterDiagnostics. Without the gate the per-second retry
// loop would spam `[patch-miss]` lines forever for any method
// whose name drifted in a newer game build.
if (logMiss) Log.Warning($"[patch-miss] {label}");
Log.Warning($"[patch-miss] {label}");
return false;
}
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {label}");
return true;
}
catch (Exception ex)
@@ -583,6 +359,13 @@ public class Mod : MelonMod
return false;
}
}
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
// by walking every loaded assembly. Tries the exact name first, then
// the same name with an `Il2Cpp` prefix, then a substring scan as a
// last resort — covers all the IL2CPP interop naming schemes the user
// might end up with depending on the MelonLoader / Il2CppInterop
// version installed.
private bool TryPatchByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
@@ -594,8 +377,9 @@ public class Mod : MelonMod
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
return TryPatch(label, type, methodName, args, prefix, postfix);
}
internal static Type? ResolveType(string name)
{
if (ResolvedTypeCache.TryGetValue(name, out var cached))
@@ -607,8 +391,6 @@ public class Mod : MelonMod
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t);
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with
@@ -633,11 +415,13 @@ public class Mod : MelonMod
}
return null;
}
private static Type CacheResolvedType(string name, Type type)
{
ResolvedTypeCache[name] = type;
return type;
}
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
{
try
@@ -658,16 +442,16 @@ public class Mod : MelonMod
return Array.Empty<Type>();
}
}
private static bool MatchesRequestedType(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
var dottedPrefixed = "Il2Cpp." + requested;
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
if (type.FullName == requested || type.FullName == prefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
@@ -676,16 +460,72 @@ public class Mod : MelonMod
names.Add(arg.Name);
return $"{typeName}.{methodName}({string.Join(",", names)})";
}
private MethodInfo GetPatchMethod(string name)
{
// Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
// Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups.
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
private void RegisterDiagnostics()
{
Log.Msg("=== Registering diagnostics ===");
if (!_diagnosticCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
_diagnosticCoreRegistered = true;
}
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
Log.Msg("=== Diagnostics registered ===");
}
private bool RegisterGameDiagnostics(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
logMiss: logMisses);
foreach (var method in new[]
{
"UnityTimeCheat",
"ObscuredTypeCheat",
"DeveloperFlagCheat",
"HeightChangeCheat",
"AdvancedMotionCheat",
"StreamingAssetCheat"
})
{
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
return complete;
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
@@ -702,85 +542,209 @@ public class Mod : MelonMod
}
}
internal static class ScreenSharePatches
{
private static bool _photonRateApplied;
// ── Patch holders ─────────────────────────────────────────────────────
// Each holder is a static class with public static Prefix methods that
// Harmony invokes. Kept separate from Mod for readability and so the
// reflection lookup in GetPatchMethod stays simple.
public static void DisplayAwake_Postfix(object __instance)
// ── Debug console access ───────────────────────────────────────────────
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
// dev console (text input + a DebugConsoleCommandConfig command table:
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
// …). Retail gates it behind a developer flag. This holder force-toggles
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
// CheatManager's tamper detectors so movement/time commands don't drop you
// to the dorm.
//
// Everything is resolved by reflection at runtime so the SAME code works on
// both client builds despite their differing obfuscation:
// * The show/hide toggle is the one non-compiler-generated private
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
// one, then fall back to that structural match.
// * The live component is found via UnityEngine.Object.FindObjectOfType
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
// * Execute(string) keeps its readable name on both builds (kept here as
// a scripted-command entry point even though the hotkey drives the UI).
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _pollErrorLogged;
private static int _probeFrame;
private static bool _probeDone;
private static bool _inputResolved;
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
private static object? _toggleKeyValue; // boxed KeyCode enum value
private static bool _consoleResolved;
private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
// Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path.
public static bool SuppressCheatDetected_Prefix() => false;
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
try
{
var fps = Mod.Cfg.DesktopScreenShareFps;
if (fps <= 0f || __instance is null) return;
var t = __instance.GetType();
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
if (Mod.Cfg.DesktopScreenShareResolution > 0)
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
if (Mod.Cfg.DesktopScreenShareQuality > 0)
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
RaisePhotonRates((int)Math.Ceiling(fps));
if (!_inputResolved) ResolveInput();
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
// console object actually exists, so we learn that even if the
// hotkey never fires.
if (!_probeDone && _probeFrame++ > 180)
{
_probeDone = true;
ProbeConsole();
}
if (_getKeyDown is null || _toggleKeyValue is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
{
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
Toggle();
}
}
catch (Exception ex)
{
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
}
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
}
private static void SetField(Type t, object inst, string name, object value)
// Diagnostic: reports whether a live DebugConsole component exists and
// whether the show/hide method resolved — without needing a keypress.
private static void ProbeConsole()
{
var f = AccessTools.Field(t, name);
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
f.SetValue(inst, value);
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
}
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
// SerializationRate <= SendRate, so SendRate is kept at the max of the
// two. Both are public static int members (property on some PUN builds,
// field on others) — handle either. GLOBAL: every PhotonView's
// OnPhotonSerializeView now fires at this rate.
private static void RaisePhotonRates(int hz)
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
public static void ExecuteCommand(string command)
{
var pn = Mod.ResolveType("PhotonNetwork");
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
var send = ReadInt(pn, "SendRate");
var ser = ReadInt(pn, "SerializationRate");
var target = Math.Max(hz, 1);
var newSend = Math.Max(target, Math.Max(send, ser));
WriteInt(pn, "SendRate", newSend);
WriteInt(pn, "SerializationRate", target);
_photonRateApplied = true;
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) return;
try
{
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
execute.Invoke(null, new object[] { command });
Mod.Log.Msg($"[debugconsole] executed: {command}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
}
private static int ReadInt(Type t, string name)
private static void ResolveInput()
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null && f.GetValue(null) is int fv) return fv;
return 0;
_inputResolved = true;
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
var inputType = Mod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
catch
{
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
}
private static void WriteInt(Type t, string name, int value)
private static void ResolveConsole()
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null) f.SetValue(null, value);
_consoleResolved = true;
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
// name, then a structural fallback that fits both builds.
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(_consoleType);
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
var objType = Mod.ResolveType("UnityEngine.Object");
_findObjectOfType = objType is null ? null
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
}
// The console's show/hide is the single private, instance, void method
// taking exactly one bool, excluding compiler-generated members (the
// property backing setter on 2020.12 is also void(bool) but is marked
// [CompilerGenerated]).
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null; // ambiguous — don't guess
found = m;
}
return found;
}
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
try
{
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
if (instance is null)
{
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
return;
}
// Best-effort: flip the static "show button" enable so the
// game's own Update keeps the console available instead of
// re-hiding it. Failures are ignored (obfuscated builds).
EnableShowButton();
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
try
{
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
{
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
}
}
catch { /* best-effort — the toggle still works without it */ }
}
}
internal static class UriPatches
{
public static void HttpRequestSetUri_Prefix(ref Uri __0)
{
RewriteUriArg(ref __0, "httprequest-seturi-rewrite");
}
public static void HttpRequestPrepareUri_Prefix(ref Uri __0)
{
RewriteUriArg(ref __0, "httprequest-prepareuri-rewrite");
}
public static void HttpManagerSendRequestString_Prefix(ref string url)
{
RewriteString(ref url, "httpmanager-rewrite");
@@ -805,24 +769,14 @@ internal static class UriPatches
{
try
{
if (request is null) return;
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
if (uriProp?.GetValue(request) is not Uri uri) return;
if (TryRewriteUriProperty(request, type.GetProperty("Uri", flags), label)) return;
if (TryRewriteUriField(request, type.GetField("<Uri>k__BackingField", flags), label)) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryRewriteUriProperty(request, prop, label)) return;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryRewriteUriField(request, field, label)) return;
}
uriProp.SetValue(request, rewritten);
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
}
catch (Exception ex)
{
@@ -830,51 +784,28 @@ internal static class UriPatches
}
}
private static void RewriteUriArg(ref Uri uri, string label)
{
if (uri is null) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
uri = rewritten;
}
private static bool TryRewriteUriProperty(object request, PropertyInfo? prop, string label)
{
if (prop is null || !prop.CanRead || !prop.CanWrite) return false;
if (prop.GetIndexParameters().Length != 0) return false;
if (prop.GetValue(request) is not Uri uri) return false;
var rewritten = RewriteUri(uri);
if (rewritten is null) return false;
prop.SetValue(request, rewritten);
return true;
}
private static bool TryRewriteUriField(object request, FieldInfo? field, string label)
{
if (field is null || field.IsInitOnly) return false;
if (field.GetValue(request) is not Uri uri) return false;
var rewritten = RewriteUri(uri);
if (rewritten is null) return false;
field.SetValue(request, rewritten);
return true;
}
private static void RewriteString(ref string uriString, string label)
{
if (string.IsNullOrEmpty(uriString)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
if (!Uri.TryCreate(uriString, UriKind.Absolute, out var uri)) return;
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
{
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
var rewrittenText = rewrittenUri.ToString();
if (rewrittenText == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
uriString = rewrittenText;
return;
}
var rewritten = rewrittenUri.ToString();
var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return;
var rewritten = uriString.Replace(".rec.net", "." + host);
if (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
uriString = rewritten;
}
@@ -887,94 +818,463 @@ internal static class UriPatches
var service = uri.Host[..^".rec.net".Length];
if (string.IsNullOrWhiteSpace(service)) return null;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(Mod.Cfg.SingleOriginBaseUrl, UriKind.Absolute, out var baseUri))
{
var path = CombinePath(baseUri.AbsolutePath, "__dn/" + service, uri.AbsolutePath);
return new UriBuilder(baseUri)
{
Path = path,
Query = uri.Query.TrimStart('?'),
}.Uri;
}
return new UriBuilder(uri)
{
Host = service + "." + host,
}.Uri;
}
}
internal static class AntiCheatPatches
{
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
private static string CombinePath(params string[] parts)
{
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
return false;
var cleaned = new List<string>();
foreach (var part in parts)
{
var p = (part ?? "").Trim('/');
if (p.Length > 0) cleaned.Add(p);
}
return "/" + string.Join("/", cleaned);
}
}
internal static class AntiTamperPatches
internal static class PhotonPatches
{
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
/// written the user's AppId into <c>PhotonServerSettings</c>. The
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
/// this flips true.</summary>
public static bool OverrideApplied;
public static void PhotonAppIdOverride_Prefix()
{
return false;
// Kept as a belt-and-braces hook in case the watch's actual
// connect path does happen to flow through ConnectUsingSettings
// (it doesn't, in March's IL2CPP build — verified via log). The
// poller in OnUpdate is what actually gets the override in
// before the wire connect.
TryApplyOverride(reason: "prefix");
}
public static bool UnknownDllDetectedCallback_Prefix()
{
return false;
}
}
internal static class FileHashCheckerPatches
{
public static bool InitializeCallback_Prefix(object? result)
{
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;
/// <summary>Tracks how many polling attempts we've logged so the
/// "type not found yet" line doesn't drown the log file. Cleared
/// once the override succeeds.</summary>
private static int _missLogCount;
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
/// <c>VoiceAppID</c> + region with the values from
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
/// written successfully; subsequent calls become no-ops. Returns
/// false (and logs nothing on most retries) when PhotonServerSettings
/// hasn't been loaded by the watch yet — the poller retries every
/// frame until the ScriptableObject shows up.</summary>
public static bool TryApplyOverride(string reason)
{
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
if (OverrideApplied) return true;
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
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)
// Use Mod.ResolveType (the same resolver TryPatchByName uses)
// — it falls back to a full assembly scan after the prefix
// attempts, which is how it finds the IL2CPP-mangled types
// that the local FindGameType helper misses.
var photonNetwork = Mod.ResolveType("PhotonNetwork");
if (photonNetwork 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;
// Log the miss only on the first call + once every ~10s
// after that, to avoid the log spam previous build hit.
if (_missLogCount == 0 || _missLogCount % 300 == 0)
Mod.Log.Msg($"[photon-appid] PhotonNetwork type not found yet (#{_missLogCount}, {reason})");
_missLogCount++;
return false;
}
// 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;
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
var settings = settingsProp?.GetValue(null);
if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
var settingsType = settings.GetType();
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
var newId = Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
appIdProp?.SetValue(settings, newId);
voiceProp?.SetValue(settings, newVoice);
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
// Force a deterministic Photon region so two watches running
// this mod end up on the same Photon master and "JoinByName"
// produces ONE shared room (not two parallel ones in eu/us
// because each watch auto-picked a different best region).
//
// The 2020 watch's default HostType is BestRegion(4) — every
// launch pings all Photon Cloud regions and picks the lowest
// latency. Two players geographically apart hit different
// "best" regions; same room name, two regions = two rooms.
// Server-side our matchmaking response carries
// `photonRegionId` but the watch's Photon code never reads
// it; the only consumer is Matchmaking.SetPhotonRegionPings
// which only reports pings *back* to the server.
//
// Strategy: switch HostType to PhotonCloud(1) and set
// PreferredRegion. Prefer the server-told region (the most
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
// any) over the config default — that way a server-side
// region change propagates without redeploying the mod.
ApplyForcedRegion(settings, settingsType);
OverrideApplied = true;
return true;
}
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;
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
return false;
}
}
}
internal static class ToxModPatches
{
public static bool CanInitializeToxMod_Prefix(ref bool __result)
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
/// and writes <c>PreferredRegion</c>. Honors any region the server
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
/// instance has been received yet (initial boot).</summary>
private static void ApplyForcedRegion(object settings, Type settingsType)
{
__result = false;
return false;
try
{
var hostTypeProp = settingsType.GetField("HostType")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
var preferredProp = settingsType.GetField("PreferredRegion")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
if (hostTypeProp is null || preferredProp is null)
{
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
return;
}
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
// type from the field/property so we don't depend on a
// specific Il2Cpp proxy namespace.
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
?? ((PropertyInfo)hostTypeProp).PropertyType;
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
?? ((PropertyInfo)preferredProp).PropertyType;
// Prefer server-provided region over config. We read it via
// the same reflection style as the existing patches so the
// Il2Cpp type-name suffix doesn't bite us.
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
object regionValue;
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
catch
{
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
region = "us";
}
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
}
/// <summary>Reads
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
/// lowercase enum name (eu, us, asia, …). Returns null if no room
/// instance has been received yet, or if any link in the chain is
/// null. Server-told region wins over the static config default
/// because the server is authoritative about which Photon region
/// hosts the room the watch is about to join.</summary>
private static string? ReadServerProvidedRegion()
{
try
{
var matchmaking = FindGameType("RecNet.Matchmaking");
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localRoom is null) return null;
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
if (regionObj is null) return null;
// CloudRegionCode enum.ToString() gives the lowercase region
// name (eu, us, asia, …) — see CloudRegionCode.cs.
return regionObj.ToString();
}
catch { return null; }
}
// AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
// — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
// now disabled at the AppId level in the Photon dashboard, so the
// watch no longer needs to attach userid+LoginLock to Photon
// Authenticate ops.
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class SavePatches
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
// our currently-loaded room. Captured BEFORE the watch's caching
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
// (roomId, subRoomId) tuple — which is every post-save push for a
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
// would-be no-op setter call into a real transition.
private static volatile string? s_lastPushedBlobName;
private static long s_lastPushedRoomId;
private static long s_lastPushedSubRoomId;
// Hook on Notifications.OnNotificationReceived(string json). Fires for
// every SignalR message before any handler dispatch. Parses the JSON
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
public static void OnNotificationReceived_Prefix(string message)
{
try
{
if (string.IsNullOrEmpty(message)) return;
// Fast reject: only parse JSON if it could plausibly be a
// SubscriptionUpdateRoom payload. Saves cycles on the (very
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
return;
using var doc = System.Text.Json.JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!root.TryGetProperty("Id", out var idEl) ||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
return;
if (!root.TryGetProperty("Msg", out var msg) ||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!msg.TryGetProperty("Room", out var room) ||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
long roomId = 0;
if (room.TryGetProperty("RoomId", out var ridEl) &&
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
roomId = ridEl.GetInt64();
if (!msg.TryGetProperty("Scenes", out var scenes) ||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
scenes.GetArrayLength() == 0) return;
var first = scenes[0];
long subRoomId = 0;
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
subRoomId = sidEl.GetInt64();
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
var blob = blobEl.GetString();
if (string.IsNullOrEmpty(blob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = blob;
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
}
}
public static void OnSubscriptionUpdateRoom_Prefix(object message)
{
try
{
if (message is null) return;
var dict = message;
// The watch parses this dict as Dictionary<string, object>
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
// get_Item(key) — call it via reflection.
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (getItem is null) return;
// The push wire shape from server (BuildRoomDetails) is
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
// on the Room sub-object; the scene's blob on Scenes[0].
object? roomObj = TryGet(dict, getItem, "Room");
object? scenesObj = TryGet(dict, getItem, "Scenes");
if (roomObj is null || scenesObj is null) return;
long roomId = 0;
long subRoomId = 0;
try
{
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (roomItem is not null)
{
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
}
}
catch { /* roomId stays 0 — we'll log it and bail later */ }
// Scenes is an IL2CPP List<RoomScene>. Take the first
// (current dorm has one scene; multi-scene rooms target
// Scenes[currentSubRoomId] specifically, but the post-save
// push always carries the scene that was just saved).
object? firstScene = null;
try
{
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
}
catch { /* leave firstScene null */ }
if (firstScene is null) return;
string? newBlob = null;
try
{
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (sceneItem is not null)
{
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
newBlob = blob?.ToString();
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
}
}
catch { /* leave newBlob null */ }
if (string.IsNullOrEmpty(newBlob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = newBlob;
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
}
}
private static object? TryGet(object dict, MethodInfo getItem, string key)
{
try { return getItem.Invoke(dict, new object[] { key }); }
catch { return null; }
}
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
// path only when the new value differs from the cached field. The
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
// save it calls the setter with the OLD blob — a no-op that skips
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
// pointed at the boot-time bytes, and PostSaveReloading deserializes
// those over the live world — wiping any MakerPen object the user
// just placed.
//
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
// after b__4 returns True, even though the SubscriptionUpdateRoom
// push already carried Scenes[0].DataBlobName="v11".
//
// Strategy: intercept the setter. If the incoming value matches the
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
// newer version (the freshly-pushed one), substitute that into the
// value parameter so the real setter sees a transition and fires
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
// Falls through silently when no override is needed.
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
{
string currentForLog = "<unread>";
string sceneForLog = "<unread>";
string scenes0ForLog = "<unread>";
try
{
if (__instance is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
return;
}
var rpmType = __instance.GetType();
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (blobNameProp is null)
{
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
return;
}
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
var roomsType = FindGameType("RecNet.Rooms");
if (roomsType is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
return;
}
var localScene = roomsType.GetProperty("LocalRoomScene",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localScene is not null)
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
var localDetails = roomsType.GetProperty("LocalRoomDetails",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localDetails is not null)
{
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
if (scenes is not null)
{
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
var first = indexer?.Invoke(scenes, new object[] { 0 });
if (first is not null)
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
}
}
// Unconditional trace of EVERYTHING we read so we can see in
// the log exactly why we did or didn't override.
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
// Pick a candidate "newer" blob name. Priority order:
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
// cache gate drops the push).
// 2. LocalRoomScene.DataBlobName.
// 3. Scenes[0].DataBlobName.
string? pushedBlob = s_lastPushedBlobName;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
if (string.IsNullOrEmpty(pushedBlob)) return;
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
value = pushedBlob;
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
}
}
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class TlsPatches
{
// Both BouncyCastle TLS authenticators have the same `void
@@ -987,3 +1287,110 @@ internal static class TlsPatches
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
}
}
internal static class DiagnosticPatches
{
public static void Write(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
try { Mod.Log.Msg(line); } catch { }
try
{
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
}
catch { }
}
public static void ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
}
public static void ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
}
public static void BootSequenceRegisterError_Prefix(string error)
{
Write($"[boot-error] BootSequence.RegisterError: {error}");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static void NamedMethod_Prefix(MethodBase __originalMethod)
{
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
return false;
}
private static void WriteStack()
{
try
{
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[stack] failed: " + ex.Message);
}
}
}
internal static class ChatPatches
{
private static bool s_logged;
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
// before raising the in-room Photon chat RPC; if it returns false
// the coroutine bails silently and the sender doesn't even see
// their own message echo.
public static void CanLocalPlayerChat_Postfix(ref bool __result)
{
if (!__result && !s_logged)
{
s_logged = true;
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
}
__result = true;
}
// Skip the original. Returning false from a Harmony prefix stops
// the underlying call.
public static bool RemoveInvalidCharactersFromMessage_Prefix()
{
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
return false;
}
public static void SendChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
}
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
{
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
}
public static void RpcChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
-319
View File
@@ -1,319 +0,0 @@
// Raise RRO quest party size past the baked 4 — precisely, only for the
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
//
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL; names
// re-verified against the March-2023 dump — 2023 obfuscated names below):
// GameConfigurationTool.Awake()
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset,
// a Unity ScriptableObject)
// → calls GameConfigurationAsset.FIFOMGAOOHH() ← builds the runtime
// protobuf GameConfigurationData (GPIGCAJICEF) FROM the asset
// → stores and applies it to the team manager.
// Inside FIFOMGAOOHH the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array) and writes each straight into a
// new GameTeamConfigurationData.TeamSize.
//
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
// READABLE, typed struct array — NOT from anything that needs the obfuscated
// protobuf. Earlier attempts failed because:
// • bumping the asset on a 2s poll happened AFTER the builder already built &
// cached the protobuf (timing, not the wrong field);
// • patching the protobuf getters is impossible — Il2CppInterop doesn't emit
// the generic RepeatedField<T> getter, and the simple int getter wasn't
// emitted either.
//
// THE FIX: Harmony-PREFIX GameConfigurationAsset.FIFOMGAOOHH() and bump the
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
// before it builds the protobuf. The built config then carries the new cap and
// the team manager / pre-game roster / open-slots all honour it. Scoped by
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
//
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
// raised cap. (A joiner can't override — that would need the unreachable
// protobuf path.)
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
namespace DorkNet.ClientMod;
internal static class QuestTeamSize
{
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
// 2023 spawn path (verified in the March-2023 ISIL): the spawn driver
// HCPLAILIHEL reads the team-player index from the roster entry
// (GameTeamManager/BNBIBPFPJPJ.JPIHHGBLLNG()) and passes it straight into
// the KMPAOJGOOCH spawn-point filter. December's intermediate helpers are
// gone from this path — the out-param resolver is inlined (zero call
// sites), and GetTeamPlayerIndex is only called by Charades/RecRally, so
// normalizing it would touch non-quest games. The KMPAOJGOOCH index
// argument is the single choke point: normalize it in a prefix and keep
// the empty-filter relax postfix as the backstop.
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
{
return TryPatchRequiredSpawnIndexNormalizer(harmony);
}
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("NBCOFGIKKHM");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "KMPAOJGOOCH");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.KMPAOJGOOCH index normalizer");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, prefix: new HarmonyMethod(patch));
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.KMPAOJGOOCH index normalizer: {ex.Message}");
return false;
}
}
// PREFIX on GameSpawnManager.KMPAOJGOOCH(..., NBCOFGIKKHM index, ...).
// The RRO quest scenes only have spawn points for indexes 0-3. After
// raising the quest team cap, players 5+ legitimately receive INDEX_4+,
// but those values cannot match any baked quest spawn point. Normalize
// just the index the spawn-point filter compares against; the actual team
// roster/cap remains raised.
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__2);
if (!TryNormalizeIndex(value, out var normalized)) return;
__2 = (T)Enum.ToObject(typeof(T), normalized);
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
}
}
private static bool TryNormalizeIndex(int value, out int normalized)
{
normalized = value % 4;
return value >= 4;
}
// PREFIX on GameConfigurationAsset.FIFOMGAOOHH() (asset → protobuf builder).
public static void Build_Prefix(object __instance)
{
try
{
var target = Mod.Cfg.QuestMaxTeamSize;
if (target <= 0 || __instance is null) return;
var name = GetMember(__instance, "Name") as string ?? "";
if (!IsAllowed(name)) return;
var arr = GetMember(__instance, "TeamConfigurations");
int len = ArrLen(arr);
if (len <= 0) return;
int bumped = 0;
for (int i = 0; i < len; i++)
{
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
if (elem is null) continue;
int cur = ReadInt(elem, "MaxTeamSize");
if (cur > 0 && cur < target &&
WriteInt(elem, "MaxTeamSize", target) &&
SetArrItem(arr!, i, elem)) // write the mutated struct back
bumped++;
}
}
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
}
// POSTFIX on GameSpawnManager.KMPAOJGOOCH(..., outputList). That method
// filters spawn points by state/game-mode/spectator/team/team-player-index/
// custom-tag before the spawn selector runs. The RRO quest prefabs only
// have indexed start points for players 0-3; once we raise the team size,
// player index 4+ can filter every point out before the built-in overlap
// fallback has a chance to help.
//
// The caller (2023: HCPLAILIHEL) runs this filter, then if output.Count
// is still 0 it logs "due to spawn point requirement tests" and falls back
// to the unfiltered source list as an arbitrary spawn set. Filling the
// output list here keeps the normal overlap pass and spawn-selection mode
// alive instead of taking that bad arbitrary fallback.
//
// Harmony binds __0 and __7 by argument position: source list and output
// list. The obfuscated parameter names are not reliable in MelonLoader's
// generated IL2CPP metadata, which is why this intentionally avoids them.
// If the filtered output is empty, copy the already-selected candidate
// scene spawn list back into the output so extra players reuse baked quest
// spawn points instead of being left out in the void.
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
if (Count(__7) > 0) return;
var sourceCount = Count(__0);
if (sourceCount <= 0) return;
CopyListItems(__0, __7);
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
}
}
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
private static List<string>? _wanted;
private static bool IsAllowed(string configName)
{
_wanted ??= BuildWanted();
if (_wanted.Count == 0) return false;
var have = Norm(configName);
foreach (var w in _wanted)
if (have.Contains(w)) return true;
return false;
}
private static List<string> BuildWanted()
{
var list = new List<string>();
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
{
var nrm = Norm(entry);
if (nrm.Length > 0) list.Add(nrm);
}
return list;
}
private static string Norm(string s)
{
if (string.IsNullOrEmpty(s)) return "";
var sb = new System.Text.StringBuilder(s.Length);
foreach (var c in s)
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
}
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
// property or a field depending on version — try both) ────────────────
private static object? GetMember(object o, string name)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return p.GetValue(o); } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { return f.GetValue(o); } catch { } }
return null;
}
private static int ReadInt(object? o, string name)
{
if (o is null) return 0;
var v = GetMember(o, name);
if (v is int n) return n;
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
}
private static bool WriteInt(object o, string name, int val)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
return false;
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static int Count(object? collection) => ArrLen(collection);
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
// Write a (boxed) struct element back into an Il2CppStructArray via its
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
private static bool SetArrItem(object arr, int i, object val)
{
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
foreach (var m in arr.GetType().GetMethods())
{
if (m.Name != "set_Item") continue;
var ps = m.GetParameters();
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
{
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
}
}
return false;
}
private static int CopyListItems(object? src, object? dst)
{
if (src is null || dst is null) return 0;
MethodInfo? add = null;
foreach (var method in dst.GetType().GetMethods())
{
if (method.Name != "Add") continue;
if (method.GetParameters().Length != 1) continue;
add = method;
break;
}
if (add is null) return 0;
var n = ArrLen(src);
var copied = 0;
for (var i = 0; i < n; i++)
{
var item = ArrItem(src, i);
if (item is null) continue;
try
{
add.Invoke(dst, new[] { item });
copied++;
}
catch { }
}
return copied;
}
private static string TypeName(object? value) =>
value?.GetType().FullName ?? "<null>";
}
-1401
View File
@@ -1,1401 +0,0 @@
// 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 });
}
}
@@ -0,0 +1,164 @@
// ─────────────────────────────────────────────────────────────────────
// AuthValuesInjector — PARKED 2026-05-28
// ─────────────────────────────────────────────────────────────────────
//
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
// doesn't pick it up. Don't rename to .cs unless you're putting the
// feature back.
//
// What this used to do
// --------------------
// Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
// would attach a Photon Custom-Auth payload (`userid` +
// `LoginLock`) to every Photon Authenticate op. The server's
// /photon/customauth controller (PhotonCustomAuthController.cs) read
// those parameters and validated them against the player's
// /player/login lock.
//
// Why it's parked
// ---------------
// Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
// front of `auth.<apex>/photon/customauth` for hosts behind
// Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
// `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
// bounced back to the dorm before a session could start.
//
// The cleaner fix turned out to be:
// 1. Turn off Custom Authentication on the Photon AppId in the
// dashboard (or flip "Anonymous connect" to Allow), so Photon
// never calls our URL.
// 2. Stop sending custom-auth params from the client — that's the
// InjectAuthValues=false flag we shipped first. With the
// dashboard side off too, the injector code below has nothing
// to gate on, so it's removed from the live patch.
//
// How to put it back
// ------------------
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic
// suffix). It compiles as part of DorkNet.ClientMod.
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
// config-load line in LoadConfig.
// 3. Re-add the harmony patch hook in OnLateInitializeMelon:
// TryPatchByName("NetworkingPeer", "CallAuthenticate",
// prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
// 4. Flip `"InjectAuthValues": true` in
// DorkNet.ClientMod/dorknet-clientmod.json.template (and the
// copy under local-releases/).
// 5. If you also want the server to enforce the lock, set
// Photon:RequireCustomAuth=true on the server (env
// Photon__RequireCustomAuth=true).
//
// The body below is verbatim what lived in Mod.cs's PhotonPatches
// class. `FindGameType` is the same helper still in Mod.cs.
using System.Reflection;
namespace DorkNet.ClientMod.Attic;
internal static class AuthValuesInjector
{
public static void AuthValuesInjector_Prefix(object __instance)
{
if (!Mod.Cfg.InjectAuthValues) return;
try
{
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
// watch never sets them itself, so we set them right before
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
// from PlayerPrefs key LoginLockTokenV2.
// RecNet.Core.LocalAccountId is declared `int` in the game
// build (dump.cs:573622 → 0x30 backing field). Convert via
// Convert.ToInt64 so boxing as int still gives us the long we
// need below; a direct (long) cast on boxed int would throw.
var coreType = FindGameType("RecNet.Core");
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
if (accountId <= 0)
{
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
return;
}
var photonNetwork = FindGameType("PhotonNetwork");
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
var existing = avProp?.GetValue(null);
if (existing is not null)
{
var userIdProp = existing.GetType().GetProperty("UserId");
var existingUserId = userIdProp?.GetValue(existing) as string;
if (!string.IsNullOrEmpty(existingUserId)) return;
}
var token = GetCurrentLockToken();
// Build a new AuthenticationValues. The type lives in the
// ExitGames.Client.Photon.LoadBalancing namespace (game-side).
// CustomAuthenticationType.Custom = 0 in this build.
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
var av = Activator.CreateInstance(authValuesType);
var authTypeProp = authValuesType.GetProperty("AuthType");
if (authTypeProp is not null)
{
var customAuthValue = authTypeProp.PropertyType.IsEnum
? Enum.ToObject(authTypeProp.PropertyType, 0)
: Convert.ChangeType(0, authTypeProp.PropertyType);
authTypeProp.SetValue(av, customAuthValue);
}
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
avProp?.SetValue(null, av);
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
}
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
}
private static string? GetCurrentLockToken()
{
try
{
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
if (string.IsNullOrEmpty(raw)) return null;
const string key = "\"LockToken\":\"";
var ix = raw.IndexOf(key, StringComparison.Ordinal);
if (ix < 0) return null;
ix += key.Length;
var end = raw.IndexOf('"', ix);
return end < 0 ? null : raw[ix..end];
}
catch { return null; }
}
// FindGameType is already defined inside Mod.cs's PhotonPatches
// class (private static) — when re-instating, either drop this
// private copy and inline the lookup, or move the helper to a
// shared internal class. The current shape preserves the original
// behaviour for an isolated paste-back.
private static Type? FindGameType(string name)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return t;
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return prefixed;
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return nsPrefixed;
}
}
catch { }
}
return null;
}
}
@@ -1,29 +1,8 @@
{
"ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"AutoCreateAccount": false,
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DevCommands": [],
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
"CrimsonCauldron",
"Crescendo",
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
],
"RecRoyaleNightMode": false,
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
"RecRoyaleNightScenes": ["RecRoyale"],
"RecRoyaleNightFlashlights": true,
"RecRoyaleNightFog": false,
"RecRoyaleNightExposure": -3.5,
"RecRoyaleNightSunScale": 0.15,
"RecRoyaleNightAmbientScale": 0.2,
"RecRoyaleNightForcePostProcessing": true,
"RecRoyaleNightMatchSunLights": true,
"RecRoyaleNightDisableFog": false,
"RecRoyaleNightHideObjects": ["Cloud"],
"RecRoyaleNightToggleKey": ""
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
}
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
-309
View File
@@ -1,309 +0,0 @@
namespace DorkNet.Contracts;
public static class DorkNetRouteOwnership
{
private const string AdminHost = "admin";
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
[
new(
ServiceNames.Identity,
["auth", "accounts"],
[
"/.well-known",
"/account",
"/api/account",
"/api/auth",
"/api/createaccount",
"/api/platformlogin",
"/api/playerreputation",
"/api/players",
"/api/relationships",
"/api/settings",
"/cachedlogin",
"/connect",
"/eac",
"/parentalcontrol",
"/photon",
"/profileimage",
"/settings",
]),
new(
ServiceNames.Rooms,
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
[
"/api/activities",
"/api/curatedroomplaylists",
"/api/gameconfigs",
"/api/gameconfigurations",
"/api/gamesessions",
"/api/leaderboard",
"/api/playerelo",
"/api/playlists",
"/api/roomkeys",
"/api/rooms",
"/api/royale",
"/discover",
"/featuredrooms",
"/goto",
// 2023 clients renamed the whole goto/* matchmaking surface
// to matchmake/* (matchmake/room, /instance, /invite,
// /chatinvite, …). Same controller as /goto, so it belongs
// to the match service; without this it fell through to the
// Monolith default owner.
"/matchmake",
"/hot_rooms",
"/hot_roomsandplaylists",
"/invite",
"/playlists",
"/player",
"/room",
"/roominstance",
"/rooms",
"/roomsandplaylists",
"/roomserver",
"/search_rooms",
"/search_roomsandplaylists",
"/v1/find",
"/v1/join",
"/v1/leave",
"/v1/session",
]),
new(
ServiceNames.Notify,
["notify", "chat", "platformnotifications"],
[
"/api/messages",
"/api/notification",
"/api/offlineinvite",
"/hub",
"/notify",
"/thread",
]),
new(
ServiceNames.Content,
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
[
"/api/images",
"/api/photos",
"/config",
"/data",
"/img",
"/invention",
"/upload",
"/video",
]),
new(
ServiceNames.Social,
["clubs"],
[
"/announcements",
"/api/announcement",
"/api/clubreporting",
"/api/communityboard",
"/api/groups",
"/api/playercheer",
"/api/playerevents",
"/api/playersubscriptions",
"/club",
"/members",
"/subscription",
]),
new(
ServiceNames.Commerce,
["commerce", "econ"],
[
"/api/avatar",
"/api/catalog",
"/api/compatibility",
"/api/consumables",
"/api/equipment",
"/api/gamerewards",
"/api/inventions",
"/api/purchase",
"/api/roomconsumables",
"/api/roomcurrencies",
"/api/storefronts",
"/commerce",
"/econ",
"/purchase",
"/purchasecampaign",
"/reminder",
]),
new(
ServiceNames.Platform,
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
[
"/api/cards",
"/api/config",
"/api/events",
"/api/quickplay",
"/api/role",
"/api/version",
"/api/versioncheck",
"/motd",
"/pageview",
"/ping",
"/role",
"/strings",
"/v1/ping",
"/v1/player",
"/v1/regions",
"/v1/services",
]),
new(
ServiceNames.Moderation,
["bugreporting", "moderation"],
[
"/api/admin",
"/api/banappeal",
"/api/bugreporting",
"/api/moderation",
"/api/playerreporting",
"/api/playersbanned",
"/api/sanitize",
"/api/testcasemanagement",
]),
new(
ServiceNames.Web,
["www", AdminHost, "feed"],
[
"/api/site",
],
IncludesApexHost: true),
];
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
public static bool IsInfrastructurePath(string path)
{
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase);
}
public static string ResolvePublicService(string? host, string path, string apexDomain)
{
var normalizedPath = NormalizePath(path);
var subdomain = Subdomain(host, apexDomain);
if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase))
{
return ServiceNames.Web;
}
foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
{
if (MatchesHost(subdomain, group.HostSubdomains))
{
return group.ServiceName;
}
}
foreach (var group in ServiceRouteGroups)
{
if (StartsWithAny(normalizedPath, group.PathPrefixes))
{
return group.ServiceName;
}
}
var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web);
if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains))
{
return ServiceNames.Web;
}
return ServiceNames.Monolith;
}
public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain)
{
if (IsInfrastructurePath(path))
{
return true;
}
return string.Equals(
ResolvePublicService(host, path, apexDomain),
serviceName,
StringComparison.OrdinalIgnoreCase);
}
public static string? Subdomain(string? host, string apexDomain)
{
if (string.IsNullOrWhiteSpace(host))
{
return null;
}
var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant();
var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedHost)
|| string.IsNullOrWhiteSpace(normalizedApex)
|| string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var suffix = "." + normalizedApex;
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return normalizedHost[..^suffix.Length];
}
var dot = normalizedHost.IndexOf('.');
return dot > 0 ? normalizedHost[..dot] : null;
}
private static bool StartsWithAny(string path, IEnumerable<string> prefixes)
{
foreach (var prefix in prefixes)
{
if (StartsWithPathSegment(path, prefix))
{
return true;
}
}
return false;
}
private static bool StartsWithPathSegment(string path, string prefix)
{
var normalizedPath = NormalizePath(path);
var normalizedPrefix = NormalizePath(prefix).TrimEnd('/');
if (string.IsNullOrWhiteSpace(normalizedPrefix))
{
normalizedPrefix = "/";
}
return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase)
|| normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase);
}
private static bool MatchesHost(string? subdomain, IEnumerable<string> candidates)
{
if (string.IsNullOrWhiteSpace(subdomain))
{
return false;
}
return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase);
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
return path[0] == '/' ? path : "/" + path;
}
}
public sealed record DorkNetRouteGroup(
string ServiceName,
string[] HostSubdomains,
string[] PathPrefixes,
bool IncludesApexHost = false);
@@ -1,38 +0,0 @@
namespace DorkNet.Contracts;
public sealed class DorkNetServiceMapOptions
{
public string Identity { get; set; } = "http://localhost:8081";
public string Rooms { get; set; } = "http://localhost:8082";
public string Notify { get; set; } = "http://localhost:8083";
public string Content { get; set; } = "http://localhost:8084";
public string Social { get; set; } = "http://localhost:8085";
public string Commerce { get; set; } = "http://localhost:8086";
public string Platform { get; set; } = "http://localhost:8087";
public string Moderation { get; set; } = "http://localhost:8088";
public string Web { get; set; } = "http://localhost:8089";
public string Monolith { get; set; } = "http://localhost:8090";
public IReadOnlyList<DorkNetServiceEndpoint> Endpoints()
{
DorkNetServiceEndpoint[] endpoints =
[
new(ServiceNames.Identity, Identity),
new(ServiceNames.Rooms, Rooms),
new(ServiceNames.Notify, Notify),
new(ServiceNames.Content, Content),
new(ServiceNames.Social, Social),
new(ServiceNames.Commerce, Commerce),
new(ServiceNames.Platform, Platform),
new(ServiceNames.Moderation, Moderation),
new(ServiceNames.Web, Web),
new(ServiceNames.Monolith, Monolith),
];
return endpoints
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
.ToArray();
}
}
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
-19
View File
@@ -1,19 +0,0 @@
namespace DorkNet.Contracts;
public sealed record ServiceHealthResponse(
string Service,
string Status,
DateTimeOffset CheckedAtUtc);
public sealed record ServiceCapabilityResponse(
string Service,
string[] Owns,
string[] PlannedPublicRoutes);
public sealed record ServiceProbeResponse(
string Service,
string BaseUrl,
string Status,
int? StatusCode,
string? Error,
DateTimeOffset CheckedAtUtc);
-16
View File
@@ -1,16 +0,0 @@
namespace DorkNet.Contracts;
public static class ServiceNames
{
public const string Gateway = "gateway";
public const string Identity = "identity";
public const string Rooms = "rooms";
public const string Notify = "notify";
public const string Content = "content";
public const string Social = "social";
public const string Commerce = "commerce";
public const string Platform = "platform";
public const string Moderation = "moderation";
public const string Web = "web";
public const string Monolith = "monolith";
}
-3751
View File
@@ -1,3751 +0,0 @@
// DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
//
// The shipping ClientMod carries ONLY the patches needed to connect and
// survive anti-cheat. All of the verbose diagnostic/tracing code lives
// here: HTTP header/request tracing, join/quit/studio/room-load traces,
// the process-level exception sink, auth-token tracing, the registration
// dialog trace, and the in-game DebugConsole enable/toggle path. Drop
// DorkNet.DebugMod.dll into the Mods folder next to DorkNet.ClientMod.dll
// to turn all of it on; remove it to ship a quiet, minimal client.
//
// It is a fully self-contained MelonMod (its own Harmony instance + its
// own copy of the reflection/patch helpers) so it neither needs nor
// couples to ClientMod at load time. Debug knobs are read from the same
// <MelonLoader>\UserData\dorknet-clientmod.json the ClientMod uses.
using HarmonyLib;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using MelonLoader;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DebugMod;
public class DebugMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DebugMod? Instance;
internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _httpDiagnosticsRegistered;
private bool _joinTracePatchesRegistered;
private bool _quitTraceCoreRegistered;
private bool _quitTraceGameComplete;
private int _quitTraceRetryFrame;
private bool _debugConsolePatchesComplete;
private int _debugConsoleRetryFrame;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private bool _processExceptionHandlersRegistered;
private bool _firstUpdateLogged;
private bool _devCommandsDumped;
private int _devCommandsDumpFrame;
private int _devCommandsDumpTries;
public static class Cfg
{
// Log-only trace for the stuck first-dorm "Change Username?" dialog.
public static bool TraceRegistrationDialog = true;
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors so
// movement/time commands don't drop you to the dorm. Default OFF. The
// toggle key is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static string[] DevCommands = Array.Empty<string>();
// Read-only one-shot: dump the full runtime command table to
// UserData/dorknet-devcommands.txt. Default OFF.
public static bool DumpDevCommands = false;
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DebugMod loading ===");
LoadConfig();
}
public override void OnLateInitializeMelon()
{
Log.Msg("=== Registering diagnostic patches ===");
RegisterProcessExceptionHandlers();
RegisterHttpDiagnostics();
RegisterQuitTracePatches(logMisses: true);
RegisterStudioTracePatches();
RegisterJoinTracePatches();
// Auth-token setter trace (LBNJFPOLCDL.JHLKGBGJKKK(access, refresh, key)).
// The shipping ClientMod used to register this unconditionally; it now
// lives here with the rest of the diagnostics.
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
args: new[] { typeof(string), typeof(string), typeof(string) },
prefix: nameof(AuthPatches.CaptureTokenArgs_Prefix));
// Deep game-side diagnostics: CheatManager tamper-detection logging,
// the Unity Debug.LogError/LogException sink, Photon connect/join
// tracing, and the room-load MoveNext state dumps. Unwired in the
// shipping client (kept out of the minimal footprint); this is their
// home now that diagnostics are opt-in.
RegisterDiagnostics();
if (Cfg.EnableDebugConsole)
RegisterDebugConsolePatches(logMisses: true);
Log.Msg("=== Diagnostic patches registered ===");
}
public override void OnUpdate()
{
if (!_firstUpdateLogged)
{
_firstUpdateLogged = true;
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
}
if (!_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
{
_quitTraceRetryFrame = 0;
RegisterQuitTracePatches(logMisses: false);
}
if (Cfg.EnableDebugConsole && !_debugConsolePatchesComplete && ++_debugConsoleRetryFrame >= 300)
{
_debugConsoleRetryFrame = 0;
RegisterDebugConsolePatches(logMisses: false);
}
if (Cfg.EnableDebugConsole)
DebugConsolePatches.PollToggleKey();
// Retry the read-only command dump every ~5s (the config singleton only
// exists after the ~26s load + dorm entry). Stops as soon as it finds a
// populated runtime table, or after ~2.5 min of attempts.
if (Cfg.DumpDevCommands && !_devCommandsDumped && ++_devCommandsDumpFrame >= 300)
{
_devCommandsDumpFrame = 0;
if (DebugConsolePatches.DumpCommandDiagnostics() || ++_devCommandsDumpTries >= 30)
_devCommandsDumped = true;
}
}
// ── Config ────────────────────────────────────────────────────────
private static void LoadConfig()
{
try
{
var userData = ResolveUserDataDirectory();
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
Log.Msg($"[config] no file at {path}; using debug defaults");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (TryGetConfigValue(r, "TraceRegistrationDialog", out var v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DumpDevCommands", out v)) Cfg.DumpDevCommands = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var commands = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
commands.Add(e.GetString()!);
Cfg.DevCommands = commands.ToArray();
}
Log.Msg($"[config] debug: debugConsole={Cfg.EnableDebugConsole}, " +
$"dumpDevCommands={Cfg.DumpDevCommands}, devCommands={Cfg.DevCommands.Length}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
}
}
private MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { typeof(HttpTracePatches), typeof(AuthPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(MakerPenGiftPreviewPatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
try
{
var method = methodName == "ctor"
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
: (args is null ? AccessTools.Method(target, methodName)
: AccessTools.Method(target, methodName, args));
if (method is null)
{
// `logMiss` is throttled by the retry-loop caller — once per
// 5 seconds in OnUpdate, every time in the one-shot initial
// RegisterDiagnostics. Without the gate the per-second retry
// loop would spam `[patch-miss]` lines forever for any method
// whose name drifted in a newer game build.
if (logMiss) Log.Warning($"[patch-miss] {label}");
return false;
}
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {label}");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {label}: {ex.Message}");
return false;
}
}
private bool TryPatchByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss);
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
if (_diagnosticPatchLabels.Contains(label))
return true;
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
return false;
_diagnosticPatchLabels.Add(label);
return true;
}
internal static Type? ResolveType(string name)
{
if (ResolvedTypeCache.TryGetValue(name, out var cached))
return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t);
var dottedPrefixed = asm.GetType("Il2Cpp." + name, throwOnError: false);
if (dottedPrefixed is not null) return CacheResolvedType(name, dottedPrefixed);
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
}
}
catch { /* unloaded / dynamic asm — skip */ }
}
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var t in GetLoadableTypes(asm))
{
if (MatchesRequestedType(t, name))
return CacheResolvedType(name, t);
}
}
return null;
}
private static Type CacheResolvedType(string name, Type type)
{
ResolvedTypeCache[name] = type;
return type;
}
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
{
try
{
return asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
var types = new List<Type>();
foreach (var t in ex.Types)
{
if (t is not null) types.Add(t);
}
return types;
}
catch
{
return Array.Empty<Type>();
}
}
private static bool MatchesRequestedType(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
var dottedPrefixed = "Il2Cpp." + requested;
if (type.FullName == requested || type.FullName == prefixed || type.FullName == dottedPrefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
var names = new List<string>();
foreach (var arg in args)
names.Add(arg.Name);
return $"{typeName}.{methodName}({string.Join(",", names)})";
}
private static string FormatParameterTypes(ParameterInfo[] parameters)
{
var names = new List<string>();
foreach (var parameter in parameters)
names.Add(parameter.ParameterType.Name);
return string.Join(",", names);
}
private static bool TypeNameContains(Type type, string value)
{
var fullName = type.FullName ?? type.Name;
return fullName.Contains(value, StringComparison.Ordinal);
}
private static bool MethodNameContains(MethodInfo method, string value)
=> method.Name.Contains(value, StringComparison.Ordinal);
private static string ResolveUserDataDirectory()
{
foreach (var typeName in new[]
{
"MelonLoader.MelonEnvironment, MelonLoader",
"MelonLoader.MelonUtils, MelonLoader",
})
{
var type = Type.GetType(typeName);
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
return value;
}
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
return Path.Combine(gameDir, "MelonLoader", "UserData");
}
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
args: Type.EmptyTypes,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
return false;
}
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
return true;
}
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
{
foreach (var method in AccessTools.GetDeclaredMethods(nested))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
}
}
}
if (patched == 0 && logMiss)
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
return patched > 0;
}
private void RegisterHttpDiagnostics()
{
if (_httpDiagnosticsRegistered) return;
var complete = true;
complete &= TryPatchBndRequestBuilderDiagnostics();
complete &= TryPatchDiagnosticAllOverloads("BestHTTP.HTTPRequest", "CallCallback",
postfix: nameof(HttpTracePatches.CallCallback_Postfix),
finalizer: nameof(HttpTracePatches.CallCallback_Finalizer));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "SetHeader",
args: new[] { typeof(string), typeof(string) },
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
_httpDiagnosticsRegistered = complete;
}
private bool TryPatchBndRequestBuilderDiagnostics()
{
const string label = "BNDIAONDFFF.request-builder";
if (_diagnosticPatchLabels.Contains(label))
return true;
var type = ResolveType("BNDIAONDFFF");
if (type is null)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder: type not found");
return false;
}
var prefix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix)));
var postfix = new HarmonyMethod(GetPatchMethod(nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix)));
var patched = 0;
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var parameters = ctor.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(ctor, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != "FILJELOLBKK") continue;
var parameters = method.GetParameters();
if (parameters.Length != 3 || parameters[2].ParameterType != typeof(string)) continue;
try
{
HarmonyInstance.Patch(method, prefix: prefix, postfix: postfix);
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BNDIAONDFFF.FILJELOLBKK({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] BNDIAONDFFF request-builder overloads");
return false;
}
_diagnosticPatchLabels.Add(label);
Log.Msg($"[patch-ok] BNDIAONDFFF request-builder x{patched}");
return true;
}
private void RegisterQuitTracePatches(bool logMisses)
{
if (!_quitTraceCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(QuitTracePatches.ApplicationQuit_Prefix),
logMiss: logMisses);
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(QuitTracePatches.ApplicationQuitInt_Prefix),
logMiss: logMisses);
_quitTraceCoreRegistered = true;
}
var complete = true;
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "GDNAOLMIDLM",
prefix: nameof(QuitTracePatches.ApplicationWantsToQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("OPEOLPDLEBO", "DGHJGMBNHCL",
prefix: nameof(QuitTracePatches.CreateExitTask_Prefix),
logMiss: logMisses);
_quitTraceGameComplete = complete;
}
private void RegisterDiagnostics()
{
Log.Msg("=== Registering diagnostics ===");
if (!_diagnosticCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
_diagnosticCoreRegistered = true;
}
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
Log.Msg("=== Diagnostics registered ===");
}
private bool RegisterGameDiagnostics(bool logMisses)
{
// CheatManager fires these when its anti-cheat heuristics trip —
// logging them surfaces "client thinks it's been tampered with",
// a common cause of silent dorm-drops / kicks on a modded client.
// Names verified in CheatManager.txt for 2020.12.18; the older
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
// there's no DeveloperFlag/StreamingAsset callback nor a
// BootSequence.RegisterError sink in this build — those legacy
// hooks were removed (they only ever logged [patch-miss] forever).
foreach (var method in new[]
{
"OnTimeCheatDetected",
"OnObscuredTypeCheatDetected",
"OnHeightCheatDetected",
"OnAdvancedMovementCheatDetected",
})
{
TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
RegisterDeepRoomDiagnostics(logMisses);
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
// pure-logging diagnostics that fired during Unity's very first
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
// methods) MonoMod's lazy CompileMethodHook on the trampoline
// crashes the CLR with 0x80131506 the instant Awake JITs. The
// hooks gave us no behavioral leverage — only a `[call] …` log
// line — so they're disabled. If anyone needs Steam-side visibility
// later, route it through the game's own log lines instead.
return true;
}
private void RegisterDebugConsolePatches(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
"Awake",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.Awake_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("RecRoom.Debugging.DebugConsole",
"InputField_OnEndEdit",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.InputFieldOnEndEdit_Prefix),
logMiss: logMisses);
// Dev commands that change movement/time trip retail anti-cheat
// callbacks. Suppress the three callbacks present in the 2023.03.21
// dump; keep the legacy height detector as a best-effort optional
// patch for adjacent builds without keeping this retry loop alive.
complete &= TryPatchDiagnosticByName("CheatManager",
"OnTimeCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("CheatManager",
"OnObscuredTypeCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("CheatManager",
"OnAdvancedMovementCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: logMisses);
TryPatchDiagnosticByName("CheatManager",
"OnHeightCheatDetected",
args: Type.EmptyTypes,
prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix),
logMiss: false);
_debugConsolePatchesComplete = complete;
if (complete)
Log.Msg("[debugconsole] restoration patches armed");
}
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
string? prefix = null, string? postfix = null,
string? finalizer = null, bool logMiss = true)
{
var label = $"{typeName}.{methodName}(*)";
if (_diagnosticPatchLabels.Contains(label))
return true;
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
return false;
}
_diagnosticPatchLabels.Add(label);
Log.Msg($"[patch-ok] {label} x{patched}");
return true;
}
private bool RegisterDeepRoomDiagnostics(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
args: new[] { typeof(object) },
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
args: new[] { typeof(Exception) },
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
logMiss: logMisses);
foreach (var method in new[]
{
"ConnectUsingSettings",
"ConnectToRegion",
"JoinRoom",
"JoinOrCreateRoom",
"CreateRoom",
"LeaveRoom",
"Disconnect",
})
{
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
logMiss: logMisses);
}
foreach (var item in new (string Type, string Method)[]
{
("CMMIJKCDKPL", "CDNPLAGEDBK"),
("KGHEDLNPANK", "CDNPLAGEDBK"),
("HMPNINFEDFB", "IJIOJAIBPKE"),
("PhotonNetwork", "Disconnect"),
})
{
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
logMiss: logMisses);
}
complete &= PatchKnownRoomLoadMoveNext(logMisses);
return true;
}
private void RegisterStudioTracePatches()
{
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "RegisterPrefabs",
postfix: nameof(RoomLoadDiagnostics.StudioRegisterPrefabs_Postfix));
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "TryGetPrefab",
postfix: nameof(RoomLoadDiagnostics.StudioTryGetPrefab_Postfix));
TryPatchByName("RecRoom.Core.Studio.RecRoomObjectPrefabManager", "IsKnownPrefab",
postfix: nameof(RoomLoadDiagnostics.StudioIsKnownPrefab_Postfix));
TryPatchByName("NLDBPDCNNCF", "FKLDMPMFLBD",
prefix: nameof(RoomLoadDiagnostics.StudioUnityAssetFetch_Prefix));
}
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
{
const string label = "room-load-state-machines.MoveNext";
if (_diagnosticPatchLabels.Contains(label))
return true;
var nameNeedles = new[]
{
"OMJKHJLFOCO", "AFMPFLKGABO",
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
};
var patched = 0;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in GetLoadableTypes(asm))
{
var fullName = type.FullName ?? type.Name;
var match = false;
foreach (var needle in nameNeedles)
{
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
{
match = true;
break;
}
}
if (!match) continue;
IEnumerable<MethodInfo> methods;
try { methods = AccessTools.GetDeclaredMethods(type); }
catch { continue; }
foreach (var method in methods)
{
if (method.Name != "MoveNext") continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method,
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
patched++;
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
}
}
}
}
if (patched == 0)
{
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
return false;
}
_diagnosticPatchLabels.Add(label);
return true;
}
private void RegisterProcessExceptionHandlers()
{
if (_processExceptionHandlersRegistered) return;
_processExceptionHandlersRegistered = true;
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try
{
var ex = e.ExceptionObject as Exception;
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
}
catch { }
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
try
{
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
}
catch { }
};
}
}
internal static class RoomLoadDiagnostics
{
private static readonly Dictionary<string, int> Counts = new();
private static int _globalCount;
public static void LogError_Prefix(object? message)
{
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
WriteStack("unity-error");
}
public static void LogException_Prefix(Exception exception)
{
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
WriteStack("unity-exception");
}
public static void NamedCall_Prefix(MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("call:" + name, 80)) return;
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
if (IsImportantCall(name))
WriteStack("runtime-call");
}
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
WriteStack("runtime-call-exception");
}
return __exception;
}
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("enter:" + name, 120)) return;
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
}
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
{
var name = FullMethodName(__originalMethod);
if (!ShouldLog("exit:" + name, 120)) return;
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
}
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write(
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
WriteStack("room-load-exception");
}
return __exception;
}
// ── Studio baked-asset load trace ──────────────────────────────────
// Pinpoints where the baked-Studio-scene load chain breaks. The room
// joins but renders no custom geometry and logs "Expected a prefab,
// but found none"; the server serves everything but the client never
// requests a bundle. These probes answer: (1) does the client ever
// call the RecNet "get unity asset" API (FKLDMPMFLBD); (2) does it
// ever register baked prefabs; (3) which prefab GUIDs does it look up
// and miss.
/// <summary>RecNet unity-asset getter
/// <c>NLDBPDCNNCF.FKLDMPMFLBD(string unityAssetId, byte target, int version, CancellationToken)</c>
/// — the call that builds <c>unity_assets/{id}/{target}/{version}</c>. If
/// this never fires, the client decided not to fetch the bundle at all
/// (trigger missing upstream); if it fires, the URL/host/response is the
/// problem.</summary>
public static void StudioUnityAssetFetch_Prefix(object? __0, object? __1, object? __2)
{
DiagnosticPatches.Write(
$"[studio] UNITY-ASSET FETCH id={__0?.ToString() ?? "<null>"} " +
$"target={__1?.ToString() ?? "?"} version={__2?.ToString() ?? "?"}");
WriteStack("studio-assetfetch");
}
/// <summary><c>RecRoomObjectPrefabManager.RegisterPrefabs</c> — baked
/// prefabs being registered after a bundle loads. If this never fires,
/// no bundle was loaded.</summary>
public static void StudioRegisterPrefabs_Postfix(object? __0)
{
int count = -1;
try
{
if (__0 is not null)
{
var p = __0.GetType().GetProperty("Count");
if (p?.GetValue(__0) is int c) count = c;
}
}
catch { /* best-effort count */ }
DiagnosticPatches.Write($"[studio] RegisterPrefabs count={count}");
WriteStack("studio-registerprefabs");
}
/// <summary><c>RecRoomObjectPrefabManager.TryGetPrefab(Guid, out GameObject)</c>
/// — the lookup that fails with "Expected a prefab, but found none".
/// Logs the GUID + whether it resolved.</summary>
public static void StudioTryGetPrefab_Postfix(object? __0, bool __result)
{
if (!ShouldLog("studio-trygetprefab", 200)) return;
DiagnosticPatches.Write(
$"[studio] TryGetPrefab guid={__0?.ToString() ?? "<null>"} found={__result}");
}
/// <summary><c>RecRoomObjectPrefabManager.IsKnownPrefab(Guid)</c>.</summary>
public static void StudioIsKnownPrefab_Postfix(object? __0, bool __result)
{
if (!ShouldLog("studio-isknownprefab", 200)) return;
DiagnosticPatches.Write(
$"[studio] IsKnownPrefab guid={__0?.ToString() ?? "<null>"} known={__result}");
}
public static string FormatException(Exception? ex, object? fallback)
{
if (ex is null) return fallback?.ToString() ?? "<null>";
var builder = new StringBuilder();
AppendException(builder, ex, 0);
return Sanitize(builder.ToString(), 5000);
}
private static void AppendException(StringBuilder builder, Exception ex, int depth)
{
if (depth > 4) return;
if (builder.Length > 0) builder.Append(" | ");
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
builder.Append(" stack=").Append(ex.StackTrace);
if (ex is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions)
AppendException(builder, inner, depth + 1);
}
else if (ex.InnerException is not null)
{
AppendException(builder, ex.InnerException, depth + 1);
}
}
private static bool ShouldLog(string key, int perKeyLimit)
{
lock (Counts)
{
_globalCount++;
if (_globalCount > 12000)
return false;
Counts.TryGetValue(key, out var count);
if (count >= perKeyLimit)
return false;
Counts[key] = count + 1;
return true;
}
}
private static string DumpInstanceFields(object? instance, int maxFields)
{
if (instance is null) return "<null>";
try
{
var type = instance.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? value;
try { value = field.GetValue(instance); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
var described = DescribeValue(value, 400);
if (ShouldExpandField(field, value))
described += " " + DumpNestedFields(value, 16);
parts.Add($"{field.Name}={described}");
}
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
}
catch (Exception ex)
{
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
}
}
private static bool ShouldExpandField(FieldInfo field, object? value)
{
if (value is null) return false;
if (value is string) return false;
var valueType = value.GetType();
if (valueType.IsPrimitive || valueType.IsEnum || value is decimal)
return false;
if (value is IEnumerable)
return false;
var fieldName = field.Name ?? string.Empty;
var typeName = valueType.FullName ?? valueType.Name;
return fieldName.IndexOf("this", StringComparison.OrdinalIgnoreCase) >= 0 ||
typeName.IndexOf("OMJKHJLFOCO", StringComparison.Ordinal) >= 0 ||
typeName.IndexOf("RoomPermissions", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string DumpNestedFields(object value, int maxFields)
{
try
{
var type = value.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? nested;
try { nested = field.GetValue(value); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
parts.Add($"{field.Name}={DescribeValue(nested, 220)}");
}
return "{nested " + string.Join("; ", parts) + "}";
}
catch (Exception ex)
{
return $"{{nested dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 220)}}}";
}
}
private static string DescribeValue(object? value, int limit)
{
if (value is null) return "null";
var type = value.GetType();
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
if (value is Exception ex) return FormatException(ex, ex);
if (type.IsPrimitive || type.IsEnum || value is decimal)
return Sanitize(value.ToString() ?? "<null>", limit);
if (value is IEnumerable enumerable && value is not string)
return DescribeEnumerable(enumerable, type, limit);
var rendered = value.ToString();
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
return "<" + type.FullName + ">";
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
}
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
{
var parts = new List<string>();
var count = 0;
foreach (var item in enumerable)
{
if (count++ >= 6)
{
parts.Add("...");
break;
}
parts.Add(DescribeValue(item, 120));
}
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
}
private static string Signature(MethodBase method)
{
try
{
var parameters = method.GetParameters();
var parts = new List<string>(parameters.Length);
foreach (var parameter in parameters)
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
return string.Join(", ", parts);
}
catch
{
return "<signature unread>";
}
}
private static string FullMethodName(MethodBase method) =>
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
private static bool IsImportantCall(string name) =>
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
private static void WriteStack(string tag)
{
try
{
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
}
}
private static string Sanitize(string value, int limit)
{
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _pollErrorLogged;
private static int _probeFrame;
private static bool _probeDone;
private static bool _inputResolved;
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
private static object? _toggleKeyValue; // boxed KeyCode enum value
private static bool _consoleResolved;
private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _setConsoleText; // DebugConsole.SetConsoleText(string)
private static List<ConfiguredCommand>? _commandCache;
// Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path.
public static bool SuppressCheatDetected_Prefix() => false;
public static bool Awake_Prefix()
{
return false;
}
public static bool InputFieldOnEndEdit_Prefix(object __instance)
{
var command = ReadConsoleInput(__instance);
if (string.IsNullOrWhiteSpace(command))
return true;
ExecuteCommand(command, __instance);
return false;
}
public static string FormatConfiguredCommandsForLog()
{
var commands = GetAvailableCommands();
if (commands.Count == 0) return "<none>";
var parts = new List<string>();
foreach (var command in commands)
parts.Add($"{command.Label}={command.Command}");
return string.Join(", ", parts);
}
// Polled every frame from DebugMod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
try
{
if (!_inputResolved) ResolveInput();
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
// console object actually exists, so we learn that even if the
// hotkey never fires.
if (!_probeDone && _probeFrame++ > 180)
{
_probeDone = true;
ProbeConsole();
}
if (_getKeyDown is null || _toggleKeyValue is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
{
DebugMod.Log.Msg($"[debugconsole] toggle key '{DebugMod.Cfg.DebugConsoleToggleKey}' detected");
Toggle();
}
}
catch (Exception ex)
{
if (!_pollErrorLogged) { _pollErrorLogged = true; DebugMod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
}
}
// Diagnostic: reports whether a live DebugConsole component exists and
// whether the show/hide method resolved — without needing a keypress.
private static void ProbeConsole()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = FindConsoleInstance();
DebugMod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
}
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
}
public static void ExecuteCommand(string command) => ExecuteCommand(command, consoleInstance: null);
private static void ExecuteCommand(string command, object? consoleInstance)
{
if (string.IsNullOrWhiteSpace(command))
return;
var output = RunCommand(command);
if (consoleInstance is null)
{
if (!_consoleResolved) ResolveConsole();
consoleInstance = FindConsoleInstance();
}
if (consoleInstance is not null)
WriteConsoleOutput(consoleInstance, output);
var parts = SplitCommandLine(command);
var verb = parts.Count > 0 ? parts[0] : "<empty>";
DebugMod.Log.Msg($"[debugconsole] command '{CleanLabel(verb)}': {FirstLine(output)}");
}
private static string RunCommand(string commandLine)
{
var tokens = SplitCommandLine(commandLine);
if (tokens.Count == 0)
return string.Empty;
var verb = tokens[0];
if (IsHelpCommand(verb))
return BuildCommandReferenceText("Debug Console Commands", maxCommands: 120);
var args = tokens.GetRange(1, tokens.Count - 1);
if (!TryFindCommand(verb, out var command))
return $"Unknown debug command: {verb}\n\n{BuildCommandReferenceText("Available Debug Console Commands", maxCommands: 80)}";
if (!command.HasRuntimeTarget)
{
var expanded = SplitCommandLine(command.Command);
if (expanded.Count == 0)
return $"Debug command '{verb}' has no backing command.";
var expandedArgs = expanded.Count > 1 ? expanded.GetRange(1, expanded.Count - 1) : new List<string>();
expandedArgs.AddRange(args);
if (!TryFindCommand(expanded[0], out command) || !command.HasRuntimeTarget)
return $"Debug command '{verb}' points at '{command.Command}', but no runtime command metadata was found.";
args = expandedArgs;
}
if (!TryResolveCommandType(command, out var targetType, out var typeError))
return typeError;
if (!TryFindCallableMethod(targetType!, command.MethodName, args, out var method, out var converted, out var methodError))
return methodError;
object? target = null;
if (!method!.IsStatic)
{
target = FindFirstInstance(targetType!);
if (target is null)
return $"Command '{command.MethodName}' needs a live {targetType!.Name} instance, but none was found.";
}
try
{
var result = method.Invoke(target, converted);
var suffix = result is null ? string.Empty : $"\nResult: {FormatResult(result)}";
return $"Executed: {command.Label}{suffix}";
}
catch (TargetInvocationException ex)
{
var inner = ex.InnerException ?? ex;
return $"Command '{command.MethodName}' failed: {inner.GetType().Name}: {inner.Message}";
}
catch (Exception ex)
{
return $"Command '{command.MethodName}' failed: {ex.GetType().Name}: {ex.Message}";
}
}
private static bool IsHelpCommand(string verb) =>
string.Equals(verb, "help", StringComparison.OrdinalIgnoreCase) ||
string.Equals(verb, "?", StringComparison.OrdinalIgnoreCase) ||
string.Equals(verb, "ListRegisteredCommands", StringComparison.OrdinalIgnoreCase);
private static bool TryResolveCommandType(ConfiguredCommand command, out Type? type, out string error)
{
foreach (var name in new[] { command.ObfuscatedDeclaringType, command.DeclaringType })
{
var normalized = NormalizeTypeName(name);
if (string.IsNullOrWhiteSpace(normalized)) continue;
type = DebugMod.ResolveType(normalized);
if (type is not null)
{
error = string.Empty;
return true;
}
}
type = null;
error = $"Command '{command.MethodName}' has no resolvable declaring type (declaring='{command.DeclaringType}', obfuscated='{command.ObfuscatedDeclaringType}').";
return false;
}
private static string NormalizeTypeName(string value)
{
value = (value ?? string.Empty).Trim();
var comma = value.IndexOf(',');
return comma >= 0 ? value[..comma].Trim() : value;
}
private static bool TryFindCallableMethod(Type type, string methodName, List<string> args,
out MethodInfo? method, out object?[] converted, out string error)
{
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (candidate.Name != methodName) continue;
if (candidate.IsAbstract || candidate.ContainsGenericParameters) continue;
if (TryConvertArguments(candidate.GetParameters(), args, out converted))
{
method = candidate;
error = string.Empty;
return true;
}
}
method = null;
converted = Array.Empty<object?>();
error = $"No overload of {type.Name}.{methodName} accepts {args.Count} argument(s): {string.Join(" ", args)}";
return false;
}
private static bool TryConvertArguments(ParameterInfo[] parameters, List<string> args, out object?[] converted)
{
converted = new object?[parameters.Length];
var argIndex = 0;
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
if (IsVector3(parameter.ParameterType) && args.Count - argIndex >= 3)
{
if (!TryParseFloat(args[argIndex], out var x) ||
!TryParseFloat(args[argIndex + 1], out var y) ||
!TryParseFloat(args[argIndex + 2], out var z) ||
!TryCreateVector3(parameter.ParameterType, x, y, z, out converted[i]))
return false;
argIndex += 3;
continue;
}
if (argIndex >= args.Count)
{
if (parameter.HasDefaultValue)
{
converted[i] = parameter.DefaultValue;
continue;
}
return false;
}
if (!TryConvertArgument(args[argIndex], parameter.ParameterType, out converted[i]))
return false;
argIndex++;
}
return argIndex == args.Count;
}
private static bool TryConvertArgument(string value, Type targetType, out object? converted)
{
converted = null;
if (targetType == typeof(string) || targetType.FullName == "System.String")
{
converted = value;
return true;
}
var nullable = Nullable.GetUnderlyingType(targetType);
if (nullable is not null)
targetType = nullable;
if (targetType == typeof(bool))
{
if (bool.TryParse(value, out var b))
{
converted = b;
return true;
}
if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
value == "1")
{
converted = true;
return true;
}
if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
value == "0")
{
converted = false;
return true;
}
return false;
}
if (targetType.IsEnum)
{
try
{
converted = Enum.Parse(targetType, value, ignoreCase: true);
return true;
}
catch { return false; }
}
try
{
if (targetType == typeof(int)) { converted = int.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(long)) { converted = long.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(float)) { converted = float.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(double)) { converted = double.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(short)) { converted = short.Parse(value, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(byte)) { converted = byte.Parse(value, CultureInfo.InvariantCulture); return true; }
}
catch { return false; }
return false;
}
private static bool IsVector3(Type type) =>
type.Name == "Vector3" && (type.Namespace == "UnityEngine" || type.FullName?.EndsWith(".Vector3", StringComparison.Ordinal) == true);
private static bool TryParseFloat(string value, out float result) =>
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result);
private static bool TryCreateVector3(Type vectorType, float x, float y, float z, out object? vector)
{
vector = null;
try
{
var ctor = vectorType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) });
if (ctor is null) return false;
vector = ctor.Invoke(new object[] { x, y, z });
return true;
}
catch { return false; }
}
private static object? FindFirstInstance(Type type)
{
try
{
var il2cppType = ToIl2CppType(type);
if (il2cppType is null) return null;
var resources = DebugMod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
{
var item = ArrItem(all, i);
if (item is not null) return item;
}
}
catch { }
return null;
}
private static string FormatResult(object result)
{
var text = result.ToString() ?? result.GetType().Name;
return text.Length <= 500 ? text : text[..500] + "...";
}
private static string FirstLine(string value)
{
value = value.Replace("\r", "");
var idx = value.IndexOf('\n');
return idx >= 0 ? value[..idx] : value;
}
private static List<string> SplitCommandLine(string value)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(value)) return result;
var sb = new StringBuilder();
var inQuotes = false;
var quote = '\0';
var escaping = false;
foreach (var ch in value.Trim())
{
if (escaping)
{
sb.Append(ch);
escaping = false;
continue;
}
if (ch == '\\')
{
escaping = true;
continue;
}
if (inQuotes)
{
if (ch == quote)
{
inQuotes = false;
continue;
}
sb.Append(ch);
continue;
}
if (ch is '"' or '\'')
{
inQuotes = true;
quote = ch;
continue;
}
if (char.IsWhiteSpace(ch))
{
if (sb.Length > 0)
{
result.Add(sb.ToString());
sb.Clear();
}
continue;
}
sb.Append(ch);
}
if (escaping) sb.Append('\\');
if (sb.Length > 0) result.Add(sb.ToString());
return result;
}
private static void ResolveInput()
{
_inputResolved = true;
var keyCodeType = DebugMod.ResolveType("UnityEngine.KeyCode");
var inputType = DebugMod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
DebugMod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKeyValue = Enum.Parse(keyCodeType, DebugMod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
catch
{
DebugMod.Log.Warning($"[debugconsole] '{DebugMod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) DebugMod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
else DebugMod.Log.Msg($"[debugconsole] hotkey armed: '{DebugMod.Cfg.DebugConsoleToggleKey}' (poll live)");
}
private static void ResolveConsole()
{
_consoleResolved = true;
_consoleType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsole");
if (_consoleType is null) { DebugMod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
// name, then a structural fallback that fits both builds.
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "KIAMDEDKFBC", new[] { typeof(bool) })
?? FindToggleByShape(_consoleType);
if (_toggleMethod is null) DebugMod.Log.Warning("[debugconsole] show/hide method not found");
}
// The console's show/hide is the single private, instance, void method
// taking exactly one bool, excluding compiler-generated members (the
// property backing setter on 2020.12 is also void(bool) but is marked
// [CompilerGenerated]).
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null; // ambiguous — don't guess
found = m;
}
return found;
}
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null) return;
try
{
var instance = FindConsoleInstance();
if (instance is null)
{
DebugMod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
return;
}
// Best-effort: flip the static "show button" enable so the
// game's own Update keeps the console available instead of
// re-hiding it. Failures are ignored (obfuscated builds).
EnableShowButton();
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
if (_consoleVisible)
ShowConfiguredCommands(instance);
DebugMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex) { DebugMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
}
private static object? FindConsoleInstance()
{
if (_consoleType is null) return null;
try
{
var il2cppType = ToIl2CppType(_consoleType);
if (il2cppType is null) return null;
var resources = DebugMod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
{
var item = ArrItem(all, i);
if (item is not null) return item;
}
var objType = DebugMod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] FindConsoleInstance failed: {ex.Message}");
return null;
}
}
private static void ShowConfiguredCommands(object instance)
{
try
{
if (_consoleType is null) return;
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
if (_setConsoleText is null)
{
DebugMod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
return;
}
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
DebugMod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] failed to display configured commands: {ex.Message}");
}
}
private static string BuildConfiguredCommandText()
{
return BuildCommandReferenceText("DorkNet debug commands", maxCommands: 80) + "\n\nType a command and press Enter.";
}
private static string ReadConsoleInput(object consoleInstance)
{
var input = FindConsoleInput(consoleInstance);
return input is null ? string.Empty : ReadText(input);
}
private static void WriteConsoleOutput(object consoleInstance, string text)
{
try
{
if (_consoleType is not null)
{
_setConsoleText ??= AccessTools.Method(_consoleType, "SetConsoleText", new[] { typeof(string) });
if (_setConsoleText is not null && _setConsoleText.DeclaringType?.IsInstanceOfType(consoleInstance) == true)
{
try
{
_setConsoleText.Invoke(consoleInstance, new object[] { text });
return;
}
catch (TargetException)
{
// The live IL2CPP proxy can differ from the resolved wrapper type.
}
}
}
var input = FindConsoleInput(consoleInstance);
if (input is not null)
SetText(input, text);
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] output write failed: {ex.Message}");
}
}
private static object? FindConsoleInput(object consoleInstance)
{
foreach (var name in new[] { "KECJGKLKFGP", "inputField", "InputField" })
{
var value = GetMemberValue(consoleInstance, name);
if (value is not null) return value;
}
var type = consoleInstance.GetType();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (!LooksLikeInputField(prop.PropertyType) || !prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try { if (prop.GetValue(consoleInstance) is { } value) return value; } catch { }
}
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (!LooksLikeInputField(field.FieldType)) continue;
try { if (field.GetValue(consoleInstance) is { } value) return value; } catch { }
}
return null;
}
private static bool LooksLikeInputField(Type type) =>
type.Name.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0 ||
type.FullName?.IndexOf("InputField", StringComparison.OrdinalIgnoreCase) >= 0;
private static string ReadText(object textObject)
{
try
{
var type = textObject.GetType();
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanRead && prop.GetValue(textObject) is string s)
return s;
var getter = type.GetMethod("get_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null)
?? type.GetMethod("get_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null);
return getter?.Invoke(textObject, Array.Empty<object>()) as string ?? string.Empty;
}
catch { return string.Empty; }
}
private static void SetText(object textObject, string text)
{
try
{
var type = textObject.GetType();
var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
?? type.GetProperty("Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanWrite)
{
prop.SetValue(textObject, text);
return;
}
var setter = type.GetMethod("set_text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null)
?? type.GetMethod("set_Text", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(string) }, null);
setter?.Invoke(textObject, new object[] { text });
}
catch { }
}
public static string BuildCommandReferenceText(string header, int maxCommands = int.MaxValue)
{
var commands = GetAvailableCommands();
var sb = new StringBuilder();
sb.AppendLine(header);
sb.AppendLine();
if (commands.Count == 0)
{
sb.AppendLine("No DebugConsoleCommandConfig commands found.");
}
else
{
var visible = Math.Min(commands.Count, maxCommands);
for (var i = 0; i < visible; i++)
sb.AppendLine($"{i + 1}. {commands[i].Label}: {commands[i].Command}");
if (visible < commands.Count)
{
sb.AppendLine();
sb.AppendLine($"+ {commands.Count - visible} more commands available through the debug console.");
}
}
return sb.ToString().TrimEnd();
}
internal static List<ConfiguredCommand> GetAvailableCommands()
{
if (_commandCache is not null) return _commandCache;
var commands = new List<ConfiguredCommand>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddConfiguredCommands(commands, seen);
AddRuntimeCommands(commands, seen);
commands.Sort((a, b) => string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase));
_commandCache = commands;
DebugMod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
return commands;
}
// Read-only one-shot diagnostic (Cfg.DumpDevCommands). Writes a full report
// to UserData/dorknet-devcommands.txt: does a live DebugConsole exist, and
// for every runtime command meta, does its declaring type resolve and does
// a method with that name exist on it (i.e. is it actually callable on this
// obfuscated build). Invokes NOTHING and touches no game UI — safe to run
// in a live session. This is what turns the text-console path from guessing
// into engineering: the resolvable/total tally says whether the feature is
// buildable as-is or needs an obfuscation name map.
// Returns true once it observes a populated runtime command table (so the
// caller can stop retrying); false while the config singleton isn't up yet.
public static bool DumpCommandDiagnostics()
{
var sb = new StringBuilder();
int runtime = 0;
void W(string s) { sb.Append(s).Append('\n'); DebugMod.Log.Msg("[devcmd] " + s); }
try
{
// Force a fresh scan — an early call (before the config singleton
// loads) must not poison the cache with an empty table.
_commandCache = null;
W("=== DorkNet dev-command diagnostic ===");
if (!_consoleResolved) ResolveConsole();
object? liveConsole = null;
try { liveConsole = FindConsoleInstance(); } catch (Exception ex) { W($"FindConsoleInstance threw: {ex.Message}"); }
W($"DebugConsole type resolved : {_consoleType?.FullName ?? "<none>"}");
W($"DebugConsole live instance : {(liveConsole is null ? "NULL (UI shell not instantiated — text console can't be shown, would need our own UI)" : "FOUND (UI can be driven)")}");
W($"toggle method : {_toggleMethod?.Name ?? "<none>"}");
W("");
List<ConfiguredCommand> commands;
try { commands = GetAvailableCommands(); }
catch (Exception ex) { W($"GetAvailableCommands threw: {ex}"); commands = new List<ConfiguredCommand>(); }
int typeOk = 0, methodOk = 0;
var samplesOk = new List<string>();
var samplesBad = new List<string>();
foreach (var c in commands)
{
if (!c.HasRuntimeTarget) continue;
runtime++;
var resolvedType = TryResolveCommandType(c, out var t, out _) ? t : null;
if (resolvedType is null)
{
if (samplesBad.Count < 30)
samplesBad.Add($" [no-type] {c.MethodName} (declaring='{c.DeclaringType}' obf='{c.ObfuscatedDeclaringType}')");
continue;
}
typeOk++;
var hasMethod = false;
foreach (var m in resolvedType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
if (m.Name == c.MethodName) { hasMethod = true; break; }
if (hasMethod)
{
methodOk++;
if (samplesOk.Count < 40) samplesOk.Add($" [OK] {resolvedType.Name}.{c.MethodName}");
}
else if (samplesBad.Count < 30)
{
samplesBad.Add($" [no-method] {resolvedType.Name}.{c.MethodName} (type resolved, method name not present → obfuscated/stripped)");
}
}
W($"total commands in table : {commands.Count}");
W($"runtime-target commands : {runtime}");
W($" declaring type resolves : {typeOk}");
W($" method callable by name : {methodOk} <-- this number decides feasibility");
W("");
W("--- sample CALLABLE commands ---");
foreach (var s in samplesOk) W(s);
W("");
W("--- sample UNRESOLVED commands ---");
foreach (var s in samplesBad) W(s);
W("");
W("--- FULL runtime command list (label | methodName | declaringType | obfuscatedDeclaringType) ---");
foreach (var c in commands)
{
if (!c.HasRuntimeTarget) continue;
sb.Append(" ").Append(c.Label).Append(" | ").Append(c.MethodName)
.Append(" | ").Append(c.DeclaringType).Append(" | ").Append(c.ObfuscatedDeclaringType).Append('\n');
}
}
catch (Exception ex)
{
sb.Append("DIAGNOSTIC FAILED: ").Append(ex).Append('\n');
DebugMod.Log.Warning($"[devcmd] diagnostic failed: {ex.Message}");
}
// Only persist the report once the table is actually populated, so an
// early empty attempt doesn't overwrite a good report from a later try.
if (runtime > 0)
{
try
{
var dir = Path.GetDirectoryName(DebugMod.DiagnosticsLogPath) ?? ".";
var outPath = Path.Combine(dir, "dorknet-devcommands.txt");
File.WriteAllText(outPath, sb.ToString());
DebugMod.Log.Msg($"[devcmd] wrote {outPath} ({runtime} runtime commands)");
}
catch (Exception ex) { DebugMod.Log.Warning($"[devcmd] could not write report: {ex.Message}"); }
}
else
{
DebugMod.Log.Msg("[devcmd] command table not populated yet — will retry");
}
return runtime > 0;
}
private static bool TryFindCommand(string verb, out ConfiguredCommand command)
{
var commands = GetAvailableCommands();
foreach (var candidate in commands)
{
if (!candidate.HasRuntimeTarget) continue;
if (CommandMatches(candidate, verb))
{
command = candidate;
return true;
}
}
foreach (var candidate in commands)
{
if (CommandMatches(candidate, verb))
{
command = candidate;
return true;
}
}
command = default;
return false;
}
private static bool CommandMatches(ConfiguredCommand command, string verb)
{
if (string.Equals(command.MethodName, verb, StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(command.Command, verb, StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(command.Label, verb, StringComparison.OrdinalIgnoreCase)) return true;
var shortLabel = command.Label;
var dot = shortLabel.LastIndexOf('.');
if (dot >= 0 && dot < shortLabel.Length - 1)
shortLabel = shortLabel[(dot + 1)..];
if (string.Equals(shortLabel, verb, StringComparison.OrdinalIgnoreCase)) return true;
var commandTokens = SplitCommandLine(command.Command);
return commandTokens.Count > 0 && string.Equals(commandTokens[0], verb, StringComparison.OrdinalIgnoreCase);
}
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
foreach (var entry in DebugMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var split = entry.IndexOf('=');
var label = split >= 0 ? entry[..split].Trim() : entry.Trim();
var command = split >= 0 ? entry[(split + 1)..].Trim() : entry.Trim();
if (string.IsNullOrWhiteSpace(command)) continue;
if (string.IsNullOrWhiteSpace(label)) label = command;
var parts = SplitCommandLine(command);
var methodName = parts.Count > 0 ? parts[0] : command;
AddCommand(commands, seen, label, command, declaringType: string.Empty, obfuscatedDeclaringType: string.Empty, methodName, isRuntime: false);
}
}
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
try
{
var cfgType = DebugMod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
if (cfgType is null)
{
DebugMod.Log.Warning("[debugconsole] DebugConsoleCommandConfig type not found");
return;
}
foreach (var method in cfgType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (method.GetParameters().Length != 0) continue;
if (!cfgType.IsAssignableFrom(method.ReturnType)) continue;
try { AddCommandConfig(method.Invoke(null, Array.Empty<object>()), commands, seen); }
catch { }
}
var il2cppType = ToIl2CppType(cfgType);
var resources = DebugMod.ResolveType("UnityEngine.Resources");
if (il2cppType is not null && resources is not null)
{
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType)?.Invoke(null, new[] { il2cppType });
var count = ArrLen(all);
for (var i = 0; i < count; i++)
AddCommandConfig(ArrItem(all, i), commands, seen);
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[debugconsole] runtime command scan failed: {ex.Message}");
}
}
private static void AddCommandConfig(object? config, List<ConfiguredCommand> commands, HashSet<string> seen)
{
if (config is null) return;
var metas = GetMemberValue(config, "Metas");
var count = ArrLen(metas);
for (var i = 0; i < count; i++)
{
var meta = ArrItem(metas, i);
if (meta is null) continue;
var methodName = ReadStringMember(meta, "MethodName");
if (string.IsNullOrWhiteSpace(methodName)) continue;
var declaringType = ReadStringMember(meta, "DeclaringType");
var obfuscatedDeclaringType = ReadStringMember(meta, "ObfuscatedDeclaringType");
var label = string.IsNullOrWhiteSpace(declaringType)
? methodName
: $"{ShortTypeName(declaringType)}.{methodName}";
AddCommand(commands, seen, label, methodName, declaringType, obfuscatedDeclaringType, methodName, isRuntime: true);
}
}
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command,
string declaringType, string obfuscatedDeclaringType, string methodName, bool isRuntime)
{
label = CleanLabel(label);
command = (command ?? string.Empty).Trim();
methodName = (methodName ?? string.Empty).Trim();
if (command.Length == 0 || methodName.Length == 0) return;
var key = isRuntime
? $"{NormalizeTypeName(obfuscatedDeclaringType)}|{NormalizeTypeName(declaringType)}|{methodName}"
: $"configured|{label}|{command}";
if (!seen.Add(key)) return;
commands.Add(new ConfiguredCommand(label, command, declaringType, obfuscatedDeclaringType, methodName, isRuntime));
}
private static object? GetMemberValue(object instance, string name)
{
var type = instance.GetType();
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
{
try { return prop.GetValue(instance); } catch { }
}
var field = type.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (field is not null)
{
try { return field.GetValue(instance); } catch { }
}
return null;
}
private static string ReadStringMember(object instance, string name)
{
return GetMemberValue(instance, name)?.ToString() ?? string.Empty;
}
private static string CleanLabel(string label)
{
label = (label ?? string.Empty).Trim();
if (label.Length == 0) return "Command";
var cleaned = new char[label.Length];
for (var i = 0; i < label.Length; i++)
{
var ch = label[i];
cleaned[i] = char.IsLetterOrDigit(ch) || ch is '.' or '_' or '-' or ' ' ? ch : '_';
}
return new string(cleaned);
}
private static string ShortTypeName(string typeName)
{
var idx = typeName.LastIndexOf('.');
return idx >= 0 && idx < typeName.Length - 1 ? typeName[(idx + 1)..] : typeName;
}
internal readonly struct ConfiguredCommand
{
public ConfiguredCommand(string label, string command, string declaringType,
string obfuscatedDeclaringType, string methodName, bool hasRuntimeTarget)
{
Label = label;
Command = command;
DeclaringType = declaringType;
ObfuscatedDeclaringType = obfuscatedDeclaringType;
MethodName = methodName;
HasRuntimeTarget = hasRuntimeTarget;
}
public string Label { get; }
public string Command { get; }
public string DeclaringType { get; }
public string ObfuscatedDeclaringType { get; }
public string MethodName { get; }
public bool HasRuntimeTarget { get; }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
try
{
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
{
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
}
}
catch { /* best-effort — the toggle still works without it */ }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = DebugMod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true }
: new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
private static MethodInfo? OneArgMethod(Type? type, string name, object arg)
{
if (type is null) return null;
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (method.Name != name) continue;
var parameters = method.GetParameters();
if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(arg))
return method;
}
return null;
}
private static int ArrLen(object? array)
{
if (array is null) return 0;
if (array is Array managedArray) return managedArray.Length;
var type = array.GetType();
var length = type.GetProperty("Length") ?? type.GetProperty("Count");
return (length?.GetValue(array) as int?) ?? 0;
}
private static object? ArrItem(object? array, int index)
{
if (array is null) return null;
if (array is Array managedArray) return managedArray.GetValue(index);
var getItem = array.GetType().GetMethod("get_Item", new[] { typeof(int) });
return getItem?.Invoke(array, new object[] { index });
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
DebugMod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class AuthPatches
{
private static string? _latestAccessToken;
private static int _tokenLogCount;
public static void CaptureTokenArgs_Prefix(object? __0, object? __1, object? __2)
{
try
{
var accessToken = __0?.ToString();
if (LooksLikeJwt(accessToken))
_latestAccessToken = accessToken;
if (_tokenLogCount++ < 3)
{
DiagnosticPatches.Write(
"[auth-trace] token setter " +
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
}
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[auth-trace] token capture failed: {ex.Message}");
}
}
private static bool LooksLikeJwt(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
var dots = 0;
foreach (var ch in value)
if (ch == '.') dots++;
return dots == 2;
}
private static string Presence(object? value) =>
string.IsNullOrWhiteSpace(value?.ToString()) ? "missing" : "present";
}
internal static class HttpTracePatches
{
private const int BodyPreviewBytes = 700;
private static readonly Regex SensitivePairRegex = new(
@"(?i)(password|client_secret|access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)",
RegexOptions.Compiled);
private static readonly Regex BearerRegex = new(
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
RegexOptions.Compiled);
private static readonly Regex SensitiveHeaderRegex = new(
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
RegexOptions.Compiled);
private static readonly Regex SensitiveJsonStringRegex = new(
@"(?i)""([^""]*(?:access_token|accessToken|refresh_token|refreshToken|authorization|token|secret|client_secret|key)[^""]*)""\s*:\s*""[^""]*""",
RegexOptions.Compiled);
private static readonly Dictionary<IntPtr, string> RecNetRequestInfoByPtr = new();
private static int _requestId;
private static int _failureLogCount;
private static int _responseTupleLogCount;
private static int _builderSendLogCount;
private static int _callbackLogCount;
private static int _uriUnreadDumpCount;
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
{
TraceRequestObject(__instance, $"{__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
public static void RecNetRequestBuilderCtor_Prefix(object? __0, object? __1, object? __2)
{
var id = NextRequestId();
DiagnosticPatches.Write(
$"[http-trace] #{id} BNDIAONDFFF.ctor method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
}
public static void RecNetRequestBuilderCtor_Postfix(object __instance, object? __0, object? __1, object? __2)
{
try
{
var ptr = TryGetIl2CppPtr(__instance);
if (ptr == IntPtr.Zero) return;
var info =
$"ptr=0x{ptr.ToInt64():X} method={SanitizeInline(__0?.ToString() ?? "<null>")} " +
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}";
lock (RecNetRequestInfoByPtr)
{
RecNetRequestInfoByPtr[ptr] = info;
if (RecNetRequestInfoByPtr.Count > 256)
RecNetRequestInfoByPtr.Clear();
}
DiagnosticPatches.Write($"[recnet-request-map] {info}");
}
catch (Exception ex)
{
LogFailure("recnet-request-map", ex);
}
}
public static void RecNetBuilderSend_Prefix(object __instance, MethodBase __originalMethod)
{
if (System.Threading.Interlocked.Increment(ref _builderSendLogCount) > 120) return;
try
{
DiagnosticPatches.Write(
$"[recnet-send] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
}
catch (Exception ex)
{
LogFailure("recnet-send-enter", ex);
}
}
public static void RecNetBuilderSend_Postfix(object __instance, MethodBase __originalMethod)
{
if (_builderSendLogCount > 120) return;
try
{
DiagnosticPatches.Write(
$"[recnet-send] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)}");
}
catch (Exception ex)
{
LogFailure("recnet-send-exit", ex);
}
}
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
{
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
try
{
DiagnosticPatches.Write(
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
}
catch (Exception ex)
{
LogFailure("recnet-response-map-enter", ex);
}
}
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
{
if (_responseTupleLogCount > 80) return;
try
{
DiagnosticPatches.Write(
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {DumpObjectFields(__instance, 20, 600)}");
}
catch (Exception ex)
{
LogFailure("recnet-response-map-exit", ex);
}
}
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
DiagnosticPatches.Write(
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{LookupRecNetRequestInfo(__instance)} {DumpBndNativeFields(__instance)} {RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
$"state={DumpObjectFields(__instance, 36, 1200)}");
}
return __exception;
}
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
{
try
{
var name = __0?.ToString() ?? "<null>";
var value = RedactHeaderValue(name, __1?.ToString());
var uri = TryReadRequestUri(__instance)?.ToString() ?? "<uri unread>";
DiagnosticPatches.Write(
$"[http-header] {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name} " +
$"{SanitizeInline(uri)} {name}: {value}");
}
catch (Exception ex)
{
LogFailure("header", ex);
}
}
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
{
try
{
DiagnosticPatches.Write(
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)}");
}
catch (Exception ex)
{
LogFailure("callback-enter", ex);
}
}
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
{
try
{
if (!ShouldLogCallback(__instance)) return;
DiagnosticPatches.Write(
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)}");
}
catch (Exception ex)
{
LogFailure("callback-exit", ex);
}
}
public static Exception? CallCallback_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
{
if (__exception is not null)
{
try
{
DiagnosticPatches.Write(
$"[http-callback] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
$"{DescribeRequestAndResponse(__instance)} " +
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} " +
$"state={DumpObjectFields(__instance, 32, 900)}");
}
catch (Exception ex)
{
LogFailure("callback-finalizer", ex);
}
}
return __exception;
}
public static void TraceUrl(string? url, string source)
{
if (string.IsNullOrWhiteSpace(url)) return;
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return;
var id = NextRequestId();
DiagnosticPatches.Write($"[http-trace] #{id} {source} url={SanitizeInline(url)}");
}
public static void TraceRequestObject(object? request, string source)
{
if (request is null) return;
try
{
var id = NextRequestId();
var uriValue = TryReadRequestUri(request);
var uri = uriValue?.ToString() ?? "<uri unread>";
var method = TryReadHttpMethod(request);
var headers = TryReadHeaders(request);
var body = TryReadBodyPreview(request);
var fallback = uriValue is null && System.Threading.Interlocked.Increment(ref _uriUnreadDumpCount) <= 40
? $" requestFields={DumpObjectFields(request, 24, 260)}"
: "";
DiagnosticPatches.Write(
$"[http-trace] #{id} {source} method={method} uri={SanitizeInline(uri)} " +
$"headers={headers} body={body}{fallback}");
}
catch (Exception ex)
{
LogFailure(source, ex);
}
}
private static int NextRequestId() => System.Threading.Interlocked.Increment(ref _requestId);
private static Uri? TryReadRequestUri(object request)
{
if (TryInvokeUriMethod(request, "KPKNHFBHPHO", out var uri)) return uri;
if (TryInvokeUriMethod(request, "IBLHNHHBNNE", out uri)) return uri;
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var type = request.GetType();
if (TryReadUriValue(type.GetProperty("Uri", flags)?.GetValue(request), out uri)) return uri;
if (TryReadUriValue(type.GetProperty("CurrentUri", flags)?.GetValue(request), out uri)) return uri;
if (TryReadUriValue(type.GetField("<Uri>k__BackingField", flags)?.GetValue(request), out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try
{
if (TryReadUriValue(prop.GetValue(request), out uri)) return uri;
}
catch { }
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
try
{
if (TryReadUriValue(field.GetValue(request), out uri)) return uri;
}
catch { }
}
return null;
}
private static bool TryInvokeUriMethod(object request, string methodName, out Uri? uri)
{
uri = null;
try
{
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
if (method is null) return false;
return TryReadUriValue(method.Invoke(request, Array.Empty<object>()), out uri);
}
catch
{
return false;
}
}
private static bool TryReadUriValue(object? value, out Uri? uri)
{
uri = null;
if (value is null) return false;
if (value is Uri direct)
{
uri = direct;
return true;
}
var text = value.ToString();
if (Uri.TryCreate(text, UriKind.Absolute, out var parsed))
{
uri = parsed;
return true;
}
return false;
}
private static string TryReadHttpMethod(object request)
{
foreach (var name in new[] { "MethodType", "Method", "LNJILMKOBIP" })
{
if (TryReadMemberValue(request, name, out var value))
return SanitizeInline(value?.ToString() ?? "<null>");
}
return "<method unread>";
}
private static string TryReadHeaders(object request)
{
try
{
var dump = AccessTools.Method(request.GetType(), "DumpHeaders", Type.EmptyTypes);
if (dump?.Invoke(request, Array.Empty<object>()) is object rawDump)
return SanitizeInline(rawDump.ToString() ?? "<empty>");
}
catch { }
var known = new List<string>();
foreach (var name in new[]
{
"Authorization", "Cookie", "X-RNSIG", "Accept-Language",
"Content-Type", "User-Agent"
})
{
var value = TryReadHeaderValue(request, name);
if (!string.IsNullOrWhiteSpace(value))
known.Add($"{name}:{RedactHeaderValue(name, value)}");
}
return known.Count == 0 ? "<headers unread>" : string.Join("; ", known);
}
private static string? TryReadHeaderValue(object request, string name)
{
foreach (var methodName in new[] { "GetFirstHeaderValue", "FKHEHIMHHBP" })
{
try
{
var method = AccessTools.Method(request.GetType(), methodName, new[] { typeof(string) });
var value = method?.Invoke(request, new object[] { name })?.ToString();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
catch { }
}
return null;
}
private static string TryReadBodyPreview(object request)
{
foreach (var methodName in new[] { "BGMFJOKNJAJ", "GetEntityBody" })
{
try
{
var method = AccessTools.Method(request.GetType(), methodName, Type.EmptyTypes);
if (method is null) continue;
var body = method.Invoke(request, Array.Empty<object>());
var bytes = TryCopyBytes(body, BodyPreviewBytes + 1);
if (bytes is null) continue;
if (bytes.Length == 0) return "<empty>";
var shown = Math.Min(bytes.Length, BodyPreviewBytes);
var text = Encoding.UTF8.GetString(bytes, 0, shown);
var suffix = bytes.Length > BodyPreviewBytes ? "...<truncated>" : "";
return $"{bytes.Length} bytes \"{SanitizeInline(text)}{suffix}\"";
}
catch { }
}
return "<body unread>";
}
private static bool ShouldLogCallback(object request)
{
var response = TryReadResponseObject(request);
var status = response is null ? null : TryReadStatusCode(response);
if (status >= 400) return true;
return System.Threading.Interlocked.Increment(ref _callbackLogCount) <= 80;
}
private static string DescribeRequestAndResponse(object request)
{
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
var method = TryReadHttpMethod(request);
var response = TryReadResponseObject(request);
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
}
private static object? TryReadResponseObject(object request)
{
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
{
if (TryReadMemberValue(request, name, out var value) && value is not null)
return value;
}
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var prop in request.GetType().GetProperties(flags))
{
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
try
{
var value = prop.GetValue(request);
if (value is not null) return value;
}
catch { }
}
foreach (var field in request.GetType().GetFields(flags))
{
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
try
{
var value = field.GetValue(request);
if (value is not null) return value;
}
catch { }
}
return null;
}
private static int? TryReadStatusCode(object response)
{
if (TryReadMemberValue(response, "StatusCode", out var value))
{
try { return Convert.ToInt32(value); }
catch { }
}
return null;
}
private static string DescribeResponseObject(object response)
{
var known = DescribeKnownMembers(response,
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
return $"{response.GetType().FullName} {{{known}}}";
}
private static string DescribeKnownMembers(object obj, params string[] names)
{
var parts = new List<string>();
foreach (var name in names)
{
if (!TryReadMemberValue(obj, name, out var value)) continue;
parts.Add($"{name}={FormatValue(value, 700)}");
}
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
}
private static string FormatValue(object? value, int limit)
{
if (value is null) return "null";
if (value is byte[] bytes)
return $"byte[{bytes.Length}]";
if (value is IEnumerable enumerable && value is not string)
return FormatEnumerable(enumerable, value.GetType(), limit);
var text = value is Exception ex
? $"{ex.GetType().Name}: {ex.Message}"
: value.ToString() ?? "<null>";
text = SanitizeInline(text);
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
}
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
{
try
{
var parts = new List<string>();
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
var shown = 0;
foreach (var item in enumerable)
{
if (shown++ >= 6)
{
parts.Add("...");
break;
}
parts.Add(FormatValue(item, 180));
}
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
}
catch (Exception ex)
{
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
{
count = 0;
if (enumerable is ICollection collection)
{
count = collection.Count;
return true;
}
try
{
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (prop?.GetValue(enumerable) is int value)
{
count = value;
return true;
}
}
catch { }
return false;
}
private static byte[]? TryCopyBytes(object? value, int limit)
{
if (value is null) return null;
if (value is byte[] direct)
return CopyLimited(direct, limit);
if (value is IEnumerable enumerable)
{
var bytes = new List<byte>();
foreach (var item in enumerable)
{
if (item is null) continue;
try { bytes.Add(Convert.ToByte(item)); }
catch { return null; }
if (bytes.Count >= limit) break;
}
return bytes.ToArray();
}
return null;
}
private static byte[] CopyLimited(byte[] source, int limit)
{
var count = Math.Min(source.Length, limit);
var copy = new byte[count];
Array.Copy(source, copy, count);
return copy;
}
private static bool TryReadMemberValue(object request, string name, out object? value)
{
value = null;
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var type = request.GetType();
try
{
var prop = type.GetProperty(name, flags);
if (prop is not null && prop.CanRead && prop.GetIndexParameters().Length == 0)
{
value = prop.GetValue(request);
return true;
}
}
catch { }
try
{
var field = type.GetField(name, flags);
if (field is not null)
{
value = field.GetValue(request);
return true;
}
}
catch { }
try
{
var method = AccessTools.Method(type, name, Type.EmptyTypes);
if (method is not null)
{
value = method.Invoke(request, Array.Empty<object>());
return true;
}
}
catch { }
return false;
}
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
{
if (instance is null) return "<null>";
try
{
var type = instance.GetType();
var parts = new List<string>();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (parts.Count >= maxFields)
{
parts.Add("...");
break;
}
object? value;
try { value = field.GetValue(instance); }
catch (Exception ex)
{
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
continue;
}
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
}
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
}
catch (Exception ex)
{
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static string LookupRecNetRequestInfo(object instance)
{
var ptr = TryGetIl2CppPtr(instance);
if (ptr == IntPtr.Zero) return "ptr=<unread>";
lock (RecNetRequestInfoByPtr)
{
return RecNetRequestInfoByPtr.TryGetValue(ptr, out var info)
? info
: $"ptr=0x{ptr.ToInt64():X} request=<unmapped>";
}
}
private static string DumpBndNativeFields(object? instance)
{
var ptr = TryGetIl2CppPtr(instance);
if (ptr == IntPtr.Zero) return "native=<unread>";
try
{
var method = Marshal.ReadByte(ptr, 16);
var service = Marshal.ReadInt32(ptr, 20);
var pathPtr = Marshal.ReadIntPtr(ptr, 24);
var fieldsPtr = Marshal.ReadIntPtr(ptr, 32);
var filesPtr = Marshal.ReadIntPtr(ptr, 40);
var objectFieldsPtr = Marshal.ReadIntPtr(ptr, 48);
var bodyKind = Marshal.ReadInt32(ptr, 56);
var callbackPtr = Marshal.ReadIntPtr(ptr, 64);
var path = ReadIl2CppString(pathPtr);
return
"native={" +
$"method={method}; service={service}; path={SanitizeInline(path)}; " +
$"pathPtr=0x{pathPtr.ToInt64():X}; fieldsPtr=0x{fieldsPtr.ToInt64():X}; " +
$"filesPtr=0x{filesPtr.ToInt64():X}; objectFieldsPtr=0x{objectFieldsPtr.ToInt64():X}; " +
$"bodyKind={bodyKind}; callbackPtr=0x{callbackPtr.ToInt64():X}" +
"}";
}
catch (Exception ex)
{
return $"native=<dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
}
}
private static string ReadIl2CppString(IntPtr ptr)
{
if (ptr == IntPtr.Zero) return "<null>";
try
{
return IL2CPP.Il2CppStringToManaged(ptr) ?? "<null>";
}
catch (Exception ex)
{
return $"<string read failed 0x{ptr.ToInt64():X} {ex.GetType().Name}>";
}
}
private static IntPtr TryGetIl2CppPtr(object? instance)
{
try
{
return instance is Il2CppObjectBase il2CppObject
? IL2CPP.Il2CppObjectBaseToPtrNotNull(il2CppObject)
: IntPtr.Zero;
}
catch
{
return IntPtr.Zero;
}
}
private static string RedactHeaderValue(string name, string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
if (name.Equals("Authorization", StringComparison.OrdinalIgnoreCase) ||
name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("X-RNSIG", StringComparison.OrdinalIgnoreCase))
{
return "<redacted>";
}
return SanitizeInline(value);
}
private static string SanitizeInline(string value)
{
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
redacted = SensitiveJsonStringRegex.Replace(redacted, "\"$1\":\"<redacted>\"");
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
}
private static void LogFailure(string source, Exception ex)
{
if (_failureLogCount++ < 10)
DebugMod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
}
}
internal static class SavePatches
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
// our currently-loaded room. Captured BEFORE the watch's caching
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
// (roomId, subRoomId) tuple — which is every post-save push for a
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
// would-be no-op setter call into a real transition.
private static volatile string? s_lastPushedBlobName;
private static long s_lastPushedRoomId;
private static long s_lastPushedSubRoomId;
// Hook on Notifications.OnNotificationReceived(string json). Fires for
// every SignalR message before any handler dispatch. Parses the JSON
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
public static void OnNotificationReceived_Prefix(string message)
{
try
{
if (string.IsNullOrEmpty(message)) return;
// Fast reject: only parse JSON if it could plausibly be a
// SubscriptionUpdateRoom payload. Saves cycles on the (very
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
return;
using var doc = System.Text.Json.JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!root.TryGetProperty("Id", out var idEl) ||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
return;
if (!root.TryGetProperty("Msg", out var msg) ||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!msg.TryGetProperty("Room", out var room) ||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
long roomId = 0;
if (room.TryGetProperty("RoomId", out var ridEl) &&
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
roomId = ridEl.GetInt64();
if (!msg.TryGetProperty("Scenes", out var scenes) ||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
scenes.GetArrayLength() == 0) return;
var first = scenes[0];
long subRoomId = 0;
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
subRoomId = sidEl.GetInt64();
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
var blob = blobEl.GetString();
if (string.IsNullOrEmpty(blob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = blob;
DebugMod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
}
}
public static void OnSubscriptionUpdateRoom_Prefix(object message)
{
try
{
if (message is null) return;
var dict = message;
// The watch parses this dict as Dictionary<string, object>
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
// get_Item(key) — call it via reflection.
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (getItem is null) return;
// The push wire shape from server (BuildRoomDetails) is
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
// on the Room sub-object; the scene's blob on Scenes[0].
object? roomObj = TryGet(dict, getItem, "Room");
object? scenesObj = TryGet(dict, getItem, "Scenes");
if (roomObj is null || scenesObj is null) return;
long roomId = 0;
long subRoomId = 0;
try
{
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (roomItem is not null)
{
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
}
}
catch { /* roomId stays 0 — we'll log it and bail later */ }
// Scenes is an IL2CPP List<RoomScene>. Take the first
// (current dorm has one scene; multi-scene rooms target
// Scenes[currentSubRoomId] specifically, but the post-save
// push always carries the scene that was just saved).
object? firstScene = null;
try
{
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
}
catch { /* leave firstScene null */ }
if (firstScene is null) return;
string? newBlob = null;
try
{
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (sceneItem is not null)
{
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
newBlob = blob?.ToString();
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
}
}
catch { /* leave newBlob null */ }
if (string.IsNullOrEmpty(newBlob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = newBlob;
DebugMod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
}
}
private static object? TryGet(object dict, MethodInfo getItem, string key)
{
try { return getItem.Invoke(dict, new object[] { key }); }
catch { return null; }
}
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
// path only when the new value differs from the cached field. The
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
// save it calls the setter with the OLD blob — a no-op that skips
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
// pointed at the boot-time bytes, and PostSaveReloading deserializes
// those over the live world — wiping any MakerPen object the user
// just placed.
//
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
// after b__4 returns True, even though the SubscriptionUpdateRoom
// push already carried Scenes[0].DataBlobName="v11".
//
// Strategy: intercept the setter. If the incoming value matches the
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
// newer version (the freshly-pushed one), substitute that into the
// value parameter so the real setter sees a transition and fires
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
// Falls through silently when no override is needed.
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
{
string currentForLog = "<unread>";
string sceneForLog = "<unread>";
string scenes0ForLog = "<unread>";
try
{
if (__instance is null)
{
DebugMod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
return;
}
var rpmType = __instance.GetType();
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (blobNameProp is null)
{
DebugMod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
return;
}
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
var roomsType = FindGameType("RecNet.Rooms");
if (roomsType is null)
{
DebugMod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
return;
}
var localScene = roomsType.GetProperty("LocalRoomScene",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localScene is not null)
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
var localDetails = roomsType.GetProperty("LocalRoomDetails",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localDetails is not null)
{
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
if (scenes is not null)
{
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
var first = indexer?.Invoke(scenes, new object[] { 0 });
if (first is not null)
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
}
}
// Unconditional trace of EVERYTHING we read so we can see in
// the log exactly why we did or didn't override.
DebugMod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
// Pick a candidate "newer" blob name. Priority order:
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
// cache gate drops the push).
// 2. LocalRoomScene.DataBlobName.
// 3. Scenes[0].DataBlobName.
string? pushedBlob = s_lastPushedBlobName;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
if (string.IsNullOrEmpty(pushedBlob)) return;
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
DebugMod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
value = pushedBlob;
}
catch (Exception ex)
{
DebugMod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
}
}
private static Type? FindGameType(string name) => DebugMod.ResolveType(name);
}
internal static class QuitTracePatches
{
public static bool ApplicationQuit_Prefix()
{
Write("[quit-trace] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit-trace] UnityEngine.Application.Quit({exitCode})");
WriteStack();
if (exitCode == 1550502249 || exitCode == 533223478)
{
Write($"[quit-blocked] UnityEngine.Application.Quit fatal client exit {exitCode}");
return false;
}
return true;
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}");
WriteStack();
return true;
}
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP})");
WriteStack();
return true;
}
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)}({DKOGJICFAEP}, {EPAIMCEIMPA})");
WriteStack();
return true;
}
public static void ApplicationWantsToQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)} wants-to-quit handler entered");
WriteStack();
}
public static void CreateExitTask_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {FormatOriginal(__originalMethod)} creating exit task");
WriteStack();
}
private static string FormatOriginal(MethodBase method)
{
return $"{method.DeclaringType?.FullName}.{method.Name}";
}
private static void Write(string message)
{
try { DebugMod.Log.Msg(message); } catch { }
}
private static void WriteStack()
{
try
{
Write("[quit-trace-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[quit-trace-stack] failed: " + ex.Message);
}
}
}
internal static class DiagnosticPatches
{
public static void Write(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
try { DebugMod.Log.Msg(line); } catch { }
try
{
if (!string.IsNullOrEmpty(DebugMod.DiagnosticsLogPath))
File.AppendAllText(DebugMod.DiagnosticsLogPath, line + Environment.NewLine);
}
catch { }
}
public static bool ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
return true;
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static void NamedMethod_Prefix(MethodBase __originalMethod)
{
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
return true;
}
public static bool SessionManagerTryApplicationQuitInt_Prefix(int DKOGJICFAEP, MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP})");
WriteStack();
return true;
}
public static bool SessionManagerFatalApplicationQuit_Prefix(int DKOGJICFAEP, string EPAIMCEIMPA, MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}({DKOGJICFAEP}, {EPAIMCEIMPA})");
WriteStack();
return true;
}
private static void WriteStack()
{
try
{
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[stack] failed: " + ex.Message);
}
}
}
internal static class ChatPatches
{
private static bool s_logged;
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
// before raising the in-room Photon chat RPC; if it returns false
// the coroutine bails silently and the sender doesn't even see
// their own message echo.
public static void CanLocalPlayerChat_Postfix(ref bool __result)
{
if (!__result && !s_logged)
{
s_logged = true;
DebugMod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
}
__result = true;
}
// Skip the original. Returning false from a Harmony prefix stops
// the underlying call.
public static bool RemoveInvalidCharactersFromMessage_Prefix()
{
DebugMod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
return false;
}
public static void SendChatEmote_Prefix(string message)
{
DebugMod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
}
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
{
DebugMod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
}
public static void RpcChatEmote_Prefix(string message)
{
DebugMod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
-56
View File
@@ -1,56 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
The shipping ClientMod carries ONLY the patches needed to connect and
survive anti-cheat. ALL of the verbose diagnostic/tracing code (HTTP
tracing, join/quit/studio/room-load traces, the process-exception sink,
auth-token tracing, the registration-dialog trace, the in-game
DebugConsole enable path) lives in THIS mod. Drop this dll into the
Mods folder next to DorkNet.ClientMod.dll to debug; remove it to ship a
quiet, minimal client.
Fully self-contained: its own Harmony instance + its own copy of the
reflection/patch helpers, so it never couples to ClientMod at load time.
Debug knobs are read from the same UserData\dorknet-clientmod.json.
Build: dotnet build DorkNet.DebugMod/DorkNet.DebugMod.csproj -c Release
-->
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DebugMod</RootNamespace>
<AssemblyName>DorkNet.DebugMod</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<!-- Reuse the MelonLoader the ClientMod build already downloads. -->
<PropertyGroup>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader\MelonLoader\net6</MelonLoaderDir>
</PropertyGroup>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
-351
View File
@@ -1,351 +0,0 @@
using DorkNet.Contracts;
using DorkNet.ServiceDefaults;
using Microsoft.Extensions.Options;
using Yarp.ReverseProxy.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.Local.json",
optional: true, reloadOnChange: true);
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
builder.Services.Configure<DorkNetServiceMapOptions>(
builder.Configuration.GetSection("DorkNet:Services"));
var serviceMap = builder.Configuration
.GetSection("DorkNet:Services")
.Get<DorkNetServiceMapOptions>() ?? new DorkNetServiceMapOptions();
var apexDomain = builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
builder.Services
.AddReverseProxy()
.LoadFromMemory(
BuildRoutes(apexDomain, serviceMap),
BuildClusters(serviceMap));
var app = builder.Build();
app.MapDorkNetServiceDefaults();
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () =>
Results.Ok(new
{
ApexDomain = apexDomain,
Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new
{
r.RouteId,
r.ClusterId,
r.Match.Hosts,
r.Match.Path,
r.Order,
}),
}));
app.MapGet("/internal/services/health", async (
IOptions<DorkNetServiceMapOptions> options,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
var client = httpClientFactory.CreateClient();
var probes = new List<ServiceProbeResponse>();
foreach (var endpoint in options.Value.Endpoints())
{
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
}
return Results.Ok(probes);
});
app.MapReverseProxy();
app.Run();
static async Task<ServiceProbeResponse> ProbeServiceAsync(
HttpClient client,
DorkNetServiceEndpoint endpoint,
CancellationToken ct)
{
var checkedAt = DateTimeOffset.UtcNow;
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
try
{
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
response.IsSuccessStatusCode ? "ok" : "unhealthy",
(int)response.StatusCode,
null,
checkedAt);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
"unreachable",
null,
ex.Message,
checkedAt);
}
}
static IReadOnlyList<RouteConfig> BuildRoutes(
string apexDomain,
DorkNetServiceMapOptions services)
{
var routes = new List<RouteConfig>();
var order = 0;
var webGroup = DorkNetRouteOwnership.RouteGroups
.First(group => group.ServiceName == ServiceNames.Web);
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-admin-host",
[SubdomainHost("admin", apexDomain)]);
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-feed-host",
[SubdomainHost("feed", apexDomain)]);
AddHostPathRoutes(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
WebBrowserHosts(apexDomain),
[
"/admin",
"/assets",
"/brand",
"/feed",
"/join",
"/p",
"/photo",
"/players",
"/rooms",
"/u",
]);
foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
{
AddHostRoute(
routes,
ref order,
group.ServiceName,
BaseUrlFor(services, group.ServiceName),
$"{group.ServiceName}-hosts",
group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray());
}
foreach (var group in DorkNetRouteOwnership.RouteGroups)
{
AddPathRoutes(
routes,
ref order,
group.ServiceName,
BaseUrlFor(services, group.ServiceName),
group.PathPrefixes);
}
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-hosts",
BuildWebHosts(webGroup, apexDomain));
if (!string.IsNullOrWhiteSpace(services.Monolith))
{
routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++));
}
return routes;
}
static IReadOnlyList<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
{
var clusters = new List<ClusterConfig>();
foreach (var endpoint in services.Endpoints())
{
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
}
return clusters;
}
static string[] WebBrowserHosts(string apexDomain)
{
return [apexDomain, SubdomainHost("www", apexDomain)];
}
static void AddHostRoute(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
string routeId,
string[] hosts)
{
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
{
return;
}
routes.Add(Route(
serviceName,
routeId,
"/{**catch-all}",
order++,
hosts));
}
static void AddPathRoutes(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
IEnumerable<string> paths)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return;
}
foreach (var path in paths)
{
var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
var exactPath = NormalizeRoutePrefix(path);
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++));
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++));
}
}
static void AddHostPathRoutes(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
string[] hosts,
IEnumerable<string> paths)
{
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
{
return;
}
foreach (var path in paths)
{
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
var exactPath = NormalizeRoutePrefix(path);
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
}
}
static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName)
{
return serviceName switch
{
ServiceNames.Identity => services.Identity,
ServiceNames.Rooms => services.Rooms,
ServiceNames.Notify => services.Notify,
ServiceNames.Content => services.Content,
ServiceNames.Social => services.Social,
ServiceNames.Commerce => services.Commerce,
ServiceNames.Platform => services.Platform,
ServiceNames.Moderation => services.Moderation,
ServiceNames.Web => services.Web,
ServiceNames.Monolith => services.Monolith,
_ => string.Empty,
};
}
static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain)
{
var hosts = new List<string>();
if (group.IncludesApexHost)
{
hosts.Add(apexDomain);
}
hosts.AddRange(group.HostSubdomains
.Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase))
.Select(subdomain => SubdomainHost(subdomain, apexDomain)));
return hosts.ToArray();
}
static string SubdomainHost(string subdomain, string apexDomain)
{
return $"{subdomain}.{apexDomain}";
}
static string NormalizeRoutePrefix(string path)
{
if (string.IsNullOrWhiteSpace(path) || path == "/")
{
return "/";
}
return (path[0] == '/' ? path : "/" + path).TrimEnd('/');
}
static RouteConfig Route(
string clusterId,
string routeId,
string path,
int order,
string[]? hosts = null)
{
return new RouteConfig
{
RouteId = routeId,
ClusterId = clusterId,
Order = order,
Match = new RouteMatch
{
Hosts = hosts,
Path = path,
},
Transforms =
[
new Dictionary<string, string>
{
["RequestHeaderOriginalHost"] = "true",
},
],
};
}
static void AddCluster(
List<ClusterConfig> clusters,
string serviceName,
string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return;
}
var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/";
clusters.Add(new ClusterConfig
{
ClusterId = serviceName,
Destinations = new Dictionary<string, DestinationConfig>
{
["primary"] = new() { Address = address },
},
});
}
-23
View File
@@ -1,23 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"DorkNet": {
"Services": {
"Identity": "http://localhost:8081",
"Rooms": "http://localhost:8082",
"Notify": "http://localhost:8083",
"Content": "http://localhost:8084",
"Social": "http://localhost:8085",
"Commerce": "http://localhost:8086",
"Platform": "http://localhost:8087",
"Moderation": "http://localhost:8088",
"Web": "http://localhost:8089",
"Monolith": "http://localhost:8090"
}
}
}
-44
View File
@@ -81,43 +81,6 @@ public class RecNetAccount
// Likewise.
[JsonPropertyName("hasBirthday")]
public bool HasBirthday { get; set; } = true;
// 2020.12 Account.Deserialize reads this key (verified via Cpp2IL ISIL
// dump of CCEOLAOLEKJ.PPGFHEDFBEA — the obfuscated Account class). The
// 2020.03 client doesn't read it, but emitting it is harmless there.
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
[JsonPropertyName("createdAt")]
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>
@@ -142,13 +105,6 @@ public class RecNetSelfAccount : RecNetAccount
[JsonPropertyName("parentAccountId")]
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>
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -1,76 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Standalone MelonLoader IL2CPP mod whose ONLY job is to dump every
Google.Protobuf FileDescriptor that the running client has loaded.
It carries no Harmony patches, so it is safe to drop onto ANY Rec Room
build (2020, 2023, …) without the version-specific ClientMod patches
misfiring. Everything game-side is reached through plain
System.Reflection over the Il2CppInterop proxy assemblies, so no game
DLLs are referenced at compile time (they would be derivative works).
Build (desktop net6 MelonLoader, same pin as the ClientMod):
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
Drop bin\Release\DorkNet.ProtoDumper.dll into <RecRoom>\Mods\ on a
MelonLoader-patched build and launch. Output lands in
<RecRoom>\DorkNet-ProtoDump\.
-->
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == ''">net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.ProtoDumper</RootNamespace>
<AssemblyName>DorkNet.ProtoDumper</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<!-- Reuse the MelonLoader DLLs the ClientMod project already downloads. -->
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-378
View File
@@ -1,378 +0,0 @@
// DorkNet ProtoDumper — runtime Google.Protobuf schema extractor.
//
// Rec Room compiles its room-save / creation / circuits data model from
// .proto files into Google.Protobuf C# classes. In the shipped IL2CPP build
// those classes are name-obfuscated and the descriptor base64 is split into
// scattered string-literal chunks, so static reconstruction from
// global-metadata.dat is unreliable.
//
// At RUNTIME every FileDescriptor still carries its original, un-obfuscated
// FileDescriptorProto (message names, field names, numbers, types intact).
// The catch: these descriptors are built LAZILY and in a specific dependency
// order by the game. Force-building them ourselves (by reading a message
// type's static descriptor property out of order) throws inside the type's
// static constructor and the failure is cached — poisoning the type. So we do
// NOT enumerate types. Instead we Harmony-postfix the one library method every
// generated cctor funnels through — Google.Protobuf.Reflection.FileDescriptor
// .BuildFrom / .FromGeneratedCode — and capture each descriptor as the game
// builds it. Completely passive: just play the client through the dorm, a
// created room, the Maker Pen and a circuits board, and every descriptor that
// loads is recorded.
//
// Output (under <RecRoom>\DorkNet-ProtoDump\):
// manifest.tsv — one "<proto path>\t<base64>" line per file
// files\<sanitised>.b64 — same base64, one file each
// dump.log — capture log
//
// Decode offline with decode_dump.py (see README.md).
using HarmonyLib;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
[assembly: MelonInfo(typeof(DorkNet.ProtoDumper.ProtoDumper), "DorkNet ProtoDumper", "2.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.ProtoDumper;
public class ProtoDumper : MelonMod
{
private static MelonLogger.Instance? _log;
// proto file name -> descriptor base64 (accumulated as the game builds them)
private static readonly Dictionary<string, string> Files = new(StringComparer.Ordinal);
private static readonly object Gate = new();
private static string _outDir = "";
private static string _filesDir = "";
private static volatile bool _dirty;
private int _frame;
// Interception heartbeat: how many times any hooked method actually ran.
// Lets us distinguish "hook never fires (inlined/wrong target)" from
// "content that builds protos hasn't loaded yet".
private static long _fireCount;
private static bool _firstFireLogged;
public override void OnInitializeMelon()
{
_log = LoggerInstance;
try
{
string baseDir = AppDomain.CurrentDomain.BaseDirectory ?? Directory.GetCurrentDirectory();
_outDir = Path.Combine(baseDir, "DorkNet-ProtoDump");
_filesDir = Path.Combine(_outDir, "files");
Directory.CreateDirectory(_filesDir);
_log.Msg($"ProtoDumper ready. Output dir: {_outDir}");
}
catch (Exception e) { _log?.Error($"ProtoDumper init failed: {e}"); }
InstallHooks();
}
private void InstallHooks()
{
try
{
// Google.Protobuf library type — NOT obfuscated (it's the library,
// not game code), reached by its original name via every loaded
// assembly so we never reference the interop DLL at compile time.
Type? fd = ResolveType("Google.Protobuf.Reflection.FileDescriptor");
if (fd == null)
{
_log?.Error("ProtoDumper: FileDescriptor type not found — cannot hook.");
return;
}
var postfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(Captured_Postfix),
BindingFlags.Public | BindingFlags.Static));
var ctorPostfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(Ctor_Postfix),
BindingFlags.Public | BindingFlags.Static));
int patched = 0;
// PRIMARY hook: the 6-arg instance constructor. Every FileDescriptor
// is built via `newobj` through this ctor, which is NOT an inlining
// candidate the way the small static BuildFrom/FromGeneratedCode
// helpers are (those get AOT-inlined into each generated cctor, so a
// Harmony patch on the managed wrapper never fires — the real work
// runs as inlined native code). The ctor stores SerializedData /
// Proto / Name before it returns, so __instance carries the full
// descriptor bytes.
foreach (ConstructorInfo c in fd.GetConstructors(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var ps = c.GetParameters();
// Skip the Il2CppInterop IntPtr wrapper ctor (single IntPtr arg).
if (ps.Length == 1 && ps[0].ParameterType == typeof(IntPtr)) continue;
try { HarmonyInstance.Patch(c, postfix: ctorPostfix); patched++; _log?.Msg($"ProtoDumper: hooked .ctor({ps.Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch .ctor({ps.Length}) failed: {e.Message}"); }
}
// SECONDARY hooks (kept as a belt-and-braces net in case the ctor is
// itself inlined on some build): BuildFrom / FromGeneratedCode.
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
{
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
{
if (m.Name != name) continue;
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; _log?.Msg($"ProtoDumper: hooked {name}({m.GetParameters().Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
}
}
// STRONGEST hook: DescriptorPool.AddSymbol(IDescriptor). Called once
// per message/enum/service symbol while a descriptor is built. Unlike
// the tiny BuildFrom/FromGeneratedCode/ctor helpers, AddSymbol does
// real work (name validation + dictionary insert + duplicate-throw),
// so the AOT compiler does NOT inline it away — the managed patch
// actually fires. From the symbol we walk `.File` to the owning
// FileDescriptor and collect its serialized bytes. We also stash the
// pool so ScanPools() can later enumerate everything it holds.
Type? pool = ResolveType("Google.Protobuf.Reflection.DescriptorPool");
if (pool != null)
{
var symPostfix = new HarmonyMethod(
typeof(ProtoDumper).GetMethod(nameof(AddSymbol_Postfix),
BindingFlags.Public | BindingFlags.Static));
foreach (MethodInfo m in pool.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (m.Name != "AddSymbol" && m.Name != "AddPackage") continue;
try { HarmonyInstance.Patch(m, postfix: symPostfix); patched++; _log?.Msg($"ProtoDumper: hooked DescriptorPool.{m.Name}({m.GetParameters().Length} args)"); }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch DescriptorPool.{m.Name} failed: {e.Message}"); }
}
}
else _log?.Warning("ProtoDumper: DescriptorPool type not found — AddSymbol hook skipped.");
_log?.Msg($"ProtoDumper: installed {patched} descriptor hooks. Play through your content; descriptors are captured as they load. Watch for 'FIRST DESCRIPTOR CAPTURED'.");
}
catch (Exception e) { _log?.Error($"ProtoDumper hook install error: {e}"); }
}
// Resolve a type by its ORIGINAL (game-side) name, accounting for the
// Il2Cpp prefix Il2CppInterop puts on the namespace (so
// "Google.Protobuf.Reflection.FileDescriptor" ->
// "Il2CppGoogle.Protobuf.Reflection.FileDescriptor").
private static Type? ResolveType(string name)
{
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp" + name, false);
if (t != null) return t;
int dot = name.IndexOf('.');
if (dot > 0)
{
var ns = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), false);
if (ns != null) return ns;
}
}
catch { }
}
return null;
}
// Harmony postfix on FileDescriptor.BuildFrom / FromGeneratedCode.
// __result is the freshly-built FileDescriptor (Il2Cpp proxy, boxed as
// object — same pattern the ClientMod uses for il2cpp reference returns).
public static void Captured_Postfix(object? __result)
{
Fired();
if (__result == null) return;
try { CollectFile(__result); }
catch { /* never let our hook disturb the game */ }
}
// Postfix on the FileDescriptor instance ctor — __instance is the freshly
// constructed descriptor (SerializedData/Proto/Name already set).
public static void Ctor_Postfix(object? __instance)
{
Fired();
if (__instance == null) return;
try { CollectFile(__instance); }
catch { /* never let our hook disturb the game */ }
}
// Live DescriptorPool references seen via AddSymbol, kept so ScanPools() can
// enumerate every descriptor a pool holds (already-built → no force-build).
private static readonly List<object> Pools = new();
// Postfix on DescriptorPool.AddSymbol / AddPackage. __instance is the pool,
// __0 is the IDescriptor symbol being registered. Walk symbol.File to the
// owning FileDescriptor and collect it.
public static void AddSymbol_Postfix(object? __instance, object? __0)
{
Fired();
try
{
if (__instance != null)
{
lock (Gate)
{
bool known = false;
foreach (var p in Pools) if (ReferenceEquals(p, __instance)) { known = true; break; }
if (!known) Pools.Add(__instance);
}
}
if (__0 == null) return;
object? file = GetProp(__0, "File");
if (file != null) CollectFile(file);
}
catch { /* never let our hook disturb the game */ }
}
// Interception heartbeat — proves a hooked method actually ran. Logged once
// on first fire, so "no output" can be diagnosed as inlining (never fires)
// vs. content-not-loaded (fires but no proto-building content yet).
private static void Fired()
{
long n = System.Threading.Interlocked.Increment(ref _fireCount);
if (!_firstFireLogged)
{
_firstFireLogged = true;
_log?.Msg("ProtoDumper: FIRST DESCRIPTOR CAPTURED — interception is live.");
}
}
private static void CollectFile(object file)
{
string? name = GetProp(file, "Name") as string;
if (string.IsNullOrEmpty(name)) return;
lock (Gate)
{
if (Files.ContainsKey(name!)) return;
string b64 = DescriptorBase64(file);
Files[name!] = b64;
_dirty = true;
}
// Capture import dependencies too (closure of well-known + leaf protos).
try
{
object? deps = GetProp(file, "Dependencies");
if (deps != null)
{
var dt = deps.GetType();
var countP = dt.GetProperty("Count");
var itemM = dt.GetMethod("get_Item", new[] { typeof(int) });
if (countP != null && itemM != null)
{
int n = (int)(countP.GetValue(deps) ?? 0);
for (int i = 0; i < n; i++)
{
object? dep = itemM.Invoke(deps, new object[] { i });
if (dep != null) CollectFile(dep);
}
}
}
}
catch { }
}
private static string DescriptorBase64(object file)
{
try
{
// FileDescriptor exposes no public SerializedData property — only
// the Il2CppInterop backing field (a ByteString with the original
// descriptor bytes). Fall back to re-serialising the parsed Proto.
object? sd = GetProp(file, "_SerializedData_k__BackingField")
?? GetProp(file, "SerializedData");
if (sd != null)
{
string b = ByteStringToBase64(sd);
if (b.Length > 0) return b;
}
object? proto = GetProp(file, "Proto");
if (proto != null)
{
MethodInfo? toBs = proto.GetType().GetMethod("ToByteString", Type.EmptyTypes);
object? bs = toBs?.Invoke(proto, null);
if (bs != null) return ByteStringToBase64(bs);
}
}
catch { }
return "";
}
private static string ByteStringToBase64(object byteString)
{
MethodInfo? toB64 = byteString.GetType().GetMethod("ToBase64", Type.EmptyTypes);
return toB64?.Invoke(byteString, null) as string ?? "";
}
private static object? GetProp(object obj, string propName)
{
var p = obj.GetType().GetProperty(propName,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
return p?.GetValue(obj);
}
private long _lastReportedFire = -1;
public override void OnUpdate()
{
// Flush to disk shortly after new descriptors are captured (every ~1s).
if (++_frame % 60 == 0 && _dirty)
Flush();
// Every ~5s, if the hooks fired since last report, log the running
// fire/capture counts — surfaces "fired but 0 files" (empty base64)
// separately from "never fired".
if (_frame % 300 == 0)
{
long f = System.Threading.Interlocked.Read(ref _fireCount);
if (f != _lastReportedFire)
{
_lastReportedFire = f;
int files; lock (Gate) files = Files.Count;
_log?.Msg($"ProtoDumper heartbeat: hook-fires={f}, unique-descriptors={files}");
}
}
}
public override void OnApplicationQuit() => Flush();
private static void Flush()
{
lock (Gate)
{
if (!_dirty) return;
_dirty = false;
try
{
var sb = new StringBuilder();
int withData = 0;
foreach (var kv in Files)
{
if (kv.Value.Length == 0) continue;
withData++;
sb.Append(kv.Key).Append('\t').Append(kv.Value).Append('\n');
string fp = Path.Combine(_filesDir, Sanitize(kv.Key) + ".b64");
if (!File.Exists(fp)) File.WriteAllText(fp, kv.Value);
}
File.WriteAllText(Path.Combine(_outDir, "manifest.tsv"), sb.ToString());
string line = $"captured files={Files.Count} (with-data={withData})";
File.AppendAllText(Path.Combine(_outDir, "dump.log"), line + "\n");
_log?.Msg($"ProtoDumper: {line} -> {_outDir}");
}
catch (Exception e) { _log?.Error($"ProtoDumper flush error: {e}"); }
}
}
private static string Sanitize(string protoPath)
{
var sb = new StringBuilder(protoPath.Length);
foreach (char c in protoPath)
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' ? c : '_');
return sb.ToString();
}
}
-81
View File
@@ -1,81 +0,0 @@
# DorkNet ProtoDumper
Runtime extractor for Rec Room's Google.Protobuf schema (room save /
creation / circuits data model). Produces clean `.proto` files for any
client build — including obfuscated ones like 2023.03.21 where static
reconstruction from `global-metadata.dat` is unreliable (the descriptor
base64 is split into scattered string-literal chunks whose concat order
only lives in IL).
At runtime every `FileDescriptor` still carries its original,
un-obfuscated `FileDescriptorProto` (real message names, field names,
numbers and types intact). The catch: Rec Room builds these descriptors
**lazily and in a specific dependency order** as content loads. Trying to
force-build them by reading a message type's static descriptor out of
order throws inside the type's static constructor, and the failure is
cached — *poisoning* the type (and crashing the client). So this mod does
**not** enumerate types.
Instead it Harmony-postfixes the one library method every generated cctor
funnels through —
`Google.Protobuf.Reflection.FileDescriptor.BuildFrom` /
`.FromGeneratedCode` — and records each descriptor as the game builds it.
Completely passive: **play the client through the content you care about**
and every descriptor that loads is captured.
## Pipeline
1. **Build the mod**
```sh
dotnet build DorkNet.ProtoDumper/DorkNet.ProtoDumper.csproj -c Release
```
Output: `DorkNet.ProtoDumper/bin/Release/DorkNet.ProtoDumper.dll`.
2. **Run it on a *runnable* 2023.03.21 client (MelonLoader 0.6.6)**
> The `GameAssembly.dll` + `il2cpp_data`-only copy under
> `Recnet-old/dist/RecRoom-2023.03.21-il2cpp/…/staging/` is an
> **offline-analysis payload, not a runnable build**. Use a full build
> with the exe + UnityPlayer + MelonLoader (e.g. the devdork package).
- Drop `DorkNet.ProtoDumper.dll` into `<RecRoom>\Mods\`. On launch it
installs the descriptor-build hooks (see `dump.log` / MelonLoader
console for "installed N descriptor-build hooks").
- **Log in and play through the content whose schema you want:** your
dorm, a created room, the Maker Pen, and a circuits (CV2) board. Each
subsystem builds its protos the first time it loads. Descriptors are
captured the instant they build — no scene/timer scanning, nothing is
force-built, so the client stays stable.
- Output appears under `<RecRoom>\DorkNet-ProtoDump\` and is flushed to
disk ~1 s after new descriptors load, and again on quit:
- `manifest.tsv` — `<proto path>\t<base64>` per file
- `files\*.b64` — same, one file each
- `dump.log` — running capture count
3. **Decode to .proto**
```sh
pip install protobuf
python DorkNet.ProtoDumper/decode_dump.py <path>/manifest.tsv proto_out
```
Emits one `.proto` per descriptor under `proto_out/`, plus a combined
`proto_out/recroom_2023.proto` (every rec_room/circuits message in one
file) for an easy diff against
`DorkNet.Server/Protos/recroom_2020.proto`.
## Notes
- No game DLLs are referenced at compile time. `FileDescriptor` is the
Google.Protobuf *library* type (not obfuscated); it's reached by its
original name with the Il2Cpp namespace-prefix fallback, and its members
are read via reflection. The same DLL works across builds and stays clean
of derivative game code.
- The only Harmony patches are the two read-only descriptor-build
postfixes above — safe to run alongside (or without) `DorkNet.ClientMod`.
- The descriptor base64 is the same `FileDescriptorProto` wire format
Google's own tooling emits, so `decode_dump.py` (or `protoc
--decode=google.protobuf.FileDescriptorProto descriptor.proto`) reads it
directly.
-165
View File
@@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""Decode a DorkNet ProtoDumper manifest into .proto text.
Usage:
python decode_dump.py manifest.tsv [out_dir]
manifest.tsv has one "<proto path>\\t<base64 FileDescriptorProto>" line per
file (as written by the ProtoDumper MelonLoader mod). This emits one .proto
file per descriptor under out_dir/ (default: ./proto_out), plus a combined
recroom_2023.proto with every rec_room/circuits message in one file for an
easy diff against DorkNet.Server/Protos/recroom_2020.proto.
Requires: pip install protobuf
"""
import sys, os, base64, re
from google.protobuf import descriptor_pb2 as D
FDP = D.FileDescriptorProto
# proto3 scalar type names keyed by FieldDescriptorProto.Type
SCALAR = {
1: "double", 2: "float", 3: "int64", 4: "uint64", 5: "int32",
6: "fixed64", 7: "fixed32", 8: "bool", 9: "string", 12: "bytes",
13: "uint32", 15: "sfixed32", 16: "sfixed64", 17: "sint32", 18: "sint64",
}
LABEL_REPEATED = 3
def type_name(f):
if f.type in (11, 14): # message / enum -> use type_name (strip leading dot)
return f.type_name.lstrip(".")
return SCALAR.get(f.type, f"type{f.type}")
def emit_enum(e, indent):
pad = " " * indent
out = [f"{pad}enum {e.name} {{"]
for v in e.value:
out.append(f"{pad} {v.name} = {v.number};")
out.append(f"{pad}}}")
return out
def emit_message(m, indent=0):
pad = " " * indent
out = [f"{pad}message {m.name} {{"]
# map<> detection: synthetic nested *Entry types with map_entry option
map_entries = {}
for nt in m.nested_type:
if nt.options.map_entry:
kt = type_name(nt.field[0])
vt = type_name(nt.field[1])
map_entries[nt.name] = (kt, vt)
# which oneofs are real (not proto3 optional synthetic)
for nt in m.nested_type:
if nt.name in map_entries:
continue
out += emit_message(nt, indent + 1)
for en in m.enum_type:
out += emit_enum(en, indent + 1)
# group fields by oneof
printed_oneof = set()
for f in m.field:
# map field?
if f.type == 11 and f.type_name.split(".")[-1] in map_entries:
kt, vt = map_entries[f.type_name.split(".")[-1]]
out.append(f"{pad} map<{kt}, {vt}> {f.name} = {f.number};")
continue
if f.HasField("oneof_index") and not f.proto3_optional:
idx = f.oneof_index
if idx not in printed_oneof:
printed_oneof.add(idx)
out.append(f"{pad} oneof {m.oneof_decl[idx].name} {{")
for g in m.field:
if g.HasField("oneof_index") and g.oneof_index == idx and not g.proto3_optional:
out.append(f"{pad} {type_name(g)} {g.name} = {g.number};")
out.append(f"{pad} }}")
continue
label = "repeated " if f.label == LABEL_REPEATED else ""
opt = "optional " if f.proto3_optional else ""
out.append(f"{pad} {label}{opt}{type_name(f)} {f.name} = {f.number};")
out.append(f"{pad}}}")
return out
def render_file(fdp):
out = []
syntax = fdp.syntax or "proto2"
out.append(f'syntax = "{syntax}";')
out.append("")
if fdp.package:
out.append(f"package {fdp.package};")
for dep in fdp.dependency:
out.append(f'import "{dep}";')
if fdp.package or fdp.dependency:
out.append("")
for en in fdp.enum_type:
out += emit_enum(en, 0)
out.append("")
for m in fdp.message_type:
out += emit_message(m, 0)
out.append("")
return "\n".join(out).rstrip() + "\n"
def main():
if len(sys.argv) < 2:
print(__doc__); sys.exit(1)
manifest = sys.argv[1]
out_dir = sys.argv[2] if len(sys.argv) > 2 else "proto_out"
os.makedirs(out_dir, exist_ok=True)
files = []
with open(manifest, "r", encoding="utf-8") as fh:
for ln in fh:
ln = ln.rstrip("\n")
if not ln or "\t" not in ln:
continue
name, b64 = ln.split("\t", 1)
try:
raw = base64.b64decode(b64)
fdp = FDP(); fdp.ParseFromString(raw)
except Exception as e:
print(f" ! failed to parse {name}: {e}")
continue
files.append((name, fdp))
print(f"parsed {len(files)} descriptors")
# per-file .proto
for name, fdp in files:
safe = re.sub(r"[^A-Za-z0-9_.\-]", "_", name)
with open(os.path.join(out_dir, safe), "w", encoding="utf-8") as w:
w.write(render_file(fdp))
# combined rec_room/circuits view (skip google/* well-known imports)
rr = [ (n,f) for n,f in files if not n.startswith("google/") ]
seen = set(); combo = ['syntax = "proto3";', "", "package recroom;", ""]
total_msgs = 0
for name, fdp in sorted(rr, key=lambda x: x[0]):
combo.append(f"// ===== {name} (package {fdp.package or '-'}) =====")
for m in fdp.message_type:
if m.name in seen:
continue
seen.add(m.name)
combo += emit_message(m, 0)
combo.append("")
total_msgs += 1
for en in fdp.enum_type:
combo += emit_enum(en, 0)
combo.append("")
with open(os.path.join(out_dir, "recroom_2023.proto"), "w", encoding="utf-8") as w:
w.write("\n".join(combo).rstrip() + "\n")
print(f"wrote {out_dir}/recroom_2023.proto with {total_msgs} unique messages "
f"from {len(rr)} rec_room/circuits descriptors")
if __name__ == "__main__":
main()
-2
View File
@@ -1,2 +0,0 @@
*.sh text eol=lf
*.cmd text eol=crlf
-9
View File
@@ -1,9 +0,0 @@
# 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
@@ -1,237 +0,0 @@
# 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
@@ -1,80 +0,0 @@
# 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
@@ -1,472 +0,0 @@
#!/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()
@@ -1,79 +0,0 @@
#!/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()
@@ -1,56 +0,0 @@
#!/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
@@ -1,43 +0,0 @@
#!/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()
@@ -1,5 +0,0 @@
@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
@@ -1,145 +0,0 @@
<#
.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
}
@@ -1,96 +0,0 @@
#!/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
@@ -1,174 +0,0 @@
#!/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
@@ -1,82 +0,0 @@
// 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
@@ -1,93 +0,0 @@
// 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
@@ -1,772 +0,0 @@
// 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);
}
@@ -1,596 +0,0 @@
/*
* @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__)
@@ -1,42 +0,0 @@
/*
* @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
@@ -1,21 +0,0 @@
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
@@ -1,122 +0,0 @@
{
"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
@@ -1,125 +0,0 @@
{
"_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."
}
]
}
@@ -1,154 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression: admin "pull a player into an instance" broke because the
/// instance id (an int64 well past JS's 2^53 safe-integer range) was
/// emitted as a JSON number. The admin SPA's JSON.parse rounded it to the
/// nearest double, so the pull URL targeted a non-existent instance and
/// the server returned <c>instance_not_active</c>. The fix serializes the
/// id as a STRING so the exact digits survive the browser round-trip.
/// </summary>
public sealed class AdminInstancePullTests : IClassFixture<DorkNetServerFactory>
{
// A dorm-style instance id past 2^53 — the value class that a JS
// Number cannot represent exactly.
private const long BigInstanceId = 7597291079085522944L;
private readonly DorkNetServerFactory _factory;
public AdminInstancePullTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Instances_list_emits_id_as_string_and_pull_with_exact_id_succeeds()
{
using var setup = ApiClient();
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long roomId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
// Promote the caller to admin (AdminOnly reads IsAdmin from DB).
var adminRow = await db.Players.FirstAsync(p => p.Id == admin.PlayerId);
adminRow.IsAdmin = true;
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
await db.SaveChangesAsync();
// Put the streamer in a live instance carrying the huge id.
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
{
RoomInstanceId = BigInstanceId,
RoomId = roomId,
SubRoomId = 0,
PhotonRoomId = "^teststreamerinstance",
PhotonRegionId = "us",
MaxCapacity = 8,
});
presence.MarkActive(streamer.PlayerId);
}
using var adminClient = ApiClient(admin);
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
// The list must emit roomInstanceId as a STRING equal to the exact
// digits — never a number (which would already be rounded here).
var list = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/instances");
var row = list.EnumerateArray()
.First(r => r.GetProperty("roomInstanceId").GetString() == BigInstanceId.ToString());
Assert.Equal(JsonValueKind.String, row.GetProperty("roomInstanceId").ValueKind);
// Pull the second player in using the EXACT id from the list. This
// is what the SPA does; with the string round-trip the digits are
// intact and the instance matches (pre-fix: 404 instance_not_active).
var idFromList = row.GetProperty("roomInstanceId").GetString()!;
using var pull = await adminClient.PostAsync(
$"/api/admin/v1/rooms/{roomId}/instances/{idFromList}/pull",
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, pull.StatusCode);
using var body = await JsonDocument.ParseAsync(await pull.Content.ReadAsStreamAsync());
Assert.True(body.RootElement.GetProperty("ok").GetBoolean());
// The pulled player now shares the streamer's instance.
await using var verify = _factory.Services.CreateAsyncScope();
var presenceCheck = verify.ServiceProvider.GetRequiredService<PlayerPresenceService>();
Assert.Equal(BigInstanceId, presenceCheck.GetRoom(pulled.PlayerId)!.RoomInstanceId);
}
[Fact]
public async Task Pull_with_precision_mangled_id_is_rejected()
{
using var setup = ApiClient();
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var streamer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var pulled = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long roomId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
roomId = (await db.Rooms.FirstAsync(r => r.State == 0)).Id;
await db.SaveChangesAsync();
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
presence.SetRoom(streamer.PlayerId, new RoomInstanceDto
{
RoomInstanceId = BigInstanceId,
RoomId = roomId,
PhotonRoomId = "^teststreamerinstance2",
PhotonRegionId = "us",
MaxCapacity = 8,
});
presence.MarkActive(streamer.PlayerId);
}
using var adminClient = ApiClient(admin);
adminClient.BaseAddress = new Uri($"http://admin.{_factory.ApexDomain}");
// The value the admin SPA's JSON.parse actually produced for this
// id (from the bug report log) — the exact failure mode.
const long mangled = 7597291079085523000L;
Assert.NotEqual(BigInstanceId, mangled);
using var pull = await adminClient.PostAsync(
$"/api/admin/v1/rooms/{roomId}/instances/{mangled}/pull",
new StringContent(JsonSerializer.Serialize(new { PlayerId = pulled.PlayerId }),
Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.NotFound, pull.StatusCode);
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
@@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using DorkNet.Server.Data;
namespace DorkNet.Server.Tests;
public sealed class CanonicalRoomBootstrapTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CanonicalRoomBootstrapTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Bootstrap_seeds_stunt_runner_subroom_and_rec_rally_remote_image_name()
{
using var _ = _factory.CreateClient(new() { AllowAutoRedirect = false });
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var stuntRunner = await db.Rooms.SingleAsync(r => r.Name == "StuntRunner");
var stuntRunnerScenes = await db.RoomScenes
.Where(s => s.RoomId == stuntRunner.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
Assert.Collection(
stuntRunnerScenes,
scene =>
{
Assert.Equal(0, scene.OrderIndex);
Assert.Equal("StuntRunner", scene.Name);
Assert.Equal("b7281665-a715-4051-826b-8e08e69c6172", scene.RoomSceneLocationId);
Assert.Equal("", scene.DataBlobName);
},
scene =>
{
Assert.Equal(1, scene.OrderIndex);
Assert.Equal("TheMainEvent", scene.Name);
Assert.Equal("3a636bd2-f896-424c-9225-c184522c0d87", scene.RoomSceneLocationId);
Assert.Equal("", scene.DataBlobName);
});
var recRally = await db.Rooms.SingleAsync(r => r.Name == "RecRally");
Assert.Equal("image_RecRally.jpg", recRally.ImageName);
}
}
@@ -1,78 +0,0 @@
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;
}
}
@@ -1,88 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// End-to-end coverage for the 3D Charades word-list feature and the
/// profanity-filter toggle. Boots the full server (which runs the seeder)
/// against SQLite via <see cref="DorkNetServerFactory"/> and exercises the
/// public wire endpoints the March 2023 client actually calls.
/// </summary>
public sealed class CharadesWordListTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public CharadesWordListTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("Charades")] // enum-name form
[InlineData("CharadesAprilFoolsDay")]
[InlineData("Icebreakers")]
[InlineData("0")] // int form the client may send
[InlineData("1")]
[InlineData("2")]
public async Task Words_endpoint_returns_seeded_deck_in_wire_shape(string source)
{
using var client = ApiClient();
using var res = await client.GetAsync($"/api/activities/charades/v1/words/{source}");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0, $"expected a non-empty deck for '{source}'");
var first = doc.RootElement[0];
// Exact wire key casing the client's LitJson importer reads.
Assert.True(first.TryGetProperty("EN_US", out var en) && en.ValueKind == JsonValueKind.String,
"each card must expose a string EN_US field");
Assert.True(first.TryGetProperty("Difficulty", out var diff) && diff.ValueKind == JsonValueKind.Number,
"each card must expose a numeric Difficulty field");
}
[Fact]
public async Task December_noparam_route_serves_the_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Unknown_source_falls_back_to_charades_slot()
{
using var client = ApiClient();
using var res = await client.GetAsync("/api/activities/charades/v1/words/totally-bogus");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.GetArrayLength() > 0);
}
[Fact]
public async Task Profanity_filter_is_active_by_default()
{
using var client = ApiClient();
using var res = await client.PostAsync(
"/api/sanitize/v1/requestIsStringPure",
new StringContent("{\"Value\":\"you piece of shit\"}", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = (await res.Content.ReadAsStringAsync()).Trim();
// Default state: filter on → the profane string is NOT pure.
Assert.Equal("false", body.ToLowerInvariant());
}
}
@@ -1,88 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// Starting a chat with a player is a two-step handshake, and both steps have to
/// work on a thread that has never carried a message:
///
/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
/// GET thread/{id} -> opens it
///
/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
/// GET 404'd 415ms later, because the handler decided a thread existed by
/// counting its messages. A brand-new conversation has none, so the client was
/// handed a thread id it was then told did not exist, and no chat could ever be
/// opened with someone you had not already messaged.
/// </summary>
public sealed class ChatStartConversationTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
{
using var starterClient = Client();
using var otherClient = Client();
var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
starterClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
// Step 1 — the client asks for the thread with that player. It sends the
// ids as a form body (observed on the wire as
// "ids=9741717&messageCount=50"), which also matters for routing: the
// content type is what separates this from POST thread/{chatThreadId},
// whose parameter segment otherwise also matches "withmembers".
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("messageCount", "50"),
]);
using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
var createBody = await createResponse.Content.ReadAsStringAsync();
Assert.True(createResponse.IsSuccessStatusCode,
$"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
var thread = JsonDocument.Parse(createBody).RootElement;
Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
|| thread.TryGetProperty("chatThreadId", out idElement),
$"no thread id in the withmembers reply: {createBody}");
var threadId = idElement.ValueKind == JsonValueKind.String
? idElement.GetString()
: idElement.ToString();
Assert.False(string.IsNullOrWhiteSpace(threadId));
// Step 2 — and immediately opens it. No message has been sent yet.
using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
var openBody = await openResponse.Content.ReadAsStringAsync();
Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
$"""
GET /thread/{threadId} returned 404 for a thread that thread/withmembers
had just created. An empty thread is not a missing thread — the chat
cannot be opened until someone has already sent a message, which is
impossible to do first.
""");
Console.WriteLine("=== WITHMEMBERS ===");
Console.WriteLine(createBody);
Console.WriteLine("=== GET THREAD ===");
Console.WriteLine(openBody);
Assert.True(openResponse.IsSuccessStatusCode,
$"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
}
private HttpClient Client()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
return client;
}
}
@@ -1,254 +0,0 @@
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,202 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
/// generated readers register — no missing ones, no invented ones.
///
/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
/// does not produce a bad request: the server answers HTTP 200, the reader
/// throws while deserialising, and it surfaces later as a bare "Failed to copy
/// room" with no URL and no payload logged on either side. By the time it is
/// visible there is nothing left to diagnose it with.
///
/// The expected sets are extracted straight from the binary by
/// <c>tools/extract-client-dto-keys.py</c> into
/// <c>data/client-dto-keys-2023.json</c>, keyed by the obfuscated reader type.
/// Cpp2IL emits each reader's property names as string literals in registration
/// order, so that file is the client's own contract rather than our reading of
/// it — regenerate it rather than editing it by hand.
/// </summary>
public sealed class ClientDtoParityTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
/// <summary>GLEGPFFPDBE — the room-details reader. This is what
/// <c>GET rooms/{id}</c> and <c>POST rooms/{id}/clone</c> both deserialise
/// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).</summary>
private const string RoomDetailsReader = "GLEGPFFPDBE";
/// <summary>EKEFFNBIOHJ — the chat-thread reader, shared by
/// <c>POST thread/withmembers</c> and <c>GET thread/{id}</c>.</summary>
private const string ChatThreadReader = "EKEFFNBIOHJ";
/// <summary>Keys the reader registers that we deliberately do NOT send.
///
/// A registered slot does not oblige us to fill it — Newtonsoft leaves an
/// absent key at its default. These two were added for parity and broke
/// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
/// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
/// per SUB-ROOM. A room-scoped answer to the same question is one the
/// client preferred, and for a dorm it named a file that was never written,
/// so the room came back "corrupt".
///
/// Every room loaded fine while these were absent. Their absence is the
/// fix, not a gap to close later — do not "restore parity" here without a
/// dorm join to prove it.</summary>
private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
[
(RoomDetailsReader, "DataBlob"),
(RoomDetailsReader, "DataBlobHash"),
];
[Fact]
public async Task Room_details_carries_exactly_the_keys_the_client_reads()
{
using var client = RoomsClient();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Parity{Guid.NewGuid():N}"[..18],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
});
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
await db.SaveChangesAsync();
}
using var response = await client.GetAsync($"/rooms/{roomId}");
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
// The reader walks into the nested objects too, and a wrong key set
// there fails exactly the same way — a 200 the client throws on.
var details = JsonDocument.Parse(body).RootElement;
foreach (var (property, reader) in NestedReaders)
{
if (!details.TryGetProperty(property, out var value)) continue;
var element = value.ValueKind switch
{
JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
JsonValueKind.Object => value,
_ => default,
};
if (element.ValueKind != JsonValueKind.Object) continue;
AssertKeyParity(reader, element.GetRawText(),
$"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
}
}
/// <summary>Nested objects in the room-details payload and the reader each
/// one is deserialised by. Sub-room 0, the first role, the stats object and
/// the first tag all have their own generated readers, and a key set that
/// disagrees with them breaks the whole room just as hard as a wrong
/// top-level one.</summary>
private static readonly (string Property, string Reader)[] NestedReaders =
[
("SubRooms", "JDEKAFDPGBE"),
("Roles", "LMEECKCCPHG"),
("Stats", "MLHBPDKBABN"),
("Tags", "GEGADPCDGBO"),
];
[Fact]
public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
{
using var client = ChatClient();
using var otherClient = ChatClient();
var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", starter.AccessToken);
using var form = new FormUrlEncodedContent(
[
new KeyValuePair<string, string>("ids", other.PlayerId.ToString()),
new KeyValuePair<string, string>("messageCount", "50"),
]);
using var response = await client.PostAsync("/thread/withmembers", form);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
}
/// <summary>Compare one response object's keys against the reader's set.
/// Matching is case-insensitive because the readers register three spellings
/// per field (Pascal, camel, lower) and accept any of them — casing is the
/// one thing they are NOT strict about.</summary>
private static void AssertKeyParity(string readerType, string body, string what)
{
var expected = LoadReaderKeys(readerType)
.Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
.ToList();
var actual = JsonDocument.Parse(body).RootElement
.EnumerateObject().Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = expected.Where(k => !actual.Contains(k)).ToList();
var extra = actual
.Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert.True(missing.Count == 0 && extra.Count == 0,
$"""
{what} does not match what the client's {readerType} reader expects.
MISSING ({missing.Count}) — the reader registers these and we never send them;
a non-nullable field left null is what throws mid-deserialise:
{string.Join(", ", missing)}
UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
{string.Join(", ", extra)}
Expected, in the reader's own registration order:
{string.Join(", ", expected)}
""");
}
private static List<string> LoadReaderKeys(string readerType)
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
Assert.True(File.Exists(path),
$"missing {path} — regenerate with tools/extract-client-dto-keys.py");
using var doc = JsonDocument.Parse(File.ReadAllText(path));
Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
$"{readerType} is not in the extracted catalogue");
return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
}
private HttpClient RoomsClient() => ClientFor("rooms");
private HttpClient ChatClient() => ClientFor("chat");
private HttpClient ClientFor(string host)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
return client;
}
}
@@ -1,115 +0,0 @@
using System.Text.RegularExpressions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
/// a handler on this server, under the verb the client actually uses.
///
/// Rec Room is shut down, so the client can't be observed against a live server.
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
/// extracted. This test is the regression guard on that inventory: adding a
/// client route without a handler, or moving a handler to a different verb,
/// fails here rather than silently 404/405-ing a real player.
///
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
/// expected to shrink and never grow. Deleting an entry as you implement it is
/// the point — it is the project's running score against the client.
/// </summary>
public sealed class ClientRouteCoverageTests
{
/// <summary>Client routes with no handler yet. Each entry is
/// <c>VERB route</c> exactly as it appears in the inventory file.
///
/// This is EMPTY, and that is the point: every request the 2023-03-21
/// client can issue reaches a handler under the verb it actually uses. Do
/// not add an entry to make a red build green — an entry here is a promise
/// that the client gets a 404 or 405 in the wild. Implement the route, or
/// if the inventory itself is wrong (a literal that is really a cache key,
/// a deeplink, or a base the client concatenates onto), fix the inventory
/// so the claim stays honest.</summary>
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase);
[Fact]
public void Every_client_route_has_a_handler()
{
var client = LoadClientRoutes();
Assert.NotEmpty(client);
var discovered = EndpointContractDiscovery.Discover()
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
.ToList();
var server = discovered.ToHashSet();
// A bare [Route] with no verb attribute serves every verb.
var verbless = discovered
.Where(d => d.Verb == "ANY")
.Select(d => d.Pattern)
.ToHashSet(StringComparer.Ordinal);
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
var missing = new List<string>();
var wrongVerb = new List<string>();
foreach (var (verb, route, _) in client)
{
var pattern = Normalise(route);
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
// Distinguish "no handler at all" from "handler exists, wrong verb" —
// they have different causes and different fixes.
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
else missing.Add($"{verb} {route}");
}
var gaps = missing.Concat(wrongVerb)
.Where(g => !KnownGaps.Contains(g))
.OrderBy(g => g, StringComparer.Ordinal)
.ToList();
var covered = client.Count - missing.Count - wrongVerb.Count;
var pct = 100.0 * covered / client.Count;
Assert.True(gaps.Count == 0,
$"""
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
{string.Join(Environment.NewLine, gaps)}
""");
}
/// <summary>Collapse a route to a comparable shape: no host, no leading or
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
private static string Normalise(string route)
{
var r = route.Trim();
var q = r.IndexOf('?');
if (q >= 0) r = r[..q];
r = r.Trim('/', '~');
r = Regex.Replace(r, @"\{[^}]*\}", "*");
return r.ToLowerInvariant();
}
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
Assert.True(File.Exists(path), $"missing route inventory at {path}");
var rows = new List<(string, string, string)>();
foreach (var line in File.ReadLines(path))
{
if (line.Length == 0 || line[0] == '#') continue;
var parts = line.Split('\t');
if (parts.Length < 2) continue;
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
parts.Length > 2 ? parts[2].Trim() : string.Empty));
}
return rows;
}
}
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,90 +0,0 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests;
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
{
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
private readonly string _dataRoot;
public DorkNetServerFactory()
{
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dataRoot);
}
public string ApexDomain => "dork.test";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseSetting("Domain:Apex", ApexDomain);
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["AllowedHosts"] = "*",
["Database:Provider"] = "sqlite",
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
["Domain:Apex"] = ApexDomain,
["Jwt:Secret"] = JwtSecret,
["S3:Endpoint"] = "",
["S3:AccessKey"] = "",
["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "march_2023_03_21",
["DorkNet:SupportedVersions:0"] = "march_2023_03_21",
});
});
builder.ConfigureServices(services =>
{
services.PostConfigure<HostFilteringOptions>(options =>
{
options.AllowedHosts =
[
ApexDomain,
$"*.{ApexDomain}",
"localhost",
"127.0.0.1",
];
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
try
{
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, recursive: true);
}
}
catch
{
// Test cleanup must not hide the original test failure.
}
}
}
@@ -1,235 +0,0 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
namespace DorkNet.Server.Tests;
public static partial class EndpointContractDiscovery
{
public static IReadOnlyList<EndpointContract> Discover()
{
var serverAssembly = typeof(Program).Assembly;
var controllerBaseType = typeof(ControllerBase);
return serverAssembly.GetTypes()
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
.SelectMany(DiscoverControllerRoutes)
.OrderBy(route => route.Method, StringComparer.Ordinal)
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
.ThenBy(route => route.Source, StringComparer.Ordinal)
.ToArray();
}
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.Cast<IRouteTemplateProvider>()
.ToArray();
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
// which routes ALL verbs — the CDN's unity_assets handler is the live
// example. Skipping those made the discovered table under-report the
// server, so route-coverage checks saw phantom gaps.
if (httpAttrs.Length == 0)
{
httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
.ToArray();
}
if (httpAttrs.Length == 0)
{
continue;
}
foreach (var attr in httpAttrs)
{
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
? verbs
: new[] { "ANY" };
foreach (var httpMethod in attrMethods)
{
foreach (var controllerTemplate in controllerRouteTemplates)
{
var template = CombineTemplates(controllerTemplate, attr.Template);
template = ReplaceRouteTokens(template, controllerName, method.Name);
yield return new EndpointContract(
httpMethod.ToUpperInvariant(),
template,
ToSamplePath(template),
$"{controller.FullName}.{method.Name}",
RequiresAuthorization(controller, method),
IsAdminEndpoint(controller, method),
ConsumesContentTypes(controller, method));
}
}
}
}
}
private static bool RequiresAuthorization(Type controller, MethodInfo method)
{
var attributes = controller.GetCustomAttributes(inherit: true)
.Concat(method.GetCustomAttributes(inherit: true))
.ToArray();
if (attributes.OfType<IAllowAnonymous>().Any())
{
return false;
}
return attributes.OfType<IAuthorizeData>().Any();
}
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
{
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
}
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
{
var consumes = method.GetCustomAttributes(inherit: true)
.Concat(controller.GetCustomAttributes(inherit: true))
.OfType<ConsumesAttribute>()
.SelectMany(a => a.ContentTypes)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return consumes.Length == 0
? ["application/json"]
: consumes;
}
private static string ControllerName(Type controller)
{
const string suffix = "Controller";
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
? controller.Name[..^suffix.Length]
: controller.Name;
}
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
{
controllerTemplate ??= "";
actionTemplate ??= "";
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
{
return "/" + actionTemplate[2..].TrimStart('/');
}
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
{
return "/" + actionTemplate.TrimStart('/');
}
var left = controllerTemplate.Trim('/');
var right = actionTemplate.Trim('/');
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
{
return "/";
}
if (string.IsNullOrEmpty(left))
{
return "/" + right;
}
if (string.IsNullOrEmpty(right))
{
return "/" + left;
}
return "/" + left + "/" + right;
}
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
{
return template
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
}
private static string ToSamplePath(string template)
{
var path = RouteParameterRegex().Replace(template, match =>
{
var name = match.Groups["name"].Value.ToLowerInvariant();
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
{
return EndpointContract.TestPlayerToken;
}
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
{
return "1";
}
if (constraint.Contains("bool"))
{
return "true";
}
if (constraint.Contains("guid"))
{
return "00000000-0000-0000-0000-000000000001";
}
if (name.Contains("roomname"))
{
return "DormRoom";
}
if (name.Contains("locale"))
{
return "en";
}
return "sample";
});
path = Regex.Replace(path, "/+", "/");
return path.StartsWith('/') ? path : "/" + path;
}
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
private static partial Regex RouteParameterRegex();
}
public sealed record EndpointContract(
string Method,
string Template,
string SamplePath,
string Source,
bool RequiresAuthorization,
bool IsAdminEndpoint,
string[] Consumes)
{
public const string TestPlayerToken = "__test_player_id__";
public string PathFor(GameClientSession session)
{
return SamplePath.Replace(
TestPlayerToken,
session.PlayerId.ToString(),
StringComparison.Ordinal);
}
}
@@ -1,438 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
public sealed class GameClientEndpointContractTests :
IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public GameClientEndpointContractTests(DorkNetServerFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Game_client_contract_probe_reaches_every_controller_route()
{
var routes = EndpointContractDiscovery.Discover()
.Where(r => !DestroysTheProbeSession(r))
.ToList();
Assert.NotEmpty(routes);
using var client = _factory.CreateClient(new()
{
AllowAutoRedirect = false,
});
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(20);
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
var failures = new List<string>();
foreach (var route in routes)
{
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
if (!result.Passed)
{
failures.Add(
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
}
}
Assert.True(
failures.Count == 0,
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
+ string.Join(Environment.NewLine, failures));
}
/// <summary>Routes this probe must not call, because they destroy the very
/// session it authenticates with.
///
/// The probe walks every route in order using one test player. Account
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
/// player row is gone, so every later probe fails on a foreign-key
/// violation or a 401. Excluding these keeps the failure list meaningful
/// instead of one real event cascading into dozens of phantom failures.
///
/// This is deliberately narrow: only self-destruct routes belong here, not
/// anything merely inconvenient.</summary>
private static bool DestroysTheProbeSession(EndpointContract route)
{
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
return false;
// Case-insensitive to match the method comparison above: a route
// declared "Account/v1/me" would otherwise slip through and delete
// the probe player mid-run.
var template = route.Template.Trim('/');
return template.Equals("account/me", StringComparison.OrdinalIgnoreCase)
|| template.Equals("account/v1/me", StringComparison.OrdinalIgnoreCase)
|| template.Equals("account/v2/me", StringComparison.OrdinalIgnoreCase);
}
private static async Task<EndpointProbeResult> ProbeAsync(
HttpClient client,
EndpointContract route,
GameClientSession session,
string apexDomain)
{
var path = route.PathFor(session);
path = AddClientQuery(path, route, session);
var host = HostFor(path, apexDomain);
using var request = new HttpRequestMessage(
ToHttpMethod(route.Method),
new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method))
{
request.Content = CreateContent(route, session);
}
try
{
using var response = await client.SendAsync(request);
var responseText = await response.Content.ReadAsStringAsync();
var statusCode = (int)response.StatusCode;
var passed = ResponseMatchesContract(route, response.StatusCode);
return new EndpointProbeResult(
route.Method,
host,
path,
ExpectedContract(route),
statusCode,
null,
Excerpt(responseText),
passed);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new EndpointProbeResult(
route.Method,
host,
path,
ExpectedContract(route),
null,
ex.Message,
null,
false);
}
}
private static bool ResponseMatchesContract(
EndpointContract route,
HttpStatusCode statusCode)
{
if ((int)statusCode >= 500)
{
return false;
}
if (statusCode is HttpStatusCode.MethodNotAllowed
or HttpStatusCode.NotImplemented
or HttpStatusCode.UpgradeRequired)
{
return false;
}
if (route.RequiresAuthorization
&& statusCode is HttpStatusCode.Unauthorized)
{
return false;
}
return true;
}
private static string ExpectedContract(EndpointContract route)
{
if (route.IsAdminEndpoint)
{
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
}
if (route.RequiresAuthorization)
{
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
}
return "game-client request should not 5xx/405/426";
}
private static HttpMethod ToHttpMethod(string method)
{
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
? HttpMethod.Get
: new HttpMethod(method);
}
private static bool NeedsRequestBody(HttpMethod method)
{
return method == HttpMethod.Post
|| method == HttpMethod.Put
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
}
private static HttpContent CreateContent(
EndpointContract route,
GameClientSession session)
{
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
{
return new FormUrlEncodedContent(new Dictionary<string, string>
{
["account_id"] = session.PlayerId.ToString(),
["AccountId"] = session.PlayerId.ToString(),
["clubId"] = "1",
["ClubId"] = "1",
["description"] = "Endpoint contract description",
["deviceId"] = session.DeviceId,
["grant_type"] = "password",
["image"] = "endpoint-contract.png",
["imageName"] = "endpoint-contract.png",
["fileName"] = "endpoint-contract.png",
["message"] = "Endpoint contract message",
["name"] = "EndpointContract",
["newPassword"] = "endpoint-contract-password",
["oldPassword"] = "",
["password"] = "endpoint-contract-password",
["ping"] = "42",
["platformId"] = session.PlatformId,
["playerId"] = session.PlayerId.ToString(),
["playerIds"] = session.PlayerId.ToString(),
["query"] = "DormRoom",
["region"] = "us",
["targetPlayerId"] = session.PlayerId.ToString(),
["text"] = "Endpoint contract message",
["username"] = "EndpointContract",
["device_id"] = session.DeviceId,
["platform"] = "0",
["platform_id"] = session.PlatformId,
["Ids"] = session.PlayerId.ToString(),
["PlayerIds"] = session.PlayerId.ToString(),
});
}
if (consumes.Any(c => c.Contains("multipart/form-data")))
{
var content = new MultipartFormDataContent();
content.Add(new StringContent("endpoint-contract"), "name");
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
content.Add(new StringContent("endpoint-contract.png"), "imageName");
content.Add(new StringContent("endpoint-contract.png"), "fileName");
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
return content;
}
if (consumes.Any(c => c.Contains("text/plain")))
{
return new StringContent("", Encoding.UTF8, "text/plain");
}
var json = JsonSerializer.Serialize(new
{
PlayerId = session.PlayerId,
AccountId = session.PlayerId,
ClubId = 1,
Description = "Endpoint contract description",
DeviceId = session.DeviceId,
FileName = "endpoint-contract.png",
Id = session.PlayerId,
Image = "endpoint-contract.png",
ImageName = "endpoint-contract.png",
Message = "Endpoint contract message",
Platform = 0,
PlatformId = session.PlatformId,
PlayerIds = new[] { session.PlayerId },
Name = "EndpointContract",
Password = "endpoint-contract-password",
Query = "DormRoom",
RoomName = "DormRoom",
TargetPlayerId = session.PlayerId,
Text = "Endpoint contract message",
});
return new StringContent(json, Encoding.UTF8, "application/json");
}
private static string AddClientQuery(
string path,
EndpointContract route,
GameClientSession session)
{
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
var lower = path.ToLowerInvariant();
var query = new List<string>();
if (lower.Contains("search", StringComparison.Ordinal)
|| lower.Contains("query", StringComparison.Ordinal))
{
query.Add("query=EndpointContract");
}
if (lower == "/members/bulk")
{
query.Add("clubId=1");
query.Add($"playerIds={session.PlayerId}");
}
if (lower == "/player/photonregionpings")
{
query.Add("region=us");
query.Add("ping=42");
}
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|| lower.Contains("savedlogins", StringComparison.Ordinal))
{
query.Add("platform=0");
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
}
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
{
query.Add($"Ids={session.PlayerId}");
}
if (query.Count == 0)
{
return path;
}
return path + separator + string.Join("&", query);
}
private static string HostFor(string path, string apexDomain)
{
var lower = path.ToLowerInvariant();
if (lower.StartsWith("/connect/")
|| lower.StartsWith("/.well-known/")
|| lower.StartsWith("/eac/")
|| lower.StartsWith("/cachedlogin/")
|| lower.StartsWith("/photon/")
|| lower.StartsWith("/api/platformlogin/")
|| lower.StartsWith("/api/version")
|| lower.StartsWith("/api/auth/"))
{
return $"auth.{apexDomain}";
}
if (lower.StartsWith("/account/"))
{
return $"accounts.{apexDomain}";
}
if (lower.StartsWith("/roomserver/")
|| lower.StartsWith("/rooms")
|| lower.StartsWith("/playlists")
|| lower.StartsWith("/featuredrooms")
|| lower.StartsWith("/hot_rooms")
|| lower.StartsWith("/search_rooms")
|| lower.StartsWith("/api/rooms")
|| lower.StartsWith("/api/roomkeys")
|| lower.StartsWith("/api/gamesessions")
|| lower.StartsWith("/api/curatedroomplaylists"))
{
return $"rooms.{apexDomain}";
}
if (lower.StartsWith("/v1/find")
|| lower.StartsWith("/v1/join")
|| lower.StartsWith("/v1/leave")
|| lower.StartsWith("/v1/session")
|| lower.StartsWith("/roominstance/")
|| lower.StartsWith("/goto/")
|| lower.StartsWith("/player/"))
{
return $"match.{apexDomain}";
}
if (lower.StartsWith("/thread"))
{
return $"chat.{apexDomain}";
}
if (lower.StartsWith("/club")
|| lower.StartsWith("/announcements/")
|| lower.StartsWith("/subscription/")
|| lower.StartsWith("/members/")
|| lower.StartsWith("/api/clubreporting/"))
{
return $"clubs.{apexDomain}";
}
if (lower.StartsWith("/api/storefronts/")
|| lower.StartsWith("/api/purchase/")
|| lower.StartsWith("/api/subscription/"))
{
return $"commerce.{apexDomain}";
}
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
{
return $"notify.{apexDomain}";
}
if (lower.StartsWith("/v1/regions"))
{
return $"geo.{apexDomain}";
}
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
{
return $"ns.{apexDomain}";
}
if (lower.StartsWith("/upload"))
{
return $"storage.{apexDomain}";
}
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
{
return $"cdn.{apexDomain}";
}
if (lower.StartsWith("/api/admin/"))
{
return $"admin.{apexDomain}";
}
return $"api.{apexDomain}";
}
private static string? Excerpt(string? body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
var compact = body.ReplaceLineEndings(" ").Trim();
return compact.Length <= 240 ? compact : compact[..240] + "...";
}
}
public sealed record EndpointProbeResult(
string Method,
string Host,
string Path,
string Expected,
int? StatusCode,
string? Error,
string? ResponseExcerpt,
bool Passed)
{
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
}
-65
View File
@@ -1,65 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
public sealed record GameClientSession(
long PlayerId,
string AccessToken,
string DeviceId,
string PlatformId);
public static class GameClientSessionFactory
{
public static async Task<GameClientSession> CreateAsync(
HttpClient client,
string apexDomain)
{
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
var tokenRequest = new HttpRequestMessage(
HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = "EndpointContract",
["device_id"] = deviceId,
["platform"] = "0",
["platform_id"] = platformId,
});
using var tokenResponse = await client.SendAsync(tokenRequest);
if (!tokenResponse.IsSuccessStatusCode)
{
var body = await tokenResponse.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
}
using var tokenJson = await JsonDocument.ParseAsync(
await tokenResponse.Content.ReadAsStreamAsync());
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("Token response did not include access_token.");
var userInfoRequest = new HttpRequestMessage(
HttpMethod.Get,
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
userInfoRequest.Headers.Accept.ParseAdd("application/json");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2023.03.21");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest);
userInfoResponse.EnsureSuccessStatusCode();
using var userInfoJson = await JsonDocument.ParseAsync(
await userInfoResponse.Content.ReadAsStreamAsync());
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
return new GameClientSession(playerId, accessToken, deviceId, platformId);
}
}
@@ -1,80 +0,0 @@
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;
}
}
@@ -1,65 +0,0 @@
using System.Net;
using DorkNet.Contracts;
namespace DorkNet.Server.Tests;
/// <summary>
/// Regression coverage for the 2023 "pull a player into a private instance"
/// flow. The 2023 client renamed the matchmaking surface from <c>goto/*</c>
/// to <c>matchmake/*</c> and added <c>matchmake/chatinvite/{account}/{instance}</c>
/// for chat party invites; the server had neither the routes nor the gateway
/// ownership entry, so those requests 404'd.
/// </summary>
public sealed class MatchmakePullInviteTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public MatchmakePullInviteTests(DorkNetServerFactory factory) => _factory = factory;
private HttpClient ApiClient()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")] // {accountId}/{roomInstanceId}
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")] // {roomId}/{code}
public async Task Matchmake_pull_routes_exist(string path)
{
using var client = ApiClient();
using var res = await client.PostAsync(path, new StringContent(string.Empty));
// The bug was a hard 404 (no route). These resolve to a dorm-fallback
// matchmaking response when the instance/invite doesn't exist, so any
// non-404 status proves the route is now mounted.
Assert.NotEqual(HttpStatusCode.NotFound, res.StatusCode);
}
[Theory]
[InlineData("/matchmake/instance/999")]
[InlineData("/matchmake/invite/999")]
[InlineData("/matchmake/chatinvite/2/16777216")]
[InlineData("/matchmake/room/5")]
[InlineData("/matchmake/player/2")]
[InlineData("/matchmake/event/999")]
[InlineData("/matchmake/club/999")]
[InlineData("/matchmake/code/5/ABCDEF")]
public void Matchmake_paths_route_to_same_service_as_goto(string path)
{
const string apex = "dork.test";
var gotoOwner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", "/goto/instance/999", apex);
var owner = DorkNetRouteOwnership.ResolvePublicService($"api.{apex}", path, apex);
// matchmake/* must land on the same service that owns goto/* — before
// the fix it fell through to the Monolith default owner.
Assert.Equal(gotoOwner, owner);
}
}
@@ -1,198 +0,0 @@
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
@@ -1,222 +0,0 @@
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();
}
}
@@ -1,356 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's
/// owned-item DTO (RecNet IFNGBPJGINL) requires <c>AvatarItemId</c> (Int32),
/// <c>TagList</c> (string) and <c>IsBaseAvatarItem</c> (bool). Omitting them
/// left every item at AvatarItemId 0; the client keys the wardrobe by that
/// id, so the second item collided, the unlocked-items list failed to build
/// (empty wardrobe = "no owned items"), and any refetch — including the one
/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew
/// ("can't buy items"). Each item must carry a unique, non-zero id.
/// </summary>
public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty");
var ids = new List<int>();
foreach (var it in items.EnumerateArray())
{
// Every 2023 key present and correctly typed.
Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind);
Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind);
var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind;
Assert.True(isBase is JsonValueKind.True or JsonValueKind.False);
Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind);
var id = it.GetProperty("AvatarItemId").GetInt32();
Assert.NotEqual(0, id);
ids.Add(id);
}
// No duplicate ids — a collision is what crashed the wardrobe.
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Purchased_item_appears_owned_with_valid_shape()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength();
// Buy the first avatar-backed watch-storefront item.
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
.GetProperty("PurchasableItemId").GetInt32();
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
new StringContent(JsonSerializer.Serialize(new
{
StorefrontType = 3,
PurchasableItemId = purchasableId,
CurrencyType = 2,
RequestedPrice = 1,
Gift = (object?)null,
}), Encoding.UTF8, "application/json"));
buy.EnsureSuccessStatusCode();
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase");
// Still no duplicate ids after the new item joins the list.
var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public async Task Buy_gift_desc_matches_v4items_and_consume_grants_once()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
// Buy an avatar-backed item — the server queues a post-purchase gift box.
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray()
.First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString()))
.GetProperty("PurchasableItemId").GetInt32();
using var buy = await client.PostAsync("/api/storefronts/v2/buyItem",
new StringContent(JsonSerializer.Serialize(new
{
StorefrontType = 3, PurchasableItemId = purchasableId,
CurrencyType = 2, RequestedPrice = 1, Gift = (object?)null,
}), Encoding.UTF8, "application/json"));
buy.EnsureSuccessStatusCode();
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
Assert.True(gifts.GetArrayLength() > 0, "buy did not queue a gift box");
var gift = gifts[0];
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
// The gift's desc MUST be the same 4-part "{guid},,," form v4/items
// emits — the client matches on it verbatim in
// OutfitManager.UnlockAvatarItemAndMarkNew after the box is consumed;
// a bare guid missed and NRE'd ("items removed on every buy").
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"gift desc not 4-part: {giftDesc}");
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()).ToHashSet();
Assert.Contains(giftDesc, descs);
// Consuming the box must not add a SECOND wardrobe row for the item
// the direct purchase already granted as a bare guid.
using var consume = await client.PostAsync(
$"/api/avatar/v2/gifts/consume/{gift.GetProperty("Id").GetInt64()}",
new StringContent("{}", Encoding.UTF8, "application/json"));
consume.EnsureSuccessStatusCode();
var after = await GetJsonAsync(client, "/api/avatar/v4/items");
var baseGuid = giftDesc.Split(',')[0];
var occurrences = after.EnumerateArray()
.Count(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] == baseGuid);
Assert.Equal(1, occurrences);
}
[Fact]
public async Task Legacy_bare_desc_gift_is_served_as_4part_matching_v4items()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
// Simulate a gift persisted BEFORE the fix: a bare-guid outfit desc
// (no ",,," suffix) — the class of row that made the post-consume
// UnlockAvatarItemAndMarkNew Find() miss and NRE.
string bareGuid;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<Data.DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
bareGuid = item.Slug["wardrobe-".Length..];
db.GiftPackages.Add(new Data.Entities.GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0, // outfit
AvatarItemDescOrHairDyeDesc = bareGuid, // BARE — the legacy bug
IsValid = true,
});
await db.SaveChangesAsync();
}
using var client = ApiClient(player);
var gifts = await GetJsonAsync(client, "/api/avatar/v2/gifts");
var gift = gifts.EnumerateArray()
.First(g => g.GetProperty("AvatarItemDesc").GetString()!.StartsWith(bareGuid));
// Both desc keys are served as the 4-part form regardless of how the
// row was stored, so the client's exact-match unlock succeeds. (A
// real buy also grants the item, so v4/items contains it — covered by
// Purchased_item_appears_owned / Buy_gift_desc_matches_v4items.)
var giftDesc = gift.GetProperty("AvatarItemDesc").GetString()!;
Assert.True(giftDesc.Count(c => c == ',') >= 3, $"legacy gift not normalized: {giftDesc}");
Assert.Equal(giftDesc, gift.GetProperty("AvatarItemDescOrHairDyeDesc").GetString());
}
[Fact]
public async Task Toggle_on_wardrobe_includes_color_variants_with_unique_ids()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var settings = scope.ServiceProvider
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>();
await settings.SetAllAvatarItemsOwnedEnabledAsync(true);
}
try
{
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
// Variant descs ("{guid},{combo},{mask},") — the colorways — are
// present, not just base "{guid},,," rows; and every id is unique
// (base + variants share a base guid, so a base-guid hash would
// collide → wardrobe dict crash).
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
var hasVariant = descs.Any(d =>
{
var p = d.Split(',');
return p.Length >= 2 && p[1].Length > 0;
});
Assert.True(hasVariant, "toggle-on wardrobe has no colored variants");
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.DoesNotContain(0, ids);
Assert.Equal(ids.Count, ids.Distinct().Count());
}
finally
{
await using var scope = _factory.Services.CreateAsyncScope();
await scope.ServiceProvider
.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
[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)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
@@ -1,101 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// While a join is pending, the heartbeat must tell the joining player which
/// instance they are joining — never null.
///
/// Deferring the presence commit is about what everyone ELSE sees: presence
/// stays uncommitted until the join is confirmed so a failed join leaves no
/// ghost behind. It must not change what the joining player is told about
/// themselves, because the client XORs (cachedRoomInstance != null) against
/// (serverRoomInstance != null): holding the target while the server says null
/// reads as the room vanishing mid-join. The client then logs "presence
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
/// go to Invalid RoomInstance", and cancels the very join the server was
/// waiting on — so it retries until a heartbeat happens to miss the window, and
/// entering a dorm takes several attempts while looking like a load failure.
/// </summary>
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
{
using var client = Client("match");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// matchmake/dorm is what arms the deferred-commit pending join.
using var dormForm = new FormUrlEncodedContent(
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
var dormBody = await dorm.Content.ReadAsStringAsync();
Assert.True(dorm.IsSuccessStatusCode,
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
var instanceId = FindInstanceId(expectedInstance);
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
// The join has NOT been reported yet, so the commit is still deferred —
// this is exactly the window the client heartbeats in.
using var beatForm = new FormUrlEncodedContent(
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
var beatBody = await beat.Content.ReadAsStringAsync();
Assert.True(beat.IsSuccessStatusCode,
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
var presence = JsonDocument.Parse(beatBody).RootElement;
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|| presence.TryGetProperty("RoomInstance", out reported),
$"no roomInstance key in the heartbeat: {beatBody}");
Assert.True(reported.ValueKind != JsonValueKind.Null,
$"""
The heartbeat returned a null roomInstance while the player's join was
still pending. The client reads that as the room vanishing mid-join and
cancels the load it is waiting on:
{beatBody}
""");
Assert.Equal(instanceId, FindInstanceId(reported));
}
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
private static string? FindInstanceId(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object) return null;
foreach (var property in element.EnumerateObject())
{
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
return property.Value.ToString();
if (property.Value.ValueKind == JsonValueKind.Object
&& FindInstanceId(property.Value) is { } nested)
return nested;
}
return null;
}
private HttpClient Client(string host)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
return client;
}
}
@@ -1,289 +0,0 @@
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;
}
}
@@ -1,145 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// Private-room semantics:
/// 1. A freshly cloned room is PRIVATE (Accessibility=0) regardless of
/// the source's visibility — the owner publishes explicitly.
/// 2. A private room has exactly ONE instance per sub-room: every
/// JoinMode (plain matchmake / new public / new private) funnels
/// into the same deterministic invite-gated instance instead of
/// nonce-forking parallel copies.
/// 3. Players who are neither the creator nor role-holders/invitees
/// are turned away with ErrorCode=4 (indistinguishable from a
/// nonexistent room, so private rooms don't leak).
/// </summary>
public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public PrivateRoomInstanceTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Clone_is_private_by_default()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var sourceId = 9_400_100L;
await SeedRoomAsync(sourceId, creatorId: owner.PlayerId, accessibility: 1);
using var roomsClient = ApiClient(owner, subdomain: "rooms");
using var resp = await roomsClient.PostAsync(
$"/rooms/{sourceId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"PrivClone_{Guid.NewGuid():N}"[..24] }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var clone = await db.Rooms.SingleAsync(r => r.Id == cloneId);
Assert.Equal(0, clone.Accessibility); // private by default
}
[Fact]
public async Task Private_room_gets_one_instance_per_subroom_for_all_join_modes()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_400_200L;
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
using var matchClient = ApiClient(owner, subdomain: "match");
var joins = new List<(long InstanceId, string Photon, bool IsPrivate)>();
foreach (var joinMode in new[] { 0, 1, 2, 0 })
{
using var resp = await matchClient.PostAsync(
$"/matchmake/room/{roomId}",
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = joinMode.ToString() }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"JoinMode={joinMode} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(0, json.RootElement.GetProperty("errorCode").GetInt32());
var inst = json.RootElement.GetProperty("roomInstance");
joins.Add((
inst.GetProperty("roomInstanceId").GetInt64(),
inst.GetProperty("photonRoomId").GetString()!,
inst.GetProperty("isPrivate").GetBoolean()));
}
// Every join mode must land in the SAME single instance.
Assert.Single(joins.Select(j => j.InstanceId).Distinct());
Assert.Single(joins.Select(j => j.Photon).Distinct());
Assert.All(joins, j => Assert.True(j.IsPrivate, "private room instances must be invite-gated"));
}
[Fact]
public async Task Private_room_turns_away_uninvited_players()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var stranger = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_400_300L;
await SeedRoomAsync(roomId, creatorId: owner.PlayerId, accessibility: 0);
using var strangerClient = ApiClient(stranger, subdomain: "match");
using var resp = await strangerClient.PostAsync(
$"/matchmake/room/{roomId}",
new FormUrlEncodedContent(new Dictionary<string, string> { ["JoinMode"] = "0" }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
// 4 = RoomDoesNotExist — private rooms are invisible, not "forbidden".
Assert.Equal(4, json.RootElement.GetProperty("errorCode").GetInt32());
}
private async Task SeedRoomAsync(long roomId, long creatorId, int accessibility)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (await db.Rooms.AnyAsync(r => r.Id == roomId)) return;
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"PrivRoom_{roomId}",
Description = "private-room test",
CreatorPlayerId = creatorId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = accessibility,
CloningAllowed = true,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Home",
});
await db.SaveChangesAsync();
}
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -1,257 +0,0 @@
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();
}
}
@@ -1,133 +0,0 @@
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;
}
}
@@ -1,127 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// The end-of-quest reward chest is the gifts/generate + gifts/consume
/// flow. This covers the load-bearing new behaviour: a gift stamped with
/// a <c>SourceStoreItemId</c> (how quest chests are built) grants exactly
/// that store item to the player's inventory when the box is opened,
/// regardless of item kind. (The room-keyed pick itself is data-driven
/// via data/quest_rewards.json and exercised once real per-quest lists
/// are configured.)
/// </summary>
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Consuming_quest_chest_grants_backing_item()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long giftId;
string wardrobeGuid;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
wardrobeGuid = item.Slug["wardrobe-".Length..];
var gift = new GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0,
AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
Message = "You earned it!",
SourceStoreItemId = item.Id,
IsValid = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
giftId = gift.Id;
}
using var client = ApiClient(player);
using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
new StringContent("{}", Encoding.UTF8, "application/json"));
consume.EnsureSuccessStatusCode();
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
Assert.NotNull(avatar);
var inv = JsonSerializer.Deserialize<List<string>>(avatar!.InventoryJson) ?? new();
Assert.Contains(wardrobeGuid, inv);
var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
Assert.True(gift.Consumed);
}
}
[Fact]
public async Task Consuming_is_idempotent_and_does_not_double_grant()
{
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
long giftId;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
var gift = new GiftPackageEntity
{
RecipientPlayerId = player.PlayerId,
AvatarItemType = 0,
AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
SourceStoreItemId = item.Id,
IsValid = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
giftId = gift.Id;
}
using var client = ApiClient(player);
for (var i = 0; i < 2; i++)
{
using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
new StringContent("{}", Encoding.UTF8, "application/json"));
r.EnsureSuccessStatusCode();
}
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
// The wardrobe guid appears exactly once despite two consumes.
Assert.Equal(inv.Distinct().Count(), inv.Count);
}
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -1,278 +0,0 @@
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;
}
}
@@ -1,95 +0,0 @@
using DorkNet.Server.Services;
using Google.Protobuf;
using RecRoom.Protobuf;
namespace DorkNet.Server.Tests;
/// <summary>
/// A modern RecNet export's subroom save stamps PersistedRoomData
/// versions past what the March-2023 client knows (field 1
/// DEPRECATED_RoomPersistenceVersion, max 38; field 30
/// PersistedRoomVersion, max 19 = V19February23BetaRelease). The client
/// rejects the whole room with its "update Rec Room to visit this room"
/// gate — the ShibuyaCrossing import (Sep-2025 save, version=131) is the
/// canonical repro. The CDN serve path clamps both varints down; these
/// tests pin the rewrite to exactly those two fields.
/// </summary>
public sealed class RoomBlobVersionClampTests
{
private static byte[] BuildModernBlob(int deprecatedVersion, int version)
{
// Emit the wire shape a modern save actually has: version fields
// plus payload fields the 2023 schema does and doesn't know about.
using var ms = new MemoryStream();
var output = new CodedOutputStream(ms);
output.WriteTag(1, WireFormat.WireType.Varint);
output.WriteInt32(deprecatedVersion);
// Field 2: persistence_views payload (opaque here — the clamp
// must copy it verbatim).
output.WriteTag(2, WireFormat.WireType.LengthDelimited);
output.WriteBytes(ByteString.CopyFrom(0x0A, 0x03, 0x01, 0x02, 0x03));
// Field 6: sub_room_id.
output.WriteTag(6, WireFormat.WireType.Varint);
output.WriteInt64(19589779);
output.WriteTag(30, WireFormat.WireType.Varint);
output.WriteInt32(version);
// Field 37: unknown-to-2023 varint field from newer schemas.
output.WriteTag(37, WireFormat.WireType.Varint);
output.WriteInt32(1);
output.Flush();
return ms.ToArray();
}
[Fact]
public void Clamps_modern_versions_to_2023_maxima()
{
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.True(changed);
var msg = PersistedRoomData.Parser.ParseFrom(bytes);
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)msg.Version);
Assert.Equal(DEPRECATED_RoomPersistenceVersion.LatestRoomPersistenceVersion, msg.DEPRECATEDVersion);
}
[Fact]
public void Preserves_every_non_version_byte()
{
var input = BuildModernBlob(deprecatedVersion: 38, version: 131);
var (bytes, _) = RoomDataBlobService.ClampVersionsFor2023(input);
// version=131 is a two-byte varint, 16 is one byte — the output
// shrinks by exactly that and matches the input everywhere else.
Assert.Equal(input.Length - 1, bytes.Length);
var expected = BuildModernBlob(
deprecatedVersion: 38,
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
Assert.Equal(expected, bytes);
}
[Fact]
public void Blob_already_at_2023_versions_passes_through_unchanged()
{
var input = BuildModernBlob(
deprecatedVersion: 38,
version: RoomDataBlobService.Client2023MaxPersistedRoomVersion);
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.False(changed);
Assert.Same(input, bytes);
}
[Fact]
public void Malformed_input_is_returned_verbatim()
{
var input = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
var (bytes, changed) = RoomDataBlobService.ClampVersionsFor2023(input);
Assert.False(changed);
Assert.Same(input, bytes);
}
}
@@ -1,91 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 play-menu browse sources. The client's room-discovery URL
/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
/// <c>rooms/search_rooms/{query}&amp;skip={skip}&amp;take={take}</c> —
/// note the paging pairs live INSIDE the final path segment, and the
/// whole family is prefixed with <c>rooms/</c> on the rooms host.
/// The room photo gallery sends <c>sort</c>/<c>filter</c> as enum
/// names (<c>CheerCount_Desc</c>, <c>PublicOnly</c>), which must not
/// trip int model binding.
/// </summary>
public sealed class RoomBrowse2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
{
using var setupClient = Client();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var marker = $"Zz{Guid.NewGuid():N}"[..14];
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = 9_300_000 + Random.Shared.Next(1, 99_999),
Name = $"Search{marker}",
Description = "2023 search test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
});
await db.SaveChangesAsync();
}
using var roomsClient = Client(owner, subdomain: "rooms");
using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
var text = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
var names = json.RootElement.EnumerateArray()
.Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
.ToArray();
Assert.Contains(names, n => n.Contains(marker));
}
[Fact]
public async Task Room_images_accept_2023_enum_name_sort_and_filter()
{
using var setupClient = Client();
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
using var apiClient = Client(player);
using var response = await apiClient.GetAsync(
"/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
var text = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
}
private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
-247
View File
@@ -1,247 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
/// 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>,
/// with the route literal on :4802). A bare room with no <c>success</c> is
/// rejected with an empty error.
///
/// 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
/// clone promise's exception is never observed, it surfaces from the finalizer
/// thread seconds later as a bare "Failed to copy room: Exception of type
/// '...' was thrown", with nothing in the server log to show for it. That is
/// exactly how this failed in the wild: the room WAS created, and the client
/// simply never registered it.
/// </summary>
public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Clone_response_matches_the_get_by_id_shape()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = sourceId,
Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
});
// Give the source a real sub-room. Without one, BOTH responses fall
// back to the synthesised default sub-room and the comparison never
// exercises the branch a real room (like the seeded RecCenter the
// room-creation picker clones) actually takes.
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = sourceId,
Name = "Home",
OrderIndex = 0,
});
await db.SaveChangesAsync();
}
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
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 actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
// The reader walks the nested sub-room list too, so a key missing there
// breaks it just as hard as one missing at the top level.
foreach (var list in new[] { "SubRooms", "Scenes" })
{
if (!getBody.TryGetProperty(list, out var expectedList) ||
expectedList.ValueKind != JsonValueKind.Array ||
expectedList.GetArrayLength() == 0) continue;
Assert.True(cloneBody.TryGetProperty(list, out var actualList)
&& actualList.ValueKind == JsonValueKind.Array,
$"clone response has no {list} array while get-by-id does");
Assert.True(actualList.GetArrayLength() > 0,
$"clone response has an EMPTY {list} array while get-by-id returns "
+ $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
}
Assert.True(missing.Count == 0,
$"""
The clone response is missing {missing.Count} key(s) that get-by-id
returns. Both deserialize into FGCPNAACHIK, so the client throws while
reading the clone and silently never registers the new room:
{string.Join(Environment.NewLine, missing)}
clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
""");
}
/// <summary>A clone must own its save data. Cloning used to be a shallow
/// copy — the new room's scenes kept pointing at the SOURCE's blob until
/// someone saved over them — so every copy of a room shared one object and
/// cloning RecCenter produced rooms whose data blob was literally
/// <c>room_100_v1.dat</c>. A clone that references another room's blob is
/// the bug, whatever the name.</summary>
[Fact]
public async Task Clone_does_not_reference_the_source_rooms_blob()
{
// The suite runs with S3 unconfigured, where there is nothing to copy
// and the clone deliberately keeps the shallow reference. Swap in a
// real (in-memory) store so this exercises the copy path itself.
var store = new InMemoryObjectStorage();
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IObjectStorage>();
services.AddSingleton<IObjectStorage>(store);
}));
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
var sourceBlob = $"room_{sourceId}_v1.dat";
var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = sourceId,
Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
CreatorPlayerId = session.PlayerId,
CloningAllowed = true,
CurrentDataBlobName = sourceBlob,
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = sourceId,
Name = "Home",
OrderIndex = 0,
DataBlobName = sourceBlob,
});
await db.SaveChangesAsync();
}
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
Assert.False(room.CurrentDataBlobName == sourceBlob,
$"the clone's room row still points at the source's blob ({sourceBlob})");
foreach (var scene in scenes)
Assert.False(scene.DataBlobName == sourceBlob,
$"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
// It must be a real copy, not just a cleared reference — the point
// is that the player's content comes along.
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
"the clone has no blob at all; the source's data should have been copied, not dropped");
var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
}
}
/// <summary>Minimal in-memory <see cref="IObjectStorage"/>. The suite runs
/// with S3 unconfigured, so without this the clone's copy path short-circuits
/// and never gets exercised.</summary>
private sealed class InMemoryObjectStorage : IObjectStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
public bool IsS3Configured => true;
public Task<long> PutAsync(
string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
{
_objects[$"{bucket}/{key}"] = bytes;
return Task.FromResult((long)bytes.Length);
}
public Task<byte[]?> GetAsync(string bucket, string key, CancellationToken ct = default) =>
Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
public Task<bool> ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
}
[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(
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode,
$"{method} {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
}
private HttpClient Client()
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
return client;
}
}
@@ -1,235 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// End-to-end coverage of the 2023 client's in-room shop
/// (api/roomconsumables/v1) using the exact request/response wire shapes
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
/// formatters in the ISIL dump). The critical regression guard: every
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
/// otherwise.
/// </summary>
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_200_001L;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
Description = "shop test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
State = 0,
Accessibility = 1,
IsAGRoom = true,
TagsCsv = "community",
});
await db.SaveChangesAsync();
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
}
using var ownerClient = ApiClient(owner);
using var buyerClient = ApiClient(buyer);
// ── Create (client body = the desc DTO itself) ───────────────────
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{
"RoomId": {{roomId}},
"Name": "Cola",
"Description": "A refreshing test beverage",
"ImageName": "",
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
}
""");
Assert.Equal(0, created.GetProperty("Status").GetInt32());
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
Assert.NotEqual(Guid.Empty, consumableId);
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
// ── Non-owner cannot create in someone else's room ───────────────
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
""");
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
// ── Room catalog ─────────────────────────────────────────────────
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
Assert.Equal(1, catalog.GetArrayLength());
// Responses carry the price FLAT. The client's consumable formatter
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
// ── Purchase at the wrong price is rejected ──────────────────────
var codeA = Guid.NewGuid();
var badPrice = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
{
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
}
""");
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
// enum plus the inventory row. The scalar token-operation enum lives in
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
var purchase = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
{
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
}
""");
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
// ── Inventory on room join: Consumable must NEVER be null ────────
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
Assert.Equal(1, inventory.GetArrayLength());
var row = inventory[0];
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
Assert.Equal(1, row.GetProperty("Count").GetInt32());
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
// ── isOwned: a non-creator holds stock ───────────────────────────
using var isOwnedResponse = await ownerClient.GetAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
// ── Consume with a stale concurrency code resyncs the client ─────
var codeB = Guid.NewGuid();
var staleConsume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
""");
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
// ── Consume with the right code decrements and rotates the code ──
var consume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
""");
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
// ── Consuming an empty stack is refused ──────────────────────────
var emptyConsume = await PostJsonAsync(buyerClient,
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
""");
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
// ── Update via POST with RoomConsumableId in the body ────────────
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
{
"RoomConsumableId": "{{consumableId}}",
"RoomId": {{roomId}},
"Name": "Diet Cola",
"Description": "Now with fewer tokens",
"ImageName": "",
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
}
""");
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
using var buyerDelete = await buyerClient.DeleteAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
using var ownerDelete = await ownerClient.DeleteAsync(
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
Assert.Equal(0, emptied.GetArrayLength());
}
[Fact]
public async Task Unprefixed_2023_commerce_probes_respond()
{
// The 2023 client calls these WITHOUT the api/ prefix on startup;
// a 404 here surfaces as "CleanupPendingTransactions failed" +
// unobserved-HTTP-404 crash reports in Player.log.
using var client = ApiClient();
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
Assert.True(cleanup.IsSuccessStatusCode);
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
}
private HttpClient ApiClient(GameClientSession? session = null)
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
{
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
{
using var response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return document.RootElement.Clone();
}
}
-350
View File
@@ -1,350 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests;
/// <summary>
/// 2023 room-save flow (captured from the 2023.03.21 client against dev,
/// 2026-07-07). The client uploads two blobs to storage /upload —
/// FileType=6 room metadata, then FileType=1 scene save — and commits
/// them with a JSON POST to rooms/{id}/subrooms/{id}/data:
/// { "UnityAssetId": null,
/// "RoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null },
/// "SubRoomData": { "Filename": "...", "Hash": null, "OwnershipProof": null } }
/// SubRoomData.Filename is the scene blob and must become the room's
/// CurrentDataBlobName.
/// </summary>
public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public RoomSave2023Tests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task SubRoomData_accepts_2023_nested_json_and_commits_blob()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_200_001L;
var blobName = $"room_{roomId}_v1_{Guid.NewGuid():N}.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"Save2023_{Guid.NewGuid():N}"[..24],
Description = "2023 save flow test room",
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
});
await db.SaveChangesAsync();
}
else
{
await db.Rooms.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.CreatorPlayerId, owner.PlayerId));
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
var body = new
{
UnityAssetId = (long?)null,
RoomData = new { Filename = "roommeta_test.bin", Hash = (string?)null, OwnershipProof = (string?)null },
SubRoomData = new { Filename = blobName, Hash = (string?)null, OwnershipProof = (string?)null },
};
using var response = await roomsClient.PostAsync(
$"/rooms/{roomId}/subrooms/0/data",
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
var responseText = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
using var json = JsonDocument.Parse(responseText);
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
// 2023 commit-response contract (NEOPBOMGIOG): value carries a
// Room and a SubRoomDataSave. The Room reuses the exact GET
// rooms/{id} shape (BuildRoomServerDetails) — do NOT bolt extra
// keys onto it: the room-details mapper is strict about value
// shapes and an invented RankingContext object broke every
// room-details parse ("Malformed Response"). See befd590 revert.
var value = json.RootElement.GetProperty("value");
var roomDto = value.GetProperty("Room");
Assert.Equal(JsonValueKind.Object, roomDto.ValueKind);
foreach (var key in new[] { "RoomId", "SubRooms", "Roles", "Tags", "Stats" })
Assert.True(roomDto.TryGetProperty(key, out _), $"Room missing '{key}': {responseText}");
var save = value.GetProperty("SubRoomDataSave");
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
foreach (var key in new[] { "SubRoomDataSaveId", "SubRoomId", "UnityAssetId", "SavedByAccountId", "CreatedAt" })
Assert.True(save.TryGetProperty(key, out _), $"SubRoomDataSave missing '{key}': {responseText}");
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.AsNoTracking().SingleAsync(r => r.Id == roomId);
Assert.Equal(blobName, room.CurrentDataBlobName);
}
}
[Fact]
public async Task Upload_fileType6_room_metadata_is_stored_not_stubbed()
{
using var setupClient = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
using var storageClient = ApiClient(player, subdomain: "storage");
using var form = new MultipartFormDataContent("BestHTTP_HTTPMultiPartForm_TEST");
var file = new ByteArrayContent(new byte[] { 0x0A, 0x02, 0x08, 0x01 });
file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(file, "File", "file.bin");
form.Add(new StringContent("6"), "FileType");
using var response = await storageClient.PostAsync("/upload", form);
var responseText = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {responseText}");
using var json = JsonDocument.Parse(responseText);
var filename = json.RootElement.GetProperty("filename").GetString();
Assert.NotNull(filename);
Assert.StartsWith("roommeta_", filename);
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
Assert.True(
await db.RoomDataBlobs.AnyAsync(b => b.BlobName == filename),
$"expected a RoomDataBlobs row for {filename}");
}
/// <summary>
/// Regression: cloning a room — and then cloning the CLONE — must both
/// succeed and return the full room-details shape (FGCPNAACHIK). Previously
/// the handler relied on DB auto-increment for the room id AND the copied
/// scene ids, which collide with the seeded id range once the identity
/// sequence lags → the clone POST 500s and the client boots the player to
/// their dorm ("Failed to copy room: Failed to clone room").
///
/// The first clone goes through the <c>roomserver/</c>-prefixed route:
/// the 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF) sends
/// that prefix like every other room mutation, and the bare-only route
/// 404'd with an empty body ("Failed to copy room: Exception of type
/// '…' was thrown").
///
/// Blob rules: cloning a FIRST-PARTY template (AG + system-owned) must
/// give the clone a FRESH empty blob — the template's blob is a MakerPen
/// overlay against the shared baked scene, not the clone's content.
/// Cloning a USER-owned room (even AG-flagged) must carry its blob so
/// "copy room" keeps the player's edits.
/// </summary>
[Fact]
public async Task Cloning_a_room_and_then_the_clone_both_succeed()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_300_777L;
var templateBlob = $"room_{roomId}_v1.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"CloneSrc_{Guid.NewGuid():N}"[..20],
Description = "clone source",
// A first-party RRO/AG base room (RecCenter-like):
// system-owned, CloningAllowed=false, yet the base-room
// picker must still be able to clone it. Its blob is a
// MakerPen overlay that must NOT leak into clones.
CreatorPlayerId = PlayerService.SystemAccountId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
IsAGRoom = true,
IsDormRoom = false,
CloningAllowed = false,
CurrentDataBlobName = templateBlob,
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Main",
DataBlobName = templateBlob,
});
await db.SaveChangesAsync();
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
// First clone: template → player copy, via the roomserver/ prefix the
// real client uses.
var firstCloneId = await CloneAndAssertAsync(
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
Assert.NotEqual(roomId, firstCloneId);
// A clone of a first-party template used to be left with NO blob, on
// the reasoning that its content is baked client geometry. That left
// the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
// a name it has nothing stored under, and the client fetches exactly
// that through GetRoomData ("/room/{blob}") while copying, then rejects
// it. Clones that carried real content joined fine.
//
// The content now comes across. Sharing the SOURCE's object is still
// wrong, but that is prevented by copying it under the clone's own name
// rather than by having no blob at all — see
// RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
// which runs with a real object store. This suite has none, so the
// copy deliberately falls back to keeping the reference and only the
// "has content" half is checkable here.
await AssertBlobsAsync(firstCloneId, expectEmpty: false,
"a clone of a first-party template must carry its content");
// Simulate the player saving MakerPen edits into their clone.
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.SingleAsync(r => r.Id == firstCloneId);
room.CurrentDataBlobName = editedBlob;
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == firstCloneId);
scene.DataBlobName = editedBlob;
await db.SaveChangesAsync();
}
// The regression: cloning the clone (bare path still works too).
// The source is user-owned — even though it inherited IsAGRoom, the
// player's edits must carry over.
var secondCloneId = await CloneAndAssertAsync(roomsClient, firstCloneId, "SecondClone");
Assert.NotEqual(firstCloneId, secondCloneId);
await AssertBlobsAsync(secondCloneId, expectEmpty: false,
"copying a user-owned room must keep its blob (player edits)");
}
private async Task AssertBlobsAsync(long roomId, bool expectEmpty, string why)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var room = await db.Rooms.SingleAsync(r => r.Id == roomId);
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == roomId);
if (expectEmpty)
{
Assert.True(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was '{room.CurrentDataBlobName}'");
Assert.True(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was '{scene.DataBlobName}'");
}
else
{
Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName), $"{why}: room blob was empty");
Assert.False(string.IsNullOrEmpty(scene.DataBlobName), $"{why}: scene blob was empty");
}
}
private static async Task<long> CloneAndAssertAsync(
HttpClient client, long sourceId, string name, string pathPrefix = "")
{
// The real 2023 client posts the name as x-www-form-urlencoded
// (`name=...`), NOT JSON — testing with JSON would miss the 415 that
// a [FromBody] handler returns for a form POST.
using var resp = await client.PostAsync(
$"{pathPrefix}/rooms/{sourceId}/clone",
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = name }));
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
// {success, value, error} envelope around the full room-details
// shape (FGCPNAACHIK).
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
var root = json.RootElement.GetProperty("value");
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64();
}
/// <summary>
/// Regression: <c>GET rooms/{id}/subrooms/{sub}/saves</c> must return the
/// paged wrapper <c>{"Results":[…],"TotalResults":N}</c>
/// (PagedResultsDTO&lt;SubRoomDataSaveDTO&gt;), NOT a bare array. The 2023
/// client's GetSubRoomDataSaves strict reader throws
/// <c>expected:'{', actual:'['</c> on an array, which aborts the whole
/// calling flow — room clone fails with the message-less "Failed to copy
/// room" and the private-instance button silently does nothing.
/// </summary>
[Fact]
public async Task SubRoom_saves_returns_paged_object_not_bare_array()
{
using var setupClient = ApiClient();
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
var roomId = 9_300_888L;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
if (!await db.Rooms.AnyAsync(r => r.Id == roomId))
{
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = $"SavesShape_{Guid.NewGuid():N}"[..20],
CreatorPlayerId = owner.PlayerId,
ImageName = RoomService.DefaultRoomImageName,
Accessibility = 1,
CurrentDataBlobName = $"room_{roomId}_v1.dat",
});
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = 0,
Name = "Home",
DataBlobName = $"room_{roomId}_v1.dat",
});
await db.SaveChangesAsync();
}
}
using var roomsClient = ApiClient(owner, subdomain: "rooms");
foreach (var path in new[]
{
$"/roomserver/rooms/{roomId}/subrooms/0/saves?skip=0&take=10",
$"/rooms/{roomId}/subrooms/0/saves",
$"/roomserver/rooms/{roomId}/subrooms/0/saves/no_unity_assets?skip=0&take=10",
})
{
using var resp = await roomsClient.GetAsync(path);
var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{path} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind);
Assert.Equal(JsonValueKind.Array, json.RootElement.GetProperty("Results").ValueKind);
Assert.True(json.RootElement.GetProperty("TotalResults").GetInt64() >= 1, $"TotalResults missing/zero: {text}");
}
}
private HttpClient ApiClient(GameClientSession? session = null, string subdomain = "api")
{
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}

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