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

+71 -12
View File
@@ -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<string, any> => {
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<string, any> => {
let next = source;
const below = (n: number) => Math.floor(next() * n);
const self: Record<string, any> = {
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<string, any> = {
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<string, { title: string; updated_at: string }>();
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();
+97 -1
View File
@@ -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<string, any> {
const re = {
@@ -186,6 +263,7 @@ export function pythonModules(): Record<string, any> {
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<string, any> {
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<string, any> {
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<string, any> {
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,
};
}
+34 -1
View File
@@ -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<object, number>();
let lastIdentity = 0;
export function userBuiltins(py: Python): Record<string, any> {
const split = (args: any[]) => {
const last = args[args.length - 1];
@@ -1522,6 +1540,21 @@ export function userBuiltins(py: Python): Record<string, any> {
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);
+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;