diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index f6eb5bc..3417870 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -120,6 +120,10 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre "renpy.ui.imagebutton", "renpy.return_statement"} from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402 CALLS |= set(NATIVE_CLASSES) +CALLS |= {"type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy", + "hashlib.md5", "hashlib.sha256", "uuid.uuid4", "time.monotonic", "re.compile", "math.isfinite", "renpy.log", + "renpy.write_log", "renpy.get_filename_line", "renpy.has_image", "renpy.has_screen", "renpy.list_files", + "renpy.get_mouse_pos", "renpy.jump_out_of_context", "renpy.random.seed", "random.seed"} CALLS |= {"dir", "datetime.date", "datetime.datetime", "datetime.timedelta", "date", "timedelta", "renpy.style.rebuild", "renpy.image_size"} CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"} @@ -134,6 +138,11 @@ TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix Opacity ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split()) SCREEN_CALLS |= set(NATIVE_CLASSES) SCREEN_CALLS |= {"SetMixer", "ImageReference"} +# Save/load screens of the game's own (slots are kept in the browser; a slot has no screenshot, see screens.ts) +SCREEN_CALLS |= set("""FileAction FileSave FileLoad FileDelete FileTakeScreenshot FilePage FilePageNext FilePagePrevious +FileLoadable FileUsedSlot FileNewest FileSaveName FileTime FileJson FileScreenshot FileCurrentScreenshot +FileCurrentPage FilePageName FileSlotName""".split()) +SCREEN_CALLS |= {"renpy.translation.translate_string"} # Screen language helpers for audio, focus and pictures that play (see screens.ts) SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership", "GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"} @@ -158,7 +167,7 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co METHODS |= NATIVE_METHODS # Methods of generators made by `renpy.random.Random()` / `random.Random()` -METHODS |= {"randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"} +METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"} def _bound_names(root) -> tuple[set[str], set[str]]: diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index a1201e1..4462c54 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} # Modules whose import is harmless: the player provides the functions it supports. KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections", - "itertools", "string", "functools", "operator", "json", "gc"} + "itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid"} class Unsupported(Exception): @@ -146,9 +146,9 @@ def expr(node: ast.AST) -> Expr: case ast.Tuple(elts=elts): return {"k": "tuple", "v": [expr(e) for e in elts]} case ast.Dict(keys=keys, values=values): - if any(k is None for k in keys): - raise Unsupported("распаковка ** в словаре") - return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]} + # `{**other, "k": v}`: a spread entry is a star key with an empty value + return {"k": "dict", "v": [[{"k": "star", "v": expr(v)}, {"k": "const", "v": None}] if k is None + else [expr(k), expr(v)] for k, v in zip(keys, values)]} case ast.Call(func=func, args=args, keywords=kws): call = {"k": "call", "f": expr(func), "a": [{"k": "star", "v": expr(a.value)} if isinstance(a, ast.Starred) else expr(a) diff --git a/backend/tests/test_renpy_universal.py b/backend/tests/test_renpy_universal.py index 38620ac..467569f 100644 --- a/backend/tests/test_renpy_universal.py +++ b/backend/tests/test_renpy_universal.py @@ -205,3 +205,21 @@ def test_hide_windows_context_call_is_a_click_wait(): assert not data.blockers other = parse('label start:\n $ renpy.call_in_new_context("something_else")\n return\n') assert other.modules["t.rpy"][0]["body"][0]["op"] == "exec" + + +def test_dict_spread_and_new_modules_are_accepted(): + from app.converter import pyexpr + + code = pyexpr.parse_stmts("import hashlib\nimport uuid\nmerged = {**base, 'k': 1}\n") + entries = code[0]["v"]["v"] + assert entries[0][0] == {"k": "star", "v": {"k": "name", "id": "base"}} + assert entries[1][0] == {"k": "const", "v": "k"} + + +def test_translated_speaker_name_and_capitalised_parameterised_transform(): + data = parse('transform FadeIn():\n alpha 0.0\n linear 0.5 alpha 1.0\n' + 'screen s():\n frame:\n at FadeIn()\n' + 'label start:\n __("Girl") "Hello there"\n call screen s\n') + say = data.modules["t.rpy"][0]["body"][0] + assert say["op"] == "say" and say["what"] == "Hello there" + assert not data.blockers diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 19ba4cf..eadb228 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -170,9 +170,19 @@ export class Engine { this.renderer.fit(this.container); window.addEventListener("keydown", this.onKey); window.addEventListener("keyup", this.onKeyUp); + window.addEventListener("pointermove", this.onPointer); } + /** The pointer in the game's own coordinates (`renpy.get_mouse_pos`). */ + private mousePos: [number, number] = [0, 0]; + private onPointer = (event: PointerEvent) => { + const box = this.container.getBoundingClientRect(); + const [w, h] = this.game.resolution ?? [1920, 1080]; + if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)]; + }; + destroy() { + window.removeEventListener("pointermove", this.onPointer); this.liveImages?.destroy(); this.screens.destroy(); this.gen++; @@ -338,7 +348,18 @@ export class Engine { const kwless = (args: any[]) => args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args; const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {}; - const makeRandom = (next: () => number): Record => { + const seeded = (seed: any) => { + let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + }; + const makeRandom = (source: () => number): Record => { + let next = source; const below = (n: number) => Math.floor(next() * n); const self: Record = { randint: (a: number, b: number) => a + below(b - a + 1), @@ -383,19 +404,11 @@ export class Engine { } }, uniform: (a: number, b: number) => a + next() * (b - a), - seed: () => null, + seed: (seed?: any) => { next = seed === undefined || seed === null ? Math.random : seeded(seed); return null; }, }; // `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable self.Random = (seed?: any) => { - if (seed === undefined || seed === null) return makeRandom(Math.random); - let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0; - return makeRandom(() => { - state = (state + 0x6d2b79f5) >>> 0; - let t = state; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }); + return makeRandom(seed === undefined || seed === null ? Math.random : seeded(seed)); }; return self; }; @@ -427,7 +440,19 @@ export class Engine { }); const renpyImpl: Record = { random, + translation: { translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null) }, style: { rebuild: () => null }, + // diagnostics only: nothing is written anywhere + log: () => null, + write_log: () => null, + get_filename_line: () => ["", 0], + has_image: (name: any) => this.resolveImage(pyStr(name).split(" ")) !== null, + has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]), + list_files: () => Object.keys(this.game.files ?? {}), + get_mouse_pos: () => [...this.mousePos], + jump_out_of_context: (label: string) => { + throw new JumpSignal("jump", label); + }, image_size: (name: any) => { const text = pyStr(name); const resolved = this.resolveImage(text.split(" ")); @@ -584,7 +609,7 @@ export class Engine { log10: Math.log10, fmod: (a: number, b: number) => a % b, modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)], degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180, - isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity, + isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity, copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1), }, len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), @@ -911,6 +936,16 @@ export class Engine { endReplay: () => this.endReplay(), quickSave: () => void this.save("quick"), quickLoad: () => void this.load("quick"), + slotInfo: (slot) => { + if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); } + const meta = this.slotCache.get(slot); + return meta ? { title: meta.title, time: meta.updated_at } : null; + }, + saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm), + loadSlot: (slot) => void this.load(slot), + deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm), + get filePage() { return String(engine.persistent._file_page ?? "1"); }, + set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); }, transform: (name) => this.game.transforms[name] ?? null, atl: (name) => this.game.atl?.[name] ?? null, }; @@ -1730,6 +1765,30 @@ export class Engine { }; } + private slotCache = new Map(); + private slotsRead = false; + + /** Slot metadata for the File* actions of a game's own save screens (read once, then kept up to date). */ + private async refreshSlots() { + try { + this.slotCache = new Map((await this.saves.list()).map((meta) => [meta.slot, meta])); + } catch { /* storage unavailable: slots look empty */ } + this.screens.refresh(); + } + + private async saveFileSlot(slot: string, confirm: boolean) { + if (confirm && this.slotCache.has(slot) && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return; + await this.save(slot); + await this.refreshSlots(); + } + + private async deleteFileSlot(slot: string, confirm: boolean) { + if (!this.slotCache.has(slot)) return; + if (confirm && !(await this.confirmAsk("Удалить сохранение?", "Удалить"))) return; + await this.saves.remove(slot).catch(() => {}); + await this.refreshSlots(); + } + private async save(slot: string, silent = false) { if (!this.running || !this.cur.label) return; const data = this.snapshotState(); diff --git a/frontend/src/player/pymodules.ts b/frontend/src/player/pymodules.ts index 68cd727..2de221b 100644 --- a/frontend/src/player/pymodules.ts +++ b/frontend/src/player/pymodules.ts @@ -98,7 +98,7 @@ class DateTime { isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; } } -function strftime(format: string, d: Date): string { +export function strftime(format: string, d: Date): string { const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => { @@ -176,6 +176,83 @@ Object.assign(DateTime.prototype, { }, }); +function md5(bytes: Uint8Array): string { + const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0); + const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]; + let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476; + const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, (bytes.length * 8) >>> 0, true); + view.setUint32(padded.length - 4, Math.floor(bytes.length * 8 / 2 ** 32), true); + for (let off = 0; off < padded.length; off += 64) { + const m = Array.from({ length: 16 }, (_, i) => view.getUint32(off + i * 4, true)); + let a = a0, b = b0, c = c0, d = d0; + for (let i = 0; i < 64; i++) { + let f: number, g: number; + if (i < 16) { f = (b & c) | (~b & d); g = i; } + else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; } + else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; } + else { f = c ^ (b | ~d); g = (7 * i) % 16; } + f = (f + a + K[i] + m[g]) >>> 0; + a = d; d = c; c = b; + const s = S[(i >> 4) * 4 + (i % 4)]; + b = (b + ((f << s) | (f >>> (32 - s)))) >>> 0; + } + a0 = (a0 + a) >>> 0; b0 = (b0 + b) >>> 0; c0 = (c0 + c) >>> 0; d0 = (d0 + d) >>> 0; + } + return [a0, b0, c0, d0].map((v) => [0, 8, 16, 24].map((sh) => ((v >>> sh) & 255).toString(16).padStart(2, "0")).join("")).join(""); +} + +function sha256(bytes: Uint8Array): string { + const K = Array.from({ length: 64 }, (_, i) => { + let n = 2, count = 0; + for (; count <= i; n++) if (Array.from({ length: n - 2 }, (_, d) => d + 2).every((d) => n % d)) count++; + return Math.floor((Math.cbrt(n - 1) % 1) * 2 ** 32) >>> 0; + }); + const h = [2, 3, 5, 7, 11, 13, 17, 19].map((p) => Math.floor((Math.sqrt(p) % 1) * 2 ** 32) >>> 0); + const padded = new Uint8Array(((bytes.length + 8) >> 6 << 6) + 64); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 2 ** 32)); + view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0); + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + for (let off = 0; off < padded.length; off += 64) { + const w = Array.from({ length: 64 }, (_, i) => (i < 16 ? view.getUint32(off + i * 4) : 0)); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + [a, b, c, d, e, f, g, hh].forEach((v, i) => { h[i] = (h[i] + v) >>> 0; }); + } + return h.map((v) => v.toString(16).padStart(8, "0")).join(""); +} + +/** `hashlib.md5(data)`: the data may come later through `update`. */ +class Hash { + private parts: Uint8Array[] = []; + constructor(private digest: (bytes: Uint8Array) => string, data?: any) { if (data !== undefined) this.update(data); } + update(data: any) { + this.parts.push(typeof data === "string" ? new TextEncoder().encode(data) : Uint8Array.from(data ?? [])); + return null; + } + hexdigest() { + const all = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of this.parts) { all.set(p, at); at += p.length; } + return this.digest(all); + } +} + /** Namespaces added to the sandbox's builtins. */ export function pythonModules(): Record { const re = { @@ -186,6 +263,7 @@ export function pythonModules(): Record { match: (s: string) => re.match(pattern, s, flags), sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags), findall: (s: string) => re.findall(pattern, s, flags), + fullmatch: (s: string) => re.fullmatch(pattern, s, flags), split: (s: string) => re.split(pattern, s, 0, flags), }), search: (pattern: string, s: string, flags = 0) => { @@ -196,6 +274,10 @@ export function pythonModules(): Record { const m = compile(`^(?:${pattern})`, flags).exec(String(s)); return m ? new ReMatch(m) : null; }, + fullmatch: (pattern: string, s: string, flags = 0) => { + const m = compile(`^(?:${pattern})$`, flags).exec(String(s)); + return m ? new ReMatch(m) : null; + }, findall: (pattern: string, s: string, flags = 0) => [...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])), sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => { @@ -220,6 +302,7 @@ export function pythonModules(): Record { copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) }, time: { time: () => Date.now() / 1000, + monotonic: () => performance.now() / 1000, sleep: () => null, strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()), }, @@ -229,5 +312,18 @@ export function pythonModules(): Record { OrderedDict: () => new PyDict(), }, functools: functionModules, + hashlib: { + md5: (data?: any) => new Hash(md5, data), + sha256: (data?: any) => new Hash(sha256, data), + }, + uuid: { + uuid4: () => ({ hex: crypto.randomUUID().replaceAll("-", ""), toString: () => crypto.randomUUID() }), + }, + // `from collections import defaultdict` and friends leave the bare names + defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null), + OrderedDict: () => new PyDict(), + deepcopy: (v: any) => deepcopy(v), + partial, + timedelta, }; } diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index be2184f..2d382fc 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -680,7 +680,17 @@ export class Python { case "tuple": return e.v.map((x: Expr) => this.eval(x)); case "dict": - return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)])); + { + const out = new PyDict(); + for (const [k, v] of e.v as [Expr, Expr][]) { + if (k.k === "star") { + const source = this.eval(k.v); + if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x)); + else if (source !== null) throw new Error("** ожидает словарь"); + } else out.set(this.eval(k), this.eval(v)); + } + return out; + } case "fmt": return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join(""); case "comp": { @@ -1229,6 +1239,11 @@ export class Python { case "dict": { const out = new PyDict(); for (const [k, v] of e.v as [Expr, Expr][]) { + if (k.k === "star") { + const source = yield* this.evalG(k.v); + if (source instanceof PyDict) source.map.forEach((x, key) => out.map.set(key, x)); + continue; + } const key = yield* this.evalG(k); out.set(key, yield* this.evalG(v)); } @@ -1461,6 +1476,9 @@ export function decodeValue(v: any): any { } /** Builtins that need the interpreter: type checks, attribute access, callbacks. */ +const identities = new WeakMap(); +let lastIdentity = 0; + export function userBuiltins(py: Python): Record { const split = (args: any[]) => { const last = args[args.length - 1]; @@ -1522,6 +1540,21 @@ export function userBuiltins(py: Python): Record { py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value); }, iter: (x: any) => [...iterate(x)], + type: (x: any) => { + if (x instanceof PyInstance) return x.cls; + if (x instanceof PyDict) return typeOf("dict"); + if (Array.isArray(x)) return typeOf("list"); + if (typeof x === "boolean") return typeOf("bool"); + if (typeof x === "number") return typeOf(Number.isInteger(x) ? "int" : "float"); + if (typeof x === "string") return typeOf("str"); + return null; + }, + id: (x: any) => { + if (x === null || typeof x !== "object") return typeof x === "number" ? x : 0; + if (!identities.has(x)) identities.set(x, ++lastIdentity); + return identities.get(x)!; + }, + vars: (x: any) => (x instanceof PyInstance ? new PyDict(Object.entries(x.attrs)) : new PyDict()), hash: (x: any) => { const text = String(key(x)); return [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7); diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 070f58e..dffe0b6 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -8,6 +8,7 @@ import { isNativeAction } from "./musicroom"; import { galleryClass } from "./gallery"; import { parseExpression } from "./pyparse"; import { movieDisplayable } from "./movie"; +import { strftime } from "./pymodules"; import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python"; import type { AtSpec, Expr } from "./types"; @@ -43,6 +44,13 @@ export interface ScreenHost { openUrl(url: string): void; quickSave(): void; quickLoad(): void; + /** Slot metadata for File* actions; null when empty or not yet read. */ + slotInfo(slot: string): { title: string; time: string } | null; + saveSlot(slot: string, confirm: boolean): void; + loadSlot(slot: string): void; + deleteSlot(slot: string, confirm: boolean): void; + /** The page of the save/load screen (`persistent._file_page`). */ + filePage: string; startReplay(label: string, scope: Record | null): void; endReplay(): void; setLanguage(code: string | null): void; @@ -1406,6 +1414,11 @@ export class ScreenManager { this.refresh(); } + /** The save slot a File* action means: its name on the current (or the given) page. */ + private slotId(name: any, page?: any): string { + return `file:${page ?? this.host.filePage}-${pyStr(name)}`; + } + private perform(action: any, screen: Instance | null) { if (action === null || action === undefined || action === false) return; if (Array.isArray(action)) { @@ -1454,6 +1467,33 @@ export class ScreenManager { throw new ActionAbort(); } case "EndReplay": this.host.endReplay(); throw new ActionAbort(); + case "FileAction": + case "FileSave": + case "FileLoad": { + const slot = this.slotId(a, action.kw.page); + const loading = action.kind === "FileLoad" || (action.kind === "FileAction" && /load/i.test(owner?.name ?? "")); + const sure = truthy(action.kw.confirm ?? (action.kind === "FileLoad" ? b : false) ?? false); + if (loading) { this.host.loadSlot(slot); throw new ActionAbort(); } + this.host.saveSlot(slot, sure); + return; + } + case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return; + case "FileTakeScreenshot": return; + case "FilePage": this.host.filePage = String(a); return; + case "FilePageNext": + case "FilePagePrevious": { + const kw = action.kw; + const max = typeof kw.max === "number" ? kw.max : typeof a === "number" ? a : null; + const order = [...(kw.auto ?? true ? ["auto"] : []), ...(kw.quick ?? true ? ["quick"] : [])]; + const numbered = max === null ? 0 : max; + const pages = [...order, ...Array.from({ length: numbered || 99 }, (_, i) => String(i + 1))]; + const here = pages.indexOf(this.host.filePage); + const step = action.kind === "FilePageNext" ? 1 : -1; + let next = (here < 0 ? pages.indexOf("1") : here) + step; + if (next < 0 || next >= pages.length) next = truthy(kw.wrap) ? (next + pages.length) % pages.length : Math.max(0, Math.min(pages.length - 1, next)); + this.host.filePage = pages[next]; + return; + } case "QuickSave": this.host.quickSave(); return; case "QuickLoad": this.host.quickLoad(); throw new ActionAbort(); case "ToggleScreenVariable": { @@ -1532,7 +1572,37 @@ export class ScreenManager { "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", "Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership", - "ToggleFocus", "PauseAudio", "SetMixer"]) out[kind] = make(kind); + "ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot", + "FilePage", "FilePageNext", "FilePagePrevious"]) out[kind] = make(kind); + // values of the save/load screens; screenshots are not stored, so a slot has none + const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {}; + const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page); + out.FileLoadable = (...a: any[]) => this.host.slotInfo(slotOf(a)) !== null; + out.FileUsedSlot = out.FileLoadable; + out.FileNewest = () => false; + out.FileSaveName = (...a: any[]) => this.host.slotInfo(slotOf(a))?.title ?? ""; + out.FileTime = (...a: any[]) => { + const kw = kwOf(a), info = this.host.slotInfo(slotOf(a)); + if (!info) return kw.empty ?? a[2] ?? ""; + return strftime(String(kw.format ?? (typeof a[1] === "string" ? a[1] : "%b %d, %H:%M")), new Date(info.time)); + }; + out.FileJson = (...a: any[]) => { + const kw = kwOf(a), info = this.host.slotInfo(slotOf(a)); + if (!info) return kw.empty ?? null; + return kw.key !== undefined ? kw.missing ?? null : new PyDict(); + }; + out.FileScreenshot = (...a: any[]) => kwOf(a).empty ?? null; + out.FileCurrentScreenshot = (...a: any[]) => kwOf(a).empty ?? null; + out.FileCurrentPage = () => this.host.filePage; + out.FilePageName = (...a: any[]) => { + const kw = kwOf(a), page = this.host.filePage; + return page === "auto" ? kw.auto ?? a[0] ?? "Auto " : page === "quick" ? kw.quick ?? a[1] ?? "Quick " : page; + }; + out.FileSlotName = (slot: any, perPage: any) => { + const n = Number(slot) - 1, size = Number(perPage) || 1; + const page = Math.floor(n / size) + 1; + return `${page}-${(n % size) + 1}`; + }; out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null); out.MixerValue = (mixer: any) => { return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v)); @@ -1589,6 +1659,9 @@ export class ScreenManager { { ...properties, idle: image, insensitive: image, sensitive: enabled, action }), }); out.Fixed = shape("composite"); + out.Composite = shape("composite"); + out.LiveComposite = shape("composite"); + out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn out.AlphaMask = shape("alphamask"); out.TrackCursor = shape("trackcursor"); out.At = (child: any) => child; diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index b4798f2..5d22bc3 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -109,3 +109,61 @@ test("datetime date arithmetic, dir() and style.rebuild", () => { assert.throws(() => renpy.image_size("missing"), /IOError/); } finally { engine.destroy(); } }); + +test("File* actions work on slots of the game's own save screens", async () => { + const { engine } = makeEngine(runtime()); + try { + engine.newStore(); + const b = engine.builtins; + assert.equal(b.FileLoadable(1), false); + assert.equal(b.FileTime(1, { __kw: { empty: "—" } }), "—"); + engine.slotCache.set("file:1-1", { title: "Привет", updated_at: "2026-01-02T03:04:05Z" }); + assert.equal(b.FileLoadable(1), true); + assert.equal(b.FileSaveName(1), "Привет"); + assert.match(b.FileTime(1), /^Jan 0[12], \d\d:\d\d$/); + assert.equal(b.FileScreenshot(1), null); + assert.equal(engine.screens.host.filePage, "1"); + engine.screens.run(b.FilePage("3"), null); + assert.equal(b.FilePageName(), "3"); + engine.screens.run(b.FilePageNext(), null); + assert.equal(b.FileCurrentPage(), "4"); + engine.screens.run(b.FilePagePrevious(), null); + engine.screens.run(b.FilePagePrevious(), null); + engine.screens.run(b.FilePagePrevious(), null); + assert.equal(b.FileCurrentPage(), "1"); + engine.screens.run(b.FilePagePrevious(), null); + assert.equal(b.FilePageName({ __kw: { auto: "A" } }), "quick" === b.FileCurrentPage() ? "Quick " : b.FileCurrentPage()); + assert.equal(typeof engine.builtins.renpy.translation.translate_string, "function"); + } finally { engine.destroy(); } +}); + +test("hashlib, re.fullmatch, type/id/vars, dict spread and small renpy helpers", () => { + const { engine } = makeEngine(runtime()); + try { + engine.newStore(); + const b = engine.builtins; + assert.equal(b.hashlib.md5("").hexdigest(), "d41d8cd98f00b204e9800998ecf8427e"); + assert.equal(b.hashlib.md5("abc").hexdigest(), "900150983cd24fb0d6963f7d28e17f72"); + assert.equal(b.hashlib.sha256("abc").hexdigest(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + const pattern = b.re.compile("[a-z]+"); + assert.ok(pattern.fullmatch("abc")); + assert.equal(pattern.fullmatch("abc1"), null); + assert.equal(b.type(3), b.int); + assert.equal(b.type("x"), b.str); + assert.equal(b.id([]) === b.id([]), false); + const same = {}; + assert.equal(b.id(same), b.id(same)); + const spread = engine.py.eval({ k: "dict", v: [[{ k: "star", v: { k: "dict", v: [[{ k: "const", v: "a" }, { k: "const", v: 1 }]] } }, { k: "const", v: null }], [{ k: "const", v: "b" }, { k: "const", v: 2 }]] }); + assert.deepEqual([...spread.map.entries()], [["a", 1], ["b", 2]]); + assert.equal(b.renpy.has_screen("nothing"), false); + assert.deepEqual(b.renpy.get_mouse_pos(), [0, 0]); + assert.equal(b.renpy.log("x"), null); + assert.equal(b.math.isfinite(1), true); + b.renpy.random.seed("a"); + const one = b.renpy.random.randint(1, 1000000); + b.renpy.random.seed("a"); + assert.equal(b.renpy.random.randint(1, 1000000), one); + assert.equal(typeof b.defaultdict, "function"); + assert.equal(typeof b.LiveComposite, "function"); + } finally { engine.destroy(); } +});