#!/usr/bin/env python3 """Extract every script's source from a binary .rbxm, byte-exact, laid out by instance path. Usage: python3 rbxm_extract.py Get the .rbxm with the robloxstudio MCP's export_rbxm (one call per top-level folder you care about, e.g. ServerScriptService/Game, ReplicatedStorage/Game, StarterPlayer/StarterPlayerScripts/Game). Output names follow the common convention: Script -> Name.server.luau, LocalScript -> Name.client.luau, ModuleScript -> Name.luau. Siblings with the same name get a "~2", "~3" suffix so nothing is overwritten. This is the snapshot exporter: run it for every folder into snapshot// and you have the rollback point, the reviewers' input and the base files for a base-compare push. No Studio round trip touches the source text, so escapes and newlines survive exactly (unlike returning .Source through execute_luau). Handles LZ4 chunks natively and ZSTD chunks if the `zstandard` package is installed. """ import os import struct import sys def lz4_block(src: bytes, size: int) -> bytes: out = bytearray() i = 0 while i < len(src): tok = src[i]; i += 1 lit = tok >> 4 if lit == 15: while True: b = src[i]; i += 1; lit += b if b != 255: break out += src[i:i + lit]; i += lit if i >= len(src): break off = src[i] | (src[i + 1] << 8); i += 2 m = tok & 15 if m == 15: while True: b = src[i]; i += 1; m += b if b != 255: break m += 4 start = len(out) - off for k in range(m): out.append(out[start + k]) return bytes(out) def decompress(raw: bytes, ulen: int) -> bytes: if raw[:4] == b"\x28\xb5\x2f\xfd": try: import zstandard except ImportError: sys.exit("this .rbxm uses ZSTD chunks: pip install zstandard") return zstandard.ZstdDecompressor().decompress(raw, max_output_size=ulen) return lz4_block(raw, ulen) def read_ints(raw: bytes, count: int, accumulate: bool): """Interleaved, zigzag-encoded big-endian int32 array (the binary format's referent encoding).""" vals = [] for k in range(count): b = bytes([raw[k], raw[count + k], raw[2 * count + k], raw[3 * count + k]]) v = int.from_bytes(b, "big") vals.append((v >> 1) ^ -(v & 1)) if not accumulate: return vals acc, out = 0, [] for v in vals: acc += v out.append(acc) return out def parse(data: bytes): if not data.startswith(b" class name ids_of = {} # class id -> referents names, sources, parents = {}, {}, {} while p < len(data): chunk = data[p:p + 4] clen, ulen = struct.unpack(" int: if len(sys.argv) != 3: print(__doc__) return 2 class_of, names, sources, parents = parse(open(sys.argv[1], "rb").read()) def segments(r): segs = [] while r is not None and r >= 0 and r in names: segs.append(names[r].decode("utf-8", "replace").replace("/", "_")) r = parents.get(r) return list(reversed(segs)) used = set() n = 0 for r, src in sorted(sources.items()): segs = segments(r) base = os.path.join(sys.argv[2], *segs[:-1], segs[-1]) fn = base + SUFFIX.get(class_of.get(r, ""), ".luau") k = 2 while fn in used: fn = f"{base}~{k}" + SUFFIX.get(class_of.get(r, ""), ".luau"); k += 1 used.add(fn) os.makedirs(os.path.dirname(fn), exist_ok=True) with open(fn, "wb") as f: f.write(src) n += 1 print(f"extracted {n} script(s) into {sys.argv[2]}") return 0 if __name__ == "__main__": sys.exit(main())