← Files ModRetro Chromatic PluginARCHIVED FILE
dist/recording-common.js
19.1 KB · Oct 2, 2026 · 00:37 UTC
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, statSync, writeSync, } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { PNG } from "pngjs";
import { recordingArchiveOrigin } from "./recording-location.js";
export const MAX_FRAMES = 3_600;
export const MAX_TIMELINE_ACTIONS = 128;
export const MAX_TIMELINE_CHECKPOINTS = 64;
export const MAX_STEP_SAMPLES = 8;
export const MAX_INSPECTION_BYTES = 256;
export const MAX_OAM_OBJECTS = 40;
export const PROTOCOL_VERSION = 1;
export const GB_HZ = 4_194_304;
export const FRAME_DOTS = 70_224;
export const MAX_RECORDING_BYTES = 512 * 1024 * 1024;
export const MAX_RECORDING_FRAMES = 216_000;
export const MAX_RECORDING_WALL_MS = 3_600_000;
export const MAX_RECENT_FRAMES = 64;
export const MAX_CLIP_FRAMES = 600;
export const MAX_STATE_BYTES = 16 * 1024 * 1024;
export const MAX_ROM_BYTES = 32 * 1024 * 1024;
export const MAX_RECORDING_EVENTS = 250_000;
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
export const MAX_RECORDING_EVENT_BYTES = 32 * 1024 * 1024;
export const MAX_REVIEW_ACTIONS = 4_096;
export const MAX_REVIEW_IMAGES = 8;
export const MAX_REVIEW_METADATA_BYTES = 2 * 1024 * 1024;
export const MAX_REVIEW_FRAME_RECORDS = MAX_FRAMES + MAX_REVIEW_ACTIONS + 1;
export const RECORDING_RESERVE_BYTES = 16 * 1024;
export const MAX_SIBLING_RECORDINGS = 64;
export const MAX_SIBLING_RESERVED_BYTES = 1024 * 1024 * 1024;
export const MAX_RECORDING_BRANCHES = 64;
export const WIDTH = 160;
export const HEIGHT = 144;
export const RGBA_BYTES = WIDTH * HEIGHT * 4;
export const DEFAULT_RECORDING_LIMITS = {
sampleEveryFrames: 4, recentFrameCount: 12, maxBytes: 64 * 1024 * 1024,
maxFrames: 36_000, maxWallTimeMs: 900_000,
};
export const BUTTONS = new Set(["a", "b", "up", "down", "left", "right", "start", "select"]);
export const MEMORY_REGIONS = {
vram: [0x8000, 0x2000], wram: [0xc000, 0x2000], oam: [0xfe00, 0xa0], hram: [0xff80, 0x7f],
};
export class RecordingLimitError extends Error {
}
export class SessionCancelled extends Error {
}
export class RequestValidationError extends Error {
}
export function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function boundedInteger(value, name, minimum, maximum) {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
}
return value;
}
export function sha256String(value, name) {
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
throw new Error(`${name} must be a lowercase SHA-256 digest`);
return value;
}
export function recordingBranchId(value) {
if (typeof value !== "string" || value.length < 11 || value.length > 100 || !/^branch-[0-9]+$/.test(value)) {
throw new Error("An exact recording branchId is required");
}
return value;
}
export function recordingPrefixPin(value) {
if (!isObject(value) || Object.keys(value).sort().join(",") !== "byteLength,eventCount,eventDigest,sha256") {
throw new Error("A recording prefix pin requires eventCount, eventDigest, byteLength, and sha256");
}
return {
eventCount: boundedInteger(value.eventCount, "prefix event count", 1, MAX_RECORDING_EVENTS),
eventDigest: sha256String(value.eventDigest, "prefix event digest"),
byteLength: boundedInteger(value.byteLength, "prefix byte length", 1, MAX_RECORDING_BYTES),
sha256: sha256String(value.sha256, "prefix SHA-256"),
};
}
export function recordedButtons(value) {
if (!Array.isArray(value) || value.length > BUTTONS.size || value.some((item) => typeof item !== "string" || !BUTTONS.has(item)) || new Set(value).size !== value.length) {
throw new Error("Recording contains an invalid controller button set");
}
return [...value];
}
export function normalizedRecordedAction(value) {
if (!isObject(value))
throw new Error("Recording contains an invalid normalized action");
if (value.type === "step" && Object.keys(value).sort().join(",") === "frames,type") {
return { type: "step", frames: boundedInteger(value.frames, "recorded step", 1, MAX_FRAMES) };
}
if (value.type === "set_buttons" && Object.keys(value).sort().join(",") === "buttons,type") {
return { type: "set_buttons", buttons: recordedButtons(value.buttons) };
}
throw new Error("Recording contains an unsupported normalized action");
}
export function recordingLimits(value) {
if (!isObject(value))
throw new Error("The recording option must be an object or false");
const unknown = Object.keys(value).filter((key) => key !== "outputPath" && !Object.hasOwn(DEFAULT_RECORDING_LIMITS, key));
if (unknown.length)
throw new Error(`Unknown recording options: ${unknown.sort().join(", ")}`);
const bounds = {
sampleEveryFrames: [1, MAX_FRAMES], recentFrameCount: [1, MAX_RECENT_FRAMES],
maxBytes: [64 * 1024, MAX_RECORDING_BYTES], maxFrames: [1, MAX_RECORDING_FRAMES], maxWallTimeMs: [1, MAX_RECORDING_WALL_MS],
};
return Object.fromEntries(Object.entries(DEFAULT_RECORDING_LIMITS).map(([key, fallback]) => {
const [minimum, maximum] = bounds[key];
return [key, boundedInteger(Object.hasOwn(value, key) ? value[key] : fallback, key, minimum, maximum)];
}));
}
function compareUnicode(left, right) {
const a = Array.from(left, (value) => value.codePointAt(0));
const b = Array.from(right, (value) => value.codePointAt(0));
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
if (a[index] !== b[index])
return a[index] - b[index];
}
return a.length - b.length;
}
function asciiString(value) {
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
}
/** Sorted ASCII JSON for new records. Python key/string ordering is retained;
* legacy floating-point spellings are authenticated by canonicalEventPayload.
*/
export function encoded(value) {
function encode(item) {
if (item === null)
return "null";
if (typeof item === "string")
return asciiString(item);
if (typeof item === "boolean")
return String(item);
if (typeof item === "number" && Number.isFinite(item))
return JSON.stringify(item);
if (Array.isArray(item))
return `[${item.map(encode).join(",")}]`;
if (isObject(item))
return `{${Object.keys(item).sort(compareUnicode).map((key) => `${asciiString(key)}:${encode(item[key])}`).join(",")}}`;
throw new Error("Recording JSON must contain only finite JSON values");
}
return Buffer.from(encode(value), "utf8");
}
/** Preserve Python's floating-point lexemes when authenticating old journals.
* JSON.parse alone destroys the distinction between 1.0 and 1. We canonicalize
* strings, key order and whitespace, but retain the writer's number tokens.
*/
export function canonicalEventPayload(line) {
const source = Buffer.from(line).toString("utf8");
let offset = 0;
const whitespace = () => { while (/\s/.test(source[offset] ?? "") && offset < source.length)
offset += 1; };
const string = () => {
const start = offset++;
while (offset < source.length) {
const character = source[offset++];
if (character === "\\")
offset += 1;
else if (character === '"')
return JSON.parse(source.slice(start, offset));
}
throw new Error("Unterminated recording JSON string");
};
const parse = (depth) => {
if (depth > 128)
throw new Error("Recording JSON nesting exceeds its limit");
whitespace();
if (source[offset] === '"')
return asciiString(string());
if (source[offset] === "{") {
offset += 1;
const members = new Map();
whitespace();
while (source[offset] !== "}") {
if (source[offset] !== '"')
throw new Error("Invalid recording JSON object");
const key = string();
whitespace();
if (source[offset++] !== ":")
throw new Error("Invalid recording JSON member");
const value = parse(depth + 1);
if (members.has(key))
throw new Error("Duplicate recording JSON member");
members.set(key, value);
whitespace();
if (source[offset] === "}")
break;
if (source[offset++] !== ",")
throw new Error("Invalid recording JSON separator");
whitespace();
}
offset += 1;
if (depth === 0)
members.delete("digest");
return `{${[...members.keys()].sort(compareUnicode).map((key) => `${asciiString(key)}:${members.get(key)}`).join(",")}}`;
}
if (source[offset] === "[") {
offset += 1;
const values = [];
whitespace();
while (source[offset] !== "]") {
values.push(parse(depth + 1));
whitespace();
if (source[offset] === "]")
break;
if (source[offset++] !== ",")
throw new Error("Invalid recording JSON array");
}
offset += 1;
return `[${values.join(",")}]`;
}
const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(source.slice(offset));
if (!match)
throw new Error("Invalid recording JSON value");
offset += match[0].length;
return match[0];
};
if (source.trimStart()[0] !== "{")
throw new Error("A recording event must be an object");
const result = parse(0);
whitespace();
if (offset !== source.length)
throw new Error("Unexpected trailing recording JSON bytes");
return Buffer.from(result, "utf8");
}
export function digest(value) { return createHash("sha256").update(value).digest("hex"); }
export function sameJson(left, right) { return encoded(left).equals(encoded(right)); }
export function inside(root, value) {
const path = relative(root, value);
return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
}
/** Canonical authorization remains independent of evidence hash authentication. */
export function projectResolver(projectRoot) {
const root = realpathSync(projectRoot);
const identity = statSync(root);
if (!identity.isDirectory())
throw new Error("The emulator project root must be a directory");
return (value, { existing }) => {
const now = lstatSync(root);
if (now.isSymbolicLink() || now.dev !== identity.dev || now.ino !== identity.ino || realpathSync(root) !== root)
throw new Error("The authorized emulator project root changed");
if (typeof value !== "string" || !value.trim() || value.includes("\0"))
throw new Error("A non-empty project-relative or absolute path is required");
// The server already expands user paths. Workers do not independently grant
// access to the host home directory or Windows device/network namespaces.
if (value.startsWith("~") || /^\\\\|^\/\//.test(value) || /^(?:\\\\[?.]\\)/.test(value))
throw new Error("Emulator paths must use an authorized local path");
const lexical = resolve(root, value);
if (!inside(root, lexical))
throw new Error("Emulator paths must remain inside the project root");
let ancestor = lexical;
const tail = [];
for (;;) {
try {
lstatSync(ancestor);
break;
}
catch (error) {
if (error.code !== "ENOENT" || existing)
throw error;
const parent = dirname(ancestor);
if (parent === ancestor)
throw error;
tail.unshift(ancestor.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
ancestor = parent;
}
}
const canonical = resolve(realpathSync(ancestor), ...tail);
if (!inside(root, canonical))
throw new Error("Emulator paths must remain inside the project root");
return canonical;
};
}
/** Bounded, no-follow, regular-file read with path/fd identity checked twice. */
export function boundedReadFile(path, maximum, label = "Recording metadata") {
const before = lstatSync(path);
if (!before.isFile() || before.isSymbolicLink())
throw new Error(`${label} must be a regular file`);
if (before.size > maximum)
throw new Error(`${label} exceeds its byte limit`);
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
try {
const opened = fstatSync(fd);
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino)
throw new Error(`${label} changed before read`);
const chunks = [];
let size = 0;
for (;;) {
const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - size));
if (!buffer.length)
throw new Error(`${label} exceeds its byte limit`);
const count = readSync(fd, buffer, 0, buffer.length, null);
if (count === 0)
break;
size += count;
if (size > maximum)
throw new Error(`${label} exceeds its byte limit`);
chunks.push(buffer.subarray(0, count));
}
const after = fstatSync(fd);
const pathAfter = lstatSync(path);
if (after.size !== size || after.dev !== pathAfter.dev || after.ino !== pathAfter.ino || !pathAfter.isFile() || pathAfter.isSymbolicLink() || opened.size !== after.size || opened.mtimeMs !== after.mtimeMs) {
throw new Error(`${label} changed while being read`);
}
return Buffer.concat(chunks, size);
}
finally {
closeSync(fd);
}
}
export function writeExclusive(path, data, resolvePath, onCreated) {
if (resolvePath(path, { existing: false }) !== path)
throw new Error("The artifact output path changed");
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
if (resolvePath(path, { existing: false }) !== path)
throw new Error("The artifact output directory changed");
const parent = lstatSync(dirname(path));
if (!parent.isDirectory() || parent.isSymbolicLink())
throw new Error("The artifact output directory is invalid");
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
try {
const opened = fstatSync(fd);
if (!opened.isFile() || opened.nlink !== 1)
throw new Error("An artifact must be a regular file with one link");
onCreated?.(opened);
if (resolvePath(path, { existing: true }) !== path)
throw new Error("The artifact path changed before write");
let written = 0;
while (written < data.length) {
const count = writeSync(fd, data, written, data.length - written);
if (count <= 0)
throw new Error("The artifact could not be written completely");
written += count;
}
const after = lstatSync(path);
const parentAfter = lstatSync(dirname(path));
if (after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 || after.isSymbolicLink() || after.size !== data.length || parentAfter.dev !== parent.dev || parentAfter.ino !== parent.ino || resolvePath(path, { existing: true }) !== path) {
throw new Error("The artifact path changed during write");
}
}
finally {
closeSync(fd);
}
}
export function directoryBytes(root) {
let total = 0;
let count = 0;
const walk = (directory) => {
const identity = lstatSync(directory);
if (!identity.isDirectory() || identity.isSymbolicLink())
throw new Error("Recording files and directories must not be symbolic links");
for (const name of readdirSync(directory)) {
const path = join(directory, name);
const stat = lstatSync(path);
count += 1;
if (count > MAX_RECORDING_EVENTS)
throw new RecordingLimitError("Recording storage exceeds the supported limit");
if (stat.isSymbolicLink())
throw new Error("Recording files and directories must not be symbolic links");
if (stat.isDirectory())
walk(path);
else if (stat.isFile())
total += stat.size;
else
throw new Error("Recording artifacts must be regular files");
if (total > MAX_RECORDING_BYTES)
throw new RecordingLimitError("Recording storage exceeds the supported limit");
}
const after = lstatSync(directory);
if (after.dev !== identity.dev || after.ino !== identity.ino || after.isSymbolicLink())
throw new Error("The recording directory changed while counting storage");
};
walk(root);
return total;
}
export function artifactUsage(root, resolvePath) {
let total = directoryBytes(root);
const originalRoot = recordingArchiveOrigin(root, resolvePath);
const ledger = join(root, "derived.jsonl");
let present = true;
try {
lstatSync(ledger);
}
catch (error) {
if (error.code === "ENOENT")
present = false;
else
throw error;
}
if (present) {
const bytes = boundedReadFile(ledger, 1024 * 1024, "The derived-artifact ledger");
for (const line of bytes.toString("utf8").split(/\r?\n/).filter(Boolean)) {
const row = JSON.parse(line);
const path = resolvePath(row.path, { existing: false });
const size = boundedInteger(row.bytes, "derived artifact size", 0, MAX_RECORDING_BYTES);
if (!inside(root, path) && !(originalRoot && inside(originalRoot, path)))
total += size;
}
}
return total;
}
export function encodePng(rgba) {
if (rgba.length !== RGBA_BYTES)
throw new Error("The emulator framebuffer must have native 160 x 144 dimensions");
return PNG.sync.write({ width: WIDTH, height: HEIGHT, data: Buffer.from(rgba) }, { colorType: 6 });
}
export function decodePng(data) {
if (data.length > 1024 * 1024 || data.length < 24 || !data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || data.readUInt32BE(16) !== WIDTH || data.readUInt32BE(20) !== HEIGHT) {
throw new Error("A retained framebuffer must be a bounded native 160 x 144 PNG");
}
const result = PNG.sync.read(data, { checkCRC: true });
if (result.width !== WIDTH || result.height !== HEIGHT || result.data.length !== RGBA_BYTES)
throw new Error("A retained framebuffer has invalid native dimensions");
return Buffer.from(result.data);
}
//# sourceMappingURL=recording-common.js.mapSHA-256: 3c751215b410f08bd1f12807e58f8c37a00713abfdc06dc00b0d2636c8f65833