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

+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]: