Compare commits

..

31 Commits

Author SHA1 Message Date
Alexa 503d838fd1 ci: treat .markdown like .md in push builds
From the local CodeRabbit review: a Markdown-only push using the .markdown
extension still rebuilt Quest. Exclude both extensions in the push planner
and in the documented job path filter.
2026-10-25 13:51:26 +02:00
Alexa e29707deeb ci: keep the checkout so the path filter can skip non-client pushes
The job's path filter needs the last build's checkout to compare against.
The pipeline deleted the whole workspace after each run, so Jenkins polled
with "Working Directory does not exist", assumed changes, and started a
run for every push (builds #6 and #7: zero changed files). Delete only
out/ and quest-work/ instead.
2026-10-25 12:49:09 +02:00
Alexa bafb380fc8 ci: build on push only for client-facing changes
Push builds used to rebuild both platforms for any Jenkinsfile or ci/jenkins/
change, and every build publishes to the downloads page. Now only
DorkNet.ClientMod/ and tools/build_pc.py (PC) or DorkNet.Quest/ (Quest)
changes rebuild, and Markdown never counts; CI-only changes take a manual
run. The README gives the matching Git path filter for the job, so other
pushes don't start a run at all.
2026-10-25 12:11:08 +02:00
Alexa e7c0b076f2 Merge pull request #68 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:58:51 +02:00
Alexa 0a3bad7c6c Merge pull request #67 from DorkSquadRR/feat/quest-install-latest-build 2026-10-25 11:57:05 +02:00
Alexa 1718912a07 Merge pull request #66 from DorkSquadRR/ci/downloads-page 2026-10-25 11:56:51 +02:00
Alexa 5acad4c9e2 Merge pull request #65 from DorkSquadRR/ci/jenkins-jdk25 2026-10-25 11:56:38 +02:00
Alexa ec88a39750 ci: leave the downloads page to the DorkNet site
The server now offers the published builds on its own site (Downloads page,
Latest build on the Quest page), reading the bucket on the internal network.
Drop the standalone index.html and DORKNET_SITE_URL; the bucket needs no
public route or access app of its own.
2026-10-25 11:53:23 +02:00
Alexa 6e5b011059 ci: publish client builds to a downloads page under player-facing names
Each run now stages what it built as DorkNet-<release>-PC-Full.zip,
-PC-Patch.zip, -Quest.xapk and -Quest-Installer.zip, writes their sizes and
SHA-256 to a manifest, and uploads them with a static page to an S3 bucket
served read-only (ci/downloads). The build scripts keep their own names,
which the launcher depends on; only the published copies are renamed.

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

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

- Dockerfile: inbound-agent base; restic cache on the agent's own volume
- docker-compose.yml: the agent as a Coolify Docker Compose application,
  reaching Jenkins over WebSocket on the predefined Coolify network
- Jenkinsfile: agent { label 'dorknet-builder' }
- README: Java 25 for both sides (Java 21 support ends Sep 2027, Java 17
  is unsupported), restart policy, node setup and Coolify deployment
2026-10-25 01:14:17 +03:00
Alexa 0839d932ce Merge pull request #64 from DorkSquadRR/fix/login-sign-in-instead-of-auto-create
fix: open the sign-in page instead of creating a new account on a fresh client
2026-10-24 23:21:12 +03:00
Alexa 9523525cb8 fix: register the title-screen login patch so it actually applies on PC
GetPatchMethod only resolves patch methods from a fixed list of holder
classes; TitleScreenLoginPatches was missing, so the prefix failed with
[patch-fail] and a fresh PC client still looped on account creation.
Also log the real exception behind a failed sign-in redirect instead of
the TargetInvocationException wrapper.
2026-10-24 22:50:54 +03:00
Alexa 3880745efd fix: open the sign-in page instead of creating a new account on a fresh client
With no remembered account, the 2023 title screen creates a brand-new
account (grant_type create_account). On a server with sign-ups off that
fails and the title screen retries forever. PC (ClientMod) and Quest
now open the sign-in page instead, so players log into their existing
DorkNet account. AutoCreateAccount = true keeps Rec Room's behaviour.

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

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

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

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

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

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

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

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

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

Uses Tango (@yume-chan/adb 2.6.4 and friends, MIT), pinned and bundled;
the page is lazy-loaded so the rest of the site doesn't grow. Credited in
NOTICE; README, DorkNet.Quest/README.md, docs/quest.md and the packaged
README.txt point players at the page.
2026-10-15 22:34:25 +03:00
Alexa 6272844445 Merge pull request #58 from DorkSquadRR/fix/base-avatar-items
Owned items showed a price: serve only the real base avatar items
2026-10-15 22:09:23 +03:00
71 changed files with 4453 additions and 956 deletions
+2 -2
View File
@@ -43,10 +43,10 @@ jobs:
Compress-Archive -Path publish/server/* ` Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip -DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ─── # ── Client patcher zip (built via tools/build_pc.py) ─────────────
- name: Build client patcher zip - name: Build client patcher zip
run: | run: |
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }} python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ─────────────────────────────── # ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets - name: Upload Release assets
+64 -1
View File
@@ -156,6 +156,14 @@ public class Mod : MelonMod
// "N"). Empty = no hotkey. A hotkey also arms the feature when // "N"). Empty = no hotkey. A hotkey also arms the feature when
// RecRoyaleNightMode is false, so it can be switched on in-game. // RecRoyaleNightMode is false, so it can be switched on in-game.
public static string RecRoyaleNightToggleKey = ""; public static string RecRoyaleNightToggleKey = "";
// A client with no remembered account for this machine. false: the
// title screen opens the sign-in page so the player logs into their
// existing account. true: Rec Room's own behaviour, which creates a
// new account automatically -- only useful when the server allows
// sign-ups (otherwise the title screen keeps retrying and failing).
// See TitleScreenLoginPatches.
public static bool AutoCreateAccount = false;
} }
public override void OnInitializeMelon() public override void OnInitializeMelon()
{ {
@@ -225,6 +233,14 @@ public class Mod : MelonMod
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix)); postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
} }
// Title-screen auto-login: with no remembered account, open the
// sign-in page instead of creating a new account.
if (!Cfg.AutoCreateAccount)
{
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
}
// Rec Royale night mode: armed when enabled OR when a hotkey is set // Rec Royale night mode: armed when enabled OR when a hotkey is set
// (so it can be switched on in-game). Scene detection + mood apply // (so it can be switched on in-game). Scene detection + mood apply
// run from OnSceneWasLoaded / OnUpdate below. // run from OnSceneWasLoaded / OnUpdate below.
@@ -457,6 +473,7 @@ public class Mod : MelonMod
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean(); if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32(); if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32(); if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array) if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{ {
@@ -662,7 +679,7 @@ public class Mod : MelonMod
private MethodInfo GetPatchMethod(string name) private MethodInfo GetPatchMethod(string name)
{ {
// Look in the core + feature patch holder classes. // Look in the core + feature patch holder classes.
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) }) foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
{ {
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m; if (m is not null) return m;
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
return false; return false;
} }
} }
internal static class TitleScreenLoginPatches
{
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
// auto-login: the title screen downloads this machine's remembered
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
// grant_type create_account). A server with sign-ups off refuses that, the
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
// "Log in" button does). ManuallyLogin carries the player into the game
// from there on its own, so the boot step gets a promise that never
// settles (MAMJDBJDFAF, the game's non-generic promise).
private static Type? _promiseType;
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
{
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
try
{
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
if (_promiseType is null || goToLogin is null)
{
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return true;
}
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
// its own class, so hand back the new promise's native object
// wrapped in that type.
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
var returnType = ((MethodInfo)__originalMethod).ReturnType;
var result = Activator.CreateInstance(returnType, pending.Pointer);
goToLogin.Invoke(__instance, null);
__result = result;
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
return false;
}
catch (Exception ex)
{
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
return true;
}
}
}
internal static class ToxModPatches internal static class ToxModPatches
{ {
public static bool CanInitializeToxMod_Prefix(ref bool __result) public static bool CanInitializeToxMod_Prefix(ref bool __result)
@@ -1,6 +1,7 @@
{ {
"ServerHost": "{HOST}", "ServerHost": "{HOST}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}, "EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
"AutoCreateAccount": false,
"EnableDebugConsole": false, "EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote", "DebugConsoleToggleKey": "BackQuote",
"DevCommands": [], "DevCommands": [],
+40 -18
View File
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning | | image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash | | hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — | | integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — | | Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
### Two builds, selected by `OculusAppId` ### Two builds, selected by `OculusAppId`
@@ -60,7 +61,7 @@ Photon shards with PC DorkNet players with no server-side version change.
and its matching **OBB**. and its matching **OBB**.
> On Windows with Controlled Folder Access on, the NDK linker and apksigner > On Windows with Controlled Folder Access on, the NDK linker and apksigner
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default; > can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
> keep the work dir outside protected folders. > keep the work dir outside protected folders.
## Build ## Build
@@ -76,20 +77,28 @@ dotnet Il2CppDumper.dll `
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName # edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
# 3. Build the signed APK: # 3. Build the signed APK (and, with --obb, the release package):
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk' $env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018' $env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar' $env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
DorkNet.Quest\scripts\build.ps1 ` py -3 DorkNet.Quest\scripts\build.py build `
-Apk C:\path\to\RecRoom-store-2128210.apk ` --apk C:\path\to\RecRoom-store-2128210.apk `
-DumpCs <scratch>\dump\dump.cs ` --dump-cs <scratch>\dump\dump.cs `
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb --obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
--keystore C:\path\to\dorknet-debug.keystore
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk # -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb) # -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
``` ```
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
folder ready to hand to players:
For a build whose target table is already committed under `targets/`, pass
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
Jenkins job does (`ci/jenkins/README.md`).
With `--obb`, the build also writes a release folder ready to hand to players:
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
@@ -98,15 +107,16 @@ folder ready to hand to players:
| `install.sh` | one-command install on macOS / Linux | | `install.sh` | one-command install on macOS / Linux |
| `README.txt` | player instructions | | `README.txt` | player instructions |
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`. To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
The package contains game binaries from your store copy, so only share it with The package contains game binaries from your store copy, so only share it with
people who own the game. people who own the game.
`build.ps1` chains: resolve targets → generate headers → compile `build.py build` chains: resolve targets → generate headers → compile
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest → `libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build → app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package keystore in the work dir on first run. **Keep that keystore** and pass it with
`--keystore` from then on — Meta ties tokens to app id + package
+ signing cert, so re-signing with a different key breaks any Meta registration. + signing cert, so re-signing with a different key breaks any Meta registration.
It is never committed. It is never committed.
@@ -129,7 +139,18 @@ the running `libil2cpp.so`, so a table can never be applied to the wrong build.
## Install ## Install
From a release folder, one command does everything (finds adb and the headset, **From the browser (easiest).** Open the **Quest** page of your DorkNet site
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
the headset, then **Install**. The page talks to the headset over WebUSB
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
path (skipped if an identical one is already there) and launches the app. The
`.xapk` never leaves the player's computer; the server hosts no game files.
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
only one program can hold the headset's USB connection. Firefox, Safari and
phones don't support WebUSB.
**From a release folder**, one command does everything (finds adb and the headset,
installs the APK, copies the OBB to the right path, skips the OBB if it is installs the APK, copies the OBB to the right path, skips the OBB if it is
already there, and launches the app): already there, and launches the app):
@@ -178,12 +199,14 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| Symptom | Likely cause | | Symptom | Likely cause |
| --- | --- | | --- | --- |
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase | | `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip | | install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
| boots to an empty world | OBB missing or named for the wrong versionCode | | boots to an empty world | OBB missing or named for the wrong versionCode |
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm | | `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin | | `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
| multiplayer never connects | Photon app ids not set in the cfg | | multiplayer never connects | Photon app ids not set in the cfg |
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` | | Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
## Layout ## Layout
@@ -198,8 +221,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` | | `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` | | `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection | | `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) | | `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package | | `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) | | `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
+6
View File
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
# Accept image Content-Signatures the server signs with its own key. # Accept image Content-Signatures the server signs with its own key.
AcceptUnsignedImages = true AcceptUnsignedImages = true
# A headset with no remembered account. false: open the sign-in page so the
# player logs into their existing DorkNet account. true: Rec Room's own
# behaviour, which creates a new account automatically -- only useful when the
# server allows sign-ups (otherwise the game keeps retrying and failing).
AutoCreateAccount = false
# ---- diagnostics (adb logcat -s DORKNET) ---- # ---- diagnostics (adb logcat -s DORKNET) ----
LogHttp = true LogHttp = true
-248
View File
@@ -1,248 +0,0 @@
<#
.SYNOPSIS
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
.DESCRIPTION
End to end:
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
2. gen_targets_header.py -> targets_generated.h
3. gen_config_header.py -> config_generated.h (from your cfg)
4. NDK clang++ -> libdorknet.so (arm64)
5. apktool decode -> working project
6. package rename + manifest/version edits
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
8. apktool build -> zipalign -> apksigner -> the signed output APK
Nothing game-derived is written into the repo. The work directory defaults to
a temp path because Windows Controlled Folder Access blocks the NDK linker and
apksigner from writing under Documents; override with -WorkDir if needed.
You supply the store APK and (for install) the matching OBB. No game binaries
ship with this tool.
.PARAMETER Apk
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
2128210 / version 20230317).
.PARAMETER Config
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
.PARAMETER WorkDir
Scratch build directory (must be outside Controlled-Folder-Access-protected
locations). Defaults to $env:TEMP\dorknet-quest-build.
.PARAMETER OutApk
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
.PARAMETER NdkRoot
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
from $env:ANDROID_SDK_ROOT.
.PARAMETER Obb
The store OBB matching the APK. When given, package.ps1 also bundles the
result into an installable .xapk with one-command installers.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[string]$Config,
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
[string]$OutApk,
[string]$NdkRoot,
[string]$BuildToolsDir,
[string]$ApktoolJar,
[string]$DumpCs,
[string]$ApiLevel = '24',
[string]$Obb
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$RepoDir = Split-Path -Parent $PSScriptRoot
$Scripts = $PSScriptRoot
$OldPkg = 'com.AgainstGravity.RecRoom'
$SoName = 'libdorknet.so'
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
# ---- resolve tools ------------------------------------------------------
if (-not $NdkRoot) {
$NdkRoot = $env:ANDROID_NDK
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
}
}
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
Need $Cxx 'NDK aarch64 clang++'
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
Need $ZipAlign 'zipalign'
Need $ApkSigner 'apksigner'
if (-not $ApktoolJar) {
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
}
Need $ApktoolJar 'apktool.jar'
Need $Apk 'store APK'
if (-not $Config) {
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
}
Need $Config 'config'
Write-Host "Using config: $Config"
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
if (-not $AppName) { $AppName = 'DorkNet' }
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
$pa = $PyPre + $a
& $PyExe @pa
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
# ---- work dirs ----------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$Gen = Join-Path $WorkDir 'gen'
$Project = Join-Path $WorkDir 'project'
$Extract = Join-Path $WorkDir 'extract'
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
# ---- 1. extract + dump + resolve targets --------------------------------
Step 'extracting libil2cpp.so + global-metadata.dat'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
try {
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
$dest = Join-Path $Extract (Split-Path $e -Leaf)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
}
} finally { $zip.Dispose() }
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
$LibMain = Join-Path $Extract 'libmain.so'
if (-not $DumpCs) {
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
}
Need $DumpCs 'dump.cs'
Step 'resolving hook targets'
$verName = '20230317'; $verCode = '2128210'
# Write the resolved table into the work dir (Controlled Folder Access can
# block writes under Documents). The repo keeps a committed copy under targets/.
$table = Join-Path $WorkDir "$verName-$verCode.json"
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
Step 'generating headers'
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
# ---- 4. compile libdorknet.so -------------------------------------------
Step 'compiling libdorknet.so (arm64)'
$src = @(
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
(Join-Path $RepoDir 'src\config.cpp'),
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
)
$So = Join-Path $WorkDir $SoName
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
if ($LASTEXITCODE) { throw 'compile failed' }
Need $So 'libdorknet.so'
# ---- 5. apktool decode --------------------------------------------------
Step 'apktool decode'
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
if ($LASTEXITCODE) { throw 'apktool decode failed' }
# ---- 6. package rename + manifest -------------------------------------
Step "renaming package $OldPkg -> $PkgName"
$manifest = Join-Path $Project 'AndroidManifest.xml'
$m = Get-Content -Raw $manifest
$m = $m.Replace($OldPkg, $PkgName)
if ($m -notmatch 'android.intent.category.LAUNCHER') {
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
}
Set-Content -NoNewline -Path $manifest -Value $m
# The launcher label comes from @string/app_name; set it in every locale.
Step "naming the app '$AppName'"
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
}
# ---- 7. inject the hook library ----------------------------------------
Step 'injecting libdorknet.so'
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
# ---- 8. rebuild + align + sign -----------------------------------------
Step 'apktool build'
$unsigned = Join-Path $WorkDir 'unsigned.apk'
& java -jar $ApktoolJar build -f -o $unsigned $Project
if ($LASTEXITCODE) { throw 'apktool build failed' }
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
if (-not (Test-Path $Keystore)) {
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
if ($LASTEXITCODE) { throw 'keytool failed' }
}
Step 'zipalign'
$aligned = Join-Path $WorkDir 'aligned.apk'
& $ZipAlign -f -p 4 $unsigned $aligned
if ($LASTEXITCODE) { throw 'zipalign failed' }
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
Step "apksigner -> $OutApk"
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
if ($LASTEXITCODE) { throw 'apksigner failed' }
& $ApkSigner verify --print-certs $OutApk | Out-Null
Write-Host ""
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
if ($Obb) {
Write-Host ""
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
} else {
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
}
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
From a pristine store APK, end to end:
1. hook targets resolve them from an Il2CppDumper dump.cs
(resolve_targets.py), or use a committed table checked
against the APK's libil2cpp.so
2. headers targets_generated.h + config_generated.h
3. compile libdorknet.so (arm64) with the Android NDK
4. apktool decode
5. rename package rename, launcher category, app label
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
7. sign apktool build -> zipalign -> apksigner
8. package with --obb: .xapk + one-command installers (or run
`package` on an APK you already built)
Nothing game-derived is written into the repo. You supply the store APK and,
for a release package, the matching OBB. The output contains game binaries
from your own store copy: share it only with people who own the game.
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
keytool), the Android NDK, Android build-tools and apktool.
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import NoReturn
from xml.sax.saxutils import escape as xml_escape
SCRIPTS = Path(__file__).resolve().parent
REPO = SCRIPTS.parent # DorkNet.Quest/
OLD_PKG = "com.AgainstGravity.RecRoom"
SO_NAME = "libdorknet.so"
ON_WINDOWS = os.name == "nt"
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def run(*cmd: object, cwd: Path | None = None) -> None:
args = [str(c) for c in cmd]
r = subprocess.run(args, cwd=cwd)
if r.returncode:
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
def capture(*cmd: object) -> str:
args = [str(c) for c in cmd]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
if r.returncode:
sys.stderr.write(r.stderr)
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
return r.stdout
def run_py(script: str, *args: object) -> None:
"""Run a sibling helper script with this interpreter (isolated mode)."""
run(sys.executable, "-I", SCRIPTS / script, *args)
def exe(name: str) -> str:
return name + (".exe" if ON_WINDOWS else "")
# ---- tool resolution ------------------------------------------------------
def sdk_root() -> Path | None:
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
return Path(v) if v else None
def newest_subdir(parent: Path) -> Path | None:
if not parent.is_dir():
return None
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
return dirs[0] if dirs else None
def resolve_build_tools(arg: str | None) -> Path:
if arg:
return need(arg, "build-tools dir")
root = sdk_root()
found = newest_subdir(root / "build-tools") if root else None
if not found:
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
return found
def resolve_ndk(arg: str | None) -> Path:
if arg:
return need(arg, "NDK")
if os.environ.get("ANDROID_NDK"):
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
root = sdk_root()
found = newest_subdir(root / "ndk") if root else None
if not found:
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
return found
def ndk_bin(ndk: Path) -> Path:
if ON_WINDOWS:
tag = "windows-x86_64"
elif sys.platform == "darwin":
tag = "darwin-x86_64"
else:
tag = "linux-x86_64"
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
def resolve_apktool(arg: str | None) -> Path:
if arg:
return need(arg, "apktool.jar")
if os.environ.get("APKTOOL_JAR"):
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
root = sdk_root()
if root and (root / "apktool.jar").exists():
return root / "apktool.jar"
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
def cfg_value(config: Path, key: str) -> str | None:
for line in config.read_text(encoding="utf-8").splitlines():
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
if m:
return m.group(1).split("#", 1)[0].strip()
return None
# ---- build ----------------------------------------------------------------
def cmd_build(a: argparse.Namespace) -> None:
ndk = resolve_ndk(a.ndk_root)
bin_dir = ndk_bin(ndk)
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
"NDK aarch64 clang++")
objdump = bin_dir / exe("llvm-objdump")
build_tools = resolve_build_tools(a.build_tools_dir)
zipalign = need(build_tools / exe("zipalign"), "zipalign")
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
apktool = resolve_apktool(a.apktool_jar)
apk = need(a.apk, "store APK").resolve()
config = Path(a.config) if a.config else (
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
need(config, "config")
print(f"Using config: {config}")
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
app_name = cfg_value(config, "AppName") or "DorkNet"
work = Path(a.work_dir)
gen, project, extract = work / "gen", work / "project", work / "extract"
for d in (work, gen, extract):
d.mkdir(parents=True, exist_ok=True)
# 1. extract + targets
step("extracting libil2cpp.so + global-metadata.dat")
with zipfile.ZipFile(apk) as z:
for entry in ("lib/arm64-v8a/libil2cpp.so",
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
"lib/arm64-v8a/libmain.so"):
try:
data = z.read(entry)
except KeyError:
fail(f"APK missing {entry}")
(extract / Path(entry).name).write_bytes(data)
il2cpp = extract / "libil2cpp.so"
ver_name, ver_code = "20230317", "2128210"
if a.targets_json:
# A committed, already-resolved table. It is only valid for the exact
# libil2cpp.so it was resolved against, so check that first.
table = need(a.targets_json, "target table")
step(f"using target table {table}")
data = json.loads(table.read_text(encoding="utf-8"))
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
want = str(data.get("il2cppSha256") or "").lower()
if want and sha != want:
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
f"for ({want}). Resolve a table for it with --dump-cs.")
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
else:
if not a.dump_cs:
# Dumping is left to Il2CppDumper (see README) to keep this
# script toolchain-light.
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
"resolved table (see DorkNet.Quest/README.md).")
need(a.dump_cs, "dump.cs")
step("resolving hook targets")
# Written into the work dir (Controlled Folder Access can block writes
# under Documents). The repo keeps a committed copy under targets/.
table = work / f"{ver_name}-{ver_code}.json"
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
"--version-name", ver_name, "--version-code", ver_code,
"--objdump", objdump, "--out", table)
# 2. headers
step("generating headers")
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
# 3. compile
step("compiling libdorknet.so (arm64)")
src = REPO / "src"
so = work / SO_NAME
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
f"-I{gen}", f"-I{src}",
src / "dorknet_quest.cpp", src / "config.cpp",
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
"-llog", "-o", so)
need(so, "libdorknet.so")
# 4. decode
step("apktool decode")
if project.exists():
shutil.rmtree(project)
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
# 5. package rename + manifest + label
step(f"renaming package {OLD_PKG} -> {pkg_name}")
manifest = project / "AndroidManifest.xml"
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
if "android.intent.category.LAUNCHER" not in m:
m = m.replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/>'
'<category android:name="android.intent.category.LAUNCHER"/>')
manifest.write_text(m, encoding="utf-8", newline="")
# The launcher label comes from @string/app_name; set it in every locale.
step(f"naming the app '{app_name}'")
# XML entities for & < > " ' (the PowerShell version used
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
label = xml_escape(app_name, {'"': "&quot;", "'": "&apos;"})
for sx in (project / "res").rglob("strings.xml"):
s = sx.read_text(encoding="utf-8")
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
if n != s:
sx.write_text(n, encoding="utf-8", newline="")
# 6. inject
step(f"injecting {SO_NAME}")
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
# 7. build + align + sign
step("apktool build")
unsigned = work / "unsigned.apk"
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
if a.keystore:
keystore = need(a.keystore, "keystore")
else:
keystore = work / "dorknet-debug.keystore"
if not keystore.exists():
step("generating a fresh debug keystore (kept in the work dir, never committed)")
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
"-dname", "CN=DorkNet Quest, O=DorkNet")
step("zipalign")
aligned = work / "aligned.apk"
run(zipalign, "-f", "-p", "4", unsigned, aligned)
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
out_apk.parent.mkdir(parents=True, exist_ok=True)
step(f"apksigner -> {out_apk}")
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
"--out", out_apk, aligned)
capture(apksigner, "verify", "--print-certs", out_apk)
print()
print(f"DONE. Patched APK: {out_apk}")
print(f"Package: {pkg_name} versionCode: {ver_code}")
if a.obb:
print()
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
else:
print("Install it, then push the matching OBB to:")
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
print("(or pass --obb to bundle both into an installable .xapk)")
# ---- package --------------------------------------------------------------
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > {name}.
The app installs as {pkg} alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"""
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
"""Bundle a patched APK and its OBB into an .xapk plus installers.
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
The APK and OBB are stored uncompressed: they are already compressed, and
storing them keeps packaging and install fast."""
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
step("reading APK metadata")
badging = capture(aapt2, "dump", "badging", apk).splitlines()
def badge(pattern: str) -> str | None:
for line in badging:
mm = re.search(pattern, line)
if mm:
return mm.group(1)
return None
pkg = badge(r"^package: name='([^']+)'")
ver_code = badge(r"versionCode='([^']+)'")
ver_name = badge(r"versionName='([^']+)'")
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
label = badge(r"^application-label:'([^']*)'")
if not pkg or not ver_code:
fail("could not read package name / versionCode from the APK")
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
icon_path = None
with zipfile.ZipFile(apk) as z:
names = set(z.namelist())
if icons:
ref = icons[0][1]
if ref.endswith(".png") and ref in names:
icon_path = ref
else:
base = Path(ref).stem
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
for kind in ("mipmap", "drawable"):
cand = f"res/{kind}-{d}/{base}.png"
if icon_path is None and cand in names:
icon_path = cand
icon_bytes = z.read(icon_path) if icon_path else None
print(f" {pkg} version {ver_name} ({ver_code})")
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
out_dir.mkdir(parents=True, exist_ok=True)
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
xapk.unlink(missing_ok=True)
manifest = {
"xapk_version": 2,
"package_name": pkg,
"name": label or "DorkNet",
"version_code": ver_code,
"version_name": ver_name,
"min_sdk_version": min_sdk,
"target_sdk_version": tgt_sdk,
"permissions": perms,
"total_size": apk.stat().st_size + obb.stat().st_size,
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
}
if icon_bytes:
manifest["icon"] = "icon.png"
step(f"writing {xapk} (the OBB is large; this takes a minute)")
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
if icon_bytes:
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
step("adding installers")
for f in ("install.ps1", "install.cmd", "install.sh"):
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
(out_dir / "install.sh").chmod(0o755)
(out_dir / "README.txt").write_text(
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
print()
print(f"DONE. Package folder: {out_dir}")
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
print(f"Install with: {out_dir / 'install.cmd'}")
return out_dir
def cmd_package(a: argparse.Namespace) -> None:
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
resolve_build_tools(a.build_tools_dir))
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
b.set_defaults(func=cmd_build)
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
k.add_argument("--apk", required=True)
k.add_argument("--obb", required=True)
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
k.add_argument("--build-tools-dir")
k.set_defaults(func=cmd_package)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()
@@ -23,6 +23,7 @@ KEYS = {
"PhotonRegion": ("DN_PHOTON_REGION", "str"), "PhotonRegion": ("DN_PHOTON_REGION", "str"),
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"), "EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"), "AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
"LogHttp": ("DN_LOG_HTTP", "bool"), "LogHttp": ("DN_LOG_HTTP", "bool"),
"Debug": ("DN_DEBUG_HTTP", "bool"), "Debug": ("DN_DEBUG_HTTP", "bool"),
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"), "LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
-170
View File
@@ -1,170 +0,0 @@
<#
.SYNOPSIS
Bundle a patched Quest APK and its OBB into one installable package.
.DESCRIPTION
Writes a release folder holding:
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
install.cmd / install.ps1 one-command installer for Windows
install.sh one-command installer for macOS / Linux
README.txt what to do with it
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
it is already compressed, and storing it keeps packaging and install fast.
The output contains game binaries from your own store copy. Share it only
with people who own the game.
.PARAMETER Apk
The signed, patched APK produced by build.ps1.
.PARAMETER Obb
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
.PARAMETER OutDir
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[Parameter(Mandatory = $true)][string]$Obb,
[string]$OutDir,
[string]$BuildToolsDir
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
Need $Apk 'patched APK'
Need $Obb 'OBB'
$Apk = (Resolve-Path -LiteralPath $Apk).Path
$Obb = (Resolve-Path -LiteralPath $Obb).Path
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
Need $Aapt2 'aapt2'
# ---- read the APK's identity --------------------------------------------
Step 'reading APK metadata'
$badging = & $Aapt2 dump badging $Apk 2>$null
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
$pkg = Badge "^package: name='([^']+)'"
$verCode = Badge "versionCode='([^']+)'"
$verName = Badge "versionName='([^']+)'"
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
$label = Badge "^application-label:'([^']*)'"
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
# fall back to the same-named PNG from the densest mipmap/drawable folder.
$iconPath = $null
if ($iconRef) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
else {
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
foreach ($kind in 'mipmap', 'drawable') {
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
}
}
}
} finally { $src.Dispose() }
}
Write-Host " $pkg version $verName ($verCode)"
$obbName = "main.$verCode.$pkg.obb"
$obbEntry = "Android/obb/$pkg/$obbName"
$apkEntry = "$pkg.apk"
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
if (Test-Path $xapk) { Remove-Item -Force $xapk }
# ---- manifest.json -------------------------------------------------------
$manifest = [ordered]@{
xapk_version = 2
package_name = $pkg
name = if ($label) { $label } else { 'DorkNet' }
version_code = $verCode
version_name = $verName
min_sdk_version = $minSdk
target_sdk_version = $tgtSdk
permissions = $perms
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
}
if ($iconPath) { $manifest.icon = 'icon.png' }
# ---- write the XAPK ------------------------------------------------------
Step "writing $xapk (the OBB is large; this takes a minute)"
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
try {
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
if ($iconPath) {
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
try {
$ie = $src.GetEntry($iconPath)
if ($ie) {
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
}
} finally { $src.Dispose() }
}
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
} finally { $zip.Dispose() }
# ---- installers + readme -------------------------------------------------
Step 'adding installers'
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
}
$readme = @"
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > $($manifest.name).
The app installs as $pkg alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"@
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
Write-Host ""
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
Binary file not shown.
+6 -1
View File
@@ -57,6 +57,11 @@
#ifndef DN_ACCEPT_UNSIGNED_IMAGES #ifndef DN_ACCEPT_UNSIGNED_IMAGES
# define DN_ACCEPT_UNSIGNED_IMAGES true # define DN_ACCEPT_UNSIGNED_IMAGES true
#endif #endif
// A headset with no remembered account: false opens the sign-in page, true
// keeps Rec Room's behaviour of creating a new account automatically.
#ifndef DN_AUTO_CREATE_ACCOUNT
# define DN_AUTO_CREATE_ACCOUNT false
#endif
#ifndef DN_LOG_HTTP #ifndef DN_LOG_HTTP
# define DN_LOG_HTTP true # define DN_LOG_HTTP true
#endif #endif
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
char *photon_realtime, char *photon_voice, char *photon_chat, char *photon_realtime, char *photon_voice, char *photon_chat,
char *photon_server, size_t photon_server_cap, int *photon_port, char *photon_server, size_t photon_server_cap, int *photon_port,
char *photon_region, size_t photon_region_cap, char *photon_region, size_t photon_region_cap,
bool *tls_bypass, bool *accept_images, bool *tls_bypass, bool *accept_images, bool *auto_create_account,
bool *log_http, bool *debug_http, int *body_max); bool *log_http, bool *debug_http, int *body_max);
#endif // DORKNET_QUEST_CONFIG_H #endif // DORKNET_QUEST_CONFIG_H
+163 -4
View File
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
static char g_photon_region[16] = DN_PHOTON_REGION; static char g_photon_region[16] = DN_PHOTON_REGION;
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS; static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES; static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
static bool g_log_http = DN_LOG_HTTP; static bool g_log_http = DN_LOG_HTTP;
static bool g_debug_http = DN_DEBUG_HTTP; static bool g_debug_http = DN_DEBUG_HTTP;
static int g_body_max = DN_LOG_BODY_MAX; static int g_body_max = DN_LOG_BODY_MAX;
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME). // Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u; static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
static const unsigned kMsgUserGetUserProof = 578880643u; static const unsigned kMsgUserGetUserProof = 578880643u;
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
static const char *kPlaceholderNonce = "dorknet-no-platform-account"; static const char *kPlaceholderNonce = "dorknet-no-platform-account";
static const unsigned long long kNoPlatformUserId = 1ULL; static const unsigned long long kNoPlatformUserId = 1ULL;
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
static void *const kPlaceholderUser = (void *)0x2;
static const size_t kLogChunk = 1500; static const size_t kLogChunk = 1500;
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
typedef void *(*il2cpp_object_new_t)(void *klass); typedef void *(*il2cpp_object_new_t)(void *klass);
typedef void *(*il2cpp_string_new_t)(const char *str); typedef void *(*il2cpp_string_new_t)(const char *str);
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc); typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
typedef void *(*il2cpp_domain_get_t)();
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
typedef const char *(*il2cpp_image_get_name_t)(void *image);
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
static il2cpp_object_get_class_t il2cpp_object_get_class; static il2cpp_object_get_class_t il2cpp_object_get_class;
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name; static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
static il2cpp_object_new_t il2cpp_object_new; static il2cpp_object_new_t il2cpp_object_new;
static il2cpp_string_new_t il2cpp_string_new; static il2cpp_string_new_t il2cpp_string_new;
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke; static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
static il2cpp_domain_get_t il2cpp_domain_get;
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
static il2cpp_image_get_name_t il2cpp_image_get_name;
static il2cpp_class_from_name_t il2cpp_class_from_name;
static uintptr_t g_il2cpp_base; static uintptr_t g_il2cpp_base;
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
return (const unsigned char *)((char *)arr + kArrayDataOff); return (const unsigned char *)((char *)arr + kArrayDataOff);
} }
static bool invoke_void(void *obj, const char *method) {
if (!obj) return false;
void *cls = il2cpp_object_get_class(obj);
if (!cls) return false;
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
if (!m) return false;
void *exc = nullptr;
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
return !exc;
}
static void *find_class(const char *image_name, const char *ns, const char *name) {
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
size_t n = 0;
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
for (size_t i = 0; assemblies && i < n; i++) {
void *image = il2cpp_assembly_get_image(assemblies[i]);
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
}
return nullptr;
}
static void invoke_str(void *obj, const char *method, char *out, size_t cap) { static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
out[0] = '\0'; out[0] = '\0';
if (!obj) return; if (!obj) return;
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
static ovr_IsError_t orig_ovr_IsError; static ovr_IsError_t orig_ovr_IsError;
static ovr_GetType_t ovr_GetType; static ovr_GetType_t ovr_GetType;
static bool is_bypassed_type(unsigned t) {
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
t == kMsgUserGetLoggedInUser;
}
// The SDK's Message constructor checks IsError twice: on the message, and --
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
// message carries the platform's refusal too but doesn't report the outer
// type, so remember the inner handle of a bypassed message (same thread, the
// constructor asks about it immediately) and pass it as well.
static __thread void *t_bypassed_native;
static __thread unsigned t_bypassed_type;
static bool hk_ovr_IsError(void *m) { static bool hk_ovr_IsError(void *m) {
bool real = orig_ovr_IsError(m); bool real = orig_ovr_IsError(m);
if (!real || !ovr_GetType) return real; if (!real) return real;
unsigned t = ovr_GetType(m); if (m && m == t_bypassed_native) return false;
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false; if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
return real; return real;
} }
typedef void *(*ovr_GetNativeMessage_t)(void *m);
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
static void *hk_ovr_GetNativeMessage(void *m) {
void *native = orig_ovr_GetNativeMessage(m);
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
t_bypassed_type = t;
return native;
}
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
// Oculus user account". A headset that can't reach Meta gets a cached user
// and never hits this. Answer a refused request with a placeholder user whose
// ID is kNoPlatformUserId -- the same placeholder the server already treats
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
static void *(*orig_ovr_Message_GetUser)(void *m);
static void *hk_ovr_Message_GetUser(void *m) {
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
if (u) return u;
// The SDK reads the user from the inner native message
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
if (!logged_in_user) return u;
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
return kPlaceholderUser;
}
// Accessors the SDK's User model reads. Real users pass straight through; the
// placeholder gets empty strings and zero values.
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
static void *(*orig_ovr_GetUserProof)(void *m); static void *(*orig_ovr_GetUserProof)(void *m);
static void *hk_ovr_GetUserProof(void *m) { static void *hk_ovr_GetUserProof(void *m) {
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr; void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
static unsigned long long (*orig_ovr_User_GetID)(void *u); static unsigned long long (*orig_ovr_User_GetID)(void *u);
static unsigned long long hk_ovr_User_GetID(void *u) { static unsigned long long hk_ovr_User_GetID(void *u) {
if (u == kPlaceholderUser) return kNoPlatformUserId;
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0; unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
return real ? real : kNoPlatformUserId; return real ? real : kNoPlatformUserId;
} }
// ------------------------------------------------------------ title screen --
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
// the title screen downloads the headset's remembered accounts and logs into
// one; with none (null) it creates a brand-new account instead
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
// refuses that, the error handler shows the connection-error page and the
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
// does). ManuallyLogin carries the player into the game from there on its own,
// so the boot step gets a promise that never settles.
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
static void *new_pending_promise() {
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
// a fresh one stays pending.
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
if (!cls) return nullptr;
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
if (!ctor) return nullptr;
void *p = il2cpp_object_new(cls);
void *exc = nullptr;
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
return exc ? nullptr : p;
}
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
void *pending = new_pending_promise();
if (!pending || !invoke_void(thiz, "GoToLogin")) {
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
return orig_TitleAutoLogin(thiz, cached, method);
}
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
return pending;
}
// ----------------------------------------------------------------- init ------ // ----------------------------------------------------------------- init ------
static uintptr_t module_base(const char *name) { static uintptr_t module_base(const char *name) {
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
SYM(il2cpp_string_new) SYM(il2cpp_string_new)
SYM(il2cpp_runtime_invoke) SYM(il2cpp_runtime_invoke)
#undef SYM #undef SYM
// Optional: only the title-screen sign-in redirect needs these.
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
return true; return true;
} }
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
return; return;
} }
void *a; void *a;
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID"))) if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID); A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
if ((a = dlsym(ovr, "ovr_User_GetID"))) if ((a = dlsym(ovr, "ovr_User_GetID")))
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof); A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce"))) if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce); A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
DN_HOOK_USER(ovr_User_GetDisplayName)
DN_HOOK_USER(ovr_User_GetOculusID)
DN_HOOK_USER(ovr_User_GetImageUrl)
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
DN_HOOK_USER(ovr_User_GetInviteToken)
DN_HOOK_USER(ovr_User_GetPresence)
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
DN_HOOK_USER(ovr_User_GetPresenceStatus)
#undef DN_HOOK_USER
} }
static void *init_thread(void *) { static void *init_thread(void *) {
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
g_photon_realtime, g_photon_voice, g_photon_chat, g_photon_realtime, g_photon_voice, g_photon_chat,
g_photon_server, sizeof(g_photon_server), &g_photon_port, g_photon_server, sizeof(g_photon_server), &g_photon_port,
g_photon_region, sizeof(g_photon_region), g_photon_region, sizeof(g_photon_region),
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max); &g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s", LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host); DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr); arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
} }
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
if (g_accept_images) if (g_accept_images)
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr); arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
@@ -111,6 +111,12 @@
"length": 412, "length": 412,
"fingerprint": "0xb12b0044ac4f844f", "fingerprint": "0xb12b0044ac4f844f",
"why": "Log-only: prints the quit code and caller." "why": "Log-only: prints the quit code and caller."
},
"title_auto_login": {
"rva": 58892048,
"length": 668,
"fingerprint": "0x83bebb9a30983b33",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
} }
} }
} }
+6
View File
@@ -114,6 +114,12 @@
"class": "public class SessionManager ", "class": "public class SessionManager ",
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)", "method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
"why": "Log-only: prints the quit code and caller." "why": "Log-only: prints the quit code and caller."
},
{
"id": "title_auto_login",
"class": "public class TitleScreenFlowModel ",
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
} }
] ]
} }
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
using System.Text.Json;
using DorkNet.Server.Hubs;
using Microsoft.AspNetCore.SignalR;
namespace DorkNet.Server.Tests;
/// <summary>
/// Stand-in for the notify hub that records every push instead of sending it,
/// so tests can assert on the exact frames the game client would receive.
/// A frame is the hub method plus its single string argument, parsed back into
/// the <c>{Id, Msg}</c> envelope.
/// </summary>
public sealed class CapturingHubContext : IHubContext<NotifyHub>
{
private readonly ConcurrentQueue<SentFrame> _sent = new();
public IHubClients Clients => new CapturingClients(this);
public IGroupManager Groups { get; } = new NoopGroups();
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
/// order, optionally filtered by envelope <c>Id</c>.</summary>
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
{
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
public JsonElement Msg => Envelope.GetProperty("Msg");
}
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
{
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
new CapturingProxy(owner, target, excluded);
public IClientProxy All => Proxy("all");
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
Proxy($"group:{groupName}", excludedConnectionIds);
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
public IClientProxy User(string userId) => Proxy($"user:{userId}");
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
}
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
owner.Record(target, excluded, method, args);
return Task.CompletedTask;
}
}
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
{
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
}
private sealed class NoopGroups : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using DorkNet.Server.Controllers.Site;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
/// <summary>The site's downloads proxy: off without config, only on the site's
/// own host, serves only files the manifest lists, and passes Range through so
/// the browser Quest installer can read an .xapk in place.</summary>
public sealed class DownloadsControllerTests
{
private const string Apex = "dorknet.example.com";
private const string Manifest = """
{"releases":[{"id":"2023.03","title":"March 2023","files":[
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
""";
[Fact]
public async Task Without_a_base_url_the_manifest_is_404()
{
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
}
[Theory]
[InlineData("api.dorknet.example.com")]
[InlineData("cdn.dorknet.example.com")]
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
{
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
ctx.Request.Host = new HostString(host);
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
}
[Fact]
public void Only_listed_paths_are_found()
{
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
}
[Fact]
public async Task Unlisted_file_is_404_and_never_fetched()
{
var requested = new List<string>();
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
{
requested.Add(req.RequestUri!.AbsolutePath);
return Json(Manifest);
});
await controller.GetFile("index.html", CancellationToken.None);
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
Assert.Equal(["/manifest.json"], requested);
}
[Fact]
public async Task Range_is_forwarded_and_the_partial_response_streamed()
{
HttpRequestMessage? fileRequest = null;
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
{
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
fileRequest = req;
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
return res;
}, host: "downloads.example.com");
ctx.Request.Headers.Range = "bytes=2-5";
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
Assert.NotNull(fileRequest);
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
}
private static (DownloadsController, HttpContext) Create(
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["DorkNet:Downloads:BaseUrl"] = baseUrl,
["DorkNet:Downloads:Host"] = host,
}).Build();
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
var ctx = new DefaultHttpContext();
ctx.Request.Host = new HostString(Apex);
ctx.Response.Body = new MemoryStream();
var controller = new DownloadsController(service, new DomainConfig(Apex))
{
ControllerContext = new ControllerContext { HttpContext = ctx },
};
return (controller, ctx);
}
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
}
@@ -0,0 +1,198 @@
using System.Net.Http.Headers;
using System.Text.Json;
using DorkNet.Server.Controllers.API.Inventory;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace DorkNet.Server.Tests;
/// <summary>
/// Push frames checked against the 2023-03-21 client's notification readers
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
/// JSON string <c>{"Id":"&lt;key&gt;","Msg":{…}}</c>; the client looks the Id
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
/// wrong key or shape drops the push without any error.
/// </summary>
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
[Fact]
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, sender);
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
.EnsureSuccessStatusCode();
// RelationshipChanged "1" = the Relationship row from each side's view.
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
Assert.Equal("Notification", toTarget.Method);
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
// MessageReceived "2" — the "X sent you a friend request" inbox row.
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
using var targetClient = ApiClient(factory, target);
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
.EnsureSuccessStatusCode();
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
.Msg.GetProperty("RelationshipType").GetInt32());
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
}
[Fact]
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
{
var (factory, hub) = WithCapturingHub();
using var setup = ApiClient(factory);
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(factory, me);
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
}
[Fact]
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
var msg = frame.Msg;
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
}
[Theory]
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
[InlineData(NotificationService.LogoutReason.Banned, 4)]
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.LogoutAsync(43, reason);
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
var value = frame.Msg.GetProperty("Reason");
Assert.Equal(JsonValueKind.Number, value.ValueKind);
Assert.Equal(wire, value.GetInt32());
}
[Fact]
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
{
var (factory, hub) = WithCapturingHub();
await using var scope = factory.Services.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
excludeConnectionIds: ["self-conn", "friend-conn"]);
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
}
[Theory]
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
[InlineData("""[6,7]""", new long[] { 6, 7 })]
[InlineData("""{"PlayerIds":[]}""", new long[0])]
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
{
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
}
[Fact]
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
{
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
}
[Fact]
public void Consumable_mapping_push_has_exactly_the_clients_keys()
{
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
{
Id = 9,
ItemSlug = "some-consumable",
Quantity = 3,
}));
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
Assert.Equal(
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
keys);
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
}
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
{
var hub = new CapturingHubContext();
var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IHubContext<NotifyHub>>();
services.AddSingleton<IHubContext<NotifyHub>>(hub);
}));
return (factory, hub);
}
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
{
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
if (session is not null)
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
return client;
}
}
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
var text = await resp.Content.ReadAsStringAsync(); var text = await resp.Content.ReadAsStringAsync();
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64(); var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
await using var scope = _factory.Services.CreateAsyncScope(); await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>(); var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
+31 -7
View File
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace DorkNet.Server.Tests; namespace DorkNet.Server.Tests;
/// <summary> /// <summary>
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the /// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns /// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares /// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
/// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>, /// <c>Task&lt;FGCPNAACHIK&gt; GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
/// with the route literal on :4802). /// with the route literal on :4802). A bare room with no <c>success</c> is
/// rejected with an empty error.
/// ///
/// So the two responses must be shape-identical. When they drift, the server /// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
/// still answers 200 and the client throws while reading it — and because the /// still answers 200 and the client throws while reading it — and because the
/// clone promise's exception is never observed, it surfaces from the finalizer /// clone promise's exception is never observed, it surfaces from the finalizer
/// thread seconds later as a bare "Failed to copy room: Exception of type /// thread seconds later as a bare "Failed to copy room: Exception of type
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
} }
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}"); var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
// The client reads clone through the {success, value, error} envelope.
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
var cloneBody = cloneEnvelope.GetProperty("value");
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone", var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])])); new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
var cloneId = clone.GetProperty("RoomId").GetInt64(); var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
await using (var scope = factory.Services.CreateAsyncScope()) await using (var scope = factory.Services.CreateAsyncScope())
{ {
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}")); Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
} }
[Fact]
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
{
using var client = Client();
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", session.AccessToken);
// No room has this id, so the clone is refused; the client shows
// `error`, which a bare 404 would leave empty.
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
Assert.False(envelope.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
}
private static async Task<JsonElement> ReadJsonAsync( private static async Task<JsonElement> ReadJsonAsync(
HttpClient client, HttpMethod method, string path, HttpContent? content = null) HttpClient client, HttpMethod method, string path, HttpContent? content = null)
{ {
+4 -2
View File
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}"); Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
using var json = JsonDocument.Parse(text); using var json = JsonDocument.Parse(text);
var root = json.RootElement; // {success, value, error} envelope around the full room-details
// Full room-details shape (FGCPNAACHIK), not a status wrapper. // shape (FGCPNAACHIK).
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
var root = json.RootElement.GetProperty("value");
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" }) foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}"); Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
return root.GetProperty("RoomId").GetInt64(); return root.GetProperty("RoomId").GetInt64();
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
await db.SaveChangesAsync(); await db.SaveChangesAsync();
foreach (var row in created) foreach (var row in created)
{ {
// The full gift package, as GET api/avatar/v2/gifts lists it: the
// client consumes a gift by its Id, which the old hand-built
// payload didn't carry.
await notifications.NotifyAsync(row.RecipientPlayerId, await notifications.NotifyAsync(row.RecipientPlayerId,
PushNotificationId.GiftPackageReceived, PushNotificationId.GiftPackageReceived,
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message }); DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
} }
return Ok(new { Success = true, Sent = created.Count }); return Ok(new { Success = true, Sent = created.Count });
@@ -1295,39 +1295,14 @@ public class ImagesController(
.Select(s => long.TryParse(s, out var v) ? v : 0L) .Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0); .Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player) /// <summary>Push the new profile image through the same account pushes
{ /// as every other profile edit: the owner's SelfAccountUpdate and an
var account = new DorkNet.Models.Auth.RecNetAccount /// AccountUpdate to everyone. A hand-built account here left out the
{ /// emoji, banner, pronouns, identity flags and creation date, so an image
AccountId = (int)player.Id, /// change wiped them from the owner's cached account, and nobody else saw
RawUsername = player.Username, /// the new picture.</summary>
Username = player.Username, private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
DisplayName = player.DisplayName ?? player.Username, notifications.AccountChanged(player);
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static string NormaliseTaggedIds(string? csv, long exceptId) private static string NormaliseTaggedIds(string? csv, long exceptId)
{ {
@@ -1095,11 +1095,9 @@ public class InventionsController(
}); });
inv.CheerCount += 1; inv.CheerCount += 1;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// No push: InventionModerationStateChanged ("100") means "your
if (inv.CreatorPlayerId != pid) // inventions' moderation state changed" and takes no payload; sent
await notifications.NotifyAsync(inv.CreatorPlayerId, // on every cheer it only made the creator refetch for nothing.
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
} }
else if (!req.Cheer && existing is not null) else if (!req.Cheer && existing is not null)
{ {
@@ -279,11 +279,11 @@ public class InventoryController(
if (row.Quantity == 0) db.PlayerInventory.Remove(row); if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// A consume always changes an existing mapping, so it is always
// ConsumableMappingRemoved (71) carrying the count that is left;
// Added (70) would make the client treat it as a new stack.
await notifications.NotifyAsync(pid, await notifications.NotifyAsync(pid,
row.Quantity > 0 PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity }); return Ok(new { row.Id, Count = row.Quantity });
} }
@@ -332,8 +332,7 @@ public class InventoryController(
if (source.Quantity == 0) db.PlayerInventory.Remove(source); if (source.Quantity == 0) db.PlayerInventory.Remove(source);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.NotifyAsync(recipient, await notifications.NotifyAsync(recipient,
PushNotificationId.ConsumableMappingAdded, PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
new { target.Id, target.ItemSlug, target.Quantity });
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
} }
@@ -449,6 +448,26 @@ public class InventoryController(
}; };
} }
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
/// as a mapping with no item and a count of 0.</summary>
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
{
inv.Id,
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
Count = inv.Quantity,
InitialCount = inv.Quantity, // the original purchase count isn't tracked
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
inv.IsActive,
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
// what the inventory list says.
IsTransferable = false,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item) private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{ {
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
@@ -152,8 +152,6 @@ public class InGameModerationController(
foreach (var target in ids) foreach (var target in ids)
{ {
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects:
// Msg.Reason must be a ModerationBlockDetail object.
await notifications.KickPlayerAsync(target, "Instant kick by moderator."); await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
} }
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
@@ -249,21 +247,14 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}", "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason); caller, target, reason, p.BannedUntil, displayReason);
// Push the ban as a ModerationKick (22) with IsBan set, NOT as // Sign the banned player out with Logout "6" (Reason Banned). Not
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager // ModerationRoomBan (24): the 2023-03-21 client registers no handler
// registers only 22, "ModerationUnkick" and 23 // for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler // 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
// for 24, so that push landed nowhere and the banned player kept playing // ModerationKick either: a kick only sends the player to their dorm
// until their next login. The 22 handler reads Msg.Reason as a // and they kept playing until their next login. The ban check refuses
// ModerationBlockDetail, which ModerationKickPayload already produces. // that next login.
await notifications.KickPlayerAsync( await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
target,
// `reason` is the numeric report category; DisplayReason is the
// moderator's text and is what the ban dialog shows.
string.IsNullOrWhiteSpace(displayReason)
? "You have been banned from this room."
: displayReason.Trim(),
isBan: true);
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
public class PlayerEventsController( public class PlayerEventsController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
PlayerPresenceService presence) : ControllerBase PlayerPresenceService presence) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
@@ -817,7 +818,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
await ApplyBodyToExtrasAsync(extras); await ApplyBodyToExtrasAsync(extras);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -901,10 +902,7 @@ public class PlayerEventsController(
// Tell the creator someone RSVP'd. They can ignore but the // Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card. // watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me) await NotifyResponseChangedAsync(ev, row);
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
// Left as the historical shape on purpose: this route is 2020-only // Left as the historical shape on purpose: this route is 2020-only
// (the 2023 client GETs .../responses and POSTs v1/respond instead), // (the 2023 client GETs .../responses and POSTs v1/respond instead),
// so there is no 2023 DTO to match here. // so there is no 2023 DTO to match here.
@@ -947,10 +945,15 @@ public class PlayerEventsController(
var existing = await db.PlayerEventResponses var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null) if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }); {
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
db.PlayerEventResponses.Add(existing);
}
else else
existing.Response = response; existing.Response = response;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
await NotifyResponseChangedAsync(responded, existing);
return RespondResult(legacyForm, 0 /*Success*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -968,9 +971,18 @@ public class PlayerEventsController(
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
await db.PlayerEventResponses var deleted = await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt) .Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
if (deleted > 0)
{
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
if (creator is long c && c != Me)
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
new { PlayerEventId = evt });
}
return RespondResult(legacyForm, 0 /*Success*/); return RespondResult(legacyForm, 0 /*Success*/);
} }
@@ -993,6 +1005,7 @@ public class PlayerEventsController(
// has to echo the event that was deleted. // has to echo the event that was deleted.
var extras = (await LoadExtrasAsync(new[] { evt }))[id]; var extras = (await LoadExtrasAsync(new[] { evt }))[id];
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id); var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
var notify = await AttendeeIdsAsync(evt);
db.PlayerEvents.Remove(evt); db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
@@ -1000,6 +1013,7 @@ public class PlayerEventsController(
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id)) .Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventDeletedAsync(id, notify);
return Ok(Wrapper(evt, extras, attendees)); return Ok(Wrapper(evt, extras, attendees));
} }
@@ -1021,6 +1035,7 @@ public class PlayerEventsController(
evt.Title = Clamp(value.Trim(), 128); evt.Title = Clamp(value.Trim(), 128);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1034,6 +1049,7 @@ public class PlayerEventsController(
evt.Description = Clamp(value.Trim(), 2000); evt.Description = Clamp(value.Trim(), 2000);
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1056,6 +1072,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId"); extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1072,6 +1089,7 @@ public class PlayerEventsController(
if (start is null && end is null) return BadRequest("missing_time"); if (start is null && end is null) return BadRequest("missing_time");
evt.UpdatedAt = DateTime.UtcNow; evt.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1085,6 +1103,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.ImageName = Clamp(value.Trim(), 128); extras.ImageName = Clamp(value.Trim(), 128);
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1099,6 +1118,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Accessibility = accessibility; extras.Accessibility = accessibility;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1120,6 +1140,7 @@ public class PlayerEventsController(
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
extras.Tags = tags; extras.Tags = tags;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1158,6 +1179,7 @@ public class PlayerEventsController(
?? extras.CanRequestBroadcastPermissions; ?? extras.CanRequestBroadcastPermissions;
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1188,6 +1210,7 @@ public class PlayerEventsController(
} }
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
@@ -1197,6 +1220,7 @@ public class PlayerEventsController(
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound(); if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid(); if (ev.CreatorPlayerId != Me) return Forbid();
var notify = await AttendeeIdsAsync(ev);
db.PlayerEvents.Remove(ev); db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade // Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually. // configured here so do it manually.
@@ -1206,6 +1230,7 @@ public class PlayerEventsController(
await db.PlayerSettings await db.PlayerSettings
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId)) .Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await NotifyEventDeletedAsync(eventId, notify);
return Ok(); return Ok();
} }
@@ -1244,6 +1269,7 @@ public class PlayerEventsController(
var failed = new List<object>(); var failed = new List<object>();
var sender = Me; var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
var eventWire = await ToWireOneAsync(ev);
foreach (var rid in req.InvitedPlayerIds.Distinct()) foreach (var rid in req.InvitedPlayerIds.Distinct())
{ {
var exists = await db.Players.AnyAsync(p => p.Id == rid); var exists = await db.Players.AnyAsync(p => p.Id == rid);
@@ -1252,17 +1278,15 @@ public class PlayerEventsController(
failed.Add(new { InvitedPlayerId = rid, Result = 1 }); failed.Add(new { InvitedPlayerId = rid, Result = 1 });
continue; continue;
} }
db.Messages.Add(new MessageEntity // The invite as a text message from the host, stored and pushed
{ // live (it used to be stored only, so it appeared after a relog).
SenderPlayerId = sender, await systemNotifications.SendAsync(rid,
RecipientPlayerId = rid, SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
Body = inviteText, // And the event itself, so it shows in the invitee's event list:
}); // PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
await notifications.NotifyAsync(rid, // the same object GET api/playerevents/v2/{id} returns.
PushNotificationId.PlayerEventCreated, await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
} }
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 }); return Ok(new { FailedInvites = failed, Result = 0 });
} }
@@ -1289,21 +1313,56 @@ public class PlayerEventsController(
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId"); "BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
await SaveExtrasAsync(evt, extras); await SaveExtrasAsync(evt, extras);
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge // Attendees' event cards key off BroadcastingRoomInstanceId, so send
// them to re-fetch instead of sending a chat message. // them the updated event.
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged); await NotifyEventUpdatedAsync(evt);
return Ok(await WrapperAsync(evt)); return Ok(await WrapperAsync(evt));
} }
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) /// <summary>Everyone who answered the event with anything but NotGoing,
{ /// except its creator.</summary>
var recipients = await db.PlayerEventResponses private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
(await db.PlayerEventResponses
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
.Select(r => r.PlayerId) .Select(r => r.PlayerId)
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync())
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) .Where(p => p != evt.CreatorPlayerId)
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); .ToList();
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
/// cached event with an empty name and default times, and the
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
/// at all.</summary>
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
{
var recipients = await AttendeeIdsAsync(evt);
if (recipients.Count == 0) return;
var wire = await ToWireOneAsync(evt);
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
}
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
/// were attending, collected before the rows were removed.</summary>
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
{
foreach (var playerId in recipients)
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
new { PlayerEventId = eventId });
}
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
/// object the event and response REST routes return.</summary>
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
{
if (evt.CreatorPlayerId == response.PlayerId) return;
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
} }
// ── Report ─────────────────────────────────────────────────────────── // ── Report ───────────────────────────────────────────────────────────
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase public class RelationshipsController(
DorkNetDbContext db,
NotificationService notifications,
SystemNotificationService systemNotifications,
ServerSettingsService settings) : ControllerBase
{ {
// RelationshipType — int values match Relationship.RelationshipType // RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs. // in the patched dump.cs.
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me); await PushPairAsync(me, id);
// The "X sent you a friend request" row in the target's notifications.
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
return Ok(BuildRelationship(row, me)); return Ok(BuildRelationship(row, me));
} }
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
rel.UpdatedAt = DateTime.UtcNow; rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me); await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(rel, me)); return Ok(BuildRelationship(rel, me));
} }
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
db.Relationships.RemoveRange(removable); db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me); await PushPairAsync(me, id);
var remaining = pair.Except(removable).ToList(); var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining)); return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
existing.UpdatedAt = DateTime.UtcNow; existing.UpdatedAt = DateTime.UtcNow;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await PushPairAsync(me, id);
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
return Ok(BuildRelationship(existing, me)); return Ok(BuildRelationship(existing, me));
} }
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
if (ignored is bool i) row.Ignored = i; if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// The HTTP reply is only an ack; the updated row reaches the client
// through RelationshipChanged. A preference is the caller's own bit,
// so only the caller is told.
await PushPairAsync(me, otherId, notifyOther: false);
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty }); return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
} }
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// ── helpers ────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
/// <paramref name="other"/>) after a change, each side getting its OWN view
/// of the relationship. Without this a friend request, accept or unfriend
/// only showed up after the friends list was refetched, and the client
/// never re-subscribed to the new friend's presence.</summary>
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
{
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me))
.ToListAsync();
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
if (notifyOther)
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
}
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
/// <c>v2/get</c> would list it.</summary>
private static Dictionary<string, object> ViewOf(
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
{
if (globalFriends
&& other != RelationshipQueries.SystemAccountId
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
}
private async Task<HashSet<long>> FriendIdsAsync(long playerId) private async Task<HashSet<long>> FriendIdsAsync(long playerId)
{ {
var rows = await db.Relationships var rows = await db.Relationships
@@ -1375,13 +1375,13 @@ public class RoomsModerationController(
{ {
var newName = await ReadCloneNameAsync(); var newName = await ReadCloneNameAsync();
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound(); if (source is null) return CloneResult(null, "That room doesn't exist.");
// RRO/AG rooms are first-party templates meant to be cloned — the // RRO/AG rooms are first-party templates meant to be cloned — the
// room-creation "base room" picker starts a new build by cloning one // room-creation "base room" picker starts a new build by cloning one
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false, // (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
// so without the IsAGRoom exception every base-room create 403s. // so without the IsAGRoom exception every base-room create is refused.
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me) if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
return Forbid(); return CloneResult(null, "You can't clone this room!");
if (string.IsNullOrWhiteSpace(newName)) if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy"; newName = $"{source.Name}Copy";
@@ -1517,17 +1517,22 @@ public class RoomsModerationController(
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me); await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // The value is the FULL room-details object (FGCPNAACHIK), the exact
// deserializes the response as the FULL room-details object // shape GET rooms/{id} returns for the 2023 client — not the slim
// (FGCPNAACHIK — the same type get-by-id / rename return on the // list-room shape, which made the strict reader fail.
// roomserver host), NOT the slim list-room shape. This is the return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
// roomserver-family path (rooms/{id}/clone), so use
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
// made the strict reader fail → "Failed to copy room: Failed to clone
// room".
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
} }
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
/// clone through the same RecNet rooms helper as "save room" and "restore
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
/// has no <c>success</c>, so the client rejects it with an empty error:
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
/// though the room was created. Refusals are 200 with success=false and a
/// message the client shows.</summary>
private OkObjectResult CloneResult(object? room, string error = "") =>
Ok(new { success = error.Length == 0, value = room, error });
/// <summary>Give a freshly-cloned room its OWN copy of the source's save /// <summary>Give a freshly-cloned room its OWN copy of the source's save
/// data instead of pointing its scenes at the source's blob. /// data instead of pointing its scenes at the source's blob.
/// ///
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes. // Push the buyer's balance update so their wallet UI refreshes.
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
// is the absolute new balance, Delta only feeds the client's log.
await notifications.NotifyAsync(pid, await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase, PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug }); new
{
BalanceAddType = 0,
Delta = (int)-item.Price,
Balance = newBalance,
Platform = 0,
req.CurrencyType,
});
if (isGift) if (isGift)
{ {
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts // GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself // and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory. // appears next time the recipient opens their inventory.
// GiftPackageReceived "30" carries the client's full gift package
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
// platform), so the box could count as spawning nowhere.
await notifications.NotifyAsync(recipient, await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived, PushNotificationId.GiftPackageReceived,
new DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty,
});
} }
// For gift purchases the buyer doesn't get a box — their flow // For gift purchases the buyer doesn't get a box — their flow
@@ -154,7 +154,7 @@ public class AccountsController(
playerId, username, summary); playerId, username, summary);
} }
await notifications.KickPlayerAsync(playerId, "This account has been deleted."); await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
presence.Clear(playerId); presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); return Ok(new RecNetResult { Success = true, Error = string.Empty });
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
public class AdminController( public class AdminController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
LevelService level, LevelService level,
CommunityBoardService communityBoard, CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer, RoomBlobNormalizerService roomBlobNormalizer,
@@ -160,7 +161,9 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? ""); await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned."); // A ban must sign the player out (a kick only sends them to the dorm);
// the next login is refused by the ban check.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
playerPresence.Clear(id); playerPresence.Clear(id);
return Ok(new { player.Id, player.BannedUntil }); return Ok(new { player.Id, player.BannedUntil });
@@ -243,7 +246,7 @@ public class AdminController(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin."); await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
playerPresence.Clear(id); playerPresence.Clear(id);
return Ok(new return Ok(new
@@ -1570,10 +1573,13 @@ public class AdminController(
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
/// <summary>Push fresh RoomDetails after role/owner mutations. The /// <summary>Push fresh room details after role/owner mutations, to everyone
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts, /// in the room plus the player whose role changed. The client caches the
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom /// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
/// stores a replacement.</summary> /// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
/// wrapper parsed as RoomId 0 with no roles, so role changes never
/// reached anyone until they re-entered the room.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason) private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1587,7 +1593,7 @@ public class AdminController(
.ToListAsync(); .ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles); .BuildRoomServerDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds() var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId) .Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
@@ -1739,7 +1745,7 @@ public class AdminController(
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason); await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
playerPresence.Clear(pid); playerPresence.Clear(pid);
kicked.Add(pid); kicked.Add(pid);
} }
@@ -2396,7 +2402,9 @@ public class AdminController(
var reason = string.IsNullOrWhiteSpace(body?.Reason) var reason = string.IsNullOrWhiteSpace(body?.Reason)
? "Kicked by an admin." ? "Kicked by an admin."
: body!.Reason!.Trim(); : body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason); // The room instance lets the client announce the kick to that room.
await notifications.KickPlayerAsync(id, reason,
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
playerPresence.Clear(id); playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -3328,10 +3336,11 @@ public class AdminController(
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, ""); await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get // Log out any session still on the old password; the client has to
// dropped — the watch re-authenticates with the new password. // sign in again with the new one. The reason must be the numeric
await notifications.NotifyAsync(id, PushNotificationId.Logout, // enum: the string "AdminPasswordReset" failed the client's parse and
new { Reason = "AdminPasswordReset" }); // the push was dropped.
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
return Ok(new { id }); return Ok(new { id });
} }
@@ -3500,22 +3509,26 @@ public class AdminController(
playerPresence.SetRoom(id, roomInstance); playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the // The move itself comes from the presence set above: the client's
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance // next heartbeat sees it is out of sync and joins that instance.
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom // RoomInstanceUpdate only refreshes the client's cached copy of the
// refreshes the watch's RoomDetails cache so the room-info // instance, and RoomUpdate its cached room details, which must be
// panel doesn't show stale data after the warp. // the full room (FGCPNAACHIK) the roomserver GET returns.
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance); PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
var forceJoinScenes = await db.RoomScenes
.Where(sc => sc.RoomId == room.Id)
.OrderBy(sc => sc.OrderIndex)
.ToListAsync();
// With every role grant: the client replaces its cached room with
// this, so a payload without them would drop everyone's roles.
var forceJoinRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom, PushNotificationId.SubscriptionUpdateRoom,
new DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
{ room, forceJoinScenes, roles: forceJoinRoles));
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id, await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}"); $"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
@@ -3532,20 +3545,46 @@ public class AdminController(
// ── System ─────────────────────────────────────────────────────────── // ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message); public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
/// <summary>Push a server-maintenance broadcast to every connected /// <summary>Broadcast to every connected player.
/// player. The MOTD config value is changed via appsettings.json /// <list type="bullet">
/// (server restart required); this is for ad-hoc announcements.</summary> /// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
/// the system account) in each online player's notifications —
/// the client's channel for free text from the server.</item>
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
/// (ServerMaintenance "25", which reads only
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
/// </list>
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
/// client never reads: the text was never shown, and StartsInMinutes 0
/// cancelled any countdown instead.</summary>
[HttpPost("broadcast")] [HttpPost("broadcast")]
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body) public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
{ {
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message"); var message = body.Message?.Trim() ?? string.Empty;
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance, if (message.Length == 0 && body.StartsInMinutes is null)
new { Message = body.Message }); return BadRequest("empty message");
await LogAsync("broadcast", "system", 0, body.Message); if (body.StartsInMinutes is < 0)
return BadRequest("StartsInMinutes must be 0 or more");
var recipients = onlinePresence.OnlinePlayerIds().ToList();
if (message.Length > 0)
{
foreach (var pid in recipients)
await systemNotifications.SendAsync(pid,
SystemNotificationService.MessageType.CoachMessage,
fromPlayerId: PlayerService.SystemAccountId,
data: message);
}
if (body.StartsInMinutes is int minutes)
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { StartsInMinutes = minutes });
await LogAsync("broadcast", "system", 0,
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok(new { recipients = recipients.Count });
} }
// ── Store catalog management ───────────────────────────────────────── // ── Store catalog management ─────────────────────────────────────────
@@ -460,6 +460,20 @@ public class ChatController(
var removed = await db.ChatThreadMembers var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
if (removed > 0)
{
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
// the handler only reads ChatThreadId and SenderPlayerId (the
// player who left) and drops them from that thread's members.
// Sent to the remaining members, whose member lists otherwise
// stayed stale, and to the leaver's own other sessions.
var thread = await EnsureThreadRowAsync(chatThreadId);
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
var recipients = await ThreadRecipientsAsync(chatThreadId);
recipients.Add(Me);
foreach (var rid in recipients.Distinct())
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
}
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
} }
@@ -39,6 +39,7 @@ public class GoToController(
RoomService rooms, RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db, DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
SystemNotificationService systemNotifications,
JoinTimeoutService joinTimeouts, JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout, PresenceBroadcastService presenceFanout,
@@ -877,11 +878,15 @@ public class GoToController(
logger.LogWarning( logger.LogWarning(
"[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting",
callerId, playerId, theirRoom.RoomInstanceId); callerId, playerId, theirRoom.RoomInstanceId);
// Notify the OWNER that someone tried to follow them in. // Tell the OWNER someone tried to follow them in, as the
// Helps surface the rejection so the owner knows there's // client's own "wants to join" message (RequestGameInvite,
// an interested party — they can choose to send an invite. // from the caller), so they can send an invite. At most once
await notifications.PrivateInstanceJoinRejected( // per requester, owner and instance per cooldown, so retrying
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0); // doesn't fill the owner's inbox.
if (callerId is long requester
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
await systemNotifications.SendAsync(playerId,
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
return Ok(new MatchmakingResponseDto return Ok(new MatchmakingResponseDto
{ {
ErrorCode = 26, // RoomInstanceIsPrivate ErrorCode = 26, // RoomInstanceIsPrivate
@@ -1668,3 +1673,35 @@ public class RoomInstanceDto
}; };
} }
} }
/// <summary>Rate limit for "wants to join" messages: one per (requester,
/// owner, room instance) per <see cref="Window"/>. Later retries and other
/// instances still go through. Per replica, which is enough to stop a
/// retry loop from flooding an inbox.</summary>
internal static class JoinRequestCooldown
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
/// <summary>True when no request for this key was sent within the window
/// (and records this one); false while it is cooling down.</summary>
public static bool TryStart(long requester, long owner, long roomInstanceId)
{
var now = DateTime.UtcNow;
if (Last.Count > 10_000)
foreach (var (key, at) in Last)
if (now - at > Window) Last.TryRemove(key, out _);
var k = (requester, owner, roomInstanceId);
while (true)
{
if (!Last.TryGetValue(k, out var prev))
{
if (Last.TryAdd(k, now)) return true;
continue;
}
if (now - prev < Window) return false;
if (Last.TryUpdate(k, now, prev)) return true;
}
}
}
@@ -69,17 +69,15 @@ public class MatchPlayerController(
if (replaced) if (replaced)
{ {
log.LogWarning( log.LogWarning(
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push", "[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
logScope, playerId, takeOverExclusiveSession); logScope, playerId, takeOverExclusiveSession);
// The new session hasn't opened its SignalR socket yet // The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's // (negotiate fires AFTER /player/login on the boot
// boot sequence — verified in the live trace), so the // sequence — verified in the live trace), so the Logout
// ModerationKick push hits only the OLDER session's // push (Reason SessionTakeover) reaches only the OLDER
// existing connections. They'll show the "you've been // session, which shows the client's own "signed in
// kicked" dialog and gracefully disconnect via the // elsewhere" message and returns to the boot scene.
// watch's LocalModerationKickSelf path. await notifications.LogoutStaleSession(playerId);
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
} }
} }
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Site;
/// <summary>
/// The client builds CI publishes, for the site's Downloads page and the
/// Quest page's "latest build" install. Files are streamed from the downloads
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
/// installer can read an .xapk's zip directory and entries without fetching
/// the whole file. Only files the manifest lists are served.
///
/// The builds contain Rec Room's own game files, so the routes answer only on
/// the site's own host (the apex) and 404 on every other host the server
/// accepts: the game's API/CDN subdomains are typically left open for the
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/site/v1/downloads")]
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
{
private bool Available => downloads.IsConfigured
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
/// <summary>GET /api/site/v1/downloads — the manifest as published
/// (snake_case keys), or 404 when downloads aren't set up or the request
/// isn't for the site's host.</summary>
[HttpGet]
public async Task<IActionResult> GetManifest(CancellationToken ct)
{
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
var manifest = await downloads.GetManifestAsync(ct);
Response.Headers.CacheControl = "no-cache";
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
.ToJsonString(), "application/json");
}
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
/// streamed; honours Range (206).</summary>
[HttpGet("files/{**path}")]
public async Task GetFile(string path, CancellationToken ct)
{
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
var manifest = await downloads.GetManifestAsync(ct);
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
Response.StatusCode = (int)upstream.StatusCode;
if (!upstream.IsSuccessStatusCode) return;
var content = upstream.Content.Headers;
if (content.ContentLength is long length) Response.ContentLength = length;
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
Response.Headers.AcceptRanges = "bytes";
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
Response.Headers.CacheControl = "private, no-cache";
Response.ContentType = "application/octet-stream";
var name = (string?)entry["name"] ?? Path.GetFileName(path);
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
await body.CopyToAsync(Response.Body, 1 << 16, ct);
}
}
+110 -37
View File
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only /// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
/// reflected this replica's connections. /// reflected this replica's connections.
/// ///
/// Hub methods invoked by the client (verified by string-literal scan /// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8): /// OMHPBIEHOHN):
/// "Subscribe" - generic topic subscribe /// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
/// "Unsubscribe" - generic topic unsubscribe /// "SubscribeTo" - {Channels}: always empty in this build
/// "SubscribeToPlayers" - subscribe to per-player events (used by /// "UnsubscribeFrom" - {Channels}: always empty in this build
/// RecNet.NotificationManager / SubscribeToPlayers) /// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
/// All accept JsonElement so we don't lock ourselves into a parameter /// complete set. All accept JsonElement and tolerate casing differences.
/// schema we haven't verified yet, and return Task so the client's ///
/// awaited Invoke completes cleanly. /// The client reconnects at most 3 times per login (its attempt counter is
/// never reset), so the server must not drop healthy connections.
/// </summary> /// </summary>
[Authorize] [Authorize]
public class NotifyHub( public class NotifyHub(
@@ -65,28 +66,25 @@ public class NotifyHub(
// Instead: at the moment the SECOND session's WebSocket // Instead: at the moment the SECOND session's WebSocket
// arrives, take a snapshot of the EXISTING connection ids // arrives, take a snapshot of the EXISTING connection ids
// (which still belong to the original session — we haven't // (which still belong to the original session — we haven't
// joined the group yet) and push a targeted ModerationKick // joined the group yet) and send those ids a Logout with
// to those ids. The watch's OnModerationKick handler at // Reason SessionTakeover. The client shows its own "signed in
// PlayerReporting.txt:1857 shows the standard moderation // elsewhere" message and returns to the boot scene. (A
// dialog and routes through LocalModerationKickSelf, the // ModerationKick only sent the old session to its dorm, still
// same graceful disconnect path the original Rec Room used // signed in.)
// for real moderator kicks.
// //
// Targeting specific connection ids (not the group) means // Targeting specific connection ids (not the group) means
// the new connection — about to join the group on the next // the new connection — about to join the group on the next
// line — never receives the kick, even with Redis backplane // line — never receives it, even with Redis backplane
// routing across replicas. // routing across replicas.
var existing = presence.GetConnections(id); var existing = presence.GetConnections(id);
if (existing.Count > 0) if (existing.Count > 0)
{ {
logger.LogWarning( logger.LogWarning(
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)", "[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
id, Context.ConnectionId, existing.Count); id, Context.ConnectionId, existing.Count);
try try
{ {
await notifications.KickConnectionsAsync( await notifications.LogoutConnectionsAsync(existing);
existing,
"Your account was signed in on another device.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,36 +154,111 @@ public class NotifyHub(
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
public Task SubscribeToPlayers(JsonElement payload) /// <summary>SignalR group a connection joins to receive one player's
/// presence. Only presence is sent here — a subscriber must never receive
/// the subject's own pushes (DMs, gifts, …).</summary>
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
private const string SubscriptionsKey = "presence-subscriptions";
/// <summary>Most players one connection may subscribe to. The client sends
/// its relationships plus the players around it (dozens); anything far
/// past that is not a real client, and each id costs a group membership
/// on the backplane.</summary>
public const int MaxPresenceSubscriptions = 5000;
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
/// List&lt;int&gt;})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
/// of players this client wants presence for — its relationships, the
/// players around it, and itself. Sent on every (re)connect and whenever
/// the set changes, always as the complete set, so it REPLACES the
/// connection's subscriptions. Presence for those players is then pushed
/// to <see cref="PresenceGroupForPlayer"/> by
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
/// looking at (people in the room, an open profile) stay current too.
///
/// The caller's own id is skipped: a presence frame about the local player
/// is applied as a correction and moves them. An unreadable payload
/// leaves the current subscriptions alone rather than clearing them.
/// Groups live per connection on its own replica, are routed cluster-wide
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
public async Task SubscribeToPlayers(JsonElement payload)
{
var me = TryGetPlayerId();
if (!TryReadPlayerIds(payload, out var requested))
{
logger.LogWarning(
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
me, Context.ConnectionId, RawPayload(payload));
return;
}
if (me is long self) requested.Remove(self);
if (requested.Count > MaxPresenceSubscriptions)
{
logger.LogWarning(
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
return;
}
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
? set
: new HashSet<long>();
var added = requested.Except(current).ToList();
var removed = current.Except(requested).ToList();
foreach (var id in added)
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
foreach (var id in removed)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
Context.Items[SubscriptionsKey] = requested;
logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
}
/// <summary><c>SubscribeTo({Channels: List&lt;string&gt;})</c>. The
/// 2023-03-21 client composes it on every connect but its channel set is
/// always empty, so there is nothing to subscribe to; logged only.</summary>
public Task SubscribeTo(JsonElement payload)
{ {
logger.LogInformation( logger.LogInformation(
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", "[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UnsubscribeFromPlayers(JsonElement payload) /// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
public Task UnsubscribeFrom(JsonElement payload)
{ {
logger.LogInformation( logger.LogInformation(
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", "[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task Subscribe(JsonElement payload) /// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
/// array, as player ids; numbers or numeric strings.</summary>
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
{ {
logger.LogInformation( ids = new HashSet<long>();
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}", var list = payload;
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); if (payload.ValueKind == JsonValueKind.Object)
return Task.CompletedTask; {
} var match = payload.EnumerateObject()
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
public Task Unsubscribe(JsonElement payload) .Select(prop => (JsonElement?)prop.Value)
{ .FirstOrDefault();
logger.LogInformation( if (match is not JsonElement value) return false;
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}", list = value;
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); }
return Task.CompletedTask; if (list.ValueKind != JsonValueKind.Array) return false;
foreach (var item in list.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
}
return true;
} }
private long? TryGetPlayerId() private long? TryGetPlayerId()
@@ -0,0 +1,97 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
namespace DorkNet.Server.Services;
/// <summary>
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
/// Downloads and Quest pages fetch them through the server, so the bucket
/// itself never has to be reachable from outside.
///
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
/// Host header to send, for web endpoints that pick the bucket by host name).
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
/// both features.
/// </summary>
public sealed class DownloadsService
{
public const string HttpClientName = "downloads";
// How long the bucket may take to start answering. The body then streams
// with no limit (the client has none; the caller's abort token ends it).
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
private readonly IHttpClientFactory _httpClientFactory;
private readonly Uri? _baseUri;
private readonly string? _host;
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
{
_httpClientFactory = httpClientFactory;
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
_baseUri = uri;
}
var host = config["DorkNet:Downloads:Host"]?.Trim();
_host = string.IsNullOrEmpty(host) ? null : host;
if (_baseUri is null)
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
else
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
}
public bool IsConfigured => _baseUri is not null;
/// <summary>The published manifest, or null when none has been published yet.</summary>
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
{
using var res = await SendAsync("manifest.json", range: null, ct);
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
res.EnsureSuccessStatusCode();
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
}
/// <summary>A manifest entry by its bucket path, or null when the manifest
/// doesn't list it. Only listed files are ever served.</summary>
public static JsonObject? FindFile(JsonObject manifest, string path)
{
if (manifest["releases"] is not JsonArray releases) return null;
return releases.OfType<JsonObject>()
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
}
/// <summary>GET a file, forwarding the caller's Range header. The caller
/// owns the response and streams its content.</summary>
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
=> SendAsync(path, range, ct);
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
{
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (_host is not null) req.Headers.Host = _host;
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
var client = _httpClientFactory.CreateClient(HttpClientName);
// The timeout covers only getting the headers: with ResponseHeadersRead
// the token is released once they arrive, and disposing the source
// here stops its timer before the body is streamed.
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
headers.CancelAfter(HeaderTimeout);
try
{
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
}
}
}
+12 -1
View File
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
player.Level = LevelForXp(player.XP); player.Level = LevelForXp(player.XP);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
// api/players/v2/progression returns. Without it the watch kept the
// level and XP it loaded at login until the next relog.
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
new { PlayerId = (int)playerId, player.Level, player.XP });
if (player.Level > oldLevel) if (player.Level > oldLevel)
{ {
// Level-up grants a small currency reward. The wallet UI // Level-up grants a small currency reward. The wallet UI
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
// CurrencyType, Platform}; the client sets that (CurrencyType,
// Platform) bucket to Balance. Platform 0 matches every balance REST
// response, so the buckets line up.
await notifications.NotifyAsync(playerId, await notifications.NotifyAsync(playerId,
PushNotificationId.StorefrontBalanceUpdate, PushNotificationId.StorefrontBalanceUpdate,
new { CurrencyType = currencyType, row.Balance, Reason = reason }); new { row.Balance, CurrencyType = currencyType, Platform = 0 });
return row.Balance; return row.Balance;
} }
+96 -88
View File
@@ -212,25 +212,17 @@ public class NotificationService(
// ── Higher-level event helpers ───────────────────────────────────── // ── Higher-level event helpers ─────────────────────────────────────
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) => /// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged, /// <paramref name="relationship"/> must be the recipient's OWN view of the
new { Reason = "FriendRequestReceived", From = fromPlayerId }); /// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) => /// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, /// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
new { Reason = "FriendRequestAccepted", By = byPlayerId }); /// changed event, which refreshes the friends list and the client's
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) => /// real row never updates.</summary>
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged, public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
new { Reason = "FriendRequestDeclined", By = byPlayerId }); NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendRemoved", By = byPlayerId });
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
new { Reason = "FriendBlocked", By = byPlayerId });
/// <summary>Push a presence delta so subscribed friends re-render /// <summary>Push a presence delta so subscribed friends re-render
/// their friend-list row without waiting for the watch's 15-45 s /// their friend-list row without waiting for the watch's 15-45 s
@@ -270,6 +262,23 @@ public class NotificationService(
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
} }
/// <summary>Push one player's presence to every connection that subscribed
/// to them with <c>SubscribeToPlayers</c>, except
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
/// (a frame about the local player moves them) and anyone already sent the
/// frame through their friend group.</summary>
public async Task PresenceToSubscribersAsync(
long playerId,
RoomInstanceDto? room,
bool isOnline,
int statusVisibility,
IReadOnlyList<string> excludeConnectionIds)
{
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
.SendAsync(HubMethod, payload);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string. /// <summary>The PresenceUpdate envelope for one player, as a JSON string.
/// ///
/// <paramref name="isOnline"/> is independent of the room: the 2023 /// <paramref name="isOnline"/> is independent of the room: the 2023
@@ -349,86 +358,85 @@ public class NotificationService(
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) => public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
PlayerPresenceChanged(friendIds, playerId, room); PlayerPresenceChanged(friendIds, playerId, room);
/// <summary>Force every currently-connected SignalR session for /// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
/// <paramref name="playerId"/> to disconnect via the watch's /// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
/// ModerationKick flow. Wire path verified against decompilation: /// value must go out as a NUMBER: release builds obfuscate enum names, so a
/// <c>PushNotificationId.ModerationKick = 22</c> is registered /// string like "AdminPasswordReset" fails the parse and the whole push is
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload. /// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads /// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then /// client to the boot scene for every reason.</summary>
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>. public enum LogoutReason
/// {
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/> Unknown = 0,
/// to enforce single-session-per-account: when a second login on UserInitiated = 1,
/// the same account replaces the LoginLock, the older SignalR SessionTakeover = 2,
/// connections are kicked. The new session hasn't called ForciblyLoggedOut = 3,
/// <c>negotiate</c> yet (it happens AFTER /player/login on the Banned = 4,
/// boot sequence), so the kick only hits old sessions. }
/// </summary>
public Task KickStaleSession(long playerId, string reason) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick,
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) => /// <summary>Log every session of <paramref name="playerId"/> out to the
NotifyAsync(playerId, PushNotificationId.ModerationKick, /// boot scene. Use this, not a kick, when the player must not stay signed
ModerationKickPayload(reason, isBan, source: "admin_kick")); /// in: a ModerationKick only sends them to their dorm.</summary>
public Task LogoutAsync(long playerId, LogoutReason reason) =>
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
/// <summary>Push a ModerationKick to a specific set of SignalR /// <summary>Single-session-per-account: when a second login replaces the
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/> /// LoginLock, log the older session out. The new session hasn't called
/// to evict the previous session when a new device connects on the /// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
/// same account. Targets specific ids (not the player group) so /// sequence), so only the old session's connections receive it.</summary>
/// the brand-new connection joining the group right after this public Task LogoutStaleSession(long playerId) =>
/// returns can't accidentally receive its own eviction notice. LogoutAsync(playerId, LogoutReason.SessionTakeover);
/// Works across replicas because SignalR's Redis backplane routes
/// <summary>The same takeover logout, sent to specific SignalR connection
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
/// to evict the previous session when a new device connects on the same
/// account. Targets ids (not the player group) so the brand-new
/// connection, which joins the group right after, never receives it. Works
/// across replicas because the Redis backplane routes
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary> /// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason) public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
{ {
if (connectionIds.Count == 0) return Task.CompletedTask; if (connectionIds.Count == 0) return Task.CompletedTask;
var payload = Serialize(PushNotificationId.ModerationKick, var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload); return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
} }
private static object ModerationKickPayload(string reason, bool isBan, string source) /// <summary>Kick <paramref name="playerId"/> back to their dorm with
/// <paramref name="reason"/> shown in the kick dialog. The player stays
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
/// Pass the room instance the player is in as
/// <paramref name="gameSessionId"/> to have the client announce the kick
/// to that room.</summary>
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
/// <summary>ModerationKick "22" payload: the FLAT moderation block
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
/// "Kicked to Dormroom" text and only adds a reason as
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
/// shown. When <c>GameSessionId</c> is the kicked player's current room
/// instance it also broadcasts the kick to that room, and
/// <c>Duration</c> &gt; 0 starts a timeout, so it stays 0. The old nested
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
/// which booted the player but dropped every reason.</summary>
private static object ModerationKickPayload(string reason, long gameSessionId)
{ {
var message = string.IsNullOrWhiteSpace(reason) var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
? "Kicked to Dormroom"
: reason.Trim();
var blockDetail = new
{
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
Duration = 0,
GameSessionId = 0L,
Message = message,
IsHostKick = true,
PlayerIdReporter = 1,
IsBan = isBan,
VoteKickReason = message,
};
return new return new
{ {
Reason = blockDetail, ReportCategory = 10, // VoteKick: the only category that shows a reason
DisplayReason = message, Duration = 0,
BannedByPlayerId = 1, GameSessionId = gameSessionId,
Source = source, IsHostKick = false,
Message = message,
PlayerIdReporter = (int?)null,
IsBan = false,
IsVoiceModAutoban = false,
IsWarning = false,
VoteKickReason = message,
TimeoutStartedAt = (DateTime?)null,
}; };
} }
/// <summary>Notify a private-instance owner that someone tried to
/// follow them in via "Go to Player" but was rejected for not
/// being on the invite list. Reuses the MessageReceived channel
/// since the watch already renders that as an inbox toast — no
/// new client-side handler needed. The body wording surfaces it
/// as "Player X tried to join your private match" so the owner
/// knows to send an explicit invite if they want to let them in.</summary>
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
new
{
Reason = "PrivateInstanceJoinRejected",
AttemptedBy = attemptedByPlayerId,
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
});
} }
@@ -148,6 +148,34 @@ public class OnlinePresenceService
lock (set) return set.ToArray(); lock (set) return set.ToArray();
} }
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
/// in one go: the Redis lookups are issued together, so the multiplexer
/// pipelines them into one round trip instead of one per player.</summary>
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
{
var ids = playerIds.Distinct().ToArray();
if (ids.Length == 0) return Array.Empty<string>();
if (_redis is { } mux)
{
try
{
var db = mux.GetDatabase();
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
}
catch
{
// Redis sick → fall through to the local fallback, as
// GetConnections does.
}
}
var result = new List<string>();
foreach (var id in ids)
if (_localConns.TryGetValue(id, out var set))
lock (set) result.AddRange(set);
return result;
}
/// <summary>Snapshot of currently-online player ids across the /// <summary>Snapshot of currently-online player ids across the
/// cluster. Returns an empty array when nobody's online; never null. /// cluster. Returns an empty array when nobody's online; never null.
/// Callers typically <c>.ToHashSet()</c> the result for membership /// Callers typically <c>.ToHashSet()</c> the result for membership
@@ -36,7 +36,10 @@ public static class PresenceVisibility
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into /// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync /// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so /// correction (:18467). A frame about the LOCAL player therefore moves that player, so
/// these frames go to friends only, never to the subject. /// these frames never go to the subject. They go to the subject's friends and to every
/// other connection that subscribed to the subject with SubscribeToPlayers (see
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
/// Public.
/// </summary> /// </summary>
public sealed class PresenceBroadcastService( public sealed class PresenceBroadcastService(
IServiceScopeFactory scopes, IServiceScopeFactory scopes,
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0; presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
/// <summary>Push the player's current presence (online state + committed /// <summary>Push the player's current presence (online state + committed
/// room) to every friend.</summary> /// room) to every friend and subscriber.</summary>
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false); public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
/// <summary>Push an offline frame regardless of any lingering markers — /// <summary>Push an offline frame regardless of any lingering markers —
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
.Where(id => id != playerId) .Where(id => id != playerId)
.Distinct() .Distinct()
.ToList(); .ToList();
if (friendIds.Count == 0) return;
var online = !forceOffline && IsOnline(playerId); var online = !forceOffline && IsOnline(playerId);
var room = online ? presence.GetRoom(playerId) : null; var room = online ? presence.GetRoom(playerId) : null;
@@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService(
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility); await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
if (hiddenFrom.Count > 0) if (hiddenFrom.Count > 0)
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility); await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
// Everyone else who subscribed to this player (SubscribeToPlayers:
// people in the same room, an open profile). They are not friends,
// so they see the real presence only when it is Public, and the
// offline frame otherwise. Friends were served above and the
// subject must never get a frame about themselves, so their
// connections are excluded.
// Only friends who are online have connections; one call for the
// online set, then one pipelined lookup for all of them.
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
var exclude = (await connections.GetConnectionsAsync(
friendIds.Where(onlineIds.Contains).Append(playerId)))
.Distinct()
.ToList();
var publicToAll = visibility == PresenceVisibility.Public && online;
await notifications.PresenceToSubscribersAsync(
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
/// listed; add more as needed.</summary> /// listed; add more as needed.</summary>
public static class MessageType public static class MessageType
{ {
public const int FriendInvite = 4;
public const int RequestGameInvite = 10;
public const int TextMessage = 30; public const int TextMessage = 30;
public const int FriendRequestAccepted = 40; public const int FriendRequestAccepted = 40;
public const int FriendCodeUsed = 41; public const int FriendCodeUsed = 41;
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
public const int CreatorPublishedNewRoom = 70; public const int CreatorPublishedNewRoom = 70;
public const int PlayerAttendingEvent = 80; public const int PlayerAttendingEvent = 80;
public const int PlayerEventInvitation = 81; public const int PlayerEventInvitation = 81;
public const int CoachMessage = 100;
public const int FriendIntroduction = 130;
} }
/// <summary>Persist and push a system notification to /// <summary>Persist and push a system notification to
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
return false; return false;
var path = request.Path.Value ?? ""; var path = request.Path.Value ?? "";
// Client builds are streamed through (several GB): never buffer them.
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
return false;
var ext = Path.GetExtension(path); var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) if (string.IsNullOrEmpty(ext))
return true; return true;
@@ -217,6 +217,13 @@ public static class ServiceCollectionExtensions
// so a deployment with no GitHub credentials behaves normally. // so a deployment with no GitHub credentials behaves normally.
services.AddSingleton<IGitHubIssues, GitHubIssueService>(); services.AddSingleton<IGitHubIssues, GitHubIssueService>();
services.AddHostedService<TestCaseIssueReconciler>(); services.AddHostedService<TestCaseIssueReconciler>();
// CI-published client builds for the site's Downloads / Quest pages.
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
// timeout: a multi-GB file streams for longer than the default 100 s.
// DownloadsService limits the wait for headers itself, and the
// request's own abort token ends the stream.
services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
services.AddSingleton<DownloadsService>();
// Scoped (per-request) — these services hold DbContext references and // Scoped (per-request) — these services hold DbContext references and
// must share the request's scope to participate in the request's // must share the request's scope to participate in the request's
// transaction lifecycle. Pre-PR-3 they were Singleton with in-process // transaction lifecycle. Pre-PR-3 they were Singleton with in-process
@@ -416,15 +423,18 @@ public static class ServiceCollectionExtensions
kopts.Limits.MinResponseDataRate = null; kopts.Limits.MinResponseDataRate = null;
}); });
// Keepalive cadence: server pings every 10s, gives the client 60s // Keepalive cadence: the server pings every 10s, which keeps frame
// to ack before declaring the connection dead. The 10s ping forces // traffic well below every proxy timeout we've seen in the path
// frame traffic well below every proxy timeout we've seen in the // (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik // Kestrel 130s). The 2023 client pings every 15s, but from its main
// 180s, Kestrel 130s). // thread, so a long room-load hitch pauses its pings. It has no
// timeout of its own and reconnects at most 3 times per login, so a
// healthy socket the server drops can leave it without live
// notifications for the rest of the session. Give it 180s.
var signalR = builder.Services.AddSignalR(opts => var signalR = builder.Services.AddSignalR(opts =>
{ {
opts.KeepAliveInterval = TimeSpan.FromSeconds(10); opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60); opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
opts.HandshakeTimeout = TimeSpan.FromSeconds(15); opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
// Allow large gift / community-board payloads (default 32 KB // Allow large gift / community-board payloads (default 32 KB
// truncates anything bigger and disconnects the client). // truncates anything bigger and disconnects the client).
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
export function Broadcast() { export function Broadcast() {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [minutes, setMinutes] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const toast = useToast(); const toast = useToast();
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
const send = async (e: React.FormEvent) => { const send = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!message.trim()) return; if (!canSend) return;
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return; const parts = [
message.trim() && `"${message.trim()}"`,
startsInMinutes !== null &&
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
].filter(Boolean);
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
setBusy(true); setBusy(true);
try { try {
await api('/broadcast', { method: 'POST', body: { Message: message } }); const res = await api<{ recipients: number }>('/broadcast', {
toast.push('Broadcast sent', 'success'); method: 'POST',
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
});
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
setMessage(''); setMessage('');
setMinutes('');
} catch (e) { } catch (e) {
toast.push((e as Error).message, 'error'); toast.push((e as Error).message, 'error');
} finally { } finally {
@@ -28,7 +42,7 @@ export function Broadcast() {
<div> <div>
<PageHeader <PageHeader
title="Broadcast" title="Broadcast"
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast." blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
/> />
<form onSubmit={send} className="card !p-5 max-w-2xl"> <form onSubmit={send} className="card !p-5 max-w-2xl">
@@ -39,14 +53,33 @@ export function Broadcast() {
rows={5} rows={5}
className="input mt-1.5 font-mono text-sm" className="input mt-1.5 font-mono text-sm"
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save." placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
required
/> />
<div className="mt-2 text-xs text-ink-400"> <div className="mt-2 text-xs text-ink-400">
{message.length} characters {message.length} characters
</div> </div>
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
<input
value={minutes}
onChange={e => setMinutes(e.target.value)}
inputMode="numeric"
className="input mt-1.5 w-40 text-sm"
placeholder="e.g. 5"
/>
<div className="mt-2 text-xs text-ink-400">
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
</div>
<div className="mt-4 flex gap-2 justify-end"> <div className="mt-4 flex gap-2 justify-end">
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button> <button
<button className="btn-primary text-xs" disabled={busy || !message.trim()}> type="button"
onClick={() => { setMessage(''); setMinutes(''); }}
className="btn-ghost text-xs"
disabled={busy || (!message && !minutes)}
>
Clear
</button>
<button className="btn-primary text-xs" disabled={!canSend}>
{busy ? 'Sending…' : 'Send broadcast'} {busy ? 'Sending…' : 'Send broadcast'}
</button> </button>
</div> </div>
+98
View File
@@ -8,6 +8,11 @@
"name": "dorknet-site", "name": "dorknet-site",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.3.0" "react-router": "^8.3.0"
@@ -1350,6 +1355,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/w3c-web-usb": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1371,6 +1382,93 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@yume-chan/adb": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-2.6.4.tgz",
"integrity": "sha512-aaN21ZOLjWW1nC09C21f0yQkCqxkuMWMqheryGEbmGLPhuzIyOhTurJK8cXh5ZnQ+ptdKBexqvoG6zYoul9XPA==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/event": "^2.0.0",
"@yume-chan/no-data-view": "^2.0.0",
"@yume-chan/stream-extra": "^2.6.1",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/adb-credential-web": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-2.1.0.tgz",
"integrity": "sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0"
}
},
"node_modules/@yume-chan/adb-daemon-webusb": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-2.3.2.tgz",
"integrity": "sha512-5ANaqsRJWFc5kKMnGALjYCrSXo5eC4u51dvYppvuBCLVjv12n7he2TPy5yEZ3XtlToRFS0kWCQC0XS8M60lRSA==",
"license": "MIT",
"dependencies": {
"@types/w3c-web-usb": "^1.0.12",
"@yume-chan/adb": "^2.3.1",
"@yume-chan/event": "^2.0.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/android-bin": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-2.1.0.tgz",
"integrity": "sha512-is97IQwmSTmGCLe8GKO3rRDib2is6Xumo9KzeArgHwizIF0XnpCUm/xIVJLnpifR9PlMyLldDUttTZMTnaNivg==",
"license": "MIT",
"dependencies": {
"@yume-chan/adb": "^2.1.0",
"@yume-chan/stream-extra": "^2.1.0",
"@yume-chan/struct": "^2.0.1"
}
},
"node_modules/@yume-chan/async": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.1.3.tgz",
"integrity": "sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==",
"license": "MIT"
},
"node_modules/@yume-chan/event": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-2.0.0.tgz",
"integrity": "sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.0.2"
}
},
"node_modules/@yume-chan/no-data-view": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-2.0.0.tgz",
"integrity": "sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==",
"license": "MIT"
},
"node_modules/@yume-chan/stream-extra": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-2.6.1.tgz",
"integrity": "sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/struct": "^2.3.2"
}
},
"node_modules/@yume-chan/struct": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-2.3.2.tgz",
"integrity": "sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==",
"license": "MIT",
"dependencies": {
"@yume-chan/async": "^4.1.3",
"@yume-chan/no-data-view": "^2.0.0"
}
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+5
View File
@@ -13,6 +13,11 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"@yume-chan/adb": "2.6.4",
"@yume-chan/adb-credential-web": "2.1.0",
"@yume-chan/adb-daemon-webusb": "2.3.2",
"@yume-chan/android-bin": "2.1.0",
"@yume-chan/stream-extra": "2.6.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router": "^8.3.0" "react-router": "^8.3.0"
+7
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router'; import { Routes, Route, Navigate } from 'react-router';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
@@ -7,8 +8,12 @@ import { Players } from './pages/Players';
import { PlayerProfile } from './pages/PlayerProfile'; import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms'; import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join'; import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { NotFound } from './pages/NotFound'; import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
const Quest = lazy(() => import('./pages/Quest').then((m) => ({ default: m.Quest })));
export function App() { export function App() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
@@ -22,6 +27,8 @@ export function App() {
<Route path="/players/:id" element={<PlayerProfile />} /> <Route path="/players/:id" element={<PlayerProfile />} />
<Route path="/rooms" element={<Rooms />} /> <Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} /> <Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */} {/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} /> <Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} /> <Route path="/u/:id" element={<RedirectPlayer />} />
+12 -2
View File
@@ -1,6 +1,7 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router'; import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark'; import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
const NAV = [ const NAV = [
{ to: '/', label: 'Home', exact: true }, { to: '/', label: 'Home', exact: true },
@@ -8,7 +9,10 @@ const NAV = [
{ to: '/players', label: 'Players' }, { to: '/players', label: 'Players' },
{ to: '/rooms', label: 'Rooms' }, { to: '/rooms', label: 'Rooms' },
{ to: '/join', label: 'Join' }, { to: '/join', label: 'Join' },
{ to: '/quest', label: 'Quest' },
]; ];
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
export function Header() { export function Header() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -16,6 +20,12 @@ export function Header() {
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
}, []);
// Close the mobile drawer on route change so tapping a nav item // Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page. // doesn't leave a half-open overlay covering the page.
@@ -36,7 +46,7 @@ export function Header() {
</Link> </Link>
<nav className="hidden md:flex items-center gap-1 ml-2"> <nav className="hidden md:flex items-center gap-1 ml-2">
{NAV.map(n => ( {navItems.map(n => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
@@ -84,7 +94,7 @@ export function Header() {
/> />
</form> </form>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
{NAV.map(n => ( {navItems.map(n => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
+59
View File
@@ -0,0 +1,59 @@
// The client builds CI publishes (ci/downloads/publish.py), served through
// the server's DownloadsController. The manifest keeps the snake_case keys
// publish.py writes.
import { ApiError, get } from './api';
export interface DownloadFile {
id: string; // pc-full | pc-patch | quest | quest-installer
platform: 'pc' | 'quest' | string;
name: string;
path: string;
size: number;
sha256: string;
build?: string;
commit?: string;
built_at?: string;
}
export interface DownloadRelease {
id: string;
title: string;
game_version?: string;
pc_exe?: string;
files: DownloadFile[];
}
export interface DownloadsManifest {
releases: DownloadRelease[];
updated?: string;
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
* once per page load; pass `fresh` to re-read it. */
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
cached = null;
throw e;
});
}
return cached;
}
export const fileUrl = (f: DownloadFile) =>
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
/** A published Quest package: the one at `path` when given, otherwise the
* newest release's. Null when there's no match. */
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
for (const release of m?.releases ?? []) {
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
if (file) return { release, file };
}
return null;
}
+142
View File
@@ -0,0 +1,142 @@
// Install a DorkNet .xapk onto a Meta Quest from the browser over WebUSB, using
// Tango (@yume-chan/adb). Mirrors DorkNet.Quest/scripts/installer/install.ps1:
// install the APK, copy each OBB to its install path (skipped when an identical
// size is already there), then launch.
import { Adb, AdbDaemonTransport } from '@yume-chan/adb';
import { AdbDaemonWebUsbDevice, AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { PackageManager } from '@yume-chan/android-bin';
import type { ReadableStream as TangoReadableStream } from '@yume-chan/stream-extra';
import { entryStream, type Xapk } from './xapk';
export const webUsbSupported = () => typeof navigator !== 'undefined' && 'usb' in navigator && !!AdbDaemonWebUsbDeviceManager.BROWSER;
export interface Headset {
adb: Adb;
model: string;
serial: string;
}
export type Phase = 'app' | 'data' | 'launch';
export interface Progress {
phase: Phase;
label: string;
done: number;
total: number;
}
const credentialStore = new AdbWebCredentialStore('DorkNet Quest installer');
export async function connectHeadset(): Promise<Headset> {
const manager = AdbDaemonWebUsbDeviceManager.BROWSER;
if (!manager) throw new Error('This browser does not support WebUSB. Use Chrome or Edge on a computer.');
const device = await manager.requestDevice();
if (!device) throw new Error('No headset selected.');
let connection;
try {
connection = await device.connect();
} catch (e) {
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
throw new Error('The headset is in use by another program. Close SideQuest, Meta Quest Developer Hub or adb (run "adb kill-server"), then try again.');
}
throw e;
}
const transport = await AdbDaemonTransport.authenticate({
serial: device.serial,
connection,
credentialStore,
});
const adb = new Adb(transport);
const model = (await adb.getProp('ro.product.model')).trim() || device.name;
return { adb, model, serial: device.serial };
}
function counted(stream: ReadableStream<Uint8Array>, onBytes: (n: number) => void): TangoReadableStream<Uint8Array> {
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
onBytes(chunk.byteLength);
controller.enqueue(chunk);
},
});
return stream.pipeThrough(counter) as unknown as TangoReadableStream<Uint8Array>;
}
async function remoteSize(adb: Adb, path: string): Promise<number | null> {
const sync = await adb.sync();
try {
const st = await sync.lstat(path);
return Number(st.size);
} catch {
return null;
} finally {
await sync.dispose();
}
}
/**
* Install the package. `confirmUninstall` is asked before removing an existing
* copy signed with a different key (that clears its local data).
*/
export async function installXapk(
headset: Headset,
xapk: Xapk,
onProgress: (p: Progress) => void,
confirmUninstall: (pkg: string) => Promise<boolean>,
): Promise<void> {
const { adb } = headset;
const pkg = xapk.manifest.package_name;
const pm = new PackageManager(adb);
// ---- APK ----
const installApk = async () => {
let done = 0;
const total = xapk.apk.size;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
done += n;
onProgress({ phase: 'app', label: 'Installing the app', done, total });
});
await pm.installStream(total, stream);
};
try {
await installApk();
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(msg)) throw e;
if (!(await confirmUninstall(pkg))) throw new Error('Cancelled.');
await pm.uninstall(pkg);
await installApk();
}
// ---- game data (OBB) ----
for (const { entry, installPath } of xapk.expansions) {
const dest = `/sdcard/${installPath}`;
const total = entry.size;
if ((await remoteSize(adb, dest)) === total) {
onProgress({ phase: 'data', label: 'Game data already on the headset', done: total, total });
continue;
}
const dir = dest.substring(0, dest.lastIndexOf('/'));
await adb.subprocess.noneProtocol.spawnWaitText(['mkdir', '-p', dir]);
let done = 0;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
const stream = counted(await entryStream(xapk.source, entry), (n) => {
done += n;
onProgress({ phase: 'data', label: 'Copying game data', done, total });
});
const sync = await adb.sync();
try {
await sync.write({ filename: dest, file: stream, permission: 0o660 });
} finally {
await sync.dispose();
}
}
// ---- launch ----
onProgress({ phase: 'launch', label: 'Starting the game', done: 1, total: 1 });
await adb.subprocess.noneProtocol.spawnWaitText(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
}
+187
View File
@@ -0,0 +1,187 @@
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
// File the player picked or from a URL that answers HTTP Range requests (the
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
// APK and the OBB uncompressed, so those entries are plain byte ranges that
// stream straight through; manifest.json is deflated and goes through
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
/** Random access to the package's bytes; `end` is exclusive. */
export interface ByteSource {
name: string;
size: number;
read(start: number, end: number): Promise<ArrayBuffer>;
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
}
export function fileSource(file: File): ByteSource {
return {
name: file.name,
size: file.size,
read: (start, end) => file.slice(start, end).arrayBuffer(),
stream: async (start, end) => file.slice(start, end).stream(),
};
}
/** A package on the server. `size` comes from the downloads manifest; every
* read is one Range request. */
export function urlSource(url: string, name: string, size: number): ByteSource {
const get = async (start: number, end: number): Promise<Response> => {
let res: Response;
try {
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
} catch {
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
}
if (res.status !== 206) {
throw new Error(res.status === 200
? "The server doesn't support partial downloads, so the package can't be read in place."
: `Downloading the package failed (HTTP ${res.status}).`);
}
return res;
};
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
return {
name,
size,
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
stream: async (start, end) => {
if (end <= start) return empty();
const body = (await get(start, end)).body;
if (!body) throw new Error('The server sent an empty response.');
return body;
},
};
}
export interface XapkEntry {
name: string;
method: number; // 0 = stored, 8 = deflate
compressedSize: number;
size: number;
localHeaderOffset: number;
}
export interface XapkExpansion {
file: string;
install_path: string;
}
export interface XapkManifest {
package_name: string;
name?: string;
version_code?: string;
version_name?: string;
expansions?: XapkExpansion[];
}
export interface Xapk {
source: ByteSource;
manifest: XapkManifest;
entries: Map<string, XapkEntry>;
apk: XapkEntry;
expansions: { entry: XapkEntry; installPath: string }[];
}
const EOCD = 0x06054b50;
const ZIP64_LOCATOR = 0x07064b50;
const ZIP64_EOCD = 0x06064b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
return new DataView(await src.read(start, end));
}
function u64(v: DataView, at: number): number {
return Number(v.getBigUint64(at, true));
}
export async function openXapk(src: ByteSource): Promise<Xapk> {
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
const tail = await bytes(src, tailStart, src.size);
let eocd = -1;
for (let i = tail.byteLength - 22; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
}
if (eocd < 0) throw new Error('This is not a zip/.xapk file.');
let count = tail.getUint16(eocd + 10, true);
let cdSize = tail.getUint32(eocd + 12, true);
let cdOffset = tail.getUint32(eocd + 16, true);
if (cdOffset === 0xffffffff || count === 0xffff || cdSize === 0xffffffff) {
const loc = eocd - 20;
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
const z64At = u64(tail, loc + 8);
const z64 = await bytes(src, z64At, z64At + 56);
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
count = u64(z64, 32);
cdSize = u64(z64, 40);
cdOffset = u64(z64, 48);
}
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
const entries = new Map<string, XapkEntry>();
const decoder = new TextDecoder();
let p = 0;
for (let n = 0; n < count; n++) {
if (cd.getUint32(p, true) !== CENTRAL) throw new Error('Corrupt zip directory.');
const method = cd.getUint16(p + 10, true);
let compressedSize = cd.getUint32(p + 20, true);
let size = cd.getUint32(p + 24, true);
const nameLen = cd.getUint16(p + 28, true);
const extraLen = cd.getUint16(p + 30, true);
const commentLen = cd.getUint16(p + 32, true);
let localHeaderOffset = cd.getUint32(p + 42, true);
const name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + p + 46, nameLen));
// zip64 extended info: present fields appear in this order, only for
// values saturated in the fixed header.
let e = p + 46 + nameLen;
const extraEnd = e + extraLen;
while (e + 4 <= extraEnd) {
const id = cd.getUint16(e, true);
const len = cd.getUint16(e + 2, true);
if (id === 0x0001) {
let q = e + 4;
if (size === 0xffffffff) { size = u64(cd, q); q += 8; }
if (compressedSize === 0xffffffff) { compressedSize = u64(cd, q); q += 8; }
if (localHeaderOffset === 0xffffffff) { localHeaderOffset = u64(cd, q); }
}
e += 4 + len;
}
entries.set(name, { name, method, compressedSize, size, localHeaderOffset });
p += 46 + nameLen + extraLen + commentLen;
}
const manifestEntry = entries.get('manifest.json');
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
const apk = entries.get(`${manifest.package_name}.apk`)
?? [...entries.values()].find((x) => x.name.endsWith('.apk'));
if (!apk) throw new Error('The .xapk contains no APK.');
const expansions = (manifest.expansions ?? []).map((x) => {
const entry = entries.get(x.file);
if (!entry) throw new Error(`The .xapk is missing ${x.file}.`);
return { entry, installPath: x.install_path };
});
return { source: src, manifest, entries, apk, expansions };
}
/** The entry's uncompressed bytes as a stream. */
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
const raw = await src.stream(start, start + entry.compressedSize);
if (entry.method === 0) return raw;
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
throw new Error(`Unsupported compression in ${entry.name}.`);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
const fmtDate = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
const PLATFORMS = [
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
];
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
switch (f.id) {
case 'pc-full':
return {
title: 'Full game', primary: true,
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
};
case 'pc-patch':
return {
title: 'Patch only',
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
DorkNet launcher installs this for you; skip it if you got the full game.</>,
};
case 'quest':
return {
title: 'Quest app', primary: true,
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
};
case 'quest-installer':
return {
title: 'Install scripts',
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
};
default:
return { title: f.name, body: null };
}
}
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
const d = describe(f, rel);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(f.sha256);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
};
return (
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-ink-50">{d.title}</h3>
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
</div>
<p className="text-sm text-ink-300">{d.body}</p>
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
<span>{fmtSize(f.size)}</span>
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
{f.build && <span>Build {f.build}</span>}
</div>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
{f.id === 'quest' ? (
<>
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
</>
) : (
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
)}
<button
type="button"
onClick={copy}
title={`Copy SHA-256: ${f.sha256}`}
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
>
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
</button>
</div>
</article>
);
}
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
<p className="text-sm text-ink-400">
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
lets you check your download.
</p>
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
{releases.map((rel) => (
<section key={rel.id} className="space-y-5">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
</div>
{PLATFORMS.map((p) => {
const files = rel.files.filter((f) => f.platform === p.id);
if (!files.length) return null;
return (
<div key={p.id} className="space-y-2">
<div>
<h3 className="label">{p.title}</h3>
<p className="text-xs text-ink-400">{p.hint}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
</div>
</div>
);
})}
</section>
))}
{releases.length > 0 && (
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
</p>
)}
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
function Step({ n, title, done, children }: { n: number; title: string; done?: boolean; children: React.ReactNode }) {
return (
<section className="card !p-5 space-y-3">
<h2 className="flex items-center gap-3 text-base font-semibold text-ink-50">
<span className={`grid size-7 place-items-center rounded-full text-sm ${done ? 'bg-brand-500 text-white' : 'bg-ink-800 text-ink-200'}`}>
{done ? '✓' : n}
</span>
{title}
</h2>
{children}
</section>
);
}
function Bar({ p }: { p: Progress }) {
const pct = p.total > 0 ? Math.min(100, Math.round((p.done / p.total) * 100)) : 0;
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-ink-300">
<span>{p.label}</span>
<span>{p.phase === 'launch' ? '' : `${fmtSize(p.done)} / ${fmtSize(p.total)} · ${pct}%`}</span>
</div>
<div className="h-2 rounded-full bg-ink-800 overflow-hidden">
<div className="h-full bg-brand-500 transition-[width] duration-200" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export function Quest() {
const supported = webUsbSupported();
const [xapk, setXapk] = useState<Xapk | null>(null);
const [headset, setHeadset] = useState<Headset | null>(null);
const [progress, setProgress] = useState<Record<string, Progress>>({});
const [busy, setBusy] = useState<'file' | 'connect' | 'install' | null>(null);
const [err, setErr] = useState<string | null>(null);
const [done, setDone] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null);
const [params] = useSearchParams();
const open = async (load: () => Promise<Xapk>) => {
setErr(null); setDone(false); setProgress({}); setBusy('file');
try { setXapk(await load()); }
catch (e) { setXapk(null); setErr((e as Error).message); }
finally { setBusy(null); }
};
const pickFile = async (f: File | undefined) => {
if (f) await open(() => openXapk(fileSource(f)));
};
const pickBuild = async (b: QuestBuild | null) => {
if (b) await open(() => openXapk(urlSource(fileUrl(b.file), b.file.name, b.file.size)));
};
useEffect(() => {
getDownloads().then((m) => {
setLatest(questBuild(m));
// Arriving from a Quest card's "Install on Quest" on the Downloads page:
// that card's own file, which may not be the newest release's.
const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted));
}).catch(() => { /* no downloads: only the file picker */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = async () => {
setErr(null); setBusy('connect');
try { setHeadset(await connectHeadset()); }
catch (e) {
const m = (e as Error).message;
// Closing the browser's device picker isn't an error worth showing.
if (!/No device selected|No headset selected/i.test(m)) setErr(m);
}
finally { setBusy(null); }
};
const install = async () => {
if (!xapk || !headset) return;
setErr(null); setDone(false); setProgress({}); setBusy('install');
try {
await installXapk(
headset,
xapk,
(p) => setProgress((prev) => ({ ...prev, [p.phase]: p })),
async (pkg) => window.confirm(
`An older copy of ${pkg} is installed with a different signing key and has to be removed first.\n\n` +
'Your account is on the server, but local settings on the headset will be lost. Continue?'),
);
setDone(true);
} catch (e) {
const m = String((e as Error)?.message ?? e);
setErr(/disconnected|NetworkError|transfer/i.test(m)
? 'The headset disconnected. Keep it plugged in and awake (wear it or cover the sensor), reconnect and try again.'
: m);
if (/disconnected|NetworkError/i.test(m)) setHeadset(null);
} finally {
setBusy(null);
}
};
const name = xapk?.manifest.name || 'DorkNet';
const totalSize = xapk ? xapk.apk.size + xapk.expansions.reduce((s, x) => s + x.entry.size, 0) : 0;
return (
<div className="max-w-2xl mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Install on Meta Quest</h1>
<p className="text-sm text-ink-400">
Installs the DorkNet Quest build straight from your browser: no SideQuest or command line.
Your headset needs <span className="text-ink-200">developer mode</span> on (Meta Horizon phone app → your
headset → Developer mode).
</p>
</div>
{!supported && (
<div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
This browser can't talk to USB devices. Open this page in <b>Chrome</b> or <b>Edge</b> on a computer
(Windows, macOS, Linux or ChromeOS).
</div>
)}
<Step n={1} title={latest ? 'Pick the build' : 'Choose the .xapk file'} done={!!xapk}>
{latest ? (
<p className="text-sm text-ink-400">
Install the latest build straight from this server: nothing to download first, it streams to the
headset during the install. Or pick a <code className="text-ink-200">.xapk</code> you already have.
</p>
) : (
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
)}
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3">
{latest && (
<button className="btn-primary" disabled={busy !== null} onClick={() => pickBuild(latest)}>
{busy === 'file' ? 'Reading…' : `Latest build: ${latest.release.title}${latest.file.build ? ` (build ${latest.file.build})` : ''}`}
</button>
)}
<button className="btn-secondary" disabled={busy !== null} onClick={() => fileRef.current?.click()}>
{busy === 'file' && !latest ? 'Reading…' : xapk && !latest ? 'Choose a different file' : 'Choose file'}
</button>
</div>
{xapk && (
<p className="text-sm text-ink-300">
Selected: <span className="text-ink-100 font-medium">{name}</span> {xapk.manifest.version_name} · {fmtSize(totalSize)}
<span className="text-ink-500"> · {xapk.source.name}</span>
</p>
)}
</Step>
<Step n={2} title="Connect your headset" done={!!headset}>
<ol className="list-decimal pl-5 text-sm text-ink-400 space-y-1">
<li>Plug the Quest into this computer with a USB cable.</li>
<li>Click <b className="text-ink-200">Connect</b> and pick the Quest in the browser's list.</li>
<li>Put the headset on and tap <b className="text-ink-200">Allow</b> on "Allow USB debugging" (tick "Always allow").</li>
</ol>
<p className="text-xs text-ink-500">If it says the device is in use, close SideQuest / Meta Quest Developer Hub, or run <code>adb kill-server</code>.</p>
<div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary" disabled={!supported || busy !== null} onClick={connect}>
{busy === 'connect' ? 'Waiting for the headset…' : headset ? 'Reconnect' : 'Connect'}
</button>
{headset && <span className="text-sm text-ink-300">Connected: <span className="text-ink-100 font-medium">{headset.model}</span></span>}
</div>
</Step>
<Step n={3} title="Install" done={done}>
<p className="text-sm text-ink-400">
Installs the app and copies the game data. The game data is large, so this takes a few minutes. Keep the
cable in and the headset awake (wear it or cover the sensor above the nose).
</p>
<button className="btn-primary w-full" disabled={!xapk || !headset || busy !== null} onClick={install}>
{busy === 'install' ? 'Installing…' : done ? 'Install again' : 'Install'}
</button>
{(['app', 'data', 'launch'] as const).map((k) => progress[k] && <Bar key={k} p={progress[k]} />)}
{done && (
<div className="rounded-lg border border-brand-500/30 bg-brand-500/10 px-4 py-3 text-sm text-ink-100">
Installed! The game is starting in the headset. Next time, find it under
<b> Library → Unknown Sources → {name}</b>.
</div>
)}
</Step>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
<p className="text-xs text-ink-500">
The app installs alongside the Meta Store version of Rec Room and doesn't replace it. You'll need a DorkNet
account to sign in.
</p>
</div>
);
}
Vendored
+251
View File
@@ -0,0 +1,251 @@
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
//
// Runs on the "dorknet-builder" agent from ci/jenkins (an inbound agent with
// Python + LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
//
// Stock builds come out of the restic build archive on the FTP. Where it lives
// is server configuration, not part of the repo: the FTP host, login and the
// restic password are Jenkins credentials, and the repository folders come
// from the agent's environment (ARCHIVE_PC_REPO / ARCHIVE_QUEST_REPO), each
// overridable per run. A build is picked by snapshot tags.
//
// A push builds only the platforms whose client code changed (never for
// docs or CI-only changes). A manual run picks the platforms with PLATFORMS.
// See ci/jenkins/README.md.
//
// Finished builds are renamed for players and published to the downloads bucket
// (ci/downloads) unless PUBLISH_DOWNLOADS is off. The build scripts keep their
// own file names, which the launcher depends on.
// The builds this branch targets. Any other archived build can be picked per
// run with the *_SNAPSHOT_TAGS parameters.
def PC_VERSION_KEY = 'march_2023_03_21'
def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317)
def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317
// How this branch's builds are listed on the site's Downloads page.
def RELEASE_ID = '2023.03'
def RELEASE_TITLE = 'March 2023'
def RELEASE_GAME_VERSION = '2023-03-21'
def buildPc = false
def buildQuest = false
pipeline {
agent { label 'dorknet-builder' }
options {
timestamps()
ansiColor('xterm')
disableConcurrentBuilds()
// Full clients and xapks are several GB each: keep artifacts for the
// last few runs only.
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '3'))
timeout(time: 3, unit: 'HOURS')
}
parameters {
choice(name: 'PLATFORMS', choices: ['both', 'pc', 'quest'],
description: 'What to build on a manual run. Push builds rebuild whatever changed.')
booleanParam(name: 'PC_FULL_CLIENT', defaultValue: true,
description: 'Also build the full PC client (stock build + MelonLoader + mod + config). The patcher zip is always built.')
string(name: 'PC_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock PC build, comma = AND. Empty: ${PC_DEFAULT_TAGS}")
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
string(name: 'PC_REPO', defaultValue: '',
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO set on the agent.')
string(name: 'QUEST_REPO', defaultValue: '',
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO set on the agent.')
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
booleanParam(name: 'PUBLISH_DOWNLOADS', defaultValue: true,
description: 'Publish what this run built to the downloads bucket (listed on the site Downloads page). Push builds publish too.')
}
stages {
stage('Plan') {
steps {
script {
def pushed = !currentBuild.getBuildCauses('com.cloudbees.jenkins.GitHubPushCause').isEmpty() ||
!currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty()
if (params.LIST_SNAPSHOTS) {
buildPc = false
buildQuest = false
} else if (pushed) {
def changed = []
for (cs in currentBuild.changeSets) {
for (entry in cs.items) changed.addAll(entry.affectedPaths)
}
// Only client-facing changes rebuild (and so republish) a
// platform; Markdown (.md, .markdown) doesn't count. The job's Git path filter
// uses the same paths, so other pushes don't start a run at all.
// A CI-only change (Jenkinsfile, ci/) takes a manual run.
def touches = { prefixes ->
changed.any { p -> !(p.toLowerCase() ==~ /.*\.(md|markdown)/) && prefixes.any { p.startsWith(it) } }
}
buildPc = touches(['DorkNet.ClientMod/', 'tools/build_pc.py'])
buildQuest = touches(['DorkNet.Quest/'])
echo "Push build; ${changed.size()} changed file(s)"
} else {
buildPc = params.PLATFORMS in ['both', 'pc']
buildQuest = params.PLATFORMS in ['both', 'quest']
}
echo "Build PC: ${buildPc}, Quest: ${buildQuest}"
}
}
}
stage('List snapshots') {
when { expression { params.LIST_SNAPSHOTS } }
steps {
withArchive {
withEnv(["PC_REPO_OVERRIDE=${params.PC_REPO}", "QUEST_REPO_OVERRIDE=${params.QUEST_REPO}",
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
sh '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
'''
}
}
}
}
stage('PC patcher zip') {
when { expression { buildPc } }
steps {
sh "python3 tools/build_pc.py patcher --version-key ${PC_VERSION_KEY} --output-dir out/pc"
}
}
stage('PC full client') {
when { expression { buildPc && params.PC_FULL_CLIENT } }
steps {
script {
def base = withArchive {
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on the agent or PC_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
''').trim().readLines().last()
}
}
withCredentials([file(credentialsId: 'clientmod-config', variable: 'CLIENTMOD_CONFIG')]) {
withEnv(["BASE_CLIENT=${base}"]) {
sh """
python3 tools/build_pc.py client \\
--base-client "\$BASE_CLIENT" \\
--patcher-zip out/pc/dorknet-clientpatch-${PC_VERSION_KEY}.zip \\
--config "\$CLIENTMOD_CONFIG" \\
--output-zip out/pc/dorknet-client-${PC_VERSION_KEY}.zip
"""
}
}
}
}
}
stage('Quest') {
when { expression { buildQuest } }
steps {
script {
def stock = withArchive {
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
sh(returnStdout: true, script: '''
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on the agent or QUEST_REPO on the run}}"
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
''').trim().readLines().last()
}
}
withCredentials([
file(credentialsId: 'quest-keystore', variable: 'QUEST_KEYSTORE'),
string(credentialsId: 'quest-keystore-password', variable: 'QUEST_KEYSTORE_PASSWORD'),
file(credentialsId: 'quest-config', variable: 'QUEST_CONFIG'),
]) {
withEnv(["QUEST_STOCK=${stock}"]) {
// The hook table for the build's versionCode must already be
// resolved and committed under DorkNet.Quest/targets/; build.py
// checks it against the APK's libil2cpp.so.
sh '''
set -eu
obb="$(ls "$QUEST_STOCK"/obbs/main.*.obb | head -n1)"
code="$(basename "$obb" | cut -d. -f2)"
table="$(ls DorkNet.Quest/targets/*-"$code".json 2>/dev/null | head -n1 || true)"
if [ -z "$table" ]; then
echo "No resolved hook table for versionCode $code in DorkNet.Quest/targets/." >&2
echo "Resolve one locally with build.py build --dump-cs and commit it." >&2
exit 1
fi
mkdir -p out/quest
python3 DorkNet.Quest/scripts/build.py build \
--apk "$QUEST_STOCK/app.apk" --obb "$obb" \
--targets-json "$table" \
--config "$QUEST_CONFIG" \
--keystore "$QUEST_KEYSTORE" --keystore-pass "$QUEST_KEYSTORE_PASSWORD" \
--work-dir "$WORKSPACE/quest-work" \
--out-apk "$WORKSPACE/out/quest/dorknet-recroom-$code.apk"
'''
}
}
}
}
}
stage('Downloads') {
when { expression { buildPc || buildQuest } }
steps {
sh """
python3 ci/downloads/publish.py stage --out out --release ${RELEASE_ID} \\
--title '${RELEASE_TITLE}' --game-version ${RELEASE_GAME_VERSION} --version-key ${PC_VERSION_KEY}
"""
script {
if (params.PUBLISH_DOWNLOADS) {
// The bucket's S3 endpoint and name come from the agent's
// environment (DOWNLOADS_S3_ENDPOINT / DOWNLOADS_BUCKET).
withCredentials([usernamePassword(credentialsId: 'downloads-s3',
usernameVariable: 'DOWNLOADS_S3_KEY_ID', passwordVariable: 'DOWNLOADS_S3_SECRET')]) {
sh 'python3 ci/downloads/publish.py upload --staged out/downloads'
}
} else {
echo 'PUBLISH_DOWNLOADS is off: the files are only archived.'
}
}
}
}
}
post {
success {
// The renamed, player-facing files (and release.json): the same set the
// downloads page offers.
archiveArtifacts artifacts: 'out/downloads/**', allowEmptyArchive: true, fingerprint: true
}
cleanup {
// Runs after archiving. Drops the build output and scratch (several
// GB) but keeps the Git checkout: the job's path filter compares
// pushes against it, and without a workspace Jenkins treats every
// push as a change and starts a run. The restic cache and restored
// stock builds live outside the workspace ($DORKNET_CACHE).
cleanWs(deleteDirs: true, notFailBuild: true, patterns: [
[pattern: 'out/**', type: 'INCLUDE'],
[pattern: 'quest-work/**', type: 'INCLUDE'],
])
}
}
}
// FTP + restic credentials for archive-restore.
def withArchive(Closure body) {
withCredentials([
usernamePassword(credentialsId: 'archive-ftp', usernameVariable: 'ARCHIVE_FTP_USER', passwordVariable: 'ARCHIVE_FTP_PASSWORD'),
string(credentialsId: 'archive-ftp-host', variable: 'ARCHIVE_FTP_HOST'),
string(credentialsId: 'archive-restic-password', variable: 'RESTIC_PASSWORD'),
]) {
return body()
}
}
+5
View File
@@ -47,6 +47,11 @@ Third-party components
text is kept alongside the sources at text is kept alongside the sources at
DorkNet.Quest/src/third_party/And64InlineHook/LICENSE. DorkNet.Quest/src/third_party/And64InlineHook/LICENSE.
The public site's Quest installer page (DorkNet.Server/site, /quest)
bundles Tango (@yume-chan/adb, adb-daemon-webusb, adb-credential-web,
android-bin, stream-extra) by Simon Chan, under the MIT License
(https://github.com/yume-chan/ya-webadb).
-------------------------------------------------------------------------- --------------------------------------------------------------------------
Acknowledgements Acknowledgements
+23 -8
View File
@@ -38,10 +38,12 @@ What the 2023 client gets here that the 2020 branches don't have:
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one `DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
regresses. Routes, verbs and wire shapes are in regresses. Routes, verbs and wire shapes are in
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md). [docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric - **Realtime over the SignalR hub** (`hub/v1` on the notify host): every
push ids, presence updates to friends that honour each player's push matches the client's own reader — friends, messages, kicks and
online-status visibility, and account updates when someone edits their logouts, gifts, balances, consumables, player events, level-ups and
profile. account updates — and presence reaches both friends and any player who
subscribed to you, honouring your online-status visibility:
[docs/recroom-2023-notifications.md](docs/recroom-2023-notifications.md).
- **Room saves and sub-rooms** through the storage host, plus cloning and - **Room saves and sub-rooms** through the storage host, plus cloning and
private instances: private instances:
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md). [docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
@@ -167,11 +169,14 @@ account. Each hook checks a fingerprint of its target first, so the patch
refuses to apply to any other build. refuses to apply to any other build.
You supply your own extracted APK and OBB; no game binaries are You supply your own extracted APK and OBB; no game binaries are
redistributed. Build, install and troubleshooting steps are in redistributed. The build can package itself as a `.xapk` that players
[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view install from the **Quest** page of your DorkNet site (`https://<apex>/quest`)
is in [docs/quest.md](docs/quest.md). in Chrome or Edge over USB, with no adb or SideQuest. Build, install and
troubleshooting steps are in [DorkNet.Quest/README.md](DorkNet.Quest/README.md),
and the operator view is in [docs/quest.md](docs/quest.md).
> The Quest build is new and has not yet been tested on a headset. > Tested on a Quest 2: the hooks arm, the client reaches the server and the
> browser installer works. Full in-game play has not been verified yet.
### Debugging (opt-in — `DorkNet.DebugMod`) ### Debugging (opt-in — `DorkNet.DebugMod`)
@@ -211,6 +216,16 @@ keep the prefab value) trade per-frame size for bandwidth if a high FPS
saturates the link. Only the player *sharing* needs the override. Default saturates the link. Only the player *sharing* needs the override. Default
`0` (off). `0` (off).
### Sign-in page instead of a new account
When the title screen finds no remembered account for the machine, Rec Room
creates a brand-new account automatically. On a server with sign-ups off that
is refused and the title screen keeps retrying. By default the mod opens the
**sign-in page** instead, so players log into their existing DorkNet account.
Set `"AutoCreateAccount": true` in `dorknet-clientmod.json` to keep Rec Room's
behaviour on a server that allows sign-ups. The Quest build has the same
option (`AutoCreateAccount` in `dorknet-quest.cfg`).
### RRO quest team size (opt-in) ### RRO quest team size (opt-in)
RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap. RRO quests ship with a baked 4-player `GameConfigurationAsset` team cap.
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""Publish DorkNet client builds to the downloads bucket.
stage the build output in out/ -> out/downloads/<release>/ under the
names players see, plus release.json describing those files
upload that folder -> the downloads bucket, then merge release.json into
the bucket's manifest.json
The DorkNet server reads the bucket and offers the files on its site (the
Downloads page, and Latest build on the Quest page); see docs/deploy.md.
The build scripts keep their own file names on purpose: the launcher fetches
dorknet-clientpatch-<VersionKey>.zip from GitHub Releases by that exact name.
Only the published copies are renamed:
dorknet-client-<key>.zip DorkNet-<release>-PC-Full.zip
dorknet-clientpatch-<key>.zip DorkNet-<release>-PC-Patch.zip
DorkNet-Quest-<ver>/DorkNet-<ver>.xapk DorkNet-<release>-Quest.xapk
DorkNet-Quest-<ver>/install.*, README DorkNet-<release>-Quest-Installer.zip
The server reads the bucket over plain HTTP on the internal network (e.g.
Garage's web endpoint); nothing needs to be public. upload talks to its S3
API with rclone; the endpoint, bucket and key come from the environment:
DOWNLOADS_S3_ENDPOINT S3 API URL, e.g. http://garage-<uuid>:3900
DOWNLOADS_BUCKET bucket name, e.g. dorknet-downloads
DOWNLOADS_S3_REGION optional, default garage
DOWNLOADS_S3_KEY_ID / DOWNLOADS_S3_SECRET
python3 ci/downloads/publish.py stage --out out --release 2023.03 \\
--title "March 2023" --game-version 2023-03-21 --version-key march_2023_03_21
python3 ci/downloads/publish.py upload --staged out/downloads
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from typing import NoReturn
NO_CACHE = "Cache-Control: no-cache"
INSTALLER_NOTE = """Unzip these files into the same folder as {xapk}, then
run install.cmd (Windows) or ./install.sh (macOS / Linux).
"""
def fail(msg: str) -> NoReturn:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def human(n: int) -> str:
return f"{n / 2**20:,.1f} MB" if n >= 2**20 else f"{max(1, round(n / 2**10))} KB"
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(1 << 20):
h.update(chunk)
return h.hexdigest()
def link_or_copy(src: Path, dst: Path) -> None:
"""Hard-link where possible: the full client is several GB."""
dst.unlink(missing_ok=True)
try:
os.link(src, dst)
except OSError:
shutil.copy2(src, dst)
def game_exe(full_zip: Path) -> str | None:
"""The game's exe: the one with a matching <name>_Data folder at the root."""
with zipfile.ZipFile(full_zip) as z:
names = z.namelist()
tops = {n.split("/", 1)[0] for n in names}
exes = sorted(t for t in tops if t.lower().endswith(".exe") and f"{t[:-4]}_Data" in tops)
return exes[0] if exes else None
# ---- stage ----------------------------------------------------------------
def cmd_stage(a: argparse.Namespace) -> None:
out = Path(a.out)
dest = Path(a.dest) if a.dest else out / "downloads"
rel_dir = dest / a.release
if rel_dir.exists():
shutil.rmtree(rel_dir)
rel_dir.mkdir(parents=True)
prefix = f"DorkNet-{a.release}"
staged: list[tuple[str, str, Path]] = [] # (id, platform, staged file)
extra: dict[str, str] = {}
full = out / "pc" / f"dorknet-client-{a.version_key}.zip"
patch = out / "pc" / f"dorknet-clientpatch-{a.version_key}.zip"
if full.is_file():
dst = rel_dir / f"{prefix}-PC-Full.zip"
link_or_copy(full, dst)
staged.append(("pc-full", "pc", dst))
exe = game_exe(full)
if exe:
extra["pc_exe"] = exe
if patch.is_file():
dst = rel_dir / f"{prefix}-PC-Patch.zip"
link_or_copy(patch, dst)
staged.append(("pc-patch", "pc", dst))
packages = sorted((out / "quest").glob("DorkNet-Quest-*/"))
if len(packages) > 1:
fail(f"more than one Quest package folder in {out / 'quest'}: {', '.join(p.name for p in packages)}")
if packages:
pkg = packages[0]
xapks = sorted(pkg.glob("*.xapk"))
if len(xapks) != 1:
fail(f"expected one .xapk in {pkg}, found {len(xapks)}")
xapk = rel_dir / f"{prefix}-Quest.xapk"
link_or_copy(xapks[0], xapk)
staged.append(("quest", "quest", xapk))
installer = rel_dir / f"{prefix}-Quest-Installer.zip"
with zipfile.ZipFile(installer, "w", zipfile.ZIP_DEFLATED) as z:
for name in ("install.cmd", "install.ps1", "install.sh"):
src = pkg / name
if not src.is_file():
fail(f"{src} missing")
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.external_attr = (0o755 if name.endswith(".sh") else 0o644) << 16
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, src.read_bytes())
readme = pkg / "README.txt"
text = INSTALLER_NOTE.format(xapk=xapk.name) + (readme.read_text(encoding="utf-8") if readme.is_file() else "")
info = zipfile.ZipInfo("README.txt", date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, text)
staged.append(("quest-installer", "quest", installer))
if not staged:
fail(f"nothing to publish in {out}")
built_at = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
files = []
for fid, platform, path in staged:
step(f"{path.name}: hashing")
files.append({
"id": fid,
"platform": platform,
"name": path.name,
"path": f"{a.release}/{path.name}",
"size": path.stat().st_size,
"sha256": sha256(path),
"build": a.build,
"commit": a.commit,
"built_at": built_at,
})
release = {"id": a.release, "title": a.title, "game_version": a.game_version, **extra, "files": files}
(dest / "release.json").write_text(json.dumps(release, indent=2) + "\n", encoding="utf-8")
print()
for f in files:
print(f" {f['name']} {human(f['size'])} {f['sha256'][:16]}…")
print(f"Staged {len(files)} file(s) in {rel_dir}")
# ---- upload ---------------------------------------------------------------
def rclone_env() -> dict[str, str]:
env = dict(os.environ)
for k in ("DOWNLOADS_S3_ENDPOINT", "DOWNLOADS_BUCKET", "DOWNLOADS_S3_KEY_ID", "DOWNLOADS_S3_SECRET"):
if not env.get(k):
fail(f"{k} is not set")
env.update({
"RCLONE_CONFIG_DL_TYPE": "s3",
"RCLONE_CONFIG_DL_PROVIDER": "Other",
"RCLONE_CONFIG_DL_ENDPOINT": env["DOWNLOADS_S3_ENDPOINT"],
"RCLONE_CONFIG_DL_REGION": env.get("DOWNLOADS_S3_REGION") or "garage",
"RCLONE_CONFIG_DL_ACCESS_KEY_ID": env["DOWNLOADS_S3_KEY_ID"],
"RCLONE_CONFIG_DL_SECRET_ACCESS_KEY": env["DOWNLOADS_S3_SECRET"],
"RCLONE_CONFIG_DL_FORCE_PATH_STYLE": "true",
"RCLONE_CONFIG_DL_NO_CHECK_BUCKET": "true",
})
return env
def rclone(env: dict[str, str], *args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
r = subprocess.run(["rclone", *args], env=env, input=stdin, capture_output=True, text=True)
if check and r.returncode != 0:
fail(f"rclone {args[0]} failed ({r.returncode}): {r.stderr.strip()}")
return r
def merge(manifest: dict, release: dict) -> dict:
"""Replace this release's files that were just built; keep the others
(a push build may rebuild only one platform)."""
releases = [r for r in manifest.get("releases", []) if r.get("id") != release["id"]]
old = next((r for r in manifest.get("releases", []) if r.get("id") == release["id"]), {})
new_ids = {f["id"] for f in release["files"]}
kept = [f for f in old.get("files", []) if f.get("id") not in new_ids]
merged = {**old, **release, "files": kept + release["files"]}
if "pc_exe" not in release and "pc_exe" in old:
merged["pc_exe"] = old["pc_exe"]
order = ["pc-full", "pc-patch", "quest", "quest-installer"]
merged["files"].sort(key=lambda f: order.index(f["id"]) if f["id"] in order else len(order))
releases.append(merged)
releases.sort(key=lambda r: r["id"], reverse=True)
out = {**manifest, "releases": releases,
"updated": datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()}
return out
def cmd_upload(a: argparse.Namespace) -> None:
staged = Path(a.staged)
release = json.loads((staged / "release.json").read_text(encoding="utf-8"))
env = rclone_env()
bucket = f"dl:{env['DOWNLOADS_BUCKET']}"
rel = release["id"]
# 1. the files, before the manifest that points at them
for f in release["files"]:
step(f"uploading {f['name']} ({human(f['size'])})")
rclone(env, "copyto", str(staged / f["path"]), f"{bucket}/{f['path']}",
"--header-upload", NO_CACHE, "--s3-upload-concurrency", "8", "--s3-chunk-size", "64M")
# 2. merge into the live manifest
r = rclone(env, "cat", f"{bucket}/manifest.json", check=False)
manifest = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}
manifest = merge(manifest, release)
# 3. the manifest, last: it is what makes the new files visible
step("uploading manifest.json")
rclone(env, "rcat", f"{bucket}/manifest.json", "--header-upload", NO_CACHE,
"--header-upload", "Content-Type: application/json", stdin=json.dumps(manifest, indent=2) + "\n")
# 4. drop files of this release the manifest no longer lists (renames)
listed = {f["path"] for r in manifest["releases"] if r["id"] == rel for f in r["files"]}
r = rclone(env, "lsf", f"{bucket}/{rel}/", "--files-only", check=False)
for name in r.stdout.split():
if f"{rel}/{name}" not in listed:
step(f"removing stale {rel}/{name}")
rclone(env, "deletefile", f"{bucket}/{rel}/{name}")
print(f"Published {len(release['files'])} file(s) for {release['title']}.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("stage", help="out/ -> out/downloads/<release>/ + release.json")
s.add_argument("--out", default="out", help="the build output folder (default: out)")
s.add_argument("--dest", help="where to stage (default: <out>/downloads)")
s.add_argument("--release", required=True, help="release id used in file names and paths, e.g. 2023.03")
s.add_argument("--title", required=True, help='shown on the Downloads page, e.g. "March 2023"')
s.add_argument("--game-version", required=True, help="the stock build's date, e.g. 2023-03-21")
s.add_argument("--version-key", required=True, help="the PC build's version key, e.g. march_2023_03_21")
s.add_argument("--build", default=os.environ.get("BUILD_NUMBER", ""), help="CI build number (default: $BUILD_NUMBER)")
s.add_argument("--commit", default=os.environ.get("GIT_COMMIT", ""), help="source commit (default: $GIT_COMMIT)")
s.set_defaults(fn=cmd_stage)
u = sub.add_parser("upload", help="staged folder -> downloads bucket + manifest.json")
u.add_argument("--staged", default="out/downloads", help="the folder `stage` wrote (default: out/downloads)")
u.set_defaults(fn=cmd_upload)
a = p.parse_args()
a.fn(a)
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside the Linux image; keep them LF on every checkout.
*.sh text eol=lf
+84
View File
@@ -0,0 +1,84 @@
# DorkNet build agent: a Jenkins inbound agent with everything the PC and
# Quest client builds need. It runs next to Jenkins on Coolify, connects to
# the controller over WebSocket, and runs the jobs labelled "dorknet-builder"
# (the Jenkinsfile at the repo root). Jenkins itself stays on the stock image.
#
# PC Python + .NET SDK tools/build_pc.py
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
# DorkNet.Quest/scripts/build.py
# Builds restic + rclone stock builds restored from the restic
# archive on the FTP (archive-restore.sh)
#
# Java 25: Jenkins supports it until Sep 2029 (Java 21 only until Sep 2027).
# Keep the agent's Java the same major version as the controller's.
#
# See ci/jenkins/README.md.
ARG AGENT_IMAGE=jenkins/inbound-agent:latest-jdk25
FROM ${AGENT_IMAGE}
USER root
# ---- base tools + .NET SDK (Microsoft's Debian feed for the image's own
# Debian release) -----------------------------------------------------------
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl wget unzip zip bzip2 python3 python3-venv \
&& . /etc/os-release \
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
&& apt-get update \
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
# build.py runs its helpers with -I (isolated), which ignores PYTHONPATH and
# user site-packages, so LIEF goes into the interpreter that is first on PATH.
RUN python3 -m venv /opt/py \
&& /opt/py/bin/pip install --no-cache-dir lief==0.15.1
ENV PATH="/opt/py/bin:${PATH}"
# ---- Android SDK: build-tools (zipalign, apksigner, aapt2) + NDK ----------
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ARG CMDLINE_TOOLS=11076708
ARG BUILD_TOOLS=34.0.0
ARG NDK=27.2.12479018
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/clt.zip \
&& unzip -q /tmp/clt.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/clt.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& (yes || true) | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install \
"build-tools;${BUILD_TOOLS}" "ndk;${NDK}" > /dev/null \
&& chown -R jenkins:jenkins ${ANDROID_SDK_ROOT}
ENV ANDROID_NDK=${ANDROID_SDK_ROOT}/ndk/${NDK}
# ---- apktool ---------------------------------------------------------------
ARG APKTOOL=2.9.3
RUN wget -q https://github.com/iBotPeaches/Apktool/releases/download/v${APKTOOL}/apktool_${APKTOOL}.jar \
-O ${ANDROID_SDK_ROOT}/apktool.jar
ENV APKTOOL_JAR=${ANDROID_SDK_ROOT}/apktool.jar
# ---- restic + rclone (restic reaches the FTP through rclone) --------------
ARG RESTIC=0.18.1
ARG RCLONE=1.68.2
RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic_${RESTIC}_linux_amd64.bz2 -O /tmp/restic.bz2 \
&& bunzip2 /tmp/restic.bz2 \
&& install -m 0755 /tmp/restic /usr/local/bin/restic && rm /tmp/restic \
&& wget -q https://downloads.rclone.org/v${RCLONE}/rclone-v${RCLONE}-linux-amd64.zip -O /tmp/rclone.zip \
&& unzip -q /tmp/rclone.zip -d /tmp/rclone \
&& install -m 0755 /tmp/rclone/rclone-v${RCLONE}-linux-amd64/rclone /usr/local/bin/rclone \
&& rm -rf /tmp/rclone /tmp/rclone.zip
COPY archive-restore.sh /usr/local/bin/archive-restore
RUN chmod +x /usr/local/bin/archive-restore
# Restored stock builds and the restic cache. Mount a volume here so each
# multi-GB stock build is downloaded once, not after every restart.
ENV DORKNET_CACHE=/home/jenkins/dorknet-cache
RUN mkdir -p ${DORKNET_CACHE} && chown jenkins:jenkins ${DORKNET_CACHE}
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1
USER jenkins
+249
View File
@@ -0,0 +1,249 @@
# Client builds on Jenkins
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
Jenkins.
The builds run on a **build agent**, not on Jenkins itself:
- **The agent:** a container built from `ci/jenkins/Dockerfile`, which is the
Jenkins inbound agent plus Python with LIEF, the .NET SDK, the Android NDK
and build-tools, apktool, restic and rclone. It runs on Coolify next to
Jenkins and connects to it over WebSocket on Docker's internal network. The
job runs on it through the `dorknet-builder` label.
- **Jenkins itself:** stays on the stock image and runs no builds.
Each build is published to the **downloads bucket** under a name players can
read, and the same files are kept as Jenkins artifacts
(`out/downloads/2023.03/`). The DorkNet server offers them on its site: the
**Downloads** page, and **Latest build** on the **Quest** page
(`docs/deploy.md` → Client downloads).
| Download | What it is | Built from |
|---|---|---|
| `DorkNet-2023.03-PC-Full.zip` | the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` → `dorknet-client-march_2023_03_21.zip` |
| `DorkNet-2023.03-PC-Patch.zip` | MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` → `dorknet-clientpatch-march_2023_03_21.zip` |
| `DorkNet-2023.03-Quest.xapk` | patched APK + OBB, for the browser installer or SideQuest | `DorkNet.Quest/scripts/build.py build` → `DorkNet-Quest-<ver>/DorkNet-<ver>.xapk` |
| `DorkNet-2023.03-Quest-Installer.zip` | `install.cmd` / `install.ps1` / `install.sh` + README, unzipped next to the `.xapk` | the same package folder |
The build scripts keep their own names: the launcher downloads
`dorknet-clientpatch-<VersionKey>.zip` from GitHub Releases by that exact
name. `ci/downloads/publish.py` renames only the published copies.
The full PC client and the Quest packages contain game files. Keep the bucket
internal and the site behind an access proxy; don't publish them anywhere
public.
## Where the stock builds come from
The stock builds live in restic repositories on the archive FTP. They are the
same repositories as the local archive's `v2/steam-v2` and `v2/quest-v2`. restic
reaches the FTP through its rclone backend (`ci/jenkins/archive-restore.sh`).
A build is picked by **snapshot tags**:
| Repository | Tags (examples) |
|---|---|
| Steam | `manifest=<depot manifest id>`, `date=…`, `branch=public` |
| Quest | `version=20230317`, `version-code=2128210` |
The job defaults to the builds this branch targets: Steam manifest
`7490748483298966814` (2023-03-21) and Quest `version-code=2128210`. Any other
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
matching a filter without building anything.
A restored snapshot is cached on the agent's `dorknet_cache` volume, so each
stock build is downloaded once.
A Quest build needs a resolved hook table for its versionCode, committed under
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
checks the table against the APK's `libil2cpp.so` and stops on a mismatch.
Resolve a table for another build locally with `build.py build --dump-cs`.
## Triggers
- **Push** to the branch (GitHub webhook) builds only for client-facing
changes, and only the platform they touch:
- `DorkNet.ClientMod/` and `tools/build_pc.py` rebuild PC;
- `DorkNet.Quest/` rebuilds Quest;
- Markdown files (`.md`, `.markdown`) never count; server, docs and CI-only pushes (including
the `Jenkinsfile` and `ci/`) build nothing. Run those manually.
Set the same filter on the job so other pushes don't start a run at all:
under the job's **Git** source, add **Additional Behaviours → Polling
ignores commits in certain paths** with included regions
`DorkNet[.]ClientMod/.*`, `DorkNet[.]Quest/.*`, `tools/build_pc[.]py` (one
per line) and excluded region `.*[.](md|markdown)`. The filter compares against the
last build's checkout, so the pipeline keeps it and deletes only `out/`
and its scratch folders after each run; without a checkout, Jenkins treats
every push as a change.
- **Build with Parameters** runs a manual build:
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
- snapshot tags, and per-run repository overrides;
- `PUBLISH_DOWNLOADS` (on by default) to publish to the downloads bucket.
Push builds publish too, but only the platform they rebuilt: the manifest
keeps the other platform's last files.
## Setup
### 1. Jenkins itself
The controller is `ci/jenkins/docker-compose.yml`: stock Jenkins, no build
tools. On Coolify, deploy it as a Docker Compose application from this
repository (base directory `/ci/jenkins`), or paste it into a Compose service.
- **Java 25.** The file defaults to `jenkins/jenkins:lts-jdk25`; set
`JENKINS_IMAGE` to pin a version, e.g. `jenkins/jenkins:2.587-jdk25`.
Jenkins supports Java 25 until September 2029, Java 21 only until September
2027, and Java 17 not at all. Keep the agent on the same Java major version;
its image defaults to Java 25.
- **`restart: unless-stopped`** is in the file: a restart from the Jenkins UI
exits the container with code 5 and relies on Docker to start it again.
- **Turn on "Connect to predefined network"** (Advanced) so the agent can
reach Jenkins by its container name, `jenkins-<uuid>`.
- **Run no builds on it:** **Manage Jenkins → Nodes → Built-In Node →
Configure**, set **Number of executors** to `0`.
Earlier versions of this file built the controller from `ci/jenkins/Dockerfile`,
which is now the agent's image. The service and data volume keep their names,
so an existing Jenkins deployed from this file keeps its jobs and credentials
when redeployed; it switches to the stock image, and its builds wait until
the agent (step 3) is connected.
That redeploy also upgrades Jenkins, and from Java 17 (the old default) to
Java 25, unless `JENKINS_IMAGE` pins your current image. Before it:
1. **Back up the data volume** (`jenkins_home`), e.g. with a Coolify volume
backup or `docker run --rm -v <volume>:/v -v "$PWD":/b alpine tar czf
/b/jenkins_home.tgz -C /v .`, so you can go back to the old image.
2. After it starts, open **Manage Jenkins**. Plugins that need a newer
version show up there as failed to load; update them under **Plugins →
Updates** and restart.
3. Run the job once (**LIST_SNAPSHOTS** is a quick check) before relying on it.
### 2. The agent node
**Manage Jenkins → Nodes → New Node**, a permanent agent:
| Setting | Value |
|---|---|
| Name | `dorknet-builder` |
| Labels | `dorknet-builder` |
| Remote root directory | `/home/jenkins/agent` |
| Launch method | Launch agent by connecting it to the controller, with **Use WebSocket** ticked |
| Number of executors | 1 |
Save, open the node, and copy its **secret**.
### 3. Deploy the agent on Coolify
1. **Create a Docker Compose application** from this repository: build pack
Docker Compose, base directory **`/ci/jenkins`**, compose location
**`/docker-compose.agent.yml`**. The base directory is the build context, so with
`/` the build would pick up the server's `Dockerfile` at the repo root. It
needs no domain. Under Advanced, turn on **Connect to predefined network**.
2. **Set its environment:**
| Variable | Value |
|---|---|
| `JENKINS_URL` | Jenkins' **internal** address, `http://jenkins-<service uuid>:8080/`. Not the public URL, which is behind Cloudflare Access. |
| `JENKINS_SECRET` | the node secret from step 2 |
| `JENKINS_AGENT_NAME` | optional, default `dorknet-builder` |
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
| `DOWNLOADS_S3_ENDPOINT` | the downloads bucket's S3 API, internal address, e.g. `http://garage-<service uuid>:3900` |
| `DOWNLOADS_BUCKET` | its name, e.g. `dorknet-downloads` |
3. **Deploy.** The first image build downloads the Android NDK (~1.5 GB) and
takes a while. The node shows as connected in Jenkins once the agent is up.
Both Jenkins and the agent join the server's predefined Coolify network, so
the agent reaches Jenkins by container name. Check the name under the Jenkins
service's containers before using it.
### 4. Plugins
The job needs these plugins:
- Pipeline
- Git
- GitHub (for the push trigger)
- Credentials Binding
- Timestamper
- AnsiColor
- Workspace Cleanup
Most are part of the suggested set.
### 5. Credentials
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
| ID | Kind | Value |
|---|---|---|
| `archive-ftp` | Username with password | the FTP login |
| `archive-ftp-host` | Secret text | FTP host name only (no `ftp://`, no user, no path) |
| `archive-restic-password` | Secret text | the archive's restic password |
| `quest-keystore` | Secret file | the Quest signing keystore |
| `quest-keystore-password` | Secret text | its store/key password |
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
| `quest-config` | Secret file | your `dorknet-quest.cfg` |
| `downloads-s3` | Username with password | S3 key for the downloads bucket: access key ID / secret. Give it read and write on that bucket only. |
**Use the keystore your players' installs were signed with**, the
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
different key means every player has to uninstall before installing a CI
build.
### 6. The job
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
2. Under **Pipeline**, choose **Pipeline script from SCM**:
- Git URL `https://github.com/DorkSquadRR/DorkNet.git`
- branch `*/march-2023-03-21`
- script path `Jenkinsfile`
3. Under **Triggers**, tick **GitHub hook trigger for GITScm polling**.
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
offer **Build with Parameters**.
### 7. GitHub webhook
In the repository's **Settings → Webhooks**:
- Payload URL: `https://<your jenkins>/github-webhook/`
- Content type: `application/json`
- Events: just the push event
### 8. The downloads bucket
`publish.py upload` puts the files and a `manifest.json` describing them in
an S3 bucket. The DorkNet server reads that bucket over HTTP on the internal
network and offers the files on its site (`docs/deploy.md` → Client
downloads), so the bucket needs no public route. With Garage (the S3 store on
the Coolify server):
1. **Bucket and key.** Create the bucket, turn on Garage's website access for
it (the server reads it through the web endpoint, port `3902`, which picks
the bucket by the request's Host header), and give Jenkins a key for that
bucket only:
```sh
garage bucket create dorknet-downloads
garage bucket website --allow dorknet-downloads
garage key create jenkins-downloads
garage bucket allow --read --write --key <key id> dorknet-downloads
```
Store the key as the `downloads-s3` credential.
2. **Server.** Point the DorkNet server at the web endpoint:
`DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902` and
`DorkNet__Downloads__Host=dorknet-downloads`.
Each release keeps one copy of each file; a new build overwrites it. The
manifest is uploaded last, so the site only ever lists files that are fully
uploaded.
### Disk
The agent's `dorknet_cache` volume holds the restored stock builds, several GB
each. Jenkins keeps the artifacts of the last 3 runs, on its own volume.
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# archive-restore — pull a Rec Room build out of the restic build archive.
#
# archive-restore list <repo-path> [tag[,tag…]]
# archive-restore restore <repo-path> <tag[,tag…]> <cache-dir> [restic restore args…]
#
# <repo-path> is the restic repository's folder on the archive FTP, e.g.
# "Rec Room Versions/steam-v2". Snapshots are picked by tag, e.g.
# "manifest=7490748483298966814" (Steam) or "version-code=2128210" (Quest);
# several tags separated by commas must all match. The newest match wins.
#
# `restore` writes the snapshot to <cache-dir>/<snapshot-id>/ and prints that
# path. A snapshot already restored there is reused, so a multi-GB build is
# only downloaded once. Extra arguments go to `restic restore` (e.g.
# --exclude /.DepotDownloader).
#
# restic reaches the FTP through its rclone backend. Everything secret comes
# from the environment (Jenkins injects it from credentials):
# ARCHIVE_FTP_HOST FTP host name
# ARCHIVE_FTP_USER FTP user
# ARCHIVE_FTP_PASSWORD FTP password
# RESTIC_PASSWORD restic repository password
# Optional: ARCHIVE_FTP_PORT (21), ARCHIVE_FTP_TLS (none | explicit | implicit,
# default explicit), RESTIC_CACHE_DIR.
set -euo pipefail
mode="${1:?usage: archive-restore list|restore <repo-path> …}"
repo_path="${2:?repo path required}"
tags="${3:-}"
: "${ARCHIVE_FTP_HOST:?ARCHIVE_FTP_HOST is not set}"
: "${ARCHIVE_FTP_USER:?ARCHIVE_FTP_USER is not set}"
: "${ARCHIVE_FTP_PASSWORD:?ARCHIVE_FTP_PASSWORD is not set}"
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD is not set}"
# rclone remote "rrarchive", configured purely through its env-var config.
export RCLONE_CONFIG_RRARCHIVE_TYPE=ftp
export RCLONE_CONFIG_RRARCHIVE_HOST="$ARCHIVE_FTP_HOST"
export RCLONE_CONFIG_RRARCHIVE_USER="$ARCHIVE_FTP_USER"
export RCLONE_CONFIG_RRARCHIVE_PORT="${ARCHIVE_FTP_PORT:-21}"
RCLONE_CONFIG_RRARCHIVE_PASS="$(printf '%s' "$ARCHIVE_FTP_PASSWORD" | rclone obscure -)"
export RCLONE_CONFIG_RRARCHIVE_PASS
case "${ARCHIVE_FTP_TLS:-explicit}" in
explicit) export RCLONE_CONFIG_RRARCHIVE_EXPLICIT_TLS=true ;;
implicit) export RCLONE_CONFIG_RRARCHIVE_TLS=true ;;
none) ;;
*) echo "archive-restore: ARCHIVE_FTP_TLS must be none, explicit or implicit" >&2; exit 2 ;;
esac
export RESTIC_REPOSITORY="rclone:rrarchive:${repo_path}"
tag_args=()
[ -n "$tags" ] && tag_args=(--tag "$tags")
case "$mode" in
list)
restic snapshots --no-lock "${tag_args[@]}"
;;
restore)
cache="${4:?cache dir required}"
[ -n "$tags" ] || { echo "archive-restore: restore needs a tag filter" >&2; exit 2; }
shift 4
id="$(restic snapshots --no-lock --json "${tag_args[@]}" \
| python3 -c 'import json,sys; s=sorted(json.load(sys.stdin), key=lambda x: x["time"]); print(s[-1]["short_id"] if s else "")')"
if [ -z "$id" ]; then
echo "archive-restore: no snapshot in '$repo_path' has tags '$tags'" >&2
exit 1
fi
dest="$cache/$id"
if [ -f "$dest.restored" ]; then
echo "archive-restore: reusing $id from $dest" >&2
else
echo "archive-restore: restoring $id ($tags) from '$repo_path'" >&2
rm -rf "$dest" "$dest.restored"
mkdir -p "$dest"
restic restore --no-lock "$id" --target "$dest" "$@" >&2
touch "$dest.restored"
fi
echo "$dest"
;;
*)
echo "archive-restore: unknown mode '$mode'" >&2
exit 2
;;
esac
+41
View File
@@ -0,0 +1,41 @@
# DorkNet build agent on Coolify. Deploy as its own Docker Compose application
# from this repository with base directory /ci/jenkins, compose location
# /docker-compose.agent.yml and "Connect to predefined network" on. It needs no
# domain: it dials out to Jenkins over WebSocket on Docker's internal network.
# Set the variables in the resource's Environment Variables; see
# ci/jenkins/README.md. The controller is docker-compose.yml.
services:
agent:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
# Reap the processes build tools leave behind (dotnet build servers,
# Gradle daemons, adb), as the inbound-agent image's docs ask for.
init: true
environment:
# Jenkins as seen from inside Docker (e.g. http://<jenkins container
# name>:8080), not the public URL, which is behind Cloudflare Access.
# Both are needed at runtime only; keep them out of the image build so
# the node secret never reaches a build argument.
JENKINS_URL: ${JENKINS_URL}
JENKINS_AGENT_NAME: ${JENKINS_AGENT_NAME:-dorknet-builder}
JENKINS_SECRET: ${JENKINS_SECRET}
JENKINS_WEB_SOCKET: "true"
JENKINS_AGENT_WORKDIR: /home/jenkins/agent
# Where the stock builds live in the archive (restic repository folders
# on the FTP, plain text). Overridable per run.
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
# The downloads bucket (S3 API, internal address) the builds are
# published to. See ci/downloads/publish.py.
DOWNLOADS_S3_ENDPOINT: ${DOWNLOADS_S3_ENDPOINT:-}
DOWNLOADS_BUCKET: ${DOWNLOADS_BUCKET:-}
volumes:
- dorknet_cache:/home/jenkins/dorknet-cache
- agent_work:/home/jenkins/agent
volumes:
dorknet_cache:
agent_work:
+25
View File
@@ -0,0 +1,25 @@
# The Jenkins controller: stock Jenkins on Java 25. It runs no builds; the
# client builds run on the agent (docker-compose.agent.yml). On Coolify,
# deploy it as a Docker Compose application from this repository (base
# directory /ci/jenkins) or paste it into a Compose service, with "Connect to
# predefined network" on so the agent can reach it. See ci/jenkins/README.md.
#
# Earlier versions of this file built the controller from ci/jenkins/Dockerfile,
# which is now the agent's image. The service name and data volume are the
# same, so redeploying an existing Jenkins from this file keeps its jobs and
# credentials; its builds then wait for the agent.
services:
jenkins:
# Pin a version with e.g. jenkins/jenkins:2.587-jdk25. Keep the agent on
# the same Java major version (its image defaults to Java 25).
image: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk25}
# A restart from the Jenkins UI exits with code 5 and relies on Docker to
# start it again.
restart: unless-stopped
expose:
- "8080"
volumes:
- jenkins_home:/var/jenkins_home
volumes:
jenkins_home:
+33
View File
@@ -412,6 +412,39 @@ for building and installing the patched APK.
--- ---
## Client downloads
Optional. When CI publishes the client builds to a bucket
(`ci/downloads/publish.py`, see `ci/jenkins/README.md`), the server can offer
them on the public site:
- a **Downloads** page (`/downloads`) listing every published file with its
size and SHA-256;
- **Latest build** on the **Quest** page, which installs the newest Quest
package straight from the server: the browser reads it with HTTP Range
requests, so nothing is downloaded first.
The server reads `manifest.json` and the files over plain HTTP, from inside
your network, and streams them to the browser. Only files the manifest lists
are served. The bucket itself never needs to be public.
```env
# Where manifest.json is: e.g. Garage's web endpoint on the internal network.
DorkNet__Downloads__BaseUrl=http://garage-<service uuid>:3902
# Optional Host header, for web endpoints that pick the bucket by host name
# (Garage serves the bucket whose name matches it).
DorkNet__Downloads__Host=<bucket name>
```
Unset, the site hides both features. The builds contain Rec Room's own game
files: only turn this on when the site is behind an access proxy (e.g.
Cloudflare Access) that admits just your community. The downloads routes
answer only on the apex host, where the site lives, and return 404 on every
other host the server accepts, so leaving the game's own subdomains open for
the client doesn't expose the files. Put the apex behind the proxy.
---
## Photon Cloud ## Photon Cloud
Use your own Photon AppIds. Rec Room's original Photon AppIds reject Use your own Photon AppIds. Rec Room's original Photon AppIds reject
+24 -3
View File
@@ -55,6 +55,17 @@ an account, never matched by the cached-login lookup
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path. players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
A real Meta platform id (the Meta build) still tags and lists normally. A real Meta platform id (the Meta build) still tags and lists normally.
Because the placeholder never lists a remembered account, the title screen
would normally create a new account on every fresh headset. With
`AutoCreateAccount = false` (the default) the hook library opens the sign-in
page instead, so players log into their existing DorkNet account; set it to
`true` only on a server that allows sign-ups.
On a headset that reaches Meta, the Oculus platform also refuses the
"logged-in user" request for the renamed app ("Unable to load Oculus user
account"). The no-Meta build passes that refusal and hands the game the same
placeholder user id `1`.
Thanks to [RecFlare](https://github.com/recflare) for documenting this Thanks to [RecFlare](https://github.com/recflare) for documenting this
pitfall in their Quest client notes. pitfall in their Quest client notes.
@@ -69,8 +80,18 @@ pitfall in their Quest client notes.
3. Image signing key configured (`ImageSignatureService`), as for PC. 3. Image signing key configured (`ImageSignatureService`), as for PC.
4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing 4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing
`-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus `-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus
`install.cmd`/`install.sh`). Players run the installer with the headset `install.cmd`/`install.sh`). Players install it from your site's
plugged in; the app appears as **DorkNet** under Unknown Sources. **Quest** page (`https://<apex>/quest`, Chrome or Edge over USB) or with the
installer scripts; the app appears as **DorkNet** under Unknown Sources.
Share the `.xapk` however you like: the page reads it from the player's own
disk, so your server never hosts game files.
5. Optionally, let players install the latest CI build without downloading it
first: point the server at the downloads bucket CI publishes to
(`DorkNet__Downloads__BaseUrl`, see `deploy.md` → Client downloads). The
Quest page then offers **Latest build** next to **Choose file** and streams
the package from the server during the install, and the site gets a
**Downloads** page. The builds contain game files, so only do this with
the site behind an access proxy.
No server component is Quest-specific beyond the placeholder-platform-id No server component is Quest-specific beyond the placeholder-platform-id
handling above. handling above and the optional downloads proxy.
+20 -16
View File
@@ -120,17 +120,19 @@ Defect classes:
## Realtime (SignalR) surface ## Realtime (SignalR) surface
Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1` Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1`
(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). (`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). The full push
contract — every handler key, its payload, and what DorkNet sends — is in
[recroom-2023-notifications.md](recroom-2023-notifications.md).
- Server→client messages arrive on the hub method named **`"Notification"`** - Server→client messages arrive on the hub method named **`"Notification"`**
(singular) carrying a **single `string`** argument whose content is JSON (singular) carrying a **single `string`** argument whose content is JSON
`{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`. `{"Id":..., "Msg":...}`. The handler is registered as `Action<string>`.
Sending `ReceiveNotification`, or passing an object instead of a string, Sending `ReceiveNotification`, or passing an object instead of a string,
makes every push silently vanish. makes every push silently vanish.
- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus - Client→server: `SubscribeToPlayers` (`{PlayerIds}`), plus
`"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime. `SubscribeTo` / `UnsubscribeFrom` (`{Channels}`, always empty).
- Presence pushes are routed by the friend graph server-side, so the - `SubscribeToPlayers` is load-bearing: it is how the client gets presence
subscription calls are advisory rather than load-bearing. for players who aren't friends (people in the room, an open profile).
### Push dispatch keys — the numeric-vs-name trap ### Push dispatch keys — the numeric-vs-name trap
@@ -160,10 +162,11 @@ So **send the number**, except for these six, which are string-keyed:
| 95 CommunityBoardUpdate | `CommunityBoardUpdate` | | 95 CommunityBoardUpdate | `CommunityBoardUpdate` |
Ids the 2023 client registers NO handler for — sending them is a no-op: Ids the 2023 client registers NO handler for — sending them is a no-op:
4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. Room bans must 4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. The moderation
therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation
manager registers only 22, `ModerationUnkick` and 23 manager registers only 22, `ModerationUnkick` and 23
(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`). (`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`), and a kick (22) only sends
the player to their dorm, so bans go out as `Logout` (6) with Reason 4
(Banned); see recroom-2023-notifications.md.
Ids the client handles that this server never sends (dead client features, Ids the client handles that this server never sends (dead client features,
available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of
@@ -173,12 +176,12 @@ string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`,
### Hub methods ### Hub methods
The client invokes exactly one server method: `SubscribeToPlayers`, with a The client sends `SubscribeToPlayers` with a `{PlayerIds: List<int>}`
`{PlayerIds: List<int>}` payload, fire-and-forget. The `SubscribeTo`/ payload, fire-and-forget, on every (re)connect and whenever the set changes.
`UnsubscribeFrom` composition exists in the binary but has no callers in The `SubscribeTo`/`UnsubscribeFrom` composition runs on connect but its
this build, and `UnsubscribeFromPlayers` does not appear at all — the channel set is always empty, and `UnsubscribeFromPlayers` does not appear at
server's extra hub methods are unused but harmless. Presence is routed all. The server keeps each connection's subscriptions as `presence:{id}`
server-side by the friend graph, so these calls are advisory. SignalR groups and pushes those players' presence to them.
### Negotiate ### Negotiate
@@ -257,7 +260,8 @@ verified against the binary before the change, and the full test suite passes.
| `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted | | `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted |
| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly | | `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly |
| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` | | `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` |
| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan | | `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan (later replaced by Logout Reason 4, below) |
| `Notification payloads (Oct 2026 audit)` | RelationshipChanged sent `{Reason, From}`; ModerationKick used the nested 2020 shape and lost every reason; bans, deletes and takeovers only kicked to the dorm; Logout sent an enum NAME and was dropped; ServerMaintenance sent `{Message}`; ConsumableMapping, PlayerEvent 80/81/83, admin RoomUpdate and level-up pushes had wrong or missing payloads; SubscribeToPlayers was ignored | every payload matches the client DTO; bans/deletes/takeovers are Logout with a numeric Reason; SubscribeToPlayers drives `presence:{id}` groups; see recroom-2023-notifications.md |
| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline | | `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline |
| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake | | `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake |
| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject | | `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject |
@@ -659,7 +663,7 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. | | GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. |
| POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 | | POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 |
| DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 | | DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 |
| POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | FGCPNAACHIK FULL room details of the clone (not a status wrapper); client 404→empty-body surfaces as message-less 'Failed to copy room' | | POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | `{success, value, error}` envelope with `value` = FGCPNAACHIK FULL room details of the clone (same helper as "save room"; a bare room has no `success` and is rejected with an empty error). Client 404→empty-body also surfaces as message-less 'Failed to copy room' |
| PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details | | PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details |
| PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details | | PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details |
+139
View File
@@ -0,0 +1,139 @@
# 2023 client notifications (realtime push)
The contract between DorkNet and the 2023-03-21 client's notification hub.
Sources: the client's RecNet.Runtime ISIL (`C:\tmp\recroom-2023-03-21-isil`,
obfuscated names given as found there), cross-checked against RecFlare
(build 20230414) where useful. Code: `DorkNet.Server/Hubs/NotifyHub.cs`,
`Services/NotificationService.cs`, `Services/PresenceBroadcastService.cs`,
`Services/SystemNotificationService.cs`. Tests:
`DorkNet.Server.Tests/NotificationContractTests.cs`.
## Connection
- The hub is `{notify host}/hub/v1` (nameserver key `Notifications`). The
client opens it once, at login.
- **Negotiate**: `POST …/hub/v1/negotiate`, no `negotiateVersion`. The client reads
`connectionId` and `availableTransports`, and accepts only `WebSockets`.
- **WebSocket**: `…/hub/v1?id=<connectionId>`, JSON protocol
(`{"protocol":"json","version":1}`), no MessagePack.
- **Auth**: `Authorization: Bearer` on both negotiate and the upgrade. No
`access_token` query string.
- **Keep-alive**: the client pings every 15 s from its main thread and has
no server timeout of its own. The server pings every 10 s and allows 180 s
of client silence, because a long room-load hitch pauses the client's
pings.
- **Reconnects: at most 3 per login.** The client's attempt counter is never
reset after a successful reconnect. Once the budget is spent the hub stays
dead, with no message, until the next login. The server must never close a
healthy connection.
## Envelope
Every push invokes the hub method **`Notification`** with **one string
argument**, whose content is JSON:
```json
{"Id":"<key>","Msg":{...}}
```
- **`Id`** is a JSON string. It is looked up case-sensitively in the
client's handler table, and a miss drops the push silently.
- Handlers registered through the enum overload use the decimal string of
the id (`"2"`).
- The rest use a name (see the table below).
- `NotificationService.WireName` maps `PushNotificationId` to the key.
- **`Msg`** is an inline JSON object of the handler's DTO, never
JSON-in-a-string.
- It is parsed strictly. Keys may be PascalCase, camelCase or all
lowercase, and unknown keys are skipped.
- A value of the wrong type (a number where a string is expected, a
string where an enum number is expected) throws, and the whole push is
dropped.
- A key that is missing takes its default value, which usually means the
push "works" but does nothing.
## Hub calls (client → server)
| Method | Payload | Server |
|---|---|---|
| `SubscribeToPlayers` | `{PlayerIds: List<int>}`: the client's relationships, the players around it, and itself. Sent on every (re)connect and whenever the set changes, always the complete set. | Replaces the connection's subscriptions. The connection is put in a `presence:{id}` group per player (own id skipped), and `PresenceBroadcastService` pushes those players' presence to those groups. Only presence, never the subject's other pushes. An unreadable payload keeps the current set. |
| `SubscribeTo` / `UnsubscribeFrom` | `{Channels: List<string>}`, always empty in this build | logged only |
There is no `UnsubscribeFromPlayers`.
## Handler table
These are the handlers the client registers. "Sent" names what DorkNet sends
today.
| Key | Msg | Sent by |
|---|---|---|
| `1` RelationshipChanged | `{PlayerID, RelationshipType, Muted, Ignored, Favorited}`: the recipient's own view, the same row `api/relationships/v2/get` lists | friend request / accept / remove / add / add-with-code (both players); favorite / mute / ignore and their reverses (caller only) |
| `2` MessageReceived | `{Id, FromPlayerId (Int32), SentTime, Type, Data (string), RoomId, PlayerEventId}`, the same row `api/messages/v2/get` lists | DMs and game invites; `SystemNotificationService` (cheers, friend request Type 4, accepted Type 40, "wants to join" Type 10, coach message Type 100, event invite text) |
| `3` MessageDeleted | `{Id}` | not sent |
| `5` RefreshLogin | none | not sent |
| `6` Logout | `{Reason}`: a **number**, enum Unknown 0, UserInitiated 1, SessionTakeover 2, ForciblyLoggedOut 3, Banned 4 | `NotificationService.LogoutAsync`: second-session takeover (2), account deleted or password reset (3), bans (4) |
| `20` / `ModerationQuitGame` | none | not sent |
| `22` ModerationKick | **flat** `{ReportCategory, Duration, GameSessionId, IsHostKick, Message, PlayerIdReporter, IsBan, IsVoiceModAutoban, IsWarning, VoteKickReason, TimeoutStartedAt}` | `KickPlayerAsync`: admin kick, close instance, in-game instant kick, join timeout |
| `ModerationUnkick` | same as 22 | not sent |
| `23` ModerationKickAttemptFailed | `{ReportCategory, YesVotes, NoVotes, PlayerIdReported}` | not sent |
| `25` ServerMaintenance | `{StartsInMinutes}`; 0 cancels the countdown | admin Broadcast, when a countdown is given |
| `30` / `31` GiftPackageReceived(Immediate) | the full gift package, as `api/avatar/v2/gifts` lists it (`AvatarGiftsController.ToWire`) | gifts, store gift purchases, free gifts, admin gifts |
| `50` RelationshipsInvalid | none (the client refetches) | admin "everyone is friends" toggle |
| `60` / `61` / `62` StorefrontBalance Add / Update / Purchase | 61 `{Balance, CurrencyType, Platform}`; 62 `{BalanceAddType, Delta, Balance, Platform, CurrencyType}` | `LevelService.GrantCurrencyAsync` (61), store purchase (62) |
| `70` / `71` ConsumableMapping Added / Removed | `{Id, ConsumableItemDesc (string), Count, InitialCount, CreatedAt, ActiveDurationMinutes, IsActive, IsTransferable}` | transfer (70 to the recipient), consume (always 71, with the remaining Count) |
| `80` / `81` PlayerEvent Created / Updated | the full player event, as `api/playerevents/v2/{id}` returns it | invites (80 to each invitee); every edit route and the broadcast setting (81 to attendees) |
| `82` / `84` PlayerEvent Deleted / ResponseDeleted | `{PlayerEventId}` | event delete (82 to attendees), RSVP removed (84 to the creator) |
| `83` PlayerEventResponseChanged | `{PlayerEvent, PlayerEventResponse}` | RSVP (to the creator) |
| `100` InventionModerationStateChanged | none | not sent |
| `AccountUpdate` / `SelfAccountUpdate` | the public / self account DTOs the account REST routes return | every profile edit, including profile image (`NotificationService.AccountChanged`) |
| `PresenceUpdate` | `{playerId, statusVisibility, deviceClass, vrMovementMode, roomInstance, isOnline, appVersion (string)}` | `PresenceBroadcastService`, to friends and subscribers, **never to the subject** (a frame about the local player moves them) |
| `RoomInstanceUpdate` | the room instance (`RoomInstanceDto`) | admin force-join / instance pull. It only refreshes the cached instance; the move itself comes from the next heartbeat. |
| `RoomUpdate` | the **full room** (`FGCPNAACHIK`, `RoomsController.BuildRoomServerDetails`), not the 2020 `{Room, Scenes}` wrapper | admin role / owner changes (everyone in the room plus the changed player), admin force-join |
| `ChatMessageReceived` | `{chatMessageId, chatThreadId, senderPlayerId, timeSent, contents}` | chat sends, to every member including the sender |
| `PlayerLeftChat` | the same chat-message shape; only `chatThreadId` and `senderPlayerId` (the leaver) are read | leaving a group chat |
| `PlayerProgressionLevelUpdate` | `{PlayerId, Level, XP}`, as `api/players/v2/progression` returns it | every XP award (`LevelService.AwardXpAsync`) |
| `CommunityBoardUpdate` | community board | community board edits |
| `ReputationUpdate`, `RoomCurrency*`, `KeepsakeInstance*`, `PhotonAccessToken`, `GoToFailure`, `AnnouncementUpdate/Delete`, `ClubMembershipUpdate`, `GameConfig.Refresh`, `PlayerSettings.Refresh`, … | see the contract notes in `recroom-2023-client-api-complete.md` | not sent |
**Not registered at all** (a push with these keys is dropped): numeric 4, 11,
12, 13, 15, 21, 24 (ModerationRoomBan), 40, 85, 90, 95.
## Kicks versus logouts
A ModerationKick only sends the player to their dorm, still signed in.
The kick handler builds the dialog text like this:
- It always starts from its own "Kicked to Dormroom".
- It appends "Reason: …" from `VoteKickReason` only when `ReportCategory`
is 10 and `IsHostKick` is false. `Message` is never shown.
- When `GameSessionId` is the kicked player's current room instance, it also
announces the kick to that room.
- A `Duration` above 0 starts a timeout follow-up.
Anything that must sign the player out uses Logout instead: bans, account
deletion, password resets and second-session takeover. The ban check then
refuses the banned player's next login.
## Admin broadcast
`POST api/admin/v1/broadcast` `{Message?, StartsInMinutes?}`:
- `Message` becomes a coach message (MessageReceived Type 100 from the
system account) in every online player's notifications.
- `StartsInMinutes` starts the client's own maintenance countdown (25).
ServerMaintenance has no text field. The old `{Message}` payload was never
shown, and it cancelled any running countdown.
## Not yet done
- **No offline queue.** A push sent while the player has no connection is lost.
Messages are stored, so they still appear in `api/messages/v2/get`.
RecFlare keeps the newest 500 per player and flushes them on connect.
- The 2020 save and restore routes (`api/rooms/v4/saveData`,
`…/restoredata`, `api/rooms/v1/datahistory/restore`) still push the 2020
`RoomUpdate` wrapper and a self PresenceUpdate. The 2023 client never
calls them; its save (`subrooms/{n}/data`) and publish routes push
nothing and return the room in the HTTP reply.
- `sendfriendintroductions` stores Type 30 text instead of FriendIntroduction
(130), and pushes nothing. The format of the 130 `Data` field is unknown.
+9 -2
View File
@@ -89,8 +89,15 @@ body — the same `roomserver/` prefix as every other room mutation from
that client. A bare-only `rooms/{id}/clone` route 404s with an empty that client. A bare-only `rooms/{id}/clone` route 404s with an empty
body, which the client's promise layer surfaces as a message-less body, which the client's promise layer surfaces as a message-less
`Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`. `Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown`.
The response must be the FULL room-details object (`FGCPNAACHIK`, The response is the legacy `{success, value, error}` envelope, the same
same shape as `GET roomserver/rooms/{id}`), not a status wrapper. one "save room" and "restore subroom save" use: the clone goes through the
same RecNet rooms helper (`NLDBPDCNNCF.BDBHCEGKGDC`, which takes an op
name). `value` is the FULL room-details object (`FGCPNAACHIK`, same shape
as `GET roomserver/rooms/{id}`). A bare room-details body has no `success`,
so the client rejects it with an empty error and shows the same
message-less `Failed to copy room`, even though the room was created.
Refusals (unknown source, cloning not allowed) are HTTP 200 with
`success: false` and the message in `error`.
Blob semantics on clone (`RoomsModerationController.BareClone`): Blob semantics on clone (`RoomsModerationController.BareClone`):
-157
View File
@@ -1,157 +0,0 @@
<#
.SYNOPSIS
Builds the per-version dorknet-clientpatch-{VersionKey}.zip the
DorkNet launcher expects to find as a GitHub Release asset.
.DESCRIPTION
Output zip layout (see launcher/RELEASES.md on the public main
branch for the full contract):
manifest.json (schema v1, describes the install)
MelonLoader.zip (downloaded from melonloader github)
DorkNet.ClientMod.dll (built from this repo)
dorknet-clientmod.json.template (copied from DorkNet.ClientMod/)
The launcher reads manifest.json, unzips MelonLoader on top of the
user's Rec Room install root, copies the DLL into Mods/, then
renders the config template into UserData/.
Self-contained: downloads MelonLoader before the dotnet build and
feeds its DLLs into the build as references, so no local Rec Room
install is required to compile DorkNet.ClientMod. (The mod resolves
every game-side type at runtime via AccessTools.TypeByName.)
.PARAMETER VersionKey
The version key matching versions.json on the public main branch
(e.g. december_2020_12_18 or march_2020_03_10).
.PARAMETER MelonLoaderVersion
MelonLoader release tag to bundle. Defaults to the 0.6.x stable
target — that's the loader DorkNet.ClientMod compiled against.
.PARAMETER OutputDir
Where the final zip lands. Defaults to repo-root/dist.
.EXAMPLE
.\tools\build-patcher-zip.ps1 -VersionKey december_2020_12_18
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$VersionKey,
[string]$MelonLoaderVersion = "v0.6.6",
[string]$OutputDir = $null
)
$ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path "$PSScriptRoot\.."
if (-not $OutputDir) { $OutputDir = Join-Path $repoRoot 'dist' }
New-Item -ItemType Directory -Force $OutputDir | Out-Null
$stage = Join-Path $env:TEMP "dorknet-patcher-$VersionKey-$([guid]::NewGuid().Guid.Substring(0,8))"
New-Item -ItemType Directory -Force $stage | Out-Null
try {
# ── 1. Fetch MelonLoader from upstream releases ──────────────────────
# We download it first so the DLLs inside can serve as compile-time
# references for DorkNet.ClientMod — keeps the build self-contained
# (no local Rec Room install required to compile the mod).
# x64 .NET 6 build targets 2020-era Rec Room. The asset name pattern
# is stable across 0.6.x: MelonLoader.x64.zip.
$mlAsset = "MelonLoader.x64.zip"
$mlUrl = "https://github.com/LavaGang/MelonLoader/releases/download/$MelonLoaderVersion/$mlAsset"
$mlZip = "$stage\MelonLoader.zip"
$mlExtract = "$stage\melonloader-extracted"
Write-Host "[fetch] downloading $mlUrl"
Invoke-WebRequest -Uri $mlUrl -OutFile $mlZip
if (-not (Test-Path $mlZip) -or (Get-Item $mlZip).Length -lt 1MB) {
throw "MelonLoader download failed or produced an unexpectedly small file"
}
Expand-Archive -Path $mlZip -DestinationPath $mlExtract -Force
# The 0.6.x release zip layout has a top-level `MelonLoader` folder
# containing net6, net35, Dependencies, Documentation. Point the
# csproj at that inner folder.
$mlRefRoot = Join-Path $mlExtract 'MelonLoader'
if (-not (Test-Path "$mlRefRoot\net6\MelonLoader.dll")) {
throw "Extracted MelonLoader is missing net6\MelonLoader.dll — release layout may have changed"
}
# ── 2. Build DorkNet.ClientMod (no Rec Room install needed) ─────────
Write-Host "[build] dotnet build DorkNet.ClientMod (Release)"
dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" `
-c Release --nologo `
"-p:MelonLoaderDir=$mlRefRoot" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "ClientMod build failed" }
$dll = Get-ChildItem "$repoRoot\DorkNet.ClientMod\bin\Release" -Recurse `
-Filter "DorkNet.ClientMod.dll" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $dll) { throw "DorkNet.ClientMod.dll not found post-build" }
Copy-Item $dll.FullName "$stage\DorkNet.ClientMod.dll"
Write-Host "[build] copied $($dll.FullName)"
# ── 3. Copy the config template ──────────────────────────────────────
$template = "$repoRoot\DorkNet.ClientMod\dorknet-clientmod.json.template"
if (-not (Test-Path $template)) {
throw "Config template missing: $template"
}
Copy-Item $template "$stage\dorknet-clientmod.json.template"
# ── 4. Copy the Steam stub (Goldberg) + appid ────────────────────────
# Replaces the Valve steam_api64.dll so the unwrapped exe can run
# without Steam loaded. App ID 471710 is Rec Room — same across all
# versions we ship for.
$stubDll = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_api64.dll"
$stubAppid = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_appid.txt"
if (-not (Test-Path $stubDll)) {
throw "Steam stub DLL missing: $stubDll"
}
if (-not (Test-Path $stubAppid)) {
throw "Steam appid file missing: $stubAppid"
}
Copy-Item $stubDll "$stage\steam_api64.dll"
Copy-Item $stubAppid "$stage\steam_appid.txt"
# ── 5. Write manifest.json ──────────────────────────────────────────
$manifest = @{
'$schema_version' = 1
'loader_archive' = 'MelonLoader.zip'
'plugin_dll' = 'DorkNet.ClientMod.dll'
'plugin_dest' = 'Mods'
'config_template' = 'dorknet-clientmod.json.template'
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
'old_plugin_paths' = @(
'BepInEx/plugins/DorkNet.ClientPatch.dll'
)
'steam_stub' = @{
# 2020.12+ is an EAC build: the real Unity binary is
# RecRoom.exe (loads RecRoom_Data/Plugins/x86_64/steam_api64.dll),
# NOT Recroom_Release.exe (which is the EAC launcher wrapper
# that loads Recroom_Release_Data/Plugins/steam_api64.dll).
# Patching the wrapper's DLL did nothing because the
# launcher's data dir is never opened by the real game
# process — symptom was "Failed to initialize Steam
# platform" because the unpatched real Steam DLL in
# RecRoom_Data tried to handshake.
'api_dll' = 'steam_api64.dll'
'api_dest' = 'RecRoom_Data/Plugins/x86_64/steam_api64.dll'
'api_backup_suffix' = '.steam-original'
'appid_file' = 'steam_appid.txt'
'appid_dest' = 'steam_appid.txt'
}
} | ConvertTo-Json -Depth 4
Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest
# ── 6. Bundle into the final zip ────────────────────────────────────
# Strip the extracted MelonLoader scratch dir before zipping so it
# doesn't bloat the artifact. The MelonLoader.zip itself stays.
Remove-Item -Recurse -Force $mlExtract
$outZip = Join-Path $OutputDir "dorknet-clientpatch-$VersionKey.zip"
if (Test-Path $outZip) { Remove-Item $outZip -Force }
Compress-Archive -Path "$stage\*" -DestinationPath $outZip
$sizeMb = [math]::Round((Get-Item $outZip).Length / 1MB, 1)
Write-Host ""
Write-Host "Built $outZip ($sizeMb MB)" -ForegroundColor Green
Write-Host " Upload this to the v1-* GitHub Release on this branch."
}
finally {
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Build the DorkNet PC client packages.
patcher dorknet-clientpatch-<VersionKey>.zip, what the DorkNet launcher
installs: MelonLoader.zip, DorkNet.ClientMod.dll (built from this
repo), the config template, the Goldberg Steam stub and a
manifest.json saying where each piece goes. No game files.
client a ready-to-run client: a stock Rec Room build with a patcher zip
applied the way the launcher applies it, plus your config. It
contains the game's own files, so keep it private.
Runs on Windows, Linux and macOS. `patcher` needs the .NET SDK (the mod
builds against MelonLoader's own DLLs, so no Rec Room install is needed).
py -3 tools/build_pc.py patcher --version-key march_2023_03_21
py -3 tools/build_pc.py client --base-client RecRoom-2023-03-21.zip \\
--patcher-zip dist/dorknet-clientpatch-march_2023_03_21.zip \\
--config dorknet-clientmod.json
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import NoReturn
REPO = Path(__file__).resolve().parent.parent
CLIENT_MOD = REPO / "DorkNet.ClientMod"
# Where each build expects the mod's config and the Steam DLL the game actually
# loads (see the per-branch build layout notes in the README):
# 2020.03 Recroom_Release.exe is the game; Steam DLL in its Data/Plugins.
# 2020.12 RecRoom.exe is the game (Recroom_Release.exe is the EAC wrapper);
# MelonLoader keeps UserData under MelonLoader/.
# 2023.03 RecRoom.exe + RecRoom_Data; MelonLoader 0.6 keeps UserData at the
# install root.
LAYOUTS = {
"march_2020_03_10": ("MelonLoader/UserData/dorknet-clientmod.json", "Recroom_Release_Data/Plugins/steam_api64.dll"),
"december_2020_12_18": ("MelonLoader/UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
"march_2023_03_21": ("UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
}
def step(msg: str) -> None:
print(f"==> {msg}", flush=True)
def fail(msg: str) -> NoReturn:
raise SystemExit(f"error: {msg}")
def need(path: Path | str, what: str) -> Path:
p = Path(path)
if not p.exists():
fail(f"{what} not found: {p}")
return p
def zip_dir(src: Path, out: Path) -> None:
"""Zip the contents of src (not src itself) into out."""
out.parent.mkdir(parents=True, exist_ok=True)
out.unlink(missing_ok=True)
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z:
for f in sorted(src.rglob("*")):
if f.is_file():
z.write(f, f.relative_to(src).as_posix())
# ---- patcher --------------------------------------------------------------
def cmd_patcher(a: argparse.Namespace) -> None:
if a.version_key not in LAYOUTS:
fail(f"unknown version key '{a.version_key}'. Known: {', '.join(LAYOUTS)}")
config_dest, steam_dest = LAYOUTS[a.version_key]
out_dir = Path(a.output_dir) if a.output_dir else REPO / "dist"
with tempfile.TemporaryDirectory(prefix=f"dorknet-patcher-{a.version_key}-") as tmp:
stage = Path(tmp) / "stage"
stage.mkdir()
# 1. MelonLoader. Downloaded first so its DLLs can serve as the mod's
# compile-time references: no local Rec Room install needed.
url = f"https://github.com/LavaGang/MelonLoader/releases/download/{a.melonloader_version}/MelonLoader.x64.zip"
ml_zip = stage / "MelonLoader.zip"
step(f"downloading {url}")
with urllib.request.urlopen(url) as r, open(ml_zip, "wb") as f:
shutil.copyfileobj(r, f)
if ml_zip.stat().st_size < 1 << 20:
fail("MelonLoader download failed or produced an unexpectedly small file")
ml_extract = Path(tmp) / "melonloader"
with zipfile.ZipFile(ml_zip) as z:
z.extractall(ml_extract)
# The 0.6.x release zip has a top-level MelonLoader/ folder holding
# net6, net35, Dependencies, Documentation.
ml_ref = ml_extract / "MelonLoader"
if not (ml_ref / "net6" / "MelonLoader.dll").exists():
fail("extracted MelonLoader is missing net6/MelonLoader.dll; the release layout may have changed")
# 2. the mod
step("dotnet build DorkNet.ClientMod (Release)")
r = subprocess.run(["dotnet", "build", str(CLIENT_MOD / "DorkNet.ClientMod.csproj"),
"-c", "Release", "--nologo", f"-p:MelonLoaderDir={ml_ref}"])
if r.returncode:
fail("ClientMod build failed")
dlls = sorted((CLIENT_MOD / "bin" / "Release").rglob("DorkNet.ClientMod.dll"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not dlls:
fail("DorkNet.ClientMod.dll not found after the build")
shutil.copy2(dlls[0], stage / "DorkNet.ClientMod.dll")
print(f" built {dlls[0]}")
# 3. config template + 4. Goldberg Steam stub (App ID 471710, the same
# for every build). The stub replaces Valve's steam_api64.dll so the
# game runs without Steam.
for src, what in ((CLIENT_MOD / "dorknet-clientmod.json.template", "config template"),
(CLIENT_MOD / "steam-stub" / "steam_api64.dll", "Steam stub DLL"),
(CLIENT_MOD / "steam-stub" / "steam_appid.txt", "Steam appid file")):
shutil.copy2(need(src, what), stage / src.name)
# 5. manifest.json, read by the launcher and by `client` below.
manifest = {
"$schema_version": 1,
"loader_archive": "MelonLoader.zip",
"plugin_dll": "DorkNet.ClientMod.dll",
"plugin_dest": "Mods",
"config_template": "dorknet-clientmod.json.template",
"config_dest": config_dest,
"old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"],
"steam_stub": {
# 2020.12+ is an EAC build: the real Unity binary is RecRoom.exe
# (it loads RecRoom_Data/Plugins/x86_64/steam_api64.dll), NOT
# Recroom_Release.exe (the EAC launcher wrapper, whose data dir
# the real game never opens).
"api_dll": "steam_api64.dll",
"api_dest": steam_dest,
"api_backup_suffix": ".steam-original",
"appid_file": "steam_appid.txt",
"appid_dest": "steam_appid.txt",
},
}
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
# 6. bundle
out = out_dir / f"dorknet-clientpatch-{a.version_key}.zip"
zip_dir(stage, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB)")
# ---- client ---------------------------------------------------------------
def cmd_client(a: argparse.Namespace) -> None:
base = need(a.base_client, "stock client")
patcher_zip = need(a.patcher_zip, "patcher zip")
config = need(a.config, "client config") if a.config else None
with tempfile.TemporaryDirectory(prefix="dorknet-pc-client-") as tmp:
tmp = Path(tmp)
install, patcher = tmp / "install", tmp / "patcher"
patcher.mkdir()
# 1. stock client: a zip or a folder, the install at its root or
# inside one wrapping folder.
step(f"unpacking stock client {base}")
if base.is_dir():
unpacked = base
else:
unpacked = tmp / "base"
with zipfile.ZipFile(base) as z:
z.extractall(unpacked)
root = unpacked
top = list(root.iterdir())
if len(top) == 1 and top[0].is_dir():
root = top[0]
games = [p.name for p in root.glob("*.exe") if (root / f"{p.stem}_Data").is_dir()]
if not games:
fail(f"no game exe with a matching _Data folder at the root of {base}")
print(f" game: {', '.join(games)}")
shutil.copytree(root, install, ignore=shutil.ignore_patterns(".DepotDownloader"))
# 2. apply the patcher the way the launcher does, from its manifest.
step(f"applying {patcher_zip}")
with zipfile.ZipFile(patcher_zip) as z:
z.extractall(patcher)
m = json.loads(need(patcher / "manifest.json", "patcher manifest.json").read_text(encoding="utf-8"))
with zipfile.ZipFile(need(patcher / m["loader_archive"], "MelonLoader archive")) as z:
z.extractall(install)
print(" MelonLoader -> install root")
plugin_dir = install / m["plugin_dest"]
plugin_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / m["plugin_dll"], plugin_dir / m["plugin_dll"])
print(f" {m['plugin_dll']} -> {m['plugin_dest']}/")
config_dest = install / m["config_dest"]
config_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(config or patcher / m["config_template"], config_dest)
print(f" config -> {m['config_dest']}{'' if config else ' (template, unedited)'}")
stub = m.get("steam_stub")
if stub:
api_dest = install / stub["api_dest"]
if api_dest.exists():
backup = api_dest.with_name(api_dest.name + stub["api_backup_suffix"])
if not backup.exists():
shutil.copy2(api_dest, backup)
api_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(patcher / stub["api_dll"], api_dest)
shutil.copy2(patcher / stub["appid_file"], install / stub["appid_dest"])
print(f" Steam stub -> {stub['api_dest']}")
for old in m.get("old_plugin_paths") or []:
p = install / old
if p.exists():
p.unlink()
print(f" removed old {old}")
# 3. zip
key = patcher_zip.stem.removeprefix("dorknet-clientpatch-")
out = Path(a.output_zip) if a.output_zip else REPO / "dist" / f"dorknet-client-{key}.zip"
step(f"zipping -> {out}")
zip_dir(install, out)
print()
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB). It contains game files: keep it private.")
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description="Build the DorkNet PC client packages.")
sub = p.add_subparsers(dest="cmd", required=True)
pz = sub.add_parser("patcher", help="dorknet-clientpatch-<VersionKey>.zip (no game files)")
pz.add_argument("--version-key", required=True, choices=sorted(LAYOUTS),
help="the build the zip is for; picks the install layout")
pz.add_argument("--melonloader-version", default="v0.6.6",
help="MelonLoader release tag to bundle (default: v0.6.6, what the mod compiles against)")
pz.add_argument("--output-dir", help="where the zip goes (default: <repo>/dist)")
pz.set_defaults(func=cmd_patcher)
c = sub.add_parser("client", help="stock build + patcher zip + config -> ready-to-run client zip")
c.add_argument("--base-client", required=True, help="the stock client, as a .zip or a folder")
c.add_argument("--patcher-zip", required=True, help="dorknet-clientpatch-<VersionKey>.zip from `patcher`")
c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)")
c.add_argument("--output-zip", help="default: <repo>/dist/dorknet-client-<VersionKey>.zip")
c.set_defaults(func=cmd_client)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()