From 83cc7fdd5c7554b225637d958c74c07110d04f73 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:39:16 +0300 Subject: [PATCH] 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 --- backend/app/converter/atl.py | 4 +- backend/app/converter/compatibility.py | 17 ++- backend/app/converter/phone_register.py | 192 ++++++++++++++++++++++++ backend/app/converter/pipeline.py | 19 +++ backend/app/converter/pyexpr.py | 17 ++- backend/app/converter/rpy_parser.py | 104 ++++++++++++- backend/app/converter/screens.py | 18 ++- backend/app/pgvn.py | 8 +- backend/tests/test_compatibility.py | 2 +- backend/tests/test_converter.py | 4 +- backend/tests/test_renpy_universal.py | 20 +++ 11 files changed, 379 insertions(+), 26 deletions(-) create mode 100644 backend/app/converter/phone_register.py diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index 9edb07a..2bf2659 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -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": diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 62516d8..ed44b50 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -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"} diff --git a/backend/app/converter/phone_register.py b/backend/app/converter/phone_register.py new file mode 100644 index 0000000..d6d920c --- /dev/null +++ b/backend/app/converter/phone_register.py @@ -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})" diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index 838e057..6339545 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -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 diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index bce8c14..c367036 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -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 diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index fa1964a..fbacab5 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -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]: diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index a46f6e8..7ecd42e 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -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 diff --git a/backend/app/pgvn.py b/backend/app/pgvn.py index 0bc930b..fda312f 100644 --- a/backend/app/pgvn.py +++ b/backend/app/pgvn.py @@ -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) diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index 45ff16f..ebdde51 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -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"), ]) diff --git a/backend/tests/test_converter.py b/backend/tests/test_converter.py index 3b371c0..e0a36be 100644 --- a/backend/tests/test_converter.py +++ b/backend/tests/test_converter.py @@ -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 diff --git a/backend/tests/test_renpy_universal.py b/backend/tests/test_renpy_universal.py index 55388b9..5615ee2 100644 --- a/backend/tests/test_renpy_universal.py +++ b/backend/tests/test_renpy_universal.py @@ -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"