mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Merge pull request #62 from DorkSquadRR/ci/jenkins-client-builds
Build the PC and Quest clients on Jenkins
This commit is contained in:
@@ -43,10 +43,10 @@ jobs:
|
|||||||
Compress-Archive -Path publish/server/* `
|
Compress-Archive -Path publish/server/* `
|
||||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||||
|
|
||||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
|
||||||
- name: Build client patcher zip
|
- name: Build client patcher zip
|
||||||
run: |
|
run: |
|
||||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||||
|
|
||||||
# ── Publish both as Release assets ───────────────────────────────
|
# ── Publish both as Release assets ───────────────────────────────
|
||||||
- name: Upload Release assets
|
- name: Upload Release assets
|
||||||
|
|||||||
+24
-16
@@ -60,7 +60,7 @@ Photon shards with PC DorkNet players with no server-side version change.
|
|||||||
and its matching **OBB**.
|
and its matching **OBB**.
|
||||||
|
|
||||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||||
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
|
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||||
> keep the work dir outside protected folders.
|
> keep the work dir outside protected folders.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -76,20 +76,28 @@ dotnet Il2CppDumper.dll `
|
|||||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||||
|
|
||||||
# 3. Build the signed APK:
|
# 3. Build the signed APK (and, with --obb, the release package):
|
||||||
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||||
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||||
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||||
DorkNet.Quest\scripts\build.ps1 `
|
py -3 DorkNet.Quest\scripts\build.py build `
|
||||||
-Apk C:\path\to\RecRoom-store-2128210.apk `
|
--apk C:\path\to\RecRoom-store-2128210.apk `
|
||||||
-DumpCs <scratch>\dump\dump.cs `
|
--dump-cs <scratch>\dump\dump.cs `
|
||||||
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
|
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
|
||||||
|
--keystore C:\path\to\dorknet-debug.keystore
|
||||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
|
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
|
||||||
```
|
```
|
||||||
|
|
||||||
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
|
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
|
||||||
folder ready to hand to players:
|
|
||||||
|
For a build whose target table is already committed under `targets/`, pass
|
||||||
|
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
|
||||||
|
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
|
||||||
|
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
|
||||||
|
Jenkins job does (`ci/jenkins/README.md`).
|
||||||
|
|
||||||
|
With `--obb`, the build also writes a release folder ready to hand to players:
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -98,15 +106,16 @@ folder ready to hand to players:
|
|||||||
| `install.sh` | one-command install on macOS / Linux |
|
| `install.sh` | one-command install on macOS / Linux |
|
||||||
| `README.txt` | player instructions |
|
| `README.txt` | player instructions |
|
||||||
|
|
||||||
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
|
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
|
||||||
The package contains game binaries from your store copy, so only share it with
|
The package contains game binaries from your store copy, so only share it with
|
||||||
people who own the game.
|
people who own the game.
|
||||||
|
|
||||||
`build.ps1` chains: resolve targets → generate headers → compile
|
`build.py build` chains: resolve targets → generate headers → compile
|
||||||
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||||
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||||
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
|
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
|
||||||
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
|
keystore in the work dir on first run. **Keep that keystore** and pass it with
|
||||||
|
`--keystore` from then on — Meta ties tokens to app id + package
|
||||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||||
It is never committed.
|
It is never committed.
|
||||||
|
|
||||||
@@ -189,7 +198,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
|||||||
| Symptom | Likely cause |
|
| Symptom | Likely cause |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
|
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
|
||||||
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||||
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||||
@@ -209,8 +218,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
|||||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||||
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
|
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
|
||||||
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
|
|
||||||
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
End to end:
|
|
||||||
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
|
|
||||||
2. gen_targets_header.py -> targets_generated.h
|
|
||||||
3. gen_config_header.py -> config_generated.h (from your cfg)
|
|
||||||
4. NDK clang++ -> libdorknet.so (arm64)
|
|
||||||
5. apktool decode -> working project
|
|
||||||
6. package rename + manifest/version edits
|
|
||||||
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
|
|
||||||
8. apktool build -> zipalign -> apksigner -> the signed output APK
|
|
||||||
|
|
||||||
Nothing game-derived is written into the repo. The work directory defaults to
|
|
||||||
a temp path because Windows Controlled Folder Access blocks the NDK linker and
|
|
||||||
apksigner from writing under Documents; override with -WorkDir if needed.
|
|
||||||
|
|
||||||
You supply the store APK and (for install) the matching OBB. No game binaries
|
|
||||||
ship with this tool.
|
|
||||||
|
|
||||||
.PARAMETER Apk
|
|
||||||
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
|
|
||||||
2128210 / version 20230317).
|
|
||||||
|
|
||||||
.PARAMETER Config
|
|
||||||
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
|
|
||||||
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
|
|
||||||
|
|
||||||
.PARAMETER WorkDir
|
|
||||||
Scratch build directory (must be outside Controlled-Folder-Access-protected
|
|
||||||
locations). Defaults to $env:TEMP\dorknet-quest-build.
|
|
||||||
|
|
||||||
.PARAMETER OutApk
|
|
||||||
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
|
|
||||||
|
|
||||||
.PARAMETER NdkRoot
|
|
||||||
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
|
|
||||||
|
|
||||||
.PARAMETER BuildToolsDir
|
|
||||||
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
|
|
||||||
from $env:ANDROID_SDK_ROOT.
|
|
||||||
|
|
||||||
.PARAMETER Obb
|
|
||||||
The store OBB matching the APK. When given, package.ps1 also bundles the
|
|
||||||
result into an installable .xapk with one-command installers.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$Apk,
|
|
||||||
[string]$Config,
|
|
||||||
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
|
|
||||||
[string]$OutApk,
|
|
||||||
[string]$NdkRoot,
|
|
||||||
[string]$BuildToolsDir,
|
|
||||||
[string]$ApktoolJar,
|
|
||||||
[string]$DumpCs,
|
|
||||||
[string]$ApiLevel = '24',
|
|
||||||
[string]$Obb
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
|
|
||||||
$RepoDir = Split-Path -Parent $PSScriptRoot
|
|
||||||
$Scripts = $PSScriptRoot
|
|
||||||
$OldPkg = 'com.AgainstGravity.RecRoom'
|
|
||||||
$SoName = 'libdorknet.so'
|
|
||||||
|
|
||||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
||||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
|
||||||
|
|
||||||
# ---- resolve tools ------------------------------------------------------
|
|
||||||
if (-not $NdkRoot) {
|
|
||||||
$NdkRoot = $env:ANDROID_NDK
|
|
||||||
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
|
|
||||||
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
|
|
||||||
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
|
|
||||||
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
|
|
||||||
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
|
|
||||||
Need $Cxx 'NDK aarch64 clang++'
|
|
||||||
|
|
||||||
if (-not $BuildToolsDir) {
|
|
||||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
|
||||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
|
||||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
|
||||||
}
|
|
||||||
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
|
|
||||||
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
|
|
||||||
Need $ZipAlign 'zipalign'
|
|
||||||
Need $ApkSigner 'apksigner'
|
|
||||||
|
|
||||||
if (-not $ApktoolJar) {
|
|
||||||
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
|
|
||||||
}
|
|
||||||
Need $ApktoolJar 'apktool.jar'
|
|
||||||
Need $Apk 'store APK'
|
|
||||||
|
|
||||||
if (-not $Config) {
|
|
||||||
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
|
|
||||||
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
|
|
||||||
}
|
|
||||||
Need $Config 'config'
|
|
||||||
Write-Host "Using config: $Config"
|
|
||||||
|
|
||||||
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
|
|
||||||
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
|
|
||||||
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
|
|
||||||
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
|
|
||||||
if (-not $AppName) { $AppName = 'DorkNet' }
|
|
||||||
|
|
||||||
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
|
|
||||||
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
|
|
||||||
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
|
|
||||||
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
|
|
||||||
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
|
|
||||||
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
|
|
||||||
$pa = $PyPre + $a
|
|
||||||
& $PyExe @pa
|
|
||||||
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
|
|
||||||
|
|
||||||
# ---- work dirs ----------------------------------------------------------
|
|
||||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
|
||||||
$Gen = Join-Path $WorkDir 'gen'
|
|
||||||
$Project = Join-Path $WorkDir 'project'
|
|
||||||
$Extract = Join-Path $WorkDir 'extract'
|
|
||||||
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
|
|
||||||
|
|
||||||
# ---- 1. extract + dump + resolve targets --------------------------------
|
|
||||||
Step 'extracting libil2cpp.so + global-metadata.dat'
|
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
||||||
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
|
|
||||||
try {
|
|
||||||
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
|
|
||||||
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
|
|
||||||
$dest = Join-Path $Extract (Split-Path $e -Leaf)
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
|
|
||||||
}
|
|
||||||
} finally { $zip.Dispose() }
|
|
||||||
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
|
|
||||||
$LibMain = Join-Path $Extract 'libmain.so'
|
|
||||||
|
|
||||||
if (-not $DumpCs) {
|
|
||||||
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
|
|
||||||
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
|
|
||||||
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
|
|
||||||
}
|
|
||||||
Need $DumpCs 'dump.cs'
|
|
||||||
|
|
||||||
Step 'resolving hook targets'
|
|
||||||
$verName = '20230317'; $verCode = '2128210'
|
|
||||||
# Write the resolved table into the work dir (Controlled Folder Access can
|
|
||||||
# block writes under Documents). The repo keeps a committed copy under targets/.
|
|
||||||
$table = Join-Path $WorkDir "$verName-$verCode.json"
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
|
|
||||||
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
|
|
||||||
|
|
||||||
Step 'generating headers'
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
|
|
||||||
|
|
||||||
# ---- 4. compile libdorknet.so -------------------------------------------
|
|
||||||
Step 'compiling libdorknet.so (arm64)'
|
|
||||||
$src = @(
|
|
||||||
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
|
|
||||||
(Join-Path $RepoDir 'src\config.cpp'),
|
|
||||||
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
|
|
||||||
)
|
|
||||||
$So = Join-Path $WorkDir $SoName
|
|
||||||
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
|
|
||||||
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
|
|
||||||
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
|
|
||||||
if ($LASTEXITCODE) { throw 'compile failed' }
|
|
||||||
Need $So 'libdorknet.so'
|
|
||||||
|
|
||||||
# ---- 5. apktool decode --------------------------------------------------
|
|
||||||
Step 'apktool decode'
|
|
||||||
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
|
|
||||||
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
|
|
||||||
if ($LASTEXITCODE) { throw 'apktool decode failed' }
|
|
||||||
|
|
||||||
# ---- 6. package rename + manifest -------------------------------------
|
|
||||||
Step "renaming package $OldPkg -> $PkgName"
|
|
||||||
$manifest = Join-Path $Project 'AndroidManifest.xml'
|
|
||||||
$m = Get-Content -Raw $manifest
|
|
||||||
$m = $m.Replace($OldPkg, $PkgName)
|
|
||||||
if ($m -notmatch 'android.intent.category.LAUNCHER') {
|
|
||||||
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
|
|
||||||
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
|
|
||||||
}
|
|
||||||
Set-Content -NoNewline -Path $manifest -Value $m
|
|
||||||
|
|
||||||
# The launcher label comes from @string/app_name; set it in every locale.
|
|
||||||
Step "naming the app '$AppName'"
|
|
||||||
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
|
|
||||||
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
|
|
||||||
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
|
|
||||||
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
|
|
||||||
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- 7. inject the hook library ----------------------------------------
|
|
||||||
Step 'injecting libdorknet.so'
|
|
||||||
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
|
|
||||||
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
|
|
||||||
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
|
|
||||||
|
|
||||||
# ---- 8. rebuild + align + sign -----------------------------------------
|
|
||||||
Step 'apktool build'
|
|
||||||
$unsigned = Join-Path $WorkDir 'unsigned.apk'
|
|
||||||
& java -jar $ApktoolJar build -f -o $unsigned $Project
|
|
||||||
if ($LASTEXITCODE) { throw 'apktool build failed' }
|
|
||||||
|
|
||||||
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
|
|
||||||
if (-not (Test-Path $Keystore)) {
|
|
||||||
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
|
|
||||||
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
|
|
||||||
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
|
|
||||||
if ($LASTEXITCODE) { throw 'keytool failed' }
|
|
||||||
}
|
|
||||||
|
|
||||||
Step 'zipalign'
|
|
||||||
$aligned = Join-Path $WorkDir 'aligned.apk'
|
|
||||||
& $ZipAlign -f -p 4 $unsigned $aligned
|
|
||||||
if ($LASTEXITCODE) { throw 'zipalign failed' }
|
|
||||||
|
|
||||||
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
|
|
||||||
Step "apksigner -> $OutApk"
|
|
||||||
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
|
|
||||||
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
|
|
||||||
if ($LASTEXITCODE) { throw 'apksigner failed' }
|
|
||||||
& $ApkSigner verify --print-certs $OutApk | Out-Null
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
|
|
||||||
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
|
|
||||||
if ($Obb) {
|
|
||||||
Write-Host ""
|
|
||||||
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
|
|
||||||
} else {
|
|
||||||
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
|
|
||||||
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
|
|
||||||
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
|
||||||
|
|
||||||
|
From a pristine store APK, end to end:
|
||||||
|
|
||||||
|
1. hook targets resolve them from an Il2CppDumper dump.cs
|
||||||
|
(resolve_targets.py), or use a committed table checked
|
||||||
|
against the APK's libil2cpp.so
|
||||||
|
2. headers targets_generated.h + config_generated.h
|
||||||
|
3. compile libdorknet.so (arm64) with the Android NDK
|
||||||
|
4. apktool decode
|
||||||
|
5. rename package rename, launcher category, app label
|
||||||
|
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
|
||||||
|
7. sign apktool build -> zipalign -> apksigner
|
||||||
|
8. package with --obb: .xapk + one-command installers (or run
|
||||||
|
`package` on an APK you already built)
|
||||||
|
|
||||||
|
Nothing game-derived is written into the repo. You supply the store APK and,
|
||||||
|
for a release package, the matching OBB. The output contains game binaries
|
||||||
|
from your own store copy: share it only with people who own the game.
|
||||||
|
|
||||||
|
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
|
||||||
|
keytool), the Android NDK, Android build-tools and apktool.
|
||||||
|
|
||||||
|
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
|
||||||
|
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
|
||||||
|
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parent
|
||||||
|
REPO = SCRIPTS.parent # DorkNet.Quest/
|
||||||
|
OLD_PKG = "com.AgainstGravity.RecRoom"
|
||||||
|
SO_NAME = "libdorknet.so"
|
||||||
|
ON_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> NoReturn:
|
||||||
|
raise SystemExit(f"error: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def need(path: Path | str, what: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
fail(f"{what} not found: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: object, cwd: Path | None = None) -> None:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, cwd=cwd)
|
||||||
|
if r.returncode:
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
|
||||||
|
|
||||||
|
def capture(*cmd: object) -> str:
|
||||||
|
args = [str(c) for c in cmd]
|
||||||
|
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode:
|
||||||
|
sys.stderr.write(r.stderr)
|
||||||
|
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def run_py(script: str, *args: object) -> None:
|
||||||
|
"""Run a sibling helper script with this interpreter (isolated mode)."""
|
||||||
|
run(sys.executable, "-I", SCRIPTS / script, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def exe(name: str) -> str:
|
||||||
|
return name + (".exe" if ON_WINDOWS else "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- tool resolution ------------------------------------------------------
|
||||||
|
|
||||||
|
def sdk_root() -> Path | None:
|
||||||
|
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||||
|
return Path(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def newest_subdir(parent: Path) -> Path | None:
|
||||||
|
if not parent.is_dir():
|
||||||
|
return None
|
||||||
|
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
|
||||||
|
return dirs[0] if dirs else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_build_tools(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "build-tools dir")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "build-tools") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ndk(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "NDK")
|
||||||
|
if os.environ.get("ANDROID_NDK"):
|
||||||
|
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
|
||||||
|
root = sdk_root()
|
||||||
|
found = newest_subdir(root / "ndk") if root else None
|
||||||
|
if not found:
|
||||||
|
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ndk_bin(ndk: Path) -> Path:
|
||||||
|
if ON_WINDOWS:
|
||||||
|
tag = "windows-x86_64"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
tag = "darwin-x86_64"
|
||||||
|
else:
|
||||||
|
tag = "linux-x86_64"
|
||||||
|
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_apktool(arg: str | None) -> Path:
|
||||||
|
if arg:
|
||||||
|
return need(arg, "apktool.jar")
|
||||||
|
if os.environ.get("APKTOOL_JAR"):
|
||||||
|
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
|
||||||
|
root = sdk_root()
|
||||||
|
if root and (root / "apktool.jar").exists():
|
||||||
|
return root / "apktool.jar"
|
||||||
|
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
|
||||||
|
|
||||||
|
|
||||||
|
def cfg_value(config: Path, key: str) -> str | None:
|
||||||
|
for line in config.read_text(encoding="utf-8").splitlines():
|
||||||
|
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1).split("#", 1)[0].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- build ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_build(a: argparse.Namespace) -> None:
|
||||||
|
ndk = resolve_ndk(a.ndk_root)
|
||||||
|
bin_dir = ndk_bin(ndk)
|
||||||
|
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
|
||||||
|
"NDK aarch64 clang++")
|
||||||
|
objdump = bin_dir / exe("llvm-objdump")
|
||||||
|
build_tools = resolve_build_tools(a.build_tools_dir)
|
||||||
|
zipalign = need(build_tools / exe("zipalign"), "zipalign")
|
||||||
|
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
|
||||||
|
apktool = resolve_apktool(a.apktool_jar)
|
||||||
|
apk = need(a.apk, "store APK").resolve()
|
||||||
|
|
||||||
|
config = Path(a.config) if a.config else (
|
||||||
|
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
|
||||||
|
need(config, "config")
|
||||||
|
print(f"Using config: {config}")
|
||||||
|
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
|
||||||
|
app_name = cfg_value(config, "AppName") or "DorkNet"
|
||||||
|
|
||||||
|
work = Path(a.work_dir)
|
||||||
|
gen, project, extract = work / "gen", work / "project", work / "extract"
|
||||||
|
for d in (work, gen, extract):
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. extract + targets
|
||||||
|
step("extracting libil2cpp.so + global-metadata.dat")
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
for entry in ("lib/arm64-v8a/libil2cpp.so",
|
||||||
|
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
|
||||||
|
"lib/arm64-v8a/libmain.so"):
|
||||||
|
try:
|
||||||
|
data = z.read(entry)
|
||||||
|
except KeyError:
|
||||||
|
fail(f"APK missing {entry}")
|
||||||
|
(extract / Path(entry).name).write_bytes(data)
|
||||||
|
il2cpp = extract / "libil2cpp.so"
|
||||||
|
|
||||||
|
ver_name, ver_code = "20230317", "2128210"
|
||||||
|
if a.targets_json:
|
||||||
|
# A committed, already-resolved table. It is only valid for the exact
|
||||||
|
# libil2cpp.so it was resolved against, so check that first.
|
||||||
|
table = need(a.targets_json, "target table")
|
||||||
|
step(f"using target table {table}")
|
||||||
|
data = json.loads(table.read_text(encoding="utf-8"))
|
||||||
|
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
|
||||||
|
want = str(data.get("il2cppSha256") or "").lower()
|
||||||
|
if want and sha != want:
|
||||||
|
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
|
||||||
|
f"for ({want}). Resolve a table for it with --dump-cs.")
|
||||||
|
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
|
||||||
|
else:
|
||||||
|
if not a.dump_cs:
|
||||||
|
# Dumping is left to Il2CppDumper (see README) to keep this
|
||||||
|
# script toolchain-light.
|
||||||
|
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
|
||||||
|
"resolved table (see DorkNet.Quest/README.md).")
|
||||||
|
need(a.dump_cs, "dump.cs")
|
||||||
|
step("resolving hook targets")
|
||||||
|
# Written into the work dir (Controlled Folder Access can block writes
|
||||||
|
# under Documents). The repo keeps a committed copy under targets/.
|
||||||
|
table = work / f"{ver_name}-{ver_code}.json"
|
||||||
|
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
|
||||||
|
"--version-name", ver_name, "--version-code", ver_code,
|
||||||
|
"--objdump", objdump, "--out", table)
|
||||||
|
|
||||||
|
# 2. headers
|
||||||
|
step("generating headers")
|
||||||
|
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
|
||||||
|
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
|
||||||
|
|
||||||
|
# 3. compile
|
||||||
|
step("compiling libdorknet.so (arm64)")
|
||||||
|
src = REPO / "src"
|
||||||
|
so = work / SO_NAME
|
||||||
|
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
|
||||||
|
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||||
|
f"-I{gen}", f"-I{src}",
|
||||||
|
src / "dorknet_quest.cpp", src / "config.cpp",
|
||||||
|
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
|
||||||
|
"-llog", "-o", so)
|
||||||
|
need(so, "libdorknet.so")
|
||||||
|
|
||||||
|
# 4. decode
|
||||||
|
step("apktool decode")
|
||||||
|
if project.exists():
|
||||||
|
shutil.rmtree(project)
|
||||||
|
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
|
||||||
|
|
||||||
|
# 5. package rename + manifest + label
|
||||||
|
step(f"renaming package {OLD_PKG} -> {pkg_name}")
|
||||||
|
manifest = project / "AndroidManifest.xml"
|
||||||
|
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
|
||||||
|
if "android.intent.category.LAUNCHER" not in m:
|
||||||
|
m = m.replace('<category android:name="android.intent.category.INFO"/>',
|
||||||
|
'<category android:name="android.intent.category.INFO"/>'
|
||||||
|
'<category android:name="android.intent.category.LAUNCHER"/>')
|
||||||
|
manifest.write_text(m, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# The launcher label comes from @string/app_name; set it in every locale.
|
||||||
|
step(f"naming the app '{app_name}'")
|
||||||
|
# XML entities for & < > " ' (the PowerShell version used
|
||||||
|
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
|
||||||
|
label = xml_escape(app_name, {'"': """, "'": "'"})
|
||||||
|
for sx in (project / "res").rglob("strings.xml"):
|
||||||
|
s = sx.read_text(encoding="utf-8")
|
||||||
|
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
|
||||||
|
if n != s:
|
||||||
|
sx.write_text(n, encoding="utf-8", newline="")
|
||||||
|
|
||||||
|
# 6. inject
|
||||||
|
step(f"injecting {SO_NAME}")
|
||||||
|
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
|
||||||
|
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
|
||||||
|
|
||||||
|
# 7. build + align + sign
|
||||||
|
step("apktool build")
|
||||||
|
unsigned = work / "unsigned.apk"
|
||||||
|
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
|
||||||
|
|
||||||
|
if a.keystore:
|
||||||
|
keystore = need(a.keystore, "keystore")
|
||||||
|
else:
|
||||||
|
keystore = work / "dorknet-debug.keystore"
|
||||||
|
if not keystore.exists():
|
||||||
|
step("generating a fresh debug keystore (kept in the work dir, never committed)")
|
||||||
|
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
|
||||||
|
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
||||||
|
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
|
||||||
|
"-dname", "CN=DorkNet Quest, O=DorkNet")
|
||||||
|
|
||||||
|
step("zipalign")
|
||||||
|
aligned = work / "aligned.apk"
|
||||||
|
run(zipalign, "-f", "-p", "4", unsigned, aligned)
|
||||||
|
|
||||||
|
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
|
||||||
|
out_apk.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
step(f"apksigner -> {out_apk}")
|
||||||
|
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
|
||||||
|
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
|
||||||
|
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
|
||||||
|
"--out", out_apk, aligned)
|
||||||
|
capture(apksigner, "verify", "--print-certs", out_apk)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Patched APK: {out_apk}")
|
||||||
|
print(f"Package: {pkg_name} versionCode: {ver_code}")
|
||||||
|
if a.obb:
|
||||||
|
print()
|
||||||
|
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
|
||||||
|
else:
|
||||||
|
print("Install it, then push the matching OBB to:")
|
||||||
|
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
|
||||||
|
print("(or pass --obb to bundle both into an installable .xapk)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- package --------------------------------------------------------------
|
||||||
|
|
||||||
|
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||||
|
in over USB and accept "Allow USB debugging" inside the headset.
|
||||||
|
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
||||||
|
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
||||||
|
steps. Or use the scripts here:
|
||||||
|
Windows: double-click install.cmd
|
||||||
|
macOS / Linux: ./install.sh
|
||||||
|
The installer needs adb (Android platform-tools) on PATH or in
|
||||||
|
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||||
|
offers to launch it.
|
||||||
|
3. In the headset: Library > Unknown Sources > {name}.
|
||||||
|
|
||||||
|
The app installs as {pkg} alongside any store copy of Rec Room.
|
||||||
|
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
|
||||||
|
"""Bundle a patched APK and its OBB into an .xapk plus installers.
|
||||||
|
|
||||||
|
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
|
||||||
|
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
|
||||||
|
The APK and OBB are stored uncompressed: they are already compressed, and
|
||||||
|
storing them keeps packaging and install fast."""
|
||||||
|
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
|
||||||
|
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
|
||||||
|
|
||||||
|
step("reading APK metadata")
|
||||||
|
badging = capture(aapt2, "dump", "badging", apk).splitlines()
|
||||||
|
|
||||||
|
def badge(pattern: str) -> str | None:
|
||||||
|
for line in badging:
|
||||||
|
mm = re.search(pattern, line)
|
||||||
|
if mm:
|
||||||
|
return mm.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
pkg = badge(r"^package: name='([^']+)'")
|
||||||
|
ver_code = badge(r"versionCode='([^']+)'")
|
||||||
|
ver_name = badge(r"versionName='([^']+)'")
|
||||||
|
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
|
||||||
|
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
|
||||||
|
label = badge(r"^application-label:'([^']*)'")
|
||||||
|
if not pkg or not ver_code:
|
||||||
|
fail("could not read package name / versionCode from the APK")
|
||||||
|
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
|
||||||
|
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
|
||||||
|
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
|
||||||
|
|
||||||
|
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
|
||||||
|
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
|
||||||
|
icon_path = None
|
||||||
|
with zipfile.ZipFile(apk) as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
if icons:
|
||||||
|
ref = icons[0][1]
|
||||||
|
if ref.endswith(".png") and ref in names:
|
||||||
|
icon_path = ref
|
||||||
|
else:
|
||||||
|
base = Path(ref).stem
|
||||||
|
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
|
||||||
|
for kind in ("mipmap", "drawable"):
|
||||||
|
cand = f"res/{kind}-{d}/{base}.png"
|
||||||
|
if icon_path is None and cand in names:
|
||||||
|
icon_path = cand
|
||||||
|
icon_bytes = z.read(icon_path) if icon_path else None
|
||||||
|
print(f" {pkg} version {ver_name} ({ver_code})")
|
||||||
|
|
||||||
|
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
|
||||||
|
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
|
||||||
|
xapk.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"xapk_version": 2,
|
||||||
|
"package_name": pkg,
|
||||||
|
"name": label or "DorkNet",
|
||||||
|
"version_code": ver_code,
|
||||||
|
"version_name": ver_name,
|
||||||
|
"min_sdk_version": min_sdk,
|
||||||
|
"target_sdk_version": tgt_sdk,
|
||||||
|
"permissions": perms,
|
||||||
|
"total_size": apk.stat().st_size + obb.stat().st_size,
|
||||||
|
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
|
||||||
|
}
|
||||||
|
if icon_bytes:
|
||||||
|
manifest["icon"] = "icon.png"
|
||||||
|
|
||||||
|
step(f"writing {xapk} (the OBB is large; this takes a minute)")
|
||||||
|
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
|
||||||
|
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
if icon_bytes:
|
||||||
|
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
step("adding installers")
|
||||||
|
for f in ("install.ps1", "install.cmd", "install.sh"):
|
||||||
|
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
|
||||||
|
(out_dir / "install.sh").chmod(0o755)
|
||||||
|
(out_dir / "README.txt").write_text(
|
||||||
|
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"DONE. Package folder: {out_dir}")
|
||||||
|
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
|
||||||
|
print(f"Install with: {out_dir / 'install.cmd'}")
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_package(a: argparse.Namespace) -> None:
|
||||||
|
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
|
||||||
|
resolve_build_tools(a.build_tools_dir))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cli ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
|
||||||
|
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
|
||||||
|
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
|
||||||
|
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
|
||||||
|
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
|
||||||
|
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
|
||||||
|
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
|
||||||
|
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
|
||||||
|
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
|
||||||
|
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
|
||||||
|
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
|
||||||
|
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
|
||||||
|
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
|
||||||
|
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
|
||||||
|
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
|
||||||
|
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
|
||||||
|
b.set_defaults(func=cmd_build)
|
||||||
|
|
||||||
|
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
|
||||||
|
k.add_argument("--apk", required=True)
|
||||||
|
k.add_argument("--obb", required=True)
|
||||||
|
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||||
|
k.add_argument("--build-tools-dir")
|
||||||
|
k.set_defaults(func=cmd_package)
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Bundle a patched Quest APK and its OBB into one installable package.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Writes a release folder holding:
|
|
||||||
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
|
|
||||||
install.cmd / install.ps1 one-command installer for Windows
|
|
||||||
install.sh one-command installer for macOS / Linux
|
|
||||||
README.txt what to do with it
|
|
||||||
|
|
||||||
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
|
|
||||||
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
|
|
||||||
it is already compressed, and storing it keeps packaging and install fast.
|
|
||||||
|
|
||||||
The output contains game binaries from your own store copy. Share it only
|
|
||||||
with people who own the game.
|
|
||||||
|
|
||||||
.PARAMETER Apk
|
|
||||||
The signed, patched APK produced by build.ps1.
|
|
||||||
|
|
||||||
.PARAMETER Obb
|
|
||||||
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
|
|
||||||
|
|
||||||
.PARAMETER OutDir
|
|
||||||
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
|
|
||||||
|
|
||||||
.PARAMETER BuildToolsDir
|
|
||||||
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)][string]$Apk,
|
|
||||||
[Parameter(Mandatory = $true)][string]$Obb,
|
|
||||||
[string]$OutDir,
|
|
||||||
[string]$BuildToolsDir
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
|
||||||
|
|
||||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
|
||||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
|
||||||
|
|
||||||
Need $Apk 'patched APK'
|
|
||||||
Need $Obb 'OBB'
|
|
||||||
$Apk = (Resolve-Path -LiteralPath $Apk).Path
|
|
||||||
$Obb = (Resolve-Path -LiteralPath $Obb).Path
|
|
||||||
|
|
||||||
if (-not $BuildToolsDir) {
|
|
||||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
|
||||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
|
||||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
|
||||||
}
|
|
||||||
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
|
|
||||||
Need $Aapt2 'aapt2'
|
|
||||||
|
|
||||||
# ---- read the APK's identity --------------------------------------------
|
|
||||||
Step 'reading APK metadata'
|
|
||||||
$badging = & $Aapt2 dump badging $Apk 2>$null
|
|
||||||
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
|
|
||||||
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
|
|
||||||
$pkg = Badge "^package: name='([^']+)'"
|
|
||||||
$verCode = Badge "versionCode='([^']+)'"
|
|
||||||
$verName = Badge "versionName='([^']+)'"
|
|
||||||
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
|
|
||||||
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
|
|
||||||
$label = Badge "^application-label:'([^']*)'"
|
|
||||||
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
|
|
||||||
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
|
|
||||||
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
|
|
||||||
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
|
|
||||||
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
|
|
||||||
|
|
||||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
|
|
||||||
# fall back to the same-named PNG from the densest mipmap/drawable folder.
|
|
||||||
$iconPath = $null
|
|
||||||
if ($iconRef) {
|
|
||||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
|
||||||
try {
|
|
||||||
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
|
|
||||||
else {
|
|
||||||
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
|
|
||||||
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
|
|
||||||
foreach ($kind in 'mipmap', 'drawable') {
|
|
||||||
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally { $src.Dispose() }
|
|
||||||
}
|
|
||||||
Write-Host " $pkg version $verName ($verCode)"
|
|
||||||
|
|
||||||
$obbName = "main.$verCode.$pkg.obb"
|
|
||||||
$obbEntry = "Android/obb/$pkg/$obbName"
|
|
||||||
$apkEntry = "$pkg.apk"
|
|
||||||
|
|
||||||
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
|
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
|
||||||
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
|
|
||||||
if (Test-Path $xapk) { Remove-Item -Force $xapk }
|
|
||||||
|
|
||||||
# ---- manifest.json -------------------------------------------------------
|
|
||||||
$manifest = [ordered]@{
|
|
||||||
xapk_version = 2
|
|
||||||
package_name = $pkg
|
|
||||||
name = if ($label) { $label } else { 'DorkNet' }
|
|
||||||
version_code = $verCode
|
|
||||||
version_name = $verName
|
|
||||||
min_sdk_version = $minSdk
|
|
||||||
target_sdk_version = $tgtSdk
|
|
||||||
permissions = $perms
|
|
||||||
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
|
|
||||||
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
|
|
||||||
}
|
|
||||||
if ($iconPath) { $manifest.icon = 'icon.png' }
|
|
||||||
|
|
||||||
# ---- write the XAPK ------------------------------------------------------
|
|
||||||
Step "writing $xapk (the OBB is large; this takes a minute)"
|
|
||||||
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
|
|
||||||
try {
|
|
||||||
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
|
|
||||||
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
|
|
||||||
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
|
|
||||||
|
|
||||||
if ($iconPath) {
|
|
||||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
|
||||||
try {
|
|
||||||
$ie = $src.GetEntry($iconPath)
|
|
||||||
if ($ie) {
|
|
||||||
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
|
|
||||||
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
|
|
||||||
}
|
|
||||||
} finally { $src.Dispose() }
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
|
||||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
|
||||||
} finally { $zip.Dispose() }
|
|
||||||
|
|
||||||
# ---- installers + readme -------------------------------------------------
|
|
||||||
Step 'adding installers'
|
|
||||||
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
|
|
||||||
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
|
|
||||||
}
|
|
||||||
$readme = @"
|
|
||||||
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
|
|
||||||
============================================================
|
|
||||||
|
|
||||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
|
||||||
in over USB and accept "Allow USB debugging" inside the headset.
|
|
||||||
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
|
||||||
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
|
||||||
steps. Or use the scripts here:
|
|
||||||
Windows: double-click install.cmd
|
|
||||||
macOS / Linux: ./install.sh
|
|
||||||
The installer needs adb (Android platform-tools) on PATH or in
|
|
||||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
|
||||||
offers to launch it.
|
|
||||||
3. In the headset: Library > Unknown Sources > $($manifest.name).
|
|
||||||
|
|
||||||
The app installs as $pkg alongside any store copy of Rec Room.
|
|
||||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
|
||||||
"@
|
|
||||||
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
|
|
||||||
|
|
||||||
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
|
|
||||||
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
|
|
||||||
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
|
// Read a DorkNet .xapk (zip) straight from a File the player picked, without
|
||||||
// loading it into memory. DorkNet.Quest/scripts/package.ps1 stores the APK and
|
// loading it into memory. DorkNet.Quest/scripts/build.py stores the APK and
|
||||||
// the OBB uncompressed, so those entries are plain byte ranges of the file and
|
// the OBB uncompressed, so those entries are plain byte ranges of the file and
|
||||||
// stream out with Blob.slice(); manifest.json is deflated and goes through
|
// stream out with Blob.slice(); manifest.json is deflated and goes through
|
||||||
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
||||||
|
|||||||
Vendored
+206
@@ -0,0 +1,206 @@
|
|||||||
|
// DorkNet client builds — PC (patcher zip + full client) and Quest (.xapk).
|
||||||
|
//
|
||||||
|
// Runs on the Jenkins image from ci/jenkins (the stock image plus Python with
|
||||||
|
// LIEF, .NET, the Android NDK/build-tools, apktool, restic and rclone).
|
||||||
|
//
|
||||||
|
// Stock builds come out of the restic build archive on the FTP. Where it lives
|
||||||
|
// is server configuration, not part of the repo: the FTP host, login and the
|
||||||
|
// restic password are Jenkins credentials, and the repository folders come
|
||||||
|
// from the Jenkins container's environment (ARCHIVE_PC_REPO /
|
||||||
|
// ARCHIVE_QUEST_REPO), each overridable per run. A build is picked by
|
||||||
|
// snapshot tags.
|
||||||
|
//
|
||||||
|
// On a push, only the platforms whose code changed are rebuilt. A manual run
|
||||||
|
// picks the platforms with PLATFORMS. See ci/jenkins/README.md.
|
||||||
|
|
||||||
|
// The builds this branch targets. Any other archived build can be picked per
|
||||||
|
// run with the *_SNAPSHOT_TAGS parameters.
|
||||||
|
def PC_VERSION_KEY = 'march_2023_03_21'
|
||||||
|
def PC_DEFAULT_TAGS = 'manifest=7490748483298966814' // Steam 2023-03-21 (appVersion 20230317)
|
||||||
|
def QUEST_DEFAULT_TAGS = 'version-code=2128210' // Quest 20230317
|
||||||
|
|
||||||
|
def buildPc = false
|
||||||
|
def buildQuest = false
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
ansiColor('xterm')
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
// Full clients and xapks are several GB each: keep artifacts for the
|
||||||
|
// last few runs only.
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '3'))
|
||||||
|
timeout(time: 3, unit: 'HOURS')
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'PLATFORMS', choices: ['both', 'pc', 'quest'],
|
||||||
|
description: 'What to build on a manual run. Push builds rebuild whatever changed.')
|
||||||
|
booleanParam(name: 'PC_FULL_CLIENT', defaultValue: true,
|
||||||
|
description: 'Also build the full PC client (stock build + MelonLoader + mod + config). The patcher zip is always built.')
|
||||||
|
string(name: 'PC_SNAPSHOT_TAGS', defaultValue: '',
|
||||||
|
description: "restic tags picking the stock PC build, comma = AND. Empty: ${PC_DEFAULT_TAGS}")
|
||||||
|
string(name: 'QUEST_SNAPSHOT_TAGS', defaultValue: '',
|
||||||
|
description: "restic tags picking the stock Quest build, comma = AND. Empty: ${QUEST_DEFAULT_TAGS}")
|
||||||
|
string(name: 'PC_REPO', defaultValue: '',
|
||||||
|
description: 'PC restic repository folder on the FTP. Empty: ARCHIVE_PC_REPO from the Jenkins environment.')
|
||||||
|
string(name: 'QUEST_REPO', defaultValue: '',
|
||||||
|
description: 'Quest restic repository folder on the FTP. Empty: ARCHIVE_QUEST_REPO from the Jenkins environment.')
|
||||||
|
booleanParam(name: 'LIST_SNAPSHOTS', defaultValue: false,
|
||||||
|
description: 'Only list the snapshots matching the tags in both repositories (to find a build), then stop.')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Plan') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def pushed = !currentBuild.getBuildCauses('com.cloudbees.jenkins.GitHubPushCause').isEmpty() ||
|
||||||
|
!currentBuild.getBuildCauses('hudson.triggers.SCMTrigger$SCMTriggerCause').isEmpty()
|
||||||
|
if (params.LIST_SNAPSHOTS) {
|
||||||
|
buildPc = false
|
||||||
|
buildQuest = false
|
||||||
|
} else if (pushed) {
|
||||||
|
def changed = []
|
||||||
|
for (cs in currentBuild.changeSets) {
|
||||||
|
for (entry in cs.items) changed.addAll(entry.affectedPaths)
|
||||||
|
}
|
||||||
|
def touches = { prefixes -> changed.any { p -> prefixes.any { p.startsWith(it) } } }
|
||||||
|
def common = ['Jenkinsfile', 'ci/jenkins/']
|
||||||
|
buildPc = touches(common + ['DorkNet.ClientMod/', 'tools/build_pc.py'])
|
||||||
|
buildQuest = touches(common + ['DorkNet.Quest/'])
|
||||||
|
echo "Push build; ${changed.size()} changed file(s)"
|
||||||
|
} else {
|
||||||
|
buildPc = params.PLATFORMS in ['both', 'pc']
|
||||||
|
buildQuest = params.PLATFORMS in ['both', 'quest']
|
||||||
|
}
|
||||||
|
echo "Build PC: ${buildPc}, Quest: ${buildQuest}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('List snapshots') {
|
||||||
|
when { expression { params.LIST_SNAPSHOTS } }
|
||||||
|
steps {
|
||||||
|
withArchive {
|
||||||
|
withEnv(["PC_REPO_OVERRIDE=${params.PC_REPO}", "QUEST_REPO_OVERRIDE=${params.QUEST_REPO}",
|
||||||
|
"PC_TAGS=${params.PC_SNAPSHOT_TAGS}", "QUEST_TAGS=${params.QUEST_SNAPSHOT_TAGS}"]) {
|
||||||
|
sh '''
|
||||||
|
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||||
|
pc="${PC_REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
|
||||||
|
quest="${QUEST_REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
|
||||||
|
echo "== PC: $pc"; archive-restore list "$pc" "$PC_TAGS"
|
||||||
|
echo "== Quest: $quest"; archive-restore list "$quest" "$QUEST_TAGS"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('PC patcher zip') {
|
||||||
|
when { expression { buildPc } }
|
||||||
|
steps {
|
||||||
|
sh "python3 tools/build_pc.py patcher --version-key ${PC_VERSION_KEY} --output-dir out/pc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('PC full client') {
|
||||||
|
when { expression { buildPc && params.PC_FULL_CLIENT } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def base = withArchive {
|
||||||
|
withEnv(["REPO_OVERRIDE=${params.PC_REPO}", "TAGS=${params.PC_SNAPSHOT_TAGS ?: PC_DEFAULT_TAGS}"]) {
|
||||||
|
sh(returnStdout: true, script: '''
|
||||||
|
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||||
|
repo="${REPO_OVERRIDE:-${ARCHIVE_PC_REPO:?set ARCHIVE_PC_REPO on Jenkins or PC_REPO on the run}}"
|
||||||
|
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/pc" --exclude /.DepotDownloader
|
||||||
|
''').trim().readLines().last()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCredentials([file(credentialsId: 'clientmod-config', variable: 'CLIENTMOD_CONFIG')]) {
|
||||||
|
withEnv(["BASE_CLIENT=${base}"]) {
|
||||||
|
sh """
|
||||||
|
python3 tools/build_pc.py client \\
|
||||||
|
--base-client "\$BASE_CLIENT" \\
|
||||||
|
--patcher-zip out/pc/dorknet-clientpatch-${PC_VERSION_KEY}.zip \\
|
||||||
|
--config "\$CLIENTMOD_CONFIG" \\
|
||||||
|
--output-zip out/pc/dorknet-client-${PC_VERSION_KEY}.zip
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Quest') {
|
||||||
|
when { expression { buildQuest } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def stock = withArchive {
|
||||||
|
withEnv(["REPO_OVERRIDE=${params.QUEST_REPO}", "TAGS=${params.QUEST_SNAPSHOT_TAGS ?: QUEST_DEFAULT_TAGS}"]) {
|
||||||
|
sh(returnStdout: true, script: '''
|
||||||
|
export RESTIC_CACHE_DIR="$DORKNET_CACHE/restic"
|
||||||
|
repo="${REPO_OVERRIDE:-${ARCHIVE_QUEST_REPO:?set ARCHIVE_QUEST_REPO on Jenkins or QUEST_REPO on the run}}"
|
||||||
|
archive-restore restore "$repo" "$TAGS" "$DORKNET_CACHE/quest"
|
||||||
|
''').trim().readLines().last()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: 'quest-keystore', variable: 'QUEST_KEYSTORE'),
|
||||||
|
string(credentialsId: 'quest-keystore-password', variable: 'QUEST_KEYSTORE_PASSWORD'),
|
||||||
|
file(credentialsId: 'quest-config', variable: 'QUEST_CONFIG'),
|
||||||
|
]) {
|
||||||
|
withEnv(["QUEST_STOCK=${stock}"]) {
|
||||||
|
// The hook table for the build's versionCode must already be
|
||||||
|
// resolved and committed under DorkNet.Quest/targets/; build.py
|
||||||
|
// checks it against the APK's libil2cpp.so.
|
||||||
|
sh '''
|
||||||
|
set -eu
|
||||||
|
obb="$(ls "$QUEST_STOCK"/obbs/main.*.obb | head -n1)"
|
||||||
|
code="$(basename "$obb" | cut -d. -f2)"
|
||||||
|
table="$(ls DorkNet.Quest/targets/*-"$code".json 2>/dev/null | head -n1 || true)"
|
||||||
|
if [ -z "$table" ]; then
|
||||||
|
echo "No resolved hook table for versionCode $code in DorkNet.Quest/targets/." >&2
|
||||||
|
echo "Resolve one locally with build.py build --dump-cs and commit it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p out/quest
|
||||||
|
python3 DorkNet.Quest/scripts/build.py build \
|
||||||
|
--apk "$QUEST_STOCK/app.apk" --obb "$obb" \
|
||||||
|
--targets-json "$table" \
|
||||||
|
--config "$QUEST_CONFIG" \
|
||||||
|
--keystore "$QUEST_KEYSTORE" --keystore-pass "$QUEST_KEYSTORE_PASSWORD" \
|
||||||
|
--work-dir "$WORKSPACE/quest-work" \
|
||||||
|
--out-apk "$WORKSPACE/out/quest/dorknet-recroom-$code.apk"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
archiveArtifacts artifacts: 'out/**', allowEmptyArchive: true, fingerprint: true
|
||||||
|
}
|
||||||
|
cleanup {
|
||||||
|
// Runs after archiving. Keeps the restic cache and restored stock
|
||||||
|
// builds ($DORKNET_CACHE in jenkins_home, outside the workspace); drops
|
||||||
|
// the workspace's build output and scratch.
|
||||||
|
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FTP + restic credentials for archive-restore.
|
||||||
|
def withArchive(Closure body) {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(credentialsId: 'archive-ftp', usernameVariable: 'ARCHIVE_FTP_USER', passwordVariable: 'ARCHIVE_FTP_PASSWORD'),
|
||||||
|
string(credentialsId: 'archive-ftp-host', variable: 'ARCHIVE_FTP_HOST'),
|
||||||
|
string(credentialsId: 'archive-restic-password', variable: 'RESTIC_PASSWORD'),
|
||||||
|
]) {
|
||||||
|
return body()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts run inside the Linux image; keep them LF on every checkout.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# DorkNet Jenkins: the stock Jenkins image plus everything the PC and Quest
|
||||||
|
# client builds need, so the job (the Jenkinsfile at the repo root) runs right
|
||||||
|
# on the controller. Jobs, plugins and settings stay in the jenkins_home
|
||||||
|
# volume; only the image changes.
|
||||||
|
#
|
||||||
|
# PC Python + .NET SDK tools/build_pc.py
|
||||||
|
# Quest Python/LIEF + JDK + Android NDK, build-tools, apktool
|
||||||
|
# DorkNet.Quest/scripts/build.py
|
||||||
|
# Builds restic + rclone stock builds restored from the restic
|
||||||
|
# archive on the FTP (archive-restore.sh)
|
||||||
|
#
|
||||||
|
# See ci/jenkins/README.md.
|
||||||
|
|
||||||
|
# Match this to the Jenkins image your Dokploy service runs today, so the
|
||||||
|
# upgrade keeps the same Jenkins version and Java: e.g.
|
||||||
|
# --build-arg JENKINS_IMAGE=jenkins/jenkins:2.492.3-lts-jdk17
|
||||||
|
ARG JENKINS_IMAGE=jenkins/jenkins:lts-jdk17
|
||||||
|
FROM ${JENKINS_IMAGE}
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# ---- base tools + .NET SDK (Microsoft's Debian feed for the image's own
|
||||||
|
# Debian release) -----------------------------------------------------------
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl wget unzip zip bzip2 python3 python3-venv \
|
||||||
|
&& . /etc/os-release \
|
||||||
|
&& wget -q "https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/ms.deb \
|
||||||
|
&& dpkg -i /tmp/ms.deb && rm /tmp/ms.deb \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- Python with LIEF (DT_NEEDED injection into libmain.so) ---------------
|
||||||
|
# build.py runs its helpers with -I (isolated), which ignores PYTHONPATH and
|
||||||
|
# user site-packages, so LIEF goes into the interpreter that is first on PATH.
|
||||||
|
RUN python3 -m venv /opt/py \
|
||||||
|
&& /opt/py/bin/pip install --no-cache-dir lief==0.15.1
|
||||||
|
ENV PATH="/opt/py/bin:${PATH}"
|
||||||
|
|
||||||
|
# ---- Android SDK: build-tools (zipalign, apksigner, aapt2) + NDK ----------
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ARG CMDLINE_TOOLS=11076708
|
||||||
|
ARG BUILD_TOOLS=34.0.0
|
||||||
|
ARG NDK=27.2.12479018
|
||||||
|
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||||
|
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/clt.zip \
|
||||||
|
&& unzip -q /tmp/clt.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/clt.zip \
|
||||||
|
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
|
||||||
|
&& (yes || true) | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null \
|
||||||
|
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install \
|
||||||
|
"build-tools;${BUILD_TOOLS}" "ndk;${NDK}" > /dev/null \
|
||||||
|
&& chown -R jenkins:jenkins ${ANDROID_SDK_ROOT}
|
||||||
|
ENV ANDROID_NDK=${ANDROID_SDK_ROOT}/ndk/${NDK}
|
||||||
|
|
||||||
|
# ---- apktool ---------------------------------------------------------------
|
||||||
|
ARG APKTOOL=2.9.3
|
||||||
|
RUN wget -q https://github.com/iBotPeaches/Apktool/releases/download/v${APKTOOL}/apktool_${APKTOOL}.jar \
|
||||||
|
-O ${ANDROID_SDK_ROOT}/apktool.jar
|
||||||
|
ENV APKTOOL_JAR=${ANDROID_SDK_ROOT}/apktool.jar
|
||||||
|
|
||||||
|
# ---- restic + rclone (restic reaches the FTP through rclone) --------------
|
||||||
|
ARG RESTIC=0.18.1
|
||||||
|
ARG RCLONE=1.68.2
|
||||||
|
RUN wget -q https://github.com/restic/restic/releases/download/v${RESTIC}/restic_${RESTIC}_linux_amd64.bz2 -O /tmp/restic.bz2 \
|
||||||
|
&& bunzip2 /tmp/restic.bz2 \
|
||||||
|
&& install -m 0755 /tmp/restic /usr/local/bin/restic && rm /tmp/restic \
|
||||||
|
&& wget -q https://downloads.rclone.org/v${RCLONE}/rclone-v${RCLONE}-linux-amd64.zip -O /tmp/rclone.zip \
|
||||||
|
&& unzip -q /tmp/rclone.zip -d /tmp/rclone \
|
||||||
|
&& install -m 0755 /tmp/rclone/rclone-v${RCLONE}-linux-amd64/rclone /usr/local/bin/rclone \
|
||||||
|
&& rm -rf /tmp/rclone /tmp/rclone.zip
|
||||||
|
|
||||||
|
COPY archive-restore.sh /usr/local/bin/archive-restore
|
||||||
|
RUN chmod +x /usr/local/bin/archive-restore
|
||||||
|
|
||||||
|
# Restored stock builds and the restic cache live in the persistent
|
||||||
|
# jenkins_home volume, so each multi-GB build is downloaded once.
|
||||||
|
ENV DORKNET_CACHE=/var/jenkins_home/dorknet-cache
|
||||||
|
|
||||||
|
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||||
|
DOTNET_NOLOGO=1
|
||||||
|
|
||||||
|
USER jenkins
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Client builds on Jenkins
|
||||||
|
|
||||||
|
The `Jenkinsfile` at the repo root builds the DorkNet clients on your existing
|
||||||
|
Jenkins.
|
||||||
|
|
||||||
|
It runs right on the Jenkins container, so that container's image needs the
|
||||||
|
build toolchain: Python with LIEF, the .NET SDK, the Android NDK and build-tools,
|
||||||
|
apktool, restic and rclone. `ci/jenkins/Dockerfile` is the stock
|
||||||
|
Jenkins image with that toolchain added. Your jobs, plugins and settings live
|
||||||
|
in the `jenkins_home` volume, not in the image, so switching images leaves
|
||||||
|
them alone.
|
||||||
|
|
||||||
|
| Platform | Output (Jenkins artifacts) | How |
|
||||||
|
|---|---|---|
|
||||||
|
| PC | `out/pc/dorknet-clientpatch-march_2023_03_21.zip`: MelonLoader, mod, config template, Steam stub, `manifest.json`; no game files | `tools/build_pc.py patcher` |
|
||||||
|
| PC | `out/pc/dorknet-client-march_2023_03_21.zip`: the stock Steam build with the patcher applied and your config; ready to run | `tools/build_pc.py client` |
|
||||||
|
| Quest | `out/quest/dorknet-recroom-<code>.apk` and `out/quest/DorkNet-Quest-<ver>/` (`.xapk` + installers) | `DorkNet.Quest/scripts/build.py build` |
|
||||||
|
|
||||||
|
The full PC client and the Quest packages contain game files, so keep them on
|
||||||
|
Jenkins. Don't publish them anywhere public.
|
||||||
|
|
||||||
|
## Where the stock builds come from
|
||||||
|
|
||||||
|
The stock builds live in restic repositories on the archive FTP. They are the
|
||||||
|
same repositories as the local archive's `v2/steam-v2` and `v2/quest-v2`. restic
|
||||||
|
reaches the FTP through its rclone backend (`ci/jenkins/archive-restore.sh`).
|
||||||
|
|
||||||
|
A build is picked by **snapshot tags**:
|
||||||
|
|
||||||
|
| Repository | Tags (examples) |
|
||||||
|
|---|---|
|
||||||
|
| Steam | `manifest=<depot manifest id>`, `date=…`, `branch=public` |
|
||||||
|
| Quest | `version=20230317`, `version-code=2128210` |
|
||||||
|
|
||||||
|
The job defaults to the builds this branch targets: Steam manifest
|
||||||
|
`7490748483298966814` (2023-03-21) and Quest `version-code=2128210`. Any other
|
||||||
|
build can be chosen per run. Tick **LIST_SNAPSHOTS** to print the snapshots
|
||||||
|
matching a filter without building anything.
|
||||||
|
|
||||||
|
A restored snapshot is cached in `jenkins_home/dorknet-cache`, so each stock
|
||||||
|
build is downloaded once.
|
||||||
|
|
||||||
|
A Quest build needs a resolved hook table for its versionCode, committed under
|
||||||
|
`DorkNet.Quest/targets/`. Only `20230317-2128210.json` exists today. The build
|
||||||
|
checks the table against the APK's `libil2cpp.so` and stops on a mismatch.
|
||||||
|
Resolve a table for another build locally with `build.py build --dump-cs`.
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- **Push** to the branch (GitHub webhook) rebuilds only what changed:
|
||||||
|
- `DorkNet.ClientMod/` and the PC scripts rebuild PC;
|
||||||
|
- `DorkNet.Quest/` rebuilds Quest;
|
||||||
|
- `Jenkinsfile` and `ci/jenkins/` rebuild both.
|
||||||
|
- **Build with Parameters** runs a manual build:
|
||||||
|
- `PLATFORMS` (both / pc / quest), and `PC_FULL_CLIENT` for the full PC client;
|
||||||
|
- snapshot tags, and per-run repository overrides.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Switch your Jenkins to this image
|
||||||
|
|
||||||
|
On your Jenkins service in Dokploy:
|
||||||
|
|
||||||
|
1. **Build from this repo.**
|
||||||
|
- For an Application service: provider GitHub, branch `march-2023-03-21`,
|
||||||
|
build type Dockerfile, Dockerfile path `ci/jenkins/Dockerfile`, build
|
||||||
|
context `ci/jenkins`.
|
||||||
|
- For a Compose service: change its `jenkins` service to `build:` from
|
||||||
|
`ci/jenkins` as in `ci/jenkins/docker-compose.yml`.
|
||||||
|
- Either way, **keep the same volume on `/var/jenkins_home`**.
|
||||||
|
2. **Match the Jenkins version.** Set the build argument `JENKINS_IMAGE` to the
|
||||||
|
image the service runs today (e.g. `jenkins/jenkins:2.492.3-lts-jdk17`).
|
||||||
|
The default is `jenkins/jenkins:lts-jdk17`.
|
||||||
|
3. **Add the environment:**
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `ARCHIVE_PC_REPO` | Steam repository folder on the FTP, e.g. `Rec Room Versions/steam-v2` (plain text, not URL-encoded) |
|
||||||
|
| `ARCHIVE_QUEST_REPO` | Quest repository folder, e.g. `Rec Room Versions/quest-v2` |
|
||||||
|
| `ARCHIVE_FTP_TLS` | `explicit` (default), `implicit` or `none` |
|
||||||
|
|
||||||
|
4. **Redeploy.** The first image build downloads the Android NDK (~1.5 GB) and
|
||||||
|
takes a while.
|
||||||
|
|
||||||
|
### 2. Plugins
|
||||||
|
|
||||||
|
The job needs these plugins:
|
||||||
|
|
||||||
|
- Pipeline
|
||||||
|
- Git
|
||||||
|
- GitHub (for the push trigger)
|
||||||
|
- Credentials Binding
|
||||||
|
- Timestamper
|
||||||
|
- AnsiColor
|
||||||
|
- Workspace Cleanup
|
||||||
|
|
||||||
|
Most are part of the suggested set.
|
||||||
|
|
||||||
|
### 3. Credentials
|
||||||
|
|
||||||
|
Add these under **Manage Jenkins → Credentials**, with exactly these IDs:
|
||||||
|
|
||||||
|
| ID | Kind | Value |
|
||||||
|
|---|---|---|
|
||||||
|
| `archive-ftp` | Username with password | the FTP login |
|
||||||
|
| `archive-ftp-host` | Secret text | FTP host name only (no `ftp://`, no user, no path) |
|
||||||
|
| `archive-restic-password` | Secret text | the archive's restic password |
|
||||||
|
| `quest-keystore` | Secret file | the Quest signing keystore |
|
||||||
|
| `quest-keystore-password` | Secret text | its store/key password |
|
||||||
|
| `clientmod-config` | Secret file | your `dorknet-clientmod.json` |
|
||||||
|
| `quest-config` | Secret file | your `dorknet-quest.cfg` |
|
||||||
|
|
||||||
|
**Use the keystore your players' installs were signed with**, the
|
||||||
|
`dorknet-debug.keystore` from your earlier local builds (password `dorknet`). A
|
||||||
|
different key means every player has to uninstall before installing a CI
|
||||||
|
build.
|
||||||
|
|
||||||
|
### 4. The job
|
||||||
|
|
||||||
|
1. Create a **New Item → Pipeline** (e.g. `dorknet-client`).
|
||||||
|
2. Under **Pipeline**, choose **Pipeline script from SCM**:
|
||||||
|
- Git URL `https://github.com/DorkSquadRR/DorkNet.git`
|
||||||
|
- branch `*/march-2023-03-21`
|
||||||
|
- script path `Jenkinsfile`
|
||||||
|
3. Under **Triggers**, tick **GitHub hook trigger for GITScm polling**.
|
||||||
|
4. Run it once with **Build** so Jenkins learns the parameters. Later runs
|
||||||
|
offer **Build with Parameters**.
|
||||||
|
|
||||||
|
### 5. GitHub webhook
|
||||||
|
|
||||||
|
In the repository's **Settings → Webhooks**:
|
||||||
|
|
||||||
|
- Payload URL: `https://<your jenkins>/github-webhook/`
|
||||||
|
- Content type: `application/json`
|
||||||
|
- Events: just the push event
|
||||||
|
|
||||||
|
### Disk
|
||||||
|
|
||||||
|
The restored stock builds are cached in `jenkins_home/dorknet-cache`, several
|
||||||
|
GB each. Jenkins keeps the artifacts of the last 3 runs.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# archive-restore — pull a Rec Room build out of the restic build archive.
|
||||||
|
#
|
||||||
|
# archive-restore list <repo-path> [tag[,tag…]]
|
||||||
|
# archive-restore restore <repo-path> <tag[,tag…]> <cache-dir> [restic restore args…]
|
||||||
|
#
|
||||||
|
# <repo-path> is the restic repository's folder on the archive FTP, e.g.
|
||||||
|
# "Rec Room Versions/steam-v2". Snapshots are picked by tag, e.g.
|
||||||
|
# "manifest=7490748483298966814" (Steam) or "version-code=2128210" (Quest);
|
||||||
|
# several tags separated by commas must all match. The newest match wins.
|
||||||
|
#
|
||||||
|
# `restore` writes the snapshot to <cache-dir>/<snapshot-id>/ and prints that
|
||||||
|
# path. A snapshot already restored there is reused, so a multi-GB build is
|
||||||
|
# only downloaded once. Extra arguments go to `restic restore` (e.g.
|
||||||
|
# --exclude /.DepotDownloader).
|
||||||
|
#
|
||||||
|
# restic reaches the FTP through its rclone backend. Everything secret comes
|
||||||
|
# from the environment (Jenkins injects it from credentials):
|
||||||
|
# ARCHIVE_FTP_HOST FTP host name
|
||||||
|
# ARCHIVE_FTP_USER FTP user
|
||||||
|
# ARCHIVE_FTP_PASSWORD FTP password
|
||||||
|
# RESTIC_PASSWORD restic repository password
|
||||||
|
# Optional: ARCHIVE_FTP_PORT (21), ARCHIVE_FTP_TLS (none | explicit | implicit,
|
||||||
|
# default explicit), RESTIC_CACHE_DIR.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mode="${1:?usage: archive-restore list|restore <repo-path> …}"
|
||||||
|
repo_path="${2:?repo path required}"
|
||||||
|
tags="${3:-}"
|
||||||
|
|
||||||
|
: "${ARCHIVE_FTP_HOST:?ARCHIVE_FTP_HOST is not set}"
|
||||||
|
: "${ARCHIVE_FTP_USER:?ARCHIVE_FTP_USER is not set}"
|
||||||
|
: "${ARCHIVE_FTP_PASSWORD:?ARCHIVE_FTP_PASSWORD is not set}"
|
||||||
|
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD is not set}"
|
||||||
|
|
||||||
|
# rclone remote "rrarchive", configured purely through its env-var config.
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_TYPE=ftp
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_HOST="$ARCHIVE_FTP_HOST"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_USER="$ARCHIVE_FTP_USER"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_PORT="${ARCHIVE_FTP_PORT:-21}"
|
||||||
|
RCLONE_CONFIG_RRARCHIVE_PASS="$(printf '%s' "$ARCHIVE_FTP_PASSWORD" | rclone obscure -)"
|
||||||
|
export RCLONE_CONFIG_RRARCHIVE_PASS
|
||||||
|
case "${ARCHIVE_FTP_TLS:-explicit}" in
|
||||||
|
explicit) export RCLONE_CONFIG_RRARCHIVE_EXPLICIT_TLS=true ;;
|
||||||
|
implicit) export RCLONE_CONFIG_RRARCHIVE_TLS=true ;;
|
||||||
|
none) ;;
|
||||||
|
*) echo "archive-restore: ARCHIVE_FTP_TLS must be none, explicit or implicit" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
export RESTIC_REPOSITORY="rclone:rrarchive:${repo_path}"
|
||||||
|
|
||||||
|
tag_args=()
|
||||||
|
[ -n "$tags" ] && tag_args=(--tag "$tags")
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
list)
|
||||||
|
restic snapshots --no-lock "${tag_args[@]}"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
cache="${4:?cache dir required}"
|
||||||
|
[ -n "$tags" ] || { echo "archive-restore: restore needs a tag filter" >&2; exit 2; }
|
||||||
|
shift 4
|
||||||
|
id="$(restic snapshots --no-lock --json "${tag_args[@]}" \
|
||||||
|
| python3 -c 'import json,sys; s=sorted(json.load(sys.stdin), key=lambda x: x["time"]); print(s[-1]["short_id"] if s else "")')"
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "archive-restore: no snapshot in '$repo_path' has tags '$tags'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dest="$cache/$id"
|
||||||
|
if [ -f "$dest.restored" ]; then
|
||||||
|
echo "archive-restore: reusing $id from $dest" >&2
|
||||||
|
else
|
||||||
|
echo "archive-restore: restoring $id ($tags) from '$repo_path'" >&2
|
||||||
|
rm -rf "$dest" "$dest.restored"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
restic restore --no-lock "$id" --target "$dest" "$@" >&2
|
||||||
|
touch "$dest.restored"
|
||||||
|
fi
|
||||||
|
echo "$dest"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "archive-restore: unknown mode '$mode'" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Reference for the Jenkins service on Dokploy, built from this directory's
|
||||||
|
# Dockerfile (the stock Jenkins image plus the client build toolchain).
|
||||||
|
#
|
||||||
|
# Use it on your EXISTING Jenkins service rather than as a second Jenkins.
|
||||||
|
# The data volume must be the one Jenkins uses today, or Jenkins starts empty.
|
||||||
|
# Point `volumes:` at that volume (and its name under the top-level
|
||||||
|
# `volumes:`, marked `external: true`), or keep this service's name and volume
|
||||||
|
# if they already match. See ci/jenkins/README.md.
|
||||||
|
services:
|
||||||
|
jenkins:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
# The image your Jenkins runs today, so the Jenkins version and Java
|
||||||
|
# stay the same.
|
||||||
|
JENKINS_IMAGE: ${JENKINS_IMAGE:-jenkins/jenkins:lts-jdk17}
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
# Where the stock builds live in the archive (restic repository folders
|
||||||
|
# on the FTP), as plain text. Overridable per run.
|
||||||
|
ARCHIVE_PC_REPO: ${ARCHIVE_PC_REPO:-}
|
||||||
|
ARCHIVE_QUEST_REPO: ${ARCHIVE_QUEST_REPO:-}
|
||||||
|
ARCHIVE_FTP_TLS: ${ARCHIVE_FTP_TLS:-explicit}
|
||||||
|
volumes:
|
||||||
|
- jenkins_home:/var/jenkins_home
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jenkins_home:
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Builds the per-version dorknet-clientpatch-{VersionKey}.zip the
|
|
||||||
DorkNet launcher expects to find as a GitHub Release asset.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Output zip layout (see launcher/RELEASES.md on the public main
|
|
||||||
branch for the full contract):
|
|
||||||
|
|
||||||
manifest.json (schema v1, describes the install)
|
|
||||||
MelonLoader.zip (downloaded from melonloader github)
|
|
||||||
DorkNet.ClientMod.dll (built from this repo)
|
|
||||||
dorknet-clientmod.json.template (copied from DorkNet.ClientMod/)
|
|
||||||
|
|
||||||
The launcher reads manifest.json, unzips MelonLoader on top of the
|
|
||||||
user's Rec Room install root, copies the DLL into Mods/, then
|
|
||||||
renders the config template into UserData/.
|
|
||||||
|
|
||||||
Self-contained: downloads MelonLoader before the dotnet build and
|
|
||||||
feeds its DLLs into the build as references, so no local Rec Room
|
|
||||||
install is required to compile DorkNet.ClientMod. (The mod resolves
|
|
||||||
every game-side type at runtime via AccessTools.TypeByName.)
|
|
||||||
|
|
||||||
.PARAMETER VersionKey
|
|
||||||
The version key matching versions.json on the public main branch
|
|
||||||
(e.g. december_2020_12_18 or march_2020_03_10).
|
|
||||||
|
|
||||||
.PARAMETER MelonLoaderVersion
|
|
||||||
MelonLoader release tag to bundle. Defaults to the 0.6.x stable
|
|
||||||
target — that's the loader DorkNet.ClientMod compiled against.
|
|
||||||
|
|
||||||
.PARAMETER OutputDir
|
|
||||||
Where the final zip lands. Defaults to repo-root/dist.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\tools\build-patcher-zip.ps1 -VersionKey december_2020_12_18
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$VersionKey,
|
|
||||||
[string]$MelonLoaderVersion = "v0.6.6",
|
|
||||||
[string]$OutputDir = $null
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
|
||||||
if (-not $OutputDir) { $OutputDir = Join-Path $repoRoot 'dist' }
|
|
||||||
New-Item -ItemType Directory -Force $OutputDir | Out-Null
|
|
||||||
|
|
||||||
$stage = Join-Path $env:TEMP "dorknet-patcher-$VersionKey-$([guid]::NewGuid().Guid.Substring(0,8))"
|
|
||||||
New-Item -ItemType Directory -Force $stage | Out-Null
|
|
||||||
try {
|
|
||||||
# ── 1. Fetch MelonLoader from upstream releases ──────────────────────
|
|
||||||
# We download it first so the DLLs inside can serve as compile-time
|
|
||||||
# references for DorkNet.ClientMod — keeps the build self-contained
|
|
||||||
# (no local Rec Room install required to compile the mod).
|
|
||||||
# x64 .NET 6 build targets 2020-era Rec Room. The asset name pattern
|
|
||||||
# is stable across 0.6.x: MelonLoader.x64.zip.
|
|
||||||
$mlAsset = "MelonLoader.x64.zip"
|
|
||||||
$mlUrl = "https://github.com/LavaGang/MelonLoader/releases/download/$MelonLoaderVersion/$mlAsset"
|
|
||||||
$mlZip = "$stage\MelonLoader.zip"
|
|
||||||
$mlExtract = "$stage\melonloader-extracted"
|
|
||||||
Write-Host "[fetch] downloading $mlUrl"
|
|
||||||
Invoke-WebRequest -Uri $mlUrl -OutFile $mlZip
|
|
||||||
if (-not (Test-Path $mlZip) -or (Get-Item $mlZip).Length -lt 1MB) {
|
|
||||||
throw "MelonLoader download failed or produced an unexpectedly small file"
|
|
||||||
}
|
|
||||||
Expand-Archive -Path $mlZip -DestinationPath $mlExtract -Force
|
|
||||||
# The 0.6.x release zip layout has a top-level `MelonLoader` folder
|
|
||||||
# containing net6, net35, Dependencies, Documentation. Point the
|
|
||||||
# csproj at that inner folder.
|
|
||||||
$mlRefRoot = Join-Path $mlExtract 'MelonLoader'
|
|
||||||
if (-not (Test-Path "$mlRefRoot\net6\MelonLoader.dll")) {
|
|
||||||
throw "Extracted MelonLoader is missing net6\MelonLoader.dll — release layout may have changed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 2. Build DorkNet.ClientMod (no Rec Room install needed) ─────────
|
|
||||||
Write-Host "[build] dotnet build DorkNet.ClientMod (Release)"
|
|
||||||
dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" `
|
|
||||||
-c Release --nologo `
|
|
||||||
"-p:MelonLoaderDir=$mlRefRoot" | Out-Null
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "ClientMod build failed" }
|
|
||||||
$dll = Get-ChildItem "$repoRoot\DorkNet.ClientMod\bin\Release" -Recurse `
|
|
||||||
-Filter "DorkNet.ClientMod.dll" |
|
|
||||||
Sort-Object LastWriteTime -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if (-not $dll) { throw "DorkNet.ClientMod.dll not found post-build" }
|
|
||||||
Copy-Item $dll.FullName "$stage\DorkNet.ClientMod.dll"
|
|
||||||
Write-Host "[build] copied $($dll.FullName)"
|
|
||||||
|
|
||||||
# ── 3. Copy the config template ──────────────────────────────────────
|
|
||||||
$template = "$repoRoot\DorkNet.ClientMod\dorknet-clientmod.json.template"
|
|
||||||
if (-not (Test-Path $template)) {
|
|
||||||
throw "Config template missing: $template"
|
|
||||||
}
|
|
||||||
Copy-Item $template "$stage\dorknet-clientmod.json.template"
|
|
||||||
|
|
||||||
# ── 4. Copy the Steam stub (Goldberg) + appid ────────────────────────
|
|
||||||
# Replaces the Valve steam_api64.dll so the unwrapped exe can run
|
|
||||||
# without Steam loaded. App ID 471710 is Rec Room — same across all
|
|
||||||
# versions we ship for.
|
|
||||||
$stubDll = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_api64.dll"
|
|
||||||
$stubAppid = "$repoRoot\DorkNet.ClientMod\steam-stub\steam_appid.txt"
|
|
||||||
if (-not (Test-Path $stubDll)) {
|
|
||||||
throw "Steam stub DLL missing: $stubDll"
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $stubAppid)) {
|
|
||||||
throw "Steam appid file missing: $stubAppid"
|
|
||||||
}
|
|
||||||
Copy-Item $stubDll "$stage\steam_api64.dll"
|
|
||||||
Copy-Item $stubAppid "$stage\steam_appid.txt"
|
|
||||||
|
|
||||||
# ── 5. Write manifest.json ──────────────────────────────────────────
|
|
||||||
$manifest = @{
|
|
||||||
'$schema_version' = 1
|
|
||||||
'loader_archive' = 'MelonLoader.zip'
|
|
||||||
'plugin_dll' = 'DorkNet.ClientMod.dll'
|
|
||||||
'plugin_dest' = 'Mods'
|
|
||||||
'config_template' = 'dorknet-clientmod.json.template'
|
|
||||||
'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json'
|
|
||||||
'old_plugin_paths' = @(
|
|
||||||
'BepInEx/plugins/DorkNet.ClientPatch.dll'
|
|
||||||
)
|
|
||||||
'steam_stub' = @{
|
|
||||||
# 2020.12+ is an EAC build: the real Unity binary is
|
|
||||||
# RecRoom.exe (loads RecRoom_Data/Plugins/x86_64/steam_api64.dll),
|
|
||||||
# NOT Recroom_Release.exe (which is the EAC launcher wrapper
|
|
||||||
# that loads Recroom_Release_Data/Plugins/steam_api64.dll).
|
|
||||||
# Patching the wrapper's DLL did nothing because the
|
|
||||||
# launcher's data dir is never opened by the real game
|
|
||||||
# process — symptom was "Failed to initialize Steam
|
|
||||||
# platform" because the unpatched real Steam DLL in
|
|
||||||
# RecRoom_Data tried to handshake.
|
|
||||||
'api_dll' = 'steam_api64.dll'
|
|
||||||
'api_dest' = 'RecRoom_Data/Plugins/x86_64/steam_api64.dll'
|
|
||||||
'api_backup_suffix' = '.steam-original'
|
|
||||||
'appid_file' = 'steam_appid.txt'
|
|
||||||
'appid_dest' = 'steam_appid.txt'
|
|
||||||
}
|
|
||||||
} | ConvertTo-Json -Depth 4
|
|
||||||
Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest
|
|
||||||
|
|
||||||
# ── 6. Bundle into the final zip ────────────────────────────────────
|
|
||||||
# Strip the extracted MelonLoader scratch dir before zipping so it
|
|
||||||
# doesn't bloat the artifact. The MelonLoader.zip itself stays.
|
|
||||||
Remove-Item -Recurse -Force $mlExtract
|
|
||||||
$outZip = Join-Path $OutputDir "dorknet-clientpatch-$VersionKey.zip"
|
|
||||||
if (Test-Path $outZip) { Remove-Item $outZip -Force }
|
|
||||||
Compress-Archive -Path "$stage\*" -DestinationPath $outZip
|
|
||||||
$sizeMb = [math]::Round((Get-Item $outZip).Length / 1MB, 1)
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Built $outZip ($sizeMb MB)" -ForegroundColor Green
|
|
||||||
Write-Host " Upload this to the v1-* GitHub Release on this branch."
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the DorkNet PC client packages.
|
||||||
|
|
||||||
|
patcher dorknet-clientpatch-<VersionKey>.zip, what the DorkNet launcher
|
||||||
|
installs: MelonLoader.zip, DorkNet.ClientMod.dll (built from this
|
||||||
|
repo), the config template, the Goldberg Steam stub and a
|
||||||
|
manifest.json saying where each piece goes. No game files.
|
||||||
|
|
||||||
|
client a ready-to-run client: a stock Rec Room build with a patcher zip
|
||||||
|
applied the way the launcher applies it, plus your config. It
|
||||||
|
contains the game's own files, so keep it private.
|
||||||
|
|
||||||
|
Runs on Windows, Linux and macOS. `patcher` needs the .NET SDK (the mod
|
||||||
|
builds against MelonLoader's own DLLs, so no Rec Room install is needed).
|
||||||
|
|
||||||
|
py -3 tools/build_pc.py patcher --version-key march_2023_03_21
|
||||||
|
py -3 tools/build_pc.py client --base-client RecRoom-2023-03-21.zip \\
|
||||||
|
--patcher-zip dist/dorknet-clientpatch-march_2023_03_21.zip \\
|
||||||
|
--config dorknet-clientmod.json
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
CLIENT_MOD = REPO / "DorkNet.ClientMod"
|
||||||
|
|
||||||
|
# Where each build expects the mod's config and the Steam DLL the game actually
|
||||||
|
# loads (see the per-branch build layout notes in the README):
|
||||||
|
# 2020.03 Recroom_Release.exe is the game; Steam DLL in its Data/Plugins.
|
||||||
|
# 2020.12 RecRoom.exe is the game (Recroom_Release.exe is the EAC wrapper);
|
||||||
|
# MelonLoader keeps UserData under MelonLoader/.
|
||||||
|
# 2023.03 RecRoom.exe + RecRoom_Data; MelonLoader 0.6 keeps UserData at the
|
||||||
|
# install root.
|
||||||
|
LAYOUTS = {
|
||||||
|
"march_2020_03_10": ("MelonLoader/UserData/dorknet-clientmod.json", "Recroom_Release_Data/Plugins/steam_api64.dll"),
|
||||||
|
"december_2020_12_18": ("MelonLoader/UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
|
||||||
|
"march_2023_03_21": ("UserData/dorknet-clientmod.json", "RecRoom_Data/Plugins/x86_64/steam_api64.dll"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg: str) -> NoReturn:
|
||||||
|
raise SystemExit(f"error: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def need(path: Path | str, what: str) -> Path:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
fail(f"{what} not found: {p}")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def zip_dir(src: Path, out: Path) -> None:
|
||||||
|
"""Zip the contents of src (not src itself) into out."""
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.unlink(missing_ok=True)
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as z:
|
||||||
|
for f in sorted(src.rglob("*")):
|
||||||
|
if f.is_file():
|
||||||
|
z.write(f, f.relative_to(src).as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- patcher --------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_patcher(a: argparse.Namespace) -> None:
|
||||||
|
if a.version_key not in LAYOUTS:
|
||||||
|
fail(f"unknown version key '{a.version_key}'. Known: {', '.join(LAYOUTS)}")
|
||||||
|
config_dest, steam_dest = LAYOUTS[a.version_key]
|
||||||
|
out_dir = Path(a.output_dir) if a.output_dir else REPO / "dist"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix=f"dorknet-patcher-{a.version_key}-") as tmp:
|
||||||
|
stage = Path(tmp) / "stage"
|
||||||
|
stage.mkdir()
|
||||||
|
|
||||||
|
# 1. MelonLoader. Downloaded first so its DLLs can serve as the mod's
|
||||||
|
# compile-time references: no local Rec Room install needed.
|
||||||
|
url = f"https://github.com/LavaGang/MelonLoader/releases/download/{a.melonloader_version}/MelonLoader.x64.zip"
|
||||||
|
ml_zip = stage / "MelonLoader.zip"
|
||||||
|
step(f"downloading {url}")
|
||||||
|
with urllib.request.urlopen(url) as r, open(ml_zip, "wb") as f:
|
||||||
|
shutil.copyfileobj(r, f)
|
||||||
|
if ml_zip.stat().st_size < 1 << 20:
|
||||||
|
fail("MelonLoader download failed or produced an unexpectedly small file")
|
||||||
|
ml_extract = Path(tmp) / "melonloader"
|
||||||
|
with zipfile.ZipFile(ml_zip) as z:
|
||||||
|
z.extractall(ml_extract)
|
||||||
|
# The 0.6.x release zip has a top-level MelonLoader/ folder holding
|
||||||
|
# net6, net35, Dependencies, Documentation.
|
||||||
|
ml_ref = ml_extract / "MelonLoader"
|
||||||
|
if not (ml_ref / "net6" / "MelonLoader.dll").exists():
|
||||||
|
fail("extracted MelonLoader is missing net6/MelonLoader.dll; the release layout may have changed")
|
||||||
|
|
||||||
|
# 2. the mod
|
||||||
|
step("dotnet build DorkNet.ClientMod (Release)")
|
||||||
|
r = subprocess.run(["dotnet", "build", str(CLIENT_MOD / "DorkNet.ClientMod.csproj"),
|
||||||
|
"-c", "Release", "--nologo", f"-p:MelonLoaderDir={ml_ref}"])
|
||||||
|
if r.returncode:
|
||||||
|
fail("ClientMod build failed")
|
||||||
|
dlls = sorted((CLIENT_MOD / "bin" / "Release").rglob("DorkNet.ClientMod.dll"),
|
||||||
|
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
if not dlls:
|
||||||
|
fail("DorkNet.ClientMod.dll not found after the build")
|
||||||
|
shutil.copy2(dlls[0], stage / "DorkNet.ClientMod.dll")
|
||||||
|
print(f" built {dlls[0]}")
|
||||||
|
|
||||||
|
# 3. config template + 4. Goldberg Steam stub (App ID 471710, the same
|
||||||
|
# for every build). The stub replaces Valve's steam_api64.dll so the
|
||||||
|
# game runs without Steam.
|
||||||
|
for src, what in ((CLIENT_MOD / "dorknet-clientmod.json.template", "config template"),
|
||||||
|
(CLIENT_MOD / "steam-stub" / "steam_api64.dll", "Steam stub DLL"),
|
||||||
|
(CLIENT_MOD / "steam-stub" / "steam_appid.txt", "Steam appid file")):
|
||||||
|
shutil.copy2(need(src, what), stage / src.name)
|
||||||
|
|
||||||
|
# 5. manifest.json, read by the launcher and by `client` below.
|
||||||
|
manifest = {
|
||||||
|
"$schema_version": 1,
|
||||||
|
"loader_archive": "MelonLoader.zip",
|
||||||
|
"plugin_dll": "DorkNet.ClientMod.dll",
|
||||||
|
"plugin_dest": "Mods",
|
||||||
|
"config_template": "dorknet-clientmod.json.template",
|
||||||
|
"config_dest": config_dest,
|
||||||
|
"old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"],
|
||||||
|
"steam_stub": {
|
||||||
|
# 2020.12+ is an EAC build: the real Unity binary is RecRoom.exe
|
||||||
|
# (it loads RecRoom_Data/Plugins/x86_64/steam_api64.dll), NOT
|
||||||
|
# Recroom_Release.exe (the EAC launcher wrapper, whose data dir
|
||||||
|
# the real game never opens).
|
||||||
|
"api_dll": "steam_api64.dll",
|
||||||
|
"api_dest": steam_dest,
|
||||||
|
"api_backup_suffix": ".steam-original",
|
||||||
|
"appid_file": "steam_appid.txt",
|
||||||
|
"appid_dest": "steam_appid.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
# 6. bundle
|
||||||
|
out = out_dir / f"dorknet-clientpatch-{a.version_key}.zip"
|
||||||
|
zip_dir(stage, out)
|
||||||
|
print()
|
||||||
|
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- client ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_client(a: argparse.Namespace) -> None:
|
||||||
|
base = need(a.base_client, "stock client")
|
||||||
|
patcher_zip = need(a.patcher_zip, "patcher zip")
|
||||||
|
config = need(a.config, "client config") if a.config else None
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="dorknet-pc-client-") as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
install, patcher = tmp / "install", tmp / "patcher"
|
||||||
|
patcher.mkdir()
|
||||||
|
|
||||||
|
# 1. stock client: a zip or a folder, the install at its root or
|
||||||
|
# inside one wrapping folder.
|
||||||
|
step(f"unpacking stock client {base}")
|
||||||
|
if base.is_dir():
|
||||||
|
unpacked = base
|
||||||
|
else:
|
||||||
|
unpacked = tmp / "base"
|
||||||
|
with zipfile.ZipFile(base) as z:
|
||||||
|
z.extractall(unpacked)
|
||||||
|
root = unpacked
|
||||||
|
top = list(root.iterdir())
|
||||||
|
if len(top) == 1 and top[0].is_dir():
|
||||||
|
root = top[0]
|
||||||
|
games = [p.name for p in root.glob("*.exe") if (root / f"{p.stem}_Data").is_dir()]
|
||||||
|
if not games:
|
||||||
|
fail(f"no game exe with a matching _Data folder at the root of {base}")
|
||||||
|
print(f" game: {', '.join(games)}")
|
||||||
|
shutil.copytree(root, install, ignore=shutil.ignore_patterns(".DepotDownloader"))
|
||||||
|
|
||||||
|
# 2. apply the patcher the way the launcher does, from its manifest.
|
||||||
|
step(f"applying {patcher_zip}")
|
||||||
|
with zipfile.ZipFile(patcher_zip) as z:
|
||||||
|
z.extractall(patcher)
|
||||||
|
m = json.loads(need(patcher / "manifest.json", "patcher manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(need(patcher / m["loader_archive"], "MelonLoader archive")) as z:
|
||||||
|
z.extractall(install)
|
||||||
|
print(" MelonLoader -> install root")
|
||||||
|
|
||||||
|
plugin_dir = install / m["plugin_dest"]
|
||||||
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(patcher / m["plugin_dll"], plugin_dir / m["plugin_dll"])
|
||||||
|
print(f" {m['plugin_dll']} -> {m['plugin_dest']}/")
|
||||||
|
|
||||||
|
config_dest = install / m["config_dest"]
|
||||||
|
config_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(config or patcher / m["config_template"], config_dest)
|
||||||
|
print(f" config -> {m['config_dest']}{'' if config else ' (template, unedited)'}")
|
||||||
|
|
||||||
|
stub = m.get("steam_stub")
|
||||||
|
if stub:
|
||||||
|
api_dest = install / stub["api_dest"]
|
||||||
|
if api_dest.exists():
|
||||||
|
backup = api_dest.with_name(api_dest.name + stub["api_backup_suffix"])
|
||||||
|
if not backup.exists():
|
||||||
|
shutil.copy2(api_dest, backup)
|
||||||
|
api_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(patcher / stub["api_dll"], api_dest)
|
||||||
|
shutil.copy2(patcher / stub["appid_file"], install / stub["appid_dest"])
|
||||||
|
print(f" Steam stub -> {stub['api_dest']}")
|
||||||
|
|
||||||
|
for old in m.get("old_plugin_paths") or []:
|
||||||
|
p = install / old
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
print(f" removed old {old}")
|
||||||
|
|
||||||
|
# 3. zip
|
||||||
|
key = patcher_zip.stem.removeprefix("dorknet-clientpatch-")
|
||||||
|
out = Path(a.output_zip) if a.output_zip else REPO / "dist" / f"dorknet-client-{key}.zip"
|
||||||
|
step(f"zipping -> {out}")
|
||||||
|
zip_dir(install, out)
|
||||||
|
print()
|
||||||
|
print(f"Built {out} ({out.stat().st_size / 2**20:.1f} MB). It contains game files: keep it private.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- cli ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description="Build the DorkNet PC client packages.")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
pz = sub.add_parser("patcher", help="dorknet-clientpatch-<VersionKey>.zip (no game files)")
|
||||||
|
pz.add_argument("--version-key", required=True, choices=sorted(LAYOUTS),
|
||||||
|
help="the build the zip is for; picks the install layout")
|
||||||
|
pz.add_argument("--melonloader-version", default="v0.6.6",
|
||||||
|
help="MelonLoader release tag to bundle (default: v0.6.6, what the mod compiles against)")
|
||||||
|
pz.add_argument("--output-dir", help="where the zip goes (default: <repo>/dist)")
|
||||||
|
pz.set_defaults(func=cmd_patcher)
|
||||||
|
|
||||||
|
c = sub.add_parser("client", help="stock build + patcher zip + config -> ready-to-run client zip")
|
||||||
|
c.add_argument("--base-client", required=True, help="the stock client, as a .zip or a folder")
|
||||||
|
c.add_argument("--patcher-zip", required=True, help="dorknet-clientpatch-<VersionKey>.zip from `patcher`")
|
||||||
|
c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)")
|
||||||
|
c.add_argument("--output-zip", help="default: <repo>/dist/dorknet-client-<VersionKey>.zip")
|
||||||
|
c.set_defaults(func=cmd_client)
|
||||||
|
|
||||||
|
a = p.parse_args()
|
||||||
|
a.func(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user