diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eae2d14..867a707 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,10 +43,10 @@ jobs: Compress-Archive -Path publish/server/* ` -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 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 ─────────────────────────────── - name: Upload Release assets diff --git a/DorkNet.Quest/README.md b/DorkNet.Quest/README.md index bc4e639..e025cbf 100644 --- a/DorkNet.Quest/README.md +++ b/DorkNet.Quest/README.md @@ -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 \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 \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 -Obb `. +To package an APK you already built, run `build.py package --apk --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 `, then `intercepted https://ns.rec.net/ -> https://ns.: ... 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 `, then `intercepted https://ns.rec.net/ -> https://ns. 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: \dorknet-recroom-.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 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('', - '') -} -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, '[^<]*', "$labelXml") - 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 -} diff --git a/DorkNet.Quest/scripts/build.py b/DorkNet.Quest/scripts/build.py new file mode 100644 index 0000000..6dc2021 --- /dev/null +++ b/DorkNet.Quest/scripts/build.py @@ -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 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('', + '' + '') + 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'[^<]*', lambda _: f'{label}', 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:///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: /dorknet-recroom-.apk)") + b.add_argument("--package-dir", help="release folder for the .xapk (default: /DorkNet-Quest-)") + 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: /DorkNet-Quest-)") + k.add_argument("--build-tools-dir") + k.set_defaults(func=cmd_package) + + a = p.parse_args() + a.func(a) + + +if __name__ == "__main__": + main() diff --git a/DorkNet.Quest/scripts/package.ps1 b/DorkNet.Quest/scripts/package.ps1 deleted file mode 100644 index decb13e..0000000 --- a/DorkNet.Quest/scripts/package.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -<# -.SYNOPSIS - Bundle a patched Quest APK and its OBB into one installable package. - -.DESCRIPTION - Writes a release folder holding: - DorkNet-.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..com.AgainstGravity.RecRoom.obb). - -.PARAMETER OutDir - Release folder to create. Default: \DorkNet-Quest-. - -.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:///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 diff --git a/DorkNet.Server/site/src/lib/xapk.ts b/DorkNet.Server/site/src/lib/xapk.ts index 27c0b1b..c74dda6 100644 --- a/DorkNet.Server/site/src/lib/xapk.ts +++ b/DorkNet.Server/site/src/lib/xapk.ts @@ -1,5 +1,5 @@ // 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 // stream out with Blob.slice(); manifest.json is deflated and goes through // DecompressionStream. Handles zip64 so packages over 4 GB still open. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..9609d05 --- /dev/null +++ b/Jenkinsfile @@ -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() + } +} diff --git a/ci/jenkins/.gitattributes b/ci/jenkins/.gitattributes new file mode 100644 index 0000000..ff28d4c --- /dev/null +++ b/ci/jenkins/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run inside the Linux image; keep them LF on every checkout. +*.sh text eol=lf diff --git a/ci/jenkins/Dockerfile b/ci/jenkins/Dockerfile new file mode 100644 index 0000000..78486f8 --- /dev/null +++ b/ci/jenkins/Dockerfile @@ -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 diff --git a/ci/jenkins/README.md b/ci/jenkins/README.md new file mode 100644 index 0000000..a6f2eda --- /dev/null +++ b/ci/jenkins/README.md @@ -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-.apk` and `out/quest/DorkNet-Quest-/` (`.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=`, `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:///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. diff --git a/ci/jenkins/archive-restore.sh b/ci/jenkins/archive-restore.sh new file mode 100644 index 0000000..b323fd7 --- /dev/null +++ b/ci/jenkins/archive-restore.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# archive-restore — pull a Rec Room build out of the restic build archive. +# +# archive-restore list [tag[,tag…]] +# archive-restore restore [restic restore args…] +# +# 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 // 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="${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 diff --git a/ci/jenkins/docker-compose.yml b/ci/jenkins/docker-compose.yml new file mode 100644 index 0000000..cc05993 --- /dev/null +++ b/ci/jenkins/docker-compose.yml @@ -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: diff --git a/tools/build-patcher-zip.ps1 b/tools/build-patcher-zip.ps1 deleted file mode 100644 index 4f885a1..0000000 --- a/tools/build-patcher-zip.ps1 +++ /dev/null @@ -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 } -} diff --git a/tools/build_pc.py b/tools/build_pc.py new file mode 100644 index 0000000..7615b55 --- /dev/null +++ b/tools/build_pc.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Build the DorkNet PC client packages. + + patcher dorknet-clientpatch-.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 + +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) -> 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 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-.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: /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-.zip from `patcher`") + c.add_argument("--config", help="dorknet-clientmod.json to install (default: the unedited template)") + c.add_argument("--output-zip", help="default: /dist/dorknet-client-.zip") + c.set_defaults(func=cmd_client) + + a = p.parse_args() + a.func(a) + + +if __name__ == "__main__": + main()