Files
pgames-manager/backend/app/converter/screens.py
T
mrleo1nidandClaude Sonnet 5.5 83cc7fdd5c feat: converter reads the Phone library's register statement, named stores and generators
`init phone register` becomes a default (the group chat) plus a function of library calls that runs with
the other defaults of a new game (phone_register.py). `init/python [early|hide] in some.store` wraps its
code in an in_store node and `default some.store.name` may name a store; yield works as an expression
(generator functions are marked gen); `import x as y`, `transform -150 name(...)`, `init:` with if/else,
`config.special_namespaces` lines, `at` lists and tuples, transforms made by Python functions, `choice`
weights that are expressions, `__len__`/`__bool__`, and the whitelist for os, calendar, itertools,
pygame_sdl2 and the new runtime. The converter also records the size of every picture it converts
(`imageSizes`) so `renpy.image_size` answers without loading.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:39:16 +03:00

517 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ren'Py screen language → PGVN screen IR.
Only the part games use for their own interface is translated (HUDs, hotspot menus,
phones, inventories). Whatever is not understood is reported through ``warn`` so a game
never imports with a half-working screen.
A screen is ``{"params": [...], "props": {...}, "defaults": [[name, expr]], "body": [node]}``.
Nodes:
* widget ``{"w": kind, "args": [expr], "props": {name: expr}, "body": [node]}``
* ``{"c": "if", "branches": [{"c": expr | None, "body": [...]}]}``
* ``{"c": "for", "t": target, "it": expr, "body": [...]}``
* ``{"c": "use", "name": str, "args": [expr], "kw": {}, "body": [...]}`` (body = transcluded children)
* ``{"c": "transclude"}``, ``{"c": "python", "code": [stmt]}``
* ``{"c": "key", "key": expr, "action": expr}``, ``{"c": "timer", ...}``, ``{"c": "on", "event": str, ...}``
"""
import ast
import re
from collections.abc import Callable
from typing import Any
from app.converter import pyexpr
from app.converter.atl import WARPER
from app.converter.exprtokens import read_value
from app.converter.pyexpr import Unsupported
from app.converter.rpy_lexer import Line
Node = dict[str, Any]
# Calls whose first argument names a screen to open
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect",
"drag"}
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea", "input"}
POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
LAYOUT = set("""xpos ypos pos xanchor yanchor anchor xalign yalign align xcenter ycenter xoffset yoffset
xsize ysize xysize xminimum yminimum xmaximum ymaximum minimum maximum xfill yfill area spacing xspacing
yspacing padding xpadding ypadding left_padding right_padding top_padding bottom_padding margin xmargin
ymargin left_margin right_margin top_margin bottom_margin width height""".split())
LOOK = set("""background foreground idle hover insensitive selected selected_idle selected_hover
selected_insensitive auto ground left_bar right_bar top_bar bottom_bar thumb alpha zoom rotate xzoom yzoom
clipping fit_first xfit yfit box_reverse box_wrap box_wrap_spacing order_reverse transpose rows cols crop size_group
xtile ytile matrixcolor blur""".split())
TEXT = set("""color size font bold italic underline strikethrough outlines text_align textalign line_spacing
kerning layout first_indent min_width antialias xalign yalign justify hyperlink_functions slow_cps""".split())
BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side
drag_name draggable droppable drag_handle drag_joined drag_offscreen cell_size fit start_lower default allow exclude mask length copypaste pixel_width multiline""".split())
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
"right_gutter", "top_gutter", "bottom_gutter")
IGNORED = {"capture", "axis", "key_events", "base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll",
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
"selected_insensitive")
ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"}
DESKTOP_VARIANTS = {"pc", "large", "web", "medium", "small"}
GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\)|\[.*\])$", re.S)
def known_property(name: str) -> bool:
if name in ALL or name in IGNORED or name.startswith(IGNORED_PREFIXES):
return True
if name.startswith("text_") and name[5:] in TEXT | LAYOUT:
return True
for prefix in STATE_PREFIXES:
if name.startswith(prefix + "_") and known_property(name[len(prefix) + 1:]):
return True
return False
def ignorable(name: str) -> bool:
return name in IGNORED or name.startswith(IGNORED_PREFIXES)
def tokens(text: str) -> list[str]:
"""Split on whitespace outside brackets and quotes (Ren'Py "simple expressions")."""
out, cur, depth, quote, escaped, closed = [], [], 0, "", False, False
for ch in text:
if closed: # `"text"size 15`: a quote that ends a word ends the token
closed = False
if (ch.isalpha() or ch == "_") and depth == 0:
out.append("".join(cur))
cur = []
if quote:
cur.append(ch)
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = ""
closed = True
elif ch in "\"'":
quote = ch
cur.append(ch)
elif ch in "([{":
depth += 1
cur.append(ch)
elif ch in ")]}":
depth -= 1
cur.append(ch)
if depth == 0:
closed = True
elif ch.isspace() and depth == 0:
if cur:
out.append("".join(cur))
cur = []
else:
cur.append(ch)
if cur:
out.append("".join(cur))
return out
class ScreenParser:
def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict],
atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None):
self.warn = warn
self.transform_ref = transform_ref
self.atl = atl
self.defaults: list[list[Any]] = []
def expr(self, source: str, line: Line) -> dict | None:
try:
return pyexpr.parse_expr(source)
except Unsupported as exc:
self.warn(f"Выражение в screen: {exc}", line)
return None
# --- definition -----------------------------------------------------------------
def screen(self, line: Line) -> tuple[str, dict] | None:
header = line.text.strip().rstrip(":").strip()
match = re.fullmatch(r"screen\s+([A-Za-z_]\w*)\s*(\((.*)\))?", header, re.S)
if not match:
self.warn("Не разобран заголовок screen", line)
return None
name = match.group(1)
try:
params = pyexpr.params(ast.parse(f"def f({match.group(3) or ''}): pass").body[0].args)
except (SyntaxError, Unsupported):
self.warn(f"Не разобраны параметры screen {name}", line)
return None
self.defaults = []
props, body = self.body(line.block, top=True)
props.pop("__atl__", None)
props.pop("__if__", None)
if "variant" in props:
variants = props["variant"]
text = variants["v"] if variants.get("k") == "const" else ""
if not any(v in DESKTOP_VARIANTS for v in re.findall(r"[a-z]+", str(text))):
return None # touch/phone layouts never apply to the desktop browser player
props.pop("variant")
return name, {"params": params, "props": props, "defaults": self.defaults, "body": body}
# --- statements -----------------------------------------------------------------
def is_atl(self, words: list[str]) -> bool:
first = words[0]
if first == "on":
return len(words) > 1 and not words[1].startswith(("'", '"'))
return first in ATL_WORDS or bool(WARPER.match(first))
def body(self, lines: list[Line], top: bool = False) -> tuple[dict[str, Any], list[Node]]:
"""Returns (properties declared in the block, child nodes)."""
props: dict[str, Any] = {}
nodes: list[Node] = []
atl_lines: list[Line] = []
conditional: list[list[dict]] = []
i = 0
while i < len(lines):
line = lines[i]
text = line.text.rstrip(":").strip() if line.block else line.text.strip()
text = re.sub(r"^(add|text|label|image|textbutton)(?=[\"'(])", r"\1 ", text) # `add"cover"`, `textbutton(_("x"))`
words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text))
i += 1
if not words:
continue
first = words[0]
if first in ("if", "showif"):
branches = [self.branch(first, words, line)]
while i < len(lines):
nxt = tokens(re.sub(r"^(elif|else)\(", r"\1 (", lines[i].text.rstrip(":").strip()))
if not nxt or nxt[0] not in ("elif", "else"):
break
branches.append(self.branch(nxt[0], nxt, lines[i]))
i += 1
branches = [b for b in branches if b]
if not top and branches and not any(b["body"] for b in branches) and any(b["props"] for b in branches):
# `if` inside a widget that only sets properties (idle "a" / idle "b")
conditional.append([{"c": b["c"], "props": b["props"]} for b in branches])
else:
nodes.append({"c": "if", "branches": [{"c": b["c"], "body": b["body"]} for b in branches]})
elif first in ("elif", "else"):
self.warn(f"Не поддерживается в screen: {first} без if", line)
elif first == "for":
node = self.for_loop(line)
if node:
nodes.append(node)
elif first == "use":
node = self.use(line, words)
if node:
nodes.append(node)
elif first in ("continue", "break"):
nodes.append({"c": first})
elif first == "transclude":
nodes.append({"c": "transclude"})
elif first == "pass":
pass
elif first == "has":
# `has vbox`: the rest of this block goes inside a new container
container = self.widget(line, words[1:])
if container:
_, rest = self.body(lines[i:])
container["body"].extend(rest)
nodes.append(container)
i = len(lines)
elif first == "python" and line.text.strip() == "python:" or first == "$":
nodes.extend(self.python(line, first))
elif first == "default" and "=" in text:
self.default(line)
elif first == "default": # `default "Bob"` inside an input: its starting text
props.update(self.properties(words, line))
elif first == "key":
node = self.key(line, words)
if node:
nodes.append(node)
elif first == "timer":
node = self.timer(line, words)
if node:
nodes.append(node)
elif first == "on" and not self.is_atl(words):
node = self.on(line, words)
if node:
nodes.append(node)
elif self.is_atl(words) and not top:
atl_lines.append(line)
elif first in CONTAINERS or first in LEAVES:
node = self.widget(line, words)
if node:
nodes.append(node)
elif (top and first in SCREEN_PROPS) or known_property(GLUED.sub(r"\1", first)):
props.update(self.properties(words, line))
else:
self.warn(f"Не поддерживается в screen: {first}", line)
if atl_lines:
props["__atl__"] = atl_lines
if conditional:
props["__if__"] = conditional
return props, nodes
def branch(self, kind: str, words: list[str], line: Line) -> dict | None:
cond = None
if kind != "else":
cond = self.expr(" ".join(words[1:]), line)
if cond is None:
return None
props, body = self.body(line.block)
props.pop("__atl__", None)
return {"c": cond, "body": body, "props": props}
def for_loop(self, line: Line) -> Node | None:
# `for item index item.id in items:`: the index only keys the widgets, the loop is the same
source = re.sub(r"\s+index\s+.+?\s+in\s+", " in ", line.text.strip().rstrip(":"), count=1)
try:
stmt = pyexpr.parse_stmts(source + ":\n pass")[0]
except Unsupported as exc:
self.warn(f"Цикл в screen: {exc}", line)
return None
_, body = self.body(line.block)
return {"c": "for", "t": stmt["t"], "it": stmt["it"], "body": body}
def use(self, line: Line, words: list[str]) -> Node | None:
if len(words) > 1 and words[1] == "expression":
return self.use_expression(line, words)
call = self.expr(" ".join(words[1:]), line)
if call is None:
return None
if call["k"] == "name":
node = {"c": "use", "name": call["id"], "args": [], "kw": {}}
elif call["k"] == "call" and call["f"]["k"] == "name":
node = {"c": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]}
else:
self.warn("Не разобран use в screen", line)
return None
if line.block: # children are placed where the used screen says `transclude`
_, node["body"] = self.body(line.block)
return node
def use_expression(self, line: Line, words: list[str]) -> Node | None:
"""``use expression name pass (args)``: the screen is chosen when the screen is drawn."""
match = re.fullmatch(r"(.+?)(?:\s+pass\s*(\(.*\)))?", " ".join(words[2:]), re.S)
name = self.expr(match.group(1), line) if match else None
call = self.expr("_use" + match.group(2), line) if match and match.group(2) else None
if name is None or (match.group(2) and (call is None or call["k"] != "call")):
self.warn("Не разобран use expression в screen", line)
return None
node: Node = {"c": "use", "nameExpr": name, "args": call["a"] if call else [],
"kw": call["kw"] if call else {}}
if call and call.get("ks"):
node["ks"] = call["ks"]
if line.block:
_, node["body"] = self.body(line.block)
return node
def python(self, line: Line, first: str) -> list[Node]:
source = line.text.strip()[1:].strip() if first == "$" else line.block_source()
try:
return [{"c": "python", "code": pyexpr.parse_stmts(source)}]
except Unsupported as exc:
self.warn(f"Python в screen: {exc}", line)
return []
def default(self, line: Line) -> None:
match = re.fullmatch(r"default\s+([A-Za-z_]\w*)\s*=\s*(.+)", line.text.strip(), re.S)
value = self.expr(match.group(2), line) if match else None
if not match or value is None:
self.warn("Не разобран default в screen", line)
return
self.defaults.append([match.group(1), value])
def block_properties(self, words: list[str], line: Line) -> dict[str, Any]:
props = self.properties(words, line)
for child in line.block:
props.update(self.properties(tokens(child.text), child))
return props
def key(self, line: Line, words: list[str]) -> Node | None:
shortcut = self.expr(words[1], line) if len(words) > 1 else None
props = self.block_properties(words[2:], line)
if shortcut is None or "action" not in props:
self.warn("Не разобран key в screen", line)
return None
return {"c": "key", "key": shortcut, "action": props["action"]}
def timer(self, line: Line, words: list[str]) -> Node | None:
delay = self.expr(words[1], line) if len(words) > 1 else None
props = self.block_properties(words[2:], line)
if delay is None or "action" not in props:
self.warn("Не разобран timer в screen", line)
return None
return {"c": "timer", "delay": delay, "action": props["action"], "repeat": props.get("repeat")}
def on(self, line: Line, words: list[str]) -> Node | None:
event = self.expr(words[1], line) if len(words) > 1 else None
props = self.block_properties(words[2:], line)
if event is None or event.get("k") != "const" or "action" not in props:
self.warn("Не разобран on в screen", line)
return None
if event["v"] not in ("show", "hide", "replace", "replaced"):
self.warn(f"Не поддерживается в screen: событие {event['v']}", line)
return None
return {"c": "on", "event": event["v"], "action": props["action"]}
# --- widgets --------------------------------------------------------------------
def widget(self, line: Line, words: list[str]) -> Node | None:
kind = words[0]
if kind == "image":
kind = "add"
if kind not in CONTAINERS and kind not in LEAVES:
self.warn(f"Не поддерживается в screen: {kind}", line)
return None
args = []
i = 1
for _ in range(POSITIONAL.get(kind, 0)):
if i >= len(words):
break
source, i = read_value(words, i)
value = self.expr(source, line)
if value is None:
return None
args.append(value)
if len(args) < POSITIONAL.get(kind, 0) and kind not in ("text", "label"):
self.warn(f"У {kind} в screen не хватает аргументов", line)
return None
props = self.properties(words[i:], line)
body: list[Node] = []
if line.block:
more, body = self.body(line.block)
props.update(more)
atl_lines = props.pop("__atl__", None)
conditional = props.pop("__if__", None)
if atl_lines:
self.inline_atl(props, atl_lines, line)
if body and kind in LEAVES:
self.warn(f"{kind} в screen не может содержать вложенные элементы", line)
return None
if kind in ("bar", "vbar"):
props["vertical"] = {"k": "const", "v": kind == "vbar"}
if kind == "fixed" and "cell_size" in props: # the game's DiamondBox (decompiled as a plain `fixed`)
kind = "diamondbox"
node: Node = {"w": kind, "args": args, "props": props, "body": body}
if conditional:
node["propIf"] = conditional
return node
def inline_atl(self, props: dict[str, Any], lines: list[Line], line: Line) -> None:
if self.atl is None:
self.warn("Не поддерживается в screen: ATL в виджете", line)
return
static, animation = self.atl(lines)
spec: dict[str, Any] = {"props": static}
if animation:
spec["atl"] = animation
props["at"] = [*props.get("at", []), spec]
def at_refs(self, source: str, line: Line) -> list[Node]:
"""The entries of one `at` item: `(a, b)` is two of them."""
text = source.strip()
if text[:1] in "([" and text[-1:] in ")]":
try:
tree = ast.parse(text, mode="eval").body
except SyntaxError:
tree = None
if isinstance(tree, (ast.Tuple, ast.List)):
return [spec for element in tree.elts for spec in self.at_refs(ast.unparse(element), line)]
return [self.at_ref(source, line)]
def at_ref(self, source: str, line: Line) -> Node:
"""One `at` entry; `(a if cond else b)` picks a transform while the screen is shown."""
text = source.strip()
if text.startswith("(") and text.endswith(")"):
try:
tree = ast.parse(text, mode="eval").body
except SyntaxError:
tree = None
if isinstance(tree, ast.IfExp):
cond = self.expr(ast.unparse(tree.test), line)
if cond is not None:
return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line),
"else": self.at_ref(ast.unparse(tree.orelse), line)}}
if text == "None":
return {}
if re.fullmatch(r"[A-Za-z_]\w*(?:\.\w+)*\[[^\]]+\]|[A-Za-z_]\w*(?:\.\w+)+", text): # `icon["at"]`, `item.at`
dynamic = self.expr(text, line)
if dynamic is not None:
return {"dyn": dynamic}
return self.transform_ref(source, line)
def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]:
props: dict[str, Any] = {}
i = 0
while i < len(words):
key, glued = words[i], None
match = GLUED.match(key) or re.fullmatch(r"([a-z_]+)([+-]?(?:\d|\.\d).*)", key)
if match and (known_property(match[1]) or match[1] in SCREEN_PROPS):
key, glued = match[1], match[2]
if key == "style_group": # the old name of style_prefix
key = "style_prefix"
if known_property(key) or key in SCREEN_PROPS:
pass
elif key in ("with", "nopredict"):
i += 1
continue
elif lenient: # styles carry many purely cosmetic properties we do not render
i += 2
continue
else:
self.warn(f"Не поддерживается в screen: свойство {key}", line)
return props
if glued is None and i + 1 >= len(words):
self.warn(f"У свойства {key} нет значения", line)
return props
if key == "at" and glued is None:
# `at a, b` / `at transform_name(args)`; transforms are not Python values
items, i = [], i + 1
while i < len(words):
items.append(words[i].rstrip(","))
more = words[i].endswith(",")
i += 1
if not more:
break
props["at"] = [spec for item in items if item for spec in self.at_refs(item, line)]
continue
if glued is not None:
source, i = glued, i + 1
else:
source, i = read_value(words, i + 1)
if ignorable(key):
continue
value = self.expr(source, line)
if value is not None:
props[key] = value
return props
def referenced_screens(definition: Any, known: dict[str, Any]) -> set[str]:
"""Screens a screen may open: `use` targets and string constants naming a screen."""
found: set[str] = set()
def visit(value: Any) -> None:
if isinstance(value, dict):
if value.get("c") == "use" and value.get("name") in known:
found.add(value["name"])
if value.get("k") == "call" and value.get("a"):
function, first = value["f"], value["a"][0]
name = function.get("id") if function.get("k") == "name" else (
function.get("a") if function.get("k") == "attr" else "")
if (name in OPENERS and first.get("k") == "const" and first.get("v") in known):
found.add(first["v"])
for child in value.values():
visit(child)
elif isinstance(value, list):
for child in value:
visit(child)
visit(definition)
return found