feat: Ren'Py screens (screen/call screen/show screen) with actions and styles

Converter
- screens.py parses the screen language: containers (fixed, frame, vbox, hbox,
  grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton,
  add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL,
  conditional properties, and decompiled forms (unparenthesised operators,
  glued property values, comma separated actions)
- style statements, call/show/hide screen operators and Python-level
  renpy.show_screen are translated; only screens the game shows are audited,
  unused stock screens no longer block an import; undefined screens do
- ATL values may be computed at run time and transforms take parameters
  (at blink(0.2, 0.3)); blur property

Player
- screens.ts builds the DOM from the IR and rebuilds it on state changes;
  buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal
  screens, timers and keys that survive re-rendering, saved with the save file
- Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide,
  ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ...
- Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property,
  globals(), renpy.variant/change_language/seen_image, transition stubs

Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from
tests/fixtures/screens.rpy. Docs: docs/SCREENS.md.

Twisted-Thoughts now passes the strict compatibility check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 13:17:11 +03:00
1 parent 47fc121e7d
commit 62ece25902
24 files changed
+2997 -60

No files matched your search

+32 -13
View File
@@ -11,6 +11,9 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
* ``{"op": "on", "events": [...], "block": block}``
* ``{"op": "use", "name": transform}``
A number or property value that is not a literal becomes ``{"e": expr}`` and is evaluated when the
timeline runs, with the transform's parameters in scope (``transform blink(delay, cycle):``).
Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix
colours) is reported through ``warn`` so the game is not imported half-working.
"""
@@ -19,6 +22,7 @@ import re
from collections.abc import Callable
from typing import Any
from app.converter import pyexpr
from app.converter.rpy_helpers import POSITION_PROPS
from app.converter.rpy_lexer import Line
@@ -69,12 +73,24 @@ class AtlParser:
# --- helpers -------------------------------------------------------------------
def number(self, raw: str, line: Line) -> float | None:
def value(self, raw: str, line: Line) -> Any:
"""A literal, or ``{"e": expr}`` for anything computed at run time."""
try:
value = ast.literal_eval(raw)
return ast.literal_eval(raw)
except (ValueError, SyntaxError):
pass
try:
return {"e": pyexpr.parse_expr(raw)}
except pyexpr.Unsupported:
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
return None
def number(self, raw: str, line: Line) -> Any:
value = self.value(raw, line)
if value is None:
return None
if isinstance(value, dict):
return value
if isinstance(value, bool) or not isinstance(value, (int, float)):
self.warn(f"Не поддерживается ATL: значение {raw}", line)
return None
@@ -94,15 +110,10 @@ class AtlParser:
if i + 1 >= len(parts):
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
return out
raw = parts[i + 1]
try:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
value = self.value(parts[i + 1], line)
if value is None:
return out
if isinstance(value, tuple):
value = list(value)
out[key] = value
out[key] = list(value) if isinstance(value, tuple) else value
i += 2
return out
@@ -123,7 +134,7 @@ class AtlParser:
if first == "repeat":
repeat = True if len(parts) == 1 else self.number(parts[1], line)
if repeat is not True and repeat is not None:
repeat = int(repeat)
repeat = repeat if isinstance(repeat, dict) else int(repeat)
elif first == "pause":
seconds = self.number(parts[1], line) if len(parts) > 1 else None
if seconds is not None:
@@ -182,7 +193,7 @@ class AtlParser:
words = tokens(current.text.rstrip(":").strip())
if not words or words[0] != first:
break
weight = 1.0
weight: Any = 1.0
if first == "choice" and len(words) > 1:
weight = self.number(words[1], current) or 1.0
group.append((weight, self.block(current.block)))
@@ -194,10 +205,18 @@ class AtlParser:
return j
def computed(value: Any) -> bool:
if isinstance(value, dict):
return "e" in value
return isinstance(value, list) and any(computed(item) for item in value)
def static_props(block: Block) -> dict[str, Any] | None:
"""Merged properties when the block only assigns, else None (it needs the timeline)."""
if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]):
return None
if any(computed(v) for step in block["steps"] for v in step["props"].values()):
return None
merged: dict[str, Any] = {}
for step in block["steps"]:
merged.update(step["props"])
@@ -210,5 +229,5 @@ def initial_props(block: Block) -> dict[str, Any]:
for step in block["steps"]:
if step["op"] != "set":
break
merged.update(step["props"])
merged.update({k: v for k, v in step["props"].items() if not computed(v)})
return merged
+53 -1
View File
@@ -105,6 +105,16 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("set_volume", "set_pause", "set_pan")}
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"}
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullAction Play Stop Notify
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate
ComposeTransition With""".split())
CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
"renpy.get_screen", "renpy.call_screen"}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
@@ -139,6 +149,11 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
target(value.get("t"))
elif kind == "lambda":
names.update(p["n"] for p in value["params"])
elif value.get("c") == "for":
target(value.get("t"))
elif "params" in value and "defaults" in value and "body" in value: # a screen
names.update(p["n"] for p in value["params"])
names.update(name for name, _ in value["defaults"])
elif kind == "comp":
target(value.get("t"))
for gen in value.get("gens", []):
@@ -152,9 +167,42 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
return names, methods
SCREEN_OPENERS = {"renpy.show_screen", "renpy.call_screen", "renpy.hide_screen", "Show", "ShowTransient",
"Hide"}
def mark_screen_calls(data) -> None:
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`."""
def visit(value):
if isinstance(value, list):
for item in value:
visit(item)
elif isinstance(value, dict):
if value.get("k") == "call" and value.get("a"):
name = value["f"]["id"] if value["f"].get("k") == "name" else (
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
first = value["a"][0]
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
if first["v"] in data.screens and first["v"] not in data.screen_used:
data.use_screen(first["v"])
changed.append(first["v"])
for child in value.values():
visit(child)
changed = [True]
while changed:
changed.clear()
visit([data.modules, data.init_code, data.defaults,
[data.screens[n] for n in data.screen_used if n in data.screens]])
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images])
mark_screen_calls(data)
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
list(used_screens.values())])
user_names |= {name for name, _ in data.defaults}
def dotted(value):
@@ -189,5 +237,9 @@ def audit_ir(data) -> None:
for path, module in data.modules.items():
visit(module, path)
visit(data.defaults, "default")
for name, definition in used_screens.items():
visit(definition, f"screen {name}")
for name in sorted(data.screen_used - set(data.screens)):
data.warn("Не найден screen", name)
visit(data.init_code, "init")
visit(data.images, "image")
+16
View File
@@ -191,6 +191,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"images": images,
"transforms": data.transforms,
"atl": data.atl,
"screens": {name: data.screens[name] for name in sorted(_used_screens(data))},
"styles": data.styles,
"transitions": data.transitions,
"audio": audio,
"channels": data.channels,
@@ -297,6 +299,20 @@ def _size(path: Path) -> int:
ENGINE_DIRS = ("cache", "saves", "tl", "renpy", "0x52-urm")
def _used_screens(data: ScriptData) -> set[str]:
"""Screens the game shows (and the ones those open); the rest stay out of game.json."""
from app.converter.screens import referenced_screens
used = {name for name in data.screen_used if name in data.screens}
pending = list(used)
while pending:
for other in referenced_screens(data.screens[pending.pop()], data.screens):
if other not in used:
used.add(other)
pending.append(other)
return used
def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ENGINE_DIRS
+1 -1
View File
@@ -11,7 +11,7 @@ Node = dict[str, Any]
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"}
"align", "pos", "anchor", "xysize", "size", "blur"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
+107 -9
View File
@@ -1,6 +1,7 @@
"""Ren'Py script (.rpy) → PGVN IR."""
import ast
import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
@@ -27,6 +28,7 @@ from app.converter.rpy_helpers import (
_static_props,
)
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
from app.converter.screens import ScreenParser, referenced_screens, tokens
Node = dict[str, Any]
@@ -59,6 +61,8 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
"offscreenleft", "offscreenright", "default", "reset"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
# Callables that look like transforms in `at` but are displayable/transition factories
KNOWN_FACTORIES = {"Position", "Transform", "Shake", "SnoopyShake", "Move", "Dissolve", "Fade", "Pause"}
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@@ -74,6 +78,11 @@ class ScriptData:
atl_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
atl_uses: dict[str, set[str]] = field(default_factory=dict)
atl_referenced: set[str] = field(default_factory=set)
screens: dict[str, dict[str, Any]] = field(default_factory=dict)
styles: dict[str, dict[str, Any]] = field(default_factory=dict)
# Like transforms, a screen only matters once something shows it
screen_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
screen_used: set[str] = field(default_factory=set)
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)
@@ -88,6 +97,17 @@ class ScriptData:
warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter)
def use_screen(self, name: str) -> None:
"""A screen is shown by the game: whatever it cannot do now blocks the import."""
self.screen_used.add(name)
for message, where in self.screen_pending.pop(name, []):
self.warn(message, where)
definition = self.screens.get(name)
if definition:
for other in referenced_screens(definition, self.screens):
if other not in self.screen_used or other in self.screen_pending:
self.use_screen(other)
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
self.warnings[kind] += 1
if blocking:
@@ -680,7 +700,7 @@ class RpyParser:
# 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))
self.d.warn("Изображение задано выражением", self.where(child), blocking=False)
frames.append({"img": {"expr": e}})
continue
if not frames or not frames[-1].get("d"):
@@ -814,6 +834,10 @@ class RpyParser:
issues: list[tuple[str, str]] = []
uses: set[str] = set()
props, animation = self.atl_spec(line.block, issues.append, uses)
params = self.transform_params(line)
if params: # parameters are read as run-time values, so the timeline is always needed
animation = animation or {"steps": [{"op": "set", "props": props}] if props else [], "repeat": None}
animation["params"] = params
self.d.transforms[name] = props
if animation:
self.d.atl[name] = animation
@@ -823,6 +847,16 @@ class RpyParser:
self.use_transform(name)
return []
def transform_params(self, line: Line) -> list[dict[str, Any]]:
match = re.fullmatch(r"transform\s+[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S)
if not match:
return []
try:
return pyexpr.params(ast.parse(f"def f({match.group(1)}): pass").body[0].args)
except (SyntaxError, Unsupported):
self.d.warn("Не разобраны параметры transform", self.where(line))
return []
def use_transform(self, name: str) -> None:
"""A transform is used by the game: its unsupported constructs now block the import."""
self.d.atl_referenced.add(name)
@@ -932,12 +966,17 @@ class RpyParser:
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:
if fname in self.d.transforms or fname in self.d.atl:
self.use_transform(fname)
spec = {"props": self.d.transforms[fname], "name": fname}
spec = {"props": self.d.transforms.get(fname, {}), "name": fname}
if fname in self.d.atl:
spec["atl"] = fname
spec["args"], spec["kw"] = e["a"], e["kw"]
return spec
if "." not in fname and fname[:1].islower() and fname not in KNOWN_FACTORIES:
# parameterised transform defined later (or in another file); the player resolves it by name
self.use_transform(fname)
return {"name": fname, "atl": fname, "args": e["a"], "kw": e["kw"]}
if e and e["k"] == "name":
self.use_transform(e["id"])
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
@@ -962,6 +1001,61 @@ class RpyParser:
], "repeat": None}
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block}
def screen_parser(self, collect=None) -> ScreenParser:
def warn(message: str, line: Line) -> None:
if collect is None:
self.d.warn(message, self.where(line))
else:
collect.append((message, self.where(line)))
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line),
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
def st_screen(self, line: Line) -> list[Node]:
issues: list[tuple[str, str]] = []
parsed = self.screen_parser(issues).screen(line)
if parsed:
name, definition = parsed
self.d.screens[name] = definition # a later definition replaces an earlier one
self.d.screen_pending[name] = issues
if name in self.d.screen_used:
self.use_screen(name)
return []
def use_screen(self, name: str) -> None:
self.d.use_screen(name)
def st_style(self, line: Line) -> list[Node]:
match = re.fullmatch(r"style\s+([A-Za-z_]\w*)(?:\s+is\s+([A-Za-z_]\w*))?\s*:?", line.text.strip())
if not match:
return [] # `style x take y`, `style x clear`: nothing a browser style needs
name, parent = match.groups()
parser = self.screen_parser([])
props: dict[str, Any] = {}
for child in line.block:
props.update(parser.properties(tokens(child.text), child, lenient=True))
entry = self.d.styles.setdefault(name, {"is": parent, "props": {}})
entry["is"] = parent or entry["is"]
entry["props"].update(props)
return []
def screen_call(self, c: Cursor, line: Line) -> Node | None:
"""``name(args)`` after `call/show screen`; trailing `nopredict` and `with` are ignored."""
source = re.split(r"\s+(?:nopredict|with)\b", c.rest(), maxsplit=1)[0].strip()
call = self.expr(source, line) if source else None
if call is None:
self.d.warn("Не разобран вызов screen", self.where(line))
return None
if call["k"] == "name":
name, args, kw = call["id"], [], {}
elif call["k"] == "call" and call["f"]["k"] == "name":
name, args, kw = call["f"]["id"], call["a"], call["kw"]
else:
self.d.warn("Не разобран вызов screen", self.where(line))
return None
self.use_screen(name)
return {"name": name, "args": args, "kw": kw}
def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("scene")
@@ -978,14 +1072,14 @@ class RpyParser:
c = self.cursor(line)
c.keyword("show")
if c.keyword("screen"):
self.d.warn("Не поддерживается: screen", self.where(line))
return []
node = self.screen_call(c, line)
return [{"op": "show_screen", **node}] if node else []
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,
node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS, ":"), line,
const(""))}}
self.show_clauses(c, line, node)
return [node]
@@ -998,7 +1092,11 @@ class RpyParser:
c = self.cursor(line)
c.keyword("hide")
if c.keyword("screen"):
return []
name = c.word()
if not name:
return []
self.d.screen_used.add(name)
return [{"op": "hide_screen", "name": name}]
spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
self.show_clauses(c, line, node)
@@ -1113,8 +1211,8 @@ class RpyParser:
c = self.cursor(line)
c.keyword("call")
if c.keyword("screen"):
self.d.warn("Не поддерживается: call screen", self.where(line))
return []
node = self.screen_call(c, line)
return [{"op": "call_screen", **node}] if node else []
if c.keyword("expression"):
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
const(""))}
+465
View File
@@ -0,0 +1,465 @@
"""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.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""".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"}
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
"if", "else", "not"}
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 = [], [], 0, "", False
for ch in text:
if quote:
cur.append(ch)
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = ""
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
def read_value(words: list[str], i: int) -> tuple[str, int]:
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
parts = [words[i]]
i += 1
if parts[0] in ("not", "-") and i < len(words):
parts.append(words[i])
i += 1
while i < len(words):
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
parts[-1] += words[i]
i += 1
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue with the next word
parts.append(words[i])
i += 1
elif i + 1 < len(words) and words[i] in OPERATORS:
parts.extend([words[i], words[i + 1]])
i += 2
else:
break
return " ".join(parts), i
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]
words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
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(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
+8
View File
@@ -14,6 +14,7 @@ from app.translation import file_json, put_json
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
OPS.add("nani_command")
OPS |= {"call_screen", "show_screen", "hide_screen"}
MISSING = object()
@@ -77,6 +78,13 @@ def validate(doc, manifest):
for key in ("expr", "c", "d"):
if isinstance(n.get(key), dict):
expression(n[key])
if op in ("call_screen", "show_screen"):
if not isinstance(n.get("name"), str) or not isinstance(n.get("args"), list):
raise ValueError("Некорректный вызов screen")
for item in [*n["args"], *(n.get("kw") or {}).values()]:
expression(item)
if op == "hide_screen" and not isinstance(n.get("name"), str):
raise ValueError("Некорректный вызов screen")
if op in {"say", "nani_add_choice"} or (op == "nani_command" and "what" in n):
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
raise ValueError("Некорректная реплика или повторяющийся tid")
+23
View File
@@ -0,0 +1,23 @@
"""Compile the screens/styles of a .rpy file to JSON: python -m tests.make_screens SOURCE.rpy OUT.json"""
import json
import sys
from pathlib import Path
from app.converter.compatibility import audit_ir, require_supported_scripts
from app.converter.pipeline import _used_screens
from app.converter.rpy_parser import RpyParser, ScriptData
def main(source: Path, out: Path) -> None:
data = ScriptData()
RpyParser(data, source.name).parse(source.read_text(encoding="utf-8"))
audit_ir(data)
require_supported_scripts(data)
shipped = {name: data.screens[name] for name in sorted(_used_screens(data))}
out.write_text(json.dumps({"screens": shipped, "styles": data.styles, "atl": data.atl,
"transforms": data.transforms}, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8")
if __name__ == "__main__":
main(Path(sys.argv[1]), Path(sys.argv[2]))
+4 -2
View File
@@ -73,13 +73,15 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
write(tmp_path / "game/script.rpy", source)
assert inspect_directory(tmp_path)["compatible"]
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
with pytest.raises(CompatibilityError, match="call screen"):
assert inspect_directory(tmp_path)["compatible"] # a defined screen can be called
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen missing_screen'))
with pytest.raises(CompatibilityError, match="screen"):
inspect_directory(tmp_path)
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
('label start:\n jump missing', "missing"),
])
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
+161
View File
@@ -0,0 +1,161 @@
import pytest
from app.converter.compatibility import CompatibilityError, audit_ir, require_supported_scripts
from app.converter.rpy_lexer import parse_tree
from app.converter.rpy_parser import RpyParser, ScriptData
HUD = '''
style hud_text:
size 28
color "#fff"
unknown_cosmetic_thing 3
transform pulse:
zoom 1.0
linear 0.5 zoom 1.1
repeat
screen hud(day=1):
zorder 5
tag hud
default open = False
frame:
xalign 0.0 yalign 0.0
background "#0008"
vbox:
spacing 4
text "Day [day]" style "hud_text"
if money > 10:
text "Rich" color "#0f0"
else:
text "Poor"
for item in inventory:
textbutton item action [SetVariable("held", item), Hide("hud")] at pulse
key "K_h" action ToggleScreenVariable("open")
timer 1.0 action NullAction() repeat True
use footer("bye")
screen footer(label):
imagebutton auto "btn_%s.png" action Return(1) hover_sound "tick.ogg"
label start:
$ money = 20
$ inventory = ["key"]
show screen hud(2)
call screen footer("x")
$ r = _return
hide screen hud
return
'''
def parse(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def test_screens_styles_and_calls_become_ir():
data = parse(HUD)
assert not data.blockers, data.blockers
hud = data.screens["hud"]
assert hud["params"][0]["n"] == "day"
assert hud["props"]["zorder"] == {"k": "const", "v": 5} and hud["props"]["tag"]["id"] == "hud"
assert hud["defaults"] == [["open", {"k": "const", "v": False}]]
frame = hud["body"][0]
assert frame["w"] == "frame" and frame["props"]["background"]["v"] == "#0008"
box = frame["body"][0]
assert box["w"] == "vbox" and [n.get("w") or n["c"] for n in box["body"]] == ["text", "if", "for"]
button = box["body"][2]["body"][0]
assert button["w"] == "textbutton" and button["props"]["action"]["k"] == "list"
assert button["props"]["at"] == [{"props": {"zoom": 1.0}, "name": "pulse", "atl": "pulse"}]
assert [n.get("c") for n in hud["body"][1:]] == ["key", "timer", "use"]
assert data.styles["hud_text"]["props"]["size"]["v"] == 28
assert "unknown_cosmetic_thing" not in data.styles["hud_text"]["props"]
ops = [n["op"] for label in data.modules["script.rpy"] for n in label["body"] if n["op"].endswith("screen")]
assert ops == ["show_screen", "call_screen", "hide_screen"]
call = next(n for label in data.modules["script.rpy"] for n in label["body"] if n["op"] == "call_screen")
assert call["name"] == "footer" and call["args"][0] == {"k": "const", "v": "x"}
def test_shown_screens_are_audited_but_unshown_ones_are_not():
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n "Hi"\n')
audit_ir(data)
require_supported_scripts(data) # never shown: harmless
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n call screen odd\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_unknown_actions_and_screens_block_the_import():
data = parse('screen s():\n textbutton "x" action Preference("fullscreen")\nlabel start:\n call screen s\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="Preference"):
require_supported_scripts(data)
data = parse('label start:\n call screen nowhere\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_screens_opened_from_python_and_by_actions_are_shipped():
from app.converter.pipeline import _used_screens
data = parse(
'screen a():\n textbutton "x" action Show("b")\nscreen b():\n text "B"\nscreen c():\n text "C"\n'
'screen d():\n text "D"\nlabel start:\n $ renpy.show_screen("d")\n call screen a\n')
audit_ir(data)
require_supported_scripts(data)
assert _used_screens(data) == {"a", "b", "d"}
def test_touch_variants_are_dropped_and_later_definitions_win():
data = parse(
'screen s():\n text "one"\nscreen s():\n variant "touch"\n text "touch"\n'
'screen t():\n text "one"\nscreen t():\n text "two"\nlabel start:\n "x"\n')
assert data.screens["s"]["body"][0]["args"][0]["v"] == "one"
assert data.screens["t"]["body"][0]["args"][0]["v"] == "two"
def test_lexer_keeps_screen_blocks_nested():
lines = parse_tree("screen s():\n vbox:\n text \"a\"\n")
assert lines[0].block[0].block[0].text.strip().startswith("text")
def test_decompiled_forms_operators_glued_properties_has_transclude_and_inline_atl():
data = parse(
'transform blink(delay, cycle=1.0):\n alpha 0.0\n pause delay\n linear cycle alpha 1.0\n'
'screen card(title):\n frame:\n has vbox\n text title\n transclude\n'
'screen deck(day):\n use card("T"):\n text "Day " + str(day) size 20\n'
' text "x" pos(5, 6) xysize(10,20)\n'
' add "bg.png":\n alpha 0.0\n linear 0.5 alpha 1.0\n'
' add "dot.png" at blink(0.2, 0.3)\n'
' on "show" action NullAction()\n'
'label start:\n call screen deck(1)\n')
audit_ir(data)
require_supported_scripts(data)
deck = data.screens["deck"]["body"]
use = deck[0]
assert use["c"] == "use" and use["body"][0]["args"][0]["k"] == "bin"
assert use["body"][0]["props"]["size"]["v"] == 20
card = data.screens["card"]["body"][0]
assert card["w"] == "frame" and [n.get("w") or n["c"] for n in card["body"][0]["body"]] == ["text", "transclude"]
assert deck[1]["props"]["pos"]["k"] == "tuple" and deck[1]["props"]["xysize"]["k"] == "tuple"
assert deck[2]["props"]["alpha"] == {"k": "const", "v": 0.0}
animated = deck[2]["props"]["at"][0]
assert animated["atl"]["steps"][-1]["op"] == "move" and animated["atl"]["steps"][-1]["props"] == {"alpha": 1.0}
called = deck[3]["props"]["at"][0]
assert called["name"] == "blink" and called["atl"] == "blink" and len(called["args"]) == 2
blink = data.atl["blink"]
assert [p["n"] for p in blink["params"]] == ["delay", "cycle"]
assert blink["steps"][1] == {"op": "pause", "t": {"e": {"k": "name", "id": "delay"}}}
assert deck[4] == {"c": "on", "event": "show", "action": {"k": "call", "f": {"k": "name", "id": "NullAction"}, "a": [], "kw": {}}}
def test_computed_atl_values_make_a_transform_animated():
data = parse('transform shift(x):\n xoffset x\nlabel start:\n "Hi"\n')
block = data.atl["shift"]
assert block["params"][0]["n"] == "x"
assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}}
assert data.transforms["shift"] == {}