mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
786 lines
34 KiB
Python
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())
|