Import december-2020-12-18 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
<#
.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/.
.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. Build DorkNet.ClientMod ───────────────────────────────────────
Write-Host "[build] dotnet build DorkNet.ClientMod (Release)"
dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" `
-c Release --nologo | 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)"
# ── 2. Fetch MelonLoader from upstream releases ──────────────────────
# The x64 .NET 6 build is what 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"
$mlOut = "$stage\MelonLoader.zip"
Write-Host "[build] downloading $mlUrl"
Invoke-WebRequest -Uri $mlUrl -OutFile $mlOut
if (-not (Test-Path $mlOut) -or (Get-Item $mlOut).Length -lt 1MB) {
throw "MelonLoader download failed or produced an unexpectedly small file"
}
# ── 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. Write manifest.json ──────────────────────────────────────────
$manifest = @{
'$schema_version' = 1
'loader_archive' = 'MelonLoader.zip'
'plugin_dll' = 'DorkNet.ClientMod.dll'
'plugin_dest' = 'MelonLoader/Mods'
'config_template' = 'dorknet-clientmod.json.template'
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
'old_plugin_paths' = @(
'BepInEx/plugins/DorkNet.ClientPatch.dll'
)
} | ConvertTo-Json -Depth 4
Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest
# ── 5. Bundle into the final zip ────────────────────────────────────
$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 }
}
@@ -0,0 +1,72 @@
# DorkNet Cloudflare Tunnel config — Coolify production target.
# ────────────────────────────────────────────────────────────────────
# Routes every localhost subdomain through the tunnel into the server
# container running on Coolify. The tunnel runs as its own Coolify
# service (or as a sidecar in the same project) and reaches the
# server over the internal Docker network.
#
# Two upstream options, pick one for {{UPSTREAM}}:
#
# a) Internal Docker DNS (preferred when cloudflared runs in the
# same Coolify project as the server):
# http://dorknet-server:8080
# The server image binds Kestrel to 0.0.0.0:8080 (HTTP only —
# this leg of traffic is already inside the project network).
#
# b) Coolify-published HTTPS endpoint (when cloudflared runs in a
# separate project or on a different host):
# https://dorknet.<your-coolify-domain>
# Requires Coolify to be issuing a real TLS cert for that
# hostname. originRequest.noTLSVerify can stay off.
#
# Origin TLS is now off the critical path because the server speaks
# plain HTTP inside the project network. Traffic between cloudflared
# and Cloudflare's edge is still TLS-encrypted; the only unencrypted
# leg is server → cloudflared inside the same Docker network.
tunnel: {{TUNNEL_ID}}
credentials-file: {{CREDENTIALS_FILE}}
# Defaults that apply to every ingress rule unless overridden. The
# notify.* SignalR hub needs long-lived WebSockets — cloudflared's
# defaults are conservative and can tear idle connections down, which
# the 2020 watch surfaces as "check your internet" mid-session. Two
# levers matter:
# * keepAliveTimeout — how long cloudflared keeps the origin
# connection pooled and alive between requests; long-lived
# WebSockets stay open well past the default 1m30s, raise it.
# * connectTimeout — initial origin handshake; bumping it past the
# default avoids spurious 502s when the server is warm-booting
# after a Coolify deploy.
# These pair with the matching Program.cs SignalR + WebSocket
# KeepAliveInterval values (15s / 30s) so traffic flows continuously
# in both directions and the tunnel never marks the stream idle.
originRequest:
connectTimeout: 30s
tlsTimeout: 10s
tcpKeepAlive: 30s
keepAliveConnections: 100
keepAliveTimeout: 10m
noHappyEyeballs: false
# Ingress rules are evaluated top-down. The wildcard catches every
# subdomain we don't list explicitly, so when the 2020 Rec Room client
# (post-domain-patch) hits a niche subdomain like roomieintegrations.
# localhost, the request still reaches the server.
ingress:
- hostname: localhost
service: {{UPSTREAM}}
originRequest:
httpHostHeader: localhost
- hostname: "*.localhost"
service: {{UPSTREAM}}
# cloudflared forwards the original Host header by default, which
# is what the server's [Host("api.localhost")] etc. attributes match
# against. No explicit httpHostHeader here so wildcards stay
# transparent.
# Catch-all rejects anything that didn't match (defensive — the
# tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- service: http_status:404
+40
View File
@@ -0,0 +1,40 @@
# DorkNet Cloudflare Tunnel config
# ────────────────────────────────────────────────────────────────────
# Routes every localhost subdomain through the tunnel into the local
# server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1
# — copy to %USERPROFILE%\.cloudflared\config.yml and replace the
# {{TUNNEL_ID}} / {{CREDENTIALS_FILE}} placeholders.
#
# Origin TLS verify is OFF because the local server's wildcard cert is
# from mkcert (a private CA trusted only on this machine). cloudflared
# would reject it as untrusted otherwise. Traffic between cloudflared
# and Cloudflare's edge is still TLS-encrypted; this only relaxes the
# leg from cloudflared → localhost.
tunnel: {{TUNNEL_ID}}
credentials-file: {{CREDENTIALS_FILE}}
# Ingress rules are evaluated top-down. The wildcard catches every
# subdomain we don't list explicitly, so when the 2020 Rec Room client
# (post-domain-patch) hits a niche subdomain like roomieintegrations.
# localhost, the request still reaches the server.
ingress:
- hostname: localhost
service: https://localhost:443
originRequest:
noTLSVerify: true
httpHostHeader: localhost
- hostname: "*.localhost"
service: https://localhost:443
originRequest:
noTLSVerify: true
# Forward the original Host header verbatim so the server's
# [Host("api.localhost")] etc. attributes match correctly.
# (cloudflared does this by default; setting it explicitly so
# config.yml is self-documenting.)
# Catch-all rejects anything that didn't match (defensive — the
# tunnel only routes traffic for hostnames we've added DNS records
# for, so this is rarely reached).
- service: http_status:404
+99
View File
@@ -0,0 +1,99 @@
# Cloudflare Tunnel for `localhost`
Stand up a Cloudflare Tunnel that exposes your local DorkNet server
on `*.localhost` for public testing — without opening port 443 on your
firewall, and using Cloudflare's real Let's-Encrypt-equivalent cert
edge-side so visitors don't need your mkcert CA installed.
## When to use this
- You want a friend to play the patched 2020 Rec Room client against
your server, but they're not on your LAN and don't want to install
your local CA.
- You want `https://admin.localhost` / `https://feed.localhost` reachable
from any browser, not just yours.
## Prerequisites
1. **`localhost` is on a Cloudflare account you control.** The DNS
nameservers for the domain need to be Cloudflare's. Check at
<https://dash.cloudflare.com/> — the zone should be Active.
2. **Local server running** on `0.0.0.0:443` with the wildcard cert
that covers `*.localhost` (re-run `tools/patch-client.ps1` if your
cert is still the `+1` rec.net-only one — it'll regenerate as
`+3` covering both domains).
3. **Patched client** with `tools/patch-domain.ps1` so the in-game
URLs resolve to `*.localhost`. (Players using the public deployment
need to run this on their own client too.)
## One-shot setup
```powershell
PS C:\…\Recnet> .\tools\setup-cloudflared.ps1
```
The script:
1. Installs `cloudflared` via winget if missing.
2. Runs `cloudflared tunnel login` — opens a browser, you pick the
`localhost` zone, Cloudflare drops `cert.pem` into
`%USERPROFILE%\.cloudflared\`.
3. Creates a named tunnel called `dorknet` (or reuses one if it
already exists).
4. Generates `%USERPROFILE%\.cloudflared\config.yml` from the
`tools/cloudflared-config.yml.template` template, filling in the
tunnel id and credentials path.
5. Adds DNS CNAMEs for every localhost subdomain DorkNet uses
(api, auth, accounts, match, notify, ns, admin, feed, …).
## Run the tunnel
Foreground (for testing — Ctrl+C to stop):
```powershell
cloudflared tunnel run dorknet
```
Or install as a Windows service so it survives reboots:
```powershell
cloudflared service install
```
## What happens at request time
```
visitor → https://api.localhost/api/versioncheck/v4
→ Cloudflare edge (real LE cert, validated)
→ cloudflared tunnel
→ cloudflared (running on your machine)
→ https://localhost:443 (Host: api.localhost, mkcert cert)
→ DorkNet.Server matches [Host("api.localhost")] → handler runs
```
The leg from cloudflared → localhost has `noTLSVerify: true` because
the local server's cert is from mkcert (private CA only trusted on
your machine). The Cloudflare-edge → cloudflared leg is still
TLS-encrypted with their cert, so this isn't a security weakening
— it's just acknowledging that "127.0.0.1 with self-trusted cert" is
where the trust chain ends.
## Tearing down
```powershell
cloudflared service uninstall # if you ran service install
cloudflared tunnel delete dorknet
```
DNS records left behind in the Cloudflare dashboard need to be
deleted manually if you want them gone.
## Limits / caveats
- **Photon traffic is NOT proxied.** Cloudflare Tunnel is HTTP-only;
the realtime Photon connection still hits the public Photon Cloud
directly, with the AppId from your `appsettings.json`. That's fine
— Photon doesn't go through your server.
- **WebSocket support** is on by default in modern cloudflared, so
the SignalR notify hub works through the tunnel.
- **Free tier rate limits** — Cloudflare Free is generous for a
small private server but watch the dashboard if you scale up.
+190
View File
@@ -0,0 +1,190 @@
<#
.SYNOPSIS
Switch a Rec Room 2020 client to DorkNet without BepInEx.
.DESCRIPTION
This is the loader-free fallback path. It removes the BepInEx plugin
install made by tools/install-plugin.ps1, then runs tools/patch-client.ps1
in its legacy mode:
- byte-patches Photon Realtime/Voice AppIds in resources.assets;
- byte-patches the native BestHTTP/BouncyCastle TLS certificate
verifier return in GameAssembly.dll;
- points *.rec.net at the DorkNet server through the hosts file;
- generates/uses a mkcert wildcard certificate for HTTPS.
No BepInEx, Harmony, Doorstop, or IL2CPP managed plugin remains involved
unless -KeepBepInEx is supplied.
Photon identity note: the stock 2020 client does not send userid/LoginLock
AuthValues. DorkNet's Photon custom-auth endpoint allows anonymous connects,
but duplicate-login enforcement through Photon is unavailable in this mode.
.PARAMETER RecRoomPath
Path to Recroom_Release_Data.
.PARAMETER PhotonAppId
Photon Cloud Realtime AppId.
.PARAMETER PhotonVoiceAppId
Photon Cloud Voice AppId. Defaults to PhotonAppId.
.PARAMETER ServerIp
IP address to put in the hosts file for *.rec.net.
.PARAMETER CertPath
Optional path for the mkcert wildcard certificate.
.PARAMETER KeepBepInEx
Skip removing BepInEx before applying the legacy patch.
.PARAMETER DryRun
Show what would happen without changing files.
.EXAMPLE
.\tools\install-legacy-client.ps1 `
-RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" `
-PhotonAppId "<your-photon-realtime-app-id>"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$RecRoomPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
[string]$PhotonAppId,
[ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
[string]$PhotonVoiceAppId,
[string]$ServerIp = '127.0.0.1',
[string]$CertPath,
[switch]$KeepBepInEx,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host ('OK: ' + $msg) -ForegroundColor Green }
function Write-Warn($msg) { Write-Host ('WARN: ' + $msg) -ForegroundColor Yellow }
$TlsBypassOffset = 0xB5EE50
$TlsBypassOriginalByte = 0x48
$TlsBypassPatchedByte = 0xC3
function Apply-TlsBypassPatch {
param(
[Parameter(Mandatory = $true)]
[string]$DataPath,
[switch]$DryRun
)
$gameDir = Split-Path -Parent $DataPath
$gameAssembly = Join-Path $gameDir 'GameAssembly.dll'
if (-not (Test-Path -LiteralPath $gameAssembly -PathType Leaf)) {
throw "GameAssembly.dll not found next to '$DataPath'."
}
Write-Step 'Applying native TLS verifier bypass'
$bytes = [System.IO.File]::ReadAllBytes($gameAssembly)
if ($bytes.Length -le $TlsBypassOffset) {
throw "GameAssembly.dll is too small for TLS bypass offset 0x$($TlsBypassOffset.ToString('X')). Wrong client build?"
}
$current = $bytes[$TlsBypassOffset]
if ($current -eq $TlsBypassPatchedByte) {
Write-OK "TLS bypass already patched at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X'))."
return
}
if ($current -ne $TlsBypassOriginalByte) {
$actual = '0x{0:X2}' -f $current
$expected = '0x{0:X2}' -f $TlsBypassOriginalByte
throw "Unexpected byte at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X')): $actual, expected $expected. Refusing to patch an unknown build."
}
$backup = "$gameAssembly.tls-bypass-backup-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
if ($DryRun) {
Write-Host "DRY RUN: would backup GameAssembly.dll to $backup"
Write-Host "DRY RUN: would patch GameAssembly.dll+0x$($TlsBypassOffset.ToString('X')) from 0x48 to 0xC3"
return
}
Copy-Item -LiteralPath $gameAssembly -Destination $backup
$bytes[$TlsBypassOffset] = [byte]$TlsBypassPatchedByte
[System.IO.File]::WriteAllBytes($gameAssembly, $bytes)
Write-OK "TLS bypass patched at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X'))."
Write-OK "Backup written to $backup"
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$installPlugin = Join-Path $PSScriptRoot 'install-plugin.ps1'
$patchClient = Join-Path $PSScriptRoot 'patch-client.ps1'
if (-not (Test-Path -LiteralPath $installPlugin -PathType Leaf)) {
throw "Missing $installPlugin"
}
if (-not (Test-Path -LiteralPath $patchClient -PathType Leaf)) {
throw "Missing $patchClient"
}
$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue
if (-not $resolved) {
throw "RecRoomPath '$RecRoomPath' does not exist."
}
$RecRoomPath = $resolved.Path
if (-not $PhotonVoiceAppId) {
$PhotonVoiceAppId = $PhotonAppId
}
Write-Host ''
Write-Step 'DorkNet legacy client install'
Write-Host (" RecRoomPath : {0}" -f $RecRoomPath)
Write-Host (" PhotonAppId : {0}" -f $PhotonAppId)
Write-Host (" VoiceAppId : {0}" -f $PhotonVoiceAppId)
Write-Host (" ServerIp : {0}" -f $ServerIp)
Write-Host (" RepoRoot : {0}" -f $repoRoot)
Write-Host ''
if (-not $KeepBepInEx) {
Write-Step 'Removing BepInEx/plugin install'
if ($DryRun) {
Write-Host "DRY RUN: would run install-plugin.ps1 -Revert"
} else {
& $installPlugin -RecRoomPath $RecRoomPath -Revert
if (-not $?) {
throw "install-plugin.ps1 -Revert failed"
}
}
} else {
Write-Warn 'Keeping BepInEx because -KeepBepInEx was supplied.'
}
Write-Step 'Applying legacy resources.assets/hosts/cert patch'
if ($CertPath -and $DryRun) {
& $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath -DryRun
} elseif ($CertPath) {
& $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath
} elseif ($DryRun) {
& $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -DryRun
} else {
& $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp
}
if (-not $?) {
throw "patch-client.ps1 failed"
}
Apply-TlsBypassPatch -DataPath $RecRoomPath -DryRun:$DryRun
Write-Host ''
Write-OK 'Legacy client path installed.'
Write-Warn 'For this mode, Photon Custom Authentication should allow anonymous connects; DorkNet already does this, but Photon dashboard strict rejection should not depend on userid/LoginLock.'
+648
View File
@@ -0,0 +1,648 @@
<#
.SYNOPSIS
MelonLoader-based setup for the DorkNet 2020 client.
.DESCRIPTION
One command that:
1. Downloads MelonLoader (IL2CPP build, x64) from GitHub releases
and extracts it into the Rec Room install dir.
2. Builds DorkNet.ClientMod (the MelonLoader port of the BepInEx
ClientPatch plugin) against MelonLoader's generated
Il2CppAssemblies.
3. Drops the built DLL into <RecRoomInstall>\Mods\ and writes
<RecRoomInstall>\MelonLoader\UserData\dorknet-clientmod.json
with the user-supplied Photon AppId / server host / etc.
4. Calls tools\patch-client.ps1 to apply the static byte patches
(Photon AppId in resources.assets, image signing public key,
image signature verifier, and the CodeStage Anti-Cheat Toolkit
neutraliser).
Idempotent — re-running re-applies any missing pieces but won't
re-download MelonLoader if it's already cached, and the byte patches
detect already-patched bytes and skip.
First-run gotcha: MelonLoader needs ONE successful game launch to
generate Il2CppAssemblies\ before the mod can build against it. This
script auto-detects that state. If Il2CppAssemblies\ is missing it
installs MelonLoader, writes the JSON config, runs the byte patches,
then prints "launch the game once, then re-run this script with
-ResumeBuild" instead of failing the build.
.PARAMETER RecRoomPath
Path to the Recroom_Release_Data folder (the one with
resources.assets). The script uses this folder's parent as the install
root for MelonLoader extraction.
.PARAMETER PhotonAppId
Photon Cloud Realtime AppId (GUID, no curly braces). Required on
the first install run.
.PARAMETER PhotonVoiceAppId
Photon Cloud Voice AppId. Defaults to PhotonAppId.
.PARAMETER ServerHost
Domain that replaces .rec.net in every URL the watch builds.
Default: localhost.
.PARAMETER MelonLoaderZip
Optional local path to a MelonLoader.x64.zip. Skips the download.
.PARAMETER ForceMelonLoader
Re-extract MelonLoader even if it's already present.
.PARAMETER ResumeBuild
Skip the MelonLoader install + patch-client.ps1 step and only do
the mod build/copy step. Use after the first run has prompted you to
launch the game once.
.PARAMETER SkipBytePatches
Skip the tools\patch-client.ps1 call. Use if you've already run it
separately or want to manage byte patches by hand.
.PARAMETER Revert
Uninstall the mod + MelonLoader. Does not touch resources.assets
byte patches (use patch-client.ps1 -Revert for those).
.PARAMETER ImageSigningPublicKeyPath
Forwarded to patch-client.ps1 unchanged — see that script's help.
.EXAMPLE
# First-time setup. After the script prints "launch the game once",
# double-click Recroom_Release.exe, wait for the watch UI, quit, then
# re-run with -ResumeBuild to finish the mod build.
.\tools\install-melon.ps1 `
-RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" `
-PhotonAppId <your-photon-realtime-app-id>
.EXAMPLE
# Second pass after the first game launch generated Il2CppAssemblies.
.\tools\install-melon.ps1 `
-RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" `
-PhotonAppId <your-photon-realtime-app-id> `
-ResumeBuild
.EXAMPLE
# Uninstall the mod (keeps the byte patches; use patch-client.ps1 -Revert
# for those).
.\tools\install-melon.ps1 -RecRoomPath D:\RecRoom\Recroom_Release_Data -Revert
#>
[CmdletBinding(DefaultParameterSetName = 'Install')]
param(
[Parameter(Mandatory = $true)]
[string]$RecRoomPath,
# Mandatory only when -FromServerConfig is not used. Validation
# happens after FromServerConfig fills it in below.
[Parameter(ParameterSetName = 'Install')]
[string]$PhotonAppId,
[Parameter(ParameterSetName = 'Install')]
[string]$PhotonVoiceAppId,
[Parameter(ParameterSetName = 'Install')]
[ValidateSet('us','eu','asia','jp','au','usw','sa','cae','kr','in','ru','rue')]
[string]$PhotonCloudRegion = 'us',
# Read PhotonAppId / VoiceAppId / CloudRegion from the DorkNet.Server
# appsettings file at this path. Use the value from
# `DorkNet.Server\appsettings.Local.json` (falls back to
# `appsettings.json`) so you don't have to retype the GUIDs you
# already configured on the server side. Any explicit -PhotonAppId /
# -PhotonVoiceAppId / -PhotonCloudRegion override the file values.
[Parameter(ParameterSetName = 'Install')]
[string]$FromServerConfig,
[Parameter(ParameterSetName = 'Install')]
[string]$ServerHost = 'localhost',
[Parameter(ParameterSetName = 'Install')]
[string]$MelonLoaderZip,
[Parameter(ParameterSetName = 'Install')]
[switch]$ForceMelonLoader,
[Parameter(ParameterSetName = 'Install')]
[switch]$ResumeBuild,
# Auto-bootstrap MelonLoader by launching Recroom_Release.exe in the
# background, polling for Il2CppAssemblies\Assembly-CSharp.dll to
# appear, then killing the process. Makes the install a single
# script run instead of the install -> launch -> ResumeBuild dance.
# On by default; pass -AutoBootstrap:$false to keep the two-step flow.
[Parameter(ParameterSetName = 'Install')]
[bool]$AutoBootstrap = $true,
[Parameter(ParameterSetName = 'Install')]
[switch]$SkipBytePatches,
[Parameter(ParameterSetName = 'Install')]
[string]$ImageSigningPublicKeyPath,
[Parameter(Mandatory = $true, ParameterSetName = 'Revert')]
[switch]$Revert
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# Seed $LASTEXITCODE so every later "did the native call succeed" check
# has something to read under StrictMode. PowerShell only populates this
# automatic variable AFTER a native executable runs, so a cold-shell
# invocation of this script can otherwise hit a VariableIsUndefined
# error the first time we test it.
$global:LASTEXITCODE = 0
# Pinned MelonLoader release. "latest" would be more convenient but
# pinning means a regression upstream doesn't silently land on tester
# machines — same rationale as the BepInEx pin in install-plugin.ps1.
# Bump when there's a known-good newer build.
$Script:MelonLoaderUrl = 'https://github.com/LavaGang/MelonLoader/releases/download/v0.6.6/MelonLoader.x64.zip'
$Script:MelonLoaderCacheName = 'MelonLoader.x64-0.6.6.zip'
# Steamless pin — same release as install-plugin.ps1 so the cache hits
# between the two scripts. The 2020 Rec Room build is wrapped with
# Valve's SteamStub DRM; if the .exe is launched outside Steam the
# stub aborts with exit code 1, which is what kills MelonLoader's
# auto-bootstrap before it can generate Il2CppAssemblies. Stripping
# the wrapper is the cleanest fix.
$Script:SteamlessUrl = 'https://github.com/atom0s/Steamless/releases/download/v3.1.0.5/Steamless.v3.1.0.5.-.by.atom0s.zip'
$Script:SteamlessCacheName = 'Steamless.v3.1.0.5.zip'
function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host ('+ ' + $msg) -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host ('x ' + $msg) -ForegroundColor Red }
# ── Steam DRM detection + stripping ──────────────────────────────────
# These three helpers are mechanically identical to the ones in
# install-plugin.ps1 (they predate this script). Kept locally here
# rather than dot-sourced so install-melon.ps1 stays a single file you
# can drop on a fresh machine.
function Test-SteamDrmWrapped {
# Walks the PE header to see whether the AddressOfEntryPoint lands
# inside a `.bind` section — Valve's drm_wrap injects `.bind`
# carrying the SteamStub trampoline and rewrites the entry point
# to land there. After Steamless strips, the entry point points
# back into `.text` and the leftover `.bind` section is harmless.
param([string]$ExePath)
try {
$fs = [System.IO.File]::OpenRead($ExePath)
try {
$br = New-Object System.IO.BinaryReader($fs)
$fs.Seek(0x3C, 'Begin') | Out-Null
$peOff = $br.ReadInt32()
if ($peOff -lt 0 -or $peOff + 24 -gt $fs.Length) { return $false }
$fs.Seek($peOff + 6, 'Begin') | Out-Null
$sections = $br.ReadUInt16()
$fs.Seek($peOff + 20, 'Begin') | Out-Null
$optSize = $br.ReadUInt16()
$fs.Seek($peOff + 24 + 16, 'Begin') | Out-Null
$entryRva = $br.ReadUInt32()
$secStart = $peOff + 24 + $optSize
for ($i = 0; $i -lt $sections; $i++) {
$fs.Seek($secStart + $i * 40, 'Begin') | Out-Null
$name = [System.Text.Encoding]::ASCII.GetString($br.ReadBytes(8)).TrimEnd([char]0)
$vSize = $br.ReadUInt32()
$vAddr = $br.ReadUInt32()
if ($name -eq '.bind' -and $entryRva -ge $vAddr -and $entryRva -lt ($vAddr + $vSize)) {
return $true
}
}
return $false
} finally { $fs.Close() }
} catch {
return $false
}
}
function Get-Steamless {
# Returns the path to a usable Steamless.CLI.exe. Downloads + extracts
# the pinned release on first run, caches under %LOCALAPPDATA%\DorkNet.
$cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
$extractDir = Join-Path $cacheDir 'Steamless'
$cli = Join-Path $extractDir 'Steamless.CLI.exe'
if (Test-Path -LiteralPath $cli -PathType Leaf) {
return $cli
}
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
$zipPath = Join-Path $cacheDir $Script:SteamlessCacheName
if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) {
Write-Step "Downloading Steamless -> $zipPath"
Write-Host " $Script:SteamlessUrl" -ForegroundColor DarkGray
Invoke-WebRequest -Uri $Script:SteamlessUrl -OutFile $zipPath `
-UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
}
Write-Step "Extracting Steamless -> $extractDir"
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
# Some Steamless releases nest the CLI in a subfolder.
$found = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'Steamless.CLI.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($found) { $cli = $found.FullName }
}
if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
throw "Steamless.CLI.exe not found after extracting $zipPath"
}
return $cli
}
function Invoke-DrmStrip {
# Runs Steamless against the wrapped exe in-place. Steamless writes
# an unpacked copy alongside the original; we move the original to
# .drm-original.exe and rename the unpacked file in place so the
# auto-bootstrap step doesn't have to know which is which.
param([string]$ExePath)
$cli = Get-Steamless
Write-Step "Stripping Steam DRM from $ExePath"
$exeDir = Split-Path -Parent $ExePath
$proc = Start-Process -FilePath $cli `
-ArgumentList @('--quiet', '--keepbind', '0', $ExePath) `
-NoNewWindow -PassThru -Wait
if ($proc.ExitCode -ne 0) {
throw "Steamless exited with code $($proc.ExitCode). The exe may not be a recognised Steam DRM build."
}
$unpacked = "$ExePath.unpacked.exe"
if (-not (Test-Path -LiteralPath $unpacked -PathType Leaf)) {
throw "Steamless ran but didn't produce $unpacked. Check the tool's output."
}
$bak = Join-Path $exeDir 'Recroom_Release.drm-original.exe'
if (-not (Test-Path -LiteralPath $bak -PathType Leaf)) {
Move-Item -LiteralPath $ExePath -Destination $bak -Force
} else {
Remove-Item -LiteralPath $ExePath -Force
}
Move-Item -LiteralPath $unpacked -Destination $ExePath -Force
Write-OK "DRM stripped. Original kept as $bak"
}
# ── Path resolution ──────────────────────────────────────────────────
$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue
if ($resolved) { $RecRoomPath = $resolved.Path } else { $RecRoomPath = $null }
if (-not $RecRoomPath -or -not (Test-Path -LiteralPath $RecRoomPath -PathType Container)) {
Write-Err "RecRoomPath '$RecRoomPath' does not exist."
exit 1
}
if (-not (Test-Path -LiteralPath (Join-Path $RecRoomPath 'resources.assets') -PathType Leaf)) {
Write-Err "resources.assets not found in '$RecRoomPath'. Point at the Recroom_Release_Data folder."
exit 1
}
$RecRoomRoot = Split-Path -Parent $RecRoomPath
$RecRoomExe = Join-Path $RecRoomRoot 'Recroom_Release.exe'
$MelonLoaderDir = Join-Path $RecRoomRoot 'MelonLoader'
$ModsDir = Join-Path $RecRoomRoot 'Mods'
$UserDataDir = Join-Path $MelonLoaderDir 'UserData'
$Il2CppAsmsDir = Join-Path $MelonLoaderDir 'Il2CppAssemblies'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$ModProj = Join-Path $RepoRoot 'DorkNet.ClientMod'
$ModCsproj = Join-Path $ModProj 'DorkNet.ClientMod.csproj'
$PatchClient = Join-Path $PSScriptRoot 'patch-client.ps1'
# ── Pull Photon values from the server's appsettings when -FromServerConfig
# is set OR when a sibling DorkNet.Server\appsettings.Local.json exists.
# Explicit -Photon* params still win over file values so you can patch
# a one-off client against a different Photon app without touching the
# server config. Both Local and the base file get a look — Local first.
function Read-ServerPhotonConfig {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null }
try {
$json = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if (-not $json.PSObject.Properties['Photon']) { return $null }
$photon = $json.Photon
$appId = if ($photon.PSObject.Properties['AppId']) { [string]$photon.AppId } else { $null }
$voice = if ($photon.PSObject.Properties['VoiceAppId']) { [string]$photon.VoiceAppId } else { $null }
$region = if ($photon.PSObject.Properties['CloudRegion']) { [string]$photon.CloudRegion } else { $null }
return [pscustomobject]@{ AppId = $appId; VoiceAppId = $voice; CloudRegion = $region; Source = $Path }
} catch {
Write-Warn2 "Could not parse Photon section from $Path : $($_.Exception.Message)"
return $null
}
}
if ($PSCmdlet.ParameterSetName -eq 'Install') {
if ($FromServerConfig) {
$candidates = @($FromServerConfig)
} else {
$candidates = @(
(Join-Path $RepoRoot 'DorkNet.Server\appsettings.Local.json'),
(Join-Path $RepoRoot 'DorkNet.Server\appsettings.json')
)
}
foreach ($cand in $candidates) {
$cfg = Read-ServerPhotonConfig -Path $cand
if (-not $cfg) { continue }
if (-not $PhotonAppId -and $cfg.AppId) { $PhotonAppId = $cfg.AppId }
if (-not $PhotonVoiceAppId -and $cfg.VoiceAppId) { $PhotonVoiceAppId = $cfg.VoiceAppId }
if (-not $PSBoundParameters.ContainsKey('PhotonCloudRegion') -and $cfg.CloudRegion) {
$PhotonCloudRegion = $cfg.CloudRegion
}
Write-OK ("Loaded Photon config from $($cfg.Source) " +
"(AppId=$(if($cfg.AppId){'<set>'}else{'<unset>'}), " +
"VoiceAppId=$(if($cfg.VoiceAppId){'<set>'}else{'<unset>'}), " +
"CloudRegion=$(if($cfg.CloudRegion){$cfg.CloudRegion}else{'<unset>'}))")
break
}
if (-not $PhotonAppId) {
Write-Err 'PhotonAppId is required (pass -PhotonAppId <guid> or -FromServerConfig <appsettings.json>).'
exit 1
}
$guidRe = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
if ($PhotonAppId -notmatch $guidRe) {
Write-Err "PhotonAppId '$PhotonAppId' is not a GUID."
exit 1
}
if ($PhotonVoiceAppId -and $PhotonVoiceAppId -notmatch $guidRe) {
Write-Err "PhotonVoiceAppId '$PhotonVoiceAppId' is not a GUID."
exit 1
}
}
# ── Revert ───────────────────────────────────────────────────────────
if ($Revert) {
Write-Step 'REVERT MODE — removing MelonLoader + DorkNet.ClientMod'
foreach ($p in @(
(Join-Path $RecRoomRoot 'MelonLoader'),
(Join-Path $RecRoomRoot 'Mods'),
(Join-Path $RecRoomRoot 'version.dll'),
(Join-Path $RecRoomRoot 'dobby.dll'),
(Join-Path $RecRoomRoot 'NOTICE.txt')
)) {
if (Test-Path -LiteralPath $p) {
Write-Step "Removing $p"
Remove-Item -LiteralPath $p -Recurse -Force
Write-OK "Removed $p"
}
}
Write-OK 'MelonLoader and ClientMod removed.'
Write-Host ' resources.assets / image signing / anti-cheat byte patches are still applied.' -ForegroundColor DarkGray
Write-Host ' Use tools\patch-client.ps1 -Revert -RecRoomPath ... to roll those back too.' -ForegroundColor DarkGray
exit 0
}
# ── 1. MelonLoader install ───────────────────────────────────────────
function Get-MelonLoaderZip {
if ($MelonLoaderZip) {
if (-not (Test-Path -LiteralPath $MelonLoaderZip -PathType Leaf)) {
Write-Err "MelonLoaderZip '$MelonLoaderZip' not found."
exit 1
}
return $MelonLoaderZip
}
$cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
$cached = Join-Path $cacheDir $Script:MelonLoaderCacheName
if (Test-Path -LiteralPath $cached -PathType Leaf) {
Write-OK "Using cached MelonLoader zip -> $cached"
return $cached
}
Write-Step "Downloading MelonLoader -> $cached"
Write-Host " $Script:MelonLoaderUrl" -ForegroundColor DarkGray
Invoke-WebRequest -Uri $Script:MelonLoaderUrl -OutFile $cached `
-UseBasicParsing -TimeoutSec 120 -ErrorAction Stop
return $cached
}
$melonAlreadyInstalled = (Test-Path -LiteralPath (Join-Path $RecRoomRoot 'version.dll') -PathType Leaf) `
-and (Test-Path -LiteralPath $MelonLoaderDir -PathType Container)
if ($ResumeBuild) {
if (-not $melonAlreadyInstalled) {
Write-Err 'ResumeBuild requested but MelonLoader is not installed. Run without -ResumeBuild first.'
exit 1
}
Write-OK 'Resuming after game launch — skipping MelonLoader extract.'
} elseif ($melonAlreadyInstalled -and -not $ForceMelonLoader) {
Write-OK "MelonLoader already installed at $MelonLoaderDir"
} else {
$zipPath = Get-MelonLoaderZip
Write-Step "Extracting MelonLoader -> $RecRoomRoot"
Expand-Archive -LiteralPath $zipPath -DestinationPath $RecRoomRoot -Force
if (-not (Test-Path -LiteralPath (Join-Path $RecRoomRoot 'version.dll'))) {
Write-Err 'MelonLoader extracted but version.dll proxy missing. The downloaded zip may be the wrong shape.'
exit 1
}
Write-OK 'MelonLoader extracted.'
}
# ── 2. Make sure UserData + Mods exist, write mod config ────────────
New-Item -ItemType Directory -Path $UserDataDir -Force | Out-Null
New-Item -ItemType Directory -Path $ModsDir -Force | Out-Null
$cfgPath = Join-Path $UserDataDir 'dorknet-clientmod.json'
$cfg = [ordered]@{
ServerHost = $ServerHost
PhotonAppId = $PhotonAppId
PhotonVoiceAppId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' }
PhotonCloudRegion = $PhotonCloudRegion
InjectAuthValues = $true
EnableTlsTrustBypass = $true
}
Write-Step "Writing mod config -> $cfgPath"
[System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4))
Write-OK 'dorknet-clientmod.json written.'
# ── 3. Byte patches via patch-client.ps1 ─────────────────────────────
if (-not $SkipBytePatches) {
if (-not (Test-Path -LiteralPath $PatchClient -PathType Leaf)) {
Write-Warn2 "patch-client.ps1 not found at $PatchClient — skipping byte patches."
} else {
Write-Step 'Running patch-client.ps1 (Photon AppIds, image signing, anti-cheat)'
$patchArgs = @{
RecRoomPath = $RecRoomPath
PhotonAppId = $PhotonAppId
}
if ($PhotonVoiceAppId) { $patchArgs.PhotonVoiceAppId = $PhotonVoiceAppId }
if ($ImageSigningPublicKeyPath) { $patchArgs.ImageSigningPublicKeyPath = $ImageSigningPublicKeyPath }
# Seed $LASTEXITCODE so the post-call check works under StrictMode.
# PowerShell only populates $LASTEXITCODE after a native exe runs;
# a pure-cmdlet path through patch-client.ps1 leaves it undefined,
# and reading an undefined variable throws under
# Set-StrictMode -Version Latest.
$global:LASTEXITCODE = 0
& $PatchClient @patchArgs
$patchExit = $LASTEXITCODE
if ($patchExit -ne 0) {
Write-Err "patch-client.ps1 returned exit code $patchExit"
exit $patchExit
}
}
}
# ── 4a. Auto-bootstrap MelonLoader to generate Il2CppAssemblies ──────
#
# When MelonLoader extracts for the first time it ships no interop
# assemblies — they only appear once the game boots with the loader
# attached and Cpp2IL + Il2CppInterop.Generator run against
# GameAssembly.dll + il2cpp_data\Metadata\global-metadata.dat.
# Generation finishes a few seconds into startup (long before the
# game's own bootloader gets anywhere interesting), so we can launch
# the .exe, poll for the marker DLL, then kill the process.
#
# Anchor marker: Assembly-CSharp.dll inside Il2CppAssemblies\. That's
# the last assembly the generator writes (after Il2Cppmscorlib +
# Il2CppSystem + all the Unity proxies), so seeing it on disk means
# the rest are already in place.
#
# Caveats called out in the user-facing log line on failure:
# * if Recroom_Release.exe is wrapped with Steam DRM (.bind section)
# it'll silently refuse to launch outside Steam; the user has
# either to keep Steam running OR strip DRM via Steamless first
# (see install-plugin.ps1's Invoke-DrmStrip for that path).
# * anti-cheat WOULD interfere here, but patch-client.ps1 above
# just neutralised it before this step runs.
function Invoke-MelonBootstrap {
param([string]$ExePath, [string]$Il2CppAsmsDir)
if (-not (Test-Path -LiteralPath $ExePath -PathType Leaf)) {
Write-Err "Recroom_Release.exe not found at $ExePath"
return $false
}
$exeDir = Split-Path -Parent $ExePath
foreach ($required in @(
(Join-Path $exeDir 'version.dll'),
(Join-Path $exeDir 'MelonLoader\Dependencies\Bootstrap.dll'),
(Join-Path $exeDir 'MelonLoader\Dependencies\dobby.dll')
)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
Write-Err "MelonLoader native dependency missing: $required"
Write-Host ' Re-run this script without -ResumeBuild, or use -ForceMelonLoader to re-extract MelonLoader.' -ForegroundColor DarkGray
return $false
}
}
Write-Step "Auto-bootstrap: launching Recroom_Release.exe to generate Il2CppAssemblies"
Write-Host ' (the game window will open briefly, then get killed once the proxy' -ForegroundColor DarkGray
Write-Host ' assemblies are on disk. Boot to the watch is NOT required.)' -ForegroundColor DarkGray
$proc = $null
try {
$proc = Start-Process -FilePath $ExePath -WorkingDirectory $exeDir -PassThru -ErrorAction Stop
} catch {
Write-Err "Failed to launch the game: $($_.Exception.Message)"
return $false
}
$marker = Join-Path $Il2CppAsmsDir 'Assembly-CSharp.dll'
$timeoutSec = 180
$pollSec = 2
$start = Get-Date
while ($true) {
if ($proc.HasExited) {
Write-Err "Game process exited (code $($proc.ExitCode)) before generating assemblies."
Write-Host ' Common causes: Steam DRM wrapper rejects standalone launch, MelonLoader proxy' -ForegroundColor DarkGray
Write-Host ' DLL got quarantined by Defender, or the game crashed on boot.' -ForegroundColor DarkGray
return $false
}
if (Test-Path -LiteralPath $marker -PathType Leaf) {
# Give Cpp2IL/Il2CppInterop a few extra seconds to finish
# writing the last of the proxy DLLs before we yank the process.
# The marker shows up partway through the generator run; the
# rest of the writes are quick but not instant.
Start-Sleep -Seconds 6
break
}
$elapsed = (Get-Date) - $start
if ($elapsed.TotalSeconds -gt $timeoutSec) {
Write-Warn2 ("Timed out after {0:n0}s waiting for {1}." -f $timeoutSec, $marker)
try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {}
return $false
}
Start-Sleep -Seconds $pollSec
}
try {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
# Wait a beat for filesystem handles to release.
Start-Sleep -Seconds 1
} catch {}
Write-OK "Il2CppAssemblies generated; game process closed."
return $true
}
if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container) -and $AutoBootstrap -and -not $ResumeBuild) {
# Strip Steam DRM first if the .exe is wrapped. The launch in
# Invoke-MelonBootstrap is detached from Steam, so the SteamStub
# trampoline would reject with exit code 1 (same symptom the user
# gets if they double-click outside Steam too — MelonLoader's
# proxy DLL never gets loaded, no Il2CppAssemblies generated, and
# MelonLoader's first-launch state stays incomplete).
if (Test-SteamDrmWrapped -ExePath $RecRoomExe) {
Write-Warn2 "Recroom_Release.exe is wrapped with Steam DRM."
Write-Step "Stripping DRM so MelonLoader can launch the .exe directly."
try {
Invoke-DrmStrip -ExePath $RecRoomExe
} catch {
Write-Err "DRM strip failed: $($_.Exception.Message)"
Write-Host ' Falling back to the manual two-step flow. Either run' -ForegroundColor DarkGray
Write-Host ' tools\install-plugin.ps1 -InstallBepInEx <zip> on this same .exe' -ForegroundColor DarkGray
Write-Host ' (it has the same Steamless logic) or launch the game from Steam' -ForegroundColor DarkGray
Write-Host ' once and re-run with -ResumeBuild.' -ForegroundColor DarkGray
}
} else {
Write-OK "Recroom_Release.exe is already DRM-stripped."
}
if (-not (Invoke-MelonBootstrap -ExePath $RecRoomExe -Il2CppAsmsDir $Il2CppAsmsDir)) {
Write-Warn2 'Auto-bootstrap failed. Falling through to the manual two-step flow.'
}
}
# ── 4b. Build DorkNet.ClientMod against Il2CppAssemblies ─────────────
if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container)) {
Write-Warn2 "Il2CppAssemblies not generated yet at $Il2CppAsmsDir"
Write-Host ''
Write-Host ' MelonLoader needs ONE successful launch to generate the IL2CPP proxy' -ForegroundColor Yellow
Write-Host ' assemblies the mod builds against. Steps:' -ForegroundColor Yellow
Write-Host ''
Write-Host ' 1. Launch Recroom_Release.exe (just open the game and let it boot' -ForegroundColor Yellow
Write-Host ' to the watch UI; MelonLoader will dump assemblies in the' -ForegroundColor Yellow
Write-Host ' background).' -ForegroundColor Yellow
Write-Host ' 2. Close the game.' -ForegroundColor Yellow
Write-Host ' 3. Re-run this script with -ResumeBuild to finish the mod build.' -ForegroundColor Yellow
Write-Host ''
Write-Host ' (The byte patches and mod config are already in place; only the' -ForegroundColor DarkGray
Write-Host ' compiled mod DLL is missing.)' -ForegroundColor DarkGray
exit 0
}
if (-not (Test-Path -LiteralPath $ModCsproj -PathType Leaf)) {
Write-Err "DorkNet.ClientMod csproj not found at $ModCsproj"
exit 1
}
Write-Step "Building DorkNet.ClientMod (interop=$Il2CppAsmsDir)"
$buildOutput = & dotnet build $ModCsproj -c Release --nologo `
-p:MelonLoaderDir=$MelonLoaderDir `
-p:Il2CppAssembliesDir=$Il2CppAsmsDir 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Err 'DorkNet.ClientMod build failed. Build output:'
Write-Host ($buildOutput -join "`n") -ForegroundColor DarkGray
exit 1
}
$builtDll = Join-Path $ModProj 'bin\Release\DorkNet.ClientMod.dll'
if (-not (Test-Path -LiteralPath $builtDll -PathType Leaf)) {
Write-Err "Build succeeded but $builtDll not found."
exit 1
}
# ── 5. Copy mod DLL into Mods/ ───────────────────────────────────────
Copy-Item -LiteralPath $builtDll -Destination $ModsDir -Force
Write-OK ("Mod DLL copied -> " + (Join-Path $ModsDir 'DorkNet.ClientMod.dll'))
# ── 6. Summary ───────────────────────────────────────────────────────
Write-Host ''
Write-OK 'Install complete.'
Write-Host (' MelonLoader : ' + $MelonLoaderDir) -ForegroundColor DarkGray
Write-Host (' Mods folder : ' + $ModsDir) -ForegroundColor DarkGray
Write-Host (' Mod config : ' + $cfgPath) -ForegroundColor DarkGray
Write-Host (' Photon AppId : ' + $PhotonAppId) -ForegroundColor DarkGray
Write-Host (' Server host : ' + $ServerHost) -ForegroundColor DarkGray
Write-Host ''
Write-Host ' Launch Recroom_Release.exe. MelonLoader log appears in:' -ForegroundColor DarkGray
Write-Host (' ' + (Join-Path $MelonLoaderDir 'Latest.log')) -ForegroundColor DarkGray
+754
View File
@@ -0,0 +1,754 @@
<#
.SYNOPSIS
BepInEx-only setup for DorkNet ClientPatch.
.DESCRIPTION
Replaces the legacy patch-client.ps1 (asset-byte-edit + hosts file +
mkcert wildcard). Now everything lives in the
DorkNet.ClientPatch BepInEx plugin: Photon AppId override, *.rec.net
→ user-configured-host URL rewrite, and Photon CustomAuth identity
injection.
After this script runs once successfully, all you need to do on a
client version update is download the new build and re-run
this script — it'll re-extract BepInEx and rebuild the plugin
against the new game's interop assemblies. No more hardcoded
resources.assets offsets, no more hosts file, no more wildcard
cert.
.PARAMETER RecRoomPath
Path to the Recroom_Release_Data folder (the one containing
resources.assets). The patcher uses this to locate the actual
install root (its parent directory).
.PARAMETER BepInExZip
Optional path to a BepInEx 6.x IL2CPP zip. Only needed if you want
to override the pinned build the script auto-downloads. On first
install (or when -ForceBepInEx is set), the script fetches a
pinned BepInEx build from builds.bepinex.dev and caches it under
%LOCALAPPDATA%\DorkNet so repeated runs don't re-download. Pass
this only if you need a specific (newer/older) build.
.PARAMETER ServerHost
Domain that replaces *.rec.net in every URL the watch builds.
Default "localhost" — set to your own apex if hosting elsewhere.
.PARAMETER PhotonAppId
Photon Cloud Realtime AppId (GUID without curly braces).
.PARAMETER PhotonVoiceAppId
Photon Cloud Voice AppId. Defaults to PhotonAppId.
.PARAMETER ForceBepInEx
Re-extract BepInEx even if it's already installed. Use after a client
update.
.PARAMETER Revert
Remove BepInEx + the plugin from the client install. Doesn't
touch the game's own files.
.EXAMPLE
# First-time install — script auto-downloads BepInEx
.\tools\install-plugin.ps1 `
-RecRoomPath "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\Recroom_Release_Data" `
-PhotonAppId <your-photon-realtime-app-id>
.EXAMPLE
# Override the pinned BepInEx build with your own zip
.\tools\install-plugin.ps1 `
-RecRoomPath "C:\…\Recroom_Release_Data" `
-BepInExZip "C:\Downloads\BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.NNN.zip" `
-PhotonAppId cb0880d9-…
.EXAMPLE
# After a client update (interop changed) — re-run with
# the same args, optionally adding -ForceBepInEx if BepInEx itself
# got clobbered.
.\tools\install-plugin.ps1 `
-RecRoomPath "C:\…\Recroom_Release_Data" `
-PhotonAppId cb0880d9-…
.EXAMPLE
# Remove the plugin
.\tools\install-plugin.ps1 -RecRoomPath D:\RecRoom\Recroom_Release_Data -Revert
#>
[CmdletBinding(DefaultParameterSetName = 'Install')]
param(
[Parameter(Mandatory = $true)]
[string]$RecRoomPath,
[Parameter(ParameterSetName = 'Install')]
[string]$BepInExZip,
[Parameter(ParameterSetName = 'Install')]
[string]$ServerHost = 'localhost',
[Parameter(Mandatory = $true, ParameterSetName = 'Install')]
[ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
[string]$PhotonAppId,
[Parameter(ParameterSetName = 'Install')]
[ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')]
[string]$PhotonVoiceAppId,
[Parameter(ParameterSetName = 'Install')]
[switch]$ForceBepInEx,
# Tell the plugin to fake the Steamworks SDK so the watch boots
# without Steam.exe running. Pure C#-level Harmony patches inside
# the plugin DLL — no replacement steam_api64.dll, no Goldberg /
# SmartSteamEmu (which Defender flags as PUA). Default off so
# Steam-launched runs behave normally.
[Parameter(ParameterSetName = 'Install')]
[switch]$FakeSteamApi,
# SteamID64 the plugin should report from SteamUser.GetSteamID
# when -FakeSteamApi is on. Empty = deterministic per-machine ID
# derived from OS user + machine name. Pin an explicit value to
# keep the same DorkNet account across machines.
[Parameter(ParameterSetName = 'Install')]
[string]$FakeSteamId,
# Display name returned from SteamFriends.GetPersonaName when
# -FakeSteamApi is on. Empty = OS user name.
[Parameter(ParameterSetName = 'Install')]
[string]$FakeAccountName,
# RSA public modulus, base64 encoded, that replaces the client's
# embedded image-signing key. Defaults to the public key derived
# from DorkNet.Server/appsettings.Local.json when that file has
# ImageSigning:PrivateKeyPem.
[Parameter(ParameterSetName = 'Install')]
[string]$ImageSigningPublicKeyBase64,
# PEM private key to derive the image-signing public modulus from.
# Use this when Dokploy/prod signs images with a key that differs
# from appsettings.Local.json.
[Parameter(ParameterSetName = 'Install')]
[string]$ImageSigningPrivateKeyPath,
[Parameter(Mandatory = $true, ParameterSetName = 'Revert')]
[switch]$Revert
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# Pinned BepInEx 6 IL2CPP win-x64 build. Auto-downloaded when neither
# BepInEx is already installed nor -BepInExZip is provided. Pin a
# specific build rather than 'latest' so a regression upstream
# doesn't silently land on tester machines.
$Script:BepInExUrl = 'https://builds.bepinex.dev/projects/bepinex_be/755/BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755%2B3fab71a.zip'
$Script:BepInExCacheName = 'BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755.zip'
# Pinned Steamless release. atom0s ships it as a single zip with a
# CLI exe inside. Cached alongside BepInEx under %LOCALAPPDATA%\DorkNet
# so a one-time download per machine handles both DRM-stripping and
# mod loading.
$Script:SteamlessUrl = 'https://github.com/atom0s/Steamless/releases/download/v3.1.0.5/Steamless.v3.1.0.5.-.by.atom0s.zip'
$Script:SteamlessCacheName = 'Steamless.v3.1.0.5.zip'
# 2020.12 image-signing public keys embedded in OHDHPENHDAP. The watch
# chooses p1 for non-localhost hosts and d1 for localhost, then expects
# Content-Signature: key-id=KEY:RSA:<p1|d1>.<domain>; data=<rsa-sha1>.
# DorkNet signs with its own private key, so stock clients must have both
# embedded moduli replaced with the server key's public modulus.
$Script:OriginalImageSigningP1Modulus = 'X07yXkxaaLcZ1wVXfkWjgFkkqdoLhDFm0GPODsF+Q47pSUlbLvtXGqStnyEJEIrQmgDiicAvCdGRq4lovr2l5sIPMaoyizsbVHBdwLUrCsji0RvSBnmvN+8KqQ8STnB4DP4pAsPilfD35def4WuX/xMCXB5+hQUVhv27HPV8Dj9XzHuJAijIM9UwDZmvUcECyiO4wv+TaZi2+ELBtaLCQR8Gm1ZPeDEwP62Ch6MJy0jx5pkvvD0KdF9Wye+3/Wx31Zn/Trdo9HL4sGFWPDM9H9kQhZd5wkTHuxpwGIIhlzIwvY2/pBGdZKP6fi1D2jROEmVkBDyhmYY9nO+s3/bndQ=='
$Script:OriginalImageSigningD1Modulus = 'htW8AsNuy5E+nBkukGpKGInje8YotC7yHekeuotwMzDiCdR5jU69H9MN9r+Dplp/g0Pz1cXNPD+PX/aGgtiXaKTBjPzTcedQCsnkDIA4V6Y5OKMbrM84x7VNUaFWC1GoCLOraTy5RA3jnMUa2bPX1CAOaRYAKbBd65T76b3GR+oI4LRuua6zxe8tbZ+RKGS9ktNuZonPcKrcAQNrEN2E+z0ig9ls+z0EE3H6ufl48N1ix53ROwTOG3DPNwqp+w+n5oE2KbdL2V3MgS6TD+C1wUaPJI1//8UEzJs6ItNoFuez5PaBr3y9P/CyKVyGQRzTS82A/8gDEvOdEC4ZKCbKQQ=='
# NOTE: Goldberg Steam Emu integration was tried and removed.
# Windows Defender flags every Steam emulator as PUA and
# quarantines mid-download. The "boot without Steam running"
# capability now lives entirely inside DorkNet.ClientPatch —
# Harmony patches on the Steamworks.NET C# wrappers
# (SteamAPI.Init, SteamUser.GetSteamID, GetAuthSessionTicket, …)
# so the watch's SteamPlatformManager.Initialize succeeds without
# Steam.exe running. Toggled via the -FakeSteamApi switch on this
# script (writes the [Steam] FakeSteamApi=true config the plugin
# reads at load).
function Write-Step($msg) { Write-Host ('▸ ' + $msg) -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host ('✓ ' + $msg) -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host ('✗ ' + $msg) -ForegroundColor Red }
function Find-AsciiOffsets {
param([byte[]]$Bytes, [string]$Needle)
$hits = New-Object System.Collections.Generic.List[int]
if ([string]::IsNullOrEmpty($Needle)) { return @() }
$pattern = [System.Text.Encoding]::ASCII.GetBytes($Needle)
for ($i = 0; $i -le $Bytes.Length - $pattern.Length; $i++) {
if ($Bytes[$i] -ne $pattern[0]) { continue }
$ok = $true
for ($j = 1; $j -lt $pattern.Length; $j++) {
if ($Bytes[$i + $j] -ne $pattern[$j]) { $ok = $false; break }
}
if ($ok) { $hits.Add($i) }
}
return @($hits)
}
function Get-ImageSigningPublicModulus {
param(
[string]$RepoRoot,
[string]$PublicKeyBase64,
[string]$PrivateKeyPath
)
if ($PublicKeyBase64 -and $PrivateKeyPath) {
Write-Err 'Use only one of -ImageSigningPublicKeyBase64 or -ImageSigningPrivateKeyPath.'
exit 1
}
if ($PublicKeyBase64) { return $PublicKeyBase64.Trim() }
$privatePem = $null
if ($PrivateKeyPath) {
$resolvedKey = Resolve-Path -LiteralPath $PrivateKeyPath -ErrorAction SilentlyContinue
if (-not $resolvedKey) {
Write-Err "Image signing private key '$PrivateKeyPath' not found."
exit 1
}
$privatePem = Get-Content -LiteralPath $resolvedKey.Path -Raw
} else {
$localSettings = Join-Path $RepoRoot 'DorkNet.Server\appsettings.Local.json'
if (Test-Path -LiteralPath $localSettings -PathType Leaf) {
try {
$json = Get-Content -LiteralPath $localSettings -Raw | ConvertFrom-Json
if ($json.PSObject.Properties['ImageSigning'] -and
$json.ImageSigning.PSObject.Properties['PrivateKeyPem']) {
$privatePem = [string]$json.ImageSigning.PrivateKeyPem
}
} catch {
Write-Warn2 "Could not read ImageSigning:PrivateKeyPem from $localSettings: $($_.Exception.Message)"
}
}
}
if ([string]::IsNullOrWhiteSpace($privatePem)) { return $null }
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$rsa.ImportFromPem($privatePem)
return [Convert]::ToBase64String($rsa.ExportParameters($false).Modulus)
} catch {
Write-Warn2 "Could not derive image-signing public key from configured private key: $($_.Exception.Message)"
return $null
} finally {
$rsa.Dispose()
}
}
function Invoke-ImageSigningPublicKeyPatch {
param(
[string]$DataDir,
[string]$RecRoomRoot,
[string]$PublicModulusBase64
)
if ([string]::IsNullOrWhiteSpace($PublicModulusBase64)) {
Write-Warn2 'No image-signing public key configured; image Content-Signature verification may fail in-game.'
return
}
$PublicModulusBase64 = $PublicModulusBase64.Trim()
if ($PublicModulusBase64.Length -ne $Script:OriginalImageSigningP1Modulus.Length) {
Write-Err "Image signing public modulus must be $($Script:OriginalImageSigningP1Modulus.Length) base64 chars for the in-place client patch."
exit 1
}
$meta = Join-Path $DataDir 'il2cpp_data\Metadata\global-metadata.dat'
$gameAssembly = Join-Path $RecRoomRoot 'GameAssembly.dll'
$candidates = @($meta, $gameAssembly) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
if (-not $candidates) {
Write-Warn2 'No IL2CPP metadata or GameAssembly file found for image-signing key patch.'
return
}
$replacement = [System.Text.Encoding]::ASCII.GetBytes($PublicModulusBase64)
$patchedFiles = 0
$alreadyPatched = $false
foreach ($path in $candidates) {
$bytes = [System.IO.File]::ReadAllBytes($path)
$desiredHits = @(Find-AsciiOffsets -Bytes $bytes -Needle $PublicModulusBase64)
if ($desiredHits.Count -gt 0) {
$alreadyPatched = $true
}
$offsets = New-Object System.Collections.Generic.List[int]
foreach ($needle in @($Script:OriginalImageSigningP1Modulus, $Script:OriginalImageSigningD1Modulus)) {
foreach ($hit in @(Find-AsciiOffsets -Bytes $bytes -Needle $needle)) {
if (-not $offsets.Contains($hit)) { $offsets.Add($hit) }
}
}
if ($offsets.Count -eq 0) { continue }
Write-Step "Patching image signing public key in $path"
if ($path -ne $meta) {
$backup = "$path.dorknet-image-signing-original"
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) {
Copy-Item -LiteralPath $path -Destination $backup
}
}
foreach ($offset in $offsets) {
[Array]::Copy($replacement, 0, $bytes, $offset, $replacement.Length)
}
[System.IO.File]::WriteAllBytes($path, $bytes)
$formatted = @($offsets | ForEach-Object { ('0x{0:x}' -f $_) }) -join ', '
Write-OK "Image signing public key patched at $formatted"
$patchedFiles++
}
if ($patchedFiles -eq 0) {
if ($alreadyPatched) {
Write-OK 'Image signing public key already matches requested key.'
} else {
Write-Warn2 'Original image signing key was not found. This client may already be patched or may not be the 2020.12 IL2CPP build.'
}
}
}
function Test-SteamDrmWrapped {
# Detects the Steamworks DRM wrapper by checking whether the PE's
# AddressOfEntryPoint falls inside a '.bind' section. Valve's
# drm_wrap tool injects .bind carrying the DRM stub and rewrites
# the entry point to land there; SteamStub jumps to the real
# .text once it's done. After Steamless strips, the entry point
# is rewritten back into .text and .bind is left as dead weight
# (the section header survives but execution never visits it),
# so we'd false-positive if we just looked at .bind's size.
#
# PE layout walked here (offsets relative to start of file):
# e_lfanew @ 0x3C → 4-byte offset to PE header
# PE+0 "PE\0\0" + COFF header (Sections @ +6, OptHeader size @ +20)
# OptionalHeader+0x10 = AddressOfEntryPoint (RVA)
# Section table starts at PE+24+OptHeaderSize, 40 bytes/entry:
# Name(8) VirtSize(4) VirtAddr(4) RawSize(4) RawPtr(4) ...
param([string]$ExePath)
try {
$fs = [System.IO.File]::OpenRead($ExePath)
try {
$br = New-Object System.IO.BinaryReader($fs)
$fs.Seek(0x3C, 'Begin') | Out-Null
$peOff = $br.ReadInt32()
if ($peOff -lt 0 -or $peOff + 24 -gt $fs.Length) { return $false }
$fs.Seek($peOff + 6, 'Begin') | Out-Null
$sections = $br.ReadUInt16()
$fs.Seek($peOff + 20, 'Begin') | Out-Null
$optSize = $br.ReadUInt16()
$fs.Seek($peOff + 24 + 16, 'Begin') | Out-Null
$entryRva = $br.ReadUInt32()
$secStart = $peOff + 24 + $optSize
for ($i = 0; $i -lt $sections; $i++) {
$fs.Seek($secStart + $i * 40, 'Begin') | Out-Null
$name = [System.Text.Encoding]::ASCII.GetString($br.ReadBytes(8)).TrimEnd([char]0)
$vSize = $br.ReadUInt32()
$vAddr = $br.ReadUInt32()
if ($name -eq '.bind' -and $entryRva -ge $vAddr -and $entryRva -lt ($vAddr + $vSize)) {
return $true
}
}
return $false
} finally { $fs.Close() }
} catch {
return $false
}
}
function Get-Steamless {
# Returns the path to a usable Steamless.CLI.exe. Downloads + extracts
# the pinned release on first run, caches under %LOCALAPPDATA%\DorkNet.
param()
$cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
$extractDir = Join-Path $cacheDir 'Steamless'
$cli = Join-Path $extractDir 'Steamless.CLI.exe'
if (Test-Path -LiteralPath $cli -PathType Leaf) {
return $cli
}
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
$zipPath = Join-Path $cacheDir $Script:SteamlessCacheName
if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) {
Write-Step "Downloading Steamless → $zipPath"
Write-Host " $Script:SteamlessUrl" -ForegroundColor DarkGray
try {
Invoke-WebRequest -Uri $Script:SteamlessUrl -OutFile $zipPath `
-UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
} catch {
Write-Err "Steamless download failed: $($_.Exception.Message)"
Write-Host ' Manually download from https://github.com/atom0s/Steamless/releases' -ForegroundColor DarkGray
Write-Host " and extract to $extractDir, then re-run." -ForegroundColor DarkGray
throw
}
}
Write-Step "Extracting Steamless → $extractDir"
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force
if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
# Some Steamless zips put the CLI inside a subfolder; locate it.
$found = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'Steamless.CLI.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($found) { $cli = $found.FullName }
}
if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) {
throw "Steamless.CLI.exe not found after extracting $zipPath"
}
return $cli
}
function Invoke-DrmStrip {
# Runs Steamless against the wrapped exe in-place. Steamless writes
# an unpacked copy alongside the original; we move the original to
# .drm-original.exe and rename the unpacked file in place so the
# rest of the script doesn't have to know which is which.
param([string]$ExePath)
$cli = Get-Steamless
Write-Step "Stripping Steam DRM from $ExePath"
$exeDir = Split-Path -Parent $ExePath
# Steamless emits <input>.exe.unpacked.exe by default. Run with
# --quiet to keep our log clean; --keepbind 0 lets it strip the
# .bind section so subsequent runs don't re-trigger the detector.
$proc = Start-Process -FilePath $cli `
-ArgumentList @('--quiet', '--keepbind', '0', $ExePath) `
-NoNewWindow -PassThru -Wait
if ($proc.ExitCode -ne 0) {
throw "Steamless exited with code $($proc.ExitCode). The exe may not be a recognised Steam DRM build."
}
$unpacked = "$ExePath.unpacked.exe"
if (-not (Test-Path -LiteralPath $unpacked -PathType Leaf)) {
throw "Steamless ran but didn't produce $unpacked. Check the tool's output."
}
$bak = Join-Path $exeDir 'Recroom_Release.drm-original.exe'
Move-Item -LiteralPath $ExePath -Destination $bak -Force
Move-Item -LiteralPath $unpacked -Destination $ExePath -Force
Write-OK "DRM stripped. Original kept as $bak"
}
function Get-BepInExZip {
# Returns a path to a usable BepInEx zip. Reuses a cached download
# under %LOCALAPPDATA%\DorkNet so a repeated install on the same
# box doesn't re-download the ~9 MB blob each run.
param()
$cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet'
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
$cached = Join-Path $cacheDir $Script:BepInExCacheName
if (Test-Path -LiteralPath $cached -PathType Leaf) {
Write-OK "Using cached BepInEx zip → $cached"
return $cached
}
Write-Step "Downloading BepInEx → $cached"
Write-Host " $Script:BepInExUrl" -ForegroundColor DarkGray
try {
# -UseBasicParsing for compatibility with Windows PowerShell 5.1
# (PS7 ignores it). 90-second timeout — the file is small but a
# cold builds.bepinex.dev cdn can be sluggish from some regions.
Invoke-WebRequest -Uri $Script:BepInExUrl -OutFile $cached `
-UseBasicParsing -TimeoutSec 90 -ErrorAction Stop
} catch {
Write-Err "Download failed: $($_.Exception.Message)"
Write-Host ' Manually download the zip and pass it via -BepInExZip <path>.' -ForegroundColor DarkGray
Write-Host " Releases page: https://github.com/BepInEx/BepInEx/releases" -ForegroundColor DarkGray
throw
}
$size = (Get-Item -LiteralPath $cached).Length
Write-OK ("Downloaded {0:N1} MB" -f ($size / 1MB))
return $cached
}
# Resolve via a two-step assignment instead of the ?. null-conditional
# operator — that's PS 7+ only and we want to support Windows
# PowerShell 5.1 (still the default on stock Win10/11).
$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue
if ($resolved) { $RecRoomPath = $resolved.Path } else { $RecRoomPath = $null }
if (-not $RecRoomPath -or -not (Test-Path -LiteralPath $RecRoomPath -PathType Container)) {
Write-Err "RecRoomPath '$RecRoomPath' does not exist."
exit 1
}
# RecRoomPath = …\Recroom_Release_Data, BepInEx lives one level up
$RecRoomRoot = Split-Path -Parent $RecRoomPath
$BepInExDir = Join-Path $RecRoomRoot 'BepInEx'
$ConfigDir = Join-Path $BepInExDir 'config'
$PluginsDir = Join-Path $BepInExDir 'plugins'
$InteropDir = Join-Path $BepInExDir 'interop'
$RepoRoot = Split-Path -Parent $PSScriptRoot
$PluginProj = Join-Path $RepoRoot 'DorkNet.ClientPatch'
if ($Revert) {
Write-Step "Removing BepInEx + plugin from $RecRoomRoot"
if (Test-Path -LiteralPath $BepInExDir -PathType Container) {
Remove-Item -LiteralPath $BepInExDir -Recurse -Force
Write-OK "Removed $BepInExDir"
}
foreach ($leaf in @('winhttp.dll', 'doorstop_config.ini', '.doorstop_version', 'changelog.txt')) {
$p = Join-Path $RecRoomRoot $leaf
if (Test-Path -LiteralPath $p -PathType Leaf) {
Remove-Item -LiteralPath $p -Force
Write-OK "Removed $leaf"
}
}
# Restore the pristine global-metadata.dat from the backup we made
# before host-patching, so the watch points at *.rec.net again.
$meta = Join-Path $RecRoomPath 'il2cpp_data\Metadata\global-metadata.dat'
$backup = $meta + '.dorknet-original'
if (Test-Path -LiteralPath $backup -PathType Leaf) {
Copy-Item -LiteralPath $backup -Destination $meta -Force
Remove-Item -LiteralPath $backup -Force
Write-OK 'Restored stock global-metadata.dat from backup.'
}
Write-OK 'Plugin reverted. Rec Room is now stock.'
exit 0
}
if (-not (Test-Path -LiteralPath $PluginProj -PathType Container)) {
Write-Err "Plugin project missing at '$PluginProj'. Run from a DorkNet repo checkout."
exit 1
}
# steam_appid.txt — Steamworks SDK uses this file as "app id is
# pre-configured, don't relaunch via Steam." Without it, even a
# DRM-stripped binary still calls SteamAPI.RestartAppIfNecessary
# at startup and gets handed back to Steam.exe before the .NET
# payload (and therefore our plugin) has a chance to run. Drop it
# next to the exe before we test the DRM-strip path.
$AppIdFile = Join-Path $RecRoomRoot 'steam_appid.txt'
if (-not (Test-Path -LiteralPath $AppIdFile -PathType Leaf)) {
'471710' | Out-File -LiteralPath $AppIdFile -Encoding ASCII -NoNewline
Write-OK "Wrote $AppIdFile (Rec Room AppID 471710 — skips Steam relaunch on boot)"
}
# DRM check + auto-strip — Steamworks DRM stub runs before BepInEx's
# winhttp.dll doorstop fires, so a stock Steam-wrapped exe would just
# exit and ask Steam to relaunch (BepInEx + plugin never get a chance).
# Detect via .bind entry-point check, then run Steamless against it.
# Original kept as Recroom_Release.drm-original.exe.
$RecRoomExe = Join-Path $RecRoomRoot 'Recroom_Release.exe'
if (Test-Path -LiteralPath $RecRoomExe -PathType Leaf) {
if (Test-SteamDrmWrapped -ExePath $RecRoomExe) {
Write-Step 'Steam DRM wrapper detected on Recroom_Release.exe — stripping with Steamless'
Invoke-DrmStrip -ExePath $RecRoomExe
# Sanity-check: if Steamless somehow left .bind in place, bail.
if (Test-SteamDrmWrapped -ExePath $RecRoomExe) {
Write-Err 'Stripped exe still reports a .bind section — Steamless may not support this build.'
Write-Host ' Manually strip with the Steamless GUI and re-run this script.' -ForegroundColor DarkGray
exit 1
}
Write-OK 'Recroom_Release.exe is now DRM-stripped.'
} else {
Write-OK 'Recroom_Release.exe is DRM-stripped (no .bind section).'
}
}
# Steam-without-Steam.exe handling now lives inside the plugin via
# the [Steam] FakeSteamApi=true config — no DLL replacement, no
# Goldberg AV trip. We just write the config in step 4 below.
# ── 0b. Patch ns.rec.net / www.rec.net in IL2CPP global metadata ──────────
# The watch hard-codes the bootstrap URL "https://ns.rec.net/?v=2" as a
# string literal in IL2CPP global-metadata.dat. It builds the URL via
# BestHTTP.HTTPRequest directly with the raw string — never goes through
# `new System.Uri(string)` — so the plugin's URL-rewrite Harmony patch
# can't see it. Same for the WWW link "www.rec.net". Both literals are
# the same length as their localhost equivalents, so we can byte-replace
# in place without shifting offsets.
#
# This is the ONLY hardcoded-byte edit the install does, and it's
# auto-detected & re-applied on every install run. The original file
# is preserved as `global-metadata.dat.dorknet-original` so -Revert
# can put it back.
function Invoke-MetadataHostPatch {
param([string]$DataDir, [string]$ServerHost)
$meta = Join-Path $DataDir 'il2cpp_data\Metadata\global-metadata.dat'
if (-not (Test-Path -LiteralPath $meta -PathType Leaf)) {
Write-Warn2 "global-metadata.dat not found at $meta — skipping host byte-patch."
return
}
$backup = $meta + '.dorknet-original'
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) {
Copy-Item -LiteralPath $meta -Destination $backup
}
# Always patch from the pristine backup so re-runs are idempotent and
# don't accidentally compound substitutions if the host changes.
$bytes = [System.IO.File]::ReadAllBytes($backup)
$subs = @(
@{ Old = 'ns.rec.net'; New = "ns.$ServerHost" },
@{ Old = 'www.rec.net'; New = "www.$ServerHost" }
)
$totalReplacements = 0
foreach ($s in $subs) {
$oldB = [System.Text.Encoding]::ASCII.GetBytes($s.Old)
$newB = [System.Text.Encoding]::ASCII.GetBytes($s.New)
if ($oldB.Length -ne $newB.Length) {
Write-Err "Host '$($s.Old)' (len $($oldB.Length)) and '$($s.New)' (len $($newB.Length)) differ in length — refusing to patch."
return
}
$i = 0
while ($i -le $bytes.Length - $oldB.Length) {
$match = $true
for ($k = 0; $k -lt $oldB.Length; $k++) {
if ($bytes[$i + $k] -ne $oldB[$k]) { $match = $false; break }
}
if ($match) {
[Array]::Copy($newB, 0, $bytes, $i, $newB.Length)
$totalReplacements++
$i += $newB.Length
} else {
$i++
}
}
}
[System.IO.File]::WriteAllBytes($meta, $bytes)
Write-OK "global-metadata.dat host-patched ($totalReplacements substitution$( if ($totalReplacements -ne 1) { 's' }))."
}
Invoke-MetadataHostPatch -DataDir $RecRoomPath -ServerHost $ServerHost
# ── 1. BepInEx install ────────────────────────────────────────────────────
$bepInExPresent = Test-Path -LiteralPath $BepInExDir -PathType Container
if (-not $bepInExPresent -or $ForceBepInEx) {
# Auto-fetch the pinned build when the operator didn't supply one.
# First-run UX: just `install-plugin.ps1 -RecRoomPath ... -PhotonAppId ...`
# works without the operator having to manually download a zip.
if (-not $BepInExZip) {
$BepInExZip = Get-BepInExZip
}
if (-not (Test-Path -LiteralPath $BepInExZip -PathType Leaf)) {
Write-Err "BepInExZip '$BepInExZip' not found."
exit 1
}
if ($ForceBepInEx -and $bepInExPresent) {
Write-Step "Removing existing BepInEx install (-ForceBepInEx)"
Remove-Item -LiteralPath $BepInExDir -Recurse -Force
}
Write-Step "Extracting BepInEx → $RecRoomRoot"
Expand-Archive -LiteralPath $BepInExZip -DestinationPath $RecRoomRoot -Force
Write-OK 'BepInEx extracted.'
} else {
Write-OK "BepInEx already installed at $BepInExDir"
}
# ── 2. Interop assemblies ─────────────────────────────────────────────────
if (-not (Test-Path -LiteralPath $InteropDir -PathType Container)) {
Write-Warn2 'Interop assemblies not found.'
Write-Host ' Launch the client ONCE so BepInEx generates them, then re-run this script.' -ForegroundColor DarkGray
Write-Host ' (One-time step per game version — takes ~30s on first launch.)' -ForegroundColor DarkGray
exit 0
}
Write-OK "Interop assemblies present at $InteropDir"
# ── 3. Build plugin ───────────────────────────────────────────────────────
$env:BepInExInterop = $InteropDir
$env:BepInExCore = Join-Path $BepInExDir 'core'
Write-Step 'Building DorkNet.ClientPatch'
$build = & dotnet build $PluginProj -c Release --nologo 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Err 'Plugin build failed:'
Write-Host ($build -join "`n") -ForegroundColor DarkGray
exit 1
}
$pluginDll = Join-Path $PluginProj 'bin\Release\DorkNet.ClientPatch.dll'
if (-not (Test-Path -LiteralPath $pluginDll -PathType Leaf)) {
Write-Err "Plugin DLL not produced at $pluginDll"
exit 1
}
New-Item -ItemType Directory -Path $PluginsDir -Force | Out-Null
$oldPluginDll = Join-Path $PluginsDir ('DorkNet.' + 'Rec' + 'RoomPatch.dll')
if (Test-Path -LiteralPath $oldPluginDll -PathType Leaf) {
Remove-Item -LiteralPath $oldPluginDll -Force
Write-OK 'Removed old client patch plugin.'
}
Copy-Item -LiteralPath $pluginDll -Destination $PluginsDir -Force
Write-OK ('Plugin DLL → ' + (Join-Path $PluginsDir 'DorkNet.ClientPatch.dll'))
# ── 4. Plugin config ──────────────────────────────────────────────────────
# Write the BepInEx config file with the operator's chosen values.
# Format mirrors what the plugin's Config.Bind defaults produce on first
# run, so editing afterwards via BepInEx config manager works as expected.
$cfgFile = Join-Path $ConfigDir 'sh.dork.clientpatch.cfg'
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
$voiceId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' }
$cfg = @"
## Settings file was created by DorkNet install-plugin.ps1
## Plugin GUID: sh.dork.clientpatch
[Server]
## Domain that replaces *.rec.net in every URL the watch builds.
## Empty disables the rewrite (use only if pointing at the official servers).
# Setting type: String
# Default value: localhost
Host = $ServerHost
[Photon]
## Photon Cloud Realtime AppId (UUID, no curly braces).
## Empty leaves the AppId baked into the build untouched.
# Setting type: String
# Default value:
AppId = $PhotonAppId
## Photon Cloud Voice AppId. Empty falls back to AppId.
# Setting type: String
# Default value:
VoiceAppId = $voiceId
## Attach userid + LoginLock to PhotonNetwork.AuthValues right before
## OpAuthenticate so DorkNet's /photon/customauth endpoint can identify
## the player. Disable to debug pure Photon connectivity issues.
# Setting type: Boolean
# Default value: true
InjectAuthValues = true
[UI]
## Replacement branding string. Any UI label containing 'RecNet'
## gets that substring replaced with this value at runtime, so error
## dialogs and the loading screen show the private-server brand.
## Empty disables the rewrite.
# Setting type: String
# Default value: DorkNet
BrandName = DorkNet
[Steam]
## Bypass Steamworks.SteamAPI.RestartAppIfNecessary so the watch
## doesn't relaunch itself through Steam on boot. No effect when
## launched normally from the Steam library; useful when running the
## game directly via Recroom_Release.exe (BepInEx mod-loader shims
## often need this to avoid an infinite restart loop).
# Setting type: Boolean
# Default value: true
SkipRestartAppIfNecessary = true
## Pretend the Steam SDK initialised even when Steam.exe isn't
## running. Patches Steamworks.NET wrappers to return canned success
## values so the watch's SteamPlatformManager.Initialize succeeds
## without a live Steam process. DorkNet's auth backend uses SteamID
## as a lookup key only — no real ticket validation — so the fake
## ticket is accepted. Default off so launches via Steam itself
## behave normally.
# Setting type: Boolean
# Default value: false
FakeSteamApi = $($FakeSteamApi.IsPresent.ToString().ToLower())
## SteamID64 returned by SteamUser.GetSteamID when FakeSteamApi=true.
## Empty = deterministic value derived from OS user + machine name.
## Pin an explicit ID to keep the same DorkNet account across
## different machines.
# Setting type: String
# Default value:
FakeSteamId = $FakeSteamId
## Display name returned by SteamFriends.GetPersonaName when
## FakeSteamApi=true. Empty = OS user name.
# Setting type: String
# Default value:
FakeAccountName = $FakeAccountName
"@
$cfg | Out-File -LiteralPath $cfgFile -Encoding utf8 -Force
Write-OK "Plugin config → $cfgFile"
Write-Host ''
Write-OK 'All done. Launch the client from Steam.'
Write-Host ' Tail BepInEx/LogOutput.log for `[auth-injector] set Photon AuthValues …`' -ForegroundColor DarkGray
Write-Host ' to confirm the plugin loaded and ran.' -ForegroundColor DarkGray
+131
View File
@@ -0,0 +1,131 @@
<#
.SYNOPSIS
Disable Easy Anti-Cheat in a Rec Room 2020.12+ install so MelonLoader /
Harmony patches can attach.
.DESCRIPTION
The 2020.12+ Steam builds ship EAC, but the integration is the standard
EAC-launcher-wrapper model — NOT in-process anti-cheat in the game
binary itself.
Files in a 2020.12 install:
RecRoom.exe (~650 KB) The actual Unity game binary.
No SteamStub, no EAC strings.
Recroom_Release.exe (~1.2 MB) The EAC launcher front-end. Steam
launches this; it reads
EasyAntiCheat/Launcher/Settings.json,
talks to the EAC service, then
spawns RecRoom.exe under EAC.
EasyAntiCheat/ EAC service binaries + the launcher's
Settings.json.
EAC blocks foreign DLL injection (MelonLoader's version.dll proxy,
Harmony hooks, etc.), which makes a normal Steam launch unrunnable for
the modded client. The fix is simple: skip the EAC launcher entirely
and run RecRoom.exe directly. EAC is never loaded into the process,
no patching of any binary required.
This script just writes Launch-DorkNet.bat at the install root that
starts RecRoom.exe directly with any flags you pass through. The
EasyAntiCheat/ folder and Recroom_Release.exe are left in place —
they're harmless when nothing invokes them, and leaving them avoids
tripping unrelated existence checks.
Designed for use with a DorkNet private server only — EAC's whole
point is the official live game. On a self-hosted server where every
client talks to YOUR backend, EAC isn't doing anything useful.
.PARAMETER RecRoomDir
The Rec Room install root (the folder containing RecRoom.exe,
EasyAntiCheat/, RecRoom_Data/).
.PARAMETER Revert
Remove the Launch-DorkNet.bat shortcut. (There's nothing else to
undo — the script never touches the game files.)
.PARAMETER WhatIf
Show what would be done without touching the filesystem.
.EXAMPLE
# Set up direct launch on a 2020.12 install
.\tools\remove-eac.ps1 -RecRoomDir 'C:\Games\RecRoom'
.EXAMPLE
# Tear it down
.\tools\remove-eac.ps1 -RecRoomDir 'C:\Games\RecRoom' -Revert
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)] [string] $RecRoomDir,
[switch] $Revert
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host ('+ ' + $msg) -ForegroundColor Green }
function Write-Warn2($msg){ Write-Host ('! ' + $msg) -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host ('x ' + $msg) -ForegroundColor Red }
$resolved = Resolve-Path -LiteralPath $RecRoomDir -ErrorAction SilentlyContinue
if (-not $resolved) { Write-Err "RecRoomDir '$RecRoomDir' does not exist."; exit 1 }
$RecRoomDir = $resolved.Path
$GameExe = Join-Path $RecRoomDir 'RecRoom.exe'
$BootBat = Join-Path $RecRoomDir 'Launch-DorkNet.bat'
if ($Revert) {
Write-Step 'Reverting — removing Launch-DorkNet.bat.'
if (Test-Path -LiteralPath $BootBat) {
if ($PSCmdlet.ShouldProcess($BootBat, 'remove launch shortcut')) {
Remove-Item -LiteralPath $BootBat
Write-OK 'Removed Launch-DorkNet.bat'
}
} else {
Write-Warn2 'No Launch-DorkNet.bat present — already reverted.'
}
Write-OK 'Done. Steam launch path is unchanged (the script never touched it).'
exit 0
}
if (-not (Test-Path -LiteralPath $GameExe -PathType Leaf)) {
Write-Err "RecRoom.exe not found in '$RecRoomDir'."
Write-Host ' This script targets 2020.12+ installs. For 2020.03.10 there' -ForegroundColor DarkGray
Write-Host ' is no EAC; just run Recroom_Release.exe directly.' -ForegroundColor DarkGray
exit 1
}
Write-Step "Setting up direct-launch in $RecRoomDir"
$batBody = @"
@echo off
REM Launch Rec Room 2020.12+ directly, bypassing the EAC launcher
REM (Recroom_Release.exe). Used with a DorkNet private server.
REM
REM RecRoom.exe is the real Unity game binary; the EAC launcher is only
REM responsible for starting it under EAC's process protection. Running
REM the game binary directly means EAC is never loaded into the process,
REM so MelonLoader / Harmony patches attach cleanly.
REM
REM Generated by tools/remove-eac.ps1 -- re-run that script with -Revert
REM to delete this shortcut.
pushd "%~dp0"
start "" "RecRoom.exe" %*
popd
"@
if ($PSCmdlet.ShouldProcess($BootBat, 'write Launch-DorkNet.bat')) {
Set-Content -LiteralPath $BootBat -Value $batBody -Encoding ASCII
Write-OK "Wrote $BootBat"
}
Write-Host ''
Write-OK 'EAC bypass ready.'
Write-Host ' - Launch with: ' -NoNewline; Write-Host $BootBat -ForegroundColor White
Write-Host ' - Or run RecRoom.exe directly.' -ForegroundColor DarkGray
Write-Host ' - Do NOT launch Recroom_Release.exe -- that is the EAC launcher.' -ForegroundColor DarkGray
Write-Host ' - Steam "Play" will still try to start Recroom_Release.exe; avoid it.' -ForegroundColor DarkGray
Write-Host ' - To undo: ' -NoNewline; Write-Host '.\tools\remove-eac.ps1 -RecRoomDir "<path>" -Revert' -ForegroundColor White
+229
View File
@@ -0,0 +1,229 @@
<#
.SYNOPSIS
Stand up a Cloudflare Tunnel that routes every *.localhost request to
the local DorkNet server. One-shot setup script for temp testing.
.DESCRIPTION
Performs the steps a tester would otherwise do by hand:
1. Installs cloudflared if missing (via winget).
2. Runs `cloudflared tunnel login` (browser auth — one-time).
3. Creates a named tunnel "dorknet" (idempotent — re-uses an
existing tunnel of the same name).
4. Generates %USERPROFILE%\.cloudflared\config.yml from the
template at tools/cloudflared-config.yml.template, substituting
the tunnel id + credentials file path.
5. Adds DNS routes for every localhost subdomain DorkNet uses
(api, auth, match, accounts, admin, feed, …). Cloudflare
creates each as a CNAME pointing at <tunnel>.cfargotunnel.com.
6. Prints the next steps to start the tunnel.
Prerequisite: localhost must be on a Cloudflare account you control,
with the nameservers pointed at Cloudflare. The CLI works against
any zone the logged-in account can manage.
.PARAMETER TunnelName
The name to give the tunnel. Default 'dorknet'.
.PARAMETER Domain
Apex domain to route. Default 'localhost'.
.PARAMETER DryRun
Print every command that would run without executing.
.EXAMPLE
PS> .\tools\setup-cloudflared.ps1
#>
[CmdletBinding()]
param(
[string]$TunnelName = 'dorknet',
[string]$Domain = 'localhost',
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Write-Step($msg) { Write-Host ('▸ ' + $msg) -ForegroundColor Cyan }
function Write-OK($msg) { Write-Host ('✓ ' + $msg) -ForegroundColor Green }
function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host ('✗ ' + $msg) -ForegroundColor Red }
function Write-DryRun($msg) { Write-Host ('[dry-run] ' + $msg) -ForegroundColor DarkGray }
# cloudflared writes harmless warnings (e.g. "Your version is outdated")
# to stderr. With ErrorActionPreference='Stop' globally, the 2>&1
# merge would surface those as "NativeCommandError" terminating
# errors. Wrap every cloudflared invocation in this helper so stderr
# becomes informational output and only a non-zero exit is treated
# as a failure.
function Invoke-Cloudflared {
param([Parameter(ValueFromRemainingArguments = $true)] $CfArgs)
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & cloudflared @CfArgs 2>&1 | ForEach-Object { $_.ToString() }
$exit = $LASTEXITCODE
return [PSCustomObject]@{
Output = $output
ExitCode = $exit
}
} finally {
$ErrorActionPreference = $prev
}
}
$RepoRoot = Split-Path -Parent $PSScriptRoot
$Template = Join-Path $PSScriptRoot 'cloudflared-config.yml.template'
$CfDir = Join-Path $env:USERPROFILE '.cloudflared'
$ConfigPath = Join-Path $CfDir 'config.yml'
# Subdomains we want routed through the tunnel. The wildcard route in
# config.yml catches everything; this list drives the per-subdomain
# DNS CNAMEs Cloudflare needs (no automatic wildcard CNAME via the
# CLI — each one is created explicitly via `cloudflared tunnel route
# dns`).
$Subdomains = @(
'@', # apex (localhost itself)
'admin', 'api', 'accounts', 'auth', 'cdn', 'chat', 'commerce',
'discovery', 'econ', 'feed', 'geo', 'img', 'match', 'notify',
'ns', 'playersettings', 'rooms', 'storage', 'strings', 'strings-cdn',
# Long-tail subdomains DorkNet's stub controllers ack — adding
# CNAMEs for these means the patched client never hits a NXDOMAIN
# for any of the URLs it might probe.
'bugreporting', 'cards', 'clubs', 'cms', 'data', 'datacollection',
'gamelogs', 'leaderboard', 'link', 'lists', 'moderation',
'platformnotifications', 'roomcomments', 'roomieintegrations',
'studio', 'thorn', 'videos'
)
# ── 1. cloudflared install ────────────────────────────────────────────────────
Write-Step 'Checking cloudflared'
$cf = Get-Command cloudflared -ErrorAction SilentlyContinue
if (-not $cf) {
Write-Warn2 'cloudflared not in PATH.'
$winget = Get-Command winget -ErrorAction SilentlyContinue
if ($winget) {
if ($DryRun) {
Write-DryRun 'Would run: winget install --id Cloudflare.cloudflared --silent'
} else {
Write-Step 'Installing via winget'
& winget install --id Cloudflare.cloudflared --silent --accept-package-agreements --accept-source-agreements
# winget puts it in PATH but not in the current shell session
$env:PATH = "$env:PATH;$env:LOCALAPPDATA\Microsoft\WinGet\Links"
$cf = Get-Command cloudflared -ErrorAction SilentlyContinue
if (-not $cf) {
Write-Err 'cloudflared still not found after install. Open a new shell and re-run.'
exit 1
}
}
} else {
Write-Err 'winget not found either. Install cloudflared manually:'
Write-Host ' https://github.com/cloudflare/cloudflared/releases' -ForegroundColor DarkGray
exit 1
}
}
$cfSource = if ($cf) { $cf.Source } else { 'just-installed' }
Write-OK ('cloudflared at ' + $cfSource)
# ── 2. Login ──────────────────────────────────────────────────────────────────
$certPath = Join-Path $CfDir 'cert.pem'
if (-not (Test-Path -LiteralPath $certPath)) {
Write-Step 'Cloudflare login (browser will open — pick the localhost zone)'
if ($DryRun) {
Write-DryRun 'Would run: cloudflared tunnel login'
} else {
$login = Invoke-Cloudflared tunnel login
$login.Output | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray }
if (-not (Test-Path -LiteralPath $certPath)) {
Write-Err 'cert.pem still missing — login was cancelled or failed.'
exit 1
}
}
}
Write-OK ('Auth cert present at ' + $certPath)
# ── 3. Tunnel create / look up ─────────────────────────────────────────────────
Write-Step ("Looking up tunnel '" + $TunnelName + "'")
if ($DryRun) {
Write-DryRun ('Would run: cloudflared tunnel list and create ' + $TunnelName + ' if missing')
$tunnelId = '{{TUNNEL_ID}}'
$credsFile = '{{CREDENTIALS_FILE}}'
} else {
# `cloudflared tunnel list` outputs human-readable text by default;
# parsing the table is fragile but works. We grep for our name and
# extract the UUID. If not found, create.
$list = Invoke-Cloudflared tunnel list
$existingLine = $list.Output | Select-String -Pattern ('\b' + [regex]::Escape($TunnelName) + '\b')
if ($existingLine) {
# Format: "<id> <name> <created> <connections>"
$tunnelId = ($existingLine.Line -split '\s+')[0]
Write-OK ('Tunnel exists: ' + $tunnelId)
} else {
Write-Step ("Creating tunnel '" + $TunnelName + "'")
$create = Invoke-Cloudflared tunnel create $TunnelName
$create.Output | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray }
$list = Invoke-Cloudflared tunnel list
$existingLine = $list.Output | Select-String -Pattern ('\b' + [regex]::Escape($TunnelName) + '\b')
if (-not $existingLine) {
Write-Err 'Tunnel create failed; cloudflared list still empty.'
exit 1
}
$tunnelId = ($existingLine.Line -split '\s+')[0]
Write-OK ('Created tunnel ' + $tunnelId)
}
$credsFile = Join-Path $CfDir ($tunnelId + '.json')
if (-not (Test-Path -LiteralPath $credsFile)) {
Write-Warn2 ('Credentials file missing: ' + $credsFile)
}
}
# ── 4. config.yml from template ───────────────────────────────────────────────
Write-Step ('Writing ' + $ConfigPath)
if (-not (Test-Path -LiteralPath $Template)) {
Write-Err ('Template missing: ' + $Template)
exit 1
}
if (-not (Test-Path -LiteralPath $CfDir)) {
if (-not $DryRun) { New-Item -ItemType Directory -Path $CfDir -Force | Out-Null }
}
$tmpl = Get-Content -LiteralPath $Template -Raw
$config = $tmpl `
-replace '\{\{TUNNEL_ID\}\}', $tunnelId `
-replace '\{\{CREDENTIALS_FILE\}\}', ($credsFile -replace '\\', '\\')
if ($DryRun) {
Write-DryRun ('Would write ' + $ConfigPath)
Write-Host $config -ForegroundColor DarkGray
} else {
Set-Content -LiteralPath $ConfigPath -Value $config -NoNewline
Write-OK 'Config written.'
}
# ── 5. DNS routes ──────────────────────────────────────────────────────────────
Write-Step ('Adding DNS CNAMEs on ' + $Domain)
foreach ($sub in $Subdomains) {
$hostname = if ($sub -eq '@') { $Domain } else { "$sub.$Domain" }
if ($DryRun) {
Write-DryRun ("Would run: cloudflared tunnel route dns $TunnelName $hostname")
continue
}
Write-Host (' ' + $hostname) -ForegroundColor DarkGray
$null = Invoke-Cloudflared tunnel route dns $TunnelName $hostname
# cloudflared returns nonzero if the CNAME already exists, but the
# net result is fine — proceed.
}
Write-OK 'DNS routes registered.'
# ── 6. Done ───────────────────────────────────────────────────────────────────
Write-Host ''
Write-OK 'Cloudflare Tunnel ready.'
Write-Host ''
Write-Host ' Start the tunnel (keep running while testing):' -ForegroundColor White
Write-Host (' cloudflared tunnel run ' + $TunnelName) -ForegroundColor Cyan
Write-Host ''
Write-Host ' Or install as a Windows service so it auto-starts:' -ForegroundColor White
Write-Host ' cloudflared service install' -ForegroundColor Cyan
Write-Host ''
Write-Host ' Then: visit https://admin.localhost / https://feed.localhost / play the' -ForegroundColor DarkGray
Write-Host ' patched client. Make sure the local DorkNet.Server is running' -ForegroundColor DarkGray
Write-Host ' and listening on 0.0.0.0:443 with the *.localhost-covering cert.' -ForegroundColor DarkGray