mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
244 lines
10 KiB
Python
244 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Point the unpacked Quest Rec Room 20230907 client at a Radicade server.
|
|
|
|
Accepts either the raw apktool-decoded APK directory (containing
|
|
assets/bin/Data/Managed/Metadata/global-metadata.dat) or a wrapper containing
|
|
apk/ and obb/ subdirectories like the provided download. It changes only exact
|
|
IL2CPP literal-table rows, not broad byte substrings.
|
|
|
|
Dry-run by default. Use --apply to back up the metadata and write the patch.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
# The Quest release's header fields were mapped structurally from its unique
|
|
# metadata section chain. These contain the v27 string-literal table/data pairs.
|
|
FIELDS = {
|
|
"literal_table_offset": 0x44,
|
|
"literal_table_size": 0x6C,
|
|
"literal_data_offset": 0x28,
|
|
"literal_data_size": 0xEC,
|
|
}
|
|
GAME_VERSION = "20230907"
|
|
KNOWN_LITERALS = {
|
|
2011: ".rec.net",
|
|
2516: "20230907",
|
|
41319: "http://cdn.rec.net/avatar/",
|
|
41405: "https://auth.rec.net",
|
|
41411: "https://ns.rec.net",
|
|
41418: "https://rec.net",
|
|
41419: "https://rec.net/shop",
|
|
41420: "https://rec.net/shop/subscriptions",
|
|
41421: "https://rec.net/{0}",
|
|
}
|
|
|
|
|
|
def u32(data: bytes | bytearray, offset: int) -> int:
|
|
return struct.unpack_from("<I", data, offset)[0]
|
|
|
|
|
|
def validate_server(value: str) -> str:
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
|
raise ValueError("server must be an https:// URL without user info")
|
|
if parsed.query or parsed.fragment:
|
|
raise ValueError("server URL cannot contain a query or fragment")
|
|
return value.rstrip("/")
|
|
|
|
|
|
def load_literals(blob: bytes) -> tuple[int, int, int, int, list[tuple[int, int]]]:
|
|
if len(blob) < 256:
|
|
raise ValueError("metadata is shorter than its 256-byte header")
|
|
table_offset = u32(blob, FIELDS["literal_table_offset"])
|
|
table_size = u32(blob, FIELDS["literal_table_size"])
|
|
data_offset = u32(blob, FIELDS["literal_data_offset"])
|
|
data_size = u32(blob, FIELDS["literal_data_size"])
|
|
if table_size % 8 or table_offset < 256 or table_offset + table_size > len(blob):
|
|
raise ValueError("Quest metadata literal-table header fields are invalid")
|
|
if data_offset < 256 or data_offset + data_size > len(blob):
|
|
raise ValueError("Quest metadata literal-data header fields are invalid")
|
|
|
|
literals = []
|
|
for index in range(table_size // 8):
|
|
length, string_index = struct.unpack_from("<Ii", blob, table_offset + index * 8)
|
|
if length == 0:
|
|
if string_index != 0:
|
|
raise ValueError(f"empty literal #{index} has unexpected data index {string_index}")
|
|
literals.append((length, string_index))
|
|
continue
|
|
if string_index < 0 or string_index + length > data_size:
|
|
raise ValueError(f"literal #{index} points outside its data segment")
|
|
try:
|
|
blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError(f"literal #{index} is not UTF-8") from exc
|
|
literals.append((length, string_index))
|
|
return table_offset, table_size, data_offset, data_size, literals
|
|
|
|
|
|
def build_patch(blob: bytes, server: str) -> tuple[bytearray, list[tuple[int, str, str]], int, int]:
|
|
table_offset, table_size, data_offset, data_size, literals = load_literals(blob)
|
|
# Refuse to patch a different client or a normalized/tooling metadata file.
|
|
for index, expected in KNOWN_LITERALS.items():
|
|
if index >= len(literals):
|
|
raise ValueError(f"expected Quest 20230907 literal #{index} is missing")
|
|
length, string_index = literals[index]
|
|
actual = blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
|
|
if actual != expected:
|
|
raise ValueError(f"not the expected Quest 20230907 metadata: literal #{index} is {actual!r}, expected {expected!r}")
|
|
|
|
rules = {
|
|
"https://ns.rec.net": server,
|
|
"https://auth.rec.net": server,
|
|
"https://rec.net/shop/subscriptions": server + "/shop/subscriptions",
|
|
"https://rec.net/shop": server + "/shop",
|
|
"https://rec.net/{0}": server + "/{0}",
|
|
"https://rec.net": server,
|
|
# The server's signed image endpoint is /images/img/{name}.
|
|
"http://cdn.rec.net/avatar/": server + "/images/img/",
|
|
".rec.net": ".radicade.internal",
|
|
}
|
|
rows = []
|
|
for index, (length, string_index) in enumerate(literals):
|
|
if not length:
|
|
continue
|
|
old = blob[data_offset + string_index:data_offset + string_index + length].decode("utf-8")
|
|
# Retain the server host tail used by the runtime's image signature key id.
|
|
if index == 2011:
|
|
replacement = ".radicade.internal"
|
|
new = replacement if old == ".rec.net" else old
|
|
else:
|
|
new = old
|
|
# Longest-first prevents the root literal from consuming the shop forms.
|
|
for source, target in sorted(rules.items(), key=lambda item: -len(item[0])):
|
|
if source in new:
|
|
new = new.replace(source, target)
|
|
if new != old:
|
|
rows.append((index, old, new))
|
|
|
|
if not rows:
|
|
raise ValueError("no target literals found; this file may already be patched or not match the supported Quest build")
|
|
out = bytearray(blob)
|
|
appended = bytearray()
|
|
new_data_offsets: dict[int, int] = {}
|
|
for index, _old, new in rows:
|
|
encoded = new.encode("utf-8")
|
|
new_data_offsets[index] = data_size + len(appended)
|
|
appended.extend(encoded)
|
|
# As on the desktop patch, keep the old literal bytes intact and append the
|
|
# replacement strings, then update only the matching table rows and fields.
|
|
out.extend(blob[data_offset:data_offset + data_size])
|
|
out.extend(appended)
|
|
new_data_size = data_size + len(appended)
|
|
struct.pack_into("<I", out, FIELDS["literal_data_offset"], len(blob))
|
|
struct.pack_into("<I", out, FIELDS["literal_data_size"], new_data_size)
|
|
for index, _old, new in rows:
|
|
row_offset = table_offset + index * 8
|
|
struct.pack_into("<I", out, row_offset, len(new.encode("utf-8")))
|
|
struct.pack_into("<i", out, row_offset + 4, new_data_offsets[index])
|
|
|
|
# Read back all live string rows; unchanged rows must resolve identically.
|
|
relocated_data_offset = len(blob)
|
|
changed = {index: new for index, _old, new in rows}
|
|
for index, (old_length, old_index) in enumerate(literals):
|
|
row_offset = table_offset + index * 8
|
|
length, string_index = struct.unpack_from("<Ii", out, row_offset)
|
|
if length == 0:
|
|
continue
|
|
actual = out[relocated_data_offset + string_index:relocated_data_offset + string_index + length]
|
|
if index in changed:
|
|
expected = changed[index].encode("utf-8")
|
|
else:
|
|
expected = blob[data_offset + old_index:data_offset + old_index + old_length]
|
|
if actual != expected:
|
|
raise ValueError(f"read-back verification failed for literal #{index}")
|
|
return out, rows, data_size, new_data_size
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("apktool_dir", help="apktool-decoded directory containing apk/assets/bin/Data")
|
|
parser.add_argument("--server", default="https://ns.radicade.app")
|
|
parser.add_argument("--apply", action="store_true", help="write files (dry-run is the default)")
|
|
args = parser.parse_args()
|
|
try:
|
|
server = validate_server(args.server)
|
|
except ValueError as exc:
|
|
print(f"invalid server URL: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
root = Path(args.apktool_dir).resolve()
|
|
candidates = [
|
|
root / "assets" / "bin" / "Data" / "Managed" / "Metadata" / "global-metadata.dat",
|
|
root / "apk" / "assets" / "bin" / "Data" / "Managed" / "Metadata" / "global-metadata.dat",
|
|
]
|
|
metadata = next((candidate for candidate in candidates if candidate.is_file()), candidates[-1])
|
|
if not metadata.is_file():
|
|
print(f"decoded Quest project missing {metadata}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
source = metadata.read_bytes()
|
|
patched, rows, old_size, new_size = build_patch(source, server)
|
|
except (OSError, ValueError, struct.error) as exc:
|
|
print(f"cannot patch Quest metadata: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"metadata: {metadata}")
|
|
print(f"source sha256: {hashlib.sha256(source).hexdigest()}")
|
|
print(f"metadata size: {len(source):,} -> {len(patched):,}; literal bytes: {old_size:,} -> {new_size:,}")
|
|
print(f"server: {server}")
|
|
print(f"{len(rows)} live literal(s) will be redirected:")
|
|
for index, before, after in rows:
|
|
print(f" #{index}: {before!r}\n -> {after!r}")
|
|
print("verification: all literal rows read back; the patch changes only the table rows and its two data-segment header fields")
|
|
if not args.apply:
|
|
print("dry-run only; pass --apply to write the metadata backup and marker")
|
|
return 0
|
|
|
|
backup = Path(str(metadata) + ".orig")
|
|
if backup.exists() and backup.read_bytes() != source:
|
|
print(f"refusing to overwrite existing backup with different bytes: {backup}", file=sys.stderr)
|
|
return 1
|
|
if not backup.exists():
|
|
shutil.copy2(metadata, backup)
|
|
# Atomic replacement prevents a partially written 30 MB metadata file.
|
|
fd, temp_name = tempfile.mkstemp(prefix=metadata.name + ".", dir=metadata.parent)
|
|
try:
|
|
with os.fdopen(fd, "wb") as handle:
|
|
handle.write(patched)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temp_name, metadata)
|
|
except Exception:
|
|
try:
|
|
os.unlink(temp_name)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
marker = metadata.parents[2] / "radicade-quest-patch.json"
|
|
marker.write_text(json.dumps({
|
|
"gameVersion": GAME_VERSION,
|
|
"serverUrl": server,
|
|
"sourceSha256": hashlib.sha256(source).hexdigest(),
|
|
"patchedSha256": hashlib.sha256(patched).hexdigest(),
|
|
"changedLiterals": len(rows),
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
print(f"backup: {backup}\nmarker: {marker}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|