Files
Radicade/Tools/patch-rrs-nameserver.ps1
T
2026-10-16 23:33:13 -07:00

341 lines
13 KiB
PowerShell

<#
.SYNOPSIS
Points the Rec Room Studio Unity editor at a Radicade server instead of Rec Room's
real nameserver.
.DESCRIPTION
Everything the Rec Room Studio editor talks to is discovered from ONE compiled-in
string: the nameserver base URL inside RecRoom.Studio.Editor.Core.dll
(shipped default: https://deluxe-backend-test.oldrecroom.com/nameserver).
The editor GETs that URL and derives every other service base (Auth, API, CDN, ...)
from the JSON it returns. There is no config file, env var or command-line override
for it, so pointing RRS at a private server means rewriting that one #US heap string.
This patch is done in place and in a size-preserving way:
* the .NET #US string heap is addressed by offset, and no other entry's offset
moves because the entry keeps its exact old byte footprint;
* the freed tail of the entry is zero-filled and is never referenced;
* the file length is unchanged.
A one-time backup (<dll>.radicade-orig) is kept, so -Restore is always possible.
The backup deliberately lives OUTSIDE the package folder, under
<ProjectRoot>\radicade-backups\..., because Unity treats everything under
Library\PackageCache as an immutable package folder and logs
"asset ... has no meta file, but it's in an immutable folder" for anything
there it cannot match to a package manifest.
The target project is probed for writability first: a Rec Room Studio project sitting
in a OneDrive folder (or covered by Controlled folder access) rejects every create and
overwrite, and Unity cannot run from such a tree at all. Copy the project somewhere
writable and keep working there.
.PARAMETER ProjectRoot
Candidate project roots, tried in order; the first one that exists, holds the studio
package cache and actually accepts writes is used.
.PARAMETER Url
Nameserver base URL the editor should use.
.PARAMETER DllPath
Patch this DLL directly instead of probing ProjectRoot.
.PARAMETER Restore
Put the backed-up DLL back and exit.
.EXAMPLE
pwsh -File tools/patch-rrs-nameserver.ps1
pwsh -File tools/patch-rrs-nameserver.ps1 -Url http://127.0.0.1:3031/nameserver
pwsh -File tools/patch-rrs-nameserver.ps1 -Restore
#>
[CmdletBinding()]
param(
[string[]] $ProjectRoot = @(
'C:\RecRoom\Documents\EggFiles\ProjectTemplate',
'C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate'
),
[string] $Url = 'https://localhost:3030/nameserver',
[string] $DllPath,
[switch] $Restore
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# Latin-1 gives a 1:1 byte<->char mapping, so IndexOf() returns byte offsets and the
# search stays native-fast instead of looping over ~330 KB in PowerShell.
$latin1 = [System.Text.Encoding]::GetEncoding(28591)
# Known shipped values of the literal. The first one is what this template ships; the
# list keeps the tool working if a future build bakes a different host.
$knownUrls = @(
'https://deluxe-backend-test.oldrecroom.com/nameserver'
)
function Write-Status {
param([string] $Level, [string] $Message)
Write-Host ("[{0}] {1}" -f $Level, $Message)
}
function Find-EditorCoreDll {
param([string] $Root, [switch] $Quiet)
$pkgRoot = Join-Path $Root 'Library\PackageCache'
if (-not (Test-Path -LiteralPath $pkgRoot)) {
if ($Quiet) { return $null }
throw "No PackageCache under '$Root' - open the project in Unity once so packages get extracted."
}
$candidates = Get-ChildItem -LiteralPath $pkgRoot -Directory -Filter 'com.recroom.studio.build@*' -ErrorAction SilentlyContinue |
ForEach-Object { Join-Path $_.FullName 'Editor\RecRoom.Studio.Editor.Core.dll' } |
Where-Object { Test-Path -LiteralPath $_ }
if (-not $candidates) {
if ($Quiet) { return $null }
throw "RecRoom.Studio.Editor.Core.dll not found under $pkgRoot (looked in com.recroom.studio.build@*)."
}
if (@($candidates).Count -gt 1) {
Write-Status 'WARN' "multiple studio.build packages present, using the first: $($candidates[0])"
}
return @($candidates)[0]
}
function Get-BackupPath {
<# Maps <ProjectRoot>\Library\PackageCache\<pkg>\...\X.dll to
<ProjectRoot>\radicade-backups\<pkg>\...\X.dll.radicade-orig, so the backup stays a
real project asset tree instead of polluting the immutable package folder. #>
param([string] $DllPath)
$marker = '\Library\PackageCache\'
$idx = $DllPath.IndexOf($marker, [System.StringComparison]::OrdinalIgnoreCase)
if ($idx -lt 0) {
# Not inside a package cache (a -DllPath into Assets, say): keep it as a sibling file.
return "$DllPath.radicade-orig"
}
$root = $DllPath.Substring(0, $idx)
$relative = $DllPath.Substring($idx + $marker.Length)
return (Join-Path $root (Join-Path 'radicade-backups' "$relative.radicade-orig"))
}
function Test-Writable {
param([string] $Directory)
$probe = Join-Path $Directory ('radicade-write-probe-' + [Guid]::NewGuid().ToString('N') + '.tmp')
try {
[System.IO.File]::WriteAllText($probe, 'probe')
Remove-Item -LiteralPath $probe -Force
return $true
} catch {
return $false
}
}
function Resolve-Project {
param([string[]] $Candidates)
$notes = New-Object System.Collections.Generic.List[string]
foreach ($candidate in $Candidates) {
if (-not (Test-Path -LiteralPath $candidate)) {
$notes.Add("$candidate - does not exist")
continue
}
$dll = Find-EditorCoreDll -Root $candidate -Quiet
if (-not $dll) {
$notes.Add("$candidate - no com.recroom.studio.build package cache inside")
continue
}
if (-not (Test-Writable -Directory $candidate)) {
$notes.Add("$candidate - present, but the tree rejects writes")
continue
}
return $dll
}
$details = ($notes | ForEach-Object { " $_" }) -join [Environment]::NewLine
throw @"
No writable Rec Room Studio project found. Tried:
$details
A tree that rejects writes cannot hold this project: Unity has to create and overwrite
files in Library\ and Temp\ just to open it. Copy the project somewhere writable first
(always exclude the OneDrive copy from your Unity Hub list afterwards), for example:
robocopy "C:\RecRoom\OneDrive\Documents\EggFiles\ProjectTemplate" "C:\RecRoom\Documents\EggFiles\ProjectTemplate" /E /MT:16 /R:1 /W:1
then pass that root with -ProjectRoot, or just re-run this script.
"@
}
function Read-CompressedUInt {
param([byte[]] $Bytes, [int] $Offset)
$b0 = $Bytes[$Offset]
if (($b0 -band 0x80) -eq 0) { return @{ Value = [int]$b0; Size = 1 } }
if (($b0 -band 0xC0) -eq 0x80) {
return @{ Value = ((([int]$b0 -band 0x3F) -shl 8) -bor [int]$Bytes[$Offset + 1]); Size = 2 }
}
$v = (([int]$b0 -band 0x1F) -shl 24) -bor ([int]$Bytes[$Offset + 1] -shl 16) -bor `
([int]$Bytes[$Offset + 2] -shl 8) -bor [int]$Bytes[$Offset + 3]
return @{ Value = $v; Size = 4 }
}
function Write-CompressedUInt {
param([byte[]] $Bytes, [int] $Offset, [int] $Value)
if ($Value -lt 0x80) {
$Bytes[$Offset] = [byte]$Value
return 1
}
if ($Value -lt 0x4000) {
$Bytes[$Offset] = [byte](0x80 -bor ($Value -shr 8))
$Bytes[$Offset + 1] = [byte]($Value -band 0xFF)
return 2
}
$Bytes[$Offset] = [byte](0xC0 -bor ($Value -shr 24))
$Bytes[$Offset + 1] = [byte](($Value -shr 16) -band 0xFF)
$Bytes[$Offset + 2] = [byte](($Value -shr 8) -band 0xFF)
$Bytes[$Offset + 3] = [byte]($Value -band 0xFF)
return 4
}
function Get-NameserverEntry {
<# Locates the #US string heap entry whose payload is one of $Known or $Target.
Returns offset/prefix/payload geometry, or $null when the literal is absent. #>
param([string] $Path, [string[]] $Known, [string] $Target)
$bytes = [System.IO.File]::ReadAllBytes($Path)
$hay = $latin1.GetString($bytes)
foreach ($candidate in @($Known + @($Target))) {
$payload = [System.Text.Encoding]::Unicode.GetBytes($candidate)
$at = $hay.IndexOf($latin1.GetString($payload), [System.StringComparison]::Ordinal)
if ($at -lt 1) { continue }
for ($width = 1; $width -le 4; $width++) {
$prefixAt = $at - $width
if ($prefixAt -lt 0) { continue }
$prefix = Read-CompressedUInt -Bytes $bytes -Offset $prefixAt
if ($prefix.Size -ne $width) { continue }
if ($prefix.Value -ne (2 * $candidate.Length + 1)) { continue }
return [pscustomobject]@{
Bytes = $bytes
PayloadOffset = $at
PrefixOffset = $prefixAt
PrefixSize = $prefix.Size
PrefixValue = $prefix.Value
Original = $candidate
Footprint = $prefix.Size + 2 * $candidate.Length + 1
TerminalByte = $bytes[$at + 2 * $candidate.Length]
}
}
}
return $null
}
# ---- main -------------------------------------------------------------------------
Write-Host ''
if (-not $DllPath) {
$DllPath = Resolve-Project -Candidates $ProjectRoot
Write-Status 'INFO' 'project : writable, using the studio package cache below'
}
$backup = Get-BackupPath -DllPath $DllPath
Write-Status 'INFO' "studio editor core: $DllPath"
Write-Status 'INFO' "backup location : $backup"
if ($Restore) {
if (-not (Test-Path -LiteralPath $backup)) {
# Older runs kept the backup in the package folder; still honour one if it is all we have.
$legacy = "$DllPath.radicade-orig"
if (Test-Path -LiteralPath $legacy) {
Write-Status 'WARN' "using legacy in-package backup at $legacy"
$backup = $legacy
} else {
throw "No backup at '$backup' - nothing to restore."
}
}
try {
Copy-Item -LiteralPath $backup -Destination $DllPath -Force
} catch {
throw "Could not write to '$DllPath' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
Write-Status 'PASS' 'restored the original DLL from backup'
exit 0
}
$entry = Get-NameserverEntry -Path $DllPath -Known $knownUrls -Target $Url
if (-not $entry) { throw "Nameserver literal not found in '$DllPath' (unexpected layout)." }
if ($entry.Original -eq $Url) {
Write-Status 'PASS' "already points at $Url"
exit 0
}
$newPayload = [System.Text.Encoding]::Unicode.GetBytes($Url)
$newValue = 2 * $Url.Length + 1
$free = $entry.Footprint - (1 + $newPayload.Length + 1)
if ($free -lt 0) {
throw "Replacement '$Url' ($($Url.Length) chars) does not fit in the original slot ($($entry.Original.Length) chars)."
}
try {
if (-not (Test-Path -LiteralPath $backup)) {
$backupDir = Split-Path -Parent $backup
if (-not (Test-Path -LiteralPath $backupDir)) {
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
}
Copy-Item -LiteralPath $DllPath -Destination $backup
Write-Status 'INFO' "backed up original to $backup"
}
} catch {
throw "Could not write to '$backup' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
$bytes = [byte[]]::new($entry.Bytes.Length)
[Array]::Copy($entry.Bytes, $bytes, $entry.Bytes.Length)
# Rewrite prefix + payload + terminal byte, then zero the unused tail of the slot.
$cursor = $entry.PrefixOffset
$cursor += Write-CompressedUInt -Bytes $bytes -Offset $cursor -Value $newValue
[Array]::Copy($newPayload, 0, $bytes, $cursor, $newPayload.Length)
$cursor += $newPayload.Length
$bytes[$cursor] = $entry.TerminalByte
$cursor++
for ($i = $cursor; $i -lt ($entry.PrefixOffset + $entry.Footprint); $i++) { $bytes[$i] = 0 }
try {
[System.IO.File]::WriteAllBytes($DllPath, $bytes)
} catch {
throw "Could not write to '$DllPath' - the project tree rejects writes. Copy the project somewhere writable and re-run with -ProjectRoot."
}
# ---- verify -----------------------------------------------------------------------
$check = Get-NameserverEntry -Path $DllPath -Known $knownUrls -Target $Url
if (-not $check -or $check.Original -ne $Url) {
throw "Verification failed: heap entry does not decode to '$Url' after patching."
}
if ([System.IO.File]::ReadAllBytes($DllPath).Length -ne $entry.Bytes.Length) {
throw 'Verification failed: file length changed.'
}
# Every other heap entry must keep its offset, so compare everything outside the slot.
$after = [System.IO.File]::ReadAllBytes($DllPath)
$slotEnd = $entry.PrefixOffset + $entry.Footprint
$diffOutsideSlot = 0
for ($i = 0; $i -lt $after.Length; $i++) {
if ($i -ge $entry.PrefixOffset -and $i -lt $slotEnd) { continue }
if ($after[$i] -ne $entry.Bytes[$i]) { $diffOutsideSlot++ }
}
if ($diffOutsideSlot -ne 0) {
throw "Verification failed: $diffOutsideSlot bytes changed outside the string-heap slot."
}
Write-Status 'PASS' "nameserver: $($entry.Original) -> $Url"
Write-Status 'INFO' "slot $($entry.PrefixOffset), $($entry.Footprint) bytes, $free bytes of padding; file size unchanged"
Write-Status 'INFO' "open this copy from Unity Hub: $(Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $DllPath)))))"
Write-Status 'INFO' "undo with: pwsh -File tools/patch-rrs-nameserver.ps1 -Restore -DllPath `"$DllPath`""