Join the RoClicks Discord
RoClicks

rbxm_src.py

Python, 150 lines from Building overview

Download rbxm_src.py
#!/usr/bin/env python3
"""Extract the exact Source of every script in a binary Roblox model (.rbxm) or place (.rbxl).

Why: when a collaborator edits scripts live in Studio, Studio is the source of truth. Reading a
script back through an MCP "get source" call can reformat it, truncate it, or flood the agent's
context. Instead, export the subtree to a binary .rbxm (an export tool, or right-click > Save to
File), then run this. You get byte-exact files on disk to diff, edit, and use as the "base" for
stage_rbxmx.py.

Usage: python3 rbxm_src.py file.rbxm outdir
Writes outdir/<Parent>/<Child>/<Script>.luau for every instance with a Source property.
Pure Python; chunks compressed with zstd need `pip install zstandard` (LZ4 is built in).

Format notes (Roblox binary format): a 32-byte header, then chunks of
  name[4] compressedLen[u32] uncompressedLen[u32] reserved[4] body
INST maps a class id to its instance referents, PROP holds one property for every instance of a
class, PRNT holds (child, parent) referent pairs. Integer arrays are byte-interleaved, zigzag
encoded, and referents are delta-accumulated.
"""
import os
import struct
import sys

try:
    import zstandard
except ImportError:
    zstandard = None


def lz4_block(src: bytes) -> 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 interleaved_ints(raw: bytes, count: int):
    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))  # zigzag
    return vals


def referents(raw: bytes, count: int):
    acc, out = 0, []
    for v in interleaved_ints(raw, count):
        acc += v
        out.append(acc)
    return out


def read_chunks(data: bytes):
    if data[:8] != b"<roblox!":
        sys.exit("not a binary Roblox file (XML .rbxmx files are plain text: just read them)")
    p = 32
    while p < len(data):
        name = data[p:p + 4]
        clen, ulen = struct.unpack("<II", data[p + 4:p + 12])
        p += 16
        if clen == 0:
            body = data[p:p + ulen]; p += ulen
        else:
            raw = data[p:p + clen]; p += clen
            if raw[:4] == b"\x28\xb5\x2f\xfd":
                if not zstandard:
                    sys.exit("this file uses zstd chunks: pip install zstandard")
                body = zstandard.ZstdDecompressor().decompress(raw, max_output_size=ulen)
            else:
                body = lz4_block(raw)
        if name == b"END\0":
            break
        yield name, body


def main():
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    data = open(sys.argv[1], "rb").read()
    classes, names, sources, parents = {}, {}, {}, {}

    for name, body in read_chunks(data):
        if name == b"INST":
            cid, ln = struct.unpack("<II", body[:8])
            q = 8 + ln + 1  # class name, then the "is service" byte
            cnt, = struct.unpack("<I", body[q:q + 4]); q += 4
            classes[cid] = referents(body[q:q + 4 * cnt], cnt)
        elif name == b"PRNT":
            cnt, = struct.unpack("<I", body[1:5]); q = 5
            kids = referents(body[q:q + 4 * cnt], cnt); q += 4 * cnt
            pars = referents(body[q:q + 4 * cnt], cnt)
            parents.update(zip(kids, pars))
        elif name == b"PROP":
            cid, ln = struct.unpack("<II", body[:8])
            pname = body[8:8 + ln].decode()
            q = 8 + ln
            ptype = body[q]; q += 1
            if pname not in ("Name", "Source") or ptype != 1:  # 1 = string
                continue
            target = names if pname == "Name" else sources
            for r in classes.get(cid, []):
                sl, = struct.unpack("<I", body[q:q + 4]); q += 4
                target[r] = body[q:q + sl]; q += sl

    def path(r):
        parts = []
        while r is not None and r >= 0 and r in names:
            parts.append(names[r].decode(errors="replace").replace("/", "_"))
            r = parents.get(r)
        return "/".join(reversed(parts))

    seen = set()
    for r, src in sources.items():
        rel = path(r) or f"ref{r}"
        if rel in seen:  # two siblings with the same name: keep both
            rel = f"{rel}__ref{r}"
        seen.add(rel)
        fn = os.path.join(sys.argv[2], rel + ".luau")
        os.makedirs(os.path.dirname(fn), exist_ok=True)
        with open(fn, "wb") as f:
            f.write(src)
        print(f"{len(src):>8}  {fn}")
    print(f"{len(sources)} script(s)")


if __name__ == "__main__":
    main()