diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index ca53686..9edb07a 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -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}) diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index e380c90..62516d8 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -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": diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index d138f03..838e057 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -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 diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index 58d1f64..bce8c14 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -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]]: diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py index 9c75050..3c24bbd 100644 --- a/backend/app/converter/rpy_helpers.py +++ b/backend/app/converter/rpy_helpers.py @@ -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", diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index c6d8bfd..fa1964a 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -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 diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index a314f99..a46f6e8 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -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: diff --git a/backend/app/pgvn.py b/backend/app/pgvn.py index 02da68f..0bc930b 100644 --- a/backend/app/pgvn.py +++ b/backend/app/pgvn.py @@ -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}") diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index a84faad..45ff16f 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -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) == [] diff --git a/backend/tests/test_converter.py b/backend/tests/test_converter.py index b7c79d2..3b371c0 100644 --- a/backend/tests/test_converter.py +++ b/backend/tests/test_converter.py @@ -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 diff --git a/backend/tests/test_renpy_universal.py b/backend/tests/test_renpy_universal.py index 9f2057d..55388b9 100644 --- a/backend/tests/test_renpy_universal.py +++ b/backend/tests/test_renpy_universal.py @@ -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"] diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index c415333..38e029a 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -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"]