renpy.random/random get choices, sample, randrange and Random(seed); frozenset, iter and hash are builtins. User classes may define __hash__ (dict keys), __iter__, __getitem__ and __contains__; the converter accepts them instead of rejecting the whole class, which removes the Perk/Skill/Status wall in SaintlyCorruption. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
367 lines
17 KiB
Python
367 lines
17 KiB
Python
"""Translate the subset of Python used by visual novels into PGVN IR.
|
|
|
|
Expressions become JSON trees ``{"k": kind, ...}``; statements become
|
|
``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
|
|
Anything outside the subset raises :class:`Unsupported` so the caller can
|
|
report it and degrade gracefully.
|
|
"""
|
|
|
|
import ast
|
|
from typing import Any
|
|
|
|
Expr = dict[str, Any]
|
|
Stmt = dict[str, Any]
|
|
|
|
BIN_OPS = {
|
|
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
|
|
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
|
|
ast.LShift: "<<", ast.RShift: ">>",
|
|
}
|
|
CMP_OPS = {
|
|
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=",
|
|
ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not",
|
|
}
|
|
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
|
|
|
|
# Modules whose import is harmless: the player provides the functions it supports.
|
|
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
|
"itertools", "string", "functools", "operator", "json", "gc"}
|
|
|
|
|
|
class Unsupported(Exception):
|
|
pass
|
|
|
|
|
|
def const(v: Any) -> Expr:
|
|
return {"k": "const", "v": v}
|
|
|
|
|
|
def python2_operators(source: str) -> str:
|
|
"""Ren'Py 7 games are Python 2: `a <> b` means `a != b`. Quoted text is left alone."""
|
|
out: list[str] = []
|
|
quote = ""
|
|
i = 0
|
|
while i < len(source):
|
|
ch = source[i]
|
|
if quote:
|
|
out.append(ch)
|
|
if ch == "\\" and i + 1 < len(source):
|
|
out.append(source[i + 1])
|
|
i += 1
|
|
elif ch == quote:
|
|
quote = ""
|
|
elif ch in "\"'":
|
|
quote = ch
|
|
out.append(ch)
|
|
elif source.startswith("<>", i):
|
|
out.append("!=")
|
|
i += 1
|
|
else:
|
|
out.append(ch)
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
def parse_expr(source: str) -> Expr:
|
|
try:
|
|
tree = ast.parse(source.strip(), mode="eval")
|
|
except SyntaxError as e:
|
|
try:
|
|
tree = ast.parse(python2_operators(source).strip(), mode="eval")
|
|
except SyntaxError:
|
|
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
|
return expr(tree.body)
|
|
|
|
|
|
def parse_stmts(source: str) -> list[Stmt]:
|
|
try:
|
|
tree = python_ast(source)
|
|
except SyntaxError as e:
|
|
try:
|
|
tree = python_ast(python2_operators(source))
|
|
except SyntaxError:
|
|
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
|
|
return stmts(tree.body)
|
|
|
|
|
|
def python_ast(source: str, mode: str = "exec") -> ast.AST:
|
|
"""Parse an embedded block as Ren'Py does, preserving string contents/columns.
|
|
|
|
Physical continuation lines may start at column zero. Wrapping the indented
|
|
code avoids dedenting those lines or changing whitespace inside literals.
|
|
"""
|
|
first = next((line for line in source.splitlines() if line.strip()), "")
|
|
indented = mode == "exec" and first.startswith((" ", "\t"))
|
|
tree = ast.parse("if True:\n" + source if indented else source, mode=mode)
|
|
if indented:
|
|
tree.body = tree.body[0].body
|
|
ast.increment_lineno(tree, -1)
|
|
return tree
|
|
|
|
|
|
def literal(source: str) -> Any:
|
|
"""Evaluate a constant expression at conversion time, or raise Unsupported."""
|
|
try:
|
|
return ast.literal_eval(source.strip())
|
|
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
|
|
raise Unsupported(f"не константа: {source!r}") from e
|
|
|
|
|
|
def expr(node: ast.AST) -> Expr:
|
|
match node:
|
|
case ast.Constant(value=v):
|
|
if isinstance(v, bytes):
|
|
v = v.decode("utf-8", "replace")
|
|
if isinstance(v, complex) or v is Ellipsis:
|
|
raise Unsupported("неподдерживаемая константа")
|
|
return const(v)
|
|
case ast.Name(id=name):
|
|
return {"k": "name", "id": name}
|
|
case ast.Attribute(value=ast.Name(id="store"), attr=attr):
|
|
return {"k": "name", "id": attr}
|
|
case ast.Attribute(value=ast.Attribute(value=ast.Name(id="renpy"), attr="store"), attr=attr):
|
|
return {"k": "name", "id": attr} # renpy.store.x is the same variable as x
|
|
case ast.Attribute(value=value, attr=attr):
|
|
return {"k": "attr", "o": expr(value), "a": attr}
|
|
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
|
|
return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
|
|
case ast.Subscript(value=value, slice=index):
|
|
return {"k": "sub", "o": expr(value), "i": expr(index)}
|
|
case ast.BinOp(left=l, op=op, right=r):
|
|
if type(op) not in BIN_OPS:
|
|
raise Unsupported(f"оператор {type(op).__name__}")
|
|
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
|
|
case ast.BoolOp(op=op, values=values):
|
|
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
|
|
"v": [expr(v) for v in values]}
|
|
case ast.UnaryOp(op=op, operand=operand):
|
|
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
|
|
case ast.Compare(left=l, ops=ops, comparators=rs):
|
|
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
|
|
"r": [expr(r) for r in rs]}
|
|
case ast.IfExp(test=t, body=b, orelse=e):
|
|
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
|
|
case ast.List(elts=elts) | ast.Set(elts=elts):
|
|
return {"k": "list", "v": [expr(e) for e in elts]}
|
|
case ast.Tuple(elts=elts):
|
|
return {"k": "tuple", "v": [expr(e) for e in elts]}
|
|
case ast.Dict(keys=keys, values=values):
|
|
if any(k is None for k in keys):
|
|
raise Unsupported("распаковка ** в словаре")
|
|
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
|
|
case ast.Call(func=func, args=args, keywords=kws):
|
|
call = {"k": "call", "f": expr(func),
|
|
"a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a)
|
|
for a in args],
|
|
"kw": {k.arg: expr(k.value) for k in kws if k.arg is not None}}
|
|
if any(k.arg is None for k in kws):
|
|
call["ks"] = [expr(k.value) for k in kws if k.arg is None] # **mapping, in order
|
|
return call
|
|
case ast.JoinedStr(values=values):
|
|
parts: list[Any] = []
|
|
for v in values:
|
|
if isinstance(v, ast.Constant):
|
|
parts.append(v.value)
|
|
elif isinstance(v, ast.FormattedValue):
|
|
parts.append(expr(v.value))
|
|
return {"k": "fmt", "v": parts}
|
|
case ast.ListComp(elt=elt, generators=gens) | ast.GeneratorExp(elt=elt, generators=gens) \
|
|
| ast.SetComp(elt=elt, generators=gens):
|
|
return {"k": "comp", "e": expr(elt), "gens": generators(gens)}
|
|
case ast.DictComp(key=key, value=value, generators=gens):
|
|
return {"k": "comp", "e": expr(key), "v": expr(value), "gens": generators(gens)}
|
|
case ast.Lambda(args=args, body=body):
|
|
return {"k": "lambda", "params": params(args), "body": expr(body)}
|
|
raise Unsupported(f"выражение {type(node).__name__}")
|
|
|
|
|
|
def _opt(node: ast.AST | None) -> Expr | None:
|
|
return None if node is None else expr(node)
|
|
|
|
|
|
def target(node: ast.AST) -> Expr:
|
|
match node:
|
|
case ast.Name() | ast.Attribute() | ast.Subscript():
|
|
return expr(node)
|
|
case ast.Tuple(elts=elts) | ast.List(elts=elts):
|
|
return {"k": "tuple", "v": [target(e) for e in elts]}
|
|
raise Unsupported(f"присваивание в {type(node).__name__}")
|
|
|
|
|
|
def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
|
|
out: list[Stmt] = []
|
|
for n in nodes:
|
|
out.extend(stmt(n))
|
|
return out
|
|
|
|
|
|
def stmt(node: ast.stmt) -> list[Stmt]:
|
|
match node:
|
|
case ast.Assign(targets=targets, value=value):
|
|
v = expr(value)
|
|
return [{"op": "set", "t": target(t), "v": v} for t in targets]
|
|
case ast.AnnAssign(target=t, value=value) if value is not None:
|
|
return [{"op": "set", "t": target(t), "v": expr(value)}]
|
|
case ast.AugAssign(target=t, op=op, value=value):
|
|
if type(op) not in BIN_OPS:
|
|
raise Unsupported(f"оператор {type(op).__name__}")
|
|
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
|
case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."):
|
|
return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser
|
|
case ast.Expr(value=ast.Constant()):
|
|
return [] # docstring / bare literal
|
|
case ast.Expr(value=value):
|
|
return [{"op": "eval", "e": expr(value)}]
|
|
case ast.If(test=test, body=body, orelse=orelse):
|
|
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
|
|
case ast.While(test=test, body=body, orelse=orelse):
|
|
return [{"op": "while", "c": expr(test), "body": stmts(body), "else": stmts(orelse)}]
|
|
case ast.For(target=t, iter=it, body=body, orelse=orelse):
|
|
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body),
|
|
"else": stmts(orelse)}]
|
|
case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final):
|
|
return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers],
|
|
"else": stmts(orelse), "final": stmts(final)}]
|
|
case ast.Match(subject=subject, cases=cases):
|
|
return match_statement(subject, cases)
|
|
case ast.Raise(exc=exc):
|
|
return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped
|
|
case ast.Assert(test=test, msg=msg):
|
|
return [{"op": "assert", "c": expr(test), "m": None if msg is None else expr(msg)}]
|
|
case ast.Delete(targets=targets):
|
|
return [{"op": "del", "t": target(t)} for t in targets]
|
|
case ast.Pass():
|
|
return []
|
|
case ast.Break():
|
|
return [{"op": "break"}]
|
|
case ast.Continue():
|
|
return [{"op": "continue"}]
|
|
case ast.Import(names=names) | ast.ImportFrom(names=names):
|
|
mod = getattr(node, "module", None) or names[0].name
|
|
if mod.split(".")[0] in KNOWN_MODULES:
|
|
return []
|
|
raise Unsupported(f"импорт модуля {mod}")
|
|
case ast.FunctionDef(decorator_list=decos) if any(
|
|
ast.unparse(d).split("(")[0] in INACTIVE_VARIANTS for d in decos):
|
|
return [] # screen-size/touch variants never apply to the desktop browser player
|
|
case ast.FunctionDef(name=name, args=args, body=body, decorator_list=decos):
|
|
decos = [d for d in decos if ast.unparse(d) not in TRANSPARENT_DECORATORS]
|
|
return [{"op": "def", "name": name, "params": params(args), "body": function_body(body),
|
|
"decos": [decorator(d) for d in decos]}]
|
|
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=[]):
|
|
return [{"op": "class", "name": name, "bases": [expr(b) for b in bases],
|
|
"body": class_body(name, body)}]
|
|
case ast.ClassDef(name=name):
|
|
raise Unsupported(f"определение класса {name} с декораторами или метаклассом")
|
|
case ast.Return(value=value):
|
|
return [{"op": "return", "v": None if value is None else expr(value)}]
|
|
case ast.Global(names=names):
|
|
return [{"op": "global", "names": list(names)}]
|
|
case ast.Nonlocal(names=names):
|
|
return [{"op": "nonlocal", "names": list(names)}]
|
|
raise Unsupported(f"оператор {type(node).__name__}")
|
|
|
|
|
|
# Methods the player can run implicitly. Any other dunder method would silently
|
|
# never be called, so the converter refuses it instead.
|
|
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
|
|
"__contains__"}
|
|
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
|
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
|
DECORATORS = {"staticmethod", "classmethod", "property"}
|
|
|
|
|
|
def params(args: ast.arguments) -> list[dict[str, Any]]:
|
|
"""Parameter list: kind ``p`` positional/keyword, ``k`` keyword-only, ``*`` and ``**`` collectors."""
|
|
out: list[dict[str, Any]] = []
|
|
positional = [*args.posonlyargs, *args.args]
|
|
defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
|
|
for a, d in zip(positional, defaults):
|
|
out.append({"n": a.arg, "d": None if d is None else expr(d), "kind": "p"})
|
|
if args.vararg:
|
|
out.append({"n": args.vararg.arg, "d": None, "kind": "*"})
|
|
for a, d in zip(args.kwonlyargs, args.kw_defaults):
|
|
out.append({"n": a.arg, "d": None if d is None else expr(d), "kind": "k"})
|
|
if args.kwarg:
|
|
out.append({"n": args.kwarg.arg, "d": None, "kind": "**"})
|
|
return out
|
|
|
|
|
|
def decorator(node: ast.expr) -> Expr:
|
|
match node:
|
|
case ast.Name(id=name) if name in DECORATORS:
|
|
return {"k": "name", "id": name}
|
|
case ast.Attribute(value=ast.Name(), attr="setter"):
|
|
return expr(node)
|
|
raise Unsupported(f"декоратор {ast.unparse(node)}")
|
|
|
|
|
|
def function_body(body: list[ast.stmt]) -> list[Stmt]:
|
|
for n in ast.walk(ast.Module(body=body, type_ignores=[])):
|
|
if isinstance(n, (ast.Yield, ast.YieldFrom, ast.Await)):
|
|
raise Unsupported("генераторы и async")
|
|
return stmts(body)
|
|
|
|
|
|
def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
|
|
for n in body:
|
|
if (isinstance(n, ast.FunctionDef) and n.name.startswith("__") and n.name.endswith("__")
|
|
and n.name not in SUPPORTED_DUNDERS):
|
|
raise Unsupported(f"специальный метод {name}.{n.name}()")
|
|
return stmts(body)
|
|
|
|
|
|
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
|
|
if any(g.is_async for g in gens):
|
|
raise Unsupported("async comprehension")
|
|
return [{"t": target(g.target), "it": expr(g.iter), "if": [expr(i) for i in g.ifs]} for g in gens]
|
|
|
|
|
|
def handler(node: ast.ExceptHandler) -> dict[str, Any]:
|
|
"""``types`` is None for a bare ``except:``; ``name`` binds the exception."""
|
|
return {"types": None if node.type is None else expr(node.type), "name": node.name,
|
|
"body": stmts(node.body)}
|
|
|
|
|
|
_match_counter = 0
|
|
|
|
|
|
def match_statement(subject: ast.expr, cases: list[ast.match_case]) -> list[Stmt]:
|
|
"""`match` becomes an if/elif chain over a temporary holding the subject."""
|
|
global _match_counter
|
|
_match_counter += 1
|
|
temp = f"_pgm_{_match_counter}"
|
|
name = {"k": "name", "id": temp}
|
|
|
|
def pattern(p: ast.pattern) -> tuple[Expr, list[Stmt]]:
|
|
match p:
|
|
case ast.MatchValue(value=value):
|
|
return {"k": "cmp", "l": name, "ops": ["=="], "r": [expr(value)]}, []
|
|
case ast.MatchSingleton(value=value):
|
|
return {"k": "cmp", "l": name, "ops": ["is"], "r": [const(value)]}, []
|
|
case ast.MatchAs(pattern=None, name=None):
|
|
return const(True), []
|
|
case ast.MatchAs(pattern=None, name=capture):
|
|
return const(True), [{"op": "set", "t": {"k": "name", "id": capture}, "v": name}]
|
|
case ast.MatchAs(pattern=inner, name=capture):
|
|
cond, binds = pattern(inner)
|
|
if capture:
|
|
binds = [*binds, {"op": "set", "t": {"k": "name", "id": capture}, "v": name}]
|
|
return cond, binds
|
|
case ast.MatchOr(patterns=alternatives):
|
|
parts = [pattern(a) for a in alternatives]
|
|
if any(binds for _, binds in parts):
|
|
raise Unsupported("match: захват внутри альтернативы")
|
|
return {"k": "bool", "op": "or", "v": [c for c, _ in parts]}, []
|
|
raise Unsupported(f"match: шаблон {type(p).__name__}")
|
|
|
|
chain: list[Stmt] = []
|
|
for case in reversed(cases):
|
|
cond, binds = pattern(case.pattern)
|
|
if case.guard is not None:
|
|
if binds:
|
|
raise Unsupported("match: захват вместе с условием guard")
|
|
cond = {"k": "bool", "op": "and", "v": [cond, expr(case.guard)]}
|
|
chain = [{"op": "if", "c": cond, "then": [*binds, *stmts(case.body)], "else": chain}]
|
|
return [{"op": "set", "t": name, "v": expr(subject)}, *chain]
|