mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
Remove BepInEx client patcher (DorkNet.ClientPatch)
MelonLoader (DorkNet.ClientMod) is the sole client patcher now, so drop the BepInEx plugin and its BepInEx-only installer: - delete the DorkNet.ClientPatch project + tools/install-plugin.ps1 - drop the project from DorkNet.sln - README / architecture / deploy docs: present DorkNet.ClientMod (install-melon.ps1) as the patcher instead of the BepInEx plugin - fix install-melon.ps1 fallback hint that pointed at the deleted install-plugin.ps1 - tidy stray 'BepInEx port' comments in Mod.cs / PhotonCustomAuthController Legacy-cleanup paths that REMOVE an old BepInEx install (install-legacy-client.ps1, the old_plugin_paths entry in build-patcher-zip.ps1) are kept intentionally.
This commit is contained in:
@@ -6,9 +6,8 @@
|
||||
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.
|
||||
2. Builds DorkNet.ClientMod (the MelonLoader IL2CPP client mod)
|
||||
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.
|
||||
@@ -155,7 +154,7 @@ $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.
|
||||
# machines — a regression upstream doesn't silently land on testers.
|
||||
# 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'
|
||||
@@ -579,10 +578,9 @@ if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container) -and $AutoB
|
||||
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
|
||||
Write-Host ' Falling back to the manual flow: launch the game once' -ForegroundColor DarkGray
|
||||
Write-Host ' (e.g. from Steam) so it self-strips the DRM, then re-run' -ForegroundColor DarkGray
|
||||
Write-Host ' this script with -ResumeBuild.' -ForegroundColor DarkGray
|
||||
}
|
||||
} else {
|
||||
Write-OK "Recroom_Release.exe is already DRM-stripped."
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
<#
|
||||
.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
|
||||
Reference in New Issue
Block a user