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>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 22:39:16 +03:00
1 parent e94f9b0d9e
commit 83cc7fdd5c
11 files changed
+379 -26

No files matched your search

+3 -1
View File
@@ -399,7 +399,9 @@ class AtlParser:
break
weight: Any = 1.0
if first == "choice" and len(words) > 1:
weight = self.number(words[1], current) or 1.0
# the weight may be any expression, `not flag` included; a false flag means no chance at all
weight = self.number(" ".join(words[1:]), current)
weight = 1.0 if weight is None else weight
group.append((weight, self.block(current.block)))
j += 1
if first == "parallel":
+15 -2
View File
@@ -124,6 +124,16 @@ CALLS |= set(NATIVE_CLASSES)
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
CALLS |= {"object", "anim.Blink", "ExplodeText", "MouseDisplayable", "ui.saybehavior", "renpy.display.layout.AdjustTimes",
"renpy.display.layout.Motion", "renpy.display.matrix.Matrix", "renpy.display.matrix.offset",
"renpy.display.matrix.rotate", "renpy.display.matrix.perspective", "renpy.display.matrix.texture_projection",
"renpy.display.matrix.screen_projection", "renpy.gl2.gl2mesh2.Mesh2.texture_rectangle", "get_power_info"}
CALLS |= {"SnowBlossom", "chain", "itertools.chain", "calendar.Calendar", "calendar.monthrange", "calendar.isleap",
"os.listdir", "os.path.join", "os.path.basename", "os.path.dirname", "os.path.splitext", "os.path.exists",
"os.path.isfile", "time.gmtime", "time.localtime", "_preferences.set_volume", "_preferences.set_mute",
"Layer", "renpy.known_languages", "renpy.predict_screen", "renpy.predicting", "renpy.rollback",
"renpy.invoke_in_new_context", "renpy.add_layer", "renpy.show_layer_at", "renpy.filter_text_tags",
"renpy.text.text.Text", "renpy.easy.displayable_or_none", "renpy.easy.displayable"}
CALLS |= {"ConditionSwitch", "DynamicImage", "renpy.register_sl_displayable", "renpy.capture_focus", "renpy.clear_capture_focus", "renpy.get_widget", "renpy.display.focus.set_focused",
"renpy.file", "renpy.open_file", "loads", "dumps", "json.loads", "json.dumps", "Animation", "im.matrix", "renpy.context", "renpy.call_in_new_context", "renpy.get_focus_rect", "gui.rebuild", "FontGroup", "RenpyText",
"MatrixColor", "im.Image", "im.AlphaMask", "GLMovie", "renpy.display.im.Image", "colorsys.rgb_to_hls",
@@ -158,6 +168,7 @@ SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreensho
FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot
FileCurrentPage FilePageName FileSlotName""".split())
SCREEN_CALLS |= {"renpy.translation.translate_string"}
SCREEN_CALLS |= {"Skip", "Rollback", "ToggleLocalVariable", "Layer"}
# Text fields and the actions around them (see InputValue in screens.ts)
SCREEN_CALLS |= set("""VariableInputValue ScreenVariableInputValue FieldInputValue DictInputValue FilePageNameInputValue
CurrentScreenName CaptureFocus ClearFocus InvertSelected UploadSync DownloadSync Confirm""".split())
@@ -186,7 +197,8 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
METHODS |= NATIVE_METHODS
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
METHODS |= {"add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
METHODS |= {"send", "_duplicate", "_unique", "set_child", "get_placement", "subsurface", "update_state",
"adjusted_times", "get_range", "interpolate", "from_iterable", "itermonthdays", "isnumeric", "isdecimal", "add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
"add_focus", "circle", "rect", "line", "lines", "polygon"}
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
@@ -350,7 +362,8 @@ def mark_screen_calls(data) -> None:
visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]])
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__"}
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__", "__hash__", "__eq__", "__iter__", "__getitem__", "__contains__", "__len__",
"__bool__"}
# Labels Ren'Py itself defines (renpy/common); jumping to them goes to the player's own menu
ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
+192
View File
@@ -0,0 +1,192 @@
"""The ``init phone register`` statement of the Phone library (a chat app games put on a phone).
In Ren'Py the library registers the statement with its own parser, which runs while the game's scripts are read;
the converter cannot run it, so the statement is read here and turned into the Python the library would run:
init phone register:
define "[nova]":
add "mc" add "nova"
icon phone.asset("nova.png")
as novaTexts key "novaTexts"
label "Previous messages cannot be loaded."
time year 2023 month 12 day 25 hour 7 minute 7
"nova" "Morning!"
becomes ``default novaTexts = phone.group_chat.GroupChat(...)`` plus a function that adds the characters and calls
``phone.discussion.register_message/_label/_date/_image/_audio`` for each line. The function runs with the other
defaults of a new game, after the group chat exists. Only what the library's own parser accepts is read; anything
else raises ``PhoneSyntax`` and the statement is reported as unsupported.
"""
import re
from app.converter.rpy_lexer import Line
STRING = r"""(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')"""
TIME_PARTS = ("month", "day", "year", "hour", "minute", "second")
class PhoneSyntax(Exception):
pass
def _split_expression(text: str) -> tuple[str, str]:
"""The first simple expression of `text` (a string, or a name with brackets) and what follows it."""
text = text.lstrip()
if (m := re.match(STRING, text)):
return m[0], text[m.end():].lstrip()
depth, i = 0, 0
while i < len(text):
ch = text[i]
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch.isspace() and depth == 0:
break
i += 1
if not text[:i]:
raise PhoneSyntax("ожидалось выражение")
return text[:i], text[i:].lstrip()
class Register:
"""One `init phone register` block turned into Python source."""
def __init__(self, line: Line, number: int):
self.line = line
self.number = number
header = line.text.strip()
match = re.fullmatch(r"phone\s+register\s*(.*?):", header, re.S)
if not match:
raise PhoneSyntax("ожидалось `phone register`")
self.target = match[1].strip() or None # an existing group chat, or None when the block defines one
self.default: tuple[str, str] | None = None # (variable, expression)
self.body: list[str] = []
def source(self) -> tuple[str, tuple[str, str] | None]:
children = list(self.line.block)
lines: list[str] = []
if self.target is None:
if not children or not children[0].text.strip().startswith("define"):
raise PhoneSyntax("ожидалось `define`")
define = children.pop(0)
lines.extend(self.define(define))
else:
lines.append(f"gc = phone.group_chat.group_chat({self.target})")
if not children and self.target is not None:
raise PhoneSyntax("ожидался хотя бы один оператор")
lines.extend(self.statements(children, 1))
function = f"__phone_register_{self.number}"
text = f"def {function}():\n" + "\n".join(" " + item for item in lines) + "\n pass\n"
return text, self.default
# -- the group chat -------------------------------------------------------------------
def define(self, line: Line) -> list[str]:
match = re.fullmatch(rf"define\s+({STRING})\s*:", line.text.strip(), re.S)
if not match:
raise PhoneSyntax("ожидалось `define \"имя\":`")
name = match[1]
icon = key = variable = None
characters: list[str] = []
transient = False
for child in line.block:
rest = child.text.strip()
while rest:
word, _, tail = rest.partition(" ")
rest = tail.lstrip()
if word == "key":
key, rest = _split_expression(rest)
elif word == "icon":
icon, rest = _split_expression(rest)
elif word == "add":
character, rest = _split_expression(rest)
characters.append(character)
elif word == "transient":
transient = True
elif word == "as":
variable, rest = _split_expression(rest)
else:
raise PhoneSyntax(f"неизвестное свойство {word}")
if key is None:
raise PhoneSyntax("ожидалось свойство key")
icon = icon or 'phone.asset("default_icon.png")'
create = f"phone.group_chat.GroupChat({name}, {icon}, {key}, transient={transient})"
out = []
if variable is not None:
self.default = (variable, create)
out.append(f"gc = {variable}")
else:
out.append(f"gc = {create}")
out.extend(f"gc.add_character(phone.character.character({c}))" for c in characters)
return out
# -- the lines ------------------------------------------------------------------------
def statements(self, lines: list[Line], depth: int) -> list[str]:
out: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
text = line.text.strip()
i += 1
if text.startswith("if ") or text.startswith("if("):
condition = text[2:].strip().removesuffix(":").strip()
out.append(f"if {condition}:")
out.extend(" " + item for item in self.statements(line.block, depth + 1) or ["pass"])
while i < len(lines) and re.match(r"(elif\b|else\s*:)", lines[i].text.strip()):
branch = lines[i].text.strip().removesuffix(":").strip()
out.append(f"{branch}:")
out.extend(" " + item for item in self.statements(lines[i].block, depth + 1) or ["pass"])
i += 1
continue
out.extend(self.one(line, text))
return out
def one(self, line: Line, text: str) -> list[str]:
if text == "pass":
return []
if text.startswith("$"):
return [text[1:].strip()]
if re.fullmatch(r"python\s*:", text):
return [row for row in line.block_source().splitlines() if row.strip()]
if (m := re.match(r"label\s+(" + STRING + r")\s*$", text)):
return [f"phone.discussion.register_label(gc, {m[1]})"]
if text.startswith("time ") or text == "time":
return [self.date(text[4:].strip())]
if (m := re.match(r"image\s+(.*)", text)):
sender, rest = _split_expression(m[1])
image, rest = _split_expression(rest)
if rest:
raise PhoneSyntax("лишнее после image")
return [f"phone.discussion.register_image(gc, {sender}, {image})"]
if (m := re.match(r"audio\s+(.*)", text)):
sender, rest = _split_expression(m[1])
audio, rest = _split_expression(rest)
if rest:
raise PhoneSyntax("лишнее после audio")
return [f"phone.discussion.register_audio(gc, {sender}, {audio})"]
sender, rest = _split_expression(text)
message, rest = _split_expression(rest)
if rest or not re.fullmatch(STRING, message):
raise PhoneSyntax("ожидалось: отправитель \"сообщение\"")
return [f"phone.discussion.register_message(gc, {sender}, {message})"]
def date(self, text: str) -> str:
values = {part: "None" for part in TIME_PARTS}
auto = "False"
seen: set[str] = set()
rest = text
while rest:
word, _, tail = rest.partition(" ")
if word not in values and word != "auto":
raise PhoneSyntax(f"`{word}` не свойство time")
if word in seen:
raise PhoneSyntax(f"`{word}` указано дважды")
if word == "auto":
raise PhoneSyntax("`auto` нельзя в init phone register")
value, rest = _split_expression(tail)
values[word] = value
seen.add(word)
arguments = ", ".join(f"{k}={v}" for k, v in values.items())
return f"phone.discussion.register_date(gc, auto={auto}, {arguments})"
+19
View File
@@ -134,11 +134,16 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
failed: list[str] = []
done = 0
image_sizes: dict[str, list[int]] = {}
def work_one(p: Path) -> tuple[str, str, str, int]:
dimensions = _dimensions(p)
out, ext = assets.convert(p, out_dir)
blob_id, size = put_file(out, ext, move=True)
rel = p.relative_to(game_dir).as_posix()
logical = str(PurePosixPath(rel).with_suffix(ext))
if dimensions:
image_sizes[logical] = dimensions # the game asks for them (`renpy.image_size`) before anything is loaded
return rel, logical, blob_id, size
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
@@ -223,6 +228,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"build": data.build,
"files": aliases,
"dataFiles": data_files,
"imageSizes": image_sizes,
"defaults": data.defaults,
"init": data.init_code,
"ui": _ui(gui, aliases),
@@ -342,6 +348,19 @@ def _used_screens(data: ScriptData) -> set[str]:
DATA_FILE_LIMIT = 4 * 1024 * 1024
def _dimensions(path: Path) -> list[int] | None:
"""Width and height of a picture, read from its header (None for other media)."""
if assets.kind_of(path) != "image":
return None
try:
from PIL import Image
with Image.open(path) as img:
return [int(img.size[0]), int(img.size[1])]
except Exception: # noqa: BLE001 - the conversion itself reports a broken picture
return None
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
+9 -8
View File
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins"}
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame", "colorsys", "fnmatch", "traceback", "builtins", "os", "calendar", "pygame_sdl2"}
class Unsupported(Exception):
@@ -146,6 +146,8 @@ def expr(node: ast.AST) -> Expr:
"r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.Yield(value=value): # `x = yield` and `x = yield v`: the value sent in by `.send(...)`
return {"k": "yield", "v": None if value is None else expr(value)}
case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [element(e) for e in elts]}
case ast.Tuple(elts=elts):
@@ -282,7 +284,7 @@ def stmt(node: ast.stmt) -> list[Stmt]:
# Pickle hooks only matter for Ren'Py's own save files; the player keeps its own
IGNORED_DUNDERS = {"__setstate__", "__getstate__", "__reduce__", "__reduce_ex__"}
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", "__getitem__",
"__contains__"}
"__contains__", "__len__", "__bool__"}
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
DECORATORS = {"staticmethod", "classmethod", "property"}
@@ -314,18 +316,17 @@ def decorator(node: ast.expr) -> Expr:
def is_generator(body: list[ast.stmt]) -> bool:
"""A function with `yield` statements. The player runs it to the end and hands out what it yielded, which is
right for generators that end; `x = yield` (sending values in) and async code stay unsupported."""
"""A function with `yield` in it: the player runs it step by step (next, send). `yield from` is accepted as a
statement only, and async code stays unsupported."""
module = ast.Module(body=body, type_ignores=[])
statement_yields = {id(n.value) for n in ast.walk(module)
if isinstance(n, ast.Expr) and isinstance(n.value, (ast.Yield, ast.YieldFrom))}
statement_froms = {id(n.value) for n in ast.walk(module) if isinstance(n, ast.Expr) and isinstance(n.value, ast.YieldFrom)}
found = False
for n in ast.walk(module):
if isinstance(n, ast.Await):
raise Unsupported("async")
if isinstance(n, ast.YieldFrom) and id(n) not in statement_froms:
raise Unsupported("yield from как выражение")
if isinstance(n, (ast.Yield, ast.YieldFrom)):
if id(n) not in statement_yields:
raise Unsupported("yield как выражение")
found = True
return found
+96 -8
View File
@@ -73,6 +73,7 @@ BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv
@dataclass
class ScriptData:
phone_registers: int = 0 # `init phone register` blocks read so far (they get numbered functions)
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
images: dict[str, dict[str, Any]] = field(default_factory=dict)
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
@@ -468,12 +469,28 @@ class RpyParser:
return []
if c.keyword("python") or c.keyword("early"):
c.keyword("python")
self.init_python(line.block_source(), line)
c.keyword("hide") # `init python hide in x:` keeps its temporary names out of the store; they are only locals here
self.init_python(line.block_source(), line, self.store_name(c))
return []
rest = c.rest().strip()
if re.match(r"phone\s+register(?!\w)", rest): # the Phone library's statement (see phone_register.py)
self.init_phone_register(line, rest)
return []
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
return self.statement(replace(line, text=rest), top_level=True)
for child in self.fold_live2d(line.block):
children = self.fold_live2d(line.block)
i = 0
while i < len(children):
child = children[i]
if re.match(r"if(?!\w)", child.text.strip()): # `init:` with an if/elif/else of `$` lines
branches, i = self.if_chain(children, i)
code = self.init_branches(branches)
if code is None:
self.d.warn("Оператор сцены внутри init", self.where(child))
else:
self.d.init_code.extend(code)
continue
i += 1
for node in self.statement(child, top_level=True):
if node["op"] == "exec":
self.d.init_code.extend(node["code"])
@@ -481,6 +498,52 @@ class RpyParser:
self.d.warn("Оператор сцены внутри init", self.where(child))
return []
def init_branches(self, branches: list[dict[str, Any]]) -> list[Stmt] | None:
"""An if/elif/else whose branches hold only Python lines, as one init statement (None when they hold more)."""
def body(nodes: list[Node]) -> list[Stmt] | None:
out: list[Stmt] = []
for node in nodes:
if node["op"] == "exec":
out.extend(node["code"])
elif node["op"] == "if":
nested = self.init_branches(node["branches"])
if nested is None:
return None
out.extend(nested)
elif node["op"] != "window":
return None
return out
chain: dict[str, Any] | None = None
for branch in reversed(branches):
inner = body(branch["body"])
if inner is None:
return None
if branch["c"] is None:
chain = {"op": "if", "c": {"k": "const", "v": True}, "then": inner, "else": []}
else:
chain = {"op": "if", "c": branch["c"], "then": inner, "else": [chain] if chain else []}
return [chain] if chain else []
def init_phone_register(self, line: Line, rest: str) -> None:
from app.converter.phone_register import PhoneSyntax, Register
self.d.phone_registers += 1
number = self.d.phone_registers
try:
source, default = Register(replace(line, text=rest), number).source()
except PhoneSyntax as exc:
self.d.warn(f"Phone register: {exc}", self.where(line))
return
if default is not None: # the group chat object, created with the other defaults of a new game
created = self.expr(default[1], line)
if created is not None:
self.d.defaults.append([default[0], created])
self.d.init_code.extend(self.python(source, line))
# ...and filled right after it: a default whose value is a call of the function just defined
self.d.defaults.append([f"__phone_register_{number}_done",
{"k": "call", "f": {"k": "name", "id": f"__phone_register_{number}"}, "a": [], "kw": {}}])
@staticmethod
def fold_live2d(children: list[Line]) -> list[Line]:
"""`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts."""
@@ -502,14 +565,31 @@ class RpyParser:
c = self.cursor(line)
c.keyword("python")
if c.keyword("early"):
self.init_python(line.block_source(), line)
self.init_python(line.block_source(), line, self.store_name(c))
return []
if c.keyword("in"): # `python in some_store:` at run time: runs in that store
store = c.dotted()
return [{"op": "exec", "code": [{"op": "in_store", "ns": store, "body": self.python(line.block_source(), line)}]}]
return self.python_nodes(self.python(line.block_source(), line), line)
def st_rpy(self, line: Line) -> list[Node]:
return []
def init_python(self, source: str, line: Line) -> None:
@staticmethod
def store_name(c: Cursor) -> str | None:
"""The `in phone.discussion` of `init python in phone.discussion:`."""
return (c.dotted() or None) if c.keyword("in") else None
def init_python(self, source: str, line: Line, store: str | None = None) -> None:
start = len(self.d.init_code)
self.init_python_body(source, line)
if store: # the names this block defines belong to a named store
body = self.d.init_code[start:]
del self.d.init_code[start:]
if body:
self.d.init_code.append({"op": "in_store", "ns": store, "body": body})
def init_python_body(self, source: str, line: Line) -> None:
try:
tree = _parse_py(source)
except SyntaxError as e:
@@ -535,6 +615,9 @@ class RpyParser:
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
and ast.unparse(node.targets[0]).startswith("config.special_namespaces")):
continue # lets `define phone.config.x = ...` name a store; named stores are plain objects here
if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt = node.targets[0]
name = _assign_name(tgt)
@@ -822,7 +905,7 @@ class RpyParser:
layers.append({"pos": list(pos), "img": sub})
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
else None}
if fname == "Animation": # pictures that take turns, named by values known only while playing
if fname in ("Animation", "SnowBlossom"): # pictures that take turns / fall, built from values known while playing
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
return {"expr": e}
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
@@ -1159,6 +1242,7 @@ class RpyParser:
def st_transform(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("transform")
c.number() # `transform -150 _fits(size):`: an init priority
name = c.word()
if name:
issues: list[tuple[str, str]] = []
@@ -1178,7 +1262,7 @@ class RpyParser:
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)
match = re.fullmatch(r"transform\s+(?:-?\d+\s+)?[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S)
if not match:
return []
try:
@@ -1278,7 +1362,7 @@ class RpyParser:
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
break
def transform_ref(self, source: str, line: Line) -> dict[str, Any]:
def transform_ref(self, source: str, line: Line, dynamic: bool = False) -> dict[str, Any]:
source = source.strip()
if source in POSITIONS:
return {"pos": source}
@@ -1318,6 +1402,10 @@ class RpyParser:
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
# transform defined later in another file; the player resolves it by name
return {"name": e["id"]}
if dynamic and e and e["k"] == "call" and e["f"].get("k") in ("name", "attr"):
# in a screen, a Python function that makes a transform (`CurriedRoundedCorners(radius=...)`): evaluated when it is shown
self.d.warn("Трансформация вычисляется при показе", f"{self.where(line)}: {source}", blocking=False)
return {"dyn": e}
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
return {"name": source}
@@ -1344,7 +1432,7 @@ class RpyParser:
else:
collect.append((message, self.where(line)))
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line),
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line, dynamic=True),
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
def st_screen(self, line: Line) -> list[Node]:
+15 -3
View File
@@ -178,7 +178,7 @@ class ScreenParser:
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"`
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:
@@ -412,6 +412,18 @@ class ScreenParser:
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()
@@ -427,7 +439,7 @@ class ScreenParser:
"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`
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}
@@ -466,7 +478,7 @@ class ScreenParser:
i += 1
if not more:
break
props["at"] = [self.at_ref(item, line) for item in items if item]
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
+7 -1
View File
@@ -71,6 +71,8 @@ def expression(value, depth=0):
expression(item, depth + 1)
elif kind == "star":
ex("v")
elif kind == "yield":
ex("v", optional=True)
elif kind == "lambda":
parameters(value.get("params"), depth + 1)
ex("body")
@@ -127,12 +129,16 @@ def statements(code, depth=0):
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",),
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (), "yield": (), "yield_from": ("v",), "in_store": (),
"assert": ("c",)}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
for key in keys[op]:
expression(s.get(key), depth + 1)
if op == "in_store":
if not isinstance(s.get("ns"), str) or not all(part.isidentifier() for part in s["ns"].split(".")):
raise ValueError("Некорректное имя хранилища")
statements(s.get("body"), depth + 1)
if op == "if":
statements(s.get("then"), depth + 1)
statements(s.get("else"), depth + 1)
+1 -1
View File
@@ -80,7 +80,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n received = yield 1\nlabel start:\n $ helper()', "init python"),
('init python:\n def helper():\n received = yield from other()\nlabel start:\n $ helper()', "init python"),
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
('label start:\n jump missing', "missing"),
])
+2 -2
View File
@@ -57,10 +57,10 @@ def test_if_chain_and_menu():
def test_unsupported_python_is_reported_not_fatal():
d = parse("init python:\n def f():\n received = yield 1\n x = 1\nlabel start:\n $ y = x\n")
d = parse("init python:\n def f():\n received = yield from other()\n x = 1\nlabel start:\n $ y = x\n")
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
from app.converter.compatibility import audit_ir
d = parse("init python:\n def f():\n received = yield 1\nlabel start:\n $ f()\n")
d = parse("init python:\n def f():\n received = yield from other()\nlabel start:\n $ f()\n")
audit_ir(d)
assert d.blockers
+20
View File
@@ -265,3 +265,23 @@ def test_starred_tuple_items_and_pickle_hooks_are_accepted():
pgvn.statements(code)
assert code[0]["v"]["v"][1] == {"k": "star", "v": {"k": "name", "id": "rest"}}
assert [m["name"] for m in code[1]["body"]] == ["run"]
def test_init_phone_register_becomes_a_default_and_a_function_of_library_calls():
data = parse('init phone register:\n define "[suz]":\n add "mc" add "suzie"\n icon phone.asset("suzie.png")\n'
' as suzieTexts key "suzieTexts"\n label "Earlier"\n time year 2023 month 12 day 25 hour 7 minute 7\n'
' "suzie" "Merry Christmas!"\n if flag:\n "mc" "hi"\n else:\n "mc" "no"\n'
'label start:\n return\n')
assert data.defaults[0][0] == "suzieTexts" and data.defaults[0][1]["f"]["a"] == "GroupChat"
assert data.defaults[1][0] == "__phone_register_1_done"
function = next(s for s in data.init_code if s.get("op") == "def")
assert function["name"] == "__phone_register_1"
assert not data.blockers
def test_named_stores_wrap_their_code_and_defaults_may_name_a_store():
data = parse('init python in phone.group_chat:\n class GroupChat(object):\n pass\n def make():\n return GroupChat()\n'
'default -100 phone.menu = False\nlabel start:\n $ x = phone.group_chat.make()\n return\n')
assert data.init_code[0]["op"] == "in_store" and data.init_code[0]["ns"] == "phone.group_chat"
assert [s["name"] for s in data.init_code[0]["body"]] == ["GroupChat", "make"]
assert data.defaults[0][0] == "phone.menu"