"""Ren'Py script (.rpy) → PGVN IR.""" import ast import re import warnings from collections import Counter from dataclasses import dataclass, field from typing import Any from app.converter import pyexpr from app.converter.pyexpr import Expr, Stmt, Unsupported, const from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree 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"} POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", "align", "pos", "anchor", "xysize", "size"} CHANNELS = {"music", "sound", "voice", "audio", "movie"} SKIP_BLOCKS = {"screen", "style", "translate", "testcase"} BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"} @dataclass class ScriptData: 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) 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) modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels warnings: Counter = field(default_factory=Counter) warning_examples: dict[str, str] = field(default_factory=dict) stats: Counter = field(default_factory=Counter) def warn(self, kind: str, where: str = "") -> None: self.warnings[kind] += 1 if where and kind not in self.warning_examples: self.warning_examples[kind] = where 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 for line in tree: c = self.cursor(line) first = c.peek_word() 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 nodes = self.statement(line, top_level=True) if nodes and current is not None: current["body"].extend(nodes) elif nodes: # statements before any label run on fall-through into the first label labels.append(current := {"name": f"_{self.filename}_{line.lineno}", "body": nodes}) self.d.modules[self.filename] = labels def block(self, lines: list[Line]) -> 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)) 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 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)) 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]: stmts = self.python(code, line) if len(stmts) == 1 and stmts[0]["op"] == "eval": special = self.renpy_call(stmts[0]["e"], line) if special is not None: return special return [{"op": "exec", "code": stmts}] if stmts else [] 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.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 and args[0].get("k") == "const": return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [], "with": None}] if fname == "renpy.hide" and args and args[0].get("k") == "const": return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}] 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 # 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") self.init_python(line.block_source(), line) return [] for child in line.block: for node in self.statement(child, top_level=True): if node["op"] == "exec": self.d.init_code.extend(node["code"]) else: self.d.warn("Оператор сцены внутри init", self.where(child)) return [] 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) return [] code = self.python(line.block_source(), line) return [{"op": "exec", "code": code}] if code else [] def st_rpy(self, line: Line) -> list[Node]: return [] def init_python(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 for node in tree.body: 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: tgt = node.targets[0] name = _assign_name(tgt) if name and self.define(name, ast.get_source_segment(_dedent(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 try: self.d.init_code.extend(pyexpr.stmt(node)) except Unsupported as e: self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}") 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 != "=": 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: (self.d.config if head == "config" else self.d.gui)[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) if c.char("="): img = self.displayable(c.rest(), line) elif c.char(":"): img = self.atl_image(line) else: raise c.error("ожидалось = или :") 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": self.d.warn("Изображение задано выражением", self.where(line)) 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 in ("Movie",): play = kw.get("play") or (args[0] if args else None) return {"movie": _const(play)} if play else None if fname == "ConditionSwitch": 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 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} # 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 self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line)) return None def atl_image(self, line: Line) -> dict[str, Any] | None: 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 not frames or word == "block": repeat = self.atl_frames(child.block, frames, props) or repeat continue if word == "repeat": 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"): 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)) frames.append({"img": {"expr": e}}) continue if not frames or not frames[-1].get("d"): props.update(_atl_props(child.text)) 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") c.word() if not c.peek("at") and not c.peek(":") else None node: Node = {"op": "camera", "at": []} 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") name = c.word() props: dict[str, Any] = {} for child in line.block: props.update(_atl_props(child.text)) if name: self.d.transforms[name] = props return [] # visual statements ------------------------------------------------------------- def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]: if c.keyword("expression"): e = self.expr(c.until(_SHOW_KEYWORDS), line) return {"expr": e} 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) 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: dict[str, Any] = {} for child in line.block: props.update(_atl_props(child.text)) if 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) -> dict[str, Any]: source = source.strip() if source in POSITIONS: return {"pos": source} if source in self.d.transforms: return {"props": self.d.transforms[source], "name": source} 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 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: return {"props": self.d.transforms[fname], "name": fname} 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"]} self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}") return {"name": source} 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"): self.d.warn("Не поддерживается: screen", self.where(line)) return [] 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"): return [] spec = self.image_spec(c, line) node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]} self.show_clauses(c, line, node) 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"): self.d.warn("Не поддерживается: call screen", self.where(line)) return [] 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 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: 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] _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", "ConditionSwitch", "Image", "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", "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} def _dedent(source: str) -> str: import textwrap return textwrap.dedent(source) def _parse_py(source: str, mode: str = "exec") -> ast.AST: # decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning with warnings.catch_warnings(): warnings.simplefilter("ignore") return ast.parse(_dedent(source), mode=mode) def _dotted_name(e: Expr) -> str: if e["k"] == "name": return e["id"] if e["k"] == "attr": return f"{_dotted_name(e['o'])}.{e['a']}" return "" def _assign_name(t: ast.AST) -> str | None: if isinstance(t, ast.Name): return t.id if isinstance(t, ast.Attribute): inner = _assign_name(t.value) return f"{inner}.{t.attr}" if inner else None return None def _const(e: Expr | None) -> Any: if e is None: return None if e.get("k") == "const": return e["v"] if e.get("k") in ("tuple", "list"): return [_const(x) for x in e["v"]] if e.get("k") == "unary" and e["op"] == "-": v = _const(e["v"]) return -v if isinstance(v, (int, float)) else None return None def _num(s: str | None) -> float | None: try: return float(s) if s is not None else None except ValueError: return None def _static_props(kw: dict[str, Expr]) -> dict[str, Any]: props = {} for k, v in kw.items(): if k in POSITION_PROPS: val = _const(v) if val is not None: props[k] = val return props def _atl_props(text: str) -> dict[str, Any]: """Extract static `prop value` pairs from one ATL line, ignoring timing.""" tokens = text.replace(",", " ").split() props: dict[str, Any] = {} i = 0 while i < len(tokens) - 1: key = tokens[i] if key in POSITION_PROPS: raw = tokens[i + 1] try: props[key] = ast.literal_eval(raw) except (ValueError, SyntaxError): if raw.startswith("(") and i + 2 < len(tokens): try: props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2])) i += 1 except (ValueError, SyntaxError): pass i += 2 else: i += 1 return props def _split_top(source: str) -> list[str]: parts, depth, cur = [], 0, [] for ch in source: if ch in "([{": depth += 1 elif ch in ")]}": depth -= 1 if ch == "," and depth == 0: parts.append("".join(cur)) cur = [] else: cur.append(ch) if "".join(cur).strip(): parts.append("".join(cur)) return [p.strip() for p in parts if p.strip()] def _jump_or_call(op: str, target: Expr) -> Node: if target.get("k") == "const": return {"op": op, "target": target["v"]} return {"op": op, "expr": target}