feat: eval/exec of strings, gallery Replay, callable actions; iNSight and SomethingBetter now pass

- pyparse.ts: parser for expressions and simple statements built at run time, producing
  the sandbox's own IR; backs eval() and exec() (e.g. exec("persistent." + var + " = 0"))
- Replay/EndReplay and renpy.end_replay: the engine stashes the full game state, runs the
  label with the given scope and restores everything when it ends; locked replays are
  insensitive. Save loading shared as applySave()
- Screen actions are callable (MainMenu(confirm=False)())
- jump main_menu_screen is Ren'Py's own label: it opens the player's main menu
- Screens: a closing quote ends the word before a letter ("text"size 15) but not before
  an operator ("a"+b); Text(...) pictures in image blocks
- README: current capabilities and limits of the Ren'Py support

Strict compatibility check on the local collection: Tomie, Twisted-Thoughts, Hooked,
iNSight-of-you and SomethingBetter pass; TheFixer (custom AlphaMask wardrobe, missing
labels) and That_New_Teacher (custom statements, Render API) do not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:40:36 +03:00
1 parent 3923ee72fb
commit 3ac4f0371f
11 files changed
+448 -36

No files matched your search

+9 -2
View File
@@ -215,10 +215,14 @@ def mark_screen_calls(data) -> None:
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"}
# 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"}
# 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"}
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes",
"eval", "exec", "Replay", "EndReplay", "renpy.end_replay"}
def blocking_names(data) -> list[str]:
@@ -340,6 +344,9 @@ def audit_ir(data) -> None:
is_method = (function.get("k") == "attr" and not name.startswith(("renpy.", "math.", "random."))
and (function.get("a") in METHODS or function.get("a") in user_methods
or function.get("a") in user_names))
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
is_method = True
if name not in CALLS and name not in user_names and not is_method:
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in {"jump", "call"} and value.get("target"):
@@ -349,7 +356,7 @@ def audit_ir(data) -> None:
visit(child, location)
from app.converter.pipeline import anchors
labels = {label["name"] for module in data.modules.values() for label in module}
labels = {label["name"] for module in data.modules.values() for label in module} | ENGINE_LABELS
labels |= {name for module in data.modules.values() for name in anchors(module)}
for path, module in data.modules.items():
visit(module, path)
+1 -1
View File
@@ -18,7 +18,7 @@ _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
"ConditionSwitch", "Image", "Text", "im.Scale", "im.FactorScale", "im.Flip",
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
+2
View File
@@ -632,6 +632,8 @@ class RpyParser:
args, kw = e["a"], e["kw"]
if fname == "Solid" and args:
return {"color": _const(args[0])}
if fname == "Text" and args:
return {"textExpr": args[0]}
if fname in ("Movie",):
play = kw.get("play") or (args[0] if args else None)
return {"movie": _const(play)} if play else None
+7 -1
View File
@@ -79,8 +79,13 @@ def ignorable(name: str) -> bool:
def tokens(text: str) -> list[str]:
"""Split on whitespace outside brackets and quotes (Ren'Py "simple expressions")."""
out, cur, depth, quote, escaped = [], [], 0, "", False
out, cur, depth, quote, escaped, closed = [], [], 0, "", False, False
for ch in text:
if closed: # `"text"size 15`: a quote that ends a word ends the token
closed = False
if (ch.isalpha() or ch == "_") and depth == 0:
out.append("".join(cur))
cur = []
if quote:
cur.append(ch)
if escaped:
@@ -89,6 +94,7 @@ def tokens(text: str) -> list[str]:
escaped = True
elif ch == quote:
quote = ""
closed = True
elif ch in "\"'":
quote = ch
cur.append(ch)