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
+432 -20

No files matched your search

+16 -9
View File
@@ -133,16 +133,23 @@ python -m tests.make_sample sample.zip # тестовая игра (из ba
Python игры выполняется в песочнице плеера: свои `def` и `class` (наследование, Python игры выполняется в песочнице плеера: свои `def` и `class` (наследование,
`super()`, свойства, замыкания, `lambda`, `*args`/`**kwargs`), `try`/`except`/ `super()`, свойства, замыкания, `lambda`, `*args`/`**kwargs`), `try`/`except`/
`finally`, `raise`, `assert`, `for`/`while … else`, comprehension. ATL: движение `finally`, `raise`, `assert`, `match`, `for`/`while … else`, comprehension, модули
(`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`, `re`, `copy`, `time`, `datetime`, `collections.defaultdict`, `functools.partial`.
`block`, `on show`/`on hide`, вызов других transform. Код, который ждёт игрока (`renpy.input`, `narrator()`, реплики персонажей, `renpy.pause`,
`renpy.display_menu`, `renpy.call_screen`), работает внутри циклов и функций. `eval` и `exec`
строк разбираются своим парсером выражений и простых операторов.
ATL: движение (`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`,
`block`, `on show`/`on hide`/`on hover`, `contains`, `time`, `function`, смена картинок с
`with dissolve`, параметры и вызов других transform, `matrixcolor`, `blur`. Просмотр сцен из
галереи (`Replay`/`EndReplay`).
Экраны игры (`screen`, `call screen`, `show screen`) и их действия — см. Экраны игры (`screen`, `call screen`, `show screen`) и их действия — см.
[docs/SCREENS.md](docs/SCREENS.md). [docs/SCREENS.md](docs/SCREENS.md).
Пока **не** поддерживается: в экранах — `input`, `imagemap`, `mousearea`, `drag`, `side`, в ATL — Пока **не** поддерживается: в экранах — `imagemap`, `mousearea`, `drag`, `side`; шейдеры
`function`, `time`, `contains`, `matrixcolor` и шейдеры, (выбрасываются как чисто графические); `AlphaMask` и transform, в теле которого картинка;
в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, `with`,
`with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет системные модули (`os`, `socket`, …), а также собственные операторы и внутренности движка
своими функциями. Такие места перечислены в отчёте конвертации у версии и (`renpy.register_statement`, `renpy.Render`). Такие места перечислены в отчёте конвертации
останавливают импорт. Игры-«песочницы» на `renpy.ui.*` и `im.*` пока не играбельны. у версии и останавливают импорт.
+9 -2
View File
@@ -215,10 +215,14 @@ def mark_screen_calls(data) -> None:
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"} 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 # 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", BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
"renpy.call_screen"} "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]: 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.")) 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 and (function.get("a") in METHODS or function.get("a") in user_methods
or function.get("a") in user_names)) 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: if name not in CALLS and name not in user_names and not is_method:
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location) data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in {"jump", "call"} and value.get("target"): if value.get("op") in {"jump", "call"} and value.get("target"):
@@ -349,7 +356,7 @@ def audit_ir(data) -> None:
visit(child, location) visit(child, location)
from app.converter.pipeline import anchors 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)} labels |= {name for module in data.modules.values() for name in anchors(module)}
for path, module in data.modules.items(): for path, module in data.modules.items():
visit(module, path) 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+$") _NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") _CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", _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"} "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", _MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
+2
View File
@@ -632,6 +632,8 @@ class RpyParser:
args, kw = e["a"], e["kw"] args, kw = e["a"], e["kw"]
if fname == "Solid" and args: if fname == "Solid" and args:
return {"color": _const(args[0])} return {"color": _const(args[0])}
if fname == "Text" and args:
return {"textExpr": args[0]}
if fname in ("Movie",): if fname in ("Movie",):
play = kw.get("play") or (args[0] if args else None) play = kw.get("play") or (args[0] if args else None)
return {"movie": _const(play)} if play 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]: def tokens(text: str) -> list[str]:
"""Split on whitespace outside brackets and quotes (Ren'Py "simple expressions").""" """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: 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: if quote:
cur.append(ch) cur.append(ch)
if escaped: if escaped:
@@ -89,6 +94,7 @@ def tokens(text: str) -> list[str]:
escaped = True escaped = True
elif ch == quote: elif ch == quote:
quote = "" quote = ""
closed = True
elif ch in "\"'": elif ch in "\"'":
quote = ch quote = ch
cur.append(ch) cur.append(ch)
+10
View File
@@ -159,3 +159,13 @@ def test_computed_atl_values_make_a_transform_animated():
assert block["params"][0]["n"] == "x" assert block["params"][0]["n"] == "x"
assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}} assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}}
assert data.transforms["shift"] == {} assert data.transforms["shift"] == {}
def test_a_closing_quote_ends_the_word_and_text_pictures_are_understood():
data = parse(
'screen hud():\n text "D [a] A [b]"size 15\n text "x" size 20\n'
'image log_line:\n Text(log.message())\n alpha 0.0\n linear 0.5 alpha 1.0\n'
'label start:\n call screen hud\n')
first = data.screens["hud"]["body"][0]
assert first["props"]["size"] == {"k": "const", "v": 15}
assert data.images["log_line"]["textExpr"]["k"] == "call"
+48 -2
View File
@@ -8,6 +8,7 @@ import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { ScreenManager, type ScreenHost } from "./screens"; import { ScreenManager, type ScreenHost } from "./screens";
import { pythonModules } from "./pymodules"; import { pythonModules } from "./pymodules";
import { parseExpression, parseStatements } from "./pyparse";
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util"; import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type { import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
@@ -39,6 +40,7 @@ export class Engine {
private waiters: (() => void)[] = []; private waiters: (() => void)[] = [];
private screens: ScreenManager; private screens: ScreenManager;
private screenFonts = new Map<string, string>(); private screenFonts = new Map<string, string>();
private replayStash: SaveData | null = null;
private seenImages = new Set<string>(); private seenImages = new Set<string>();
private running = false; private running = false;
@@ -344,7 +346,10 @@ export class Engine {
version: () => "pgvn", version: () => "pgvn",
...this.screens.renpyApi(), ...this.screens.renpyApi(),
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
end_replay: () => null, end_replay: () => {
this.endReplay();
return null;
},
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args), partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
loadable: (path: any) => this.fileUrl(String(path)) !== null, loadable: (path: any) => this.fileUrl(String(path)) !== null,
is_init_phase: () => false, is_init_phase: () => false,
@@ -391,6 +396,13 @@ export class Engine {
// a read-only view: `"name" in globals()` works, writes do not reach the game // a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)), globals: () => new PyDict(Object.entries(this.store)),
print: () => null, print: () => null,
// code built at run time ("persistent." + name + " = 0") goes through a parser producing the same
// sandboxed IR; there is no other way to run source text
eval: (source: any) => this.py.eval(parseExpression(pyStr(source))),
exec: (source: any) => {
this.py.exec(parseStatements(pyStr(source)));
return null;
},
Borders: (...a: number[]) => a, Borders: (...a: number[]) => a,
...pythonModules(), ...pythonModules(),
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; }, _window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
@@ -614,6 +626,8 @@ export class Engine {
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer"); if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
}, },
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"), setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
startReplay: (label, scope) => this.startReplay(label, scope),
endReplay: () => this.endReplay(),
quickSave: () => void this.save("quick"), quickSave: () => void this.save("quick"),
quickLoad: () => void this.load("quick"), quickLoad: () => void this.load("quick"),
transform: (name) => this.game.transforms[name] ?? null, transform: (name) => this.game.transforms[name] ?? null,
@@ -726,6 +740,7 @@ export class Engine {
} }
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) { private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
if (target === "__main_menu__" || target === "__quit__") { if (target === "__main_menu__" || target === "__quit__") {
throw new Restart(target); throw new Restart(target);
} }
@@ -776,6 +791,10 @@ export class Engine {
private doReturn() { private doReturn() {
const frame = this.stack.pop(); const frame = this.stack.pop();
if (!frame) { if (!frame) {
if (this.replayStash) {
this.endReplay(); // the replayed label ran out: back to the game
return;
}
this.cur = { label: "", path: [0] }; this.cur = { label: "", path: [0] };
return; return;
} }
@@ -1459,7 +1478,12 @@ export class Engine {
frames.forEach((f, i) => Object.assign(f, mapped[i])); frames.forEach((f, i) => Object.assign(f, mapped[i]));
} }
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return; if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
this.restart(() => { this.replayStash = null;
this.restart(() => this.applySave(data));
}
/** Put the game into the state described by a save (also used to come back from a replay). */
private applySave(data: SaveData) {
this.resetStore(); this.resetStore();
Object.assign(this.store, decodeValue(data.store)); Object.assign(this.store, decodeValue(data.store));
this.screens.restore(decodeValue(data.screens ?? [])); this.screens.restore(decodeValue(data.screens ?? []));
@@ -1477,9 +1501,31 @@ export class Engine {
this.cur = data.cur; this.cur = data.cur;
this.lastSay = null; this.lastSay = null;
this.history = []; this.history = [];
}
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
private startReplay(label: string, scope: Record<string, any> | null) {
if (!this.labels.has(label)) {
this.notify(`Метка «${label}» не найдена`);
return;
}
if (!this.replayStash) this.replayStash = this.snapshotState();
this.restart(() => {
for (const [key, value] of Object.entries(scope ?? {})) this.store[key] = value;
this.store._in_replay = label;
this.stack = [];
this.cur = { label: "", path: [0] };
this.guardedJump(label);
}); });
} }
private endReplay() {
const stash = this.replayStash;
if (!stash) return;
this.replayStash = null;
this.restart(() => this.applySave(stash));
}
private setSkip(on: boolean) { private setSkip(on: boolean) {
this.skip = on; this.skip = on;
this.renderer.fast = on; this.renderer.fast = on;
+297
View File
@@ -0,0 +1,297 @@
// A small parser for Python source built at run time (eval/exec of strings): expressions and simple
// statements only. It produces the same IR as the converter, so the sandbox's rules apply unchanged.
import type { Expr, PyStmt } from "./types";
type Token = { t: "num" | "str" | "name" | "op" | "end"; v: any };
const OPS = ["**=", "//=", ">>=", "<<=", "**", "//", "==", "!=", "<=", ">=", "<<", ">>", "+=", "-=", "*=", "/=", "%=",
"&=", "|=", "^=", "->", "+", "-", "*", "/", "%", "<", ">", "=", "(", ")", "[", "]", "{", "}", ",", ":", ".", "&", "|", "^",
"~", ";"];
const MAX_SOURCE = 20000;
function fail(message: string): never {
throw new SyntaxError(`Синтаксическая ошибка в строке кода: ${message}`);
}
function tokenize(source: string): Token[] {
if (source.length > MAX_SOURCE) fail("слишком длинный код");
const out: Token[] = [];
let i = 0;
while (i < source.length) {
const ch = source[i];
if (ch === "\n") { out.push({ t: "op", v: "\n" }); i++; continue; }
if (/\s/.test(ch)) { i++; continue; }
if (ch === "#") { while (i < source.length && source[i] !== "\n") i++; continue; }
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] ?? ""))) {
const m = /^(0[xX][0-9a-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)/.exec(source.slice(i))!;
out.push({ t: "num", v: Number(m[0]) });
i += m[0].length;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
const m = /^[A-Za-z_]\w*/.exec(source.slice(i))!;
const next = source[i + m[0].length];
if (/^[rRuUbB]$/.test(m[0]) && (next === "'" || next === '"')) { i += m[0].length; continue; } // string prefix
out.push({ t: "name", v: m[0] });
i += m[0].length;
continue;
}
if (ch === "'" || ch === '"') {
const triple = source.startsWith(ch.repeat(3), i);
const quote = triple ? ch.repeat(3) : ch;
let j = i + quote.length;
let text = "";
while (j < source.length && !source.startsWith(quote, j)) {
if (source[j] === "\\" && j + 1 < source.length) {
const e = source[j + 1];
text += ({ n: "\n", t: "\t", r: "\r", "0": "\0" } as Record<string, string>)[e] ?? e;
j += 2;
} else text += source[j++];
}
if (j >= source.length) fail("строка не закрыта");
out.push({ t: "str", v: text });
i = j + quote.length;
continue;
}
const op = OPS.find((o) => source.startsWith(o, i));
if (!op) fail(`неизвестный символ «${ch}»`);
out.push({ t: "op", v: op });
i += op.length;
}
out.push({ t: "end", v: null });
return out;
}
const KEYWORDS = new Set(["and", "or", "not", "in", "is", "if", "else", "lambda", "for", "while", "def", "class", "import", "from",
"return", "yield", "with", "try", "except", "raise", "global", "nonlocal", "assert", "del", "pass", "elif", "async", "await"]);
const BIN_LEVELS: string[][] = [["|"], ["^"], ["&"], ["<<", ">>"], ["+", "-"], ["*", "/", "//", "%"]];
const COMPARE = new Set(["<", ">", "==", "!=", "<=", ">=", "in", "is"]);
class Parser {
private pos = 0;
constructor(private tokens: Token[]) {}
private peek(): Token { return this.tokens[this.pos]; }
private next(): Token { return this.tokens[this.pos++]; }
private isOp(v: string) { const t = this.peek(); return t.t === "op" && t.v === v; }
private isName(v: string) { const t = this.peek(); return t.t === "name" && t.v === v; }
private eat(v: string) { if (this.isOp(v)) { this.pos++; return true; } return false; }
private expect(v: string) { if (!this.eat(v)) fail(`ожидалось «${v}»`); }
atEnd() { return this.peek().t === "end"; }
expression(): Expr {
const body = this.orTest();
if (this.isName("if")) {
this.next();
const cond = this.orTest();
if (!this.isName("else")) fail("ожидалось else");
this.next();
return { k: "if", c: cond, t: body, e: this.expression() };
}
return body;
}
private orTest(): Expr {
const parts = [this.andTest()];
while (this.isName("or")) { this.next(); parts.push(this.andTest()); }
return parts.length === 1 ? parts[0] : { k: "bool", op: "or", v: parts };
}
private andTest(): Expr {
const parts = [this.notTest()];
while (this.isName("and")) { this.next(); parts.push(this.notTest()); }
return parts.length === 1 ? parts[0] : { k: "bool", op: "and", v: parts };
}
private notTest(): Expr {
if (this.isName("not")) { this.next(); return { k: "unary", op: "not", v: this.notTest() }; }
return this.comparison();
}
private comparison(): Expr {
const left = this.binary(0);
const ops: string[] = [];
const rights: Expr[] = [];
for (;;) {
const t = this.peek();
let op: string | null = null;
if (t.t === "op" && COMPARE.has(t.v)) { op = t.v; this.next(); }
else if (t.t === "name" && t.v === "in") { op = "in"; this.next(); }
else if (t.t === "name" && t.v === "is") { this.next(); op = this.isName("not") ? (this.next(), "is not") : "is"; }
else if (t.t === "name" && t.v === "not" && this.tokens[this.pos + 1]?.v === "in") { this.pos += 2; op = "not in"; }
if (!op) break;
ops.push(op);
rights.push(this.binary(0));
}
return ops.length ? { k: "cmp", l: left, ops, r: rights } : left;
}
private binary(level: number): Expr {
if (level >= BIN_LEVELS.length) return this.factor();
let left = this.binary(level + 1);
for (;;) {
const t = this.peek();
if (t.t === "op" && BIN_LEVELS[level].includes(t.v)) {
this.next();
left = { k: "bin", op: t.v, l: left, r: this.binary(level + 1) };
} else return left;
}
}
private factor(): Expr {
const t = this.peek();
if (t.t === "op" && (t.v === "-" || t.v === "+" || t.v === "~")) {
this.next();
return { k: "unary", op: t.v, v: this.factor() };
}
return this.power();
}
private power(): Expr {
const base = this.primary();
if (this.eat("**")) return { k: "bin", op: "**", l: base, r: this.factor() };
return base;
}
private primary(): Expr {
let e = this.atom();
for (;;) {
if (this.eat(".")) {
const t = this.next();
if (t.t !== "name") fail("ожидалось имя после точки");
e = { k: "attr", o: e, a: t.v };
} else if (this.eat("(")) {
const args: Expr[] = [];
const kw: Record<string, Expr> = {};
while (!this.isOp(")")) {
const t = this.peek();
if (t.t === "name" && this.tokens[this.pos + 1]?.v === "=" && this.tokens[this.pos + 1].t === "op") {
this.pos += 2;
kw[t.v] = this.expression();
} else args.push(this.expression());
if (!this.eat(",")) break;
}
this.expect(")");
e = { k: "call", f: e, a: args, kw };
} else if (this.eat("[")) {
let lo: Expr | null = null;
if (!this.isOp(":")) lo = this.expression();
if (this.eat(":")) {
const hi = this.isOp("]") || this.isOp(":") ? null : this.expression();
const st = this.eat(":") && !this.isOp("]") ? this.expression() : null;
e = { k: "slice", o: e, lo, hi, st };
} else e = { k: "sub", o: e, i: lo! };
this.expect("]");
} else return e;
}
}
private atom(): Expr {
const t = this.next();
if (t.t === "num") return { k: "const", v: t.v };
if (t.t === "str") {
let text = t.v as string;
while (this.peek().t === "str") text += this.next().v;
return { k: "const", v: text };
}
if (t.t === "name") {
if (t.v === "True") return { k: "const", v: true };
if (t.v === "False") return { k: "const", v: false };
if (t.v === "None") return { k: "const", v: null };
if (KEYWORDS.has(t.v)) fail(`«${t.v}» здесь не поддерживается`);
return { k: "name", id: t.v };
}
if (t.t === "op" && t.v === "(") {
if (this.eat(")")) return { k: "tuple", v: [] };
const first = this.expression();
if (this.eat(",")) {
const items = [first];
while (!this.isOp(")")) { items.push(this.expression()); if (!this.eat(",")) break; }
this.expect(")");
return { k: "tuple", v: items };
}
this.expect(")");
return first;
}
if (t.t === "op" && t.v === "[") {
const items: Expr[] = [];
while (!this.isOp("]")) { items.push(this.expression()); if (!this.eat(",")) break; }
this.expect("]");
return { k: "list", v: items };
}
if (t.t === "op" && t.v === "{") {
const pairs: [Expr, Expr][] = [];
while (!this.isOp("}")) {
const key = this.expression();
this.expect(":");
pairs.push([key, this.expression()]);
if (!this.eat(",")) break;
}
this.expect("}");
return { k: "dict", v: pairs };
}
return fail(`неожиданный элемент «${t.v ?? "конец"}»`);
}
/** One simple statement: assignment, augmented assignment, pass or an expression. */
statement(): PyStmt[] {
if (this.isName("pass")) { this.next(); return []; }
const first = this.expressionOrTuple();
const t = this.peek();
if (t.t === "op" && t.v === "=") {
const targets: Expr[] = [first];
let value: Expr = first;
while (this.eat("=")) {
value = this.expressionOrTuple();
if (this.isOp("=")) targets.push(value);
}
return targets.map((target) => ({ op: "set", t: asTarget(target), v: value }));
}
if (t.t === "op" && /^(\*\*|\/\/|>>|<<|[+\-*/%&|^])=$/.test(t.v)) {
this.next();
return [{ op: "aug", t: asTarget(first), o: t.v.slice(0, -1), v: this.expressionOrTuple() }];
}
return [{ op: "eval", e: first }];
}
private expressionOrTuple(): Expr {
const first = this.expression();
if (!this.isOp(",")) return first;
const items = [first];
while (this.eat(",")) {
if (this.atEnd() || this.isOp("=") || this.isOp("\n") || this.isOp(";")) break;
items.push(this.expression());
}
return { k: "tuple", v: items };
}
separators() {
while (this.isOp("\n") || this.isOp(";")) this.pos++;
}
}
function asTarget(e: Expr): Expr {
if (e.k === "name" || e.k === "attr" || e.k === "sub") return e;
if (e.k === "tuple" || e.k === "list") return { k: "tuple", v: e.v.map(asTarget) };
return fail("в эту часть нельзя присваивать");
}
export function parseExpression(source: string): Expr {
const parser = new Parser(tokenize(source.trim()));
const e = parser.expression();
parser.separators();
if (!parser.atEnd()) fail("лишний текст после выражения");
return e;
}
export function parseStatements(source: string): PyStmt[] {
const parser = new Parser(tokenize(source));
const out: PyStmt[] = [];
parser.separators();
while (!parser.atEnd()) {
out.push(...parser.statement());
parser.separators();
}
return out;
}
+1
View File
@@ -585,6 +585,7 @@ export class Python {
} }
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args; const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
if (f instanceof PyClass) return this.instantiate(f, call); if (f instanceof PyClass) return this.instantiate(f, call);
if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions
if (typeof f !== "function") { if (typeof f !== "function") {
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`); this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
return null; return null;
+19 -3
View File
@@ -38,6 +38,8 @@ export interface ScreenHost {
openUrl(url: string): void; openUrl(url: string): void;
quickSave(): void; quickSave(): void;
quickLoad(): void; quickLoad(): void;
startReplay(label: string, scope: Record<string, any> | null): void;
endReplay(): void;
setLanguage(code: string | null): void; setLanguage(code: string | null): void;
transform(name: string): Record<string, any> | null; transform(name: string): Record<string, any> | null;
atl(name: string): AtlBlock | null; atl(name: string): AtlBlock | null;
@@ -49,6 +51,8 @@ class ActionAbort extends Error {}
/** A screen action such as Jump("x"); run when the player activates a widget. */ /** A screen action such as Jump("x"); run when the player activates a widget. */
export class ScreenAction { export class ScreenAction {
/** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */
__call__: () => null = () => null;
constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {} constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {}
} }
@@ -1084,7 +1088,8 @@ export class ScreenManager {
private actionSensitive(action: any): boolean { private actionSensitive(action: any): boolean {
const list = Array.isArray(action) ? action : [action]; const list = Array.isArray(action) ? action : [action];
return list.every((a) => !(a instanceof ScreenAction) || a.kind !== "SensitiveIf" || truthy(a.args[0])); return list.every((a) => !(a instanceof ScreenAction) ||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true));
} }
private actionSelected(action: any): boolean { private actionSelected(action: any): boolean {
@@ -1169,6 +1174,12 @@ export class ScreenManager {
} }
case "SetLocalVariable": case "SetLocalVariable":
case "SetScreenVariable": owner?.vars.set(a, b); return; case "SetScreenVariable": owner?.vars.set(a, b); return;
case "Replay": {
const scope = action.kw.scope ?? b;
this.host.startReplay(String(a), scope instanceof PyDict ? Object.fromEntries([...scope.map.entries()].map(([k, v]) => [String(k), v])) : null);
throw new ActionAbort();
}
case "EndReplay": this.host.endReplay(); throw new ActionAbort();
case "QuickSave": this.host.quickSave(); return; case "QuickSave": this.host.quickSave(); return;
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort(); case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
case "ToggleScreenVariable": { case "ToggleScreenVariable": {
@@ -1217,13 +1228,18 @@ export class ScreenManager {
const make = (kind: string) => (...args: any[]) => { const make = (kind: string) => (...args: any[]) => {
const last = args[args.length - 1]; const last = args[args.length - 1];
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
return new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering); const action = new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering);
action.__call__ = () => {
this.run(action, action.screen);
return null;
};
return action;
}; };
const out: Record<string, any> = {}; const out: Record<string, any> = {};
for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField", for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", "Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) out[kind] = make(kind); "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay"]) out[kind] = make(kind);
const shape = (kind: string) => (...args: any[]) => { const shape = (kind: string) => (...args: any[]) => {
const last = args[args.length - 1]; const last = args[args.length - 1];
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
+22 -2
View File
@@ -9,12 +9,12 @@ Object.assign(globalThis, { window, document });
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4); globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
const compiled = await build({ const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`, stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules"; export * from "./pyparse";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" }, resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent", bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
}); });
const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m; const { parseExpression, parseStatements, Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const name = (id) => ({ k: "name", id }); const name = (id) => ({ k: "name", id });
const attr = (o, a) => ({ k: "attr", o, a }); const attr = (o, a) => ({ k: "attr", o, a });
@@ -130,3 +130,23 @@ test("on hover handlers run when the button reports the event", async () => {
await wait(20); await wait(20);
assert.equal(props.alpha, 1); assert.equal(props.alpha, 1);
}); });
test("eval/exec of source built at run time use the sandbox's own rules", () => {
const { py, store } = python();
store.score = 7;
store.persistent = new PyDict();
const evaluate = (source) => py.eval(parseExpression(source));
assert.equal(evaluate("score * 2 + 1"), 15);
assert.equal(evaluate("'a' + 'b' if score > 5 else 'c'"), "ab");
assert.equal(evaluate("not (score < 3) and score in [7, 8]"), true);
assert.deepEqual(evaluate("[1, 2, (3, 4)][2]"), [3, 4]);
assert.equal(evaluate("score < 10 < 20"), true);
py.exec(parseStatements("score += 3; other = score ** 2\npersistent.seen = 1"));
assert.equal(store.score, 10);
assert.equal(store.other, 100);
assert.equal(store.persistent.get("seen"), 1);
assert.throws(() => parseExpression("score +"), /Синтаксическая ошибка/);
assert.throws(() => parseExpression("lambda: 1"), /не поддерживается/);
assert.throws(() => parseStatements("import os"), /не поддерживается/);
assert.equal(py.eval(parseExpression("__import__")), null, "dunder names stay unreachable");
});