feat: support user-defined functions and classes in Ren'Py python

- Converter: def/return/global/nonlocal, class (inheritance, property,
  staticmethod, classmethod), lambda, *args/**kwargs and call-site unpacking.
  Unsupported dunder methods, generators and unknown decorators are still refused
- Player: closures, super(), instances that survive save/load, isinstance,
  hasattr/getattr/setattr, map/filter/sorted(key=), recursion limit
- audit_ir accepts calls to names, classes and methods the game defines itself;
  unknown renpy.* calls still block the import. Volume/pan/pause/replay calls
  are accepted as presentation-only no-ops; build.* settings are skipped
- Tests: IR fixture compiled from tests/fixtures/classes.py (tests/make_ir.py)
  and run in the interpreter; converter tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:01:09 +03:00
1 parent 12ec82c7e6
commit 962595c925
12 files changed
+782 -23

No files matched your search

+1
View File
@@ -0,0 +1 @@
[{"op":"class","name":"Perk","bases":[{"k":"name","id":"object"}],"body":[{"op":"set","t":{"k":"name","id":"count"},"v":{"k":"const","v":0}},{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"},{"n":"cost","d":{"k":"const","v":1},"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"},"v":{"k":"name","id":"name"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"cost"},"v":{"k":"name","id":"cost"}},{"op":"aug","t":{"k":"attr","o":{"k":"name","id":"Perk"},"a":"count"},"o":"+","v":{"k":"const","v":1}}],"decos":[]},{"op":"def","name":"__str__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"bin","op":"%","l":{"k":"const","v":"Perk(%s)"},"r":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"}}}],"decos":[]}]},{"op":"class","name":"Player","bases":[{"k":"name","id":"object"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"},{"n":"perks","d":null,"kind":"*"},{"n":"extra","d":null,"kind":"**"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"name"},"v":{"k":"name","id":"name"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"name","id":"perks"}],"kw":{}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"extra"},"v":{"k":"name","id":"extra"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"},"v":{"k":"const","v":10}}],"decos":[]},{"op":"def","name":"has_perk","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"any"},"a":[{"k":"comp","e":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"p"},"a":"name"},"ops":["=="],"r":[{"k":"name","id":"name"}]},"t":{"k":"name","id":"p"},"it":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"if":[]}],"kw":{}}}],"decos":[]},{"op":"def","name":"add","params":[{"n":"self","d":null,"kind":"p"},{"n":"perk","d":null,"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"perks"},"a":"append"},"a":[{"k":"name","id":"perk"}],"kw":{}}},{"op":"return","v":{"k":"name","id":"self"}}],"decos":[]},{"op":"def","name":"hp","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"}}],"decos":[{"k":"name","id":"property"}]},{"op":"def","name":"hp","params":[{"n":"self","d":null,"kind":"p"},{"n":"value","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"_hp"},"v":{"k":"call","f":{"k":"name","id":"max"},"a":[{"k":"const","v":0},{"k":"name","id":"value"}],"kw":{}}}],"decos":[{"k":"attr","o":{"k":"name","id":"hp"},"a":"setter"}]},{"op":"def","name":"make","params":[{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"Player"},"a":[{"k":"name","id":"name"}],"kw":{}}}],"decos":[{"k":"name","id":"staticmethod"}]},{"op":"def","name":"label","params":[{"n":"cls","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"cls"},"ops":["is"],"r":[{"k":"name","id":"Player"}]}}],"decos":[{"k":"name","id":"classmethod"}]}]},{"op":"class","name":"Hero","bases":[{"k":"name","id":"Player"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"__init__"},"a":[{"k":"name","id":"name"},{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"brave"}],"kw":{}}],"kw":{"role":{"k":"const","v":"hero"}}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"level"},"v":{"k":"const","v":1}}],"decos":[]},{"op":"def","name":"has_perk","params":[{"n":"self","d":null,"kind":"p"},{"n":"name","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"bool","op":"or","v":[{"k":"cmp","l":{"k":"name","id":"name"},"ops":["=="],"r":[{"k":"const","v":"legend"}]},{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"has_perk"},"a":[{"k":"name","id":"name"}],"kw":{}}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"total"},"v":{"k":"const","v":0}},{"op":"def","name":"bump","params":[{"n":"step","d":{"k":"const","v":1},"kind":"p"},{"n":"scale","d":{"k":"const","v":2},"kind":"k"}],"body":[{"op":"global","names":["total"]},{"op":"aug","t":{"k":"name","id":"total"},"o":"+","v":{"k":"bin","op":"*","l":{"k":"name","id":"step"},"r":{"k":"name","id":"scale"}}},{"op":"return","v":{"k":"name","id":"total"}}],"decos":[]},{"op":"def","name":"fact","params":[{"n":"n","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"if","c":{"k":"cmp","l":{"k":"name","id":"n"},"ops":["<="],"r":[{"k":"const","v":1}]},"t":{"k":"const","v":1},"e":{"k":"bin","op":"*","l":{"k":"name","id":"n"},"r":{"kLine truncated
+103
View File
@@ -0,0 +1,103 @@
# Source of classes.ir.json. Regenerate from backend/ with:
# python -m tests.make_ir ../frontend/tests/fixtures/classes.py ../frontend/tests/fixtures/classes.ir.json
# The Python below is also valid CPython, so expected values can be checked there.
class Perk(object):
count = 0
def __init__(self, name, cost=1):
self.name = name
self.cost = cost
Perk.count += 1
def __str__(self):
return "Perk(%s)" % self.name
class Player(object):
def __init__(self, name, *perks, **extra):
self.name = name
self.perks = list(perks)
self.extra = extra
self._hp = 10
def has_perk(self, name):
return any([p.name == name for p in self.perks])
def add(self, perk):
self.perks.append(perk)
return self
@property
def hp(self):
return self._hp
@hp.setter
def hp(self, value):
self._hp = max(0, value)
@staticmethod
def make(name):
return Player(name)
@classmethod
def label(cls):
return cls is Player
class Hero(Player):
def __init__(self, name):
super().__init__(name, Perk("brave"), role="hero")
self.level = 1
def has_perk(self, name):
return name == "legend" or super().has_perk(name)
total = 0
def bump(step=1, *, scale=2):
global total
total += step * scale
return total
def fact(n):
return 1 if n <= 1 else n * fact(n - 1)
def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
hero = Hero("Ann")
hero.add(Perk("fast", cost=3))
hero.hp = -5
call_args = [2]
call_kw = {"scale": 10}
result_brave = hero.has_perk("brave")
result_legend = hero.has_perk("legend")
result_missing = hero.has_perk("nope")
result_count = Perk.count
result_str = str(Perk("x"))
result_hp = hero.hp
result_role = hero.extra["role"]
result_static = Player.make("Bob").name
result_cls = Player.label()
result_bump = bump(*call_args, **call_kw)
result_total = total
result_fact = fact(6)
tick = counter()
tick()
result_closure = tick()
result_lambda = sorted([3, 1, 2], key=lambda v: -v)
result_isinstance = [isinstance(hero, Player), isinstance(hero, Perk), isinstance(1, int)]
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/python.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { Python, PyDict, userBuiltins, encodeValue, decodeValue, pyStr } =
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json`, import.meta.url), "utf8"));
function runtime() {
const store = Object.create(null);
const builtins = {
any: (xs) => xs.some(Boolean), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
};
const scope = {
get: (n) => (n in store ? store[n] : builtins[n]),
has: (n) => n in store || n in builtins,
set: (n, v) => { store[n] = v; },
del: (n) => { delete store[n]; },
};
const py = new Python(scope);
Object.assign(builtins, userBuiltins(py));
return { py, store };
}
test("user classes, functions, closures and lambdas behave like CPython", () => {
const { py, store } = runtime();
py.exec(ir("classes"));
assert.equal(store.result_brave, true);
assert.equal(store.result_legend, true);
assert.equal(store.result_missing, false);
assert.equal(store.result_count, 2);
assert.equal(store.result_str, "Perk(x)");
assert.equal(store.result_hp, 0); // property setter clamps
assert.equal(store.result_role, "hero"); // **kwargs
assert.equal(store.result_static, "Bob");
assert.equal(store.result_cls, true); // classmethod gets the class
assert.equal(store.result_bump, 20); // *args / **kwargs call and `global`
assert.equal(store.total, 20);
assert.equal(store.result_fact, 720);
assert.equal(store.result_closure, 2); // nonlocal
assert.deepEqual(store.result_lambda, [3, 2, 1]);
assert.deepEqual(store.result_isinstance, [true, false, true]);
});
test("instances survive a save round trip once the class is defined again", () => {
const { py, store } = runtime();
py.exec(ir("classes"));
const saved = JSON.parse(JSON.stringify(encodeValue({ hero: store.hero, dict: new PyDict([["a", store.hero]]) })));
assert.equal(saved.hero.__inst__, "Hero");
assert.equal("Hero" in saved, false);
const restored = decodeValue(saved);
assert.equal(restored.hero.cls.name, "Hero");
store.hero = restored.hero;
assert.equal(py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "hero" }, a: "has_perk" },
a: [{ k: "const", v: "fast" }], kw: {} }), true);
});
test("the sandbox still refuses native members through user objects", () => {
const { py, store } = runtime();
py.exec(ir("classes"));
const hero = { k: "name", id: "hero" };
for (const key of ["constructor", "__proto__", "__class__", "__dict__", "prototype"]) {
assert.equal(py.eval({ k: "attr", o: hero, a: key }), null);
assert.throws(() => py.bind({ k: "attr", o: hero, a: key }, 1), /недоступ/);
}
assert.equal(Object.getPrototypeOf(store.hero.attrs), null);
assert.equal({}.injected, undefined);
});
test("unbounded recursion and bad calls fail with explicit errors", () => {
const { py } = runtime();
py.exec([{ op: "def", name: "loop", params: [], decos: [], body: [{ op: "return",
v: { k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} } }] }]);
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "loop" }, a: [], kw: {} }), /рекурси/);
py.exec([{ op: "def", name: "one", params: [{ n: "x", d: null, kind: "p" }], decos: [], body: [] }]);
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" }, a: [], kw: {} }), /не хватает/);
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" },
a: [{ k: "const", v: 1 }, { k: "const", v: 2 }], kw: {} }), /слишком много/);
});