Targets Rec Room 2020.12.18 client.
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 the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
11 KiB
Architecture
A mental model of the DorkNet codebase for new contributors. Read this before you touch code; it explains layout, request flow, the design patterns that look weird in isolation, and where to start.
For deployment, see deploy.md. For wire-protocol
reverse-engineering deliverables, see the recroom-2020-client-*
files in this folder.
Projects in the solution
DorkNet.sln
├── DorkNet.Server ASP.NET Core 9 backend — the bulk of the code
├── DorkNet.Models Shared DTO + serialization types
├── DorkNet.ClientMod MelonLoader plugin (portable / experimental)
└── DorkNet.ClientPatch BepInEx 6 IL2CPP plugin (production)
| Project | Responsibility | Target |
|---|---|---|
DorkNet.Server |
REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 |
DorkNet.Models |
DTOs the wire protocol speaks. No project dependencies. | .NET 9 |
DorkNet.ClientMod |
MelonLoader 0.6.x port — same patches as ClientPatch, JSON config | .NET 6 |
DorkNet.ClientPatch |
BepInEx 6 IL2CPP plugin — the standard client patcher, TOML config | .NET 6 |
ClientMod and ClientPatch are intentional twins: both apply the
same patches (URI rewrite .rec.net → configured apex, Photon AppId
override, TLS trust bypass, auth injection) against different mod
loaders. ClientPatch is the supported deployment; ClientMod exists for
contributors who already have MelonLoader installed and don't want to
swap loaders.
Server folder layout
DorkNet.Server/
├── Auth/ JWT config, IP-ban + player-ban middleware, AdminOnly attribute
├── Compat/ Version detection middleware, client-version registry, HttpContext extensions
├── Controllers/ REST endpoints (see below)
├── Data/ DbContext, Entities/, EF migrations, LegacyUpgrades, seed JSON
├── Hubs/ SignalR NotifyHub (real-time presence on notify.{apex}/hub/v1)
├── Protos/ Protobuf schemas — trimmed `dorknet_room_data.proto` + full decompiled `recroom_2020.proto`
├── Services/ ~27 business-logic services (PlayerService, RoomService, etc.)
├── Versions/ Per-version plugins; Late2020VersionPlugin marks the 2020.12.18 build
├── admin-ui/ Vite + React SPA source — builds to wwwroot/admin/
├── site/ Public website Vite SPA source — builds to wwwroot/site/
├── wwwroot/ Static file root, with per-host subdirs (admin/, site/, feed/)
└── Program.cs Host + DI + middleware pipeline (split candidates noted at the bottom)
Controllers/ groupings
20 top-level groups, each responsible for one product surface the watch (game client) talks to. The grouping mirrors the watch's URL layout, which is why some folders are large — splitting them would obscure the mirror relationship.
| Folder | Surface |
|---|---|
API/ |
Main REST API split by subsystem (Avatar, Store, Rooms, Messages, Inventions, Leaderboard, BugReporting, Moderation, …) |
Accounts/ |
Account creation, login, profile updates |
Admin/ |
Admin SPA backend, gated by AdminOnlyAttribute |
Auth/ |
Photon custom-auth (/photon/customauth), version-check /api/versioncheck/v4 |
Cdn/ |
Image transforms, room blob serving |
Clubs/ |
clubs.{apex}/* — clubs surface |
Commerce/ |
commerce.{apex}/* — monetization catalog |
Discovery/ |
discovery.{apex}/* — room discovery + search |
Econ/ |
econ.{apex}/* — currency + inventory |
Geo/ |
geo.{apex}/* — region lookup |
Health/ |
/healthz probe |
Match/ |
Matchmaking + /goto/* flow |
Notify/ |
REST-side companion to the SignalR hub |
Ns/ |
ns.{apex}/* — service-URL registry the watch queries on startup |
PlayerSettings/, Rooms/, Site/, Storage/, Strings/ |
Smaller surfaces with self-explanatory scope |
Request lifecycle
Middleware ordering matters in ASP.NET Core; the pipeline order in
Program.cs is below. Numbers are the order, not line numbers.
- Logging — Serilog enriches every request with structured fields.
- HTTPS redirect — Only when Kestrel has an HTTPS endpoint (direct mode); a no-op behind nginx/cloudflared.
- WebSocket config — 30-second keep-alive pings to keep cloudflared from killing idle tunnels.
- Per-host static branches (
MountStaticHost) —admin.{apex}, apex,feed.{apex}each serve their own SPA fromwwwroot/<dir>. Mounted withUseWhen(notMapWhen) so API calls under those subdomains still reach the controllers. - IP-ban check — earliest filter; rejects banned IPs before any work runs.
- Version detection (
VersionDetectionMiddleware) — readsX-DorkNet-Version, validates against the version registry, returns 426 on mismatch. Apex + admin/site/feed subdomains skip the gate (humans on browsers don't carry a client version). - JWT authentication —
UseAuthentication. - Authorization — role/policy checks.
- Player-ban check (
BanCheckMiddleware) — after auth so it can seectx.User. - MapControllers — route to controller actions.
- SignalR hub —
NotifyHubonnotify.{apex}/hub/v1, WebSocket-only,RequireHostfilter.
Multi-tenancy + the domain config
DorkNet is built to run many concurrent players. Every feature assumes
multi-user semantics, not single-player sandbox. There's no global
"current player"; everything threads playerId through the request.
The deployment apex (default localhost, overridable via
Domain:Apex config or DORKNET_DOMAIN env var) is read once at
startup into the DomainConfig singleton. Inject it anywhere code
needs to build a subdomain URL — never hardcode localhost.
public sealed class Foo(DomainConfig domain) {
public string Url() => $"https://cdn.{domain.Apex}/...";
}
Per-controller [Host("api.rec.net", "api.localhost")] filters are
gone; allowed hosts are derived from {apex} + *.{apex} and
enforced by HostFilteringMiddleware. Subdomain-discriminating
handlers branch on Request.Host.Host.Split('.')[0].
Version plugins
Versions/ holds an IVersionPlugin per client generation we support.
Today there's one (Late2020VersionPlugin for the 2020.12.18 build).
The pattern is a marker registry:
VersionKeys— the wire-format identifiers a plugin claims.Generation— human-readable name controllers branch on viactx.GetClientVersion().Generation.RegisterStrategies(IServiceCollection)— for plugins that ship generation-specific implementations (today empty; will grow as wire shapes diverge across builds).
When a new client build is wire-compatible with an existing
generation, just add its key to VersionKeys. When it diverges, add
a new plugin and register strategy bindings keyed by generation.
Reverse-engineering provenance
DorkNet is a clean-room reimplementation of the 2020.12.18 Rec Room
backend protocol. ~46 files carry Cpp2IL provenance comments tying
specific code to decompiled client internals. Example:
// Wire shape per Cpp2IL_ISIL/.../PLILLKHMNDA.txt deserializer:
// gap-skipping enum, not 0..N
public const int Member = 10, Moderator = 20, CoOwner = 30, Creator = 100;
The decompiled dumps themselves are not in this repo (dist/RecRoom-2020.12.18-isil/
locally, gitignored). The comments are the bridge — if you're writing
or fixing wire-shape code, always check the decompiled source first
rather than guessing from symptoms.
The docs/recroom-2020-client-* files are an exhaustive catalog of
client-side endpoints, DTOs, and request expectations extracted from
the same decompile. They're the ground truth when a controller needs
a new endpoint.
Database story
Two providers, switched by Database:Provider config:
- SQLite — default for local dev. File at
bin/<config>/data/dorknet.db. - Postgres — production. Connection string from
ConnectionStrings:Default.
Schema lives in Data/Entities/* and Data/DorkNetDbContext.cs. EF
migrations under Data/Migrations/ apply via Database.Migrate() on
boot. Data transforms that can't be expressed as schema migrations
(seed renames, computed backfills) go in Data/LegacyUpgrades.cs as
idempotent methods registered in RunAsync(). See
Data/MIGRATIONS.md for the
discipline: one release = one migration; everything else is
LegacyUpgrades.
Where to start
If you've never touched this codebase, the most approachable path:
- Read a small Service (
ConfigService,DomainConfig,OnlinePresenceService) to learn the DI pattern. - Read a small Controller (
Health/HealthController,Strings/StringsController) to see how services are injected and responses are shaped. - Trace one end-to-end request through
Rooms/orPlayers/— middleware → controller → service → DbContext → response. The middleware pipeline (above) is the lens. - Look at one
Cpp2IL-cited line in a controller to see how wire shapes are pinned to decompiled provenance.
When in doubt: open DorkNet.Server/Program.cs and the
Versions/Late2020/Late2020VersionPlugin.cs summary doc-comment.
Between them they explain 80% of the cross-cutting decisions.
Acknowledged sharp edges
These look messy. They're messy on purpose — the alternative is worse.
- Some controllers are large.
AdminController.cs,RoomsController.cs,GoToController.csare big because they mirror watch surfaces that are themselves sprawling. Splitting them by endpoint group would obscure the surface-mirror relationship that makes the code navigable when paired with the watch's decompiled call sites. - Cpp2IL comments reference paths outside the repo. That's intentional — the decompiled source is provenance only, not shippable. Comments give a future contributor a breadcrumb to re-derive the wire shape if the decompile changes.
Program.csis long. Most of it is one-time schema patching (RunPatchAsynccalls for the PostgresEnsureCreatedpath) that's pending consolidation perData/MIGRATIONS.md. Once that lands, the remaining ~500 lines of middleware + host setup will be split intoStartup/*extensions.- Two-provider EF. SQLite and Postgres each have their own EnsureCreated/Migrate dance because some Postgres-only column types drift the model snapshot. The split is annoying but lets one codebase serve both local dev (zero-config SQLite) and production (managed Postgres).
- Hardcoded
rec.netURLs inRoomService+HtrAssetMirrorService. These pull canonical assets (room thumbnails,.htrasset data) from the official Rec Room CDN at seed time. The alternative is shipping the assets in the repo, which would put DorkNet on legally weaker footing.