ci: build the PC and Quest clients on Jenkins

A Jenkinsfile builds, on push (only what changed) or on demand:
- the PC patcher zip (MelonLoader, the mod, config template, Steam stub),
- the full PC client (stock build + patcher + config),
- the Quest APK and .xapk.

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

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

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

Setup steps are in ci/jenkins/README.md.
This commit is contained in:
Alexa
2026-10-24 20:26:42 +03:00
parent 38335c06a9
commit b1c1239165
14 changed files with 1304 additions and 596 deletions
+24 -16
View File
@@ -60,7 +60,7 @@ Photon shards with PC DorkNet players with no server-side version change.
and its matching **OBB**.
> 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.
## Build
@@ -76,20 +76,28 @@ dotnet Il2CppDumper.dll `
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
# 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_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
DorkNet.Quest\scripts\build.ps1 `
-Apk C:\path\to\RecRoom-store-2128210.apk `
-DumpCs <scratch>\dump\dump.cs `
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
py -3 DorkNet.Quest\scripts\build.py build `
--apk C:\path\to\RecRoom-store-2128210.apk `
--dump-cs <scratch>\dump\dump.cs `
--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-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
folder ready to hand to players:
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
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 |
| --- | --- |
@@ -98,15 +106,16 @@ folder ready to hand to players:
| `install.sh` | one-command install on macOS / Linux |
| `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
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 →
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
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
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.
It is never committed.
@@ -189,7 +198,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
| Symptom | Likely cause |
| --- | --- |
| `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 |
| `[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 |
@@ -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_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
| `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) |
-248
View File
@@ -1,248 +0,0 @@
<#
.SYNOPSIS
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
.DESCRIPTION
End to end:
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
2. gen_targets_header.py -> targets_generated.h
3. gen_config_header.py -> config_generated.h (from your cfg)
4. NDK clang++ -> libdorknet.so (arm64)
5. apktool decode -> working project
6. package rename + manifest/version edits
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
8. apktool build -> zipalign -> apksigner -> the signed output APK
Nothing game-derived is written into the repo. The work directory defaults to
a temp path because Windows Controlled Folder Access blocks the NDK linker and
apksigner from writing under Documents; override with -WorkDir if needed.
You supply the store APK and (for install) the matching OBB. No game binaries
ship with this tool.
.PARAMETER Apk
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
2128210 / version 20230317).
.PARAMETER Config
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
.PARAMETER WorkDir
Scratch build directory (must be outside Controlled-Folder-Access-protected
locations). Defaults to $env:TEMP\dorknet-quest-build.
.PARAMETER OutApk
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
.PARAMETER NdkRoot
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
.PARAMETER BuildToolsDir
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
from $env:ANDROID_SDK_ROOT.
.PARAMETER Obb
The store OBB matching the APK. When given, package.ps1 also bundles the
result into an installable .xapk with one-command installers.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Apk,
[string]$Config,
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
[string]$OutApk,
[string]$NdkRoot,
[string]$BuildToolsDir,
[string]$ApktoolJar,
[string]$DumpCs,
[string]$ApiLevel = '24',
[string]$Obb
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$RepoDir = Split-Path -Parent $PSScriptRoot
$Scripts = $PSScriptRoot
$OldPkg = 'com.AgainstGravity.RecRoom'
$SoName = 'libdorknet.so'
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
# ---- resolve tools ------------------------------------------------------
if (-not $NdkRoot) {
$NdkRoot = $env:ANDROID_NDK
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
}
}
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
Need $Cxx 'NDK aarch64 clang++'
if (-not $BuildToolsDir) {
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
}
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
Need $ZipAlign 'zipalign'
Need $ApkSigner 'apksigner'
if (-not $ApktoolJar) {
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
}
Need $ApktoolJar 'apktool.jar'
Need $Apk 'store APK'
if (-not $Config) {
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
}
Need $Config 'config'
Write-Host "Using config: $Config"
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
if (-not $AppName) { $AppName = 'DorkNet' }
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
$pa = $PyPre + $a
& $PyExe @pa
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
# ---- work dirs ----------------------------------------------------------
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$Gen = Join-Path $WorkDir 'gen'
$Project = Join-Path $WorkDir 'project'
$Extract = Join-Path $WorkDir 'extract'
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
# ---- 1. extract + dump + resolve targets --------------------------------
Step 'extracting libil2cpp.so + global-metadata.dat'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
try {
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
$dest = Join-Path $Extract (Split-Path $e -Leaf)
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
}
} finally { $zip.Dispose() }
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
$LibMain = Join-Path $Extract 'libmain.so'
if (-not $DumpCs) {
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
}
Need $DumpCs 'dump.cs'
Step 'resolving hook targets'
$verName = '20230317'; $verCode = '2128210'
# Write the resolved table into the work dir (Controlled Folder Access can
# block writes under Documents). The repo keeps a committed copy under targets/.
$table = Join-Path $WorkDir "$verName-$verCode.json"
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
Step 'generating headers'
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
# ---- 4. compile libdorknet.so -------------------------------------------
Step 'compiling libdorknet.so (arm64)'
$src = @(
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
(Join-Path $RepoDir 'src\config.cpp'),
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
)
$So = Join-Path $WorkDir $SoName
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
if ($LASTEXITCODE) { throw 'compile failed' }
Need $So 'libdorknet.so'
# ---- 5. apktool decode --------------------------------------------------
Step 'apktool decode'
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
if ($LASTEXITCODE) { throw 'apktool decode failed' }
# ---- 6. package rename + manifest -------------------------------------
Step "renaming package $OldPkg -> $PkgName"
$manifest = Join-Path $Project 'AndroidManifest.xml'
$m = Get-Content -Raw $manifest
$m = $m.Replace($OldPkg, $PkgName)
if ($m -notmatch 'android.intent.category.LAUNCHER') {
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
}
Set-Content -NoNewline -Path $manifest -Value $m
# The launcher label comes from @string/app_name; set it in every locale.
Step "naming the app '$AppName'"
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
}
# ---- 7. inject the hook library ----------------------------------------
Step 'injecting libdorknet.so'
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
# ---- 8. rebuild + align + sign -----------------------------------------
Step 'apktool build'
$unsigned = Join-Path $WorkDir 'unsigned.apk'
& java -jar $ApktoolJar build -f -o $unsigned $Project
if ($LASTEXITCODE) { throw 'apktool build failed' }
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
if (-not (Test-Path $Keystore)) {
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
if ($LASTEXITCODE) { throw 'keytool failed' }
}
Step 'zipalign'
$aligned = Join-Path $WorkDir 'aligned.apk'
& $ZipAlign -f -p 4 $unsigned $aligned
if ($LASTEXITCODE) { throw 'zipalign failed' }
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
Step "apksigner -> $OutApk"
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
if ($LASTEXITCODE) { throw 'apksigner failed' }
& $ApkSigner verify --print-certs $OutApk | Out-Null
Write-Host ""
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
if ($Obb) {
Write-Host ""
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
} else {
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
}
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
From a pristine store APK, end to end:
1. hook targets resolve them from an Il2CppDumper dump.cs
(resolve_targets.py), or use a committed table checked
against the APK's libil2cpp.so
2. headers targets_generated.h + config_generated.h
3. compile libdorknet.so (arm64) with the Android NDK
4. apktool decode
5. rename package rename, launcher category, app label
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
7. sign apktool build -> zipalign -> apksigner
8. package with --obb: .xapk + one-command installers (or run
`package` on an APK you already built)
Nothing game-derived is written into the repo. You supply the store APK and,
for a release package, the matching OBB. The output contains game binaries
from your own store copy: share it only with people who own the game.
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
keytool), the Android NDK, Android build-tools and apktool.
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
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) -> None:
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"
fail("apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
def cfg_value(config: Path, key: str) -> str | None:
for line in config.read_text(encoding="utf-8").splitlines():
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
if m:
return m.group(1).split("#", 1)[0].strip()
return None
# ---- build ----------------------------------------------------------------
def cmd_build(a: argparse.Namespace) -> None:
ndk = resolve_ndk(a.ndk_root)
bin_dir = ndk_bin(ndk)
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
"NDK aarch64 clang++")
objdump = bin_dir / exe("llvm-objdump")
build_tools = resolve_build_tools(a.build_tools_dir)
zipalign = need(build_tools / exe("zipalign"), "zipalign")
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
apktool = resolve_apktool(a.apktool_jar)
apk = need(a.apk, "store APK").resolve()
config = Path(a.config) if a.config else (
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
need(config, "config")
print(f"Using config: {config}")
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
app_name = cfg_value(config, "AppName") or "DorkNet"
work = Path(a.work_dir)
gen, project, extract = work / "gen", work / "project", work / "extract"
for d in (work, gen, extract):
d.mkdir(parents=True, exist_ok=True)
# 1. extract + targets
step("extracting libil2cpp.so + global-metadata.dat")
with zipfile.ZipFile(apk) as z:
for entry in ("lib/arm64-v8a/libil2cpp.so",
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
"lib/arm64-v8a/libmain.so"):
try:
data = z.read(entry)
except KeyError:
fail(f"APK missing {entry}")
(extract / Path(entry).name).write_bytes(data)
il2cpp = extract / "libil2cpp.so"
ver_name, ver_code = "20230317", "2128210"
if a.targets_json:
# A committed, already-resolved table. It is only valid for the exact
# libil2cpp.so it was resolved against, so check that first.
table = need(a.targets_json, "target table")
step(f"using target table {table}")
data = json.loads(table.read_text(encoding="utf-8"))
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
want = str(data.get("il2cppSha256") or "").lower()
if want and sha != want:
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
f"for ({want}). Resolve a table for it with --dump-cs.")
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
else:
if not a.dump_cs:
# Dumping is left to Il2CppDumper (see README) to keep this
# script toolchain-light.
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
"resolved table (see DorkNet.Quest/README.md).")
need(a.dump_cs, "dump.cs")
step("resolving hook targets")
# Written into the work dir (Controlled Folder Access can block writes
# under Documents). The repo keeps a committed copy under targets/.
table = work / f"{ver_name}-{ver_code}.json"
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
"--version-name", ver_name, "--version-code", ver_code,
"--objdump", objdump, "--out", table)
# 2. headers
step("generating headers")
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
# 3. compile
step("compiling libdorknet.so (arm64)")
src = REPO / "src"
so = work / SO_NAME
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
f"-I{gen}", f"-I{src}",
src / "dorknet_quest.cpp", src / "config.cpp",
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
"-llog", "-o", so)
need(so, "libdorknet.so")
# 4. decode
step("apktool decode")
if project.exists():
shutil.rmtree(project)
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
# 5. package rename + manifest + label
step(f"renaming package {OLD_PKG} -> {pkg_name}")
manifest = project / "AndroidManifest.xml"
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
if "android.intent.category.LAUNCHER" not in m:
m = m.replace('<category android:name="android.intent.category.INFO"/>',
'<category android:name="android.intent.category.INFO"/>'
'<category android:name="android.intent.category.LAUNCHER"/>')
manifest.write_text(m, encoding="utf-8", newline="")
# The launcher label comes from @string/app_name; set it in every locale.
step(f"naming the app '{app_name}'")
# XML entities for & < > " ' (the PowerShell version used
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
label = xml_escape(app_name, {'"': "&quot;", "'": "&apos;"})
for sx in (project / "res").rglob("strings.xml"):
s = sx.read_text(encoding="utf-8")
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
if n != s:
sx.write_text(n, encoding="utf-8", newline="")
# 6. inject
step(f"injecting {SO_NAME}")
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
# 7. build + align + sign
step("apktool build")
unsigned = work / "unsigned.apk"
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
if a.keystore:
keystore = need(a.keystore, "keystore")
else:
keystore = work / "dorknet-debug.keystore"
if not keystore.exists():
step("generating a fresh debug keystore (kept in the work dir, never committed)")
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
"-dname", "CN=DorkNet Quest, O=DorkNet")
step("zipalign")
aligned = work / "aligned.apk"
run(zipalign, "-f", "-p", "4", unsigned, aligned)
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
out_apk.parent.mkdir(parents=True, exist_ok=True)
step(f"apksigner -> {out_apk}")
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
"--out", out_apk, aligned)
capture(apksigner, "verify", "--print-certs", out_apk)
print()
print(f"DONE. Patched APK: {out_apk}")
print(f"Package: {pkg_name} versionCode: {ver_code}")
if a.obb:
print()
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
else:
print("Install it, then push the matching OBB to:")
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
print("(or pass --obb to bundle both into an installable .xapk)")
# ---- package --------------------------------------------------------------
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
============================================================
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
in over USB and accept "Allow USB debugging" inside the headset.
2. Install. Easiest: open the Quest page of your DorkNet server's website
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
steps. Or use the scripts here:
Windows: double-click install.cmd
macOS / Linux: ./install.sh
The installer needs adb (Android platform-tools) on PATH or in
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
offers to launch it.
3. In the headset: Library > Unknown Sources > {name}.
The app installs as {pkg} alongside any store copy of Rec Room.
SideQuest users can also drop the .xapk onto SideQuest instead.
"""
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
"""Bundle a patched APK and its OBB into an .xapk plus installers.
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
The APK and OBB are stored uncompressed: they are already compressed, and
storing them keeps packaging and install fast."""
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
step("reading APK metadata")
badging = capture(aapt2, "dump", "badging", apk).splitlines()
def badge(pattern: str) -> str | None:
for line in badging:
mm = re.search(pattern, line)
if mm:
return mm.group(1)
return None
pkg = badge(r"^package: name='([^']+)'")
ver_code = badge(r"versionCode='([^']+)'")
ver_name = badge(r"versionName='([^']+)'")
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
label = badge(r"^application-label:'([^']*)'")
if not pkg or not ver_code:
fail("could not read package name / versionCode from the APK")
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
icon_path = None
with zipfile.ZipFile(apk) as z:
names = set(z.namelist())
if icons:
ref = icons[0][1]
if ref.endswith(".png") and ref in names:
icon_path = ref
else:
base = Path(ref).stem
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
for kind in ("mipmap", "drawable"):
cand = f"res/{kind}-{d}/{base}.png"
if icon_path is None and cand in names:
icon_path = cand
icon_bytes = z.read(icon_path) if icon_path else None
print(f" {pkg} version {ver_name} ({ver_code})")
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
out_dir.mkdir(parents=True, exist_ok=True)
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
xapk.unlink(missing_ok=True)
manifest = {
"xapk_version": 2,
"package_name": pkg,
"name": label or "DorkNet",
"version_code": ver_code,
"version_name": ver_name,
"min_sdk_version": min_sdk,
"target_sdk_version": tgt_sdk,
"permissions": perms,
"total_size": apk.stat().st_size + obb.stat().st_size,
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
}
if icon_bytes:
manifest["icon"] = "icon.png"
step(f"writing {xapk} (the OBB is large; this takes a minute)")
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
if icon_bytes:
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
step("adding installers")
for f in ("install.ps1", "install.cmd", "install.sh"):
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
(out_dir / "install.sh").chmod(0o755)
(out_dir / "README.txt").write_text(
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
print()
print(f"DONE. Package folder: {out_dir}")
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
print(f"Install with: {out_dir / 'install.cmd'}")
return out_dir
def cmd_package(a: argparse.Namespace) -> None:
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
resolve_build_tools(a.build_tools_dir))
# ---- cli ------------------------------------------------------------------
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
b.set_defaults(func=cmd_build)
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
k.add_argument("--apk", required=True)
k.add_argument("--obb", required=True)
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
k.add_argument("--build-tools-dir")
k.set_defaults(func=cmd_package)
a = p.parse_args()
a.func(a)
if __name__ == "__main__":
main()
-172
View File
@@ -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