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:
1 parent
35033fb86c
commit
a97b46079e
18 files changed
+3456
-2695
No files matched your search
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user