Files
Radicade/Tools/patch-0831-client-metadata.py
2026-10-16 23:33:13 -07:00

786 lines
34 KiB
Python

#!/usr/bin/env python3
"""Point a Rec Room 20230831 client at a local Radicade server.
Rewrites the rec.net bootstrap URLs that the client hardcodes as IL2CPP string
literals, so every service base it reads from GET /ns on the nameserver lands on
the local server. Same technique the 20230825 Radicade patch used: the rebuilt
literal blob is relaid at EOF and only the four header fields that describe it are
repointed, so every other byte of the metadata stays identical.
The shuffled-header field positions differ per release and are derived
structurally (see .tmp-newbuild/find_layout.py, chain.py); for 20230831 they are
literal table offset 0x74 / size 0x24, literal data offset 0xA0 / size 0x3C.
Usage:
python tools/patch-0831-client-metadata.py <game-root> [--server https://ns.radicade.app] [--apply]
Dry-run by default. Originals are kept as global-metadata.dat.orig on first patch.
An install already patched for the previous Radicade address is re-pointed
directly: that host sits in the string table verbatim, so the rules below rewrite
it and no pristine copy is needed. Anything else - an install patched by a
different tool, or with the address baked in some other way - still needs the
pristine copy FIRST, because the replacement is applied to the file as it stands
and would otherwise find nothing to rewrite and silently keep its old bootstrap
host:
cp global-metadata.dat.orig global-metadata.dat
python tools/patch-0831-client-metadata.py <game-root> --server <url> --apply
--bake writes the one file worth handing out. Relocating leaves the original
literal blob in the file (dead once the header is repointed) and appends a
verbatim copy of it, so the stock Rec Room hosts survive inside the metadata twice
and the file grows by the size of the blob. Baked mode rebuilds the blob inside
its own region instead: every record gets a fresh (length, index) pair into a
repacked blob holding only the final strings, the stock hosts are gone from the
file, and the file keeps its exact original size. The whole region is literal
data and nothing else - 99.9999% of its bytes are covered by the records' own
ranges - which is what makes rewriting it in place safe; the run refuses if that
coverage check fails. The records keep their table order, because the client's own
plugins find the literal table by that shape rather than by any header field, and
the bake is checked by running their detector (2023Patch/RadicadeEacBypass/
ImageSignatureRequest.cs) over the result. It reads the pristine copy when one is
present, so a single
invocation turns either a stock install or an already-patched one into the same
canonical file:
python tools/patch-0831-client-metadata.py <game-root> --bake --apply
The file that comes out is the whole fix, so it is also what to hand to someone
else - same 20230831 build, drop it in place of their global-metadata.dat. Use
--out to write it somewhere other than the install (which is also how to bake
while the game is running, since a running Radicade holds the metadata open):
python tools/patch-0831-client-metadata.py <game-root> --bake --apply --out out/global-metadata.dat
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
HEADER_FIELDS = {
"stringLiteral": (0x74, 0x24),
"stringLiteralData": (0xA0, 0x3C),
}
KEEP = [
"Upload to rec.net",
"rec.net/terms",
"rec.net/privacy",
"rec.net/rules",
"rec.net/parents",
"help.rec.net",
"api.steampowered.com",
"store.steampowered.com",
"firebase-settings.crashlytics.com",
"app-measurement.com",
]
# Static, server-independent replacements for the client's own branding/support
# strings and links. The 20230831 build keeps these in the same il2cpp string
# table as the service hosts (a contiguous block of legal/community URLs around
# "rec.net"/"recroom.com", plus one support-host sentence spread across ~34
# literals). Longest source wins, so the /home URL is retargeted before the
# bare-host rule rewrites the rest.
TEXT_RULES = [
("https://recroom.zendesk.com/home", "https://support.radicade.app/"),
("recroom.zendesk.com", "support.radicade.app"),
("https://www.recroom.com/privacy-policy/", "https://radicade.app/privacy-policy/"),
("https://recroom.com/terms-of-service", "https://radicade.app/terms-of-service/"),
("https://www.recroom.com/community/", "https://radicade.app/community/"),
# The rest of Rec Room's public pages and both of its support mailboxes: the
# Radicade site serves them at the same path, so these two catch-alls finish
# any page not named above. Rules run longest source first, so the named ones
# win and keep their exact destinations.
("https://www.recroom.com/", "https://radicade.app/"),
("https://recroom.com/", "https://radicade.app/"),
("tokens@recroom.com", "tokens@radicade.app"),
("support@recroom.com", "support@radicade.app"),
]
def build_rules(server: str):
b = server.rstrip("/")
return [
("https://ns.rec.net", b),
("https://auth.rec.net", b),
("https://api.rec.net", b),
("https://accounts.rec.net", b),
("https://cdn.rec.net", b),
("https://notifications.rec.net", b),
("https://www.rec.net", b),
("http://cdn.rec.net", b),
("http://img.rec.net/", f"{b}/images/img/"),
("https://img.rec.net", f"{b}/images/img"),
("https://images.rec.net", f"{b}/images/img"),
("https://rec.net/shop/subscriptions", f"{b}/shop/subscriptions"),
("https://rec.net/shop", f"{b}/shop"),
("https://rec.net/{0}", f"{b}/{{0}}"),
("https://rec.net", b),
("rec.net/creator", f"{b.split('://')[-1]}/creator"),
(".rec.net", ".radicade.internal"),
("rec.net", b),
# The previous Radicade address, so an install patched for it is re-pointed in
# one pass instead of needing the pristine copy. Longest source wins, exactly as
# above, and every destination matches what the rec.net rules above produce for
# the same literal - so an install patched from the pristine copy and one patched
# from the previous address converge on the same string table.
("https://radicade.example.com/images/img", f"{b}/images/img"),
("https://radicade.example.com/shop/subscriptions", f"{b}/shop/subscriptions"),
("https://radicade.example.com/shop", f"{b}/shop"),
("https://radicade.example.com/{0}", f"{b}/{{0}}"),
("https://radicade.example.com", b),
("http://radicade.example.com", b),
("radicade.example.com/creator", f"{b.split('://')[-1]}/creator"),
("radicade.example.com", b),
]
# Substring sharing makes a repacked blob comfortably smaller than the original
# dense one (1,567,053 of 1,657,800 bytes), which is the headroom that lets the
# rebuild land inside the same region. Overlaps past this length are not worth
# probing for; the longest one that matters here is a shared https:// prefix.
SHARE_LIMIT = 1024
def read_u32(buf, off: int) -> int:
return int.from_bytes(buf[off:off + 4], "little")
def read_i32(buf, off: int) -> int:
return int.from_bytes(buf[off:off + 4], "little", signed=True)
def resolve_metadata(game_root: str):
"""-> (data_dir, path to the live global-metadata.dat)"""
data_dir = next(
(name for name in ("RecRoom_Data", "Radicade_Data")
if os.path.isdir(os.path.join(game_root, name))),
"RecRoom_Data",
)
return data_dir, os.path.join(game_root, data_dir, "il2cpp_data", "Metadata", "global-metadata.dat")
def place_literals(buf, off, literals):
"""Add literals to buf (in the order given), reusing any that already appears
in it, and record where each one landed in off. Mutates and returns both.
A record's (length, index) pair is self-contained, so a literal may be placed
anywhere, including the middle of another one - that is what makes this pick
up the prefix sharing (https://ns.radicade.app inside .../shop) that IL2CPP's
own, suffix-only packer leaves on the table.
"""
for lit in literals:
if not lit or lit in off:
continue
found = buf.find(lit)
if found >= 0:
off[lit] = found
continue
used = len(buf)
# longest suffix of the buffer that is a prefix of the literal
lo, hi, tail = 1, min(len(lit), used, SHARE_LIMIT), 0
while lo <= hi:
mid = (lo + hi) // 2
if bytes(buf[used - mid:]) == lit[:mid]:
tail = mid
lo = mid + 1
else:
hi = mid - 1
if tail:
off[lit] = used - tail
buf += lit[tail:]
else:
off[lit] = used
buf += lit
return buf, off
def layout_blob(final, data_size):
"""Lay the literals out in the shape the game's own build writes them in.
IL2CPP writes this blob in table order, each record starting exactly where the
previous one ended, and the client finds the table again by that shape:
RadicadeEacBypass/ImageSignatureRequest.cs probes the first 8 and the first
1024 records and wants >=99% of them to chain before it will read the image key
id (KEY:RSA:p1...) out of the ".rec.net"/".radicade.internal" literal it finds
there. A blob packed purely for size - every literal placed wherever it shared
most - stops looking like a literal table at all, so the key id comes back
empty, image requests lose their ?rcsig, and the client is killed on the next
scene load. So records that fit stay in table order and only the tail that does
not fit is packed for size.
-> (blob, offset of every record, how many records stayed in table order)
"""
count = len(final)
ordered = [b or b"" for b in final]
tail = 0
while True:
keep = count - tail
blob = bytearray()
placements = [0] * count
off = {}
for i in range(keep):
placements[i] = len(blob)
if ordered[i]:
blob += ordered[i]
off.setdefault(ordered[i], placements[i])
place_literals(blob, off, sorted(
{ordered[i] for i in range(keep, count) if ordered[i]},
key=lambda b: (-len(b), b),
))
for i in range(keep, count):
if ordered[i]:
placements[i] = off[ordered[i]]
if len(blob) <= data_size or tail >= count:
return bytes(blob), placements, keep
tail = 64 if tail == 0 else tail * 2
def final_bytes_per_record(data, table_off, count, data_off, data_size, rules):
"""-> (bytes each record should expose, indices of the records we preserved).
None marks a record that exposes nothing (the build's one empty literal), and
a bail-out message comes back instead when a record cannot be resolved: a
record whose range is broken would have to keep an index into a blob we are
about to overwrite, so those fail the bake rather than get a guess.
"""
final = []
preserved = []
for i in range(count):
pos = table_off + i * 8
length = read_u32(data, pos)
index = read_i32(data, pos + 4)
if length == 0:
final.append(None)
continue
if index < 0 or index + length > data_size:
return None, None, f"literal #{i} has an unusable range ({length}, {index})"
raw = bytes(data[data_off + index: data_off + index + length])
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return None, None, f"literal #{i} is not UTF-8; it cannot be repacked"
if any(k.lower() in text.lower() for k in KEEP):
final.append(raw)
preserved.append(i)
continue
new = text
for frm, to in rules:
if frm in new:
new = new.replace(frm, to)
final.append(new.encode("utf-8"))
return final, preserved, None
def bake_metadata(data, table_off, table_size, data_off, data_size, rules):
"""Rebuild the literal blob inside its own region.
-> (out, report, error) where the report carries the per-record bytes, the
ranges we deliberately preserved, the packed blob size and how many literals
the rules actually rewrote.
"""
count = table_size // 8
final, preserved, err = final_bytes_per_record(data, table_off, count, data_off, data_size, rules)
if err:
return None, None, err
# Table order first, and never set iteration order for what is packed: an
# unstable order would let PYTHONHASHSEED change the layout, so two runs on the
# same input would produce different bytes and no one could check a copy by hash.
blob, placements, contiguous = layout_blob(final, data_size)
if len(blob) > data_size:
return None, None, (
f"repacked blob needs {len(blob):,} bytes but the region holds {data_size:,}"
)
if contiguous < min(count, 1024):
return None, None, (
f"only {contiguous:,} of {count:,} literals could stay in table order; the "
"client finds the literal table by that shape, so this file would not load"
)
out = bytearray(data)
out[data_off:data_off + len(blob)] = blob
out[data_off + len(blob):data_off + data_size] = b"\x00" * (data_size - len(blob))
for i, b in enumerate(final):
pos = table_off + i * 8
if b is None:
out[pos:pos + 4] = (0).to_bytes(4, "little")
out[pos + 4:pos + 8] = (0).to_bytes(4, "little", signed=True)
continue
out[pos:pos + 4] = len(b).to_bytes(4, "little")
out[pos + 4:pos + 8] = placements[i].to_bytes(4, "little", signed=True)
rewritten = sum(
1 for i, b in enumerate(final)
if b is not None
and bytes(data[data_off + read_i32(data, table_off + i * 8 + 4):
data_off + read_i32(data, table_off + i * 8 + 4) + read_u32(data, table_off + i * 8)]) != b
)
report = {
"final": final,
# file offsets, so they line up with the positions the host scan reports
"kept_ranges": [(data_off + placements[i], len(final[i])) for i in preserved],
"blob_size": len(blob),
"contiguous": contiguous,
"rewritten": rewritten,
}
return out, report, None
# ---------------------------------------------------------------------------
# The client's own table detection, ported from
# 2023Patch/RadicadeEacBypass/ImageSignatureRequest.cs.
#
# This metadata has no magic/version at 0/4, so the client takes the header-scan
# path: it builds candidates out of every plausible (offset, size) pair in the
# first 0x100 header bytes, keeps the ones whose records chain end-to-end for the
# first 8 and the first 1024 records, and reads the image key id (KEY:RSA:p1...) out
# of the ".rec.net"/".radicade.internal" literal it finds there. Running this after
# a bake is the point: it is the check that a repacked blob can pass every one of
# our own and still fail, and the failure is silent - no key id, no ?rcsig on image
# requests, and the client is killed on the next scene load.
# ---------------------------------------------------------------------------
DETECTOR_HEADER_SCAN_LIMIT = 0x100
DETECTOR_MINIMUM_RECORDS = 32
DETECTOR_PREFIX_PROBE = 8
DETECTOR_PACKING_PROBE = 1024
DETECTOR_MAX_LITERAL_LENGTH = 1 << 20
def detector_record(data, table_off, record):
pos = table_off + 8 * record
return read_u32(data, pos), read_i32(data, pos + 4)
def looks_like_literal_table(data, table_off, count, data_off, data_size, probe):
examined = min(count, probe)
contiguous = 0
running = 0
for record in range(examined):
length, index = detector_record(data, table_off, record)
if length < 0 or length > DETECTOR_MAX_LITERAL_LENGTH or index < 0 \
or index + length > data_size:
return False
if index == running:
contiguous += 1
running = index + length
return contiguous >= examined - max(1, examined // 100)
def sample_decodes_as_text(data, table_off, count, data_off, data_size):
step = max(1, count // 48)
for record in range(0, count, step):
length, index = detector_record(data, table_off, record)
if length < 0 or index < 0 or index + length > data_size:
return False
if length == 0:
continue
try:
bytes(data[data_off + index:data_off + index + length]).decode("utf-8")
except UnicodeDecodeError:
return False
return True
def scan_key_tails(data, table_off, count, data_off, data_size):
"""The one literal the client turns into its image key id."""
key = None
for record in range(count):
length, index = detector_record(data, table_off, record)
if length < 0 or index < 0 or index + length > data_size:
return None
if length != 8 and length != 18:
continue
literal = bytes(data[data_off + index:data_off + index + length]).decode("utf-8", "replace")
if literal != ".rec.net" and literal != ".radicade.internal":
continue
candidate = "KEY:RSA:p1" + literal
if key is not None and key != candidate:
return None
key = candidate
return key
def detect_key_id_from_game_metadata(data):
"""-> the image key id the client's plugin derives from this metadata, or None."""
if len(data) < 24:
return None
if read_u32(data, 0) == 0xFAB11BAF and read_i32(data, 4) == 27:
table, size = read_i32(data, 8), read_i32(data, 12)
data_off, data_size = read_i32(data, 16), read_i32(data, 20)
if (table < 24 or size < 0 or size % 8 != 0 or table + size > len(data)
or data_off < 24 or data_size < 0 or data_off + data_size > len(data)):
return None
return scan_key_tails(data, table, size // 8, data_off, data_size)
fields = []
for off in range(0, DETECTOR_HEADER_SCAN_LIMIT, 4):
value = read_i32(data, off)
if 0 < value <= len(data) and value not in fields:
fields.append(value)
tables = sorted(
((t, s // 8) for t in fields for s in fields
if s % 8 == 0 and s // 8 >= DETECTOR_MINIMUM_RECORDS
and t >= DETECTOR_HEADER_SCAN_LIMIT and t + s <= len(data)),
key=lambda t: -t[1],
)
slices = [(d, s) for d in fields for s in fields
if s > 0 and d >= DETECTOR_HEADER_SCAN_LIMIT and d + s <= len(data)]
for table_off, count in tables:
for data_off, data_size in slices:
if not looks_like_literal_table(data, table_off, count, data_off, data_size,
DETECTOR_PREFIX_PROBE):
continue
if not sample_decodes_as_text(data, table_off, count, data_off, data_size):
continue
if not looks_like_literal_table(data, table_off, count, data_off, data_size,
DETECTOR_PACKING_PROBE):
continue
key = scan_key_tails(data, table_off, count, data_off, data_size)
if key is not None:
return key
return None
def verify_baked(data, out, table_off, table_size, data_off, data_size, final, header_fields):
"""Read the baked file back the way the loader will. -> list of failures."""
count = table_size // 8
bad = []
for i in range(count):
pos = table_off + i * 8
length = read_u32(out, pos)
text = bytes(out[data_off + read_i32(out, pos + 4): data_off + read_i32(out, pos + 4) + length])
want = final[i] or b""
if text != want:
bad.append("literal #%d does not read back" % i)
_, data_size_f = HEADER_FIELDS["stringLiteralData"]
# everything outside the literal region, its table and the resized size field
# must be byte-identical: the bake moves no other structure.
def blanked(buf):
copy = bytearray(buf)
copy[data_off:data_off + data_size] = b"\x00" * data_size
copy[table_off:table_off + table_size] = b"\x00" * table_size
copy[data_size_f:data_size_f + 4] = b"\x00" * 4
return copy
want, got = blanked(data), blanked(out)
for off in range(min(len(want), len(got))):
if want[off] != got[off]:
bad.append("byte 0x%X outside the literal data and its table changed" % off)
break
if len(out) != len(data):
bad.append("file size changed (%d -> %d)" % (len(data), len(out)))
return bad
def scan_hosts(out, pattern, kept_ranges, region):
"""Hits of a bytes pattern in the baked file, split by the literal-data region.
-> (inside the literal data, elsewhere). A hit inside a literal we deliberately
preserve (KEEP) is not a leftover, so those ranges are exempted.
"""
inside, outside = [], []
for match in re.finditer(pattern, out):
if any(start <= match.start() and match.end() <= start + length
for start, length in kept_ranges):
continue
(inside if match.start() in region else outside).append(
(match.start(), bytes(match.group(0))))
return inside, outside
def bake_main(args, data_dir, metadata) -> int:
rules = sorted(build_rules(args.server) + TEXT_RULES, key=lambda r: -len(r[0]))
pristine = metadata + ".orig"
source = pristine if os.path.isfile(pristine) else metadata
note = "" if source == pristine else " (no pristine copy beside the live file)"
print(f"source : {source}{note}")
data = bytearray(open(source, "rb").read())
table_off, table_size = read_u32(data, HEADER_FIELDS["stringLiteral"][0]), read_u32(data, HEADER_FIELDS["stringLiteral"][1])
data_off, data_size = read_u32(data, HEADER_FIELDS["stringLiteralData"][0]), read_u32(data, HEADER_FIELDS["stringLiteralData"][1])
count = table_size // 8
print(f"size : {len(data):,}")
print(f"literal table: 0x{table_off:X} ({table_off:,}) records {count:,}")
print(f"literal data : 0x{data_off:X} ({data_off:,}) size {data_size:,}")
# The region is only safe to overwrite if the records account for all of it:
# that is what proves it holds literal data and nothing else.
coverage = bytearray(data_size)
for i in range(count):
pos = table_off + i * 8
length, index = read_u32(data, pos), read_i32(data, pos + 4)
if length == 0 or index < 0 or index + length > data_size:
continue
coverage[index:index + length] = b"\x01" * length
covered = coverage.count(1)
print(f"region coverage: {covered:,}/{data_size:,} bytes ({100.0 * covered / data_size:.4f}%)")
if covered < data_size * 0.999:
print("refusing to bake: the literal data region is not accounted for by the records", file=sys.stderr)
return 1
out, report, err = bake_metadata(data, table_off, table_size, data_off, data_size, rules)
if err:
print(f"bake failed: {err}", file=sys.stderr)
return 1
final, kept_ranges = report["final"], report["kept_ranges"]
blob_size = report["blob_size"]
print(f"literals : {report['rewritten']:,} rewritten by the rules, {len(final):,} repacked into the region")
print(f"repacked blob: {blob_size:,} bytes ({data_size - blob_size:,} reclaimed, tail zeroed)")
print(f"table shape : {report['contiguous']:,}/{count:,} literals kept in table order, "
f"{count - report['contiguous']:,} shared at the end of the blob")
region = range(data_off, data_off + data_size)
bad = verify_baked(data, out, table_off, table_size, data_off, data_size, final, HEADER_FIELDS)
sources = b"|".join(re.escape(frm.encode("utf-8")) for frm, _ in rules)
inside, outside = scan_hosts(out, sources, kept_ranges, region)
# Independently of the rule list: every Rec Room service host lives under
# rec.net, and in stock metadata lowercase rec.net only ever appears as one of
# those hosts, so anything still matching here is a bootstrap address the rules
# do not know about.
missed, _ = scan_hosts(out, rb"rec\.net", kept_ranges, region)
inside += missed
if inside:
bad.append("%d stock host reference(s) survived in the literal data, first at 0x%X: %r"
% (len(inside), inside[0][0], inside[0][1]))
key_id = detect_key_id_from_game_metadata(bytes(out))
if key_id is None:
bad.append("the client's own key-id detection finds nothing in this file "
"(ImageSignatureRequest.cs), so image requests would lose their ?rcsig")
else:
was = detect_key_id_from_game_metadata(bytes(data))
moved = f" (was {was})" if was and was != key_id else ""
print(f"image key id : {key_id}{moved}, derived the way the client derives it")
if bad:
for line in bad[:5]:
print(f"verification failed: {line}", file=sys.stderr)
return 1
print(f"verification : {count:,} literals read back; header, other tables and size untouched")
print(f"service hosts: none left in the literal data ({len(rules)} rule patterns + any *.rec.net)")
if outside:
kinds = sorted({text.decode("utf-8", "replace") for _, text in outside})
print(f"note : {len(outside)} reference(s) to {', '.join(kinds)} remain outside the")
print(" literal data, in the client's UI string table. They are display text")
print(" and do not decide which server the client contacts, so they are left")
print(" byte-for-byte alone.")
pages = sorted({b.decode("utf-8") for b in final if b and re.search(rb"(?i)recroom\.com", b)})
if pages:
print(f"note : {len(pages)} literal(s) still name a recroom.com page or address. Those")
print(" are browser links and support emails, not hosts the client connects to,")
print(" and there is no Radicade equivalent recorded for them:")
for text in pages[:8]:
print(f" {text}")
if len(pages) > 8:
print(f" ... and {len(pages) - 8} more")
if not args.apply:
print("\ndry run - pass --apply to write")
return 0
if args.out:
open(args.out, "wb").write(bytes(out))
print(f"written: {args.out}")
print("drop it in place of global-metadata.dat; the existing radicade-patch.json still")
print("names this server, so the client's own check stays satisfied")
return 0
backup = metadata + ".bak-" + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(metadata, backup)
try:
with open(metadata, "wb") as handle:
handle.write(bytes(out))
except OSError as ex:
print(f"\ncannot write {metadata}: {ex}", file=sys.stderr)
print("the game keeps the metadata open while it runs - close Radicade and re-run,", file=sys.stderr)
print("or use --out to produce the file somewhere else and drop it in", file=sys.stderr)
return 1
if source == metadata and not os.path.exists(pristine):
shutil.copy2(backup, pristine)
print(f"pristine kept: {pristine}")
marker = os.path.join(args.game_root, data_dir, "radicade-patch.json")
json.dump({
"patchedAtUtc": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"gameVersion": "20230831",
"serverUrl": args.server,
"mode": "baked",
"metadata": {
"changedLiterals": report["rewritten"],
"repackedLiterals": sum(1 for b in final if b),
"oldDataSize": data_size,
"newDataSize": blob_size,
"headerSha256": hashlib.sha256(bytes(out[:256])).hexdigest(),
"imageKeyId": key_id,
"literalOrder": report["contiguous"],
"literalTableField": f"0x{HEADER_FIELDS['stringLiteral'][0]:02x}",
"literalDataField": f"0x{HEADER_FIELDS['stringLiteralData'][0]:02x}",
},
}, open(marker, "w"), indent=2)
print(f"written: {metadata}\nbackup : {backup}\nmarker : {marker}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("game_root")
ap.add_argument("--server", default="https://ns.radicade.app")
ap.add_argument("--bake", action="store_true",
help="rebuild the literal blob in place: one canonical file with no stock hosts left in it")
ap.add_argument("--out", default=None,
help="write the baked metadata here instead of into the install, to hand the file out")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
data_dir, metadata = resolve_metadata(args.game_root)
if not os.path.isfile(metadata):
print(f"not a Rec Room install: {metadata} missing", file=sys.stderr)
return 1
if args.bake:
return bake_main(args, data_dir, metadata)
data = bytearray(open(metadata, "rb").read())
n = len(data)
table_off_f, table_size_f = HEADER_FIELDS["stringLiteral"]
data_off_f, data_size_f = HEADER_FIELDS["stringLiteralData"]
def u32(byte: int) -> int:
return int.from_bytes(data[byte:byte + 4], "little")
table_off, table_size = u32(table_off_f), u32(table_size_f)
data_off, data_size = u32(data_off_f), u32(data_size_f)
count = table_size // 8
print(f"metadata : {metadata}")
print(f"size : {n:,}")
print(f"literal table: 0x{table_off:X} ({table_off:,}) records {count:,}")
print(f"literal data : 0x{data_off:X} ({data_off:,}) size {data_size:,}")
fp = hashlib.sha256(bytes(data[:256])).hexdigest()
print(f"header sha256: {fp}")
rules = build_rules(args.server) + TEXT_RULES
targets = {to for _, to in rules}
records = []
changes = []
saw_target = False
for i in range(count):
pos = table_off + i * 8
length = u32(pos)
index = int.from_bytes(data[pos + 4:pos + 8], "little", signed=True)
if length == 0 or index < 0 or index + length > data_size:
records.append((length, index, b"", False))
continue
raw = bytes(data[data_off + index: data_off + index + length])
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
records.append((length, index, raw, False))
continue
if any(k.lower() in text.lower() for k in KEEP):
records.append((length, index, raw, False))
continue
new = text
for frm, to in sorted(rules, key=lambda r: -len(r[0])):
if frm in new:
new = new.replace(frm, to)
if any(t and t in new for t in targets):
saw_target = True
if new != text:
changes.append((i, text, new))
records.append((len(new.encode()), index, new.encode(), True))
else:
records.append((length, index, raw, False))
if not changes:
print("nothing to rewrite" + (" (already patched)" if saw_target else ""))
return 0
# rebuild the blob: old bytes verbatim, changed literals appended after them
appended = bytearray()
new_index = {}
for i, old, new in changes:
new_index[i] = data_size + len(appended)
appended += new.encode()
new_data_size = data_size + len(appended)
out = bytearray(data)
out += bytearray(data[data_off: data_off + data_size])
out += appended
out[data_off_f:data_off_f + 4] = n.to_bytes(4, "little")
out[data_size_f:data_size_f + 4] = new_data_size.to_bytes(4, "little")
for i, old, new in changes:
pos = table_off + i * 8
out[pos:pos + 4] = len(new.encode()).to_bytes(4, "little")
out[pos + 4:pos + 8] = new_index[i].to_bytes(4, "little", signed=True)
print(f"\n{len(changes)} literal(s) to rewrite:")
for i, old, new in changes:
print(f" [{i}] {old!r}\n -> {new!r}")
print(f"blob: {data_size:,} -> {new_data_size:,} bytes, file: {n:,} -> {len(out):,}")
# --- verification -------------------------------------------------------
changed_positions = set()
for i, _, _ in changes:
pos = table_off + i * 8
changed_positions.update(range(pos, pos + 8))
for off in range(256):
if off in range(data_off_f, data_off_f + 4) or off in range(data_size_f, data_size_f + 4):
continue
if out[off] != data[off]:
print(f"verification failed: header byte 0x{off:X} changed", file=sys.stderr)
return 1
for off in range(256, n):
if off in changed_positions:
continue
if out[off] != data[off]:
print(f"verification failed: payload byte 0x{off:X} changed", file=sys.stderr)
return 1
if bytes(out[n:n + data_size]) != bytes(data[data_off:data_off + data_size]):
print("verification failed: relocated blob is not a verbatim copy", file=sys.stderr)
return 1
changed_map = {i: new for i, _, new in changes}
for i, (length, index, raw, _) in enumerate(records):
pos = table_off + i * 8
new_len = int.from_bytes(out[pos:pos + 4], "little")
new_idx = int.from_bytes(out[pos + 4:pos + 8], "little", signed=True)
text = bytes(out[new_data_size and n + new_idx: n + new_idx + new_len])
want = changed_map[i].encode() if i in changed_map else raw
if text != want:
print(f"verification failed: literal #{i} does not read back", file=sys.stderr)
return 1
print(f"verification: {count:,} literals read back, header and payload otherwise byte-identical")
if not args.apply:
print("\ndry run - pass --apply to write")
return 0
backup = metadata + ".orig"
if not os.path.exists(backup):
shutil.copy2(metadata, backup)
print(f"backup written: {backup}")
open(metadata, "wb").write(bytes(out))
marker = os.path.join(args.game_root, data_dir, "radicade-patch.json")
json.dump({
"patchedAtUtc": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"gameVersion": "20230831",
"serverUrl": args.server,
"metadata": {
"changedLiterals": len(changes),
"oldDataSize": data_size,
"newDataSize": new_data_size,
"headerSha256": fp,
"literalTableField": f"0x{table_off_f:02x}",
"literalDataField": f"0x{data_off_f:02x}",
},
}, open(marker, "w"), indent=2)
print(f"written: {metadata}\nmarker : {marker}")
return 0
if __name__ == "__main__":
raise SystemExit(main())