feat(ariadne): export Ren'Py AST artifacts
This commit is contained in:
191
src/ariadne/tools/export-renpy-ast.py
Executable file
191
src/ariadne/tools/export-renpy-ast.py
Executable file
@@ -0,0 +1,191 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user