mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: DorkNet.Quest, a sideload patcher for the 2023 Quest client
Patches the Quest build of Rec Room 2023.03.21 (20230317 / 2128210, the
same appVersion as the PC client) to connect to a DorkNet server, using
one native hook library loaded via DT_NEEDED on libmain.so.
- host rewrite onto <svc>.<ServerHost> or a {service}-dev.example.com template
- Photon app ids and region injection (PhotonRegion must match PC)
- TLS / pinning / image-signature bypass, integrity callbacks neutralised
- no-Meta-account build; the server treats platform id 0/1/blank as a
placeholder and keys those accounts by device id
- build.ps1 -Obb packages a DorkNet-<ver>.xapk with one-command
installers for Windows and macOS/Linux; app is named via AppName
- README, docs/quest.md and NOTICE updated; credits RecFlare and
And64InlineHook (MIT)
Tested on a Quest 2: all hooks arm, requests reach the dev server.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
<#
|
||||
.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,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate config_generated.h from dorknet-quest.cfg.
|
||||
|
||||
Bakes the operator's config into the library at compile time. Keys absent from
|
||||
the cfg are simply not emitted, so the built-in defaults in config.h apply.
|
||||
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
|
||||
committed file.
|
||||
|
||||
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
|
||||
KEYS = {
|
||||
"ServerHost": ("DN_SERVER_HOST", "str"),
|
||||
"OculusAppId": ("DN_OCULUS_APPID", "str"),
|
||||
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
|
||||
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
|
||||
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
|
||||
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
|
||||
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
}
|
||||
TRUE = {"true", "1", "yes", "on"}
|
||||
FALSE = {"false", "0", "no", "off"}
|
||||
|
||||
|
||||
def c_string(v):
|
||||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
|
||||
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
|
||||
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
f"#define DN_PACKAGE {c_string(package)}", ""]
|
||||
|
||||
if cfg.exists():
|
||||
for raw in cfg.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or "=" not in line or line.startswith("["):
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if key not in KEYS or val == "":
|
||||
continue
|
||||
macro, kind = KEYS[key]
|
||||
if kind == "str":
|
||||
lines.append(f"#define {macro} {c_string(val)}")
|
||||
elif kind == "int":
|
||||
lines.append(f"#define {macro} {int(val)}")
|
||||
elif kind == "bool":
|
||||
low = val.lower()
|
||||
if low in TRUE:
|
||||
lines.append(f"#define {macro} true")
|
||||
elif low in FALSE:
|
||||
lines.append(f"#define {macro} false")
|
||||
else:
|
||||
raise SystemExit(f"{key}: not a boolean: {val}")
|
||||
else:
|
||||
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
|
||||
|
||||
lines += ["", "#endif"]
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate targets_generated.h from a resolved target table.
|
||||
|
||||
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
|
||||
named struct field, plus the build's version. The hook library checks the
|
||||
fingerprint against the running libil2cpp.so before arming, so a table can only
|
||||
ever be applied to the build it was resolved from.
|
||||
|
||||
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
|
||||
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
ids = list(table["targets"].keys())
|
||||
|
||||
lines = [
|
||||
"// Generated by scripts/gen_targets_header.py -- do not edit.",
|
||||
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
|
||||
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
|
||||
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
|
||||
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
|
||||
f"#define DN_HOOK_BYTES {table['hookBytes']}",
|
||||
"",
|
||||
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
|
||||
"",
|
||||
"typedef struct {",
|
||||
]
|
||||
for tid in ids:
|
||||
lines.append(f" DnTarget {tid};")
|
||||
lines.append("} DnTargets;")
|
||||
lines.append("")
|
||||
lines.append("static const DnTargets dn_targets = {")
|
||||
for tid in ids:
|
||||
e = table["targets"][tid]
|
||||
length = e["length"] if e["length"] is not None else 0
|
||||
lines.append(
|
||||
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
lines.append("#endif")
|
||||
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
|
||||
libdorknet.so before Unity starts.
|
||||
|
||||
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
|
||||
DT_NEEDED; adding one more makes the loader map our hook library first. This is
|
||||
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
|
||||
|
||||
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
|
||||
"""
|
||||
import sys
|
||||
|
||||
import lief
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
|
||||
path, soname = sys.argv[1], sys.argv[2]
|
||||
binary = lief.ELF.parse(path)
|
||||
if binary is None:
|
||||
raise SystemExit(f"could not parse {path}")
|
||||
|
||||
existing = [e.name for e in binary.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname in existing:
|
||||
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
|
||||
return
|
||||
|
||||
binary.add_library(soname)
|
||||
binary.write(path)
|
||||
print(f"added DT_NEEDED {soname} to {path}")
|
||||
|
||||
check = lief.ELF.parse(path)
|
||||
names = [e.name for e in check.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname not in names:
|
||||
raise SystemExit("injection did not stick")
|
||||
print("verified:", ", ".join(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
rem One-click installer: runs install.ps1 next to this file.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,145 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs the APK, copies the OBB to the path the game expects, then launches
|
||||
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
|
||||
arguments from the package folder, or double-click install.cmd.
|
||||
|
||||
.PARAMETER Xapk
|
||||
The package to install. Default: the only .xapk next to this script.
|
||||
|
||||
.PARAMETER Serial
|
||||
adb serial of the headset when more than one device is connected.
|
||||
|
||||
.PARAMETER NoLaunch
|
||||
Install only; don't start the app afterwards.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Xapk,
|
||||
[string]$Serial,
|
||||
[switch]$NoLaunch
|
||||
)
|
||||
|
||||
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
|
||||
# that as a failure, so exit codes are checked by hand instead.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Set-StrictMode -Version 2
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# ---- find the package ----------------------------------------------------
|
||||
if (-not $Xapk) {
|
||||
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
|
||||
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
|
||||
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
|
||||
$Xapk = $found[0].FullName
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
|
||||
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
|
||||
|
||||
# ---- find adb -----------------------------------------------------------
|
||||
$adb = $null
|
||||
$cmd = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $adb = $cmd.Source }
|
||||
if (-not $adb) {
|
||||
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
|
||||
foreach ($r in $roots) {
|
||||
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
|
||||
if (Test-Path $c) { $adb = $c; break }
|
||||
}
|
||||
if ($adb) { break }
|
||||
}
|
||||
}
|
||||
if (-not $adb) {
|
||||
Fail ("adb not found. Install Android platform-tools from " +
|
||||
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
|
||||
"(or unzip platform-tools next to this script).")
|
||||
}
|
||||
|
||||
# ---- pick the headset ----------------------------------------------------
|
||||
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
|
||||
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
|
||||
if ($Serial) {
|
||||
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
|
||||
} elseif ($ready.Count -eq 1) {
|
||||
$Serial = $ready[0]
|
||||
} elseif ($ready.Count -gt 1) {
|
||||
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
|
||||
} elseif ($unauth.Count -gt 0) {
|
||||
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
} else {
|
||||
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
|
||||
}
|
||||
function Adb { & $adb -s $Serial @args }
|
||||
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
|
||||
Say "headset: $model ($Serial)"
|
||||
|
||||
# ---- read the package ----------------------------------------------------
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
try {
|
||||
$me = $zip.GetEntry('manifest.json')
|
||||
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
|
||||
$sr = New-Object System.IO.StreamReader($me.Open())
|
||||
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
|
||||
$sr.Dispose()
|
||||
$pkg = $manifest.package_name
|
||||
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
|
||||
|
||||
function Extract($entry) {
|
||||
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
|
||||
return $out
|
||||
}
|
||||
|
||||
# ---- APK -------------------------------------------------------------
|
||||
$apkEntry = $zip.GetEntry("$pkg.apk")
|
||||
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
|
||||
if (-not $apkEntry) { Fail 'no APK inside the package.' }
|
||||
Say 'installing the app'
|
||||
$apkFile = Extract $apkEntry
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
|
||||
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
|
||||
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
|
||||
$ans = Read-Host 'Uninstall it and continue? [y/N]'
|
||||
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
|
||||
Adb uninstall $pkg | Out-Null
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
}
|
||||
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
|
||||
Remove-Item -Force $apkFile
|
||||
|
||||
# ---- OBB (expansion files) ------------------------------------------
|
||||
foreach ($x in @($manifest.expansions)) {
|
||||
if (-not $x) { continue }
|
||||
$entry = $zip.GetEntry($x.file)
|
||||
if (-not $entry) { Fail "package is missing $($x.file)." }
|
||||
$dest = '/sdcard/' + $x.install_path
|
||||
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
|
||||
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
|
||||
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
|
||||
$f = Extract $entry
|
||||
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
|
||||
Adb push $f $dest
|
||||
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
|
||||
Remove-Item -Force $f
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
|
||||
if (-not $NoLaunch) {
|
||||
Say 'launching (put the headset on)'
|
||||
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
|
||||
#
|
||||
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
|
||||
#
|
||||
# Needs adb (Android platform-tools) and unzip.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
xapk="" serial="" launch=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--serial) serial="$2"; shift 2 ;;
|
||||
--no-launch) launch=0; shift ;;
|
||||
*) xapk="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
if [ -z "$xapk" ]; then
|
||||
set -- "$here"/*.xapk
|
||||
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
|
||||
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
|
||||
xapk="$1"
|
||||
fi
|
||||
[ -f "$xapk" ] || fail "package not found: $xapk"
|
||||
command -v unzip >/dev/null || fail "unzip is required."
|
||||
|
||||
adb="$(command -v adb || true)"
|
||||
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
|
||||
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
|
||||
"$here/platform-tools/adb"; do
|
||||
[ -n "$adb" ] && break
|
||||
[ -x "$c" ] && adb="$c"
|
||||
done
|
||||
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
|
||||
|
||||
if [ -z "$serial" ]; then
|
||||
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
|
||||
count=$(printf '%s' "$ready" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
if "$adb" devices | grep -q unauthorized; then
|
||||
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
fi
|
||||
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
|
||||
fi
|
||||
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
|
||||
serial="$ready"
|
||||
fi
|
||||
a() { "$adb" -s "$serial" "$@"; }
|
||||
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
|
||||
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
|
||||
pkg="$(field package_name)"
|
||||
[ -n "$pkg" ] || fail "manifest.json has no package_name."
|
||||
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
|
||||
|
||||
say "installing the app"
|
||||
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
|
||||
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
|
||||
echo "Your account lives on the server, but local settings on the headset are lost."
|
||||
read -r -p "Uninstall it and continue? [y/N] " ans
|
||||
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
|
||||
a uninstall "$pkg" >/dev/null || true
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
fi
|
||||
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
|
||||
rm -f "$tmp/$pkg.apk"
|
||||
|
||||
# Expansion files: every "install_path" in the manifest.
|
||||
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
|
||||
while read -r path; do
|
||||
dest="/sdcard/$path"
|
||||
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
|
||||
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
|
||||
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
|
||||
say "copying game data - this takes a few minutes"
|
||||
unzip -q -o "$xapk" "$path" -d "$tmp"
|
||||
a shell "mkdir -p '$(dirname "$dest")'"
|
||||
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
|
||||
rm -f "$tmp/$path"
|
||||
done
|
||||
|
||||
echo
|
||||
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
|
||||
if [ "$launch" -eq 1 ]; then
|
||||
say "launching (put the headset on)"
|
||||
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,170 @@
|
||||
<#
|
||||
.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:
|
||||
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
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
|
||||
|
||||
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
|
||||
class header and method signature, and verifies each against the build's
|
||||
libil2cpp.so:
|
||||
|
||||
* exactly one method matches the signature,
|
||||
* the method body is at least 20 bytes (the inline hook rewrites the first
|
||||
five instructions),
|
||||
* the first instructions are disassembled (llvm-objdump, when provided) so
|
||||
the prologue can be eyeballed before a build.
|
||||
|
||||
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
|
||||
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
|
||||
bytes. The hook library refuses to arm a target whose fingerprint does not
|
||||
match the running libil2cpp.so, so a table can never be applied to the wrong
|
||||
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
|
||||
|
||||
Usage:
|
||||
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
|
||||
--version-name 20230317 --version-code 2128210 \
|
||||
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOOK_BYTES = 20
|
||||
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
|
||||
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
|
||||
|
||||
|
||||
def fnv1a64(data):
|
||||
h = 0xCBF29CE484222325
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def sha256(path):
|
||||
d = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
d.update(chunk)
|
||||
return d.hexdigest()
|
||||
|
||||
|
||||
def scan_dump(dump, spec):
|
||||
"""One pass over dump.cs collecting (rva, offset) for each spec match and
|
||||
for the NEXT method after it (to bound the body length)."""
|
||||
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
|
||||
hits = {} # id -> {rva, offset, ...}
|
||||
all_rvas = set() # every method RVA, for next-greater length bounding
|
||||
rva = off = None
|
||||
cur_class = None
|
||||
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
for ln in lines:
|
||||
m = RVA_RE.match(ln)
|
||||
if m:
|
||||
rva, off = int(m.group(1), 16), int(m.group(2), 16)
|
||||
if rva:
|
||||
all_rvas.add(rva)
|
||||
continue
|
||||
if TYPE_RE.match(ln):
|
||||
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
|
||||
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
|
||||
continue
|
||||
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
|
||||
body = ln.strip()
|
||||
body = body.split("// RVA")[0].rstrip()
|
||||
if body.endswith("{ }"):
|
||||
body = body[:-3].rstrip()
|
||||
elif body.endswith(";"):
|
||||
body = body[:-1].rstrip()
|
||||
for tid, (cls, meth) in wanted.items():
|
||||
if tid in hits:
|
||||
continue
|
||||
# Class header may carry a base/interface list ("X : Base, IFoo").
|
||||
# Match the exact class, or that class followed by " : <bases>",
|
||||
# but never a nested "X.Y" (which continues with "." not " ").
|
||||
spec_class = cls.rstrip()
|
||||
class_ok = cur_class is not None and (
|
||||
cur_class == spec_class or cur_class.startswith(spec_class + " "))
|
||||
if class_ok and body == meth.rstrip():
|
||||
if rva is None:
|
||||
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
|
||||
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
|
||||
"class": cls.rstrip()}
|
||||
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
|
||||
# lists methods in declaration order, so the following *line* is not the
|
||||
# following *function* in memory.
|
||||
ordered = sorted(all_rvas)
|
||||
import bisect
|
||||
for h in hits.values():
|
||||
i = bisect.bisect_right(ordered, h["rva"])
|
||||
h["end_rva"] = ordered[i] if i < len(ordered) else None
|
||||
return hits
|
||||
|
||||
|
||||
def objdump_prologue(objdump, il2cpp, rva, count=6):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[objdump, "-d", f"--start-address={rva:#x}",
|
||||
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return f"(objdump failed: {e})"
|
||||
return "\n".join(l for l in out.splitlines() if ":\t" in l)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dump", required=True, type=Path)
|
||||
ap.add_argument("--il2cpp", required=True, type=Path)
|
||||
ap.add_argument("--version-name", required=True)
|
||||
ap.add_argument("--version-code", required=True)
|
||||
ap.add_argument("--objdump")
|
||||
ap.add_argument("--spec", type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
|
||||
ap.add_argument("--out", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = json.loads(args.spec.read_text(encoding="utf-8"))
|
||||
hits = scan_dump(args.dump, spec)
|
||||
|
||||
code = args.il2cpp.read_bytes()
|
||||
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
|
||||
if missing:
|
||||
raise SystemExit("targets not found in dump: " + ", ".join(missing))
|
||||
|
||||
entries = {}
|
||||
ok = True
|
||||
for t in spec["targets"]:
|
||||
tid = t["id"]
|
||||
h = hits[tid]
|
||||
rva = h["rva"]
|
||||
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
|
||||
length = (h["end_rva"] - rva) if h["end_rva"] else None
|
||||
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
|
||||
short = length is not None and length < HOOK_BYTES
|
||||
if short:
|
||||
ok = False
|
||||
entries[tid] = {
|
||||
"rva": rva,
|
||||
"length": length,
|
||||
"fingerprint": f"{fp:#018x}",
|
||||
"why": t["why"],
|
||||
}
|
||||
flag = " TOO SHORT" if short else ""
|
||||
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
|
||||
if args.objdump:
|
||||
print(objdump_prologue(args.objdump, args.il2cpp, rva))
|
||||
|
||||
table = {
|
||||
"versionName": args.version_name,
|
||||
"versionCode": args.version_code,
|
||||
"il2cppSha256": sha256(args.il2cpp),
|
||||
"hookBytes": HOOK_BYTES,
|
||||
"targets": entries,
|
||||
}
|
||||
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
|
||||
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\nwrote {out}")
|
||||
if not ok:
|
||||
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user