Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
1234 lines
50 KiB
Python
1234 lines
50 KiB
Python
"""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)
|
|
blockers: 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 = "", *, blocking: bool = True) -> None:
|
|
self.warnings[kind] += 1
|
|
if blocking:
|
|
self.blockers[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),
|
|
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]:
|
|
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: <image>_<group>_<attribute>
|
|
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}
|