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