mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -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,2 @@
|
||||
*.sh text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -0,0 +1,9 @@
|
||||
# Operator secrets and build outputs — never commit.
|
||||
dorknet-quest.cfg
|
||||
*.keystore
|
||||
*.so
|
||||
build/
|
||||
*_generated.h
|
||||
|
||||
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
|
||||
# only the generated C header is ignored.
|
||||
@@ -0,0 +1,215 @@
|
||||
# DorkNet.Quest
|
||||
|
||||
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
|
||||
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
|
||||
|
||||
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
|
||||
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
|
||||
MelonLoader, so everything is done in **one injected native library**
|
||||
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
|
||||
loader maps it before Unity, then it installs arm64 inline hooks at addresses
|
||||
resolved from the build's own `dump.cs`.
|
||||
|
||||
This build reports appVersion **`20230317`** — the *same* version the PC client
|
||||
reports — so a Quest player patched with this tool shares rooms, presence and
|
||||
Photon shards with PC DorkNet players with no server-side version change.
|
||||
|
||||
> This ships **no** Rec Room code or assets. You supply your own extracted store
|
||||
> APK and OBB. It disables the client's certificate pinning and image-signature
|
||||
> checks so a self-hosted server works; do not redistribute game binaries.
|
||||
|
||||
## What it does
|
||||
|
||||
| Hook | Effect | PC equivalent |
|
||||
| --- | --- | --- |
|
||||
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
|
||||
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
|
||||
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
|
||||
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
|
||||
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
|
||||
app you registered; the platform hooks stay disarmed. Players need a Meta
|
||||
account, and your package + signing cert must be registered with that app.
|
||||
- **Blank** — the *no-Meta-account build*: the platform path is steered past
|
||||
Meta's checks to Rec Room's own username/password screen. It authenticates
|
||||
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
|
||||
server treats platform id `1` (and `0`/blank) as a non-identity and keys
|
||||
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
|
||||
players are **not** merged into one account and are never offered each other's
|
||||
"remembered logins". **Never** add a server rule that accepts platform id `1`
|
||||
as an identity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
|
||||
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
|
||||
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
|
||||
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
|
||||
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
|
||||
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
|
||||
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
|
||||
`dump.cs` for the target APK (see below).
|
||||
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
|
||||
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;
|
||||
> keep the work dir outside protected folders.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
|
||||
dotnet Il2CppDumper.dll `
|
||||
<extracted>\lib\arm64-v8a\libil2cpp.so `
|
||||
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
|
||||
<scratch>\dump
|
||||
|
||||
# 2. Configure:
|
||||
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:
|
||||
$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
|
||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||
# -> %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:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
|
||||
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
|
||||
| `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>`.
|
||||
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
|
||||
`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
|
||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||
It is never committed.
|
||||
|
||||
### Rebasing onto a different build
|
||||
|
||||
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
|
||||
the PC build of the same release, so the PC decompile is the oracle. For a new
|
||||
build:
|
||||
|
||||
1. Dump it with Il2CppDumper.
|
||||
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
|
||||
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
|
||||
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
|
||||
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
|
||||
(cross-check against the PC decompile first).
|
||||
3. Commit the new `targets/<v>-<c>.json`.
|
||||
|
||||
The library refuses to arm any target whose 20-byte fingerprint does not match
|
||||
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||
|
||||
## Install
|
||||
|
||||
From a release folder, one command does everything (finds adb and the headset,
|
||||
installs the APK, copies the OBB to the right path, skips the OBB if it is
|
||||
already there, and launches the app):
|
||||
|
||||
```powershell
|
||||
.\install.cmd # Windows (or double-click it)
|
||||
./install.sh # macOS / Linux
|
||||
```
|
||||
|
||||
Both take an optional package path, `-Serial`/`--serial` for a specific headset
|
||||
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
|
||||
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
|
||||
Library > Unknown Sources.
|
||||
|
||||
By hand:
|
||||
|
||||
```sh
|
||||
PKG=net.dorknet.RecRoom # your PackageName
|
||||
VC=2128210
|
||||
adb uninstall $PKG 2>/dev/null
|
||||
adb install -r dorknet-recroom-20230317.apk
|
||||
adb shell mkdir -p /sdcard/Android/obb/$PKG
|
||||
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
|
||||
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
|
||||
```
|
||||
|
||||
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
|
||||
Unity rebuilds that exact path at runtime, so the source file's own name is
|
||||
irrelevant, only the destination is.
|
||||
|
||||
## Check it on the headset
|
||||
|
||||
**Wear the headset** — off-head it sleeps within seconds and never finishes
|
||||
init, which looks like a crash.
|
||||
|
||||
```sh
|
||||
adb logcat -c
|
||||
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
|
||||
adb logcat -d -s DORKNET
|
||||
```
|
||||
|
||||
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
|
||||
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
|
||||
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
|
||||
`photon appsettings: RT=... Voice=... Chat=...`.
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/dorknet_quest.cpp` | all hooks + init |
|
||||
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
|
||||
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
|
||||
| `targets/spec.json` | target signatures (class header + method), build-independent |
|
||||
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
|
||||
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
|
||||
| `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/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||
|
||||
## Credits
|
||||
|
||||
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
|
||||
showed the way this patcher is built: one native hook library loaded
|
||||
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
|
||||
hooking. They target a newer build (20230414), so every hook target here
|
||||
was re-derived from the 20230317 build; no RecFlare code is included.
|
||||
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
|
||||
vendored under `src/third_party/`.
|
||||
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
|
||||
@@ -0,0 +1,74 @@
|
||||
# DorkNet.Quest build configuration.
|
||||
#
|
||||
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
|
||||
# these into libdorknet.so at compile time. Omit a key to keep the built-in
|
||||
# default. This same file can be pushed to
|
||||
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
|
||||
# to override on-device without a rebuild.
|
||||
|
||||
# ---- deployment ----
|
||||
|
||||
# The apex your DorkNet server is reachable at. The client's service hosts are
|
||||
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
|
||||
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
|
||||
# then supplies every other service URL. Your DNS must resolve each subdomain
|
||||
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
|
||||
# them. No default -- required.
|
||||
#
|
||||
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
|
||||
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
|
||||
ServerHost = example.com
|
||||
|
||||
# Android package name for the patched build, so it coexists with the store
|
||||
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
|
||||
# package + signing cert, and the runtime-config path derives from it.
|
||||
PackageName = net.dorknet.RecRoom
|
||||
|
||||
# Name shown in the headset library (Unknown Sources). Build-time only.
|
||||
AppName = DorkNet
|
||||
|
||||
# ---- Meta (Oculus) platform ----
|
||||
|
||||
# Your registered Meta app id. Set it for the Meta build: real entitlement and
|
||||
# Meta login run against an app your players own. Leave BLANK for the
|
||||
# no-Meta-account build -- the platform entitlement/login path is steered to the
|
||||
# client's own username/password screen. Blank sends platform id 1 from every
|
||||
# headset; DorkNet's server treats that as a placeholder and keys accounts by
|
||||
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
|
||||
# merged. Never add a server rule that accepts platform id 1 as an identity.
|
||||
OculusAppId =
|
||||
|
||||
# ---- Photon ----
|
||||
|
||||
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
|
||||
# reject external clients. Leave an id blank to keep the client's own (dead)
|
||||
# value -- multiplayer will not connect until these are set. No defaults.
|
||||
PhotonAppId =
|
||||
PhotonVoiceAppId =
|
||||
PhotonChatAppId =
|
||||
|
||||
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
|
||||
PhotonNameServer =
|
||||
PhotonNameServerPort = 0
|
||||
|
||||
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
|
||||
# config), or Quest and PC players connect to different masters and never meet.
|
||||
PhotonRegion = us
|
||||
|
||||
# ---- client behaviour ----
|
||||
|
||||
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
|
||||
# Required for a self-hosted server; turn off only if you front it with a cert
|
||||
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
|
||||
EnableTlsTrustBypass = true
|
||||
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
# WARNING: Debug logs request/response BODIES, including auth tokens and
|
||||
# passwords. Leave off except when debugging, and don't share the logs.
|
||||
Debug = false
|
||||
LogHttpBodyMax = 10000
|
||||
@@ -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()
|
||||
Binary file not shown.
@@ -0,0 +1,88 @@
|
||||
// Compile-time configuration for libdorknet.so.
|
||||
//
|
||||
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
|
||||
// (put on the include path by the build), baking the operator's values into the
|
||||
// APK. Any key left out of the cfg falls back to the built-in default below.
|
||||
// The same cfg can also be pushed to
|
||||
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
|
||||
// to override at runtime without a rebuild (dn_load_runtime_config).
|
||||
#ifndef DORKNET_QUEST_CONFIG_H
|
||||
#define DORKNET_QUEST_CONFIG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include("config_generated.h")
|
||||
# include "config_generated.h"
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
|
||||
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
|
||||
// build with no server configured would do nothing useful.
|
||||
#ifndef DN_SERVER_HOST
|
||||
# define DN_SERVER_HOST ""
|
||||
#endif
|
||||
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
|
||||
// no-Meta-account build (platform entitlement/login are steered past).
|
||||
#ifndef DN_OCULUS_APPID
|
||||
# define DN_OCULUS_APPID ""
|
||||
#endif
|
||||
// Photon app ids: per-deployment, no default. Empty leaves the client's own
|
||||
// (dead) value in place, so multiplayer will not connect until set.
|
||||
#ifndef DN_PHOTON_REALTIME
|
||||
# define DN_PHOTON_REALTIME ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_VOICE
|
||||
# define DN_PHOTON_VOICE ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_CHAT
|
||||
# define DN_PHOTON_CHAT ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_SERVER
|
||||
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
|
||||
#endif
|
||||
#ifndef DN_PHOTON_PORT
|
||||
# define DN_PHOTON_PORT 0
|
||||
#endif
|
||||
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
|
||||
// PhotonCloudRegion), or Quest and PC players land on different masters.
|
||||
#ifndef DN_PHOTON_REGION
|
||||
# define DN_PHOTON_REGION "us"
|
||||
#endif
|
||||
#ifndef DN_TLS_TRUST_BYPASS
|
||||
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
|
||||
#endif
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
#ifndef DN_DEBUG_HTTP
|
||||
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
|
||||
#endif
|
||||
#ifndef DN_LOG_BODY_MAX
|
||||
# define DN_LOG_BODY_MAX 10000
|
||||
#endif
|
||||
|
||||
#ifndef DN_RUNTIME_CFG_NAME
|
||||
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
|
||||
#endif
|
||||
// Package the runtime cfg is read from; the build's gen step overrides this.
|
||||
#ifndef DN_PACKAGE
|
||||
# define DN_PACKAGE "net.dorknet.RecRoom"
|
||||
#endif
|
||||
|
||||
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
|
||||
// baked defaults. Each out pointer already holds the compiled-in value.
|
||||
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *oculus_appid, size_t oculus_appid_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
@@ -0,0 +1,613 @@
|
||||
// DorkNet.Quest native hook library (libdorknet.so).
|
||||
//
|
||||
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
|
||||
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
|
||||
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
|
||||
// baked into targets_generated.h at compile time. It is the Quest counterpart
|
||||
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
|
||||
//
|
||||
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
|
||||
// response then supplies every other service URL) -- like ClientMod's
|
||||
// UriPatches,
|
||||
// * inject the operator's Photon app ids -- like the PC byte patch
|
||||
// tools/patch-client.ps1,
|
||||
// * optionally set the operator's Meta (Oculus) app id,
|
||||
// * accept the self-hosted TLS chain and the image signature the operator's
|
||||
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
|
||||
// * neutralise the integrity callbacks that would reject a re-signed,
|
||||
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
|
||||
// * when no Meta app id is set, steer the Oculus platform path past its
|
||||
// entitlement/login checks to the client's own credential screen.
|
||||
//
|
||||
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
|
||||
// table before it is hooked, so the wrong build is refused rather than
|
||||
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
|
||||
// but shared with the PC build of the same release; this build (20230317 /
|
||||
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
|
||||
//
|
||||
// No Rec Room code or assets are included. Third-party inline hooker in
|
||||
// src/third_party/And64InlineHook (MIT, (c) Rprop).
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/And64InlineHook/And64InlineHook.hpp"
|
||||
#include "config.h"
|
||||
#include "targets_generated.h"
|
||||
|
||||
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
|
||||
|
||||
// --------------------------------------------------------------- config -----
|
||||
|
||||
static const char *kOfficialSuffix = ".rec.net";
|
||||
|
||||
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
|
||||
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
|
||||
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
|
||||
static char g_photon_voice[64] = DN_PHOTON_VOICE;
|
||||
static char g_photon_chat[64] = DN_PHOTON_CHAT;
|
||||
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
|
||||
static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
|
||||
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
|
||||
static const size_t kAppIdRealtimeOff = 0x10;
|
||||
static const size_t kAppIdChatOff = 0x18;
|
||||
static const size_t kAppIdVoiceOff = 0x20;
|
||||
static const size_t kUseNameServerOff = 0x30; // bool
|
||||
static const size_t kFixedRegionOff = 0x38;
|
||||
static const size_t kServerOff = 0x48;
|
||||
static const size_t kPortOff = 0x50; // int
|
||||
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
|
||||
|
||||
// HTTPRequest / HTTPResponse field offsets (dump.cs).
|
||||
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
|
||||
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
|
||||
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
|
||||
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
|
||||
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
|
||||
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
|
||||
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
|
||||
|
||||
// Il2CppArray data layout on 64-bit.
|
||||
static const size_t kArrayLengthOff = 0x18;
|
||||
static const size_t kArrayDataOff = 0x20;
|
||||
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
static const size_t kErrorStateCodeOff = 0x4;
|
||||
|
||||
// --------------------------------------------------------------- il2cpp ------
|
||||
|
||||
typedef void *(*il2cpp_object_get_class_t)(void *obj);
|
||||
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
|
||||
static void str_to_utf8(void *s, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!s || cap == 0) return;
|
||||
int32_t len = *(int32_t *)((char *)s + 0x10);
|
||||
if (len < 0) return;
|
||||
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
|
||||
size_t i = 0;
|
||||
for (; i < (size_t)len && i + 1 < cap; i++)
|
||||
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
|
||||
*len = 0;
|
||||
if (!obj) return nullptr;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return nullptr;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return nullptr;
|
||||
void *exc = nullptr;
|
||||
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (!arr || exc) return nullptr;
|
||||
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return;
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (s && !exc) str_to_utf8(s, out, cap);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- logging ----
|
||||
|
||||
static void log_chunked(const char *prefix, const char *text) {
|
||||
size_t n = strlen(text);
|
||||
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
|
||||
char buf[kLogChunk + 1];
|
||||
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
|
||||
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
|
||||
memcpy(buf, text + off, take);
|
||||
buf[take] = '\0';
|
||||
LOGI("%s[%zu] %s", prefix, part, buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool looks_binary(const unsigned char *d, size_t n) {
|
||||
if (n == 0) return false;
|
||||
size_t sample = n < 4096 ? n : 4096, nontext = 0;
|
||||
for (size_t i = 0; i < sample; i++) {
|
||||
unsigned char b = d[i];
|
||||
if (b == 0) return true;
|
||||
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
|
||||
}
|
||||
return nontext * 100 / sample > 10;
|
||||
}
|
||||
|
||||
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
|
||||
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
|
||||
size_t limit = (size_t)g_body_max;
|
||||
if (limit > cap - 64) limit = cap - 64;
|
||||
if (!looks_binary(d, n)) {
|
||||
size_t take = n < limit ? n : limit;
|
||||
memcpy(out, d, take);
|
||||
out[take] = '\0';
|
||||
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
|
||||
return;
|
||||
}
|
||||
int w = snprintf(out, cap, "<binary %zu bytes>", n);
|
||||
out[w < 0 ? 0 : (size_t)w] = '\0';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- hooks -------
|
||||
|
||||
static void *(*orig_SendRequest)(void *request);
|
||||
|
||||
static void *hk_SendRequest(void *request) {
|
||||
if (!request) return orig_SendRequest(request);
|
||||
void *uri = *(void **)((char *)request + kUriFieldOffset);
|
||||
if (!uri) return orig_SendRequest(request);
|
||||
void *uri_class = il2cpp_object_get_class(uri);
|
||||
if (!uri_class) return orig_SendRequest(request);
|
||||
|
||||
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
|
||||
if (!m_abs) return orig_SendRequest(request);
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
|
||||
if (!s || exc) return orig_SendRequest(request);
|
||||
|
||||
char url[2048];
|
||||
str_to_utf8(s, url, sizeof(url));
|
||||
|
||||
if (g_log_http) {
|
||||
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
|
||||
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
|
||||
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
|
||||
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
|
||||
LOGI("[http] %s %s", mname, url);
|
||||
if (g_debug_http) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
|
||||
if (body && blen) {
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
|
||||
const char *scheme_end = strstr(url, "://");
|
||||
if (!scheme_end) return orig_SendRequest(request);
|
||||
const char *host = scheme_end + 3;
|
||||
const char *host_end = host;
|
||||
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
|
||||
size_t host_len = (size_t)(host_end - host);
|
||||
size_t suf = strlen(kOfficialSuffix);
|
||||
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
|
||||
return orig_SendRequest(request);
|
||||
|
||||
char newurl[1024];
|
||||
size_t head = (size_t)(host - url); // "https://"
|
||||
size_t service_len = host_len - suf; // "api", "auth", ...
|
||||
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
|
||||
// template with "{service}" for servers on DorkNet's hyphen host style
|
||||
// ("{service}-dev.example.com" -> "ns-dev.example.com").
|
||||
static const char kServiceToken[] = "{service}";
|
||||
const char *token = strstr(g_server_host, kServiceToken);
|
||||
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
|
||||
: service_len + 1 + strlen(g_server_host);
|
||||
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
|
||||
return orig_SendRequest(request);
|
||||
memcpy(newurl, url, head);
|
||||
size_t p = head;
|
||||
if (token) {
|
||||
size_t before = (size_t)(token - g_server_host);
|
||||
memcpy(newurl + p, g_server_host, before);
|
||||
p += before;
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
|
||||
} else {
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
newurl[p++] = '.';
|
||||
strcpy(newurl + p, g_server_host);
|
||||
}
|
||||
strcat(newurl + head, host_end);
|
||||
|
||||
void *new_str = il2cpp_string_new(newurl);
|
||||
void *new_uri = il2cpp_object_new(uri_class);
|
||||
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
|
||||
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
|
||||
void *args[1] = {new_str};
|
||||
exc = nullptr;
|
||||
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
|
||||
if (exc) return orig_SendRequest(request);
|
||||
*(void **)((char *)request + kUriFieldOffset) = new_uri;
|
||||
LOGI("intercepted %s -> %s", url, newurl);
|
||||
return orig_SendRequest(request);
|
||||
}
|
||||
|
||||
static void (*orig_CallCallback)(void *thiz, void *method);
|
||||
static void hk_CallCallback(void *thiz, void *method) {
|
||||
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
|
||||
char url[2048];
|
||||
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
|
||||
int state = *(int *)((char *)thiz + kReqStateOffset);
|
||||
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
|
||||
if (!resp) {
|
||||
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
|
||||
if (ex) {
|
||||
char msg[900];
|
||||
invoke_str(ex, "ToString", msg, sizeof(msg));
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
|
||||
} else {
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
|
||||
}
|
||||
} else {
|
||||
int status = *(int *)((char *)resp + kRespStatusOffset);
|
||||
LOGI("[http<-] %d %s", status, url);
|
||||
if (g_debug_http || status < 200 || status >= 300) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
orig_CallCallback(thiz, method);
|
||||
}
|
||||
|
||||
static void *hk_get_AppID(void *method) {
|
||||
(void)method;
|
||||
return il2cpp_string_new(g_oculus_appid);
|
||||
}
|
||||
|
||||
static void *(*orig_Photon)(void *thiz, void *method);
|
||||
static void *hk_Photon(void *thiz, void *method) {
|
||||
void *s = orig_Photon(thiz, method);
|
||||
if (s) {
|
||||
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
|
||||
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
|
||||
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
|
||||
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
|
||||
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
|
||||
*(int *)((char *)s + kProtocolOff) = 0;
|
||||
if (g_photon_server[0]) {
|
||||
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
|
||||
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
|
||||
}
|
||||
static int logged = 0;
|
||||
if (logged < 2) {
|
||||
logged++;
|
||||
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
|
||||
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
|
||||
g_photon_voice[0] ? g_photon_voice : "(unset)",
|
||||
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
|
||||
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
|
||||
if (!g_photon_realtime[0])
|
||||
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
|
||||
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
|
||||
uint32_t r = orig_UnityTls(thiz, chain, es, method);
|
||||
if (r != kUnityTlsSuccess) {
|
||||
static int logged = 0;
|
||||
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
|
||||
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
|
||||
}
|
||||
return kUnityTlsSuccess;
|
||||
}
|
||||
|
||||
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
|
||||
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
|
||||
(void)thiz; (void)cert; (void)method;
|
||||
}
|
||||
|
||||
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
|
||||
// report is ever built and the client never quits).
|
||||
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
|
||||
static bool hk_image_true(void *data, void *sig, void *method) {
|
||||
(void)data; (void)sig; (void)method;
|
||||
return true;
|
||||
}
|
||||
|
||||
// void callbacks the client runs on an integrity rejection -- no-op them so a
|
||||
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
|
||||
// on PC). The quit funnels and the report are left to run but are logged.
|
||||
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
|
||||
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
|
||||
|
||||
static bool hk_toxmod_false(void *method) { (void)method; return false; }
|
||||
|
||||
static const char *cheat_reason(int r) {
|
||||
switch (r) {
|
||||
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
|
||||
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
|
||||
case 6: return "ImageSignature"; case 7: return "AvatarHack";
|
||||
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
|
||||
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
|
||||
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
|
||||
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
|
||||
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
|
||||
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
|
||||
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
|
||||
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
|
||||
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
|
||||
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void log_caller(const char *what, void *ret) {
|
||||
uintptr_t a = (uintptr_t)ret;
|
||||
if (g_il2cpp_base && a > g_il2cpp_base)
|
||||
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
|
||||
else
|
||||
LOGI("[caller] %s <- %p", what, ret);
|
||||
}
|
||||
|
||||
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
|
||||
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
|
||||
char text[512];
|
||||
str_to_utf8(msg, text, sizeof(text));
|
||||
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
|
||||
log_caller("report", __builtin_return_address(0));
|
||||
return orig_CheatReport(reason, msg, code, method);
|
||||
}
|
||||
|
||||
static void (*orig_FatalQuit)(int code, void *reason, void *method);
|
||||
static void hk_FatalQuit(int code, void *reason, void *method) {
|
||||
char msg[512];
|
||||
str_to_utf8(reason, msg, sizeof(msg));
|
||||
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
|
||||
log_caller("fatal quit", __builtin_return_address(0));
|
||||
orig_FatalQuit(code, reason, method);
|
||||
}
|
||||
|
||||
static void (*orig_TryQuit)(int code, void *method);
|
||||
static void hk_TryQuit(int code, void *method) {
|
||||
LOGI("[exit] TryApplicationQuit(%d)", code);
|
||||
log_caller("try quit", __builtin_return_address(0));
|
||||
orig_TryQuit(code, method);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- oculus platform -----
|
||||
|
||||
typedef bool (*ovr_IsError_t)(void *m);
|
||||
typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
return p ? p : (void *)0x1;
|
||||
}
|
||||
|
||||
static const char *(*orig_ovr_GetNonce)(void *proof);
|
||||
static const char *hk_ovr_GetNonce(void *proof) {
|
||||
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
|
||||
const char *n = orig_ovr_GetNonce(proof);
|
||||
if (n && n[0]) return n;
|
||||
}
|
||||
return kPlaceholderNonce;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
|
||||
static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
FILE *f = fopen("/proc/self/maps", "r");
|
||||
if (!f) return 0;
|
||||
char line[512];
|
||||
uintptr_t base = 0;
|
||||
while (fgets(line, sizeof(line), f))
|
||||
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
|
||||
fclose(f);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a(const unsigned char *d, size_t n) {
|
||||
uint64_t h = 0xCBF29CE484222325ULL;
|
||||
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
|
||||
return h;
|
||||
}
|
||||
|
||||
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
|
||||
static bool arm(const DnTarget *t, void *hook, void **orig) {
|
||||
void *addr = (void *)(g_il2cpp_base + t->rva);
|
||||
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
|
||||
if (fp != t->fingerprint) {
|
||||
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
|
||||
t->id, (unsigned long)t->rva, (unsigned long long)fp,
|
||||
(unsigned long long)t->fingerprint);
|
||||
return false;
|
||||
}
|
||||
void *dummy = nullptr;
|
||||
A64HookFunction(addr, hook, orig ? orig : &dummy);
|
||||
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool resolve_il2cpp(void *h) {
|
||||
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
|
||||
SYM(il2cpp_object_get_class)
|
||||
SYM(il2cpp_class_get_method_from_name)
|
||||
SYM(il2cpp_object_new)
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
return true;
|
||||
}
|
||||
|
||||
static void arm_oculus_platform_bypass() {
|
||||
void *ovr = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (ovr) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
|
||||
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
|
||||
void *isErr = dlsym(ovr, "ovr_Message_IsError");
|
||||
if (ovr_GetType && isErr) {
|
||||
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
|
||||
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
|
||||
} else {
|
||||
LOGE("ovr symbols missing; platform bypass not armed");
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
void *h = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (h) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
|
||||
g_il2cpp_base = module_base("libil2cpp.so");
|
||||
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
|
||||
if (!resolve_il2cpp(h)) return nullptr;
|
||||
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
|
||||
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
|
||||
if (g_log_http)
|
||||
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
|
||||
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
|
||||
|
||||
if (g_oculus_appid[0]) {
|
||||
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
|
||||
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
|
||||
LOGI("oculus app id override: %s", g_oculus_appid);
|
||||
}
|
||||
|
||||
if (g_tls_bypass) {
|
||||
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
|
||||
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
|
||||
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
|
||||
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
// Integrity callbacks that would reject a re-signed, hook-loaded client.
|
||||
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
|
||||
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
|
||||
|
||||
// Log-only diagnostics.
|
||||
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
|
||||
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
|
||||
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
|
||||
|
||||
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
|
||||
LOGI("DorkNet.Quest init complete");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void on_load() {
|
||||
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, init_thread, nullptr);
|
||||
pthread_detach(t);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#define __STDC_FORMAT_MACROS
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#if defined(__aarch64__)
|
||||
|
||||
#include "And64InlineHook.hpp"
|
||||
#define A64_MAX_INSTRUCTIONS 5
|
||||
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
|
||||
#define A64_NOP 0xd503201fu
|
||||
#define A64_JNIEXPORT __attribute__((visibility("default")))
|
||||
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
|
||||
#ifndef NDEBUG
|
||||
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
|
||||
#else
|
||||
# define A64_LOGI(...) ((void)0)
|
||||
#endif // NDEBUG
|
||||
typedef uint32_t *__restrict *__restrict instruction;
|
||||
struct context
|
||||
{
|
||||
struct fix_info
|
||||
{
|
||||
uint32_t *bp;
|
||||
uint32_t ls; // left-shift counts
|
||||
uint32_t ad; // & operand
|
||||
};
|
||||
struct insns_info
|
||||
{
|
||||
union
|
||||
{
|
||||
uint64_t insu;
|
||||
int64_t ins;
|
||||
void *insp;
|
||||
};
|
||||
fix_info fmap[A64_MAX_REFERENCES];
|
||||
};
|
||||
int64_t basep;
|
||||
int64_t endp;
|
||||
insns_info dat[A64_MAX_INSTRUCTIONS];
|
||||
|
||||
public:
|
||||
inline bool is_in_fixing_range(const int64_t absolute_addr) {
|
||||
return absolute_addr >= this->basep && absolute_addr < this->endp;
|
||||
}
|
||||
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
|
||||
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
|
||||
}
|
||||
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
|
||||
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
|
||||
this->dat[current_idx].insp = outp;
|
||||
return current_idx;
|
||||
}
|
||||
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
|
||||
this->dat[idx].insp = outp;
|
||||
}
|
||||
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) {
|
||||
f.bp = bp;
|
||||
f.ls = ls;
|
||||
f.ad = ad;
|
||||
return;
|
||||
} //if
|
||||
}
|
||||
// What? GGing..
|
||||
}
|
||||
void process_fix_map(const intptr_t idx) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) break;
|
||||
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
|
||||
f.bp = NULL;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
#define __intval(p) reinterpret_cast<intptr_t>(p)
|
||||
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
|
||||
#define __ptr(p) reinterpret_cast<void *>(p)
|
||||
#define __page_size 4096
|
||||
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
|
||||
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
|
||||
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
|
||||
#define __align_down(x, n) ((x) & -(n))
|
||||
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
|
||||
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
|
||||
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
|
||||
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
|
||||
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
|
||||
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
|
||||
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
|
||||
PROT_READ | PROT_WRITE | PROT_EXEC)
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t mbits = 6u;
|
||||
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
|
||||
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
const uint32_t opc = ins & mask;
|
||||
switch (opc) {
|
||||
case op_b:
|
||||
case op_bl:
|
||||
{
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// whether the branch should be converted to absolute jump
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
|
||||
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
|
||||
if (opc == op_b) {
|
||||
if (b_aligned != true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[1] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (b_aligned == true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000071u; // LDR X17, #12
|
||||
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
|
||||
(*outpp)[2] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 5;
|
||||
} //if
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = opc | (new_pc_offset & ~mask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
|
||||
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
|
||||
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
|
||||
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
uint32_t lmask = lmask01;
|
||||
if ((ins & mask0) != op_bc) {
|
||||
uint32_t opc = ins & mask1;
|
||||
if (opc != op_cbz && opc != op_cbnz) {
|
||||
opc = ins & mask2;
|
||||
if (opc != op_tbz && opc != op_tbnz) {
|
||||
return false;
|
||||
} //if
|
||||
lmask = lmask2;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
const uint32_t msb = __builtin_clz(~lmask);
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
|
||||
(*outpp)[1] = 0x14000005u; // B #0x14
|
||||
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[3] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 6;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
const uint32_t ins = *(*inpp);
|
||||
|
||||
// memory prefetch("prfm"), just skip it
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
|
||||
if ((ins & 0xff000000u) == 0xd8000000u) {
|
||||
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
|
||||
++(*inpp);
|
||||
return true;
|
||||
} //if
|
||||
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
|
||||
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
|
||||
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
|
||||
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
|
||||
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
|
||||
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
|
||||
|
||||
uint32_t mask = mask_ldr;
|
||||
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
|
||||
if ((ins & mask_ldr) != op_ldr) {
|
||||
mask = mask_ldrv;
|
||||
if (faligned != 7u)
|
||||
faligned = (ins & mask_31) ? 15u : 3u;
|
||||
if ((ins & mask_ldrv) != op_ldrv) {
|
||||
if ((ins & mask_ldrsw) != op_ldrsw) {
|
||||
return false;
|
||||
} //if
|
||||
mask = mask_ldrsw;
|
||||
faligned = 7u;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// special_fix_type may encounter issue when there are mixed data and code
|
||||
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
|
||||
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
|
||||
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
|
||||
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000001u + ns; // B #0xc
|
||||
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
|
||||
*outpp += 2 + ns;
|
||||
} else {
|
||||
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
|
||||
while ((new_pc_offset & faligned) != 0) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
// Load a PC-relative address into a register
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
|
||||
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
|
||||
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
|
||||
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
intptr_t current_idx;
|
||||
switch (ins & mask) {
|
||||
case op_adr:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
// the lsb_bytes will never be changed, so we can use lmask to keep it
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
case op_adrp:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
|
||||
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
|
||||
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
|
||||
if (ctxp->is_in_fixing_range(absolute_addr)) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
|
||||
if (ref_idx > current_idx) {
|
||||
// the bottom 12 bits of absolute_addr are masked out,
|
||||
// so ref_idx must be less than or equal to current_idx!
|
||||
A64_LOGE("ref_idx must be less than or equal to current_idx!");
|
||||
} //if
|
||||
|
||||
// *absolute_addr may be changed due to relocation fixing
|
||||
A64_LOGI("What is the correct way to fix this?");
|
||||
*(*outpp)++ = ins; // 0x90000000u;
|
||||
} else {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
|
||||
*outpp += 4;
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
ctxp->process_fix_map(current_idx);
|
||||
++(*inpp);
|
||||
return true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
|
||||
{
|
||||
context ctx;
|
||||
ctx.basep = reinterpret_cast<int64_t>(inp);
|
||||
ctx.endp = reinterpret_cast<int64_t>(inp + count);
|
||||
memset(ctx.dat, 0, sizeof(ctx.dat));
|
||||
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
|
||||
"please use A64_MAX_INSTRUCTIONS!");
|
||||
#ifndef NDEBUG
|
||||
if (count > A64_MAX_INSTRUCTIONS) {
|
||||
A64_LOGE("too many fixing instructions!");
|
||||
} //if
|
||||
#endif // NDEBUG
|
||||
|
||||
uint32_t *const outp_base = outp;
|
||||
|
||||
while (--count >= 0) {
|
||||
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
|
||||
|
||||
// without PC-relative offset
|
||||
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
|
||||
*(outp++) = *(inp++);
|
||||
}
|
||||
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
auto callback = reinterpret_cast<int64_t>(inp);
|
||||
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
|
||||
outp[0] = A64_NOP;
|
||||
++outp;
|
||||
} //if
|
||||
outp[0] = 0x58000051u; // LDR X17, #0x8
|
||||
outp[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(outp + 2) = callback;
|
||||
outp += 4;
|
||||
} else {
|
||||
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
|
||||
++outp;
|
||||
} //if
|
||||
|
||||
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
|
||||
__flush_cache(outp_base, total); // necessary
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
extern "C" {
|
||||
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
class A64HookInit
|
||||
{
|
||||
public:
|
||||
A64HookInit()
|
||||
{
|
||||
__make_rwx(__insns_pool, sizeof(__insns_pool));
|
||||
A64_LOGI("insns pool initialized.");
|
||||
}
|
||||
};
|
||||
static A64HookInit __init;
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static uint32_t *FastAllocateTrampoline()
|
||||
{
|
||||
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
|
||||
static volatile int32_t __index = -1;
|
||||
|
||||
int32_t i = __atomic_increase(&__index);
|
||||
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
|
||||
return __insns_pool[i];
|
||||
} //if
|
||||
|
||||
A64_LOGE("failed to allocate trampoline!");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size)
|
||||
{
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
|
||||
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
|
||||
|
||||
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
|
||||
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >>1)) {
|
||||
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
|
||||
if (trampoline) {
|
||||
if (rwx_size < count * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, count, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
|
||||
if (count == 5) {
|
||||
original[0] = A64_NOP;
|
||||
++original;
|
||||
} //if
|
||||
original[0] = 0x58000051u; // LDR X17, #0x8
|
||||
original[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
|
||||
__flush_cache(symbol, 5 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 5 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 5 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} else {
|
||||
if (trampoline) {
|
||||
if (rwx_size < 1u * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, 1, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
|
||||
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
|
||||
__flush_cache(symbol, 1 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 1 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 1 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
return trampoline;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
|
||||
{
|
||||
void *trampoline = NULL;
|
||||
if (result != NULL) {
|
||||
trampoline = FastAllocateTrampoline();
|
||||
*result = trampoline;
|
||||
if (trampoline == NULL) return;
|
||||
} //if
|
||||
|
||||
// fix Android 10 .text segment is read-only by default
|
||||
__make_rwx(symbol, 5 * sizeof(size_t));
|
||||
|
||||
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
|
||||
if (trampoline == NULL && result != NULL) {
|
||||
*result = NULL;
|
||||
} //if
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(__aarch64__)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#pragma once
|
||||
#define A64_MAX_BACKUPS 256
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void A64HookFunction(void *const symbol, void *const replace, void **result);
|
||||
void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 RLib
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"versionName": "20230317",
|
||||
"versionCode": "2128210",
|
||||
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
|
||||
"hookBytes": 20,
|
||||
"targets": {
|
||||
"http_send_request": {
|
||||
"rva": 108025384,
|
||||
"length": 456,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
"http_call_callback": {
|
||||
"rva": 108061608,
|
||||
"length": 424,
|
||||
"fingerprint": "0x98ed61ac83527269",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
"oculus_get_app_id": {
|
||||
"rva": 166405008,
|
||||
"length": 32,
|
||||
"fingerprint": "0xaa5a50a8d4246c46",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
"oculus_get_mobile_app_id": {
|
||||
"rva": 166380836,
|
||||
"length": 32,
|
||||
"fingerprint": "0x5f5d3bc64dedb8de",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
"photon_app_settings": {
|
||||
"rva": 69268008,
|
||||
"length": 196,
|
||||
"fingerprint": "0xc8050d166579e43b",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
"tls_unity_verify": {
|
||||
"rva": 159789160,
|
||||
"length": 884,
|
||||
"fingerprint": "0xf8d4a696f15e6f0e",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
"tls_bc_notify_server_certificate": {
|
||||
"rva": 103301824,
|
||||
"length": 284,
|
||||
"fingerprint": "0x7f061bfc843e881f",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
"pin_nameserver": {
|
||||
"rva": 96989180,
|
||||
"length": 332,
|
||||
"fingerprint": "0x659c928fe96e1008",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
"pin_https": {
|
||||
"rva": 103249928,
|
||||
"length": 1032,
|
||||
"fingerprint": "0x943a238f04d54362",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
"pin_bc_verifier": {
|
||||
"rva": 82659376,
|
||||
"length": 1128,
|
||||
"fingerprint": "0x60415d1d29a7315d",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
"image_signature_verify": {
|
||||
"rva": 97302848,
|
||||
"length": 732,
|
||||
"fingerprint": "0xe1549ef6f4691a33",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
"hile_warning_callback": {
|
||||
"rva": 75750004,
|
||||
"length": 352,
|
||||
"fingerprint": "0xccf510feeada40d9",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
"unknown_dll_callback": {
|
||||
"rva": 57998052,
|
||||
"length": 112,
|
||||
"fingerprint": "0x84e1fed3a70e7560",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
"file_hash_callback": {
|
||||
"rva": 52872472,
|
||||
"length": 1052,
|
||||
"fingerprint": "0x03a8b6f1b373e600",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
"toxmod_can_initialize": {
|
||||
"rva": 55928080,
|
||||
"length": 1040,
|
||||
"fingerprint": "0xf72dd6f5dbb16216",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
"cheat_report": {
|
||||
"rva": 106127656,
|
||||
"length": 2248,
|
||||
"fingerprint": "0x9c1e0ef53e5a3ec8",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
"fatal_application_quit": {
|
||||
"rva": 55748824,
|
||||
"length": 348,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
"try_application_quit": {
|
||||
"rva": 55748412,
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Hook targets, identified by the class header and method signature exactly as",
|
||||
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
|
||||
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
|
||||
"release and shared with the PC build of the same release; when rebasing, re-check",
|
||||
"each one against the PC decompile and update the signature here."
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"id": "http_send_request",
|
||||
"class": "public static class HTTPManager ",
|
||||
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
{
|
||||
"id": "http_call_callback",
|
||||
"class": "public sealed class HTTPRequest ",
|
||||
"method": "internal void CallCallback()",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_AppID()",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_mobile_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_MobileAppID()",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
{
|
||||
"id": "photon_app_settings",
|
||||
"class": "internal class NFOMFIAOKFO ",
|
||||
"method": "public AppSettings LBKJOHGFHOO()",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
{
|
||||
"id": "tls_unity_verify",
|
||||
"class": "internal class UnityTlsContext ",
|
||||
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "tls_bc_notify_server_certificate",
|
||||
"class": "public class LegacyTlsAuthentication ",
|
||||
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "pin_nameserver",
|
||||
"class": "public class PAPLNIPKAMG ",
|
||||
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
{
|
||||
"id": "pin_https",
|
||||
"class": "public static class LJEHIHEHMCN ",
|
||||
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "pin_bc_verifier",
|
||||
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
|
||||
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "image_signature_verify",
|
||||
"class": "public class KLJOGJHBONK ",
|
||||
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
{
|
||||
"id": "hile_warning_callback",
|
||||
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
|
||||
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "unknown_dll_callback",
|
||||
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
|
||||
"method": "internal void <OnUnknownDLLDetected>b__0()",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
{
|
||||
"id": "file_hash_callback",
|
||||
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
|
||||
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "toxmod_can_initialize",
|
||||
"class": "public class ToxModVoiceComponent ",
|
||||
"method": "public static bool CanInitializeToxMod()",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "cheat_report",
|
||||
"class": "public class FPIBGPIAOBI ",
|
||||
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
{
|
||||
"id": "fatal_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
{
|
||||
"id": "try_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A Quest client patched without its own Meta app has no platform session,
|
||||
/// so every headset reports the same placeholder platform user id ("1").
|
||||
/// Accounts must stay keyed by device id: two headsets get two accounts, a
|
||||
/// headset gets its own account back on the next boot, and the placeholder is
|
||||
/// never stored or matched by the remembered-logins lookup — otherwise every
|
||||
/// Quest player would be offered every other Quest player's account.
|
||||
/// </summary>
|
||||
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// RecNet platform value the 2023 Quest client sends on /connect/token.
|
||||
// The fix does not depend on it; any platform with a placeholder id is
|
||||
// handled the same way.
|
||||
private const string QuestPlatform = "1";
|
||||
private const string PlaceholderPlatformId = "1";
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("1", true)]
|
||||
[InlineData(" 1 ", true)]
|
||||
[InlineData("10", false)]
|
||||
[InlineData("2173678582678296", false)]
|
||||
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
|
||||
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
|
||||
|
||||
[Fact]
|
||||
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
|
||||
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
|
||||
|
||||
var a = await CreateAccountAsync(client, headsetA);
|
||||
var b = await CreateAccountAsync(client, headsetB);
|
||||
Assert.NotEqual(a, b);
|
||||
|
||||
// Same headset, next boot: its own account comes back.
|
||||
var aAgain = await CreateAccountAsync(client, headsetA);
|
||||
Assert.Equal(a, aAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Placeholder_platform_id_never_lists_remembered_logins()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
Assert.Equal(0, json.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_platform_id_still_lists_its_remembered_login()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
|
||||
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
Assert.Contains(id.ToString(), body);
|
||||
}
|
||||
|
||||
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
|
||||
// client_id, platform, platform_id, device_id, ...). No username/password,
|
||||
// so the server takes the device-id path.
|
||||
private async Task<long> CreateAccountAsync(
|
||||
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
|
||||
{
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "create_account",
|
||||
["client_id"] = "recroom",
|
||||
["platform"] = QuestPlatform,
|
||||
["platform_id"] = platformId,
|
||||
["device_id"] = deviceId,
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(form),
|
||||
};
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var userInfoResponse = await client.SendAsync(userInfo);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
|
||||
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -423,6 +423,38 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
outfitsFixed, inventoryFixed, colorsFixed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for a platform id that identifies nobody: blank, "0" (no platform
|
||||
/// session) or "1". A Quest client patched without its own Meta app
|
||||
/// (DorkNet.Quest, no-Meta mode) has no platform session, so it reports
|
||||
/// the same placeholder user id "1" from every headset. Treating that as a
|
||||
/// real id would list every Quest player's account in every other Quest
|
||||
/// player's remembered-logins prompt and, with no device id, merge them
|
||||
/// into one synthetic account. Placeholders are never stored on an
|
||||
/// account, never matched by the cached-login lookup and never used to
|
||||
/// synthesise a device id — those players are keyed by deviceId alone.
|
||||
/// </summary>
|
||||
public static bool IsPlaceholderPlatformId(string? platformId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformId)) return true;
|
||||
var trimmed = platformId.Trim();
|
||||
return trimmed == "0" || trimmed == "1";
|
||||
}
|
||||
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up an existing account by deviceId, or create a new one if no
|
||||
/// account is bound to that device yet. This is the canonical
|
||||
@@ -440,26 +472,13 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// - When deviceId is empty (rare paths), fall back to a generated
|
||||
/// synthetic id so we never create accidentally-shared accounts.
|
||||
/// </summary>
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
public async Task<PlayerEntity> GetOrCreateByDeviceAsync(
|
||||
string? deviceId,
|
||||
int platform = 0,
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
// Synthesize a stable id from the platformId or generate one so
|
||||
@@ -559,6 +578,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
// Synthesise deviceId / platformId for empty inputs so the
|
||||
// audit trail still lands somewhere meaningful.
|
||||
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
@@ -646,7 +666,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
if (player is null) return;
|
||||
player.LastSeenAt = DateTime.UtcNow;
|
||||
if (platform != 0) player.LastPlatform = platform;
|
||||
if (!string.IsNullOrWhiteSpace(platformId)) player.LastPlatformId = platformId;
|
||||
if (!IsPlaceholderPlatformId(platformId)) player.LastPlatformId = platformId!;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -663,7 +683,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// </summary>
|
||||
public async Task<List<PlayerEntity>> GetCachedLoginsAsync(int platform, string? platformId, int take = 10)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformId))
|
||||
if (IsPlaceholderPlatformId(platformId))
|
||||
return [];
|
||||
return await db.Players
|
||||
.Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId)
|
||||
|
||||
@@ -37,3 +37,21 @@ covered by AGPL §13 — you must:
|
||||
changes you have made relative to the upstream version.
|
||||
|
||||
The full text of the AGPL-3.0 license is in LICENSE.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Third-party components
|
||||
|
||||
DorkNet.Quest vendors And64InlineHook (src/third_party/And64InlineHook/),
|
||||
an arm64 inline-hook helper by Rprop, under the MIT License. Its license
|
||||
text is kept alongside the sources at
|
||||
DorkNet.Quest/src/third_party/And64InlineHook/LICENSE.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Acknowledgements
|
||||
|
||||
RecFlare (https://github.com/recflare, by djdevin) is an independent
|
||||
open-source 2023 Rec Room server and Quest client. DorkNet.Quest follows
|
||||
its native-hook loading approach, and its implementation served as a
|
||||
cross-check for several 2023 wire contracts. No RecFlare code is included.
|
||||
|
||||
@@ -153,10 +153,25 @@ calls so self-signed certs work.
|
||||
|
||||
### Quest
|
||||
|
||||
A Quest client for this branch is in progress and not usable yet. The
|
||||
archive holds a Quest build of the same release (version `20230317`), so
|
||||
Quest and PC players will be on matching versions once it lands. Until
|
||||
then, this branch supports the PC client only.
|
||||
`DorkNet.Quest` patches the Rec Room 2023.03.21 Quest client (versionCode
|
||||
`2128210`) for sideloading onto a standalone Meta Quest. That build reports
|
||||
`appVersion` `20230317`, the same version as the PC client, so Quest and PC
|
||||
players can share rooms.
|
||||
|
||||
The patcher injects a single native hook library into the APK. The library
|
||||
redirects the Rec Room name server to your DorkNet host, supplies your
|
||||
Photon AppIds, and turns off the client's certificate pinning and
|
||||
image-signature checks so a self-hosted server works. A build without a
|
||||
Meta app ID lets players sign in with DorkNet credentials and no Meta
|
||||
account. Each hook checks a fingerprint of its target first, so the patch
|
||||
refuses to apply to any other build.
|
||||
|
||||
You supply your own extracted APK and OBB; no game binaries are
|
||||
redistributed. Build, install and troubleshooting steps are in
|
||||
[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view
|
||||
is in [docs/quest.md](docs/quest.md).
|
||||
|
||||
> The Quest build is new and has not yet been tested on a headset.
|
||||
|
||||
### Debugging (opt-in — `DorkNet.DebugMod`)
|
||||
|
||||
@@ -478,6 +493,31 @@ it across. Don't rebase across branches.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Thanks to [RecFlare](https://github.com/recflare), an independent
|
||||
open-source 2023 Rec Room server by djdevin, for sharing their work in the
|
||||
open. Their server and Quest client were a valuable second opinion while
|
||||
building this branch:
|
||||
|
||||
- **Quest:** `DorkNet.Quest` follows their approach of a single native hook
|
||||
library loaded through a `DT_NEEDED` entry on `libmain.so`. Their notes
|
||||
also showed which client checks a self-hosted server has to get past.
|
||||
- **Wire contracts:** their implementation pointed us at several things we
|
||||
then confirmed in our own client disassembly. These include the profile
|
||||
edit form fields, the saved-outfit keys, the `{Success, Error}` envelope
|
||||
behind `isCreationAllowedForAccount`, and the gameconfig catalogue,
|
||||
including the `PlayMenuCarouselOrdering_*` format.
|
||||
- **Accounts:** their warning that a Quest client without a Meta account
|
||||
sends the same platform id from every headset led to the placeholder
|
||||
platform-id handling in `PlayerService`.
|
||||
|
||||
No RecFlare code is included in DorkNet. RecFlare targets a newer client
|
||||
(20230414), so every finding was re-verified against the 2023.03.21 build
|
||||
before use.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE). Hosted forks must publish their source.
|
||||
|
||||
@@ -50,6 +50,7 @@ DorkNet.sln
|
||||
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 |
|
||||
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
|
||||
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
||||
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
|
||||
|
||||
`ClientMod` applies the client-side patches needed to point the 2020
|
||||
watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex),
|
||||
@@ -59,6 +60,13 @@ MelonLoader 0.6.x and reads its settings from a JSON config under
|
||||
same config: Desktop Screen-Share FPS, RRO quest party size, and Rec
|
||||
Royale night mode (see the README and `recroom-2023-scene-moods.md`).
|
||||
|
||||
`DorkNet.Quest` does the same jobs for the Meta Quest (Android/IL2CPP)
|
||||
2023.03.21 client, where there is no MelonLoader: one native library
|
||||
(`libdorknet.so`) is injected via a `DT_NEEDED` entry on `libmain.so`
|
||||
and installs arm64 inline hooks (host rewrite, Photon ids, TLS/pin and
|
||||
image-signature bypass, integrity-callback neutralisation). See
|
||||
[`quest.md`](quest.md).
|
||||
|
||||
---
|
||||
|
||||
## Server folder layout
|
||||
|
||||
@@ -400,6 +400,18 @@ means losing post-cutover writes unless you migrate them back.
|
||||
|
||||
---
|
||||
|
||||
## Meta Quest client
|
||||
|
||||
DorkNet also serves a sideload-patched Rec Room 2023.03.21 **Quest** client
|
||||
(versionCode 2128210, appVersion `20230317` — the same version the PC client
|
||||
reports, so Quest and PC players share rooms). The same DNS/TLS/Photon/image-
|
||||
signing setup here serves it; the only Quest-specific server behaviour is the
|
||||
placeholder-platform-id handling in `PlayerService`. See [`quest.md`](quest.md)
|
||||
for the operator view and [`../DorkNet.Quest/README.md`](../DorkNet.Quest/README.md)
|
||||
for building and installing the patched APK.
|
||||
|
||||
---
|
||||
|
||||
## Photon Cloud
|
||||
|
||||
Use your own Photon AppIds. Rec Room's original Photon AppIds reject
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Meta Quest client
|
||||
|
||||
DorkNet can be played on a standalone Meta Quest headset using a
|
||||
sideload-patched Rec Room **2023.03.21** client. The patcher and the full build
|
||||
and install guide live in [`../DorkNet.Quest/`](../DorkNet.Quest/README.md);
|
||||
this page is the server operator's view.
|
||||
|
||||
## Which build
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Rec Room version | `2023.03.21` (versionName `20230317`, versionCode `2128210`) |
|
||||
| Package (store) | `com.AgainstGravity.RecRoom`, arm64-v8a, IL2CPP |
|
||||
| Ships | single APK + a ~1.4 GB OBB |
|
||||
|
||||
This build reports appVersion **`20230317`** — identical to the PC
|
||||
`march-2023-03-21` client. The server hardcodes `"20230317"` in presence /
|
||||
match / notification responses, so **Quest and PC players interoperate with no
|
||||
server change**: the version gate (`GET api/versioncheck/v4`), the presence
|
||||
`appVersion` string, and Photon room versioning all line up. Do **not** patch a
|
||||
different Quest build (e.g. `20230414`) against this branch — a different
|
||||
appVersion reads as "version mismatch, can't join".
|
||||
|
||||
## How it connects
|
||||
|
||||
There is no MelonLoader on Android, so `DorkNet.Quest` does the ClientMod +
|
||||
Photon-byte-patch jobs with one native library (`libdorknet.so`) injected via a
|
||||
`DT_NEEDED` entry on `libmain.so`. At runtime it:
|
||||
|
||||
- rewrites `<service>.rec.net` → `<service>.<ServerHost>` (for a server on
|
||||
`Domain:HostStyle = hyphen`, set `ServerHost = {service}-<env>.<apex>`) (the name server then
|
||||
hands out every other service URL — same bootstrap as the PC client);
|
||||
- injects the operator's Photon app ids and fixed region (`PhotonRegion`; it
|
||||
must match the PC clients' `PhotonCloudRegion` or the two never meet);
|
||||
- accepts the self-hosted TLS chain and image signatures;
|
||||
- neutralises the integrity callbacks that would otherwise reject a re-signed,
|
||||
hook-loaded sideload.
|
||||
|
||||
So the **same DNS, TLS, Photon and image-signing setup from
|
||||
[`deploy.md`](deploy.md) serves Quest** — every `*.<apex>` subdomain the PC
|
||||
client uses must resolve and be covered by the cert.
|
||||
|
||||
## Accounts without a Meta account
|
||||
|
||||
If the build is made with a blank `OculusAppId` (the no-Meta-account build), the
|
||||
client can't produce a real Oculus platform identity, so **every headset sends
|
||||
the placeholder platform user id `1`**. The server must not treat that as an
|
||||
identity, or all those players collapse into one shared account and are offered
|
||||
each other's "remembered logins".
|
||||
|
||||
DorkNet handles this server-side in `PlayerService`
|
||||
(`IsPlaceholderPlatformId`): platform ids `0`, blank and `1` are never stored on
|
||||
an account, never matched by the cached-login lookup
|
||||
(`cachedlogin/forplatformid`), and never used to synthesise a device id — those
|
||||
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
|
||||
A real Meta platform id (the Meta build) still tags and lists normally.
|
||||
|
||||
Thanks to [RecFlare](https://github.com/recflare) for documenting this
|
||||
pitfall in their Quest client notes.
|
||||
|
||||
> Covered by `DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs`. **Never
|
||||
> add a rule that accepts platform id `1` as a real identity** — the whole
|
||||
> no-Meta fleet sends the same value.
|
||||
|
||||
## Operator checklist
|
||||
|
||||
1. DNS + TLS for every `*.<apex>` service subdomain (see `deploy.md`).
|
||||
2. Your own Photon Realtime/Voice app ids (see `deploy.md` → Photon Cloud).
|
||||
3. Image signing key configured (`ImageSignatureService`), as for PC.
|
||||
4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing
|
||||
`-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus
|
||||
`install.cmd`/`install.sh`). Players run the installer with the headset
|
||||
plugged in; the app appears as **DorkNet** under Unknown Sources.
|
||||
|
||||
No server component is Quest-specific beyond the placeholder-platform-id
|
||||
handling above.
|
||||
Reference in New Issue
Block a user