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:
1 parent
e94f9b0d9e
commit
83cc7fdd5c
11 files changed
+379
-26
No files matched your search
@@ -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":
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user