feat: File* save/load actions, hashlib/uuid, dict spread and small renpy helpers in the player
CI / frontend (push) Failing after 21s
CI / backend (push) Failing after 36s

Games with their own save screens work: FileAction/FileSave/FileLoad/FileDelete/FilePage*, FileLoadable,
FileTime, FileSaveName, FileJson and friends use browser slots (a slot has no screenshot). Added
renpy.has_image/has_screen/list_files/get_mouse_pos/log/jump_out_of_context/random.seed,
renpy.translation.translate_string, type/id/vars/Flatten/LiveComposite/Composite, bare defaultdict/
deepcopy, md5/sha256 (checked against test vectors), uuid4, time.monotonic, re.compile().fullmatch,
math.isfinite and `{**a, "k": v}`. The converter whitelists them and accepts the hashlib/uuid imports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:02:22 +03:00
1 parent 5c1e1aee01
commit 16ba20816c
8 files changed
+366 -20

No files matched your search

+74 -1
View File
@@ -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<string, any> | 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;