#!/usr/bin/env python """Export a Ren'Py AST pickle to Ariadne-neutral JSON records. Run this with the Python interpreter bundled with the target Ren'Py launcher. The script intentionally emits a small normalized record set instead of exposing Ren'Py objects to Rust. """ from __future__ import print_function import argparse import json import os import pickle import struct import sys import zlib from collections import Counter try: text_type = unicode # noqa: F821 - Python 2 runtime. except NameError: text_type = str RPYC2_HEADER = b"RENPY RPC2" def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--renpy-root", required=True, help="Launcher root containing the renpy package") parser.add_argument("--namespace", required=True, help="Namespace assigned to exported nodes") parser.add_argument("--rpyc", required=True, help="Compiled Ren'Py script file") parser.add_argument("--slot", type=int, default=1, choices=(1, 2), help="RPYC slot to export") parser.add_argument("--output", required=True, help="JSON output path") return parser.parse_args() def install_minimal_renpy_runtime(renpy): class Obj(object): pass script = Obj() script.all_pyexpr = [] script.all_pycode = [] script.record_pycode = False log = Obj() log.mutated = {} game = Obj() game.script = script game.log = log renpy.game = game def read_rpyc_slot(path, slot): with open(path, "rb") as handle: data = handle.read() if not data.startswith(RPYC2_HEADER): if slot != 1: raise ValueError("legacy rpyc files only have slot 1") return zlib.decompress(data) pos = len(RPYC2_HEADER) while True: header_slot, start, length = struct.unpack("III", data[pos : pos + 12]) if header_slot == slot: return zlib.decompress(data[start : start + length]) if header_slot == 0: raise ValueError("slot {0} not found".format(slot)) pos += 12 def py_string(value): if value is None: return None if isinstance(value, bytes): return value.decode("utf-8", "replace") return text_type(value) def simple_value(value): if value is None or isinstance(value, (bool, int, float)): return value if isinstance(value, (str, bytes)): return py_string(value) if isinstance(value, tuple): return [simple_value(item) for item in value] if isinstance(value, list): return [simple_value(item) for item in value] return py_string(value) def node_fields(node): kind = type(node).__name__ if kind == "Label": return {"name": py_string(node.name)} if kind == "Say": return { "who": py_string(node.who), "what": py_string(node.what), "identifier": py_string(getattr(node, "identifier", None)), } if kind == "Jump": return {"target": py_string(node.target), "expression": bool(node.expression)} if kind == "Call": return {"label": py_string(node.label), "expression": bool(node.expression)} if kind == "If": return {"conditions": [simple_value(condition) for condition, _block in node.entries]} if kind == "Menu": return { "items": [ {"label": py_string(label), "condition": simple_value(condition), "has_block": block is not None} for label, condition, block in node.items ] } if kind == "Python": return {"source": py_string(node.code.source), "store": py_string(node.store), "hide": bool(node.hide)} return {} def flatten(stmts): nodes = [] for stmt in stmts: stmt.get_children(nodes.append) return nodes def main(): args = parse_args() renpy_root = os.path.abspath(args.renpy_root) sys.path.insert(0, renpy_root) import renpy # pylint: disable=import-error,import-outside-toplevel import renpy.object # pylint: disable=import-error,unused-import,import-outside-toplevel import renpy.ast # pylint: disable=import-error,unused-import,import-outside-toplevel import renpy.python # pylint: disable=import-error,unused-import,import-outside-toplevel install_minimal_renpy_runtime(renpy) payload = pickle.loads(read_rpyc_slot(args.rpyc, args.slot)) metadata, stmts = payload nodes = flatten(stmts) line_counts = Counter((py_string(node.filename), int(node.linenumber)) for node in nodes) seen = Counter() records = [] for node in nodes: path = py_string(node.filename) line = int(node.linenumber) occurrence = seen[(path, line)] seen[(path, line)] += 1 records.append( { "key": { "namespace": args.namespace, "path": path, "line": line, "occurrence": occurrence, }, "kind": type(node).__name__, "fields": node_fields(node), } ) export = { "schema": "ariadne-renpy-ast-adapter-prototype-v1", "namespace": args.namespace, "renpy_version": py_string(renpy.version), "script_version": metadata.get("version"), "slot": args.slot, "source": os.path.abspath(args.rpyc), "node_count": len(records), "ambiguous_source_locations": sum(1 for count in line_counts.values() if count > 1), "records": records, } output = os.path.abspath(args.output) parent = os.path.dirname(output) if parent and not os.path.isdir(parent): os.makedirs(parent) with open(output, "w") as handle: json.dump(export, handle, indent=2, sort_keys=True) handle.write("\n") print(json.dumps({key: export[key] for key in ("renpy_version", "script_version", "node_count", "ambiguous_source_locations")}, indent=2, sort_keys=True)) if __name__ == "__main__": main()