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:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:22:38 +03:00
1 parent ea03cebf6a
commit 3923ee72fb
11 files changed
+698 -27

No files matched your search

+48
View File
@@ -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":