feat: converter accepts the constructs of goodluke and SaintlyCorruption

`init -490 screen`, `import x as y` and `from m import A as B`, classes with @renpy.pure, pickle hooks
on classes, `*` items in tuples and lists, generator functions (yield statements; the player runs them
to the end), multi-line expressions that need brackets, Null() and Animation() images, images given as
expressions (`"path/{}.png".format(x)`, f-strings), window statements in init, `offset` in ATL, input
widgets with their values and the focus/confirm actions, `continue` and `break` in screens, `drag`
and DiamondBox-style `fixed` with cell_size, `getattr(...)()` calls, transforms called with arguments,
and the whitelist for the new runtime. JSON files of a game are shipped as data files. Also fixes
blocking_names failing on a call of a call during full conversion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 17:28:13 +03:00
1 parent f2fcd05467
commit e66160cffa
12 files changed
+176 -31

No files matched your search

+52 -15
View File
@@ -25,7 +25,7 @@ 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", "hashlib", "uuid", "pygame"}
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"}
class Unsupported(Exception):
@@ -66,9 +66,14 @@ 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:
# a long expression continued on the next lines (`A | B`, `FontGroup().add(...)`) needs brackets
for candidate in (python2_operators(source), "(" + source.strip() + ")"):
try:
tree = ast.parse(candidate.strip(), mode="eval")
break
except SyntaxError:
continue
else:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
@@ -142,9 +147,9 @@ def expr(node: ast.AST) -> Expr:
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]}
return {"k": "list", "v": [element(e) for e in elts]}
case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]}
return {"k": "tuple", "v": [element(e) for e in elts]}
case ast.Dict(keys=keys, values=values):
# `{**other, "k": v}`: a spread entry is a star key with an empty value
return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None
@@ -208,6 +213,10 @@ def stmt(node: ast.stmt) -> list[Stmt]:
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.Yield(value=value)):
return [{"op": "yield", "v": None if value is None else expr(value)}]
case ast.Expr(value=ast.YieldFrom(value=value)):
return [{"op": "yield_from", "v": expr(value)}]
case ast.Expr(value=ast.Constant()):
return [] # docstring / bare literal
case ast.Expr(value=value):
@@ -239,16 +248,22 @@ def stmt(node: ast.stmt) -> list[Stmt]:
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 []
# `import time as t` / `from renpy.text.text import Text as RenpyText`: the new name is the old one
return [{"op": "set", "t": {"k": "name", "id": alias.asname},
"v": {"k": "name", "id": alias.name.split(".")[0] if isinstance(node, ast.Import) else alias.name}}
for alias in names if alias.asname and alias.asname != alias.name]
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=[]):
node = {"op": "def", "name": name, "params": params(args), "body": function_body(body),
"decos": [decorator(d) for d in decos]}
if is_generator(body):
node["gen"] = True
return [node]
case ast.ClassDef(name=name, bases=bases, keywords=[], body=body, decorator_list=decos) if all(ast.unparse(d) in TRANSPARENT_DECORATORS for d in decos):
return [{"op": "class", "name": name, "bases": [expr(b) for b in bases],
"body": class_body(name, body)}]
case ast.ClassDef(name=name):
@@ -264,6 +279,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
# Methods the player can run implicitly. Any other dunder method would silently
# never be called, so the converter refuses it instead.
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
"__contains__"}
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
@@ -296,19 +313,39 @@ def decorator(node: ast.expr) -> Expr:
raise Unsupported(f"декоратор {ast.unparse(node)}")
def is_generator(body: list[ast.stmt]) -> bool:
"""A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is
right for generators that end; `x = yield` (sending values in) and async code stay unsupported."""
module = ast.Module(body=body, type_ignores=[])
statement_yields = {id(n.value) for n in ast.walk(module)
if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))}
found = False
for n in ast.walk(module):
if isinstance(n, ast.Await):
raise Unsupported("async")
if isinstance(n, (ast.Yield, ast.YieldFrom)):
if id(n) not in statement_yields:
raise Unsupported("yield как выражение")
found = True
return found
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")
is_generator(body)
return stmts(body)
def element(node: ast.expr) -> Expr:
"""One item of a list or tuple; `*rest` spreads a sequence into it."""
return {"k": "star", "v": expr(node.value)} if isinstance(node, ast.Starred) else expr(node)
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):
and n.name not in SUPPORTED_DUNDERS | IGNORED_DUNDERS):
raise Unsupported(f"специальный метод {name}.{n.name}()")
return stmts(body)
return stmts([n for n in body if not (isinstance(n, ast.FunctionDef) and n.name in IGNORED_DUNDERS)])
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]: