feat: generator interpreter for Python that waits for the player (Ren'Py)
Python containing renpy.input, narrator("..."), character calls, renpy.say,
renpy.pause, renpy.display_menu, renpy.with_statement or renpy.call_screen now
runs through loops, try, functions, methods and expressions.
Player
- python.ts: execG/evalG/callG run only the code that may wait (mayBlock);
everything else stays on the synchronous fast path. A waiting call returns a
BlockingCall; outside the generator interpreter it is an explicit error. The
interpreter scope is set aside while the player decides
- engine: drives the generator and answers requests (dialogue, text input with
length/allow/exclude, pause, choice menu, transition, call screen)
- renpy.show/hide/scene, transitions and transforms as Python values,
Character(...) objects, Borders, set_tag_attributes
Converter
- blocking_names() lists game functions that may wait, transitively, and ships
them as game.json "blocking"; character calls are allowed
- hide screen of an unknown screen is harmless; images defined by a parenthesised
expression are supported
Hooked now passes the strict compatibility check (with Tomie and Twisted-Thoughts).
Tests: waiting.py fixture run through the generator interpreter; blocking-name analysis.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea03cebf6a
commit
3923ee72fb
11 files changed
+698
-27
No files matched your search
@@ -215,6 +215,53 @@ def mark_screen_calls(data) -> None:
|
||||
|
||||
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"}
|
||||
|
||||
# Calls that make the player wait; Python containing them runs on the player's generator interpreter
|
||||
BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
|
||||
"renpy.call_screen"}
|
||||
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes"}
|
||||
|
||||
|
||||
def blocking_names(data) -> list[str]:
|
||||
"""Names of game functions and methods that may wait for the player, directly or through each other."""
|
||||
definitions: dict[str, list] = {}
|
||||
|
||||
def collect(statements: list) -> None:
|
||||
for statement in statements:
|
||||
if statement.get("op") == "def":
|
||||
definitions.setdefault(statement["name"], []).append(statement)
|
||||
elif statement.get("op") == "class":
|
||||
collect([m for m in statement["body"] if m.get("op") == "def"])
|
||||
|
||||
collect(data.init_code)
|
||||
waiting = BLOCKING_BUILTINS | set(data.characters) | {"narrator"}
|
||||
found: set[str] = set()
|
||||
|
||||
def waits(node, known: set[str]) -> bool:
|
||||
stack = [node]
|
||||
while stack:
|
||||
value = stack.pop()
|
||||
if isinstance(value, list):
|
||||
stack.extend(value)
|
||||
elif isinstance(value, dict):
|
||||
if value.get("k") == "call":
|
||||
function = value["f"]
|
||||
last = function.get("id") if function.get("k") == "name" else function.get("a")
|
||||
dotted = ("renpy." + function["a"]) if (function.get("k") == "attr" and
|
||||
function["o"].get("k") == "name" and function["o"]["id"] == "renpy") else last
|
||||
if dotted in waiting or last in known or last in waiting:
|
||||
return True
|
||||
stack.extend(value.values())
|
||||
return False
|
||||
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, nodes in definitions.items():
|
||||
if name not in found and any(waits(n["body"], found) for n in nodes):
|
||||
found.add(name)
|
||||
changed = True
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def reachable_init_code(data, used_screens) -> list:
|
||||
"""The part of `init python` that can run: startup statements plus every function, class and
|
||||
@@ -273,6 +320,7 @@ def audit_ir(data) -> None:
|
||||
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
|
||||
list(used_screens.values())])
|
||||
user_names |= {name for name, _ in data.defaults}
|
||||
user_names |= set(data.characters) | {"narrator"}
|
||||
|
||||
def dotted(value):
|
||||
if value.get("k") == "name":
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.converter.compatibility import (
|
||||
CompatibilityError,
|
||||
Issue,
|
||||
audit_ir,
|
||||
blocking_names,
|
||||
detect,
|
||||
require_supported_scripts,
|
||||
)
|
||||
@@ -191,6 +192,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
"images": images,
|
||||
"transforms": data.transforms,
|
||||
"atl": data.atl,
|
||||
"blocking": blocking_names(data),
|
||||
"screens": {name: data.screens[name] for name in sorted(_used_screens(data))},
|
||||
"styles": data.styles,
|
||||
"transitions": data.transitions,
|
||||
|
||||
@@ -717,26 +717,32 @@ class RpyParser:
|
||||
return bool(m and m.group(1) in _DISPLAYABLE_CALLS)
|
||||
|
||||
def expression_picture(self, line: Line) -> dict[str, Any] | None:
|
||||
"""``image x:`` whose first line is a call computing the picture (``get_face("base")``) followed by
|
||||
transform lines: the picture is evaluated when the image is shown, the transforms are ATL."""
|
||||
"""``image x:`` whose first line is an expression computing the picture (``get_face("base")`` or
|
||||
``("a_" + str(n))``), optionally followed by transform lines: it is evaluated when the image is shown."""
|
||||
if not line.block:
|
||||
return None
|
||||
first = line.block[0]
|
||||
match = _CALL_LINE.match(first.text)
|
||||
text = first.text.strip()
|
||||
match = _CALL_LINE.match(text)
|
||||
word = Cursor(first.text).peek_word() or ""
|
||||
if (not match or self.displayable_line(first) or match.group(1) in self.d.transforms
|
||||
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
|
||||
if not (match or text.startswith("(")):
|
||||
return None
|
||||
if match and (self.displayable_line(first) or match.group(1) in self.d.transforms
|
||||
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
|
||||
return None
|
||||
expr = self.expr(text, first)
|
||||
if expr is None:
|
||||
return None
|
||||
picture: dict[str, Any] = {"expr": expr}
|
||||
rest = line.block[1:]
|
||||
expr = self.expr(first.text, first)
|
||||
if expr is None or not rest:
|
||||
return None
|
||||
issues: list[tuple[str, str]] = []
|
||||
props, animation = self.atl_spec(rest, issues.append, set())
|
||||
if issues or not animation:
|
||||
return None
|
||||
if rest:
|
||||
issues: list[tuple[str, str]] = []
|
||||
props, animation = self.atl_spec(rest, issues.append, set())
|
||||
if issues or not animation:
|
||||
return None
|
||||
picture["at"] = [{"props": props, "atl": animation}]
|
||||
self.d.warn("Изображение задано выражением", self.where(first), blocking=False)
|
||||
return {"expr": expr, "at": [{"props": props, "atl": animation}]}
|
||||
return picture
|
||||
|
||||
def atl_image(self, line: Line) -> dict[str, Any] | None:
|
||||
if (picture := self.expression_picture(line)) is not None:
|
||||
@@ -1231,8 +1237,7 @@ class RpyParser:
|
||||
name = c.word()
|
||||
if not name:
|
||||
return []
|
||||
self.d.screen_used.add(name)
|
||||
return [{"op": "hide_screen", "name": name}]
|
||||
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
|
||||
spec = self.image_spec(c, line)
|
||||
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
|
||||
self.show_clauses(c, line, node)
|
||||
|
||||
@@ -195,3 +195,24 @@ def test_gui_variants_and_renpy_store_are_understood():
|
||||
assert d.init_code[0]["decos"] == [] and d.init_code[0]["name"] == "twice"
|
||||
assert d.init_code[1]["e"]["f"]["o"] == {"k": "name", "id": "seen"}
|
||||
assert not d.blockers
|
||||
|
||||
|
||||
def test_functions_that_wait_for_the_player_are_listed_transitively():
|
||||
from app.converter.compatibility import audit_ir, blocking_names
|
||||
|
||||
d = parse(
|
||||
"define e = Character('E')\n"
|
||||
"init python:\n"
|
||||
" def ask_name():\n"
|
||||
" return renpy.input('Name?')\n"
|
||||
" def outer():\n"
|
||||
" n = ask_name()\n"
|
||||
" e('hi ' + n)\n"
|
||||
" def plain(x):\n"
|
||||
" return x + 1\n"
|
||||
"label start:\n"
|
||||
" $ outer()\n"
|
||||
" $ y = plain(2)\n")
|
||||
audit_ir(d)
|
||||
assert not d.blockers
|
||||
assert blocking_names(d) == ["ask_name", "outer"]
|
||||
Reference in new issue
Block a user