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

+3 -1
View File
@@ -223,6 +223,8 @@ class AtlParser:
if key == "offset": # `offset (x, y)` is xoffset and yoffset together
if isinstance(value, (list, tuple)) and len(value) == 2:
out["xoffset"], out["yoffset"] = value
elif isinstance(value, dict) and value.get("e", {}).get("k") in ("tuple", "list") and len(value["e"]["v"]) == 2: # `offset (0, -LIFT)`: each half is computed
out["xoffset"], out["yoffset"] = ({"e": half} for half in value["e"]["v"])
else:
self.warn("Не поддерживается ATL: offset не пара чисел", line)
continue
@@ -307,7 +309,7 @@ class AtlParser:
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
if seconds is not None:
steps.append({"op": "pause", "t": seconds})
elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
elif first in POSITION_PROPS or first == "offset" or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS):
props = self.props(parts, line)
if props:
steps.append({"op": "set", "props": props})
+20 -4
View File
@@ -124,6 +124,15 @@ CALLS |= set(NATIVE_CLASSES)
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
"colorsys.hls_to_rgb"}
CALLS |= {"next", "repr", "dict.fromkeys", "colorsys.rgb_to_hsv", "colorsys.hsv_to_rgb", "fnmatch.fnmatch",
"traceback.format_exc", "traceback.print_exc", "math.log1p", "math.expm1", "renpy.in_rollback",
"renpy.is_in_test", "renpy.retain_after_load", "renpy.context_nesting_level", "renpy.current_screen",
"renpy.substitutions.substitute", "renpy.set_screen_variable", "renpy.music.register_channel",
"renpy.music.get_loop", "renpy.music.get_channel"}
CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures",
"renpy.display.render.free_memory", "cache.get_total_size"}
CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
@@ -149,6 +158,10 @@ SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreensho
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
FileCurrentPage FilePageName FileSlotName""".split())
SCREEN_CALLS |= {"renpy.translation.translate_string"}
# Text fields and the actions around them (see InputValue in screens.ts)
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
METHODS_INPUT = {"Enable", "Disable", "Toggle"}
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
@@ -173,7 +186,7 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
METHODS |= NATIVE_METHODS
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
METHODS |= {"blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
METHODS |= {"add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
"add_focus", "circle", "rect", "line", "lines", "polygon"}
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
@@ -380,7 +393,8 @@ def blocking_names(data) -> list[str]:
elif isinstance(value, dict):
if value.get("k") == "call":
function = value["f"]
last = function.get("id") if function.get("k") == "name" else function.get("a")
last = function.get("id") if function.get("k") == "name" else (
function.get("a") if function.get("k") == "attr" else None) # `f()()` has neither
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
function["o"].get("k") == "name") else last
if dotted in waiting or last in known or last in waiting:
@@ -504,6 +518,7 @@ def audit_ir(data) -> None:
list(used_screens.values())])
user_names |= {name for name, _ in data.defaults}
user_names |= set(data.characters) | {"narrator"}
user_names |= set(data.transforms) | set(data.atl) # `store.move_in(0.3)`: a transform called with arguments
def dotted(value):
if value.get("k") == "name":
@@ -524,8 +539,9 @@ def audit_ir(data) -> None:
and (function.get("a") in METHODS or function.get("a") in user_methods
or function.get("a") in user_names))
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
is_method = True
if function.get("k") == "call" and (dotted(function.get("f", {})) in SCREEN_CALLS
or dotted(function.get("f", {})) == "getattr"):
is_method = True # an action a constructor returned, or `getattr(obj, "f", fallback)()`
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
if function.get("k") == "sub" and function["o"].get("k") == "call" \
and dotted(function["o"]["f"]) == "globals":
+14
View File
@@ -162,6 +162,16 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
for name in failed])
# JSON the game reads itself (`renpy.file("data/x.json").read()`): kept as it is, loaded before the game starts
data_files: list[str] = []
for p in sorted(game_dir.rglob("*.json")):
relative = p.relative_to(game_dir).as_posix()
if _is_engine_file(game_dir, p) or relative.lower().startswith(("cache/", "saves/")) or p.stat().st_size > DATA_FILE_LIMIT:
continue
blob_id, size = put_file(p, ".json")
files[f"data/{relative}"] = {"hash": blob_id, "size": size}
data_files.append(relative)
progress("Сборка формата PGVN")
_auto_images(data, aliases)
missing: set[str] = set()
@@ -212,6 +222,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"channels": data.channels,
"build": data.build,
"files": aliases,
"dataFiles": data_files,
"defaults": data.defaults,
"init": data.init_code,
"ui": _ui(gui, aliases),
@@ -328,6 +339,9 @@ def _used_screens(data: ScriptData) -> set[str]:
return used
DATA_FILE_LIMIT = 4 * 1024 * 1024
def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ENGINE_DIRS
+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]]:
+1 -1
View File
@@ -18,7 +18,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
_DISPLAYABLE_CALLS = {"Null", "Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
"ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip",
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
+16 -2
View File
@@ -152,6 +152,7 @@ class ScriptData:
self.warning_examples[kind] = where
LAZY_IMAGE_KINDS = {"fmt", "bin", "name", "attr", "sub", "if"}
LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$")
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
@@ -469,11 +470,14 @@ class RpyParser:
c.keyword("python")
self.init_python(line.block_source(), line)
return []
rest = c.rest().strip()
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
return self.statement(replace(line, text=rest), top_level=True)
for child in self.fold_live2d(line.block):
for node in self.statement(child, top_level=True):
if node["op"] == "exec":
self.d.init_code.extend(node["code"])
else:
elif node["op"] != "window": # `window hide` at init time changes nothing that is shown
self.d.warn("Оператор сцены внутри init", self.where(child))
return []
@@ -758,12 +762,15 @@ class RpyParser:
return {"file": s}
return {"ref": s}
if e["k"] != "call":
self.d.warn("Изображение задано выражением", self.where(line))
# a name, an f-string or a sum of strings: the picture is whatever it evaluates to when it is shown
self.d.warn("Изображение задано выражением", self.where(line), blocking=e["k"] not in LAZY_IMAGE_KINDS)
return {"expr": e}
fname = _dotted_name(e["f"])
args, kw = e["a"], e["kw"]
if fname == "Solid" and args:
return {"color": _const(args[0])}
if fname == "Null": # nothing to draw: a transparent picture
return {"color": "#00000000"}
if fname == "Text" and args:
return {"textExpr": args[0]}
if fname in ("Movie",):
@@ -815,6 +822,9 @@ class RpyParser:
layers.append({"pos": list(pos), "img": sub})
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
else None}
if fname == "Animation": # pictures that take turns, named by values known only while playing
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
return {"expr": e}
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
for a in args:
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
@@ -832,6 +842,10 @@ class RpyParser:
if props:
sub = {**sub, "props": {**sub.get("props", {}), **props}}
return sub
if fname == "Animation" or e["f"].get("k") == "attr" and not fname.startswith(("im.", "renpy.")):
# `Animation(...)` and `"path/{}.png".format(x)`: built from values known only while playing
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
return {"expr": e}
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
return None
+12 -4
View File
@@ -32,7 +32,7 @@ OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect",
"drag"}
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"}
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea", "input"}
POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
@@ -50,11 +50,11 @@ BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_ma
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side
drag_name draggable droppable drag_handle drag_joined drag_offscreen""".split())
drag_name draggable droppable drag_handle drag_joined drag_offscreen cell_size fit start_lower default allow exclude mask length copypaste pixel_width multiline""".split())
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
"right_gutter", "top_gutter", "bottom_gutter")
IGNORED = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll",
IGNORED = {"capture", "axis", "key_events", "base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll",
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
"selected_insensitive")
@@ -208,6 +208,8 @@ class ScreenParser:
node = self.use(line, words)
if node:
nodes.append(node)
elif first in ("continue", "break"):
nodes.append({"c": first})
elif first == "transclude":
nodes.append({"c": "transclude"})
elif first == "pass":
@@ -222,8 +224,10 @@ class ScreenParser:
i = len(lines)
elif first == "python" and line.text.strip() == "python:" or first == "$":
nodes.extend(self.python(line, first))
elif first == "default":
elif first == "default" and "=" in text:
self.default(line)
elif first == "default": # `default "Bob"` inside an input: its starting text
props.update(self.properties(words, line))
elif first == "key":
node = self.key(line, words)
if node:
@@ -391,6 +395,8 @@ class ScreenParser:
return None
if kind in ("bar", "vbar"):
props["vertical"] = {"k": "const", "v": kind == "vbar"}
if kind == "fixed" and "cell_size" in props: # the game's DiamondBox (decompiled as a plain `fixed`)
kind = "diamondbox"
node: Node = {"w": kind, "args": args, "props": props, "body": body}
if conditional:
node["propIf"] = conditional
@@ -419,6 +425,8 @@ class ScreenParser:
if cond is not None:
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
if text == "None":
return {}
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*\.\w+", text): # `icon["at"]`, `item.at`
dynamic = self.expr(text, line)
if dynamic is not None:
+1 -1
View File
@@ -127,7 +127,7 @@ def statements(code, depth=0):
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (),
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",),
"assert": ("c",)}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
+12 -1
View File
@@ -80,7 +80,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n yield 1\nlabel start:\n $ helper()', "init python"),
('init python:\n def helper():\n received = yield 1\nlabel start:\n $ helper()', "init python"),
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
('label start:\n jump missing', "missing"),
])
@@ -362,3 +362,14 @@ def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatc
assert not upload_path(123).exists()
finally:
get_settings.cache_clear()
def test_blocking_names_survives_a_call_of_a_call():
from app.converter.compatibility import blocking_names
data = ScriptData()
source = ('init python:\n def helper():\n return getattr(renpy.store, "x", lambda: 1)()\n'
'label start:\n $ helper()\n')
data.prescan(source)
RpyParser(data, "t.rpy").parse(source)
assert blocking_names(data) == []
+2 -2
View File
@@ -57,10 +57,10 @@ def test_if_chain_and_menu():
def test_unsupported_python_is_reported_not_fatal():
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
d = parse("init python:\n def f():\n received = yield 1\n x = 1\nlabel start:\n $ y = x\n")
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
from app.converter.compatibility import audit_ir
d = parse("init python:\n def f():\n yield 1\nlabel start:\n $ f()\n")
d = parse("init python:\n def f():\n received = yield 1\nlabel start:\n $ f()\n")
audit_ir(d)
assert d.blockers
+30
View File
@@ -235,3 +235,33 @@ def test_init_if_has_live2d_keeps_only_the_else_branch_and_crop_call_glued_to_a_
def test_static_atl_line_with_a_spaced_crop_tuple():
data = parse('image plap:\n "base"\n crop (1697,54, 172, 94)\nimage slurp:\n "base"\n crop (1, 2, 3, 4) xanchor 0.5\n')
assert not data.blockers
def test_priority_screen_continue_and_offset_with_computed_halves():
data = parse('init -490 screen s(items):\n for item in items:\n if item == 1:\n continue\n text "[item]"\n'
'transform lift:\n linear 0.2 offset (0, -LIFT)\nlabel start:\n call screen s([1, 2])\n')
assert "s" in data.screens
loop = data.screens["s"]["body"][0]
assert loop["c"] == "for" and loop["body"][0]["branches"][0]["body"] == [{"c": "continue"}]
assert not data.blockers
def test_import_alias_decorated_class_null_image_window_in_init_and_offset_line():
from app.converter import pyexpr
code = pyexpr.parse_stmts("import time as stamp\nfrom renpy.text.text import Text as RenpyText\n"
"@renpy.pure\nclass Typing(VariableInputValue):\n pass\n")
assert code[0] == {"op": "set", "t": {"k": "name", "id": "stamp"}, "v": {"k": "name", "id": "time"}}
assert code[1]["v"] == {"k": "name", "id": "Text"} and code[2]["op"] == "class"
data = parse('init 1:\n image blank = Null()\ninit -1:\n window hide\ntransform t:\n offset (0, 0)\n on idle:\n linear 0.2 offset (1, 2)\n')
assert data.images["blank"] == {"color": "#00000000"} and not data.blockers
def test_starred_tuple_items_and_pickle_hooks_are_accepted():
from app import pgvn
from app.converter import pyexpr
code = pyexpr.parse_stmts("pair = (first, *rest)\nclass Scene:\n def __setstate__(self, state):\n pass\n def run(self):\n return 1\n")
pgvn.statements(code)
assert code[0]["v"]["v"][1] == {"k": "star", "v": {"k": "name", "id": "rest"}}
assert [m["name"] for m in code[1]["body"]] == ["run"]
+13
View File
@@ -246,3 +246,16 @@ def test_dynamic_at_value_and_draggable_box():
body = data.screens["s"]["body"]
assert body[0]["props"]["at"][0]["dyn"]["k"] == "sub"
assert body[1]["w"] == "drag" and "draggable" in body[1]["props"]
def test_input_widget_values_and_focus_actions_are_accepted():
data = parse('screen s():\n default name_value = VariableInputValue("name")\n input:\n id "player_name_input"\n'
' value name_value\n length 12\n input:\n default "Bob"\n allow "ab"\n'
' textbutton "x" action [name_value.Toggle(), Confirm("sure?", yes=NullAction()), CaptureFocus("tip")]\n'
' for i in [1, 2]:\n if i == 1:\n continue\n text "[i]"\n'
'label start:\n call screen s\n')
audit_ir(data)
require_supported_scripts(data)
body = data.screens["s"]["body"]
assert [n.get("w") for n in body[:3]] == ["input", "input", "textbutton"]
assert "default" in body[1]["props"] and "value" in body[0]["props"]