Converter: cover real Ren'Py games better, add folder import and dry run

Checked against six real games (Ren'Py 7.5 and 8.x):
- nested labels and named menus become jump anchors inside label bodies
- call with arguments and labels with parameters (dynamically scoped)
- frame-by-frame ATL images, Movie frames, layeredimage, camera, Shake,
  custom audio channels via register_channel, show text with expressions
- generator expressions in Python, no SyntaxWarning noise from decompiled code

Also:
- app.import_dir converts an unpacked game folder without changing it
- app.converter.analyze dry-runs the script part of the converter
- imports use their own "importing" status so a worker restart can't hijack them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:23:20 +03:00
1 parent 35033fb86c
commit a97b46079e
18 files changed
+3456 -2695

No files matched your search

+283 -31
View File
@@ -1,6 +1,8 @@
"""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
@@ -43,7 +45,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
"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", "layeredimage", "camera"}
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@@ -57,6 +59,7 @@ class ScriptData:
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)
@@ -73,6 +76,11 @@ 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 -------------------------------------------------------------
@@ -92,7 +100,7 @@ class RpyParser:
def python(self, source: str, line: Line) -> list[Stmt]:
"""Translate python code statement by statement, skipping unsupported ones."""
try:
tree = ast.parse(_dedent(source))
tree = _parse_py(source)
except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return []
@@ -114,13 +122,12 @@ class RpyParser:
c = self.cursor(line)
first = c.peek_word()
if first == "label":
c.keyword("label")
name = c.label_name()
if not name:
raise c.error("ожидалось имя метки")
if c.char("("):
self.d.warn("Параметры меток не поддерживаются", self.where(line))
current = {"name": name, "body": self.block(line.block)}
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)
@@ -143,7 +150,15 @@ class RpyParser:
out.append({"op": "if", "branches": branches})
continue
if word == "label":
self.d.warn("Вложенные метки не поддерживаются", self.where(line))
# 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
@@ -151,6 +166,37 @@ class RpyParser:
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):
@@ -282,11 +328,15 @@ class RpyParser:
def init_python(self, source: str, line: Line) -> None:
try:
tree = ast.parse(_dedent(source))
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)
@@ -306,6 +356,21 @@ class RpyParser:
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]:
@@ -499,25 +564,182 @@ class RpyParser:
def atl_image(self, line: Line) -> dict[str, Any] | None:
frames: list[dict[str, Any]] = []
props: dict[str, Any] = {}
for child in line.block:
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)
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 not frames:
self.d.warn("ATL-изображение без кадров", self.where(line))
return None
if len(frames) > 1:
self.d.warn("Анимированные ATL-изображения упрощены до первого кадра",
self.where(line))
img = dict(frames[0])
if props:
img["props"] = {**img.get("props", {}), **props}
return img
node["at"].append({"props": props})
return [node]
def st_transform(self, line: Line) -> list[Node]:
c = self.cursor(line)
@@ -588,6 +810,10 @@ class RpyParser:
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:
@@ -615,8 +841,12 @@ class RpyParser:
self.d.warn("Не поддерживается: screen", self.where(line))
return []
if c.keyword("text"):
text = c.string() or ""
node: Node = {"op": "show", "img": {"text": 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)}
@@ -668,6 +898,8 @@ class RpyParser:
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:
@@ -690,8 +922,6 @@ class RpyParser:
c = self.cursor(line)
c.keyword(op)
ch = c.word() or "music"
if ch not in CHANNELS:
self.d.warn(f"Нестандартный аудиоканал {ch}", self.where(line))
node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)}
while not c.eol():
if c.keyword("fadein"):
@@ -737,7 +967,7 @@ class RpyParser:
c.keyword("jump")
if c.keyword("expression"):
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}]
return [{"op": "jump", "target": c.label_name()}]
return [{"op": "jump", "target": self.label_ref(c.label_name() or "")}]
def st_call(self, line: Line) -> list[Node]:
c = self.cursor(line)
@@ -748,10 +978,12 @@ class RpyParser:
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": c.label_name()}
if c.peek("("):
self.d.warn("Аргументы call не поддерживаются", self.where(line))
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]:
@@ -791,11 +1023,19 @@ class RpyParser:
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
@@ -813,7 +1053,7 @@ class RpyParser:
else:
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
self.d.stats["menu"] += 1
return [node]
return out + [node]
# say ------------------------------------------------------------------------------
@@ -871,6 +1111,11 @@ class RpyParser:
_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"}
@@ -880,6 +1125,13 @@ def _dedent(source: str) -> str:
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"]