Compare commits

..

22 Commits

Author SHA1 Message Date
Alexa a07360ee3f ci(release): grant contents:write to the release workflow
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:17 +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
395 changed files with 42626 additions and 173089 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
+3 -3
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
+2 -25
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,32 +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/**
local-releases.zip
+4 -4
View File
@@ -1,14 +1,14 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:10.0 with only the published output.
# slimmer aspnet:9.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=10.0
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
@@ -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:10.0 base image strips both by
# both have to be present. The aspnet:9.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:10.0 ships with the `app` UID 1654.
# Run as a non-root user — aspnet:9.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=10.0
ARG NODE_VERSION=20
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);
}
}
+11 -19
View File
@@ -1,13 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.ClientMod</RootNamespace>
<AssemblyName>DorkNet.ClientMod</AssemblyName>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
@@ -20,28 +19,21 @@
they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed.
By default the project downloads the pinned desktop MelonLoader release
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest /
LemonLoader builds, pass:
-p:TargetFrameworks=net8.0
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
The reference folder follows the target framework unless explicitly
overridden with -p:MelonLoaderReferenceTfm=...
By default the project downloads the pinned MelonLoader release
into DorkNet.ClientMod/build/ and references those DLLs. Pass
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
deliberately want to use a local override.
-->
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)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')">
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
@@ -55,19 +47,19 @@
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath>
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
+448 -3509
View File
@@ -3,7 +3,9 @@
// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. TLS trust bypass — make BouncyCastle never reject server certs
// 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
@@ -20,21 +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.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)]
@@ -49,19 +43,10 @@ public class Mod : MelonMod
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _httpDiagnosticsRegistered;
private bool _joinTracePatchesRegistered;
private bool _quitTraceCoreRegistered;
private bool _quitTraceGameComplete;
private int _quitTraceRetryFrame;
private bool _antiTamperCallbackPatchesComplete;
private int _antiTamperCallbackRetryFrame;
private bool _toxModPatchesComplete;
private int _toxModRetryFrame;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private bool _firstUpdateLogged;
private bool _processExceptionHandlersRegistered;
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
@@ -69,17 +54,22 @@ 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;
// Log-only trace for the stuck first-dorm "Change Username?"
// dialog. When true, RegistrationPatches hooks the dorm
// registration-prompt flow + the confirm-dialog buttons and writes
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
// brand-new account that reproduces the freeze tells us exactly
// where it breaks (does the click reach Button_Affirmative? does
// the prompt even run?). No behaviour change — purely diagnostic.
// See RegistrationPatches and the registration site in
// OnLateInitializeMelon.
public static bool TraceRegistrationDialog = true;
// 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
@@ -87,57 +77,6 @@ public class Mod : MelonMod
// 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>();
public static bool DiagnoseDevMenu = false;
public static bool EnableBuiltInDevDebugTools = false;
// Master switch for the verbose, non-essential DIAGNOSTIC patches
// (HTTP header tracing, join/quit traces, studio traces, the deep
// RegisterDiagnostics path). OFF by default so the shipping client
// mod installs only the patches needed to connect + survive
// anti-cheat — the safest possible footprint. Turned ON automatically
// when DorkNet.DebugMod.dll is present in the Mods folder (drop it in
// to debug, remove it to ship), or by "EnableDiagnostics": true in
// the config. Core connectivity/anti-cheat patches are NEVER gated.
public static bool EnableDiagnostics = false;
// ── 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" };
}
public override void OnInitializeMelon()
@@ -146,378 +85,124 @@ public class Mod : MelonMod
Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig();
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));
TryPatchByName("LBNJFPOLCDL", "JHLKGBGJKKK",
args: new[] { typeof(string), typeof(string), typeof(string) },
prefix: nameof(AuthPatches.CaptureTokenArgs_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)
{
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.
// Quit-trace is pure diagnostics, so it's gated.
if (Cfg.EnableDiagnostics)
RegisterQuitTracePatches(logMisses: true);
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));
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
}
// 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.GDECPANDBHJ()
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
// struct-array) into the protobuf TeamSize. Prefix that builder and
// bump the source array IN PLACE before it converts, scoped by
// asset.Name. This avoids the obfuscated protobuf entirely (its
// generic RepeatedField getter isn't emitted by Il2CppInterop).
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
prefix: nameof(QuestTeamSize.Build_Prefix));
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
}
if (Cfg.EnableDiagnostics)
RegisterStudioTracePatches();
if (Cfg.EnableBuiltInDevDebugTools)
RegisterBuiltInDevDebugTools();
Log.Msg("=== Client patches registered ===");
}
private void RegisterBuiltInDevDebugTools()
{
Log.Msg("=== Registering built-in Dev Debug Tools hooks ===");
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDeveloperAndNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.GlobalModelController+HideIfNotDebugBuildImpl",
"DGFIJKPCBLK", args: Type.EmptyTypes,
prefix: nameof(BuiltInDevDebugToolsPatches.KeepVisible_Prefix));
TryPatchByName("RRUI.Data.DevDebugBuildInformationModel",
"get_BuildInfoFormatted", args: Type.EmptyTypes,
postfix: nameof(BuiltInDevDebugToolsPatches.BuildInfoFormatted_Postfix));
Log.Msg("=== Built-in Dev Debug Tools hooks registered ===");
}
// ── Room-join + watch-button trace ────────────────────────────────
// Traces the full "press the Dorm Room button → join the room" chain
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
// is debuggable from the log alone. The chain, verified against the
// 2020.12.18 IL2CPP dump:
//
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
// → NotificationManager.Play("An error occured", "…happyfox…")
// (the "contact recroom.happyfox.com" toast)
//
// We can't express the obfuscated RunJoinRoom param types as a Type[]
// for AccessTools, so every RunJoinRoom overload + every /goto overload
// is patched by iterating declared methods by name, and the prefixes
// read arguments generically via Harmony's `object[] __args`.
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
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,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
// Patch every declared method on `typeName` named `methodName`,
// regardless of signature, with a single prefix. Used for obfuscated
// overload sets whose param types we can't name in a Type[].
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
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)
{
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;
}
// Patch compiler-generated lambda methods (names containing "b__")
// on the nested display classes of `typeName` whose (de-mangled) name
// also contains `nameContains`. Il2CppInterop mangles the angle
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
// method name itself intact, so a plain substring match still works.
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
{
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 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++;
Log.Msg($"[patch-ok] file hash checker callback {type.FullName}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] file hash checker callback {type.FullName}.{method.Name}: {ex.Message}");
}
}
}
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.");
}
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++;
Log.Msg($"[patch-ok] {label} {type.FullName}.{method.Name}");
}
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);
RegisterDiagnostics();
Log.Msg("=== Client patches registered ===");
}
private void RegisterNetworkPatches()
@@ -531,7 +216,6 @@ public class Mod : MelonMod
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatchHttpRequestUriConstructors();
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
complete &= TryPatchHttpManagerStringSendRequestOverloads();
@@ -539,130 +223,9 @@ public class Mod : MelonMod
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
// HTTP header/callback tracing is pure diagnostics (and the noisiest
// thing in the log) — only install it when diagnostics are enabled.
if (Cfg.EnableDiagnostics)
RegisterHttpDiagnostics();
_networkPatchesRegistered = complete;
}
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 bool TryPatchHttpRequestUriConstructors()
{
var type = ResolveType("BestHTTP.HTTPRequest");
if (type is null)
{
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri): type not found");
return false;
}
var prefix = GetPatchMethod(nameof(UriPatches.HttpRequestCtorUri_Prefix));
var patched = 0;
foreach (var ctor in type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
var parameters = ctor.GetParameters();
if (parameters.Length == 0 || !IsUriType(parameters[0].ParameterType)) continue;
try
{
HarmonyInstance.Patch(ctor, prefix: new HarmonyMethod(prefix));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] BestHTTP.HTTPRequest..ctor({FormatParameterTypes(parameters)}): {ex.Message}");
}
}
if (patched == 0)
{
Log.Warning("[patch-miss] BestHTTP.HTTPRequest..ctor(Uri)");
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPRequest..ctor(Uri first) overloads x{patched}");
return true;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
@@ -693,54 +256,35 @@ public class Mod : MelonMod
return true;
}
private static bool IsUriType(Type type)
{
return type == typeof(Uri) || type.FullName == typeof(Uri).FullName || type.Name == nameof(Uri);
}
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);
}
public override void OnUpdate()
{
if (!_firstUpdateLogged)
{
_firstUpdateLogged = true;
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
}
// 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 (Cfg.EnableDiagnostics && !_quitTraceGameComplete && ++_quitTraceRetryFrame >= 300)
{
_quitTraceRetryFrame = 0;
RegisterQuitTracePatches(logMisses: false);
}
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300)
{
_antiTamperCallbackRetryFrame = 0;
RegisterAntiTamperCallbackPatches(logMisses: false);
}
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
if ((_diagnosticRetryFrame % 60) != 0) return;
if (!_toxModPatchesComplete && ++_toxModRetryFrame >= 300)
{
_toxModRetryFrame = 0;
RegisterToxModPatches(logMisses: false);
}
if (Cfg.EnableDebugConsole)
DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu)
DevMenuProbe.Tick();
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 ────────────────────────────────────────────────────────
@@ -748,25 +292,19 @@ public class Mod : MelonMod
{
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");
// Auto-enable diagnostics when the separate DorkNet.DebugMod.dll is
// dropped in alongside this mod. Done before the config read (and
// before the no-config-file early return) so presence alone is
// enough — no config edit required to debug.
try
{
var modsDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (modsDir != null && File.Exists(Path.Combine(modsDir, "DorkNet.DebugMod.dll")))
{
Cfg.EnableDiagnostics = true;
Log.Msg("[config] DorkNet.DebugMod.dll present → diagnostics ENABLED");
}
}
catch { /* presence detection is best-effort */ }
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
@@ -775,38 +313,17 @@ public class Mod : MelonMod
}
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, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "EnableBuiltInDevDebugTools", out v)) Cfg.EnableBuiltInDevDebugTools = v.GetBoolean();
// Config can also enable diagnostics; OR with the debug-mod
// presence check above so either path turns them on.
if (TryGetConfigValue(r, "EnableDiagnostics", out v)) Cfg.EnableDiagnostics |= 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();
}
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, "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)
}
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, " +
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)
@@ -815,48 +332,9 @@ public class Mod : MelonMod
}
}
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
{
@@ -866,12 +344,7 @@ 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,
@@ -904,59 +377,7 @@ 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);
}
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
string fallbackMethodName,
string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var type = ResolveType(typeName);
var label = $"{typeName}.<returns {returnTypeName}>";
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
}
var matches = new List<MethodInfo>();
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
}
if (matches.Count == 1)
{
var method = matches[0];
try
{
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {typeName}.{method.Name} (returns {returnTypeName})");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
return false;
}
}
if (matches.Count > 1)
{
var names = new List<string>();
foreach (var method in matches) names.Add(method.Name);
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
}
else if (logMiss)
{
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
}
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
return TryPatch(label, type, methodName, args, prefix, postfix);
}
internal static Type? ResolveType(string name)
@@ -970,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
@@ -1027,22 +446,9 @@ public class Mod : MelonMod
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 bool ReturnTypeMatches(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;
}
@@ -1059,7 +465,7 @@ public class Mod : MelonMod
{
// 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(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(QuitTracePatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(BuiltInDevDebugToolsPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
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;
@@ -1067,30 +473,6 @@ public class Mod : MelonMod
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
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 ===");
@@ -1109,37 +491,39 @@ public class Mod : MelonMod
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).
var complete = true;
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
logMiss: logMisses);
foreach (var method in new[]
{
"OnTimeCheatDetected",
"OnObscuredTypeCheatDetected",
"OnHeightCheatDetected",
"OnAdvancedMovementCheatDetected",
"UnityTimeCheat",
"ObscuredTypeCheat",
"DeveloperFlagCheat",
"HeightChangeCheat",
"AdvancedMotionCheat",
"StreamingAssetCheat"
})
{
TryPatchDiagnosticByName("CheatManager", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("AnalyticsHelper", 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;
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,
@@ -1156,211 +540,6 @@ public class Mod : MelonMod
_diagnosticPatchLabels.Add(label);
return true;
}
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;
}
/// <summary>Studio baked-asset load trace. Registered directly from the
/// live client-patch flow (the deep diagnostics path
/// <see cref="RegisterDiagnostics"/> is not wired into init, so probes
/// placed there never run). Pinpoints where the baked-Studio-scene chain
/// breaks: the unity-asset getter (does the client ever request a
/// bundle), prefab registration (does a bundle ever load), and the prefab
/// lookups that fail with "Expected a prefab, but found none".
/// RecRoomObjectPrefabManager is name-preserved; the getter is the
/// obfuscated NLDBPDCNNCF.FKLDMPMFLBD. Each is a single overload.</summary>
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 { }
};
}
}
// ── Patch holders ─────────────────────────────────────────────────────
@@ -1368,369 +547,7 @@ public class Mod : MelonMod
// Harmony invokes. Kept separate from Mod for readability and so the
// reflection lookup in GetPatchMethod stays simple.
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>";
}
}
// ── Debug console access ───────────────────────────────────────────────
// Built-in watch Dev Debug Tools hooks. The 2023 RRUI page already exists,
// but retail rows are hidden by GlobalModelController hide gates. We keep
// those rows visible and append the debug-console command table to the
// built-in Build Information text so the watch page reflects the real client
// command config.
internal static class BuiltInDevDebugToolsPatches
{
public static bool KeepVisible_Prefix() => false;
public static void BuildInfoFormatted_Postfix(ref string __result)
{
try
{
var commands = DebugConsolePatches.BuildCommandReferenceText("Debug Console Commands", maxCommands: 60);
__result = string.IsNullOrWhiteSpace(__result)
? commands
: __result.TrimEnd() + "\n\n" + commands;
}
catch (Exception ex)
{
Mod.Log.Warning($"[devtools] failed to append command list: {ex.Message}");
}
}
}
// 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,
@@ -1741,13 +558,12 @@ internal static class BuiltInDevDebugToolsPatches
//
// 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 private instance void(bool) on
// DebugConsole — ShowInputField on 2020.03, MCNBJFECHLJ on 2020.12,
// KIAMDEDKFBC on 2023.03. We try the known names, then fall back to
// a structural match.
// * The live component is found via Resources.FindObjectsOfTypeAll
// using an Il2CppSystem.Type argument; this interop build does not
// expose UnityEngine.Object.FindObjectOfType(System.Type).
// * 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
@@ -1764,24 +580,12 @@ internal static class DebugConsolePatches
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;
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;
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 Mod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
@@ -1817,7 +621,7 @@ internal static class DebugConsolePatches
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = FindConsoleInstance();
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}"); }
@@ -1870,9 +674,13 @@ internal static class DebugConsolePatches
// 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) 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
@@ -1898,10 +706,10 @@ internal static class DebugConsolePatches
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null) return;
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
try
{
var instance = FindConsoleInstance();
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");
@@ -1914,236 +722,10 @@ internal static class DebugConsolePatches
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
if (_consoleVisible)
ShowConfiguredCommands(instance);
}
catch (Exception ex) { Mod.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 = Mod.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 = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch (Exception ex)
{
Mod.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)
{
Mod.Log.Warning($"[debugconsole] SetConsoleText(string) not found; configured commands: {FormatConfiguredCommandsForLog()}");
return;
}
_setConsoleText.Invoke(instance, new object[] { BuildConfiguredCommandText() });
Mod.Log.Msg($"[debugconsole] displayed configured commands: {FormatConfiguredCommandsForLog()}");
}
catch (Exception ex)
{
Mod.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.";
}
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;
Mod.Log.Msg($"[debugconsole] command table loaded: {commands.Count} command(s)");
return commands;
}
private static void AddConfiguredCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
foreach (var entry in Mod.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;
AddCommand(commands, seen, label, command);
}
}
private static void AddRuntimeCommands(List<ConfiguredCommand> commands, HashSet<string> seen)
{
try
{
var cfgType = Mod.ResolveType("RecRoom.Debugging.DebugConsoleCommandConfig");
if (cfgType is null)
{
Mod.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 = Mod.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)
{
Mod.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 label = string.IsNullOrWhiteSpace(declaringType)
? methodName
: $"{ShortTypeName(declaringType)}.{methodName}";
AddCommand(commands, seen, label, methodName);
}
}
private static void AddCommand(List<ConfiguredCommand> commands, HashSet<string> seen, string label, string command)
{
label = CleanLabel(label);
command = (command ?? string.Empty).Trim();
if (command.Length == 0 || !seen.Add(command)) return;
commands.Add(new ConfiguredCommand(label, command));
}
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)
{
Label = label;
Command = command;
}
public string Label { get; }
public string Command { get; }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
@@ -2159,1514 +741,42 @@ internal static class DebugConsolePatches
}
catch { /* best-effort — the toggle still works without it */ }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true }
: new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
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 });
}
}
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
// Answers stripped-vs-gated for the watch developer menu. With a real dev
// account + forced flag the menu still doesn't appear, so the gate isn't the
// account flag — either the native dev-menu prefab content is stripped from
// this retail build, or a build-level flag skips its init. This logs the
// actual runtime state once ~5s after load (no behaviour change) so we know
// whether to un-gate the game's own dev menu or build our own watch entry.
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
// once. Not a Harmony holder — invoked directly, so it's not registered in
// GetPatchMethod.
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
if ((_frame % 60) != 0) return; // then check ~once per second
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
// UIBase wired (it gets assigned when the watch StackedUI initialises
// in the dorm). The earlier version fired during boot on the loaded
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
// whose runtime UIBase is null) and, being one-shot, captured nothing
// and never retried. NO timeout now — we wait until the watch is up.
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
var home = homeType != null ? FindLiveHome(homeType) : null;
if (home == null)
{
if ((_frame % 600) == 0)
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
return;
}
_done = true;
try { Run(homeType!, home); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
}
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
// copies FindObjectsOfTypeAll also returns.
private static object? FindLiveHome(Type homeType)
{
try
{
var il2 = ToIl2CppType(homeType);
if (il2 is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
int n = ArrLen(all);
for (int i = 0; i < n; i++)
{
var o = ArrItem(all, i);
if (o is null) continue;
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
}
}
catch { }
return null;
}
private static void Run(Type homeType, object homeInst)
{
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
try { DumpHomeScreen(homeType, homeInst); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
Mod.Log.Msg("=== [devmenu-probe] done ===");
}
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
{
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
var inst = FindOne(t);
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
}
private static void DumpHomeScreen(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
// managed proxy, so a plain AccessTools.Field misses them (the earlier
// "field not found" was a false negative). Try property first, then
// field. NULL => content stripped from the prefab (can't un-gate);
// present => only gated.
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
{
var (found, mt, v) = GetMember(t, inst, name);
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
var extra = "";
if (v != null && name == "devMenuPanel")
{
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
}
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
}
// Calibration: are ordinary serialized field names exposed at all on
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
// in this build. If profileButton is ALSO not found, Il2CppInterop
// isn't exposing these fields by name (need a different access path).
foreach (var cal in new[] { "profileButton", "settingsButton" })
{
var (cf, cmt, cv) = GetMember(t, inst, cal);
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
}
// Named lookups miss → dump the proxy's actual member names to see
// Il2CppInterop's scheme, then enumerate Button3D children directly
// (no field names needed) — those are our clone sources for injection.
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
DumpWatchButtons(t, inst);
DumpCollections(t, inst, "HomeScreenFlow");
DumpStackedUi(t, inst);
}
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
// to build a CUSTOM watch page by cloning a real flow:
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
// are our clone-target candidates (pick a simple self-contained one).
// * the Type->flow cache — what's already instantiated (do NOT mutate
// these cached singletons; clone an independent copy instead).
// * ScreenHeader presence — proves a cloned flow brings its own title
// bar + back button for free.
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
// Dictionary<Type,flow>) rather than by field name.
private static void DumpStackedUi(Type homeType, object homeInst)
{
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
var uiType = ui.GetType();
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
if (hdr != null)
{
var ht = hdr.GetType();
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
}
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
object? v; try { v = f.GetValue(ui); } catch { continue; }
if (v is null) continue;
int n = ArrLen(v);
// Flow-prefab catalog: a non-empty array whose elements expose a
// TypeName string (FlowPrefabResourceReference).
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
{
var first = ArrItem(v, 0);
var et = first?.GetType().Name ?? "?";
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
for (int i = 0; i < n; i++)
{
var e = ArrItem(v, i); if (e is null) continue;
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
}
continue;
}
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
var vt = v.GetType();
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
{
var ga = vt.GetGenericArguments();
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
{
var cp = vt.GetProperty("Count");
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
}
}
}
}
// Property by name, else parameterless getter method, else null.
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
{
var (found, _, v) = GetMember(t, inst, prop);
if (found && v != null) return v;
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
null, Type.EmptyTypes, null);
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
return v;
}
// First non-empty string field/property on an object — for obfuscated
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
private static string ReadStringMember(object o)
{
var t = o.GetType();
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (f.FieldType == typeof(string))
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
return "(no string member)";
}
// Enumerate Button3D components under the HomeScreenFlow via Unity's
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
// Confirms we can grab native buttons to clone for the dev entry.
private static void DumpWatchButtons(Type homeType, object homeInst)
{
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
var il2 = ToIl2CppType(btnType);
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
MethodInfo? m = null;
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (mi.Name != "GetComponentsInChildren") continue;
var mp = mi.GetParameters();
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
}
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
object? arr;
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
int n = ArrLen(arr);
Mod.Log.Msg($" [buttons] Button3D children: {n}");
for (int i = 0; i < n && i < 20; i++)
{
var b = ArrItem(arr, i);
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
Mod.Log.Msg($" [{i}] {nm}");
}
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
var f = AccessTools.Field(t, name);
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
return (false, typeof(object), null);
}
private static void DumpWatchUi(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
DumpCollections(t, inst, "WatchUI");
}
private static void DumpDebugConsole(Type t, object? inst)
{
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
{
try
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
}
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
}
// Does the static command path work without the console UI in-scene?
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
}
// Log every array / List<> field whose element type mentions Flow/Watch,
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
// regardless of its obfuscated name, so we can see if dev flows exist.
private static void DumpCollections(Type t, object inst, string label)
{
var members = new List<(string name, object? val)>();
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
foreach (var (name, v) in members)
{
if (v is null) continue;
int? count = null; string elem = "";
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
else
{
var ft = v.GetType();
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
{
var cp = ft.GetProperty("Count");
if (cp != null && cp.PropertyType == typeof(int))
{
count = (int?)cp.GetValue(v);
var ga = ft.GetGenericArguments();
elem = ga.Length > 0 ? ga[0].Name : "?";
}
}
}
if (count.HasValue &&
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
}
}
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
// in the log. Convert the managed Type to an Il2CppSystem.Type via
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
// returns INACTIVE objects + loaded prefab instances — important, the
// watch UI is usually inactive until opened). Falls back to the
// active-only FindObjectOfType.
private static object? FindOne(Type t)
{
try
{
var il2cppType = ToIl2CppType(t);
if (il2cppType is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
if (first != null) return first;
var objType = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch { return null; }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true } : new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
// Find a public static method `name` whose single parameter accepts `arg`.
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
{
if (t is null) return null;
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
}
return null;
}
// First element of an Il2CppReferenceArray / Array / IList, or null.
private static object? FirstOf(object? arr)
{
if (arr is null) return null;
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
var at = arr.GetType();
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
if (lenProp?.GetValue(arr) is int n && n > 0)
{
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
}
return null;
}
}
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
// shared desktop image. Capture cadence is the baked [SerializeField]
// float screenShareImageRefreshFrequency; the broadcast tick gates on
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
// an Awake postfix. The captured frame is transmitted via the component's
// IPunObservable OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
// Photon send/serialization rate to cover the target — otherwise a 30 fps
// capture still only delivers ~10. Field names are un-obfuscated on both
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
// lookup works.
internal static class ScreenSharePatches
{
private static bool _photonRateApplied;
public static void DisplayAwake_Postfix(object __instance)
{
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);
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
RaisePhotonRates((int)Math.Ceiling(fps));
}
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
}
private static void SetField(Type t, object inst, string name, object value)
{
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);
}
// 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)
{
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;
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
}
private static int ReadInt(Type t, string name)
{
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;
}
private static void WriteInt(Type t, string name, int value)
{
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);
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class 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;
else if (string.IsNullOrWhiteSpace(__0?.ToString()) && string.IsNullOrWhiteSpace(__1?.ToString()))
_latestAccessToken = null;
if (_tokenLogCount++ < 3)
{
DiagnosticPatches.Write(
"[auth-trace] token setter " +
$"access={Presence(__0)} refresh={Presence(__1)} key={Presence(__2)}");
}
}
catch (Exception ex)
{
Mod.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";
public static void ApplyBearerTokenHeader(object? request)
{
try
{
if (request is null || string.IsNullOrWhiteSpace(_latestAccessToken)) return;
var uri = TryReadUri(request);
if (uri is null || !IsDorkNetServiceHost(uri.Host)) return;
var type = request.GetType();
var setHeader = AccessTools.Method(type, "SetHeader", new[] { typeof(string), typeof(string) })
?? AccessTools.Method(type, "AddHeader", new[] { typeof(string), typeof(string) });
setHeader?.Invoke(request, new object[] { "Authorization", "Bearer " + _latestAccessToken });
}
catch (Exception ex)
{
if (_tokenLogCount++ < 6)
Mod.Log.Warning($"[auth-trace] bearer propagation failed: {ex.Message}");
}
}
private static Uri? TryReadUri(object request)
{
var type = request.GetType();
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
if (TryGetUri(type.GetProperty("Uri", flags), request, out var uri)) return uri;
if (TryGetUri(type.GetField("<Uri>k__BackingField", flags), request, out uri)) return uri;
foreach (var prop in type.GetProperties(flags))
{
if (prop.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(prop, request, out uri)) return uri;
}
foreach (var field in type.GetFields(flags))
{
if (field.Name.IndexOf("Uri", StringComparison.OrdinalIgnoreCase) < 0) continue;
if (TryGetUri(field, request, out uri)) return uri;
}
return null;
}
private static bool TryGetUri(PropertyInfo? prop, object request, out Uri? uri)
{
uri = null;
if (prop is null || !prop.CanRead || prop.GetIndexParameters().Length != 0) return false;
uri = prop.GetValue(request) as Uri;
return uri is not null;
}
private static bool TryGetUri(FieldInfo? field, object request, out Uri? uri)
{
uri = null;
if (field is null) return false;
uri = field.GetValue(request) as Uri;
return uri is not null;
}
private static bool IsDorkNetServiceHost(string host)
{
var configuredHost = Mod.Cfg.ServerHost?.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(configuredHost)) return false;
return host.Equals(configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("." + configuredHost, StringComparison.OrdinalIgnoreCase)
|| host.EndsWith("-" + configuredHost, StringComparison.OrdinalIgnoreCase);
}
}
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)
Mod.Log.Warning($"[http-trace] {source} failed: {ex.GetType().Name}: {ex.Message}");
}
}
internal static class UriPatches
{
public static void HttpRequestCtorUri_Prefix(object __0)
{
HttpTracePatches.TraceUrl(__0?.ToString(), "BestHTTP.HTTPRequest..ctor(Uri)");
}
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");
HttpTracePatches.TraceUrl(url, "BestHTTP.HTTPManager.SendRequest(string)");
}
public static void HttpManagerSendRequestObject_Prefix(object request)
{
RewriteRequestUri(request, "httpmanager-request-rewrite");
AuthPatches.ApplyBearerTokenHeader(request);
}
public static void UriStringCtor_Prefix(ref string uriString)
{
RewriteString(ref uriString, "uri-rewrite");
HttpTracePatches.TraceUrl(uriString, "System.Uri..ctor(string)");
}
public static void HttpRequestSend_Prefix(object __instance)
{
RewriteRequestUri(__instance, "http-rewrite");
AuthPatches.ApplyBearerTokenHeader(__instance);
}
private static void RewriteRequestUri(object request, string label)
{
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)
{
@@ -3674,53 +784,26 @@ 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;
Mod.Log.Msg($"[{label}] {uri} -> {rewritten}");
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);
Mod.Log.Msg($"[{label}] {uri} -> {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);
Mod.Log.Msg($"[{label}] {uri} -> {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;
@@ -3735,11 +818,213 @@ 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;
}
private static string CombinePath(params string[] parts)
{
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 PhotonPatches
{
/// <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()
{
// 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");
}
/// <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;
/// <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 (OverrideApplied) return true;
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
try
{
// 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)
{
// 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;
}
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)
{
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
return false;
}
}
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
/// and writes <c>PreferredRegion</c>. Honors any region the server
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
/// instance has been received yet (initial boot).</summary>
private static void ApplyForcedRegion(object settings, Type settingsType)
{
try
{
var hostTypeProp = settingsType.GetField("HostType")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
var preferredProp = settingsType.GetField("PreferredRegion")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
if (hostTypeProp is null || preferredProp is null)
{
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
return;
}
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
// type from the field/property so we don't depend on a
// specific Il2Cpp proxy namespace.
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
?? ((PropertyInfo)hostTypeProp).PropertyType;
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
?? ((PropertyInfo)preferredProp).PropertyType;
// Prefer server-provided region over config. We read it via
// the same reflection style as the existing patches so the
// Il2Cpp type-name suffix doesn't bite us.
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
object regionValue;
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
catch
{
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
region = "us";
}
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
}
/// <summary>Reads
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
/// lowercase enum name (eu, us, asia, …). Returns null if no room
/// instance has been received yet, or if any link in the chain is
/// null. Server-told region wins over the static config default
/// because the server is authoritative about which Photon region
/// hosts the room the watch is about to join.</summary>
private static string? ReadServerProvidedRegion()
{
try
{
var matchmaking = FindGameType("RecNet.Matchmaking");
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localRoom is null) return null;
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
if (regionObj is null) return null;
// CloudRegionCode enum.ToString() gives the lowercase region
// name (eu, us, asia, …) — see CloudRegionCode.cs.
return regionObj.ToString();
}
catch { return null; }
}
// AuthValuesInjector_Prefix + 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
@@ -3990,50 +1275,6 @@ internal static class SavePatches
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class AntiCheatPatches
{
public static bool GenerateChallengeResponse_Prefix(string FIMBPCACMKJ, ref string __result)
{
__result = "AAAAAAAAAAAAAAAAAAAAAAAA";
Mod.Log.Msg("[eac] using local challenge response for copied test client");
return false;
}
}
internal static class AntiTamperPatches
{
public static bool CreateHileWarningCallback_Prefix(bool shouldQuit)
{
Mod.Log.Msg($"[hile] suppressed warning callback shouldQuit={shouldQuit}");
return false;
}
public static bool UnknownDllDetectedCallback_Prefix()
{
Mod.Log.Msg("[cheatmanager] suppressed unknown DLL fatal callback");
return false;
}
}
internal static class FileHashCheckerPatches
{
public static bool InitializeCallback_Prefix(object? result)
{
Mod.Log.Msg($"[filecheck] suppressed verifier callback result={result?.GetType().FullName ?? "null"}");
return false;
}
}
internal static class ToxModPatches
{
public static bool CanInitializeToxMod_Prefix(ref bool __result)
{
__result = false;
return false;
}
}
internal static class TlsPatches
{
// Both BouncyCastle TLS authenticators have the same `void
@@ -4047,83 +1288,6 @@ internal static class TlsPatches
}
}
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 { Mod.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)
@@ -4138,18 +1302,22 @@ internal static class DiagnosticPatches
catch { }
}
public static bool ApplicationQuit_Prefix()
public static void ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
return true;
}
public static bool ApplicationQuitInt_Prefix(int exitCode)
public static void ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
return true;
}
public static void BootSequenceRegisterError_Prefix(string error)
{
Write($"[boot-error] BootSequence.RegisterError: {error}");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
@@ -4166,23 +1334,9 @@ internal static class DiagnosticPatches
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-trace] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
Write($"[quit-blocked] {__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;
return false;
}
private static void WriteStack()
@@ -4240,218 +1394,3 @@ internal static class ChatPatches
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
// See the registration site in OnLateInitializeMelon and
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
// hook only writes to the diagnostics log.
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
// Trace hooks for the watch's room-join flow. Registered by
// Mod.RegisterJoinTracePatches — see that method for the verified call
// chain. Everything here is log-only; no prefix returns false, so the
// game's own join logic runs unchanged.
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
-464
View File
@@ -1,464 +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):
// GameConfigurationTool.Awake()
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
// Unity ScriptableObject @0x58)
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
//
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
// READABLE, typed struct array — NOT from anything that needs the obfuscated
// protobuf. Earlier attempts failed because:
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
// cached the protobuf (timing, not the wrong field);
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
// getter, and the simple int getter wasn't emitted either.
//
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() 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;
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
private static bool _loggedBump;
private static bool _activeAllowedQuestConfig;
private static int _teamIndexNormalizeLogCount;
private static int _spawnModeDataNormalizeLogCount;
private static int _requiredSpawnIndexNormalizeLogCount;
private static int _spawnRelaxLogCount;
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
{
var ok = true;
ok &= TryPatchTeamIndexNormalizer(harmony);
ok &= TryPatchSpawnModeDataNormalizer(harmony);
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
return ok;
}
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var managerType = Mod.ResolveType("GameTeamManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (managerType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
return false;
}
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
return false;
}
}
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
return false;
}
}
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ 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));
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
return false;
}
}
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
// scenes only have spawn points for indexes 0-3. After raising the quest
// team cap, players 5+ legitimately receive INDEX_4+, but those values
// cannot match any baked quest spawn point. Normalize just the enum result
// that spawn code asks for; the actual team roster/cap remains raised.
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__result);
if (!TryNormalizeIndex(value, out var normalized)) return;
__result = (T)Enum.ToObject(typeof(T), normalized);
if (_teamIndexNormalizeLogCount < 12)
{
_teamIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
}
}
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__3);
if (!TryNormalizeIndex(value, out var normalized)) return;
__3 = (T)Enum.ToObject(typeof(T), normalized);
if (_spawnModeDataNormalizeLogCount < 12)
{
_spawnModeDataNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
}
}
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
// This is the last guard before the spawn point field compare at +0x88.
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);
if (_requiredSpawnIndexNormalizeLogCount < 12)
{
_requiredSpawnIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
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.GDECPANDBHJ() (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 ?? "";
var allowed = IsAllowed(name);
_activeAllowedQuestConfig = allowed;
if (_seen.Add(name))
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
if (!allowed) 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++;
}
if (bumped > 0 && !_loggedBump)
{
_loggedBump = true;
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
}
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
// filters spawn points by state/game-mode/spectator/team/team-player-index/
// custom-tag before the spawn selector runs. The RRO quest prefabs only
// have indexed start points for players 0-3; once we raise the team size,
// player index 4+ can filter every point out before the built-in overlap
// fallback has a chance to help.
//
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
// 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;
var copied = CopyListItems(__0, __7);
if (_spawnRelaxLogCount < 12)
{
_spawnRelaxLogCount++;
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
$"source={sourceCount} copied={copied} " +
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
}
}
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>";
}
@@ -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,12 +1,8 @@
{
"ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
"CrimsonCauldron",
"Crescendo",
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
]
"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>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
-298
View File
@@ -1,298 +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",
"/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/storefronts",
"/commerce",
"/econ",
]),
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";
}
-31
View File
@@ -1,31 +0,0 @@
// DorkNet.DebugMod — opt-in debugging switch for DorkNet.ClientMod.
//
// This mod intentionally does almost nothing on its own. Its job is to
// EXIST: when its DLL sits in the Mods folder next to DorkNet.ClientMod.dll,
// the ClientMod detects it at startup (Cfg.EnableDiagnostics) and installs
// its verbose diagnostic patches — HTTP header/callback tracing, join/quit
// /studio traces, and the deep RegisterDiagnostics probes. Remove this DLL
// to ship a quiet, minimal client that only carries the connect + anti-cheat
// patches.
//
// Keeping the switch as a separate assembly (rather than a config flag baked
// into the shipping mod) means the diagnostics literally cannot turn
// themselves on in a normal install — you have to deliberately drop this
// file in. That is the "as safe as possible" default the main mod wants.
using MelonLoader;
[assembly: MelonInfo(typeof(DorkNet.DebugMod.DebugMod), "DorkNet DebugMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DebugMod;
public class DebugMod : MelonMod
{
public override void OnInitializeMelon()
{
LoggerInstance.Msg(
"=== DorkNet DebugMod present — DorkNet.ClientMod diagnostics are ENABLED. " +
"Remove DorkNet.DebugMod.dll from Mods to ship a quiet client. ===");
}
}
-40
View File
@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
DorkNet.DebugMod — the opt-in debugging companion to DorkNet.ClientMod.
The shipping ClientMod installs ONLY the patches needed to connect and
survive anti-cheat; all of its verbose diagnostic patches (HTTP header
tracing, join/quit/studio traces, the deep RegisterDiagnostics path) are
gated OFF by default. Dropping THIS dll into the Mods folder next to
DorkNet.ClientMod.dll flips Cfg.EnableDiagnostics on (the ClientMod
detects this dll's presence at startup), turning all of that tracing
back on. Remove it to ship a quiet, minimal client.
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>
</ItemGroup>
</Project>
-290
View File
@@ -1,290 +0,0 @@
using HarmonyLib;
using Il2CppRecRoom.Debugging;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using UnityEngine;
using UObject = UnityEngine.Object;
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DevMenu;
public class DevMenuMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DevMenuMod? Instance;
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
public static class Cfg
{
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static bool DiagnoseDevMenu = false;
public static bool EnableDevWatchButton = false;
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DevMenu loading ===");
LoadConfig();
if (Cfg.EnableDebugConsole)
{
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
}
public override void OnUpdate()
{
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
}
private static void LoadConfig()
{
try
{
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
Directory.CreateDirectory(userData);
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
var path = File.Exists(devPath) ? devPath : legacyPath;
if (!File.Exists(path))
{
Log.Msg($"[config] no dev config found; all dev menu features disabled");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
}
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
}
}
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
{
try
{
var type = ResolveType(typeName);
if (type is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
return false;
}
var method = args is null
? AccessTools.Method(type, methodName)
: AccessTools.Method(type, methodName, args);
if (method is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
return false;
}
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
Instance!.PatchHarmony.Patch(method, pre, post);
Log.Msg($"[patch-ok] {typeName}.{methodName}");
return true;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found");
}
internal static Type? ResolveType(string requested)
{
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
Type? found = null;
try
{
found = asm.GetType(requested, throwOnError: false)
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
if (found is null)
{
foreach (var t in asm.GetTypes())
{
if (t.FullName == requested ||
t.Name == requested ||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
{
found = t;
break;
}
}
}
}
catch { }
if (found is null) continue;
ResolvedTypeCache[requested] = found;
return found;
}
return null;
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _warned;
private static KeyCode _toggleKey = KeyCode.BackQuote;
private static bool _keyParsed;
private static MethodInfo? _toggleMethod;
public static bool SuppressCheatDetected_Prefix() => false;
public static void PollToggleKey()
{
try
{
if (!_keyParsed)
{
_keyParsed = true;
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
{
_toggleKey = KeyCode.BackQuote;
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
}
}
if (Input.GetKeyDown(_toggleKey))
{
Toggle();
}
}
catch (Exception ex)
{
if (!_warned)
{
_warned = true;
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
}
}
}
private static void Toggle()
{
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
if (console == null)
{
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
return;
}
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(typeof(DebugConsole));
if (_toggleMethod is null)
{
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
return;
}
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
}
}
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null;
found = m;
}
return found;
}
}
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 180) return;
_done = true;
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
}
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
}
}
-124
View File
@@ -1,124 +0,0 @@
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
// we use real types here: clone an existing watch Button3D, relabel it, and
// wire its OnClick to run console commands via DebugConsole.Execute (which
// works headless even though the console UI isn't in the scene). It's the
// game's own Button3D, so it's interactable with the VR laser AND the
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
using System;
using Il2CppInterop.Runtime;
using Il2CppAGUI.StackedUI;
using Il2CppRecRoom.Debugging;
using Il2CppTMPro;
using UnityEngine.Events;
using UObject = UnityEngine.Object;
namespace DorkNet.DevMenu;
internal static class DevWatchButton
{
private static int _frame;
private static bool _done;
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
// the watch's HomeScreenFlow to exist, then injects once.
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
var home = UObject.FindObjectOfType<HomeScreenFlow>();
if (home == null && _frame < 3600) return; // wait for the watch
_done = true;
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
try { Inject(home); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
}
private static void Inject(HomeScreenFlow home)
{
var buttons = home.GetComponentsInChildren<Button3D>(true);
if (buttons == null || buttons.Length == 0)
{
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
return;
}
// Prefer a source button that HAS a TMP label, so our clones can be
// relabeled (HomeButton etc. are icon-only — no text child).
Button3D? src = null;
for (int i = 0; i < buttons.Length; i++)
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
bool labeled = src != null;
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
int n = 0;
foreach (var (label, cmd) in ParsePresets())
{
var clone = UObject.Instantiate(src, src.transform.parent);
clone.name = $"DorkNetDev_{label}";
clone.gameObject.SetActive(true);
clone.transform.localScale = src.transform.localScale;
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null) tmp.text = label;
var command = cmd; // capture per-button
clone.OnClick.RemoveAllListeners();
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
_clones.Add(clone);
n++;
}
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
DumpCommands();
}
// Log the real console command names from DebugConsoleCommandConfig.Metas
// so we can set DevCommands correctly (the asset loads even though the
// console component isn't in the scene).
private static void DumpCommands()
{
try
{
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
for (int c = 0; c < cfgs.Length; c++)
{
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
var metas = cfg?.Metas;
if (metas == null) continue;
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
for (int i = 0; i < metas.Count; i++)
{
var m = metas[i];
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
}
}
}
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
}
private static void Run(string label, string command)
{
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
try { DebugConsole.Execute(command); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
}
// Presets come from Cfg.DevCommands as "Label=command" strings.
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
{
foreach (var entry in DevMenuMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var eq = entry.IndexOf('=');
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
else yield return (entry.Trim(), entry.Trim());
}
}
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
}
-68
View File
@@ -1,68 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DevMenu</RootNamespace>
<AssemblyName>DorkNet.DevMenu</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<PropertyGroup>
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
</PropertyGroup>
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -1,11 +0,0 @@
{
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DiagnoseDevMenu": false,
"EnableDevWatchButton": false,
"DevCommands": [
"Help=help",
"Fly=fly",
"NoClip=noclip"
]
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.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"
}
}
}
-7
View File
@@ -81,13 +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;
}
/// <summary>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.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>
-259
View File
@@ -1,259 +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;
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));
int patched = 0;
// BuildFrom is the lower-level builder every descriptor passes
// through; FromGeneratedCode is the generated-code entry point.
// Patch both (deduped downstream) so nothing is missed.
foreach (string name in new[] { "BuildFrom", "FromGeneratedCode" })
{
foreach (MethodInfo m in fd.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
try { HarmonyInstance.Patch(m, postfix: postfix); patched++; }
catch (Exception e) { _log?.Warning($"ProtoDumper: patch {name} failed: {e.Message}"); }
}
}
_log?.Msg($"ProtoDumper: installed {patched} descriptor-build hooks. Play through your content; descriptors are captured as they load.");
}
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)
{
if (__result == null) return;
try { CollectFile(__result); }
catch { /* never let our hook disturb the game */ }
}
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);
}
public override void OnUpdate()
{
// Flush to disk shortly after new descriptors are captured (every ~1s).
if (++_frame % 60 == 0 && _dirty)
Flush();
}
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()
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.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="10.0.9" />
<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" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
});
});
}
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,222 +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>()
.ToArray();
if (httpAttrs.Length == 0)
{
continue;
}
foreach (var attr in httpAttrs)
{
var attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
: 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,411 +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();
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));
}
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";
}
-67
View File
@@ -1,67 +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",
["client_id"] = "recroom",
["client_secret"] = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2",
["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,56 +0,0 @@
using System.Text.Json;
using DorkNet.Server.Compat;
using DorkNet.Server.Controllers.Auth;
using DorkNet.Server.Versions.Late2020;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void March_2023_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20230321", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
[Fact]
public void Unknown_build_still_requires_update()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
Assert.Equal(2, ReadVersionStatus(result.Value));
}
private static VersionCheckController CreateController()
{
var config = new ConfigurationBuilder().Build();
var registry = new VersionRegistry(
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"march_2023_03_21",
},
"march_2023_03_21");
return new VersionCheckController(
config,
registry,
NullLogger<VersionCheckController>.Instance);
}
private static int ReadVersionStatus(object? value)
{
var json = JsonSerializer.Serialize(value);
using var document = JsonDocument.Parse(json);
return document.RootElement.GetProperty("VersionStatus").GetInt32();
}
}
@@ -1,785 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.ResponseHandling;
using Open.IdentityServer.Services;
using Open.IdentityServer.Stores;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
services.AddTransient<ITokenResponseGenerator, RecRoomTokenResponseGenerator>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(
RequestDelegate next,
DomainConfig domain,
IConfiguration config,
ILogger<IdentityServerGameTokenRequestMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await InvokeAndPersistAccessTokenCookieAsync(context);
}
private async Task InvokeAndPersistAccessTokenCookieAsync(HttpContext context)
{
var originalBody = context.Response.Body;
await using var bufferedBody = new MemoryStream();
context.Response.Body = bufferedBody;
try
{
await next(context);
bufferedBody.Position = 0;
if (IsSuccessfulJsonResponse(context.Response))
TryPersistAccessTokenCookie(context, bufferedBody);
bufferedBody.Position = 0;
await bufferedBody.CopyToAsync(originalBody);
}
finally
{
context.Response.Body = originalBody;
}
}
private static bool IsSuccessfulJsonResponse(HttpResponse response) =>
response.StatusCode is >= 200 and < 300
&& response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true;
private void TryPersistAccessTokenCookie(HttpContext context, Stream responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
if (!document.RootElement.TryGetProperty("access_token", out var accessTokenElement))
return;
var accessToken = accessTokenElement.GetString();
if (string.IsNullOrWhiteSpace(accessToken))
return;
var expires = DateTimeOffset.UtcNow.AddHours(12);
if (document.RootElement.TryGetProperty("expires_in", out var expiresInElement) &&
expiresInElement.TryGetInt32(out var expiresIn) &&
expiresIn > 0)
{
expires = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
}
var options = new CookieOptions
{
HttpOnly = true,
Secure = string.Equals(domain.Scheme, "https", StringComparison.OrdinalIgnoreCase),
SameSite = SameSiteMode.None,
Expires = expires,
IsEssential = true,
};
var cookieDomain = ResolveCookieDomain(context.Request.Host.Host);
if (cookieDomain is not null)
options.Domain = cookieDomain;
context.Response.Cookies.Append(AuthService.AccessCookieName, accessToken, options);
logger.LogInformation(
"[auth-cookie] issued access cookie host={Host} domain={Domain} secure={Secure} sameSite={SameSite} essential={IsEssential}",
context.Request.Host.Host,
cookieDomain ?? "<host-only>",
options.Secure,
options.SameSite,
options.IsEssential);
}
catch (JsonException)
{
// Leave the token response untouched if the upstream payload is not JSON.
}
}
private string? ResolveCookieDomain(string host)
{
var configuredCookieDomain =
Environment.GetEnvironmentVariable("DORKNET_AUTH_COOKIE_DOMAIN")
?? config["Auth:CookieDomain"]
?? config["IdentityServer:CookieDomain"];
if (!string.IsNullOrWhiteSpace(configuredCookieDomain))
return configuredCookieDomain.Trim().TrimStart('.') is { Length: > 0 } value ? "." + value : null;
var apex = domain.Apex.Trim().TrimStart('.');
if (string.IsNullOrWhiteSpace(apex) ||
apex.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) ||
apex.Equals("::1", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (host.Equals(apex, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith("." + apex, StringComparison.OrdinalIgnoreCase))
{
return "." + apex;
}
if (domain.UsesHyphenSubdomains)
{
var parent = HyphenCookieParent(apex, host);
if (parent is not null)
return "." + parent;
}
return null;
}
private static string? HyphenCookieParent(string apex, string host)
{
var parts = apex.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 3)
return null;
var environmentLabel = parts[0];
var parent = string.Join('.', parts.Skip(1));
if (!host.EndsWith("." + parent, StringComparison.OrdinalIgnoreCase))
return null;
var hostLabel = host[..^(parent.Length + 1)];
return hostLabel.EndsWith("-" + environmentLabel, StringComparison.OrdinalIgnoreCase)
? parent
: null;
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomTokenResponseGenerator(
TimeProvider clock,
ITokenService tokenService,
IRefreshTokenService refreshTokenService,
IScopeParser scopeParser,
IResourceStore resources,
IClientStore clients,
ILogger<TokenResponseGenerator> logger) : ITokenResponseGenerator
{
private readonly TokenResponseGenerator _inner = new(
clock,
tokenService,
refreshTokenService,
scopeParser,
resources,
clients,
logger);
public async Task<TokenResponse> ProcessAsync(TokenRequestValidationResult request)
{
var response = await _inner.ProcessAsync(request);
AddLegacyGameFields(response);
return response;
}
private static void AddLegacyGameFields(TokenResponse response)
{
if (string.IsNullOrWhiteSpace(response.AccessToken))
return;
response.Custom ??= new Dictionary<string, object>(StringComparer.Ordinal);
AddIfMissing(response.Custom, "Access_token", response.AccessToken);
AddIfMissing(response.Custom, "accessToken", response.AccessToken);
AddIfMissing(response.Custom, "AccessToken", response.AccessToken);
AddIfMissing(response.Custom, "token", response.AccessToken);
AddIfMissing(response.Custom, "Token", response.AccessToken);
AddIfMissing(response.Custom, "tokenType", "Bearer");
AddIfMissing(response.Custom, "TokenType", "Bearer");
AddIfMissing(response.Custom, "expiresIn", response.AccessTokenLifetime);
AddIfMissing(response.Custom, "ExpiresIn", response.AccessTokenLifetime);
if (!string.IsNullOrWhiteSpace(response.RefreshToken))
{
AddIfMissing(response.Custom, "Refresh_token", response.RefreshToken);
AddIfMissing(response.Custom, "refreshToken", response.RefreshToken);
AddIfMissing(response.Custom, "RefreshToken", response.RefreshToken);
}
var signingKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
AddIfMissing(response.Custom, "key", signingKey);
AddIfMissing(response.Custom, "Key", signingKey);
}
private static void AddIfMissing(IDictionary<string, object> values, string key, object value)
{
if (!values.ContainsKey(key))
values[key] = value;
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId,
orphanId);
}
orphans.Clear(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
-28
View File
@@ -1,28 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Identifies a specific Rec Room client build.
/// <para>
/// <see cref="Key"/> is the wire-format identifier the client mod
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
/// it shows up in admin logs and error responses.
/// </para>
/// <para>
/// <see cref="Generation"/> is the wire-format-compatibility group:
/// every version inside the same generation shares the same wire
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
/// strategy implementations. Adding a new build that's protocol-compatible
/// with an existing generation = a one-line registration; only a new
/// generation entails new strategy code.
/// </para>
/// </summary>
public sealed record ClientVersion(string Key, string Generation)
{
/// <summary>Sentinel returned when a request has no
/// <c>X-DorkNet-Version</c> header AND no
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
/// can branch on this to either fall through to a sensible default
/// or 426 the request.</summary>
public static readonly ClientVersion Unknown = new("unknown", "unknown");
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace DorkNet.Server.Compat;
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
/// off the current request. The version is set by
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
/// downstream consumer (controllers, services, filters) can resolve
/// it without re-parsing the header.</summary>
public static class HttpContextExtensions
{
private const string ClientVersionKey = "DorkNet.ClientVersion";
/// <summary>Stamp the resolved version on the request. Called by
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
=> ctx.Items[ClientVersionKey] = v;
/// <summary>The client version for this request. Returns
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
/// run (e.g. a unit test that calls a controller method directly)
/// rather than null, so callers can safely branch on
/// <c>Generation</c> without null-checks.</summary>
public static ClientVersion ClientVersion(this HttpContext ctx)
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
&& v is ClientVersion cv
? cv
: Compat.ClientVersion.Unknown;
}
-39
View File
@@ -1,39 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>One client-version-family plugin. Each implementation
/// declares which exact version <see cref="VersionKeys"/> it handles
/// and which protocol-compatibility <see cref="Generation"/> they map
/// to. The plugin's job at registration time is to also wire any
/// generation-specific strategy services into DI via
/// <see cref="RegisterStrategies"/> so controllers / services
/// downstream can resolve the right <c>IWireFormat</c>,
/// <c>IRouteOverride</c>, etc. for the active request's generation.
///
/// <para>Adding a new version with the SAME wire shapes as an existing
/// generation = just listing its key in that generation's plugin
/// <see cref="VersionKeys"/>. Only a new generation needs new
/// strategy classes.</para>
/// </summary>
public interface IVersionPlugin
{
/// <summary>All client-build keys this plugin handles. Multiple
/// keys can share one plugin when several Rec Room builds shipped
/// the same wire format. Convention: lowercase, underscore-separated,
/// matching the literal the client mod sends in
/// <c>X-DorkNet-Version</c>.</summary>
IReadOnlyCollection<string> VersionKeys { get; }
/// <summary>Wire-format-compatibility group this plugin implements.
/// Multiple plugins can share a generation (rare, but allowed when
/// e.g. a Quest build and a PC build of the same era are
/// protocol-identical and we just want separate
/// <see cref="VersionKeys"/> bookkeeping).</summary>
string Generation { get; }
/// <summary>Register any generation-specific strategy services
/// into DI. Called once at startup by the bootstrap code in
/// <c>Program.cs</c>. Use keyed registrations (keyed by
/// <see cref="Generation"/>) so requests for other generations
/// don't collide.</summary>
void RegisterStrategies(IServiceCollection services);
}
@@ -1,144 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using DorkNet.Server.Services;
namespace DorkNet.Server.Compat;
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
/// on every request, validates it against this deployment's allow-list,
/// and either:
/// <list type="bullet">
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
/// describing the mismatch when the client's version isn't in
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
/// </list>
///
/// <para>Headerless requests fall back to
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
/// the transition pathway — once every shipped client mod stamps the
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
/// headerless requests get rejected too.</para>
///
/// <para>Some routes intentionally don't carry a client identity
/// (the public-facing site at the apex, the admin SPA at
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
/// + path prefix below — they always skip the version gate and
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
/// running on a different domain get the same exemption automatically.</para>
/// </summary>
public sealed class VersionDetectionMiddleware(
RequestDelegate next,
VersionRegistry registry,
DomainConfig domain,
ILogger<VersionDetectionMiddleware> logger)
{
private const string HeaderName = "X-DorkNet-Version";
public async Task InvokeAsync(HttpContext ctx)
{
if (IsExempt(ctx))
{
ctx.SetClientVersion(ClientVersion.Unknown);
await next(ctx);
return;
}
var headerVal = ctx.Request.Headers[HeaderName].ToString();
var versionKey = string.IsNullOrWhiteSpace(headerVal)
? registry.DefaultVersionKey
: headerVal.Trim();
// No header AND no default configured — reject. Once every
// client mod sends the header this is the path that catches
// probes / misconfigured clients.
if (string.IsNullOrWhiteSpace(versionKey))
{
await WriteMismatch(ctx, "client_version_missing", got: null);
return;
}
var resolved = registry.Resolve(versionKey);
if (resolved == ClientVersion.Unknown)
{
// The client claims a version but no plugin knows it. That
// means either a typo in the header or a build of Rec Room
// we haven't onboarded yet. Either way, reject — server
// can't reliably serve a version it doesn't have a
// strategy bundle for.
await WriteMismatch(ctx, "client_version_unknown", got: versionKey);
return;
}
if (!registry.IsSupported(versionKey))
{
// Known version, but this specific deployment is
// configured to accept a different set. e.g. a March
// client hitting a December-only server.
await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey);
return;
}
ctx.SetClientVersion(resolved);
await next(ctx);
}
private bool IsExempt(HttpContext ctx)
{
// Hosts that don't serve the watch — these are humans on a
// browser or admin tooling, not the game client.
var host = ctx.Request.Host.Host.ToLowerInvariant();
var apex = domain.Apex.ToLowerInvariant();
if (host == apex || host == "www." + apex) return true;
if (host.StartsWith("admin.")) return true;
if (host.StartsWith("site.")) return true;
if (host.StartsWith("feed.")) return true;
// Server health / readiness probes.
var path = ctx.Request.Path.Value;
if (path is null) return false;
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
// The boot-time version gate itself — the whole POINT of this
// endpoint is to receive a mismatched-version client and reply
// with the watch's native "Update Required" payload. Gating it
// here would 426 the request before VersionCheckController
// could respond.
if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private async Task WriteMismatch(HttpContext ctx, string error, string? got)
{
logger.LogWarning(
"[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]",
ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)",
string.Join(",", registry.SupportedVersionKeys));
ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
ctx.Response.ContentType = "application/json; charset=utf-8";
var body = new
{
error,
got,
supported = registry.SupportedVersionKeys.ToArray(),
message = error switch
{
"client_version_missing" =>
"Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.",
"client_version_unknown" =>
$"Client claims version '{got}', which no version plugin in this server recognises.",
"client_version_not_accepted" =>
$"Client version '{got}' is known but this deployment is configured to accept only: " +
string.Join(", ", registry.SupportedVersionKeys) + ".",
_ => error,
},
};
await JsonSerializer.SerializeAsync(ctx.Response.Body, body);
}
}
-69
View File
@@ -1,69 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
/// to its <see cref="ClientVersion"/>. Built once at startup from all
/// registered <see cref="IVersionPlugin"/> instances.
///
/// <para>Singleton — the version-key → generation mapping is fixed at
/// boot time, and the registry is read on every request by the
/// version-detection middleware.</para>
/// </summary>
public sealed class VersionRegistry
{
private readonly Dictionary<string, ClientVersion> _byKey;
/// <summary>The set of version keys this deployment is willing to
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
/// Anything else gets a 426 from the middleware.</summary>
public IReadOnlySet<string> SupportedVersionKeys { get; }
/// <summary>Falls back to this when a request arrives without an
/// <c>X-DorkNet-Version</c> header — set via
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
/// "no fallback; reject headerless requests". Set during the
/// transition window where the old client mod doesn't yet stamp
/// the header.</summary>
public string? DefaultVersionKey { get; }
public VersionRegistry(
IEnumerable<IVersionPlugin> plugins,
IReadOnlySet<string> supportedVersionKeys,
string? defaultVersionKey)
{
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
foreach (var key in plugin.VersionKeys)
{
if (_byKey.ContainsKey(key))
throw new InvalidOperationException(
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
$"Each version key maps to exactly one plugin.");
_byKey[key] = new ClientVersion(key, plugin.Generation);
}
}
SupportedVersionKeys = supportedVersionKeys;
DefaultVersionKey = defaultVersionKey;
}
/// <summary>Resolve a version key (case-insensitive) to its
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
/// if no plugin claims it. The middleware decides whether unknown
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
public ClientVersion Resolve(string? key)
{
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
}
/// <summary>True when the given version key is in
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
/// 426 cross-version connection attempts (e.g. a March client
/// hitting a server configured for December only).</summary>
public bool IsSupported(string key)
=> SupportedVersionKeys.Contains(key);
/// <summary>Every known plugin-registered version key, for
/// diagnostic responses and admin tooling.</summary>
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
}
@@ -106,11 +106,9 @@ public class AccountApiController(
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
var token = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? auth["Bearer ".Length..].Trim()
: Request.Cookies[AuthService.AccessCookieName];
if (string.IsNullOrWhiteSpace(token))
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
@@ -130,8 +128,6 @@ public class AccountApiController(
var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name);
var updated = await playerService.GetByIdAsync(p.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
@@ -157,7 +153,6 @@ public class AccountApiController(
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName;
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
@@ -203,12 +198,12 @@ public class AccountApiController(
[HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage", Order = 10)]
[HttpPost("profileimage", Order = 10)]
[HttpPost("account/me/profileimage")]
[HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage", Order = 10)]
[HttpPut("profileimage", Order = 10)]
[HttpPut("account/me/profileimage")]
[HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
@@ -227,10 +222,44 @@ public class AccountApiController(
"[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id,
player.ProfileImageName ?? "<cleared>");
await notifications.AccountChanged(player);
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
[HttpGet("api/account/v1/namegen")]
[HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
@@ -1,31 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AgeVerification;
[ApiController]
[Route("api/ageverification")]
[Authorize]
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("generateCode")]
[HttpGet("generateCode")]
public async Task<IActionResult> GenerateCode()
{
var pid = this.RequireCurrentPlayerId();
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"ageverification:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
}
}
@@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.AppIntegrity;
[ApiController]
public class AppIntegrityController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/AppIntegrity/v1/iosproducts")]
[AllowAnonymous]
public async Task<IActionResult> IosProducts()
{
var rows = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.Id)
.Take(100)
.Select(i => new
{
ProductId = i.Slug,
i.Price,
i.CurrencyType,
Name = i.DisplayName,
})
.ToListAsync();
return Ok(rows);
}
[HttpPost("api/AppIntegrity/v1/iospaymentqueuefailed")]
[Authorize]
public async Task<IActionResult> IosPaymentQueueFailed([FromForm] string? reason)
{
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = this.RequireCurrentPlayerId(),
Title = "iOS payment queue failed",
Body = reason ?? Request.Query["reason"].FirstOrDefault() ?? string.Empty,
Category = "appintegrity",
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new { Success = true });
}
}
@@ -1,44 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Apple;
[ApiController]
public class AppleMusicPromotionController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/apple/musicpromotion/active")]
[AllowAnonymous]
public IActionResult Active()
{
var start = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var end = start.AddYears(1);
return Ok(new { Active = true, StartAt = start, EndAt = end });
}
[HttpPost("api/apple/musicpromotion/code")]
[HttpGet("api/apple/musicpromotion/code")]
[Authorize]
public async Task<IActionResult> Code()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "applemusic:promotion_code");
if (row is null)
{
row = new PlayerSettingEntity
{
PlayerId = me,
Key = "applemusic:promotion_code",
Value = $"RR-{Guid.NewGuid():N}"[..16],
};
db.PlayerSettings.Add(row);
await db.SaveChangesAsync();
}
return Ok(new { Code = row.Value, Redeemed = false });
}
}
@@ -198,32 +198,6 @@ public class AvatarGiftsController(
}
}
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
{
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
: gift.EquipmentPrefabName;
if (!string.IsNullOrWhiteSpace(itemSlug))
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = itemSlug,
Quantity = 1,
});
}
else
{
row.Quantity = Math.Max(row.Quantity, 1);
}
}
}
// Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed
@@ -278,17 +252,7 @@ public class AvatarGiftsController(
_ => 50, // Legendary
};
// Wire shape verified against the 2020.12 watch's
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
// surfaces as "Failed to download gifts: Malformed Response" from
// BootSequence and BLOCKS LOGIN.
public static object ToWire(GiftPackageEntity g) => new
private static object ToWire(GiftPackageEntity g) => new
{
g.Id,
g.FromPlayerId,
@@ -306,23 +270,6 @@ public class AvatarGiftsController(
g.GiftRarity,
g.Message,
g.Platform,
// PlatformsToSpawnOn — required by the watch's gift DTO
// deserializer. This is a SINGLE int (4-byte field at offset
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
// The watch reads it with strict Util.GetKey<int>, so sending
// an empty array crashes Convert.ChangeType([], typeof(int))
// with "Object must implement IConvertible". -1 mirrors the
// sibling Platform field's "every platform" sentinel — no
// platform-gated gift restrictions on a private server.
PlatformsToSpawnOn = -1,
// BalanceType — required mandatory key on the gift DTO. The
// enum identifies whether the gift's payload is a Currency
// balance update (0) vs Inventory item (1) vs XP (2). For
// currency-bearing gifts we'd send 0; the safe default for
// our seed gifts (which are item-only) is 0 too since the
// watch only reads it to dispatch the balance-update side
// and we never carry one. Wrong type doesn't crash.
BalanceType = 0,
g.PackageMaterial,
g.PackageVariant,
g.Consumed,
@@ -85,17 +85,8 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
// render-budget headroom — feeding it too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
// has no such limit, and the watch-era cap was silently truncating the
// owned wardrobe to 100 ("most of my items don't show"). Raise the
// ceilings well above the full catalog (882 base items + variants) so
// nothing the player owns is dropped; the config keys still let an
// operator dial it back down for a watch build.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -105,7 +96,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
.Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync();
var owned = ParseInventoryEntries(avatar?.InventoryJson);
var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug)
@@ -118,54 +109,28 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
// Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new
// GUIDs to the end of InventoryJson, so the original index is also
// the purchase order. When per-slot truncation has to drop entries,
// we want it to drop the OLDEST (starter seed) items rather than the
// one the user just bought.
var ordered = owned
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx))
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid))
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue)
.ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Entry);
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var entry in ordered)
foreach (var guid in ordered)
{
if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're
// silently dropped here. The catalog is keyed by the BARE GUID,
// so colored variants (stored as "{guid},{swatch},{mask},") must
// look up by BaseGuid — keying by the full comma string would
// miss and silently drop every owned colored item (the contest
// hat "I bought it but it never shows in my wardrobe" bug).
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
// silently dropped here.
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1;
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
// overrides AvatarItemDesc with the stored swatch/mask desc so
// the watch renders the variant the player paid for instead of
// falling back to the default swatch.
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
? item
: new UnlockedAvatarItemDto
{
AvatarItemType = item.AvatarItemType,
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
});
result.Add(item);
if (result.Count >= maxItems) break;
}
@@ -187,22 +152,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
return Ok(result);
}
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
[AllowAnonymous]
public IActionResult DefaultBaseAvatarItems()
{
var rows = _catalog.Value.Items.Values
.OrderBy(i => i.AvatarItemType)
.ThenBy(i => i.FriendlyName)
.Take(100)
.ToList();
return Ok(rows);
}
[HttpGet("api/avatar/v1/defaultunlocked")]
[AllowAnonymous]
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
@@ -217,49 +166,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
/// specific avatar item descriptions the caller does not own. The
/// store UI calls this with item descs from purchasable rows and
/// expects the same item-detail shape used by unlocked items,
/// minus ownership. We resolve against the safe runtime catalog so
/// unknown GUIDs never reach the client parser.</summary>
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task<IActionResult> LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
{
var invJson = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => a.InventoryJson)
.FirstOrDefaultAsync();
foreach (var guid in ParseInventory(invJson))
owned.Add(guid);
}
var catalog = _catalog.Value.Items;
var rows = requested
.Where(guid => !owned.Contains(guid))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null)
.Select(item => new
{
item!.AvatarItemType,
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
@@ -270,103 +176,47 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private IEnumerable<string> RequestedAvatarItemGuids()
private static List<string> ParseInventory(string? json)
{
foreach (var pair in Request.Query)
foreach (var raw in pair.Value)
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var value = part.Trim();
if (string.IsNullOrWhiteSpace(value)) continue;
var comma = value.IndexOf(',');
if (comma >= 0) value = value[..comma];
if (Guid.TryParse(value, out _)) yield return value;
}
}
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
/// as stored in InventoryJson. For a plain item the two are identical;
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
/// both the plain JSON string array of descs (the shape
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
/// prefix, derives the bare GUID (substring before the first comma) for
/// catalog lookups, and keeps the full desc so a colored variant keeps
/// its swatch. Dedupes by full desc, so multiple colors of the same
/// base item remain distinct wardrobe tiles while exact duplicates
/// collapse.</summary>
private static List<OwnedEntry> ParseInventoryEntries(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
var raw = new List<string>();
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) raw.AddRange(strings);
if (strings is not null) return strings;
}
catch { }
if (raw.Count == 0)
try
{
try
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var result = new List<string>();
foreach (var element in doc.RootElement.EnumerateArray())
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
foreach (var element in doc.RootElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
raw.Add(element.GetString() ?? string.Empty);
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
raw.Add(itemId.GetString() ?? string.Empty);
}
string? value = null;
if (element.ValueKind == JsonValueKind.String)
value = element.GetString();
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
value = itemId.GetString();
if (string.IsNullOrWhiteSpace(value)) continue;
const string wardrobePrefix = "wardrobe-";
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
if (comma >= 0)
value = value[..comma];
result.Add(value);
}
catch { return new(); }
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
const string wardrobePrefix = "wardrobe-";
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<OwnedEntry>();
foreach (var entry in raw)
{
var value = entry;
if (string.IsNullOrWhiteSpace(value)) continue;
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
var baseGuid = comma >= 0 ? value[..comma] : value;
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
if (!seen.Add(value)) continue;
result.Add(new OwnedEntry(baseGuid, value));
}
return result;
}
/// <summary>Bare-GUID view of the owned set, for callers that only need
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
private static List<string> ParseInventory(string? json) =>
ParseInventoryEntries(json)
.Select(e => e.BaseGuid)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
/// (guid + 3 customizations) that the watch's
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
/// inside the wardrobe parser. Stored colored descs are already
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
/// value.</summary>
private static string ToWatchDesc(string storedDesc)
{
var commas = storedDesc.Count(c => c == ',');
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
catch { return new(); }
}
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
@@ -432,13 +282,10 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid);
// Prefer the baked display_name from the JSON if present
// (tools/add-display-names.py writes it once per refresh).
// Falls back to the raw-name cleanup if the field is missing
// — e.g. partial dumps or older fixture files.
var pretty = !string.IsNullOrEmpty(row.DisplayName)
? row.DisplayName
: PrettyName(row.Name);
// Rec Room's bundled item names are wrapped in parens like
// "(Hat_Wizard)" — strip those and the underscores for a
// friendlier UI label.
var pretty = PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto
{
// AvatarItemType is the wire enum the watch parses
@@ -502,22 +349,11 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
// Algorithmically derived from `name` by
// tools/add-display-names.py — Rec Room's official UI strings
// live in Unity asset bundles we can't read from a code dump,
// so this is a best-effort cleanup. Optional — server falls
// back to PrettyName() if missing.
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
// hides_left_ear / hides_right_ear were added to AvatarItemData
// in the 2020.12.18 client build. Optional in the JSON so older
// dumps still deserialize.
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
@@ -67,19 +67,9 @@ public class CommunityBoardController(
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
// breaks the boot promise chain on the community-board fetch.
featuredRoomGroup = new
{
FeaturedRoomGroupId = 0L,
Name = group.Name ?? string.Empty,
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
// Keep the old key around for any 2020.03 clients hitting prod.
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
@@ -1,180 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
{
var pid = this.RequireCurrentPlayerId();
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadCampusCardRequestAsync();
var targetId = req.PlatformAccountSubscribedPlayerId > 0
? req.PlatformAccountSubscribedPlayerId
: pid;
if (targetId != pid && req.Subscription)
{
var exists = await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
if (!exists)
{
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = pid,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
}
}
else if (targetId != pid && !req.Subscription)
{
await db.Subscriptions
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
.ExecuteDeleteAsync();
}
var active = targetId == pid || await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
var now = DateTime.UtcNow;
var subscriptionId = unchecked((pid * 397L) ^ targetId);
var subscription = new Dictionary<string, object?>
{
["subscriptionId"] = subscriptionId,
["recNetPlayerId"] = pid,
["platformAccountSubscribedPlayerId"] = targetId,
["isActive"] = active,
["startedAt"] = now,
["currentPeriodEnd"] = active ? now.AddYears(10) : now,
["renewalDate"] = active ? now.AddYears(10) : now,
["SubscriptionId"] = subscriptionId,
["RecNetPlayerId"] = pid,
["PlatformAccountSubscribedPlayerId"] = targetId,
["IsActive"] = active,
["StartedAt"] = now,
["CurrentPeriodEnd"] = active ? now.AddYears(10) : now,
["RenewalDate"] = active ? now.AddYears(10) : now,
};
return Ok(new Dictionary<string, object?>
{
["subscription"] = subscription,
["platformAccountSubscribedPlayerId"] = targetId,
["Subscription"] = subscription,
["PlatformAccountSubscribedPlayerId"] = targetId,
});
}
[HttpGet("api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
[AllowAnonymous]
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json");
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ClubReport()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadClubReportAsync();
var club = req.ClubId > 0
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = club.CreatorPlayerId,
Category = req.ReportCategory,
Message = message,
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CampusCardRequest
{
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
PlatformAccountSubscribedPlayerId = long.TryParse(
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
}
catch (JsonException) { return new CampusCardRequest(); }
}
private async Task<ClubReportRequest> ReadClubReportAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new ClubReportRequest
{
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
}
catch (JsonException) { return new ClubReportRequest(); }
}
private sealed class CampusCardRequest
{
public bool Subscription { get; set; }
public long PlatformAccountSubscribedPlayerId { get; set; }
}
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
}
}
@@ -21,109 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// GET <c>api/config/v1/backtrace</c> — 2023 boot downloads this before
/// audio/core initialization. Returning 404 leaves the static backtrace
/// config null and later crashes <c>ConfigSettings</c> consumers. Keep
/// crash upload/deobfuscation disabled locally while preserving the
/// object shape and readable config keys the client inspects.
/// </summary>
[HttpGet("backtrace")]
public ActionResult<object> GetBacktrace(
[FromQuery(Name = "platformType")] string? platformType,
[FromQuery(Name = "allocate")] bool? allocate)
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var submissionUrl = $"{baseUrl}/backtrace/submit";
var minidumpSubmissionUrl = $"{baseUrl}/backtrace/minidump";
var config = new Dictionary<string, object>
{
["Backtrace.SampleRate"] = 0.0,
["Backtrace.ReportBudget"] = 0,
["Backtrace.deobfuscationRate"] = 0.0,
["Backtrace.Deobfuscate"] = false,
["Backtrace.DeobfuscationUrl"] = string.Empty,
["Backtrace.Enabled"] = false,
["Backtrace.PlatformType"] = platformType ?? string.Empty,
["Backtrace.Allocate"] = allocate ?? false,
};
return Ok(new Dictionary<string, object>
{
["MaxMessagesPerMinute"] = 0,
["MaxReportsPerMinute"] = 0,
["SampleRate"] = 0.0,
["ReportBudget"] = 0,
["ErrorReportBudget"] = 0,
["WarningReportBudget"] = 0,
["LogReportBudget"] = 0,
["SubmissionUrl"] = submissionUrl,
["MinidumpSubmissionUrl"] = minidumpSubmissionUrl,
["ServerUrl"] = submissionUrl,
["DatabasePath"] = "backtrace/database",
["Config"] = config,
["maxMessagesPerMinute"] = 0,
["maxReportsPerMinute"] = 0,
["sampleRate"] = 0.0,
["reportBudget"] = 0,
["errorReportBudget"] = 0,
["warningReportBudget"] = 0,
["logReportBudget"] = 0,
["submissionUrl"] = submissionUrl,
["minidumpSubmissionUrl"] = minidumpSubmissionUrl,
["serverUrl"] = submissionUrl,
["databasePath"] = "backtrace/database",
["config"] = config,
});
}
[HttpGet("azurespeech")]
public IActionResult GetAzureSpeech()
{
return Ok(new Dictionary<string, object>
{
["Enabled"] = false,
["SpeechRecognitionEnabled"] = false,
["SpeechSynthesisEnabled"] = false,
["Region"] = string.Empty,
["SubscriptionKey"] = string.Empty,
["TokenEndpoint"] = string.Empty,
["SpeechRecognitionEndpoint"] = string.Empty,
["SpeechSynthesisEndpoint"] = string.Empty,
["enabled"] = false,
["speechRecognitionEnabled"] = false,
["speechSynthesisEnabled"] = false,
["region"] = string.Empty,
["subscriptionKey"] = string.Empty,
["tokenEndpoint"] = string.Empty,
["speechRecognitionEndpoint"] = string.Empty,
["speechSynthesisEndpoint"] = string.Empty,
});
}
[HttpGet("/voice/config")]
public IActionResult GetVoiceConfig()
{
return Ok(new Dictionary<string, object>
{
["ToxMod.Enabled"] = false,
["ToxMod.SampleRate"] = 0.0,
["ToxMod.SessionRecordingEnabled"] = false,
["ToxMod.Endpoint"] = string.Empty,
["ToxMod.ApiKey"] = string.Empty,
["Enabled"] = false,
["enabled"] = false,
["toxModEnabled"] = false,
});
}
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
/// button is hidden.</summary>
[HttpGet("freegiftbutton")]
public IActionResult FreeGiftButton() => Content("false", "application/json");
/// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE
@@ -5,7 +5,6 @@ using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/config")]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
@@ -1,410 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
[ApiController]
[Route("api/customAvatarItems")]
[Route("api/customAvatarItems/v1")]
[Route("api/customAvatarItems/v2")]
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
DomainConfig domain) : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> List(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
.Where(i => i.IsPublic);
if (needle.Length > 0)
{
rows = rows.Where(i =>
i.Name.ToLower().Contains(needle) ||
i.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[HttpGet("{id:guid}")]
[AllowAnonymous]
public async Task<IActionResult> Get(Guid id)
{
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
return Ok(ToWire(item));
}
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
{
var ids = await ReadIdsAsync(body);
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.CustomAvatarItems
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("featured")]
[AllowAnonymous]
public async Task<IActionResult> Featured([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic && i.IsFeatured)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("hot")]
[AllowAnonymous]
public async Task<IActionResult> Hot([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CheerCount)
.ThenByDescending(i => i.PurchaseCount)
.ThenByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("new")]
[AllowAnonymous]
public async Task<IActionResult> New([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.CustomAvatarItems
.Where(i => i.IsPublic)
.OrderByDescending(i => i.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("fromCreator/{creatorId:long}")]
[AllowAnonymous]
public async Task<IActionResult> FromCreator(
long creatorId,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var rows = await db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var createdQuery = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt);
var total = await createdQuery.CountAsync();
var created = await createdQuery
.Skip(skip)
.Take(take)
.ToListAsync();
var ownedIds = await db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Select(o => o.CustomAvatarItemId)
.ToListAsync();
var owned = ownedIds.Count == 0
? new List<CustomAvatarItemEntity>()
: await db.CustomAvatarItems
.Where(i => ownedIds.Contains(i.Id))
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
return Ok(new
{
Results = created.Select(ToWire),
TotalResults = total,
Created = created.Select(ToWire),
Owned = owned.Select(ToWire),
});
}
[HttpGet("owned")]
[Authorize]
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
{
var pid = this.RequireCurrentPlayerId();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var query = db.CustomAvatarItemOwnership
.Where(o => o.PlayerId == pid)
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
.OrderByDescending(i => i.UpdatedAt);
var total = await query.CountAsync();
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(new
{
Results = rows.Select(ToWire),
TotalResults = total,
});
}
[HttpGet("isCreationAllowedForAccount")]
[Authorize]
public async Task<IActionResult> IsCreationAllowedForAccount()
{
var pid = this.RequireCurrentPlayerId();
var player = await db.Players
.Where(p => p.Id == pid)
.Select(p => new { p.IsJunior, p.BannedUntil })
.FirstOrDefaultAsync();
var allowed = player is not null
&& !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
}
[HttpGet("isCreationEnabled")]
[AllowAnonymous]
public IActionResult IsCreationEnabled() => Content("true", "application/json");
[HttpGet("isRenderingEnabled")]
[AllowAnonymous]
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
[HttpGet("minPriceForPublicItem")]
[AllowAnonymous]
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
[HttpPost("design")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Design()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadDesignRequestAsync();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
if (name.Length > 128) name = name[..128];
var description = (req.Description ?? string.Empty).Trim();
if (description.Length > 1024) description = description[..1024];
CustomAvatarItemEntity item;
if (req.Id is Guid id)
{
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
if (item.Id == 0) db.CustomAvatarItems.Add(item);
}
else
{
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
db.CustomAvatarItems.Add(item);
}
item.Name = name;
item.Description = description;
item.Price = Math.Max(100, req.Price ?? item.Price);
item.ItemType = req.ItemType ?? item.ItemType;
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
item.Color = Trim(req.Color, 64);
item.ImageName = Trim(req.ImageName, 256);
item.AssetName = Trim(req.AssetName, 256);
item.IsPublic = req.IsPublic ?? item.IsPublic;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await EnsureOwnershipAsync(pid, item.Id);
return Ok(ToWire(item));
}
[HttpPost("{id:guid}/purchase")]
[Authorize]
public async Task<IActionResult> Purchase(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
if (item is null) return NotFound();
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < item.Price)
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
await EnsureOwnershipAsync(pid, item.Id);
item.PurchaseCount += 1;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
}
private async Task EnsureOwnershipAsync(long playerId, long itemId)
{
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
{
return;
}
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
{
PlayerId = playerId,
CustomAvatarItemId = itemId,
});
await db.SaveChangesAsync();
}
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
{
var result = new List<Guid>();
if (body?.Ids is { Count: > 0 })
result.AddRange(body.Ids);
if (body?.ItemIds is { Count: > 0 })
result.AddRange(body.ItemIds);
foreach (var pair in Request.Query)
foreach (var value in pair.Value)
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
foreach (var value in form[key])
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (Guid.TryParse(part, out var id)) result.Add(id);
}
return result.Distinct().Take(200).ToList();
}
private async Task<DesignRequest> ReadDesignRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new DesignRequest
{
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<DesignRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new DesignRequest();
}
catch (JsonException)
{
return new DesignRequest();
}
}
private object ToWire(CustomAvatarItemEntity item) => new
{
Id = item.PublicId,
CustomAvatarItemId = item.PublicId,
CreatorId = (int)item.CreatorPlayerId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.Price,
item.ItemType,
item.BaseAvatarItemId,
item.Color,
item.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
item.AssetName,
item.IsPublic,
item.IsFeatured,
item.CheerCount,
item.PurchaseCount,
item.CreatedAt,
item.UpdatedAt,
Status = item.IsPublic ? 1 : 0,
};
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class BulkRequest
{
public List<Guid>? Ids { get; set; }
public List<Guid>? ItemIds { get; set; }
}
public sealed class DesignRequest
{
public Guid? Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int? Price { get; set; }
public int? ItemType { get; set; }
public int? BaseAvatarItemId { get; set; }
public string? Color { get; set; }
public string? ImageName { get; set; }
public string? AssetName { get; set; }
public bool? IsPublic { get; set; }
}
}
@@ -1,34 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController(DorkNetDbContext db) : ControllerBase
public class EventsController : ControllerBase
{
[HttpGet("list")]
public async Task<ActionResult<List<object>>> GetActiveEvents([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.Select(e => new
{
EventId = e.Id,
PlayerEventId = e.Id,
Name = e.Title,
e.Description,
StartTime = e.StartsAt,
EndTime = e.EndsAt,
CreatorPlayerId = (int)e.CreatorPlayerId,
e.RoomId,
e.Capacity,
})
.ToListAsync();
return Ok(rows.Cast<object>().ToList());
}
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
}
@@ -1,79 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ExternalFriendInvite;
[ApiController]
[Authorize]
public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpPost("api/externalfriendinvite/v1/createplatforminvite")]
public async Task<IActionResult> CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null)
{
var code = Guid.NewGuid().ToString("N")[..10];
var key = $"externalinvite:platform:{code}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = key,
Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
var url = $"{Request.Scheme}://{Request.Host}/invite/{code}";
return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty });
}
[HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")]
public async Task<IActionResult> SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message)
{
var code = Guid.NewGuid().ToString("N")[..10];
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = $"externalinvite:text:{code}",
Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}",
});
await db.SaveChangesAsync();
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
}
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
private static object ToInviteWire(PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new
{
InviteCode = row.Key.Split(':').Last(),
Kind = row.Key.Contains(":text:") ? "text" : "platform",
Value = parts.FirstOrDefault() ?? string.Empty,
CreatedAt = parts.LastOrDefault() ?? string.Empty,
};
}
}
@@ -1,124 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.FreeGifts;
[ApiController]
[Authorize]
public class FreeGiftsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
[HttpPost("api/freegifts/v1/sendmultiple")]
public async Task<IActionResult> SendMultiple()
{
var fields = await ReadFieldsAsync();
var sender = this.RequireCurrentPlayerId();
var recipients = ReadLongList(fields, "recipientPlayerIds", "RecipientPlayerIds", "playerIds", "PlayerIds");
if (recipients.Count == 0) return BadRequest("missing_recipients");
var currencyType = ReadInt(fields, "currencyType", "CurrencyType") ?? 2;
var currency = Math.Max(0, ReadInt(fields, "currency", "Currency") ?? 0);
var xp = Math.Max(0, ReadInt(fields, "xp", "Xp") ?? 0);
var message = ReadString(fields, "message", "Message") ?? string.Empty;
var created = new List<(long RecipientPlayerId, GiftPackageEntity Gift)>();
foreach (var recipient in recipients.Distinct().Take(100))
{
if (!await db.Players.AnyAsync(p => p.Id == recipient)) continue;
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = (int)sender,
CurrencyType = currency > 0 ? currencyType : 0,
Currency = currency,
Xp = xp,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
Message = message,
Platform = -1,
PackageVariant = "FreeGift",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
created.Add((recipient, gift));
}
await db.SaveChangesAsync();
foreach (var row in created)
{
await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
}
return Ok(new { Success = true, Sent = created.Count });
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<long> ReadLongList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.Take(100)
.ToList();
}
@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase
public class GameConfigurationsController : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
@@ -12,6 +11,5 @@ public class GameConfigurationsController(ServerSettingsService serverSettings)
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll()
=> Ok(await serverSettings.GetGameConfigurationsAsync());
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
}
@@ -1,151 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameRewards;
[ApiController]
[Authorize]
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/gamerewards/v1/pending")]
public async Task<IActionResult> Pending()
{
var pid = Me;
var rows = await db.GameRewardSelections
.Where(r => r.PlayerId == pid && r.SelectedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/gamerewards/v1/request")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> RequestReward()
{
var req = await ReadRewardRequestAsync();
var row = new GameRewardSelectionEntity
{
PlayerId = Me,
RewardType = req.RewardType,
GiftContext = req.GiftContext,
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
};
db.GameRewardSelections.Add(row);
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
[HttpPost("api/gamerewards/v1/select")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SelectReward()
{
var req = await ReadRewardSelectAsync();
var pid = Me;
var row = await db.GameRewardSelections
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
row.SelectedAt = DateTime.UtcNow;
row.SelectedGiftDropId = req.GiftDropId;
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
private static object ToWire(GameRewardSelectionEntity row) => new
{
RewardSelectionId = row.Id,
row.Message,
row.GiftContext,
row.RewardType,
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
row.CreatedAt,
};
private static object GiftDrop(long id, string name, int currency) => new
{
GiftDropId = id,
FriendlyName = name,
Tooltip = string.Empty,
ConsumableItemDesc = string.Empty,
AvatarItemDesc = string.Empty,
AvatarItemType = 0,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = 0,
CurrencyType = 2,
Currency = currency,
Context = 0,
ItemSetId = 0,
ItemSetFriendlyName = string.Empty,
};
private async Task<RewardRequest> ReadRewardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardRequest
{
RewardType = int.TryParse(form["rewardType"].FirstOrDefault(), out var rt) ? rt : 0,
GiftContext = int.TryParse(form["giftContext"].FirstOrDefault(), out var gc) ? gc : 0,
Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardRequest();
}
catch (JsonException) { return new RewardRequest(); }
}
private async Task<RewardSelect> ReadRewardSelectAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardSelect
{
RewardSelectionId = long.TryParse(form["rewardSelectionId"].FirstOrDefault(), out var id) ? id : 0,
GiftDropId = int.TryParse(form["giftDropId"].FirstOrDefault(), out var giftDropId) ? giftDropId : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardSelect>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardSelect();
}
catch (JsonException) { return new RewardSelect(); }
}
private sealed class RewardRequest
{
public int RewardType { get; set; }
public int GiftContext { get; set; }
public string? Message { get; set; }
}
private sealed class RewardSelect
{
public long RewardSelectionId { get; set; }
public int GiftDropId { get; set; }
}
}
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameSight;
[ApiController]
[AllowAnonymous]
public class GameSightController : ControllerBase
{
[HttpPost("/api/gamesight/event")]
[HttpPost("/data/event")]
[HttpPost("/data/heartbeat")]
public IActionResult Event() => Ok(new { Success = true });
}
@@ -29,10 +29,10 @@ namespace DorkNet.Server.Controllers.API.Images;
/// Returning [] (the previous catch-all) made the watch silently
/// drop the photo.
///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows. The 2020
/// client sends camera share state inside an imgMeta JSON form field
/// (accessibility=1 for public ShareCamera uploads), while newer callers
/// can still send top-level IsPublic.
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// IsPublic=false by default. A separate Share button (POST
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// feed.rec.net.
/// </summary>
[ApiController]
public class ImagesController(
@@ -82,17 +82,12 @@ public class ImagesController(
// Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var imageMeta = ParseImageMeta(form);
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(taggedIds) && imageMeta?.PlayerIds.Count > 0)
taggedIds = string.Join(",", imageMeta.PlayerIds);
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0 && imageMeta?.RoomId is > 0) roomId = imageMeta.RoomId.Value;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var explicitPublic = form is null ? null : TryReadBool(form["IsPublic"].ToString());
var isPublic = explicitPublic ?? (imageMeta?.Accessibility == 1 || imageMeta?.SavedImageType == 1);
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
@@ -274,48 +269,6 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
public sealed class ImageBulkRequest
{
public List<long>? ImageIds { get; set; }
public List<long>? SavedImageIds { get; set; }
}
[HttpGet("api/images/v5/bulk")]
[HttpPost("api/images/v5/bulk")]
[Authorize]
public async Task<IActionResult> BulkImages([FromBody] ImageBulkRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.ImageIds ?? body?.SavedImageIds ?? new List<long>())
.Concat(Request.Query.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0))
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Photos
.Where(p => ids.Contains(p.Id) && p.DeletedAt == null
&& (p.IsPublic || p.UploaderPlayerId == pid))
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
[HttpGet("api/images/v6")]
[AllowAnonymous]
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
@@ -327,23 +280,16 @@ public class ImagesController(
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// A short <c>ValidTill</c> lets the Rec Center board pick up newly
/// shared camera photos without needing a server restart.</summary>
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
[HttpGet("api/images/v1/slideshow")]
public async Task<IActionResult> SlideshowInfo()
public IActionResult SlideshowInfo() => Ok(new
{
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(50)
.ToListAsync();
return Ok(new
{
ValidTill = DateTime.UtcNow.AddMinutes(5),
Images = rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)),
});
}
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
// overflows the watch's local-timezone date parse in some DST
// timezones → "malformed RecNet response". See WireDates.
ValidTill = WireDates.FarFuture,
Images = Array.Empty<object>(),
});
/// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
@@ -396,63 +342,19 @@ public class ImagesController(
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
[HttpGet("api/images/v2/player/{accountId:long}")]
[HttpGet("api/images/v3/player/{accountId:long}")]
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
[FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
[HttpGet("api/images/v5/room/{roomId:long}")]
public async Task<IActionResult> GetByRoom(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
[FromQuery] int sort = 0,
[FromQuery] int filter = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
query = sort switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt),
};
var rows = await query
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
@@ -503,37 +405,6 @@ public class ImagesController(
public long PhotoId { get; set; }
}
public sealed class BulkCheeredRequest
{
public List<long>? SavedImageIds { get; set; }
public List<long>? ImageIds { get; set; }
}
[HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
.Select(c => c.TargetPhotoId)
.ToListAsync();
var set = cheered.ToHashSet();
return Ok(ids.Select(id => new
{
SavedImageId = id,
IsCheered = set.Contains(id),
}));
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
@@ -895,104 +766,6 @@ public class ImagesController(
return false;
}
private sealed class ParsedImageMeta
{
public int? SavedImageType { get; init; }
public int? Accessibility { get; init; }
public long? RoomId { get; init; }
public IReadOnlyList<long> PlayerIds { get; init; } = Array.Empty<long>();
}
private static ParsedImageMeta? ParseImageMeta(IFormCollection? form)
{
if (form is null) return null;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return null;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
return new ParsedImageMeta
{
SavedImageType = TryGetInt(doc.RootElement, "savedImageType", "SavedImageType", "imageType", "ImageType"),
Accessibility = TryGetInt(doc.RootElement, "accessibility", "Accessibility"),
RoomId = TryGetLong(doc.RootElement, "roomId", "RoomId"),
PlayerIds = TryGetLongArray(doc.RootElement, "playerIds", "PlayerIds"),
};
}
catch (JsonException)
{
return null;
}
}
private static bool? TryReadBool(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
return null;
}
private static int? TryGetInt(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static long? TryGetLong(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static IReadOnlyList<long> TryGetLongArray(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
continue;
return value.EnumerateArray()
.Select(x => x.ValueKind == JsonValueKind.Number && x.TryGetInt64(out var n)
? n
: x.ValueKind == JsonValueKind.String && long.TryParse(x.GetString(), out n)
? n
: 0L)
.Where(x => x > 0)
.Distinct()
.ToArray();
}
return Array.Empty<long>();
}
/// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The
@@ -1004,49 +777,24 @@ public class ImagesController(
private object BuildImageInfo(PhotoEntity p, long accountId)
{
var cdnHost = domain.Sub("cdn");
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
// deserialiser reads a non-default value (0=Private, 1=Public).
var accessibility = p.IsPublic ? 1 : 0;
return new
{
// 2020.12 watch's IBANLCLBGLM strict keys (per
// docs/recroom-2020-client-response-contracts.md:1048).
// Missing any of these throws KeyNotFoundException in LitJson
// and the gallery empties out.
Id = p.Id,
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
ImageName = p.BlobName,
AccountId = (int)accountId,
// Server-side mirror of AccountId as PlayerId — some
// call-sites read PlayerId (lowercase id) instead of
// AccountId. Including both is harmless on the watch.
PlayerId = (int)accountId,
RoomId = p.RoomId,
// PlayerEventId is always 0 here (photo-event linkage isn't
// tracked yet); the field still has to be present so the
// strict deserialiser doesn't bail.
PlayerEventId = 0L,
Accessibility = accessibility,
// AccessibilityLocked = whether the watch can flip the
// privacy toggle. We don't gate on this server-side so the
// value is always false (UI is free to toggle).
AccessibilityLocked = false,
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
// we don't differentiate yet — surface 0 unconditionally.
Type = 0,
ImageType = 0,
CreatedAt = p.CreatedAt,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
CheerCount = p.CheerCount,
// We don't track per-photo comments yet — surface 0 for
// the strict-key requirement.
CommentCount = 0,
// Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
};
}
@@ -1,57 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.IncentivizedReferrals;
[ApiController]
[Authorize]
[Route("api/incentivizedreferrals")]
public class IncentivizedReferralsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("")]
[HttpGet("progress")]
public async Task<IActionResult> Progress()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
var claimed = await db.PlayerSettings
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
return Ok(new
{
ReferralCount = referralCount,
RequiredReferralCount = 1,
CanClaim = referralCount >= 1 && !claimed,
Claimed = claimed,
RewardCurrencyType = 2,
RewardCurrency = 250,
});
}
[HttpPost("claim")]
public async Task<IActionResult> Claim()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
if (referralCount < 1)
return Ok(new { Success = false, Error = "not_enough_referrals" });
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
return Ok(new { Success = false, Error = "already_claimed" });
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = "referral:reward_claimed",
Value = DateTime.UtcNow.ToString("O"),
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
}
}
@@ -1,115 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
[ApiController]
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/influencerpartnerprogram/influencers")]
[AllowAnonymous]
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
.Distinct()
.Take(take)
.ToListAsync();
var players = await db.Players
.Where(p => creatorIds.Contains(p.Id))
.OrderBy(p => p.Username)
.ToListAsync();
var influencers = players.Select(ToInfluencerWire).ToList();
return Ok(new Dictionary<string, object>
{
["Influencers"] = influencers,
["influencers"] = influencers,
["Accounts"] = influencers,
["accounts"] = influencers,
});
}
[HttpGet("api/influencerpartnerprogram/influencer")]
[AllowAnonymous]
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
{
var player = accountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (player is null) return NotFound();
return Ok(ToInfluencerWire(player));
}
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
[Authorize]
public async Task<IActionResult> MyInfluencer()
{
var me = this.RequireCurrentPlayerId();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null || !long.TryParse(setting.Value, out var influencerId))
return Ok(new { Influencer = (object?)null });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == influencerId);
return Ok(new { Influencer = player is null ? null : ToInfluencerWire(player) });
}
[HttpPost("api/influencerpartnerprogram/support")]
[Authorize]
public async Task<IActionResult> Support([FromForm] long? influencerAccountId, [FromForm] string? username)
{
var me = this.RequireCurrentPlayerId();
var influencer = influencerAccountId is long id
? await db.Players.FirstOrDefaultAsync(p => p.Id == id)
: await db.Players.FirstOrDefaultAsync(p => p.Username == username);
if (influencer is null) return NotFound();
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "influencer:supported");
if (setting is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "influencer:supported",
Value = influencer.Id.ToString(),
});
}
else
{
setting.Value = influencer.Id.ToString();
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Influencer = ToInfluencerWire(influencer) });
}
[HttpPost("api/influencerpartnerprogram/remove")]
[Authorize]
public async Task<IActionResult> Remove()
{
var me = this.RequireCurrentPlayerId();
await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key == "influencer:supported")
.ExecuteDeleteAsync();
return Ok(new { Success = true });
}
private static RecNetAccount ToInfluencerWire(PlayerEntity player) => new()
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
CreatedAt = player.CreatedAt == default ? DateTime.UtcNow : player.CreatedAt,
};
}
@@ -19,15 +19,13 @@ namespace DorkNet.Server.Controllers.API.Inventions;
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET.
///
/// Wire DTO matches the watch's <c>OBBBPCBIMME.PPGFHEDFBEA</c>
/// deserializer (Cpp2IL ISIL <c>OBBBPCBIMME.txt:588-704</c>): keys
/// Wire DTO matches <c>RecNet.Invention</c>
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
/// has a capital G.
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// </summary>
[ApiController]
public class InventionsController(
@@ -40,9 +38,6 @@ public class InventionsController(
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v1/featured")]
[HttpGet("api/inventions/v1/featureddormskins")]
[HttpGet("api/inventions/v1/toptoday")]
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
@@ -76,12 +71,10 @@ public class InventionsController(
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")]
[HttpGet("api/inventions/v2/mine")]
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
[HttpGet("api/inventions/v2/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
@@ -131,62 +124,17 @@ public class InventionsController(
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
[HttpPost("api/inventions/v2/batch")]
[HttpPost("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
return await BatchIdsAsync(req?.InventionIds ?? []);
}
[HttpGet("api/inventions/v1/batch")]
[HttpGet("api/inventions/v2/batch")]
[HttpGet("api/inventions/v1/dormskinsfromids")]
public async Task<ActionResult> BatchFromQuery()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
return await BatchIdsAsync(ids);
}
private async Task<ActionResult> BatchIdsAsync(IReadOnlyCollection<long> requestedIds)
{
if (requestedIds.Count == 0)
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = requestedIds.Take(200).ToList();
var ids = req.InventionIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v1/fromcreators")]
public async Task<ActionResult> FromCreators()
{
var creatorIds = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (creatorIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.UpdatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
@@ -219,11 +167,11 @@ public class InventionsController(
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
/// creator account ids of every invention placed in a room
/// (<c>GetCreatorIdsOfInventionsInRoom</c>). The watch uses this to
/// resolve which creators it must request invention permissions for
/// before spawning a room's invention-based geometry. Returns
/// <c>List&lt;int&gt;</c> — empty when the room has no tracked
/// inventions.</summary>
/// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
/// watch on room load). It uses these to resolve which creators it
/// must request invention permissions for before spawning a room's
/// invention-based geometry. Returns <c>List&lt;int&gt;</c> — empty
/// when the room has no tracked inventions.</summary>
[HttpGet("api/inventions/v1/creatorIds")]
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
{
@@ -271,59 +219,8 @@ public class InventionsController(
.ToListAsync();
}
/// <summary>GET <c>api/inventions/v1/fulllineageowner</c> —
/// raw Boolean. The client uses this to decide whether a set of
/// referenced inventions can be saved together under one owner.
/// DorkNet tracks current creator ownership, so the concrete rule
/// here is: every requested invention id must exist and share the
/// same CreatorPlayerId.</summary>
[HttpGet("api/inventions/v1/fulllineageowner")]
public async Task<IActionResult> FullLineageOwner()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Content("true", "application/json");
var owners = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.CreatorPlayerId })
.ToListAsync();
var ok = owners.Count == ids.Count
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
return Content(ok ? "true" : "false", "application/json");
}
// ── Single fetch ─────────────────────────────────────────────────────
/// <summary>GET <c>/api/inventions/v1/room?id={roomId}</c> — every
/// invention used by the room. The 2020.12 watch fires this on
/// EVERY room load right after fetching the room data blob; it uses
/// the response to pre-resolve invention references the blob will
/// spawn. Without this, imported rooms with invention-based
/// geometry (floors, walls, custom shapes) render with everything
/// missing — the watch silently skips invention spawns when it
/// has no list, and you fall through the void.
///
/// We match on <see cref="InventionEntity.CreationRoomId"/> which
/// the zip importer populates from each Invention.json's
/// <c>CreationRoomId</c> field. Returns an array of
/// <see cref="ToWire"/>-shaped Invention objects (same wire shape
/// the single-fetch endpoint returns) so the watch's existing
/// deserializer works.</summary>
[HttpGet("api/inventions/v1/room")]
public async Task<ActionResult> ByRoom([FromQuery] long id)
{
var inventions = await ResolveRoomInventionsAsync(id);
return Ok(inventions.Select(ToWire).ToList());
}
[HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{
@@ -387,88 +284,53 @@ public class InventionsController(
// ── Create / version ─────────────────────────────────────────────────
public sealed class SaveInventionV4Request
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
public List<long>? ReferencedInventions { get; set; }
public int CreatorAccountRole { get; set; }
}
public sealed record SaveInventionRequest(
string Name, string? Description, string? ImageName,
int? Permission, string? Tags, string BlobName,
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
/// <summary>POST <c>api/inventions/v4/save</c> — 2020.12 maker-pen
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[HttpPost("api/inventions/v6/save")]
[HttpPost("api/inventions/v3/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{
var pid = CurrentPlayerId;
var name = (req.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" });
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = name,
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = 0,
CurrentBlobName = blobName,
TagsCsv = string.Empty,
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = req.BlobName,
TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100,
GeneralPermission = 0,
CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = blobName,
InstantiationCost = Math.Max(0, req.InstantiationCost),
LightsCost = Math.Max(0, req.LightsCost),
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(v1);
if (req.ReferencedInventions is { Count: > 0 })
{
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"invention:{inv.Id}:references:{referencedId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
}
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(new
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
});
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(ToWire(inv));
}
public sealed record AddVersionRequest(
@@ -476,7 +338,6 @@ public class InventionsController(
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
@@ -542,22 +403,6 @@ public class InventionsController(
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/updateprice")]
[Authorize]
public async Task<ActionResult> UpdatePrice(
[FromQuery] long inventionId,
[FromQuery] int price)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.Price = Math.Clamp(price, 0, 1000000);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
@@ -574,7 +419,7 @@ public class InventionsController(
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v3/publish")]
[HttpGet("api/inventions/v2/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
@@ -712,23 +557,6 @@ public class InventionsController(
_ => 0,
};
// Wire shape verified against the 2020.12 watch's
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
// lines 588-704). All keys read via Util.GetKey<T> are required —
// a missing key throws KeyNotFoundException → the watch logs
// "Received malformed RecNet response" and aborts the join/fetch.
//
// Required (in disasm order):
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
// Price, HideFromPlayer.
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
//
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
// In particular IsAGInvention has a capital G (matches the asset's
// "IsAGInvention" property, not the entity column IsAgInvention).
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
@@ -741,7 +569,6 @@ public class InventionsController(
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
@@ -751,13 +578,9 @@ public class InventionsController(
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
i.Price,
HideFromPlayer = false,
i.IsAgInvention,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
@@ -66,13 +66,6 @@ public class InventoryController(
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
// The 2023 client sends Equipped/IsEquipped when a weapon skin is
// equipped (distinct from Favorited). We only have one IsActive flag,
// and for skins "active" == "equipped", so honor whichever the client
// sent. Without this the equip was dropped (only Favorited was read)
// and the skin never stuck.
public bool? Equipped { get; set; }
public bool? IsEquipped { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
@@ -82,27 +75,15 @@ public class InventoryController(
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{
var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip
// the client's separate PrefabName + ModificationGuid fields.
// The slug for equipment is the modification GUID (or prefab
// name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? StoreService.EquipmentSkinSlug(req.PrefabName, req.ModificationGuid)
? req.ModificationGuid
: req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
{
// Legacy rows created before catalog-backed equipment skins
// used the raw modification GUID as ItemSlug.
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
}
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
{
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
}
if (row is null)
{
// Player doesn't own this; reject rather than silently
@@ -110,42 +91,15 @@ public class InventoryController(
// to themselves).
return Forbid();
}
// "equipped" is the meaningful state for weapon skins; prefer it,
// fall back to Favorited for plain tool-pin updates.
var active = req.Equipped ?? req.IsEquipped ?? req.Favorited;
// One skin equipped per weapon: when equipping a catalog-backed skin,
// clear the equipped flag on the player's OTHER skins for the SAME
// prefab (slugs share the "{prefix}{prefab}:" head) so the weapon
// doesn't end up with two "equipped" skins.
if (active && !string.IsNullOrWhiteSpace(req.ModificationGuid)
&& !string.IsNullOrWhiteSpace(req.PrefabName))
{
var prefabPrefix = StoreService.EquipmentSkinSlug(req.PrefabName, string.Empty);
var siblings = await db.PlayerInventory
.Where(inv => inv.PlayerId == pid && inv.IsActive
&& inv.ItemSlug != slug
&& inv.ItemSlug.StartsWith(prefabPrefix))
.ToListAsync();
foreach (var sib in siblings) sib.IsActive = false;
}
row.IsActive = active;
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
// ── Consumables ──────────────────────────────────────────────────────
// 2020.12 client bumped the URL to v2 AND changed the response shape from
// per-item to BulkConsumable: each entry groups N instances of the same
// ConsumableItemDesc with parallel Ids[] / CreatedAts[] arrays. Deserializer
// is PLHFAEBLDAE.PPGFHEDFBEA reading PASCALCASE keys: Ids, CreatedAts,
// ConsumableItemDesc, Count, InitialCount, IsActive, ActiveDurationMinutes,
// IsTransferable. Returning the v1 per-item shape on v2 throws "Malformed
// Response" and the chain calls BootSequence.OINIBKFNFAJ — fatal kick.
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV1()
public async Task<IActionResult> GetUnlockedConsumables()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
@@ -156,33 +110,6 @@ public class InventoryController(
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
}
[HttpGet("api/consumables/v2/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV2()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
// Group by ConsumableItemDesc — one BulkConsumable per consumable type.
var bulk = rows
.GroupBy(r => StoreService.TryGetConsumableItemDesc(r.item.Slug, out var d) ? d : r.item.Slug)
.Select(g => new
{
Ids = g.Select(r => r.inv.Id).ToArray(),
CreatedAts = g.Select(r => r.inv.AcquiredAt).ToArray(),
ConsumableItemDesc = g.Key,
Count = g.Sum(r => r.inv.Quantity),
InitialCount = g.Sum(r => r.inv.Quantity),
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false,
});
return Ok(bulk);
}
public sealed class ConsumableItemRequest
{
public long Id { get; set; }
@@ -209,44 +136,6 @@ public class InventoryController(
return Ok(new { row.Id, Count = row.Quantity });
}
[HttpPost("api/consumables/v1/transfer")]
public async Task<IActionResult> TransferConsumable(
[FromForm] long? id,
[FromForm] long? recipientPlayerId,
[FromForm] int quantity = 1)
{
var pid = Me;
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, quantity);
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
return BadRequest("invalid_transfer");
var source = await db.PlayerInventory.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == inventoryId);
if (source is null || source.Quantity < amount) return NotFound();
var target = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == recipient && p.ItemSlug == source.ItemSlug);
if (target is null)
{
target = new PlayerInventoryEntity
{
PlayerId = recipient,
ItemSlug = source.ItemSlug,
Quantity = 0,
};
db.PlayerInventory.Add(target);
}
source.Quantity -= amount;
target.Quantity += amount;
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded,
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
@@ -267,34 +156,17 @@ public class InventoryController(
// ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item)
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
{
var prefabName = item.Slug;
var modificationGuid = inv.ItemSlug;
var rarity = 0;
if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var mod, out var skin))
{
prefabName = prefab;
modificationGuid = mod;
rarity = skin?.rarity ?? 0;
}
return new
{
PrefabName = prefabName,
ModificationGuid = modificationGuid,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
// IsActive is the equipped/active flag; surface it under every key
// the 2023 client reads so the equipped skin shows on reload.
Favorited = inv.IsActive,
Equipped = inv.IsActive,
IsEquipped = inv.IsActive,
};
}
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
ModificationGuid = inv.ItemSlug,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = 0, // Common (no rarity column on StoreItem yet)
Favorited = inv.IsActive,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
@@ -1,134 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.ItemWishlists;
[ApiController]
[Route("api/itemWishlists")]
[Authorize]
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("v1/wishlist/me")]
public async Task<IActionResult> Mine()
{
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == Me)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("v1/wishlist")]
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
{
var pid = playerId is > 0 ? playerId.Value : Me;
var rows = await db.ItemWishlists
.Where(w => w.PlayerId == pid)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost]
[HttpPost("v1/wishlist")]
public async Task<IActionResult> Add()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
if (key.Length > 128) key = key[..128];
var itemType = req.ItemType ?? 0;
var exists = await db.ItemWishlists.AnyAsync(w =>
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
if (!exists)
{
db.ItemWishlists.Add(new ItemWishlistEntity
{
PlayerId = Me,
ItemKey = key,
ItemType = itemType,
});
await db.SaveChangesAsync();
}
return await Mine();
}
[HttpDelete("v1/wishlist")]
[HttpPost("v1/wishlist/remove")]
public async Task<IActionResult> Remove()
{
var req = await ReadRequestAsync();
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
var itemType = req.ItemType ?? 0;
await db.ItemWishlists
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
.ExecuteDeleteAsync();
return await Mine();
}
private async Task<WishlistRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new WishlistRequest
{
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
{
return new WishlistRequest
{
ItemKey = queryKey,
ItemId = queryId,
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new WishlistRequest();
}
catch (JsonException)
{
return new WishlistRequest();
}
}
private static object ToWire(ItemWishlistEntity row) => new
{
row.Id,
row.PlayerId,
row.ItemKey,
ItemId = row.ItemKey,
row.ItemType,
row.CreatedAt,
};
public sealed class WishlistRequest
{
public string? ItemKey { get; set; }
public string? ItemId { get; set; }
public int? ItemType { get; set; }
}
}
@@ -1,246 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Keepsakes;
[ApiController]
[Route("api/keepsakes")]
[Authorize]
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet]
public async Task<IActionResult> Mine()
{
await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me)
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var results = new[]
{
new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
};
return Ok(new
{
Results = results,
TotalResults = results.Length,
});
}
[HttpGet("events")]
public async Task<IActionResult> Events()
{
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category == "event")
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("events/{eventId:long}")]
public async Task<IActionResult> EventInstances(long eventId)
{
var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me
&& k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(new
{
KeepsakeProgressionEventId = eventId,
Instances = rows.Select(ToWire),
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = new[] { eventId },
});
}
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> RoomKeepsakes(long roomId)
{
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
if (!roomExists) return NotFound();
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.Category == "room"
&& (k.EventKey == $"room:{roomIdText}"
|| k.EventKey == $"room/{roomIdText}"
|| k.EventKey == roomIdText
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
return Ok(new
{
Instances = instances,
CollectionRecords = Array.Empty<object>(),
KeepsakeProgressionEventIds = Array.Empty<long>(),
});
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 64,
SocialXpBoostEnabled = false,
});
[HttpPost]
public async Task<IActionResult> Create()
{
var req = await ReadRequestAsync();
var key = Trim(req.EventKey, 128);
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
var category = Trim(req.Category, 64);
if (category.Length == 0) category = "event";
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
k.PlayerId == Me && k.EventKey == key);
if (existing is null)
{
existing = new KeepsakeEntity
{
PlayerId = Me,
EventKey = key,
};
db.Keepsakes.Add(existing);
}
existing.Category = category;
existing.Title = Trim(req.Title, 128);
existing.Description = Trim(req.Description, 1024);
existing.ImageName = Trim(req.ImageName, 256);
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(existing));
}
private async Task EnsureLoginKeepsakeAsync(long playerId)
{
const string key = "first-login";
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
return;
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = playerId,
Category = "account",
EventKey = key,
Title = "First Login",
Description = "Joined this server.",
});
await db.SaveChangesAsync();
}
private async Task<KeepsakeRequest> ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new KeepsakeRequest
{
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
};
}
try
{
return await JsonSerializer.DeserializeAsync<KeepsakeRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new KeepsakeRequest();
}
catch (JsonException)
{
return new KeepsakeRequest();
}
}
private object ToWire(KeepsakeEntity row) => new
{
row.Id,
row.PlayerId,
row.Category,
row.EventKey,
row.Title,
row.Description,
row.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
row.EarnedAt,
};
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
{
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
? (int)row.PlayerId
: 0;
return new
{
KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"),
KeepsakeCategoryConfigId = CategoryId(row.Category),
PlacedByAccountId = placedByAccountId,
RoomId = roomId,
SubRoomId = (long?)null,
};
}
private static int CategoryId(string? category) =>
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
0;
private static Guid StableKeepsakeInstanceId(long id)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes($"keepsakeInstance:{id}"));
return new Guid(hash);
}
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
public sealed class KeepsakeRequest
{
public string? Category { get; set; }
public string? EventKey { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public DateTime? EarnedAt { get; set; }
}
}
@@ -78,15 +78,13 @@ public class LeaderboardController(
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
RoomId = roomId,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
@@ -105,10 +103,11 @@ public class LeaderboardController(
// when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0)
{
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel);
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null)
{
// First time this channel reports. Stamp a meta row so
@@ -125,6 +124,17 @@ public class LeaderboardController(
ValueFormat = "count",
});
}
else if (meta.RoomId == 0)
{
// Channel registered globally (e.g. from the hardcoded
// KnownStatChannels fallback or an unscoped admin
// entry). Bind it to this room now that we know which
// room it came from. Don't re-bind if it already
// points at a different room — the admin must be the
// one to reassign once a channel has a home.
meta.RoomId = roomId;
meta.UpdatedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
@@ -151,22 +161,18 @@ public class LeaderboardController(
if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 });
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var betterCount = await db.LeaderboardStats
.CountAsync(s => s.RoomId == roomId
&& s.StatChannel == req.StatChannel
&& (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value));
var higherCount = await db.LeaderboardStats
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
return Ok(new
{
playerId = (int)pid,
score = row.Value,
rank = betterCount + 1,
rank = higherCount + 1,
});
}
@@ -188,13 +194,12 @@ public class LeaderboardController(
{
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50);
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync();
var all = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0)
{
// Empty case must still come back as a SingleLeaderboard
@@ -246,14 +251,12 @@ public class LeaderboardController(
var end = Math.Max(start, req.RankEnd);
var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200);
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var rows = await query.Skip(skip).Take(take).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
@@ -268,15 +271,14 @@ public class LeaderboardController(
/// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50)
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0);
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel);
var query = db.LeaderboardStats
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel);
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value);
var rows = await query.Take(take).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == channel)
.OrderByDescending(s => s.Value)
.Take(take)
.ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global
@@ -296,20 +298,4 @@ public class LeaderboardController(
NextResetUTC = DateTime.UtcNow.AddDays(7),
});
}
private long ResolveRoomId(long requestedRoomId, long playerId)
{
if (requestedRoomId > 0) return requestedRoomId;
if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0;
return 0;
}
private async Task<bool> IsLowerIsBetterAsync(long roomId, int channel)
{
var live = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == roomId && c.Channel == channel)
.Select(c => (bool?)c.LowerIsBetter)
.FirstOrDefaultAsync();
return live ?? false;
}
}
@@ -22,7 +22,7 @@ public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase
{
@@ -36,12 +36,12 @@ public class MessagesController(
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
.Where(m => m.Type >= 0)
// Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30)
@@ -65,7 +65,7 @@ public class MessagesController(
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0)
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
@@ -82,7 +82,7 @@ public class MessagesController(
public List<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — delete one or
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
@@ -90,9 +90,7 @@ public class MessagesController(
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend. Game invites are tombstoned instead of
/// hard-deleted because the watch accepts an invite by firing this
/// delete request and <c>/goto/invite/{id}</c> in parallel.</summary>
/// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
@@ -130,27 +128,10 @@ public class MessagesController(
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var inviteRows = await db.Messages
.Where(m => ids.Contains(m.Id)
&& m.RecipientPlayerId == Me
&& (m.Type == 6 || m.Type == 7))
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var invite in inviteRows)
{
invite.Type = -invite.Type;
invite.ReadAt ??= now;
}
var hardDeleted = await db.Messages
var deleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7)
.ExecuteDeleteAsync();
if (inviteRows.Count > 0)
await db.SaveChangesAsync();
return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count });
return Ok(new { success = true, error = "", deleted });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
@@ -297,71 +278,14 @@ public class MessagesController(
if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" });
// Resolve which room the invite is for. Two code paths:
//
// PRIVATE instance: the sender created an invite-only match
// (via /goto/room/X?CreatePrivateInstance=1). The
// roomInstanceId the watch sends maps to a registered
// PrivateInstance and the owner or someone already inside
// that exact live instance can invite. That keeps access
// control intact while still letting a guest in someone
// else's dorm invite a friend from the in-game watch.
//
// PUBLIC room: the sender is in a public room / a dorm /
// any non-private room. RoomInstanceId is just
// roomId*100_000 — synthetic, never registered. We have to
// resolve which Room they're in via PlayerPresenceService
// (heartbeats keep this up to date) so we can stamp RoomId
// on the message and the recipient's /goto/invite/{N} path
// knows where to send them. No ownership check applies for
// public rooms.
long resolvedRoomId;
string roomName;
// Only the OWNER of the private instance can invite into it.
// Otherwise an attacker who somehow learned a roomInstanceId
// could invite themselves (or anyone) freely.
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is not null)
{
var senderPresence = presence.GetRoom(Me);
var senderIsInInstance = senderPresence is not null
&& senderPresence.RoomInstanceId == roomInstanceId;
if (inst.OwnerPlayerId != Me && !senderIsInInstance)
{
logger.LogWarning(
"[invite] from={From} tried to invite to private instance={Instance} ownedBy={Owner} while presenceInstance={PresenceInstance}; rejecting",
Me, roomInstanceId, inst.OwnerPlayerId, senderPresence?.RoomInstanceId);
return Forbid();
}
resolvedRoomId = inst.RoomId;
roomName = !string.IsNullOrWhiteSpace(inst.Name)
? inst.Name
: await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
}
else
{
// Public-room path. Trust the sender's current presence —
// the watch only fires SendInvite from the social menu
// while you're in a room, and heartbeats refresh presence
// every few seconds. We DON'T trust the raw roomInstanceId
// from the form because it's user-controlled and a
// malicious mod could send a fabricated one.
var senderPresence = presence.GetRoom(Me);
if (senderPresence is null || senderPresence.RoomId <= 0)
{
logger.LogWarning(
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
Me, playerId, roomInstanceId);
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
}
resolvedRoomId = senderPresence.RoomId;
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
? senderPresence.Name
: await db.Rooms
.Where(r => r.Id == senderPresence.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Room";
}
if (inst is null)
return BadRequest(new { error = "instance_not_found", roomInstanceId });
if (inst.OwnerPlayerId != Me)
return Forbid();
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls
@@ -374,6 +298,10 @@ public class MessagesController(
// second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges.
var roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
var entry = new MessageEntity
{
@@ -383,19 +311,18 @@ public class MessagesController(
// until we overwrite it post-save with the real JSON.
Body = string.Empty,
Type = 6,
RoomId = resolvedRoomId,
RoomId = inst.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Only pin the invitee row for PRIVATE instances — public-room
// invites have no registered instance to track invitees against.
// GoToInvite handles the missing-mapping case by falling through
// to the message's RoomId + BuildResponseAsync(roomName).
if (inst is not null)
{
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
}
// InviteAsync AFTER the message save so we can pin the freshly-
// allocated Message.Id onto the invitee row's
// LatestInviteMessageId. This is what /goto/invite/{N} falls
// back to when the watch's accept flow has already raced
// through DELETE /api/messages/v3/delete — the message row is
// gone but the (instance, player) → messageId mapping survives.
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
// Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at
@@ -492,20 +419,6 @@ public class MessagesController(
return Ok(Array.Empty<object>());
}
// NOTE: There is intentionally NO separate handler for
// `/api/messages/v2/send`. The controller route is
// `api/[controller]/v2`, so SendFormMessage's relative
// `[HttpPost("send")]` already binds `api/messages/v2/send` for
// form posts (and SendMessage binds it for JSON, disambiguated by
// [Consumes]). A previous standalone `SendSingle` action with an
// explicit `[HttpPost("/api/messages/v2/send")]` duplicated that
// binding, which made every form post to v2/send match two
// endpoints → AmbiguousMatchException (500). That broke the very
// thing it was meant to add: the watch's PartyUpRequest /
// "send join request" (MessageType 120), which posts the same
// ToPlayerId/Type/Data form shape SendFormMessage already accepts.
// Do not re-add a v2/send action; extend SendFormMessage instead.
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
@@ -555,23 +468,17 @@ public class MessagesController(
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.
///
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
/// <c>onlineStatus</c> URLs alias to this handler since they
/// share the wire shape; pre-2026-05-25 the onlineStatus route
/// was wrongly aliased to GetMessages and returned the inbox
/// list instead, breaking the friends-online HUD.</summary>
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
// Honors the global-friends toggle (everyone is a friend when on).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = presence.IsRecentlyActive(id),
Online = online.Contains(id),
}));
}
@@ -136,9 +136,12 @@ public class InGameModerationController(
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects:
// Msg.Reason must be a ModerationBlockDetail object.
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
// Push a SignalR ModerationKick notification — the watch's
// PlayerReporting.OnModerationKick handler boots the player
// back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
}
return Ok(new { success = true, error = "" });
}
@@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{
error = "use_signalr_hub",
hint = $"connect to {domain.Url("notify", "/hub/v1")} instead",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
@@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
Url = domain.Url("notify", "/hub/v1"),
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
@@ -1,57 +0,0 @@
using DorkNet.Server.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Pageview;
/// <summary>
/// Endpoints behind the watch's <c>COFHGNFJMOG</c> page-view + link
/// system. The 2020.12 client fires <c>POST pageview/consume</c> on
/// every screen change in the boot/menu flow expecting a response
/// that tells it (a) whether there's an in-app deep-link URL to
/// resolve and (b) how long the answer is fresh for. Returning a 404
/// here surfaces as red toast spam in the menus and (per
/// <c>BootSequence.OINIBKFNFAJ</c>) can short-circuit the avatar /
/// inventory bootstrap.
///
/// <para>Response shape verified at
/// <c>Cpp2IL_ISIL/.../MPHABHIMOOO.txt:45,50</c> — the watch's
/// <c>MPHABHIMOOO.PPGFHEDFBEA(Dictionary)</c> reads two camelCase
/// keys:
/// <list type="bullet">
/// <item><c>url</c> — string. Empty means "no deep-link to load".</item>
/// <item><c>freshnessSeconds</c> — int. How long the client can
/// cache this response before re-polling.</item>
/// </list>
/// Returning a long freshness (1 hour) keeps a private server's
/// chattiness low when there's no real linking layer.</para>
///
/// <para>Sibling endpoints from the same watch class
/// (<c>actionlink/*</c>, <c>datalink/*</c>, <c>referral</c>) are out
/// of scope for now — they fire only on explicit user share/click,
/// not in the boot path. Add them as separate routes when they show
/// up in the production log.</para>
/// </summary>
[ApiController]
public class PageviewController(ILogger<PageviewController> logger) : ControllerBase
{
/// <summary>POST <c>pageview/consume</c> — record a screen
/// transition. The body is empty in 2020.12.
/// <c>Promise&lt;MPHABHIMOOO&gt;</c> result; the watch only uses
/// the <c>url</c> / <c>freshnessSeconds</c> fields. We
/// intentionally return an empty url + a 1-hour freshness — the
/// private server has no in-app deep-link system, so there's
/// nothing to navigate to and no reason to be polled more often.</summary>
[HttpPost("pageview/consume", Order = 10)]
[HttpPost("/pageview/consume")]
[HttpGet("pageview/consume", Order = 10)]
[HttpGet("/pageview/consume")]
public IActionResult Consume()
{
logger.LogDebug("[pageview] consume callerId={Caller}", this.CurrentPlayerId() ?? 0);
return Ok(new
{
url = string.Empty,
freshnessSeconds = 3600,
});
}
}
@@ -114,18 +114,7 @@ public class PlatformLoginController(
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public async Task<IActionResult> RemoveCachedLogin([FromQuery] long? accountId = null)
{
var targetId = accountId ?? this.CurrentPlayerId();
if (targetId is not long playerId) return Unauthorized();
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return NotFound();
player.LastPlatform = 0;
player.LastPlatformId = string.Empty;
await db.SaveChangesAsync();
return Ok(new { Success = true, AccountId = (int)player.Id });
}
public IActionResult RemoveCachedLogin() => Ok(new { });
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
@@ -1,54 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayStationPlus;
[ApiController]
[Authorize]
public class PlayStationPlusController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/playstationplus/membership")]
public async Task<IActionResult> Membership()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
var expires = row is not null && DateTime.TryParse(row.Value, out var parsed)
? parsed
: DateTime.MinValue;
var active = expires > DateTime.UtcNow;
return Ok(new
{
IsMember = active,
MembershipActive = active,
ExpiresAt = active ? expires : (DateTime?)null,
});
}
[HttpPost("api/playstationplus/expire")]
public async Task<IActionResult> Expire()
{
var me = this.RequireCurrentPlayerId();
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == "playstationplus:expires");
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = "playstationplus:expires",
Value = DateTime.UtcNow.AddSeconds(-1).ToString("O"),
});
}
else
{
row.Value = DateTime.UtcNow.AddSeconds(-1).ToString("O");
}
await db.SaveChangesAsync();
return Ok(new { Success = true, IsMember = false });
}
}
@@ -62,35 +62,17 @@ public class PlayerCheerController(
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
///
/// Form field is <c>CheerCategory</c> — NOT <c>Cheer</c>. The 2020.12
/// watch's AIGGBBFKAKL.SetSelectedCheer (at
/// <c>AIGGBBFKAKL.txt:1083-1084</c>) does
/// <c>AddField("CheerCategory", value)</c> when POSTing to
/// <c>api/PlayerCheer/v1/SetSelectedCheer</c>. A mismatched binding
/// silently parses to 0, which the watch reads back as "no badge",
/// so the player's pinned cheer reverts every time.
///
/// The posted value is the watch's PIDFCOGLFHN category enum (e.g.
/// <c>9000</c> for a specific category) — we store it verbatim and
/// round-trip through <see cref="Reputation.SelectedCheer"/>; the
/// watch maps it back to the visual badge.</summary>
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "CheerCategory")] int? cheerCategory = null,
[FromForm(Name = "Cheer")] int? legacyCheer = null)
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0)
{
// Accept either field name — `CheerCategory` for 2020.12, `Cheer`
// as a defensive fallback for older / re-skinned clients.
var value = cheerCategory ?? legacyCheer ?? 0;
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = value.ToString() });
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
else
existing.Value = value.ToString();
existing.Value = cheer.ToString();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
@@ -46,7 +45,6 @@ public class PlayerEventsController(
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
[HttpGet("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
@@ -54,121 +52,6 @@ public class PlayerEventsController(
return Ok(ToWire(ev));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/search")]
[HttpGet("api/playerevents/v1/searchlive")]
public async Task<ActionResult> Search(
[FromQuery] string? q = null,
[FromQuery] string? query = null,
[FromQuery] int skip = 0,
[FromQuery] int take = 50)
{
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow);
if (needle.Length > 0)
{
rows = rows.Where(e =>
e.Title.ToLower().Contains(needle) ||
e.Description.ToLower().Contains(needle));
}
var result = await rows
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(result.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/room/{roomId:long}")]
public async Task<ActionResult> ForRoom(long roomId)
{
var rows = await db.PlayerEvents
.Where(e => e.RoomId == roomId && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
public async Task<ActionResult> ForClub(long clubId)
{
var memberIds = await db.ClubMemberships
.Where(m => m.ClubId == clubId)
.Select(m => m.PlayerId)
.ToListAsync();
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => memberIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(100)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/clubs")]
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var clubPlayerIds = await db.ClubMemberships
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
var roomIds = await db.Rooms
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
.Select(r => r.Id)
.ToListAsync();
var rows = await db.PlayerEvents
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed class EventBulkRequest
{
public List<long>? PlayerEventIds { get; set; }
public List<long>? EventIds { get; set; }
public List<long>? Ids { get; set; }
}
[AllowAnonymous]
[HttpPost("api/playerevents/v1/bulk")]
[HttpGet("api/playerevents/v1/bulk")]
public async Task<ActionResult> Bulk()
{
var ids = await ReadEventIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await db.PlayerEvents
.Where(e => ids.Contains(e.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/tagfilters")]
public IActionResult TagFilters() => Ok(new
{
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
});
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
@@ -210,39 +93,6 @@ public class PlayerEventsController(
return Ok(new { Created = created, Responses = responses });
}
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
{
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == accountId)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == accountId)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
@@ -334,19 +184,13 @@ public class PlayerEventsController(
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
/// Idempotent — re-responding overwrites.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
@@ -354,20 +198,19 @@ public class PlayerEventsController(
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
/// the caller's RSVP row.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
return Ok(new { success = true, error = "" });
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
@@ -383,123 +226,6 @@ public class PlayerEventsController(
return Ok(new { success = true, error = "" });
}
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
public async Task<IActionResult> UpdateName(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("name", "Name", "value");
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
[HttpPut("api/playerevents/v2/{eventId:long}/description")]
public async Task<IActionResult> UpdateDescription(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
public async Task<IActionResult> UpdateRoom(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var roomId = await ReadLongFieldAsync("roomId", "RoomId", "value");
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
evt.RoomId = id;
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
[HttpPut("api/playerevents/v2/{eventId:long}/time")]
public async Task<IActionResult> UpdateTime(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var start = await ReadDateFieldAsync("startTime", "StartTime", "startsAt", "StartsAt");
var end = await ReadDateFieldAsync("endTime", "EndTime", "endsAt", "EndsAt");
if (start is DateTime s) evt.StartsAt = s;
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
[HttpPut("api/playerevents/v2/{eventId:long}/image")]
public async Task<IActionResult> UpdateImage(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "image", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
[HttpPut("api/playerevents/v2/{eventId:long}/accessibility")]
public async Task<IActionResult> UpdateAccessibility(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
if (value is not int accessibility) return BadRequest("missing_accessibility");
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
public async Task<IActionResult> UpdateTags(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
await SetEventSettingAsync(evt, "tags", value.Trim());
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
public async Task<IActionResult> UpdateMultiInstance(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
return Ok(ToWire(evt));
}
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
public async Task<IActionResult> UpdateClub(long eventId)
{
var evt = await GetOwnedEventAsync(eventId);
if (evt is null) return NotFound();
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
return Forbid();
await SetEventSettingAsync(evt, "club", id.ToString());
return Ok(ToWire(evt));
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
@@ -564,44 +290,6 @@ public class PlayerEventsController(
return Ok(new { FailedInvites = failed, Result = 0 });
}
public sealed class BroadcastRequest
{
public long PlayerEventId { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/playerevents/v1/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var recipients = await db.PlayerEventResponses
.Where(r => r.EventId == ev.Id && r.Response != 2)
.Select(r => r.PlayerId)
.Distinct()
.ToListAsync();
var body = string.IsNullOrWhiteSpace(req.Message)
? $"Update for '{ev.Title}'"
: req.Message.Trim();
foreach (var playerId in recipients.Where(id => id != Me))
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
Body = body,
});
await notifications.NotifyAsync(playerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, ev.Title, Message = body });
}
await db.SaveChangesAsync();
return Ok(new { Success = true, Sent = recipients.Count });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
@@ -611,183 +299,11 @@ public class PlayerEventsController(
public string? Details { get; set; }
}
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
private async Task<List<long>> ReadEventIdsAsync()
{
var ids = new List<long>();
foreach (var value in Request.Query.SelectMany(q => q.Value))
AddDelimitedIds(ids, value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
foreach (var value in form[key])
AddDelimitedIds(ids, value);
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
{
if (doc.RootElement.TryGetProperty(key, out var prop))
AddJsonIds(ids, prop);
}
}
else
{
AddJsonIds(ids, doc.RootElement);
}
}
catch (JsonException)
{
}
}
return ids.Distinct().Take(200).ToList();
}
private static void AddDelimitedIds(List<long> ids, string? value)
{
foreach (var part in (value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
}
private static void AddJsonIds(List<long> ids, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
AddJsonIds(ids, item);
break;
case JsonValueKind.Number:
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
break;
case JsonValueKind.String:
AddDelimitedIds(ids, element.GetString());
break;
}
}
private async Task<string?> ReadStringFieldAsync(params string[] names)
{
var fields = await ReadRequestFieldsAsync();
foreach (var name in names)
if (fields.TryGetValue(name, out var value))
return value;
return null;
}
private async Task<long?> ReadLongFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return long.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
return DateTime.TryParse(value, out var parsed) ? parsed : null;
}
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
{
const string itemKey = "__playerevent_fields";
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
&& cached is Dictionary<string, string> existing)
{
return existing;
}
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.GetString() ?? string.Empty
: prop.Value.GetRawText();
}
}
catch (JsonException)
{
}
}
HttpContext.Items[itemKey] = fields;
return fields;
}
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
{
var settingKey = $"playerevent:{evt.Id}:{key}";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = evt.CreatorPlayerId,
Key = settingKey,
Value = value,
});
}
else
{
row.Value = value;
}
evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
/// <c>{Reported:true}</c> throws on the strict deserialiser.</summary>
[HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" });
if (ev is null) return NotFound();
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
@@ -798,6 +314,6 @@ public class PlayerEventsController(
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
return Ok(new { Reported = true });
}
}
@@ -23,19 +23,8 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
public IActionResult VoteToKickReasons() => Ok(new[]
{
new { ReportCategory = 1, Reason = "Harassment" },
new { ReportCategory = 2, Reason = "Inappropriate behavior" },
new { ReportCategory = 3, Reason = "Cheating" },
new { ReportCategory = 4, Reason = "Spam" },
new { ReportCategory = 5, Reason = "Other" },
});
[HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")]
@@ -202,7 +191,7 @@ public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> l
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return HileResult();
if (pid is not long me) return Ok(new { success = true });
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
@@ -215,22 +204,7 @@ public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> l
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
{
db.ChangeTracker.Clear();
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
}
return HileResult();
await db.SaveChangesAsync();
return Ok(new { success = true });
}
private static IActionResult HileResult() => new ContentResult
{
Content = "true",
ContentType = "application/json",
StatusCode = StatusCodes.Status200OK,
};
}

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