Converter: cover real Ren'Py games better, add folder import and dry run

Checked against six real games (Ren'Py 7.5 and 8.x):
- nested labels and named menus become jump anchors inside label bodies
- call with arguments and labels with parameters (dynamically scoped)
- frame-by-frame ATL images, Movie frames, layeredimage, camera, Shake,
  custom audio channels via register_channel, show text with expressions
- generator expressions in Python, no SyntaxWarning noise from decompiled code

Also:
- app.import_dir converts an unpacked game folder without changing it
- app.converter.analyze dry-runs the script part of the converter
- imports use their own "importing" status so a worker restart can't hijack them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:23:20 +03:00
1 parent 35033fb86c
commit a97b46079e
18 files changed
+3456 -2695

No files matched your search

+189 -185
View File
@@ -1,185 +1,189 @@
"""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"}
class Unsupported(Exception):
pass
def const(v: Any) -> Expr:
return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr:
try:
tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body)
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
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=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):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}}
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=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)]):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
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.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=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
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(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")
"""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"}
class Unsupported(Exception):
pass
def const(v: Any) -> Expr:
return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr:
try:
tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body)
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
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=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):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}}
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=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
ifs=ifs, is_async=0)])
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
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.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=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
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(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")