Files
pgames-manager/backend/app/converter/rpy_parser.py
T
mrleo1nidandClaude Opus 5.5 e1d7b29f77 Localization stage 1: stable IDs, import of tl/ translations, language picker
- every script node gets a permanent id, say nodes a Ren'Py-style tid
- all languages from the game's tl/ are imported at conversion: dialogue blocks
  matched by their source-location comment or by label + original text,
  `translate strings` become a per-language strings table
- source language from config.language or detected from the dialogue
- manifest stores locales/<lang>/<module>.json and strings.json; game.json
  lists sourceLang and available locales with progress
- player: language picker in main menu and settings, fallback to source,
  __(), [var!t] and _preferences.language
- parser: `default` with init priority, and one bad statement no longer drops
  the whole file
- docs/LOCALIZATION.md: design for translations, publishing and mods

Line endings of several converter/player files go back to LF: an earlier
commit accidentally stored them as CRLF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 03:39:40 +03:00

1230 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)
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: <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}