Files
pgames-manager/backend/app/converter/screens.py
T
mrleo1nidandClaude Sonnet 5.5 3ac4f0371f feat: eval/exec of strings, gallery Replay, callable actions; iNSight and SomethingBetter now pass
- pyparse.ts: parser for expressions and simple statements built at run time, producing
  the sandbox's own IR; backs eval() and exec() (e.g. exec("persistent." + var + " = 0"))
- Replay/EndReplay and renpy.end_replay: the engine stashes the full game state, runs the
  label with the given scope and restores everything when it ends; locked replays are
  insensitive. Save loading shared as applySave()
- Screen actions are callable (MainMenu(confirm=False)())
- jump main_menu_screen is Ren'Py's own label: it opens the player's main menu
- Screens: a closing quote ends the word before a letter ("text"size 15) but not before
  an operator ("a"+b); Text(...) pictures in image blocks
- README: current capabilities and limits of the Ren'Py support

Strict compatibility check on the local collection: Tomie, Twisted-Thoughts, Hooked,
iNSight-of-you and SomethingBetter pass; TheFixer (custom AlphaMask wardrobe, missing
labels) and That_New_Teacher (custom statements, Render API) do not.

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

449 lines
20 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"}
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"}
POSITIONAL = {"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 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""".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 = {"xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable",
"child_size", "focus", "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)
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()
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 == "transclude":
nodes.append({"c": "transclude"})
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":
self.default(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:
try:
stmt = pyexpr.parse_stmts(line.text.strip().rstrip(":") + ":\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:
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 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"}
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 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)
if match and (known_property(match[1]) or match[1] in SCREEN_PROPS):
key, glued = match[1], match[2]
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"] = [self.transform_ref(item, line) for item in items if item]
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