"""Ren'Py script (.rpy) → PGVN IR.""" import ast import re from collections import Counter from contextlib import contextmanager from dataclasses import dataclass, field, replace from typing import Any from app.converter import pyexpr from app.converter.atl import WARPER, AtlParser, initial_props, static_props from app.converter.pyexpr import Expr, Stmt, Unsupported, const from app.converter.rpy_helpers import ( _CALL_LINE, _DISPLAYABLE_CALLS, _MEDIA_EXTS, _NUMBER_LINE, _SHOW_KEYWORDS, POSITION_PROPS, _assign_name, _atl_props, _const, _dotted_name, _jump_or_call, _num, _parse_py, _split_top, _static_props, ) from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree from app.converter.screens import ScreenParser, referenced_screens, tokens from app.games.renpy_native import COMPILED_IMAGE_CLASSES, NATIVE_CLASSES, NATIVE_IMPORTS Node = dict[str, Any] TRANSITIONS: dict[str, dict[str, Any]] = { "dissolve": {"t": "dissolve", "d": 0.5}, "fade": {"t": "fade", "out": 0.5, "hold": 0.0, "in": 0.5, "color": "#000000"}, "pixellate": {"t": "dissolve", "d": 1.0}, "move": {"t": "move", "d": 0.5}, "ease": {"t": "move", "d": 0.5}, "vpunch": {"t": "punch", "dir": "v", "d": 0.3}, "hpunch": {"t": "punch", "dir": "h", "d": 0.3}, "blinds": {"t": "dissolve", "d": 1.0}, "squares": {"t": "dissolve", "d": 1.0}, "wipeleft": {"t": "wipe", "dir": "left", "d": 1.0}, "wiperight": {"t": "wipe", "dir": "right", "d": 1.0}, "wipeup": {"t": "wipe", "dir": "up", "d": 1.0}, "wipedown": {"t": "wipe", "dir": "down", "d": 1.0}, "zoomin": {"t": "dissolve", "d": 0.5}, "zoomout": {"t": "dissolve", "d": 0.5}, } for _side in ("left", "right", "top", "bottom"): for _kind in ("movein", "moveout", "easein", "easeout", "slide", "slideaway", "push", "irisin", "irisout"): TRANSITIONS.setdefault(f"{_kind}{_side}", {"t": "slide", "dir": _side, "d": 0.5, "kind": _kind}) TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pixellate", "MoveTransition", "CropMove", "PushMove", "ComposeTransition", "MultipleTransition", "Move"} POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top", "offscreenleft", "offscreenright", "default", "reset"} CHANNELS = {"music", "sound", "voice", "audio", "movie"} # Callables that look like transforms in `at` but are displayable/transition factories KNOWN_FACTORIES = {"Position", "Transform", "Shake", "SnoopyShake", "Move", "Dissolve", "Fade", "Pause"} SKIP_BLOCKS = {"screen", "style", "translate", "testcase"} # Creator-defined statements of the Ren'Py cookbook that the converter implements (see creator_statements) CREATOR_STATEMENTS = {"random", "block"} ENGINE_SETTINGS = {"config", "gui", "build", "preferences", "style", "_preferences"} BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"} @dataclass class ScriptData: phone_registers: int = 0 # `init phone register` blocks read so far (they get numbered functions) characters: dict[str, dict[str, Any]] = field(default_factory=dict) images: dict[str, dict[str, Any]] = field(default_factory=dict) transforms: dict[str, dict[str, Any]] = field(default_factory=dict) # every `transform name` of the game, known before any file is parsed (files are read in any order) transform_names: set[str] = field(default_factory=set) atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name # A transform's unsupported constructs only matter once something uses it (Ren'Py's stock # screens.rpy defines several that no game ever shows). atl_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict) atl_uses: dict[str, set[str]] = field(default_factory=dict) atl_referenced: set[str] = field(default_factory=set) screens: dict[str, dict[str, Any]] = field(default_factory=dict) styles: dict[str, dict[str, Any]] = field(default_factory=dict) # Like transforms, a screen only matters once something shows it screen_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict) screen_used: set[str] = field(default_factory=set) statements: set[str] = field(default_factory=set) # creator-defined statements the game registers random_counter: int = 0 # image name -> [(message, where)] of pictures the converter could not build; see audit_ir deferred_images: dict[str, list[tuple[str, str]]] = field(default_factory=dict) _captured: list | None = None # name -> (message, where) of definitions the converter could not translate; see audit_ir deferred: dict[str, list[tuple[str, str]]] = field(default_factory=dict) transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict) audio: dict[str, str] = field(default_factory=dict) defaults: list[list[Any]] = field(default_factory=list) init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order gui: dict[str, Any] = field(default_factory=dict) channels: dict[str, dict[str, Any]] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict) build: dict[str, Any] = field(default_factory=dict) modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels warnings: Counter = field(default_factory=Counter) blockers: Counter = field(default_factory=Counter) blocker_examples: dict[str, str] = field(default_factory=dict) warning_examples: dict[str, str] = field(default_factory=dict) stats: Counter = field(default_factory=Counter) @contextmanager def capture(self): """Collect the warnings raised inside the block instead of recording them: (kind, where, blocking).""" previous, self._captured = self._captured, [] try: yield self._captured finally: self._captured = previous def prescan(self, source: str) -> None: """Facts about the whole game that statements need before their own file is parsed: the names of transforms, and the creator-defined statements the game registers.""" self.transform_names.update(re.findall(r"^[ ]*transform[ ]+([A-Za-z_]\w*)", source, re.M)) for name in re.findall(r"renpy\.register_statement\(\s*[\"'](\w+)[\"']", source): if name in CREATOR_STATEMENTS: self.statements.add(name) def use_screen(self, name: str) -> None: """A screen is shown by the game: whatever it cannot do now blocks the import.""" self.screen_used.add(name) for message, where in self.screen_pending.pop(name, []): self.warn(message, where) definition = self.screens.get(name) if definition: for other in referenced_screens(definition, self.screens): if other not in self.screen_used or other in self.screen_pending: self.use_screen(other) def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None: if self._captured is not None: self._captured.append((kind, where, blocking)) return self.warnings[kind] += 1 if blocking: self.blockers[kind] += 1 if where: self.blocker_examples.setdefault(kind, where) if where and kind not in self.warning_examples: 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+["'])""") class RpyParser: def __init__(self, data: ScriptData, filename: str): self.d = data self.filename = filename self.global_label = "" def label_ref(self, name: str) -> str: """Resolve `.local` label names against the enclosing global label.""" return self.global_label + name if name.startswith(".") else name # --- helpers ------------------------------------------------------------- def where(self, line: Line) -> str: return f"{self.filename}:{line.lineno}" def cursor(self, line: Line, text: str | None = None) -> Cursor: return Cursor(line.text if text is None else text, self.filename, line.lineno) def expr(self, source: str, line: Line, default: Expr | None = None) -> Expr | None: try: return pyexpr.parse_expr(source) except Unsupported as e: self.d.warn(f"Выражение: {e}", self.where(line)) return default def python(self, source: str, line: Line) -> list[Stmt]: """Translate python code statement by statement, skipping unsupported ones.""" try: tree = _parse_py(source) except SyntaxError as e: self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) return [] out: list[Stmt] = [] for node in tree.body: try: out.extend(pyexpr.stmt(node)) except Unsupported as e: self.d.warn(f"Python: {e}", f"{self.filename}:{line.lineno + node.lineno}") return out # --- top level ------------------------------------------------------------- def parse(self, source: str) -> None: tree = parse_tree(source, self.filename) labels: list[Node] = [] current: Node | None = None index = 0 while index < len(tree): line = tree[index] index += 1 c = self.cursor(line) first = c.peek_word() if first != "label": # statements after a label without a block (`label x`) or before any label; take the whole # run at once so that an `if` / `else` chain stays together run = [line] while index < len(tree) and Cursor(tree[index].text).peek_word() != "label": run.append(tree[index]) index += 1 nodes = self.block(run, top_level=True) if nodes and current is not None: current["body"].extend(nodes) elif nodes: labels.append(current := {"name": f"_{self.filename}_{line.lineno}", "body": nodes}) continue if first == "label": name, params = self.label_header(line) if not name.startswith("."): self.global_label = name current = {"name": self.label_ref(name), "body": self.block(line.block)} if params: current["params"] = params labels.append(current) continue self.d.modules[self.filename] = labels def block(self, lines: list[Line], top_level: bool = False) -> list[Node]: out: list[Node] = [] i = 0 while i < len(lines): line = lines[i] word = Cursor(line.text).peek_word() if word == "if": branches, i = self.if_chain(lines, i) out.append({"op": "if", "branches": branches}) continue if word == "label": # a label inside a block is an entry point in the middle of the flow: # keep a marker the player can jump to and inline the label's block name, params = self.label_header(line) if not name.startswith("."): self.global_label = name marker: Node = {"op": "label", "name": self.label_ref(name)} if params: marker["params"] = params out.append(marker) out.extend(self.block(line.block)) i += 1 continue out.extend(self.statement(line, top_level=top_level)) i += 1 return out def label_header(self, line: Line) -> tuple[str, list[list[Any]] | None]: c = self.cursor(line) c.keyword("label") name = c.label_name() if not name: raise c.error("ожидалось имя метки") params = None if c.peek("("): sig = c.until(set(), ":") try: fn = _parse_py(f"def _f{sig}: pass").body[0] args = fn.args defaults = [None] * (len(args.args) - len(args.defaults)) + list(args.defaults) params = [[a.arg, pyexpr.expr(d) if d is not None else None] for a, d in zip(args.args, defaults)] except (SyntaxError, Unsupported): self.d.warn("Не разобраны параметры метки", self.where(line)) return name, params def call_args(self, c: Cursor, line: Line) -> dict[str, Any] | None: if not c.peek("("): return None src = c.until({"from"}) try: call = _parse_py(f"_f{src}", mode="eval").body return {"a": [pyexpr.expr(a) for a in call.args], "kw": {k.arg: pyexpr.expr(k.value) for k in call.keywords}} except (SyntaxError, Unsupported, AttributeError): self.d.warn("Не разобраны аргументы call", self.where(line)) return None def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]: branches: list[Node] = [] while i < len(lines): line = lines[i] c = self.cursor(line) kw = c.word() if kw in ("if", "elif") and (kw == "if") == (not branches): cond = c.rest().removesuffix(":").strip() branches.append({"c": self.expr(cond, line, const(False)), "body": self.block(line.block)}) elif kw == "else" and branches: branches.append({"c": None, "body": self.block(line.block)}) i += 1 break else: break i += 1 return branches, i # --- statements ---------------------------------------------------------------- def statement(self, line: Line, top_level: bool = False) -> list[Node]: text = line.text if text.startswith("$"): return self.dollar(text[1:].strip(), line) c = self.cursor(line) word = c.peek_word() handler = getattr(self, f"st_{word}", None) if word else None if word in CREATOR_STATEMENTS and word in self.d.statements: handler = getattr(self, f"cds_{word}") if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)): try: return handler(line) or [] except ParseError as e: # one odd statement (custom syntax, typo) must not drop the whole file self.d.warn(f"Не разобран оператор {word}", f"{e.filename}:{e.lineno}") return [] if word in SKIP_BLOCKS: if word not in ("style", "translate"): self.d.warn(f"Не поддерживается: {word}", self.where(line), blocking=not (top_level and word == "screen")) return [] say = self.say(line) if say is not None: return say self.d.warn("Неизвестный оператор", f"{self.where(line)}: {text[:60]}") return [] def _is_say(self, line: Line) -> bool: c = self.cursor(line) c.word() while c.image_part() is not None and not c.peek('"') and not c.peek("'"): pass return c.string_raw() is not None or c.peek('"') or c.peek("'") def dollar(self, code: str, line: Line) -> list[Node]: return self.python_nodes(self.python(code, line), line) def python_nodes(self, stmts: list[Stmt], line: Line) -> list[Node]: """Statements become one `exec` node, except calls that are really script commands (renpy.show, renpy.say, narrator("...")): those become the matching nodes.""" nodes: list[Node] = [] pending: list[Stmt] = [] for statement in stmts: special = self.renpy_call(statement["e"], line) if statement.get("op") == "eval" else None if special is None: pending.append(statement) continue if pending: nodes.append({"op": "exec", "code": pending}) pending = [] nodes.extend(special) if pending: nodes.append({"op": "exec", "code": pending}) return nodes def renpy_call(self, e: Expr, line: Line) -> list[Node] | None: """Turn common renpy.* calls into native script nodes.""" if e.get("k") != "call": return None fname = _dotted_name(e["f"]) args, kw = e["a"], e["kw"] if fname == "renpy.pause": return [{"op": "pause", "d": args[0] if args else kw.get("delay")}] if fname == "renpy.call_in_new_context" and len(args) == 1 and not kw and args[0].get("k") == "const" and args[0]["v"] == "_hide_windows": # Ren'Py's own label: hides the dialogue window until the player clicks return [{"op": "pause", "d": None}] if fname == "renpy.jump" and args: return [_jump_or_call("jump", args[0])] if fname == "renpy.call" and args: return [_jump_or_call("call", args[0])] if fname in ("renpy.music.play", "renpy.sound.play", "renpy.play"): ch = "sound" if fname != "renpy.music.play" else "music" if "channel" in kw and kw["channel"].get("k") == "const": ch = kw["channel"]["v"] if not args: return None node = {"op": "play", "ch": ch, "files": [args[0]], "loop": _const(kw.get("loop"))} for key in ("fadein", "fadeout"): if key in kw: node[key] = _const(kw[key]) return [node] if fname in ("renpy.music.stop", "renpy.sound.stop"): ch = "music" if fname == "renpy.music.stop" else "sound" if "channel" in kw and kw["channel"].get("k") == "const": ch = kw["channel"]["v"] return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}] if fname == "renpy.show" and args: at = self.at_list(kw.get("at_list"), line) if at is None: return None node: Node = {"op": "show", "at": at, "with": None, "img": {"name": str(args[0]["v"]).split()} if args[0].get("k") == "const" else {"expr": args[0]}} if "zorder" in kw: node["zorder"] = kw["zorder"] return [node] if fname == "renpy.hide" and args: if args[0].get("k") == "const": return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}] return [{"op": "hide", "tag": "", "tagExpr": args[0]}] if fname == "renpy.say" and len(args) >= 2: return [self.say_node(args[0], args[1])] if fname == "narrator" or fname in self.d.characters: if len(args) == 1 and set(kw) <= {"interact"}: who = {"k": "const", "v": None} if fname == "narrator" else {"k": "name", "id": fname} node = self.say_node(who, args[0]) if "interact" in kw and kw["interact"] == {"k": "const", "v": False}: node["interact"] = False return [node] if fname == "renpy.scene": return [{"op": "scene", "img": None, "at": [], "with": None}] if fname == "renpy.with_statement" and args: return [{"op": "with", "t": self.transition_expr(args[0])}] return None def at_list(self, e: Expr | None, line: Line) -> list[dict[str, Any]] | None: """``at_list=[a, b]`` of renpy.show: names of transforms, or None when not understood.""" if e is None: return [] if e.get("k") not in ("list", "tuple"): return None out = [] for item in e["v"]: if item.get("k") != "name": return None out.append(self.transform_ref(item["id"], line)) return out def say_node(self, who: Expr, what: Expr) -> Node: """A say command from Python: literal text stays translatable, anything else is evaluated.""" node: Node = {"op": "say", "what": ""} if (what.get("k") == "call" and what["f"].get("k") == "name" and what["f"]["id"] in ("_", "__") and len(what["a"]) == 1): what = what["a"][0] # _("text"): the marker only asks for translation, which says do anyway if what.get("k") == "const" and isinstance(what["v"], str): node["what"] = what["v"] else: node["whatExpr"] = what if who.get("k") == "const" and who["v"] is None: pass elif who.get("k") == "name" and (who["id"] in self.d.characters or who["id"] == "narrator"): node["who"] = who["id"] else: node["whoExpr"] = who return node # init / python ---------------------------------------------------------------- def st_init(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("init") c.number() if c.keyword("offset"): return [] if c.keyword("python") or c.keyword("early"): c.keyword("python") c.keyword("hide") # `init python hide in x:` keeps its temporary names out of the store; they are only locals here self.init_python(line.block_source(), line, self.store_name(c)) return [] rest = c.rest().strip() if re.match(r"phone\s+register(?!\w)", rest): # the Phone library's statement (see phone_register.py) self.init_phone_register(line, rest) return [] 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) children = self.fold_live2d(line.block) i = 0 while i < len(children): child = children[i] if re.match(r"if(?!\w)", child.text.strip()): # `init:` with an if/elif/else of `$` lines branches, i = self.if_chain(children, i) code = self.init_branches(branches) if code is None: self.d.warn("Оператор сцены внутри init", self.where(child)) else: self.d.init_code.extend(code) continue i += 1 for node in self.statement(child, top_level=True): if node["op"] == "exec": self.d.init_code.extend(node["code"]) elif node["op"] != "window": # `window hide` at init time changes nothing that is shown self.d.warn("Оператор сцены внутри init", self.where(child)) return [] def init_branches(self, branches: list[dict[str, Any]]) -> list[Stmt] | None: """An if/elif/else whose branches hold only Python lines, as one init statement (None when they hold more).""" def body(nodes: list[Node]) -> list[Stmt] | None: out: list[Stmt] = [] for node in nodes: if node["op"] == "exec": out.extend(node["code"]) elif node["op"] == "if": nested = self.init_branches(node["branches"]) if nested is None: return None out.extend(nested) elif node["op"] != "window": return None return out chain: dict[str, Any] | None = None for branch in reversed(branches): inner = body(branch["body"]) if inner is None: return None if branch["c"] is None: chain = {"op": "if", "c": {"k": "const", "v": True}, "then": inner, "else": []} else: chain = {"op": "if", "c": branch["c"], "then": inner, "else": [chain] if chain else []} return [chain] if chain else [] def init_phone_register(self, line: Line, rest: str) -> None: from app.converter.phone_register import PhoneSyntax, Register self.d.phone_registers += 1 number = self.d.phone_registers try: source, default = Register(replace(line, text=rest), number).source() except PhoneSyntax as exc: self.d.warn(f"Phone register: {exc}", self.where(line)) return if default is not None: # the group chat object, created with the other defaults of a new game created = self.expr(default[1], line) if created is not None: self.d.defaults.append([default[0], created]) self.d.init_code.extend(self.python(source, line)) # ...and filled right after it: a default whose value is a call of the function just defined self.d.defaults.append([f"__phone_register_{number}_done", {"k": "call", "f": {"k": "name", "id": f"__phone_register_{number}"}, "a": [], "kw": {}}]) @staticmethod def fold_live2d(children: list[Line]) -> list[Line]: """`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts.""" out: list[Line] = [] i = 0 while i < len(children): child = children[i] if LIVE2D_IF.match(child.text): i += 1 if i < len(children) and children[i].text.strip().startswith("else"): out.extend(RpyParser.fold_live2d(children[i].block)) i += 1 continue out.append(child) i += 1 return out def st_python(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("python") if c.keyword("early"): self.init_python(line.block_source(), line, self.store_name(c)) return [] if c.keyword("in"): # `python in some_store:` at run time: runs in that store store = c.dotted() return [{"op": "exec", "code": [{"op": "in_store", "ns": store, "body": self.python(line.block_source(), line)}]}] return self.python_nodes(self.python(line.block_source(), line), line) def st_rpy(self, line: Line) -> list[Node]: return [] @staticmethod def store_name(c: Cursor) -> str | None: """The `in phone.discussion` of `init python in phone.discussion:`.""" return (c.dotted() or None) if c.keyword("in") else None def init_python(self, source: str, line: Line, store: str | None = None) -> None: start = len(self.d.init_code) self.init_python_body(source, line) if store: # the names this block defines belong to a named store body = self.d.init_code[start:] del self.d.init_code[start:] if body: self.d.init_code.append({"op": "in_store", "ns": store, "body": body}) def init_python_body(self, source: str, line: Line) -> None: try: tree = _parse_py(source) except SyntaxError as e: self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) return native = {n.name for n in tree.body if isinstance(n, ast.ClassDef) and n.name in NATIVE_CLASSES} recipe_functions = self.creator_statements(tree) for node in tree.body: if isinstance(node, ast.FunctionDef) and node.name in recipe_functions: continue # the parse/next functions of a statement the converter implements itself if self.is_recipe_registration(node): continue if isinstance(node, ast.ClassDef) and node.name in native: continue # the player ships its own implementation (see NATIVE_CLASSES) if isinstance(node, ast.ClassDef) and node.name in COMPILED_IMAGE_CLASSES: # Image declarations using this factory are compiled by displayable_expr. # Its Ren'Py render-API implementation must not execute during browser init. # Calls outside converted images remain unknown and fail the strict audit. continue if native and isinstance(node, ast.Import) and {a.name for a in node.names} <= NATIVE_IMPORTS: continue if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) and ast.unparse(node.value.func) == "renpy.music.register_channel"): self.register_channel(node.value) continue if (isinstance(node, ast.Assign) and len(node.targets) == 1 and ast.unparse(node.targets[0]).startswith("config.special_namespaces")): continue # lets `define phone.config.x = ...` name a store; named stores are plain objects here if isinstance(node, ast.Assign) and len(node.targets) == 1: tgt = node.targets[0] name = _assign_name(tgt) if name and self.define(name, ast.get_source_segment(source, node.value) or ast.unparse(node.value), line): continue if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) and ast.unparse(node.value.func) == "gui.init" and len(node.value.args) == 2): try: self.d.gui["resolution"] = [ast.literal_eval(a) for a in node.value.args] except ValueError: pass continue if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) and ast.unparse(node.value.func).startswith("build.")): continue # distribution build settings (build.classify, build.name ...), no gameplay try: self.d.init_code.extend(pyexpr.stmt(node)) except Unsupported as e: where = f"{self.filename}:{line.lineno + node.lineno}" names = self.definition_names(node) if names: # a definition nobody refers to cannot hurt: it blocks only if reachable code names it for name in names: self.d.deferred.setdefault(name, []).append((f"init python: {e}", where)) else: self.d.warn(f"init python: {e}", where) @staticmethod def is_recipe_registration(node: ast.stmt) -> bool: call = node.value if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) else None return bool(call and ast.unparse(call.func) == "renpy.register_statement" and call.args and isinstance(call.args[0], ast.Constant) and call.args[0].value in CREATOR_STATEMENTS) def creator_statements(self, tree: ast.Module) -> set[str]: """``renpy.register_statement("random", parse=..., next=...)`` as in the Ren'Py cookbook: the statement is implemented by the converter, so the registration and its functions are dropped. Returns the names of the functions that belonged to such registrations.""" functions: set[str] = set() for node in tree.body: if not self.is_recipe_registration(node): continue call = node.value # type: ignore[attr-defined] self.d.statements.add(call.args[0].value) functions.update(k.value.id for k in call.keywords if k.arg in ("parse", "next", "execute", "predict", "lint") and isinstance(k.value, ast.Name)) return functions @staticmethod def definition_names(node: ast.stmt) -> list[str]: """Names a function, class or import statement binds.""" if isinstance(node, (ast.FunctionDef, ast.ClassDef)): return [node.name] if isinstance(node, ast.Import): return [(a.asname or a.name).split(".")[0] for a in node.names] if isinstance(node, ast.ImportFrom): return [a.asname or a.name for a in node.names] return [] def register_channel(self, call: ast.Call) -> None: args = list(call.args) + [None] * 3 kw = {k.arg: k.value for k in call.keywords} def lit(node, default=None): try: return ast.literal_eval(node) if node is not None else default except ValueError: return default name = lit(args[0]) if isinstance(name, str): self.d.channels[name] = {"mixer": lit(kw.get("mixer", args[1]), "sfx"), "loop": bool(lit(kw.get("loop", args[2]), False))} # define / default / image / transform ------------------------------------------- def st_define(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("define") c.number() name = c.dotted() if not name: raise c.error("ожидалось имя") op = "=" for candidate in ("+=", "|=", "="): if c.char(candidate): op = candidate break value = c.rest() if op != "=": if name.partition(".")[0] in ENGINE_SETTINGS: return [] # `define config.search_prefixes += [...]`: engine settings the converter does not model self.d.warn("define с += не поддерживается", self.where(line)) return [] self.define(name, value, line) return [] def define(self, name: str, value: str, line: Line) -> bool: """Handle `define name = value`. Returns False if value was not understood.""" head, _, attr = name.partition(".") if head in ("config", "gui", "build", "preferences", "style", "_preferences"): if attr: try: settings = self.d.config if head == "config" else self.d.build if head == "build" else self.d.gui settings[attr] = pyexpr.literal(value) except Unsupported: pass return True if head == "audio" and attr: try: self.d.audio[attr] = str(pyexpr.literal(value)) except Unsupported: pass return True if head == "store": name = attr e = self.expr(value, line) if e is None: return False if "." in name: # e.g. persistent.flag = ... : a plain assignment at init time self.d.init_code.append({"op": "set", "t": pyexpr.parse_expr(name), "v": e}) return True if e["k"] == "call": fname = _dotted_name(e["f"]) if fname in ("Character", "DynamicCharacter", "ADVCharacter", "NVLCharacter"): self.d.characters[name] = self.character(fname, e, line) return True if fname in TRANSITION_CLASSES or fname in TRANSITIONS: self.d.transitions[name] = self.transition_expr(e) return True if fname in ("Transform", "Position"): self.d.transforms[name] = _static_props(e["kw"]) return True if e["k"] == "name" and e["id"] in TRANSITIONS: self.d.transitions[name] = TRANSITIONS[e["id"]] return True if e["k"] == "name" and e["id"] in self.d.characters: self.d.characters[name] = dict(self.d.characters[e["id"]]) return True self.d.init_code.append({"op": "set", "t": {"k": "name", "id": name}, "v": e}) return True def character(self, fname: str, e: Expr, line: Line) -> dict[str, Any]: kw = e["kw"] char: dict[str, Any] = {} kind = kw.get("kind") if kind and kind.get("k") == "name": if kind["id"] == "nvl": char["nvl"] = True elif kind["id"] in self.d.characters: char.update(self.d.characters[kind["id"]]) if fname == "NVLCharacter": char["nvl"] = True args = e["a"] if args: name = args[0] if fname == "DynamicCharacter" or (kw.get("dynamic") or {}).get("v"): char["nameExpr"] = (self.expr(name["v"], line, const("")) if name.get("k") == "const" and isinstance(name["v"], str) else name) elif name.get("k") == "const": char["name"] = name["v"] else: char["nameExpr"] = name mapping = {"color": "color", "who_color": "color", "what_color": "whatColor", "what_prefix": "whatPrefix", "what_suffix": "whatSuffix", "who_prefix": "whoPrefix", "who_suffix": "whoSuffix", "image": "image", "what_italic": "whatItalic", "what_bold": "whatBold"} for key, target in mapping.items(): v = kw.get(key) if v is not None and v.get("k") == "const": char[target] = v["v"] return char def st_default(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("default") c.number() # optional init priority name = c.dotted() if not name or not c.char("="): raise c.error("ожидалось default имя = значение") e = self.expr(c.rest(), line) if e is not None: self.d.defaults.append([name.removeprefix("store."), e]) return [] def st_image(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("image") parts: list[str] = [] while (p := c.image_part()) is not None: parts.append(p) if not parts: raise c.error("ожидалось имя изображения") name = " ".join(parts) with self.d.capture() as caught: if c.char("="): img = self.displayable(c.rest(), line) elif c.char(":"): img = self.atl_image(line) else: raise c.error("ожидалось = или :") # a picture the converter cannot build matters only if something shows it (checked in audit_ir) for kind, where, blocking in caught: if blocking: self.d.deferred_images.setdefault(name, []).append((kind, where)) else: self.d.warn(kind, where, blocking=False) if img is not None: self.d.images[name] = img return [] def displayable(self, source: str, line: Line) -> dict[str, Any] | None: e = self.expr(source, line) return self.displayable_expr(e, line) if e is not None else None def displayable_expr(self, e: Expr, line: Line) -> dict[str, Any] | None: if e["k"] == "const" and isinstance(e["v"], str): s = e["v"] if s.startswith("#"): return {"color": s} if "." in s and "/" in s or s.rsplit(".", 1)[-1].lower() in _MEDIA_EXTS: return {"file": s} return {"ref": s} if e["k"] != "call": # 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",): play = kw.get("play") or (args[0] if args else None) if not play: return None if not isinstance(_const(play), str): self.d.warn("Movie: динамическое имя файла не поддерживается", self.where(line)) return None movie = {"movie": _const(play)} if "mask" in kw and kw["mask"].get("k") != "const": self.d.warn("Movie: динамическая маска не поддерживается", self.where(line)) return None if "mask" in kw and _const(kw["mask"]) is not None: if not isinstance(_const(kw["mask"]), str): self.d.warn("Movie: маска должна быть именем файла", self.where(line)) return None movie["movieMask"] = _const(kw["mask"]) if "loop" in kw: loop = _const(kw["loop"]) if not isinstance(loop, (bool, int)) or loop not in (0, 1): self.d.warn("Movie: динамический режим повтора не поддерживается", self.where(line)) return None movie["movieLoop"] = bool(loop) return movie if fname in ("ConditionSwitch", "TransitionConditionSwitch"): if fname == "TransitionConditionSwitch": args = args[1:] # the first argument is the transition between the states pairs = [] for i in range(0, len(args) - 1, 2): cond = args[i] cexpr = (self.expr(cond["v"], line, const(False)) if cond.get("k") == "const" and isinstance(cond["v"], str) else cond) sub = self.displayable_expr(args[i + 1], line) if sub: pairs.append([cexpr, sub]) return {"switch": pairs} if fname == "AlphaMask" and (len(args) >= 2 or {"child", "mask"} <= set(kw)): child = self.lazy_displayable(args[0] if args else kw["child"], line) mask = self.lazy_displayable(args[1] if len(args) > 1 else kw["mask"], line) return {"alphaMask": {"img": child, "mask": mask}} if child and mask else None if fname in ("Composite", "LiveComposite", "im.Composite") and args: size = _const(args[0]) layers = [] for i in range(1, len(args) - 1, 2): pos = _const(args[i]) sub = self.displayable_expr(args[i + 1], line) if sub and isinstance(pos, (list, tuple)): layers.append({"pos": list(pos), "img": sub}) return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple)) else None} if fname in ("Animation", "SnowBlossom"): # pictures that take turns / fall, built from values known 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 if sub: props = _static_props(kw) if fname in ("im.Flip", "Flip") and (_const(kw.get("horizontal")) is True): props["xzoom"] = -1 if fname in ("im.Scale", "im.FactorScale") and len(args) >= 2: if fname == "im.FactorScale": props["zoom"] = _const(args[1]) elif len(args) >= 3: props["size"] = [_const(args[1]), _const(args[2])] if fname in ("im.Grayscale", "im.Sepia"): props["filter"] = "grayscale" if fname == "im.Grayscale" else "sepia" 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 def lazy_displayable(self, e: Expr, line: Line) -> dict[str, Any] | None: """A picture known now, or (for a computed one) an expression evaluated when it is shown.""" if e["k"] == "const" or (e["k"] == "call" and (_dotted_name(e["f"]) in _DISPLAYABLE_CALLS or _dotted_name(e["f"]) == "AlphaMask")): return self.displayable_expr(e, line) self.d.warn("Изображение задано выражением", self.where(line), blocking=False) return {"expr": e} MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat", "block", "on"} TIMELINE_WORDS = {"function", "time", "contains", "animation", "parallel", "choice", "block", "on"} def atl_image_line(self, line: Line) -> tuple[dict, Any] | None: """A picture inside an ATL block: `"a.png" with dissolve` -> (ImageDef, transition).""" if not self.displayable_line(line): return None match = re.search(r"\s+with\s+", line.text) if match: picture, transition = line.text[:match.start()].strip(), line.text[match.end():].strip() else: picture, transition = line.text.strip(), "" expr = self.expr(picture, line) image = self.displayable_expr(expr, line) if expr else None if image is None: return None return image, (self.transition(transition, line) if transition.strip() else None) def single_motion(self, lines: list[Line]) -> bool: """One picture plus plain linear/ease motion, handled by the lighter `at` path.""" shown = [child for child in lines if self.displayable_line(child)] return len(shown) == 1 and not any( (Cursor(child.text).peek_word() or "") in self.TIMELINE_WORDS for child in lines) def needs_timeline(self, lines: list[Line]) -> bool: """Whether an `image` block is more than a static picture or a plain slideshow.""" for child in lines: word = Cursor(child.text).peek_word() or "" if word in self.TIMELINE_WORDS or WARPER.match(word): return True if word == "repeat" and child.text.strip() != "repeat": return True if re.match(r"^[\d.(]", child.text.strip()) and not _NUMBER_LINE.match(child.text.strip()): return True # a computed pause: `0.5*speed` if re.fullmatch(r"[A-Za-z_]\w*\[.+\]", child.text.strip()): return True # a pause read from a list: `delays[1]` if re.fullmatch(r"[A-Za-z_]\w*(\(.*\))?", child.text.strip()) and not self.displayable_line(child) \ and word not in ("repeat", "pause"): return True # a transform or a number held by a variable: `wing_flap`, `random_delay(1, 2)` return False def displayable_line(self, child: Line) -> bool: if self.cursor(child).string() is not None: return True m = _CALL_LINE.match(child.text) return bool(m and m.group(1) in _DISPLAYABLE_CALLS) def expression_picture(self, line: Line) -> dict[str, Any] | None: """``image x:`` whose first line is an expression computing the picture (``get_face("base")`` or ``("a_" + str(n))``), optionally followed by transform lines: it is evaluated when the image is shown.""" if not line.block: return None first = line.block[0] text = first.text.strip() match = _CALL_LINE.match(text) word = Cursor(first.text).peek_word() or "" if not (match or text.startswith("(")): return None if match and (self.displayable_line(first) or match.group(1) in self.d.transforms or match.group(1) in self.d.transform_names or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS): return None expr = self.expr(text, first) if expr is None: return None picture: dict[str, Any] = {"expr": expr} rest = line.block[1:] if rest: issues: list[tuple[str, str]] = [] props, animation = self.atl_spec(rest, issues.append, set()) if issues or not animation: return None picture["at"] = [{"props": props, "atl": animation}] self.d.warn("Изображение задано выражением", self.where(first), blocking=False) return picture def atl_image(self, line: Line) -> dict[str, Any] | None: if (picture := self.expression_picture(line)) is not None: return picture if self.needs_timeline(line.block) and not self.single_motion(line.block): props, timeline = self.atl_spec(line.block) if timeline is None: return None return {"timeline": timeline} shown = [child for child in line.block if self.displayable_line(child)] rest = [child for child in line.block if not self.displayable_line(child)] if len(shown) == 1 and rest: # a picture with computed properties (matrixcolor OpacityMatrix(...)): try the timeline path quietly issues: list[tuple[str, str]] = [] props, animation = self.atl_spec(rest, issues.append, set()) if animation and not issues: first = self.cursor(shown[0]).string() if first is not None: img = self.displayable_expr(const(first), shown[0]) else: expr = self.expr(shown[0].text.split(" with ")[0], shown[0]) img = self.displayable_expr(expr, shown[0]) if expr else None if img: return {**img, "at": [*img.get("at", []), {"props": props, "atl": animation}]} if len(shown) == 1 and any( (word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "") for child in rest): # one picture that moves, e.g. a spinning "click to continue" mark first = self.cursor(shown[0]).string() if first is not None: img = self.displayable_expr(const(first), shown[0]) else: expr = self.expr(shown[0].text.split(" with ")[0], shown[0]) img = self.displayable_expr(expr, shown[0]) if expr else None if img: props, animation = self.atl_spec(rest) spec: dict[str, Any] = {"props": props} if animation: spec["atl"] = animation return {**img, "at": [*img.get("at", []), spec]} frames: list[dict[str, Any]] = [] props: dict[str, Any] = {} repeat = self.atl_frames(line.block, frames, props) if not frames: self.d.warn("ATL-изображение без кадров", self.where(line)) return None if len(frames) == 1: img = dict(frames[0]["img"]) else: img = {"frames": frames, "repeat": repeat} if props: img["props"] = {**img.get("props", {}), **props} return img def atl_frames(self, lines: list[Line], frames: list[dict[str, Any]], props: dict[str, Any]) -> bool: """Collect displayable frames of an ATL block. Returns whether it repeats.""" repeat = False for child in lines: c = self.cursor(child) word = c.peek_word() if word in ("contains", "block", "parallel", "choice"): if word != "block": self.d.warn(f"Не поддерживается ATL: {word}", self.where(child)) if not frames or word == "block": repeat = self.atl_frames(child.block, frames, props) or repeat continue if word == "repeat": if child.text.strip() != "repeat": self.d.warn("Не поддерживается ATL: конечное число повторов", self.where(child)) repeat = True continue if word in ("pause",) or _NUMBER_LINE.match(child.text): c.keyword("pause") d = _num(c.number()) if frames and d is not None: frames[-1]["d"] = frames[-1].get("d", 0) + d continue if word in ("function", "on", "event", "time", "transform"): self.d.warn(f"Не поддерживается ATL: {word}", self.where(child)) continue s = c.string() if s is not None: img = self.displayable_expr(const(s), child) if img: frames.append({"img": img}) continue m = _CALL_LINE.match(child.text) if m and m.group(1) in _DISPLAYABLE_CALLS: e = self.expr(child.text.split(" with ")[0], child) img = self.displayable_expr(e, child) if e else None if img: frames.append({"img": img}) continue if m and not frames and not _atl_props(child.text): # e.g. get_face_path("base"): an image computed by game code e = self.expr(child.text, child) if e is not None: self.d.warn("Изображение задано выражением", self.where(child), blocking=False) frames.append({"img": {"expr": e}}) continue if not frames or not frames[-1].get("d"): props.update(self.static_atl(child)) return repeat def st_layeredimage(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("layeredimage") parts: list[str] = [] while (p := c.image_part()) is not None: parts.append(p) name = " ".join(parts) layers: list[dict[str, Any]] = [] at: list[dict[str, Any]] = [] for child in line.block: cc = self.cursor(child) kw = cc.word() if kw == "always": img = self.layer_image(cc, child, None) if img: layers.append({"always": img}) elif kw == "attribute": layers.append(self.layer_attribute(cc, child, name, None)) elif kw == "group": group = cc.word() or "" auto = False while not cc.eol(): w = cc.word() if w == "auto": auto = True elif w in ("multiple", "prefix", "variant"): cc.word() if w in ("prefix", "variant") else None elif w is None: break for sub in child.block: sc = self.cursor(sub) if sc.keyword("attribute"): layers.append(self.layer_attribute(sc, sub, name, group)) if auto: layers.append({"autoGroup": group}) elif kw == "if": cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) img = self.layer_image(Cursor(""), child, None) if img: layers.append({"cond": [[cond, img]]}) elif kw in ("elif", "else"): img = self.layer_image(Cursor(""), child, None) if img and layers and "cond" in layers[-1]: cond = None if kw == "elif": cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) layers[-1]["cond"].append([cond, img]) elif kw == "at": for item in _split_top(cc.rest()): at.append(self.transform_ref(item, child)) self.d.images[name] = {"layered": layers, "at": at} self.d.stats["layeredimage"] += 1 return [] def layer_attribute(self, c: Cursor, line: Line, image: str, group: str | None ) -> dict[str, Any]: attr = c.image_part() or "" layer: dict[str, Any] = {"attr": attr, "group": group} img = None while not c.eol(): if c.peek('"') or c.peek("'"): img = self.displayable_expr(const(c.string()), line) continue w = c.word() if w == "default": layer["default"] = True elif w == "null": layer["null"] = True elif w in ("if_any", "if_all", "if_not"): src = c.until({"if_any", "if_all", "if_not", "default", "null", "variant", "at"}, ":") try: val = pyexpr.literal(src) layer[w] = [val] if isinstance(val, str) else list(val) except Unsupported: pass elif w in ("variant", "at"): c.until({"if_any", "if_all", "if_not", "default", "null"}, ":") elif w is None: break if img is None and line.block: img = self.layer_image(Cursor(""), line, None) if img is None and not layer.get("null"): # Ren'Py's default naming: __ img = {"ref": "_".join(x for x in (image.replace(" ", "_"), group, attr) if x)} if img: layer["img"] = img return layer def layer_image(self, c: Cursor, line: Line, default) -> dict[str, Any] | None: s = c.string() if c.text else None if s is not None: return self.displayable_expr(const(s), line) for child in line.block: cc = self.cursor(child) s = cc.string() if s is not None: return self.displayable_expr(const(s), child) m = _CALL_LINE.match(child.text) if m and m.group(1) in _DISPLAYABLE_CALLS: e = self.expr(child.text, child) return self.displayable_expr(e, child) if e else None return default def st_camera(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("camera") layer = c.word() if not c.peek("at") and not c.peek(":") else None node: Node = {"op": "camera", "at": []} if layer: node["layer"] = layer if c.keyword("at"): for item in _split_top(c.until(set(), ":")): node["at"].append(self.transform_ref(item, line)) props: dict[str, Any] = {} for child in line.block: props.update(_atl_props(child.text)) if props: node["at"].append({"props": props}) return [node] def st_transform(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("transform") c.number() # `transform -150 _fits(size):`: an init priority name = c.word() if name: issues: list[tuple[str, str]] = [] uses: set[str] = set() props, animation = self.atl_spec(line.block, issues.append, uses) params = self.transform_params(line) if params: # parameters are read as run-time values, so the timeline is always needed animation = animation or {"steps": [{"op": "set", "props": props}] if props else [], "repeat": None} animation["params"] = params self.d.transforms[name] = props if animation: self.d.atl[name] = animation self.d.atl_pending[name] = issues self.d.atl_uses[name] = uses if name in self.d.atl_referenced: self.use_transform(name) return [] def transform_params(self, line: Line) -> list[dict[str, Any]]: match = re.fullmatch(r"transform\s+(?:-?\d+\s+)?[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S) if not match: return [] try: return pyexpr.params(ast.parse(f"def f({match.group(1)}): pass").body[0].args) except (SyntaxError, Unsupported): self.d.warn("Не разобраны параметры transform", self.where(line)) return [] def use_transform(self, name: str) -> None: """A transform is used by the game: its unsupported constructs now block the import.""" self.d.atl_referenced.add(name) pending = self.d.atl_pending.pop(name, []) for message, where in pending: self.d.warn(message, where) for other in self.d.atl_uses.get(name, ()): self.use_transform(other) def atl_spec(self, lines: list[Line], collect=None, uses=None) -> tuple[dict[str, Any], dict[str, Any] | None]: """Parse an ATL block: (properties to start with, timeline or None when it is static).""" def warn(message: str, line: Line) -> None: if collect is None: self.d.warn(message, self.where(line)) else: collect((message, self.where(line))) def on_use(name: str) -> None: if uses is None: self.use_transform(name) else: uses.add(name) parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl and name in self.d.transforms else None, on_use, lambda line: self.atl_image_line(line)) block = parser.block(lines) static = static_props(block) if static is not None: return static, None return initial_props(block), block def static_atl(self, line: Line) -> dict[str, Any]: props = _atl_props(line.text) first = Cursor(line.text).peek_word() if first in ("subpixel", "animation") and not line.block: return {} # no visible effect in the browser if first not in POSITION_PROPS or not props or line.block: self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line)) return props # visual statements ------------------------------------------------------------- def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]: if c.keyword("expression") or c.keyword("image"): source = c.until(_SHOW_KEYWORDS, ":") e = self.expr(source, line) return {"expr": e, "tag": source} parts: list[str] = [] while True: save = c.pos p = c.image_part() if p is None: break if p in _SHOW_KEYWORDS: c.pos = save break parts.append(p) if not parts: raise c.error("ожидалось имя изображения") return {"name": parts} def show_clauses(self, c: Cursor, line: Line, node: Node) -> None: node.setdefault("at", []) node.setdefault("with", None) if (node.get("img") or {}).get("tag"): node.setdefault("as", node["img"]["tag"]) while not c.eol(): if c.keyword("as"): node["as"] = c.image_part() elif c.keyword("at"): for item in _split_top(c.until(_SHOW_KEYWORDS - {"at"}, ":")): node["at"].append(self.transform_ref(item, line)) elif c.keyword("behind"): node["behind"] = [b.strip() for b in c.until(_SHOW_KEYWORDS, ":").split(",")] elif c.keyword("onlayer"): node["layer"] = c.word() elif c.keyword("zorder"): node["zorder"] = self.expr(c.until(_SHOW_KEYWORDS, ":"), line) elif c.keyword("with"): node["with"] = self.transition(c.until(set(), ":"), line) elif c.char(":"): props, animation = self.atl_spec(line.block) if animation: node["at"].append({"props": props, "atl": animation}) elif props: node["at"].append({"props": props}) else: self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}") break def transform_ref(self, source: str, line: Line, dynamic: bool = False) -> dict[str, Any]: source = source.strip() if source in POSITIONS: return {"pos": source} if source.isidentifier(): self.use_transform(source) if source in self.d.transforms: spec = {"props": self.d.transforms[source], "name": source} if source in self.d.atl: spec["atl"] = source return spec e = self.expr(source, line) if e and e["k"] == "call": fname = _dotted_name(e["f"]) if fname in ("Position", "Transform"): return {"props": _static_props(e["kw"])} if fname == "Move": moved = self.move_transform(e) if moved: return moved if fname in ("Shake", "SnoopyShake"): args = [_const(a) for a in e["a"]] return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5, "dist": _const(e["kw"].get("dist")) or 20} if fname in self.d.transforms or fname in self.d.atl: self.use_transform(fname) spec = {"props": self.d.transforms.get(fname, {}), "name": fname} if fname in self.d.atl: spec["atl"] = fname spec["args"], spec["kw"] = e["a"], e["kw"] return spec if "." not in fname and (fname[:1].islower() or fname in self.d.transform_names) and fname not in KNOWN_FACTORIES: # parameterised transform defined later (or in another file); the player resolves it by name self.use_transform(fname) return {"name": fname, "atl": fname, "args": e["a"], "kw": e["kw"]} if e and e["k"] == "name": self.use_transform(e["id"]) if e and e["k"] == "name" and e["id"] not in self.d.transforms: # transform defined later in another file; the player resolves it by name return {"name": e["id"]} if dynamic and e and e["k"] == "call" and e["f"].get("k") in ("name", "attr"): # in a screen, a Python function that makes a transform (`CurriedRoundedCorners(radius=...)`): evaluated when it is shown self.d.warn("Трансформация вычисляется при показе", f"{self.where(line)}: {source}", blocking=False) return {"dyn": e} self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}") return {"name": source} def move_transform(self, call: Expr) -> dict[str, Any] | None: """``Move((x0, y0), (x1, y1), seconds)``: slide the picture between two positions.""" args = [_const(a) for a in call["a"]] kw = {k: _const(v) for k, v in call["kw"].items()} start, end, seconds = (args + [None] * 3)[:3] start, end = start or kw.get("pos1"), end or kw.get("pos2") seconds = seconds if seconds is not None else kw.get("delay") if not (isinstance(start, (list, tuple)) and isinstance(end, (list, tuple)) and len(start) == len(end) == 2 and isinstance(seconds, (int, float))): return None block = {"steps": [ {"op": "set", "props": {"xpos": start[0], "ypos": start[1]}}, {"op": "move", "warp": "linear", "t": float(seconds), "props": {"xpos": end[0], "ypos": end[1]}}, ], "repeat": None} return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block} def screen_parser(self, collect=None) -> ScreenParser: def warn(message: str, line: Line) -> None: if collect is None: self.d.warn(message, self.where(line)) else: collect.append((message, self.where(line))) return ScreenParser(warn, lambda source, line: self.transform_ref(source, line, dynamic=True), lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set())) def st_screen(self, line: Line) -> list[Node]: issues: list[tuple[str, str]] = [] parsed = self.screen_parser(issues).screen(line) if parsed: name, definition = parsed self.d.screens[name] = definition # a later definition replaces an earlier one self.d.screen_pending[name] = issues if name in self.d.screen_used: self.use_screen(name) return [] def use_screen(self, name: str) -> None: self.d.use_screen(name) def st_style(self, line: Line) -> list[Node]: match = re.fullmatch(r"style\s+([A-Za-z_]\w*)(?:\s+is\s+([A-Za-z_]\w*))?\s*:?", line.text.strip()) if not match: return [] # `style x take y`, `style x clear`: nothing a browser style needs name, parent = match.groups() parser = self.screen_parser([]) props: dict[str, Any] = {} for child in line.block: props.update(parser.properties(tokens(child.text), child, lenient=True)) entry = self.d.styles.setdefault(name, {"is": parent, "props": {}}) entry["is"] = parent or entry["is"] entry["props"].update(props) return [] def screen_call(self, c: Cursor, line: Line) -> Node | None: """``name(args)`` after `call/show screen`; trailing `nopredict` and `with` are ignored.""" source = re.split(r"\s+(?:nopredict|with)\b", c.rest(), maxsplit=1)[0].strip() layer_match = re.search(r"\s+onlayer\s+(\w+)\s*$", source) layer = layer_match.group(1) if layer_match else None if layer_match: source = source[:layer_match.start()] call = self.expr(source, line) if source else None if call is None: self.d.warn("Не разобран вызов screen", self.where(line)) return None if call["k"] == "name": name, args, kw = call["id"], [], {} elif call["k"] == "call" and call["f"]["k"] == "name": name, args, kw = call["f"]["id"], call["a"], call["kw"] else: self.d.warn("Не разобран вызов screen", self.where(line)) return None self.use_screen(name) found: Node = {"name": name, "args": args, "kw": kw} if layer: found["layer"] = layer return found def st_scene(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("scene") node: Node = {"op": "scene", "img": None} if c.keyword("onlayer"): node["layer"] = c.word() if not c.eol() and not c.peek("with") and not c.peek("at"): node["img"] = self.image_spec(c, line) self.show_clauses(c, line, node) self.d.stats["scene"] += 1 return [node] def st_show(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("show") if c.keyword("screen"): node = self.screen_call(c, line) return [{"op": "show_screen", **node}] if node else [] if c.keyword("text"): text = c.string() if text is not None: node: Node = {"op": "show", "img": {"text": text}} else: node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS, ":"), line, const(""))}} self.show_clauses(c, line, node) return [node] node = {"op": "show", "img": self.image_spec(c, line)} self.show_clauses(c, line, node) self.d.stats["show"] += 1 return [node] def st_hide(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("hide") if c.keyword("screen"): name = c.word() if not name: return [] hidden: Node = {"op": "hide_screen", "name": name} # hiding a screen that does not exist is harmless if c.keyword("onlayer"): hidden["layer"] = c.word() return [hidden] spec = self.image_spec(c, line) node: Node = {"op": "hide", "tag": spec.get("tag", spec.get("name", [""])[0])} self.show_clauses(c, line, node) if node.get("as"): node["tag"] = node.pop("as") node.pop("at", None) return [node] def st_with(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("with") return [{"op": "with", "t": self.transition(c.rest(), line)}] def transition(self, source: str, line: Line) -> dict[str, Any] | None: source = source.strip() if source in ("None", ""): return None if source in self.d.transitions: return self.d.transitions[source] if source in TRANSITIONS: return TRANSITIONS[source] e = self.expr(source, line) return self.transition_expr(e) if e else TRANSITIONS["dissolve"] def transition_expr(self, e: Expr) -> dict[str, Any] | None: if e["k"] == "const" and e["v"] is None: return None if e["k"] == "name": return self.d.transitions.get(e["id"]) or TRANSITIONS.get(e["id"]) or { "t": "ref", "name": e["id"]} if e["k"] == "call": fname = _dotted_name(e["f"]) args = [_const(a) for a in e["a"]] kw = {k: _const(v) for k, v in e["kw"].items()} nums = [a for a in args if isinstance(a, (int, float))] if fname == "Fade": out = nums[0] if nums else 0.5 hold = nums[1] if len(nums) > 2 else 0.0 fin = nums[2] if len(nums) > 2 else (nums[1] if len(nums) > 1 else out) return {"t": "fade", "out": out, "hold": hold, "in": fin, "color": kw.get("color") or "#000000"} if fname in ("Shake", "SnoopyShake"): return {"t": "punch", "dir": "h", "d": nums[0] if nums else 0.5} if fname in ("MoveTransition", "Move"): return {"t": "move", "d": nums[0] if nums else 0.5} if fname in TRANSITIONS: return TRANSITIONS[fname] return {"t": "dissolve", "d": nums[0] if nums else kw.get("time", 0.5) or 0.5} return TRANSITIONS["dissolve"] # audio ------------------------------------------------------------------------ def audio_files(self, c: Cursor, line: Line) -> list[Expr]: src = c.until({"fadein", "fadeout", "loop", "noloop", "if_changed", "volume", "channel"}) e = self.expr(src, line) if e is None: return [] if e["k"] in ("list", "tuple"): return e["v"] return [e] def st_play(self, line: Line, op: str = "play") -> list[Node]: c = self.cursor(line) c.keyword(op) ch = c.word() or "music" node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)} while not c.eol(): if c.keyword("fadein"): node["fadein"] = _num(c.number()) elif c.keyword("fadeout"): node["fadeout"] = _num(c.number()) elif c.keyword("loop"): node["loop"] = True elif c.keyword("noloop"): node["loop"] = False elif c.keyword("if_changed"): node["ifChanged"] = True elif c.keyword("volume"): node["volume"] = _num(c.number()) else: c.rest() self.d.stats["audio"] += 1 return [node] def st_queue(self, line: Line) -> list[Node]: return self.st_play(line, "queue") def st_stop(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("stop") node: Node = {"op": "stop", "ch": c.word() or "music"} if c.keyword("fadeout"): node["fadeout"] = _num(c.number()) return [node] def st_voice(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("voice") if c.keyword("sustain"): return [{"op": "voice", "sustain": True}] e = self.expr(c.rest(), line) return [{"op": "voice", "file": e}] if e else [] # flow --------------------------------------------------------------------------- def st_jump(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("jump") if c.keyword("expression"): return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}] return [{"op": "jump", "target": self.label_ref(c.label_name() or "")}] def st_call(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("call") if c.keyword("screen"): node = self.screen_call(c, line) return [{"op": "call_screen", **node}] if node else [] if c.keyword("expression"): node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line, const(""))} c.keyword("pass") else: node = {"op": "call", "target": self.label_ref(c.label_name() or "")} args = self.call_args(c, line) if args: node["args"] = args return [node] def st_return(self, line: Line) -> list[Node]: return [{"op": "return"}] def st_pass(self, line: Line) -> list[Node]: return [] def st_pause(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("pause") rest = c.rest() return [{"op": "pause", "d": self.expr(rest, line) if rest else None}] def st_while(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("while") cond = c.rest().removesuffix(":").strip() return [{"op": "while", "c": self.expr(cond, line, const(False)), "body": self.block(line.block)}] def st_if(self, line: Line) -> list[Node]: branches, _ = self.if_chain([line], 0) return [{"op": "if", "branches": branches}] def cds_random(self, line: Line) -> list[Node]: """``random:`` (cookbook recipe): run one of the statements below, by weight (`weight 2.0 "text"`).""" options: list[tuple[float, list[Node]]] = [] for child in line.block: weight, text = 1.0, child.text if match := re.match(r"weight\s+([\d.]+)\s*(.*)$", text, re.S): weight, text = float(match[1]), match[2] options.append((weight, self.block([replace(child, text=text)]))) if not options: return [] self.d.random_counter += 1 var = f"_random_{self.d.random_counter}" total = sum(w for w, _ in options) pick = pyexpr.parse_stmts(f"{var} = renpy.random.random() * {total!r}") branches, bound = [], 0.0 for index, (weight, body) in enumerate(options): bound += weight cond = None if index == len(options) - 1 else pyexpr.parse_expr(f"{var} <= {bound!r}") branches.append({"c": cond, "body": body}) return [{"op": "exec", "code": pick}, {"op": "if", "branches": branches}] def cds_block(self, line: Line) -> list[Node]: """``block:`` (cookbook recipe): its statements run in place.""" return self.block(line.block) def st_window(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("window") mode = c.word() return [{"op": "window", "mode": mode}] if mode in ("show", "hide") else [] def st_nvl(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("nvl") action = c.word() return [{"op": "nvl", "a": action}] if action in ("clear", "show", "hide") else [] def st_menu(self, line: Line) -> list[Node]: node: Node = {"op": "menu", "items": [], "say": None} head = self.cursor(line) head.keyword("menu") name = head.label_name() out: list[Node] = [] if name: out.append({"op": "label", "name": self.label_ref(name)}) for child in line.block: c = self.cursor(child) if c.keyword("set"): node["set"] = self.expr(c.rest(), child) continue if c.keyword("with"): continue text = c.string() if text is not None and child.text.rstrip().endswith(":"): cond = None if c.keyword("if"): cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True)) elif c.peek("("): c.until({"if"}, ":") if c.keyword("if"): cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True)) node["items"].append({"text": text, "if": cond, "body": self.block(child.block)}) continue say = self.say(child) if say: node["say"] = say[0] else: self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}") self.d.stats["menu"] += 1 return out + [node] # say ------------------------------------------------------------------------------ def say(self, line: Line) -> list[Node] | None: # `__("Girl") "Hello"`: a translatable speaker name; the name is looked up by its text anyway if wrapped := TRANSLATED_SPEAKER.match(line.text): line = replace(line, text=wrapped[1] + line.text[wrapped.end():]) c = self.cursor(line) who: str | None = None who_str: str | None = None attrs: list[str] = [] first = c.string() if first is not None: second = c.string() if not c.eol() else None if second is not None: who_str, what = first, second else: what = first else: who = c.dotted() if who is None: return None while True: save = c.pos if c.string_raw() is not None: c.pos = save break p = c.image_part() or ("@" if c.char("@") else None) if p is None: c.pos = save break if p != "@": attrs.append(p) what = c.string() if what is None: return None node: Node = {"op": "say", "what": what, "_ln": line.lineno} # _ln: dropped after IDs if who and who != "narrator": node["who"] = who if who_str is not None: node["whoStr"] = who_str if attrs: node["attrs"] = attrs while not c.eol(): if c.keyword("with"): node["with"] = self.transition(c.rest(), line) elif c.keyword("nointeract"): node["interact"] = False elif c.keyword("id"): c.word() elif c.char("("): c.pos -= 1 c.until({"with", "nointeract", "id"}) else: c.rest() self.d.stats["say"] += 1 return [node]