Merge pull request #56 from DorkSquadRR/feat/cv2-network-limits

Free default avatar items + DorkNet.Quest client patcher
This commit is contained in:
Alexa
2026-10-15 20:33:18 +03:00
committed by GitHub
31 changed files with 3339 additions and 28 deletions
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
*.cmd text eol=crlf
+9
View File
@@ -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.
+215
View File
@@ -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).
+74
View File
@@ -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
+248
View File
@@ -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()
+43
View File
@@ -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
+145
View File
@@ -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
+170
View File
@@ -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
+174
View File
@@ -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.
+88
View File
@@ -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
+613
View File
@@ -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
+21
View File
@@ -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.
+116
View File
@@ -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."
}
}
}
+119
View File
@@ -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."
}
]
}
@@ -215,6 +215,42 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
} }
} }
// Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
[Fact]
public async Task Default_unlocked_items_are_owned_by_every_player()
{
// The client replaces its unlocked set with each list it downloads,
// so v4/items must carry the free default items or they show a price.
using var setup = ApiClient();
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
using var client = ApiClient(player);
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
// A default item the starter inventory also holds appears once.
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
Assert.Equal(ids.Count, ids.Distinct().Count());
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
Assert.Equal(0, locked.GetArrayLength());
}
[Fact]
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
{
using var anonymous = ApiClient();
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
Assert.Equal(
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
descs.Count);
}
private HttpClient ApiClient(GameClientSession? session = null) private HttpClient ApiClient(GameClientSession? session = null)
{ {
var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -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;
}
}
@@ -59,7 +59,15 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// makes every store item appear owned. We instead intersect the /// makes every store item appear owned. We instead intersect the
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master /// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns. /// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return []. ///
/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with
/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70,
/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that
/// field). It never merges in its own baked default list, so the server
/// must include the free starter items (avatar_item_lookup.json
/// <c>default_unlocked_variants</c>) in every response. Without them a
/// default item such as Beard Close showed a price in the outfit menu.
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class AvatarItemsController( public class AvatarItemsController(
@@ -110,7 +118,7 @@ public class AvatarItemsController(
return Ok(BuildEntireCatalogOwned(maxItems)); return Ok(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId(); var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>()); if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems));
var avatar = await db.Avatars var avatar = await db.Avatars
.Where(a => a.PlayerId == me) .Where(a => a.PlayerId == me)
@@ -126,7 +134,7 @@ public class AvatarItemsController(
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>()); if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems));
var catalog = _catalog.Value; var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
@@ -142,7 +150,10 @@ public class AvatarItemsController(
.ThenByDescending(x => x.OriginalIndex) .ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Entry); .Select(x => x.Entry);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); // Room is kept for the default-unlocked items so a large wardrobe
// can't push them out (they'd show as buyable again).
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count);
var emittedBase = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var perSlot = new Dictionary<int, int>(); var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems)); var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var entry in ordered) foreach (var entry in ordered)
@@ -162,6 +173,8 @@ public class AvatarItemsController(
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
perSlot[slot] = count + 1; perSlot[slot] = count + 1;
if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase))
emittedBase.Add(entry.BaseGuid);
// Preserve the bought color: a bare entry (FullDesc == BaseGuid) // Preserve the bought color: a bare entry (FullDesc == BaseGuid)
// emits the catalog DTO as-is ("{guid},,,"); a colored entry // emits the catalog DTO as-is ("{guid},,,"); a colored entry
// overrides AvatarItemDesc with the stored swatch/mask desc so // overrides AvatarItemDesc with the stored swatch/mask desc so
@@ -189,6 +202,12 @@ public class AvatarItemsController(
if (result.Count >= maxItems) break; if (result.Count >= maxItems) break;
} }
foreach (var guid in catalog.DefaultUnlocked)
{
if (result.Count >= maxItems) break;
if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]);
}
foreach (var slug in ownedHairDyes) foreach (var slug in ownedHairDyes)
{ {
if (result.Count >= maxItems) break; if (result.Count >= maxItems) break;
@@ -210,6 +229,14 @@ public class AvatarItemsController(
return Ok(result); return Ok(result);
} }
/// <summary>The free items every player owns
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
{
var catalog = _catalog.Value;
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
}
/// <summary>The full owned set when the "everyone owns all avatar /// <summary>The full owned set when the "everyone owns all avatar
/// items" toggle is on: every catalog item (already carrying the /// items" toggle is on: every catalog item (already carrying the
/// "{guid},,," desc) plus every permanent hair dye, capped at /// "{guid},,," desc) plus every permanent hair dye, capped at
@@ -295,6 +322,10 @@ public class AvatarItemsController(
/// data against the same safe list.</summary> /// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids; public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <summary>Base GUIDs of the free items every player owns
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
/// <summary>Master catalog of all renderable items, keyed by /// <summary>Master catalog of all renderable items, keyed by
/// GUID. Used by callers that need item metadata (slot type, /// GUID. Used by callers that need item metadata (slot type,
/// name, hair-color flags) outside of the per-player ownership /// name, hair-color flags) outside of the per-player ownership
@@ -332,6 +363,8 @@ public class AvatarItemsController(
foreach (var guid in ParseInventory(invJson)) foreach (var guid in ParseInventory(invJson))
owned.Add(guid); owned.Add(guid);
} }
// Free default items are owned by everyone, logged in or not.
owned.UnionWith(_catalog.Value.DefaultUnlocked);
// The client parses this as List<IFNGBPJGINL> — the SAME element as // The client parses this as List<IFNGBPJGINL> — the SAME element as
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by // v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
@@ -485,7 +518,8 @@ public class AvatarItemsController(
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items, IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids, IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot, IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> SlotByGuid); IReadOnlyDictionary<string, int> SlotByGuid,
IReadOnlyList<string> DefaultUnlocked);
private static CatalogData LoadCatalog() private static CatalogData LoadCatalog()
{ {
@@ -512,7 +546,8 @@ public class AvatarItemsController(
new Dictionary<string, UnlockedAvatarItemDto>(), new Dictionary<string, UnlockedAvatarItemDto>(),
new HashSet<string>(), new HashSet<string>(),
new Dictionary<int, List<string>>(), new Dictionary<int, List<string>>(),
new Dictionary<string, int>()); new Dictionary<string, int>(),
Array.Empty<string>());
using var fs = System.IO.File.OpenRead(path); using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs, var dump = JsonSerializer.Deserialize<LookupFile>(fs,
@@ -559,7 +594,16 @@ public class AvatarItemsController(
slotByGuid[row.Guid] = row.OutfitType; slotByGuid[row.Guid] = row.OutfitType;
} }
return new CatalogData(items, safe, bySlot, slotByGuid); // Default-unlocked entries are all plain base items (no swatch, mask
// or decal) in the March dump; only keep ones the catalog can render.
var defaults = dump.DefaultUnlockedVariants
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
.Select(v => v.Item)
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
} }
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned /// <summary>Stable, deterministic, non-zero 31-bit id for an owned
@@ -621,6 +665,19 @@ public class AvatarItemsController(
public List<string> SwatchGuids { get; set; } = new(); public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")] [JsonPropertyName("decal_guids")]
public List<string> DecalGuids { get; set; } = new(); public List<string> DecalGuids { get; set; } = new();
// The client config's defaultUnlockedVariants: free items every
// player owns.
[JsonPropertyName("default_unlocked_variants")]
public List<LookupVariant> DefaultUnlockedVariants { get; set; } = new();
}
private sealed class LookupVariant
{
[JsonPropertyName("item")] public string Item { get; set; } = string.Empty;
[JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty;
[JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty;
[JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty;
} }
private sealed class LookupItem private sealed class LookupItem
+36 -16
View File
@@ -423,6 +423,38 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
outfitsFixed, inventoryFixed, colorsFixed); 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> /// <summary>
/// Look up an existing account by deviceId, or create a new one if no /// 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 /// 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 /// - When deviceId is empty (rare paths), fall back to a generated
/// synthetic id so we never create accidentally-shared accounts. /// synthetic id so we never create accidentally-shared accounts.
/// </summary> /// </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( public async Task<PlayerEntity> GetOrCreateByDeviceAsync(
string? deviceId, string? deviceId,
int platform = 0, int platform = 0,
string? platformId = null, string? platformId = null,
string? displayName = null) string? displayName = null)
{ {
if (IsPlaceholderPlatformId(platformId)) platformId = null;
if (string.IsNullOrWhiteSpace(deviceId)) if (string.IsNullOrWhiteSpace(deviceId))
{ {
// Synthesize a stable id from the platformId or generate one so // 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? platformId = null,
string? displayName = null) string? displayName = null)
{ {
if (IsPlaceholderPlatformId(platformId)) platformId = null;
// Synthesise deviceId / platformId for empty inputs so the // Synthesise deviceId / platformId for empty inputs so the
// audit trail still lands somewhere meaningful. // audit trail still lands somewhere meaningful.
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId) var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
@@ -646,7 +666,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
if (player is null) return; if (player is null) return;
player.LastSeenAt = DateTime.UtcNow; player.LastSeenAt = DateTime.UtcNow;
if (platform != 0) player.LastPlatform = platform; if (platform != 0) player.LastPlatform = platform;
if (!string.IsNullOrWhiteSpace(platformId)) player.LastPlatformId = platformId; if (!IsPlaceholderPlatformId(platformId)) player.LastPlatformId = platformId!;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -663,7 +683,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
/// </summary> /// </summary>
public async Task<List<PlayerEntity>> GetCachedLoginsAsync(int platform, string? platformId, int take = 10) public async Task<List<PlayerEntity>> GetCachedLoginsAsync(int platform, string? platformId, int take = 10)
{ {
if (string.IsNullOrWhiteSpace(platformId)) if (IsPlaceholderPlatformId(platformId))
return []; return [];
return await db.Players return await db.Players
.Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId) .Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId)
+18
View File
@@ -37,3 +37,21 @@ covered by AGPL §13 — you must:
changes you have made relative to the upstream version. changes you have made relative to the upstream version.
The full text of the AGPL-3.0 license is in LICENSE. 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.
+44 -4
View File
@@ -153,10 +153,25 @@ calls so self-signed certs work.
### Quest ### Quest
A Quest client for this branch is in progress and not usable yet. The `DorkNet.Quest` patches the Rec Room 2023.03.21 Quest client (versionCode
archive holds a Quest build of the same release (version `20230317`), so `2128210`) for sideloading onto a standalone Meta Quest. That build reports
Quest and PC players will be on matching versions once it lands. Until `appVersion` `20230317`, the same version as the PC client, so Quest and PC
then, this branch supports the PC client only. 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`) ### 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 ## License
[AGPL-3.0](LICENSE). Hosted forks must publish their source. [AGPL-3.0](LICENSE). Hosted forks must publish their source.
+8
View File
@@ -50,6 +50,7 @@ DorkNet.sln
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 | | `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.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.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 `ClientMod` applies the client-side patches needed to point the 2020
watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex), 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 same config: Desktop Screen-Share FPS, RRO quest party size, and Rec
Royale night mode (see the README and `recroom-2023-scene-moods.md`). 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 ## Server folder layout
+12
View File
@@ -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 ## Photon Cloud
Use your own Photon AppIds. Rec Room's original Photon AppIds reject Use your own Photon AppIds. Rec Room's original Photon AppIds reject
+76
View File
@@ -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.
+2 -1
View File
@@ -215,6 +215,7 @@ verified against the binary before the change, and the full test suite passes.
| `POST api/avatar/v{2,3}/gifts/generate` | 415 on the client's form-urlencoded body (seen live) | form-or-JSON binder | | `POST api/avatar/v{2,3}/gifts/generate` | 415 on the client's form-urlencoded body (seen live) | form-or-JSON binder |
| `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape | | `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape |
| `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor | | `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor |
| `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (default items) | the client REPLACES its unlocked set with each downloaded list (OutfitManager.GFEIDFKKNMO clears field +0x70) and never merges its own defaults, so the free `default_unlocked_variants` items (e.g. Beard Close) showed a price | every list includes the 239 default items; `defaultunlocked` serves them before login; `lockeditems` never reports them |
| `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container | | `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container |
| `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added | | `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added |
| `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added | | `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added |
@@ -1348,7 +1349,7 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every
| Verb | Route | Request | Response | | Verb | Route | Request | Response |
|---|---|---|---| |---|---|---|---|
| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items | | GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items |
| GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler) | | GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler; logged out it returns the `default_unlocked_variants` items, logged in the full owned set including them) |
| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List<IFNGBPJGINL> — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own | | GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List<IFNGBPJGINL> — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own |
| GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte | | GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte |
| GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid | | GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid |