← Files ModRetro Chromatic PluginARCHIVED FILE
dist/emulator.js
63.6 KB · Oct 2, 2026 · 00:37 UTC
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { accessSync, appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { PNG } from "pngjs";
import { RecordingMaintenance } from "./recording-maintenance.js";
import { assertSafePlatformPath, isPathWithinRoot, runtimeExecutablePaths, sanitizeSubprocessEnvironment, } from "./platform.js";
export const MAX_EMULATOR_FRAMES = 3_600;
export const MAX_EMULATOR_TIMELINE_ACTIONS = 128;
export const MAX_EMULATOR_TIMELINE_CHECKPOINTS = 64;
export const MAX_EMULATOR_INSPECTION_BYTES = 256;
export const MAX_EMULATOR_OAM_OBJECTS = 40;
export const EMULATOR_CLOCK_HZ = 4_194_304;
export const EMULATOR_DOTS_PER_FRAME = 70_224;
export const MAX_EMULATOR_RECORDING_BYTES = 512 * 1024 * 1024;
export const MAX_EMULATOR_RECORDING_FRAMES = 216_000;
export const MAX_EMULATOR_RECORDING_WALL_MS = 3_600_000;
export const MAX_EMULATOR_RECENT_FRAMES = 64;
export const MAX_EMULATOR_CLIP_FRAMES = 600;
export const MAX_EMULATOR_CLIP_CUTS = 16;
export const MAX_EMULATOR_RECORDING_EVENTS = 250_000;
export const MAX_EMULATOR_REVIEW_ACTIONS = 4_096;
export const MAX_EMULATOR_REVIEW_IMAGES = 8;
export const MAX_EMULATOR_REVIEW_METADATA_BYTES = 2 * 1024 * 1024;
export const MAX_EMULATOR_STEP_SAMPLES = 8;
export const MAX_EMULATOR_SAMPLE_BYTES = 128 * 1024;
export const EMULATOR_BUTTONS = [
"a",
"b",
"up",
"down",
"left",
"right",
"start",
"select",
];
export class EmulatorError extends Error {
execution;
imageDelivery;
cleanup;
constructor(message, options) {
super(message, options);
this.name = "EmulatorError";
}
}
export class EmulatorTimelineError extends EmulatorError {
failedActionIndex;
advancedFrames;
startFrame;
frame;
activeButtons;
recordingSpan;
sampledButtons;
appliedButtons;
constructor(message, details) {
super(message);
this.name = "EmulatorTimelineError";
this.failedActionIndex = details.failedActionIndex;
this.advancedFrames = details.advancedFrames;
this.startFrame = details.startFrame;
this.frame = details.frame;
this.activeButtons = details.activeButtons;
this.recordingSpan = details.recordingSpan;
this.sampledButtons = details.sampledButtons;
this.appliedButtons = details.appliedButtons;
}
}
/** A persistent, root-bounded session around an optional PyBoy installation. */
export class EmulatorService {
projectRoot;
#pythonPath;
#workerPath;
#requestTimeoutMs;
#platform;
#pending = new Map();
#diagnostics = [];
#worker;
#responses;
#nextRequestId = 1;
#activeRomPath;
#activeRomDigest;
#activeColorMode;
#activeRecordingPath;
#lastRecordingPath;
#recordingRequested = false;
#sessionGeneration = 0;
#cancelPromise;
#checkpointRecordings = new Map();
#expectedExits = new WeakSet();
#workerClosures = new WeakMap();
#recordingMaintenance;
#releaseRecordingAccess;
#recordingWorkerClosed = Promise.resolve();
#recordingWorkers = new Set();
constructor(options = {}) {
this.#platform = options.platform ?? process.platform;
const configuredRoot = options.projectRoot ?? process.env.GB_STUDIO_PROJECT_ROOT ?? process.cwd();
try {
this.projectRoot = realpathSync(resolve(configuredRoot));
if (!statSync(this.projectRoot).isDirectory()) {
throw new Error("not a directory");
}
}
catch (error) {
throw new EmulatorError(`The emulator project root does not exist: ${configuredRoot}`, {
cause: error,
});
}
this.#recordingMaintenance = new RecordingMaintenance(this.projectRoot);
const bundledWorker = fileURLToPath(new URL("../scripts/emulator_worker.py", import.meta.url));
this.#workerPath = resolve(options.workerPath ?? bundledWorker);
const packageRoot = dirname(dirname(bundledWorker));
const configuredToolchain = options.toolchainRoot ?? process.env.GB_STUDIO_TOOLCHAIN_ROOT;
let toolchainRoot = packageRoot;
if (configuredToolchain) {
try {
toolchainRoot = realpathSync(resolve(configuredToolchain));
if (!statSync(toolchainRoot).isDirectory()) {
throw new Error("not a directory");
}
const localDirectory = resolve(toolchainRoot, ".local");
if (existsSync(localDirectory)) {
const canonicalLocal = realpathSync(localDirectory);
if (canonicalLocal !== localDirectory) {
if (basename(canonicalLocal) !== ".local" || !statSync(canonicalLocal).isDirectory()) {
throw new Error("invalid linked .local directory");
}
toolchainRoot = dirname(canonicalLocal);
}
}
}
catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
// The stable dependency root can be absent on first launch. Setup may
// populate it later; constructing the server must not require Python.
toolchainRoot = resolve(configuredToolchain);
}
else {
throw new EmulatorError(`The emulator toolchain root is invalid: ${configuredToolchain}`, { cause: error });
}
}
}
const configuredPython = options.pythonPath ??
process.env.GB_STUDIO_PYTHON ??
runtimeExecutablePaths(toolchainRoot, this.#platform).emulatorPython;
if (!isAbsolute(configuredPython)) {
throw new EmulatorError("The emulator Python executable must be an absolute path");
}
if (this.#platform === "win32" && extname(configuredPython).toLowerCase() !== ".exe") {
throw new EmulatorError("The Windows emulator Python executable must be an absolute .exe path");
}
this.#pythonPath = resolve(configuredPython);
this.#requestTimeoutMs = options.requestTimeoutMs ?? 60_000;
if (!Number.isSafeInteger(this.#requestTimeoutMs) || this.#requestTimeoutMs < 1) {
throw new EmulatorError("Emulator requestTimeoutMs must be a positive integer");
}
}
/** True only while a successfully opened ROM still has a live worker. */
get active() {
return (this.#activeRomPath !== undefined &&
this.#worker !== undefined &&
this.#worker.exitCode === null &&
this.#worker.signalCode === null);
}
async run(romPath, options = {}, signal) {
if (signal?.aborted)
throw new EmulatorError("The emulator open request was cancelled before execution");
const validatedRom = this.#resolvePath(romPath, { existing: true });
if (![".gb", ".gbc"].includes(extname(validatedRom).toLowerCase())) {
throw new EmulatorError("The emulator requires an existing .gb or .gbc ROM");
}
const initialFrames = options.initialFrames ?? 120;
this.#validateFrames(initialFrames, 0);
if (options.cgb !== undefined && typeof options.cgb !== "boolean") {
throw new EmulatorError("The cgb option must be a boolean");
}
if (options.restart !== undefined && typeof options.restart !== "boolean") {
throw new EmulatorError("The restart option must be a boolean");
}
const recording = this.#validateRecordingOptions(options.recording);
let romDigest;
try {
romDigest = createHash("sha256").update(readFileSync(validatedRom)).digest("hex");
}
catch (error) {
throw new EmulatorError(`The emulator ROM could not be read: ${validatedRom}`, {
cause: error,
});
}
if (this.#worker !== undefined && this.#workerClosures.has(this.#worker)) {
throw new EmulatorError("The emulator worker is closing; wait for cleanup before opening a ROM");
}
if (options.restart !== true &&
recording === undefined &&
this.active &&
this.#activeRomPath === validatedRom &&
this.#activeRomDigest === romDigest &&
(options.cgb === undefined || options.cgb === this.#activeColorMode)) {
return this.status();
}
const generation = ++this.#sessionGeneration;
this.#clearActiveSession();
this.#recordingRequested = recording !== undefined;
if (recording !== undefined) {
this.#activeRecordingPath = recording.outputPath;
this.#lastRecordingPath = recording.outputPath;
}
let cancellation;
let operationFailure;
let openingWorker;
const cancel = () => {
cancellation ??= this.control({ action: "cancel" });
// Observe rejection immediately; cleanup is awaited and reported below.
void cancellation.catch(() => undefined);
};
signal?.addEventListener("abort", cancel, { once: true });
try {
const opening = this.#request("open", {
romPath: validatedRom,
initialFrames,
...(options.cgb === undefined ? {} : { cgb: options.cgb }),
...(recording === undefined ? {} : { recording }),
});
openingWorker = this.#worker;
const state = await opening;
if (signal?.aborted || generation !== this.#sessionGeneration) {
throw new EmulatorError("The emulator open request was cancelled or superseded");
}
this.#activeRomPath = validatedRom;
this.#activeRomDigest = romDigest;
this.#activeColorMode = state.cgb;
if (state.recording !== undefined) {
this.#activeRecordingPath = state.recording.recordingPath;
this.#lastRecordingPath = state.recording.recordingPath;
}
return state;
}
catch (error) {
operationFailure = error instanceof EmulatorError ? error : new EmulatorError(error instanceof Error ? error.message : String(error), { cause: error });
// A rejected open can leave its worker and recording-access lease alive
// even though no active ROM was established. Use the ordinary owned
// transport closure, without closing a session that superseded this run.
if (generation === this.#sessionGeneration && (this.#worker === openingWorker || this.#worker === undefined)) {
try {
await this.close();
}
catch (cleanup) {
operationFailure.cleanup = { status: "failed", error: cleanup instanceof Error ? cleanup.message : String(cleanup) };
}
}
throw operationFailure;
}
finally {
signal?.removeEventListener("abort", cancel);
try {
await cancellation;
}
catch (error) {
const failure = operationFailure ?? new EmulatorError("Emulator open cancellation cleanup failed", { cause: error });
failure.cleanup = { status: "failed", error: error instanceof Error ? error.message : String(error) };
throw failure;
}
}
}
async control(options) {
if (!["pause", "cancel"].includes(options?.action)) {
throw new EmulatorError("Continuous play is unavailable; use exact stepped actions or cancel");
}
if (options.action !== "cancel") {
return this.#request("control", { action: options.action });
}
if (this.#cancelPromise !== undefined)
return this.#cancelPromise;
this.#sessionGeneration++;
const worker = this.#worker;
if (worker === undefined)
return { cancelled: true, closed: true };
const cancellation = (async () => {
let state;
try {
state = await this.#request("control", { action: "cancel" });
}
catch {
// Cancellation must also handle a failed/pending open, a timeout, or a
// concurrent caller which already closed the same worker.
}
finally {
if (this.#worker === worker)
await this.close();
else
await this.#terminateWorker(worker);
}
return state === undefined ? { cancelled: true, closed: true } : { ...state, cancelled: true, closed: true };
})();
this.#cancelPromise = cancellation;
try {
return await cancellation;
}
finally {
if (this.#cancelPromise === cancellation)
this.#cancelPromise = undefined;
}
}
async recentFrames(options = {}) {
if (options.limit !== undefined)
this.#validateInteger(options.limit, "Recent frame limit", 1, MAX_EMULATOR_RECENT_FRAMES);
return this.#request("recent_frames", { ...options });
}
async checkpointSave(options = {}) {
if (options.label !== undefined && (typeof options.label !== "string" || options.label.length > 160)) {
throw new EmulatorError("Checkpoint labels must contain at most 160 characters");
}
const checkpoint = await this.#request("checkpoint_save", { ...options });
this.#checkpointRecordings.set(checkpoint.checkpointId, checkpoint.recordingPath);
return checkpoint;
}
async checkpointRestore(options) {
if (typeof options?.checkpointId !== "string" || !/^checkpoint-[0-9a-f]{32}$/u.test(options.checkpointId)) {
throw new EmulatorError("Invalid opaque checkpoint ID");
}
const origin = options.recordingPath ?? this.#checkpointRecordings.get(options.checkpointId);
return this.#request("checkpoint_restore", {
checkpointId: options.checkpointId,
...(origin === undefined ? {} : { recordingPath: this.#resolveDirectory(origin) }),
});
}
async recordingStatus(options = {}) {
return this.#request("recording_status", this.#recordingSelection(options));
}
recordingAccessDiagnostic() {
return { ...this.#recordingMaintenance.accessDiagnostic(), localTransports: {
workerAttached: this.#worker !== undefined, workerTransportsAwaitingClose: this.#recordingWorkers.size,
pendingRequests: this.#pending.size, emulatorActive: this.active,
} };
}
async recordingExport(options = {}) {
return this.#request("recording_export", this.#recordingSelection(options));
}
/** Maintain finalized recording storage without starting an emulator. */
async recordingMaintain(options) {
if (!["archive", "retrieve", "restore"].includes(options.action))
throw new EmulatorError("Unknown recording maintenance action");
if (this.#worker !== undefined || this.#pending.size || this.#releaseRecordingAccess !== undefined) {
throw new EmulatorError("Close the emulator and settle its transports before recording maintenance");
}
const release = this.#recordingMaintenance.acquire();
let result;
try {
result = this.#recordingMaintenance[options.action](options.recordingPath);
}
catch (error) {
try {
release();
}
catch (closure) {
throw new EmulatorError(`${error instanceof Error ? error.message : String(error)}; ${closure instanceof Error ? closure.message : String(closure)}`, { cause: error });
}
throw error;
}
release();
return result;
}
/** Read only already committed inputs and sampled PNGs; never capture or tick. */
async recordingReview(options) {
if (options === null || typeof options !== "object" || Array.isArray(options)) {
throw new EmulatorError("Recording review requires an options object");
}
const allowed = new Set(["recordingPath", "sessionId", "branchId", "fromActionIndex", "toActionIndex", "fromFrame", "toFrame", "prefixPin", "maxImages"]);
if (Object.keys(options).some((key) => !allowed.has(key)))
throw new EmulatorError("Unknown recording review option");
if (typeof options.sessionId !== "string" || !/^[0-9a-f]{32}$/u.test(options.sessionId)) {
throw new EmulatorError("Recording review requires an exact sessionId");
}
if (typeof options.branchId !== "string" || options.branchId.length > 100 || !/^branch-[0-9]{4,}$/u.test(options.branchId)) {
throw new EmulatorError("Recording review requires an exact branchId");
}
// Imported prefixes can contain more normalized actions than this journal
// has events. Bound the requested delta, not its absolute branch indices.
this.#validateInteger(options.fromActionIndex, "fromActionIndex", 0, Number.MAX_SAFE_INTEGER);
this.#validateInteger(options.toActionIndex, "toActionIndex", 0, Number.MAX_SAFE_INTEGER);
if (options.toActionIndex < options.fromActionIndex || options.toActionIndex - options.fromActionIndex > MAX_EMULATOR_REVIEW_ACTIONS) {
throw new EmulatorError(`Review requires an ordered interval of at most ${MAX_EMULATOR_REVIEW_ACTIONS} normalized actions; narrow the interval`);
}
if ((options.fromFrame === undefined) !== (options.toFrame === undefined)) {
throw new EmulatorError("Review frame bounds must be supplied together");
}
if (options.fromFrame !== undefined && options.toFrame !== undefined) {
this.#validateInteger(options.fromFrame, "fromFrame", 0, MAX_EMULATOR_RECORDING_FRAMES);
this.#validateInteger(options.toFrame, "toFrame", 0, MAX_EMULATOR_RECORDING_FRAMES);
if (options.toFrame < options.fromFrame || options.toFrame - options.fromFrame > MAX_EMULATOR_FRAMES) {
throw new EmulatorError(`Review requires an ordered interval of at most ${MAX_EMULATOR_FRAMES} native frames; narrow the interval`);
}
}
if (options.prefixPin !== undefined) {
const pin = options.prefixPin;
if (pin === null || typeof pin !== "object" || Array.isArray(pin) ||
Object.keys(pin).length !== 4 || Object.keys(pin).some((key) => !["eventCount", "eventDigest", "byteLength", "sha256"].includes(key))) {
throw new EmulatorError("A recording prefix pin requires eventCount, eventDigest, byteLength, and sha256");
}
this.#validateInteger(pin.eventCount, "Prefix event count", 1, MAX_EMULATOR_RECORDING_EVENTS);
this.#validateInteger(pin.byteLength, "Prefix byte length", 1, MAX_EMULATOR_RECORDING_BYTES);
if (typeof pin.eventDigest !== "string" || !/^[0-9a-f]{64}$/u.test(pin.eventDigest) ||
typeof pin.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(pin.sha256)) {
throw new EmulatorError("Recording prefix digests must be lowercase SHA-256 values");
}
}
const maxImages = options.maxImages === undefined ? 4 : options.maxImages;
this.#validateInteger(maxImages, "Review image limit", 1, MAX_EMULATOR_REVIEW_IMAGES);
const result = await this.#request("recording_review", {
...this.#recordingSelection(options), sessionId: options.sessionId,
fromActionIndex: options.fromActionIndex, toActionIndex: options.toActionIndex, maxImages,
...(options.fromFrame === undefined ? {} : { fromFrame: options.fromFrame, toFrame: options.toFrame }),
...(options.prefixPin === undefined ? {} : { prefixPin: options.prefixPin }),
});
if (Buffer.byteLength(JSON.stringify(result), "utf8") > MAX_EMULATOR_REVIEW_METADATA_BYTES) {
throw new EmulatorError("Review metadata exceeds 2 MiB; narrow the interval");
}
return result;
}
async recordingStop(options = {}) {
if (options.outcome !== undefined && !["passed", "failed", "needs-review", "cancelled"].includes(options.outcome)) {
throw new EmulatorError("Unknown recording outcome");
}
if (options.reason !== undefined && (typeof options.reason !== "string" || options.reason.length > 2000)) {
throw new EmulatorError("Recording outcome reason must contain at most 2000 characters");
}
return this.#request("recording_stop", { ...options });
}
async clip(options) {
const outputPath = this.#resolvePath(options.outputPath, { existing: false });
if (extname(outputPath).toLowerCase() !== ".gif" || existsSync(outputPath)) {
throw new EmulatorError("A clip requires a new .gif output path; existing files are never overwritten");
}
const selection = this.#recordingSelection(options);
const recordingRoot = String(selection.recordingPath);
if (this.#recordingMaintenance.isArchived(recordingRoot))
throw new EmulatorError("Restore an archived recording before creating derived clips; archive bytes are immutable");
if (isPathWithinRoot(recordingRoot, outputPath, this.#platform)) {
this.#assertRecordingPackageProtection(recordingRoot);
}
else {
const generatedRoot = this.#unredirectedArtifactsRoot();
if (!isPathWithinRoot(generatedRoot, outputPath, this.#platform)) {
throw new EmulatorError("Clips must be written inside their recording directory or the authorized artifacts directory");
}
}
for (const [name, value] of [["Clip start frame", options.startFrame], ["Clip end frame", options.endFrame]]) {
if (value !== undefined)
this.#validateInteger(value, name, 0, MAX_EMULATOR_RECORDING_FRAMES);
}
if (options.maxFrames !== undefined)
this.#validateInteger(options.maxFrames, "Clip frame limit", 1, MAX_EMULATOR_CLIP_FRAMES);
if (options.startFrame !== undefined && options.endFrame !== undefined && options.endFrame < options.startFrame) {
throw new EmulatorError("Clip endFrame must not precede startFrame");
}
if (options.cuts !== undefined) {
if (options.startFrame !== undefined || options.endFrame !== undefined) {
throw new EmulatorError("Clip cuts cannot be combined with startFrame or endFrame");
}
if (!Array.isArray(options.cuts) || options.cuts.length < 1 || options.cuts.length > MAX_EMULATOR_CLIP_CUTS) {
throw new EmulatorError(`A montage requires 1–${MAX_EMULATOR_CLIP_CUTS} ordered cuts`);
}
let periods = 0;
for (const cut of options.cuts) {
if (cut === null || typeof cut !== "object" || Array.isArray(cut) ||
Object.keys(cut).some((key) => !["startFrame", "endFrame"].includes(key))) {
throw new EmulatorError("Each montage cut requires only startFrame and endFrame");
}
this.#validateInteger(cut.startFrame, "Cut start frame", 0, MAX_EMULATOR_RECORDING_FRAMES);
this.#validateInteger(cut.endFrame, "Cut end frame", cut.startFrame, MAX_EMULATOR_RECORDING_FRAMES);
periods += cut.endFrame - cut.startFrame + 1;
}
if (periods * EMULATOR_DOTS_PER_FRAME > 60 * EMULATOR_CLOCK_HZ) {
throw new EmulatorError("A montage may contain at most 60 seconds of native game time");
}
}
return this.#request("clip", { ...selection, outputPath,
...(options.startFrame === undefined ? {} : { startFrame: options.startFrame }),
...(options.endFrame === undefined ? {} : { endFrame: options.endFrame }),
...(options.maxFrames === undefined ? {} : { maxFrames: options.maxFrames }),
...(options.cuts === undefined ? {} : { cuts: options.cuts.map((cut) => ({ ...cut })) }),
});
}
async step(frames = 1) {
this.#validateFrames(frames);
return this.#request("step", { frames });
}
/** The sole interactive play primitive: exact inputs, exact frames, genuine samples. */
async playStep(options, signal) {
let execution = { status: "pre-execution-rejected" };
let cancellation;
let operationFailure;
const cancel = () => {
cancellation ??= this.control({ action: "cancel" });
void cancellation.catch(() => undefined);
};
try {
if (options === null || typeof options !== "object" || Array.isArray(options) ||
Object.keys(options).some((key) => !["buttons", "frames", "sampleCount"].includes(key))) {
throw new EmulatorError("A stepped action requires only buttons, frames, and optional sampleCount");
}
this.#validateButtonSet(options.buttons);
this.#validateFrames(options.frames);
execution.requestedFrames = options.frames;
const sampleCount = options.sampleCount ?? 4;
this.#validateInteger(sampleCount, "Step sample count", 1, MAX_EMULATOR_STEP_SAMPLES);
if (signal?.aborted)
throw new EmulatorError("The stepped action was cancelled before execution");
if (!this.active)
throw new EmulatorError("No emulator session is running; run a ROM first");
signal?.addEventListener("abort", cancel, { once: true });
// No authoritative response means unknown execution, never measured zero.
execution = { status: "unknown", requestedFrames: options.frames };
const result = await this.#request("play_step", {
buttons: [...options.buttons], frames: options.frames, sampleCount,
});
this.#validateObservationExecution(result, options);
execution = {
status: "completed", requestedFrames: options.frames,
startFrame: result.startFrame, frame: result.frame, advancedFrames: result.advancedFrames,
appliedButtons: result.appliedButtons,
...(result.recordingSpan === undefined ? {} : { recordingSpan: result.recordingSpan }),
};
if (signal?.aborted)
throw new EmulatorError("The stepped action completed before cancellation cleanup");
// Preserve execution above even if subsequent image validation/read fails.
this.#validateObservation(result, { ...options, sampleCount });
execution.sampledButtons = result.sampledButtons;
return { ...result, execution };
}
catch (error) {
const failure = error instanceof EmulatorError ? error : new EmulatorError(error instanceof Error ? error.message : String(error), { cause: error });
if (failure.execution?.status === "unknown") {
execution = { ...failure.execution, requestedFrames: options.frames };
}
else if (error instanceof EmulatorTimelineError &&
Number.isSafeInteger(error.startFrame) && error.startFrame >= 0 &&
Number.isSafeInteger(error.frame) && error.frame >= error.startFrame &&
error.advancedFrames === error.frame - error.startFrame &&
Number.isSafeInteger(options?.frames) && error.advancedFrames <= options.frames) {
execution = {
status: error.advancedFrames === options.frames ? "completed" : "known-partial",
requestedFrames: options.frames, startFrame: error.startFrame,
frame: error.frame, advancedFrames: error.advancedFrames,
...(error.appliedButtons === undefined ? {} : { appliedButtons: error.appliedButtons }),
...(error.sampledButtons === undefined ? {} : { sampledButtons: error.sampledButtons }),
...(error.recordingSpan === undefined ? {} : { recordingSpan: error.recordingSpan }),
};
}
else if (failure.execution?.status === "pre-execution-rejected") {
execution = { status: "pre-execution-rejected", requestedFrames: options.frames };
}
failure.execution = execution;
failure.imageDelivery = {
status: execution.status === "pre-execution-rejected" ? "not-attempted" : execution.status === "unknown" ? "unavailable" : "failed",
...(execution.status === "pre-execution-rejected" ? {} : {
recovery: "Inspect emulator_status and recover images with emulator_observe or emulator_review. Do not repeat input to repair an image response.",
}),
};
operationFailure = failure;
throw failure;
}
finally {
signal?.removeEventListener("abort", cancel);
try {
await cancellation;
}
catch (error) {
const failure = operationFailure ?? new EmulatorError("Stepped action cancellation cleanup failed", { cause: error });
failure.execution = execution;
failure.cleanup = { status: "failed", error: error instanceof Error ? error.message : String(error) };
throw failure;
}
}
}
/** Encode the current framebuffer in memory, without ticking or journaling. */
async observe() {
const result = await this.#request("observe", {});
this.#validateObservation(result);
return result;
}
#validateButtonSet(buttons) {
if (!Array.isArray(buttons) || buttons.length > EMULATOR_BUTTONS.length ||
buttons.some((button) => !EMULATOR_BUTTONS.includes(button)) || new Set(buttons).size !== buttons.length) {
throw new EmulatorError("Buttons must be a complete unique set of Game Boy buttons; use [] for neutral input");
}
}
#validateObservationExecution(result, step) {
const sameButtons = (left, right) => [...left].sort().join(",") === [...right].sort().join(",");
if (result === null || typeof result !== "object" ||
result.paused !== true || result.clock?.mode !== "paused" ||
!Number.isSafeInteger(result.startFrame) || result.startFrame < 0 ||
!Number.isSafeInteger(result.frame) || result.frame !== result.startFrame + result.advancedFrames ||
result.advancedFrames !== (step?.frames ?? 0)) {
throw new EmulatorError("The stepped observation returned inconsistent native-frame or paused-state evidence");
}
this.#validateButtonSet(result.activeButtons);
this.#validateButtonSet(result.appliedButtons);
if (!sameButtons(result.appliedButtons, result.activeButtons) ||
(step !== undefined && !sameButtons(result.appliedButtons, step.buttons))) {
throw new EmulatorError("The stepped observation returned inconsistent applied controller input");
}
}
#validateObservation(result, step) {
this.#validateObservationExecution(result, step);
this.#validateButtonSet(result.sampledButtons);
if (result.width !== 160 || result.height !== 144) {
throw new EmulatorError("The stepped observation must contain native 160×144 framebuffers");
}
const sameButtons = (left, right) => [...left].sort().join(",") === [...right].sort().join(",");
const count = step === undefined ? 1 : Math.min(step.sampleCount ?? 4, step.frames);
if (!Array.isArray(result.samples) || result.samples.length !== count) {
throw new EmulatorError("The stepped observation did not return the requested bounded samples");
}
for (const [index, sample] of result.samples.entries()) {
const frame = result.startFrame + (step === undefined ? 0 : Math.floor((index + 1) * step.frames / count));
if (sample === null || typeof sample !== "object" || sample.frame !== frame ||
sample.width !== 160 || sample.height !== 144 || typeof sample.pngBase64 !== "string" ||
sample.pngBase64.length > Math.ceil(MAX_EMULATOR_SAMPLE_BYTES / 3) * 4 ||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(sample.pngBase64) ||
typeof sample.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(sample.sha256) ||
typeof sample.rgbaSha256 !== "string" || !/^[0-9a-f]{64}$/u.test(sample.rgbaSha256)) {
throw new EmulatorError("The stepped observation returned invalid sample metadata");
}
this.#validateButtonSet(sample.sampledButtons);
if (index === count - 1 && !sameButtons(result.sampledButtons, sample.sampledButtons)) {
throw new EmulatorError("The observation's sampled buttons disagree with its final framebuffer sample");
}
if (step !== undefined && !sameButtons(sample.sampledButtons, step.buttons)) {
throw new EmulatorError("The sampled framebuffer does not describe the requested controller input");
}
const bytes = Buffer.from(sample.pngBase64, "base64");
if (bytes.toString("base64") !== sample.pngBase64 || bytes.length < 24 || bytes.length > MAX_EMULATOR_SAMPLE_BYTES ||
!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
bytes.readUInt32BE(16) !== 160 || bytes.readUInt32BE(20) !== 144 ||
createHash("sha256").update(bytes).digest("hex") !== sample.sha256) {
throw new EmulatorError("The stepped observation failed PNG dimension or SHA-256 verification");
}
const decoded = PNG.sync.read(bytes, { checkCRC: true });
if (createHash("sha256").update(decoded.data).digest("hex") !== sample.rgbaSha256) {
throw new EmulatorError("The stepped observation failed RGBA framebuffer verification");
}
if (sample.path !== undefined) {
const filename = this.#resolvePath(sample.path, { existing: true });
const descriptor = openSync(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
try {
const before = fstatSync(descriptor);
if (!before.isFile() || before.size > MAX_EMULATOR_SAMPLE_BYTES) {
throw new EmulatorError("The retained stepped framebuffer exceeds its regular-file byte limit");
}
const buffer = Buffer.alloc(bytes.length + 1);
let count = 0;
while (count < buffer.length) {
const read = readSync(descriptor, buffer, count, buffer.length - count, count);
if (read === 0)
break;
count += read;
}
const after = fstatSync(descriptor);
if (count !== bytes.length || !buffer.subarray(0, count).equals(bytes) ||
before.size !== after.size || before.mtimeMs !== after.mtimeMs ||
this.#resolvePath(sample.path, { existing: true }) !== filename) {
throw new EmulatorError("The retained stepped framebuffer differs from the returned image");
}
}
finally {
closeSync(descriptor);
}
sample.path = filename;
}
}
}
async input(button, action, frames) {
if (!EMULATOR_BUTTONS.includes(button)) {
throw new EmulatorError(`Unknown Game Boy input button: ${String(button)}`);
}
if (action !== "press" && action !== "release" && action !== "tap") {
throw new EmulatorError(`Unknown Game Boy input action: ${String(action)}`);
}
if (frames !== undefined) {
this.#validateFrames(frames);
}
if (frames !== undefined && action !== "tap") {
throw new EmulatorError("A frame count is only supported for tap input actions");
}
return this.#request("input", {
button,
action,
...(frames === undefined ? {} : { frames }),
});
}
async capture(outputPath) {
const validatedOutput = this.#resolvePath(outputPath, { existing: false });
if (extname(validatedOutput).toLowerCase() !== ".png") {
throw new EmulatorError("Emulator screenshots must use the .png extension");
}
return this.#request("screenshot", { outputPath: validatedOutput });
}
async timeline(actions) {
this.#validateTimelineActions(actions, { requireActions: true });
return this.#request("timeline", { actions });
}
async captureTimeline(checkpoints) {
if (!Array.isArray(checkpoints) ||
checkpoints.length < 1 ||
checkpoints.length > MAX_EMULATOR_TIMELINE_CHECKPOINTS) {
throw new EmulatorError(`An emulator capture timeline requires between 1 and ${MAX_EMULATOR_TIMELINE_CHECKPOINTS} checkpoints`);
}
const actions = [];
const validatedCheckpoints = checkpoints.map((checkpoint, index) => {
if (checkpoint === null ||
typeof checkpoint !== "object" ||
!Array.isArray(checkpoint.actions)) {
throw new EmulatorError(`Emulator checkpoint ${index} requires an actions array`);
}
actions.push(...checkpoint.actions);
const outputPath = this.#resolvePath(checkpoint.outputPath, { existing: false });
if (extname(outputPath).toLowerCase() !== ".png") {
throw new EmulatorError("Emulator timeline screenshots must use the .png extension");
}
return { actions: checkpoint.actions, outputPath };
});
this.#validateTimelineActions(actions, { requireActions: false });
return this.#request("capture_timeline", {
checkpoints: validatedCheckpoints,
});
}
async inspect(options) {
if (options === null || typeof options !== "object") {
throw new EmulatorError("Emulator inspection requires a bounded inspection view");
}
switch (options.view) {
case "oam": {
if (options.limit !== undefined &&
(!Number.isSafeInteger(options.limit) ||
options.limit < 1 ||
options.limit > MAX_EMULATOR_OAM_OBJECTS)) {
throw new EmulatorError(`OAM inspection limits must be integers between 1 and ${MAX_EMULATOR_OAM_OBJECTS}`);
}
for (const value of [options.visibleOnly, options.includeScanlineSummary]) {
if (value !== undefined && typeof value !== "boolean") {
throw new EmulatorError("OAM inspection flags must be boolean values");
}
}
break;
}
case "memory":
case "vram": {
if (!["vram", "wram", "oam", "hram"].includes(options.region)) {
throw new EmulatorError("Memory inspection requires a named VRAM, WRAM, OAM, or HRAM region");
}
if (options.view === "vram" && options.region !== "vram") {
throw new EmulatorError("The VRAM inspection view only permits the VRAM region");
}
if (!Number.isSafeInteger(options.offset) || options.offset < 0) {
throw new EmulatorError("Memory inspection offsets must be non-negative integers");
}
if (!Number.isSafeInteger(options.length) ||
options.length < 1 ||
options.length > MAX_EMULATOR_INSPECTION_BYTES) {
throw new EmulatorError(`Memory inspections must return between 1 and ${MAX_EMULATOR_INSPECTION_BYTES} bytes`);
}
const regionSize = { vram: 0x2000, wram: 0x2000, oam: 0xa0, hram: 0x7f }[options.region];
if (options.offset + options.length > regionSize) {
throw new EmulatorError(`The requested byte range exceeds the ${options.region} region`);
}
if (options.bank !== undefined) {
if (!Number.isSafeInteger(options.bank) || options.bank < 0) {
throw new EmulatorError("Memory bank selections must be non-negative integers");
}
if (options.region !== "vram" && options.region !== "wram") {
throw new EmulatorError("Only VRAM and WRAM regions support bank selection");
}
const maximumBank = options.region === "vram" ? 1 : 7;
if (options.bank > maximumBank) {
throw new EmulatorError(`The ${options.region} bank must be between 0 and ${maximumBank}`);
}
if (options.region === "wram" && options.offset + options.length > 0x1000) {
throw new EmulatorError("A banked WRAM inspection must remain inside one 4 KiB bank");
}
}
break;
}
default:
throw new EmulatorError("Unsupported emulator inspection view");
}
return this.#request("inspect", { ...options });
}
async status() {
return this.#request("state", {});
}
async close() {
const worker = this.#worker;
if (worker === undefined) {
this.#clearActiveSession();
await this.#releaseRecordingLease();
return;
}
const existing = this.#workerClosures.get(worker);
if (existing !== undefined)
return existing;
this.#sessionGeneration++;
this.#expectedExits.add(worker);
const closing = Promise.resolve().then(async () => {
try {
if (this.#worker === worker)
await this.#request("close", {});
}
finally {
if (this.#worker === worker)
this.#clearActiveSession();
worker.stdin.end();
await this.#terminateWorker(worker, true);
await this.#releaseRecordingLease();
}
});
this.#workerClosures.set(worker, closing);
try {
await closing;
}
finally {
if (this.#workerClosures.get(worker) === closing)
this.#workerClosures.delete(worker);
}
}
async #releaseRecordingLease() {
const release = this.#releaseRecordingAccess;
if (!release)
return;
let timer;
try {
await Promise.race([this.#recordingWorkerClosed, new Promise((_, reject) => {
timer = setTimeout(() => reject(new EmulatorError("Recording transport closure is unresolved; access lease retained")), 2000);
})]);
if (this.#recordingWorkers.size !== 0)
throw new EmulatorError("Worker transport closure changed; access lease retained");
if (this.#releaseRecordingAccess === release) {
release();
this.#releaseRecordingAccess = undefined;
}
}
finally {
if (timer)
clearTimeout(timer);
}
}
#clearActiveSession() {
this.#activeRomPath = undefined;
this.#activeRomDigest = undefined;
this.#activeColorMode = undefined;
this.#activeRecordingPath = undefined;
this.#recordingRequested = false;
}
#validateInteger(value, name, minimum, maximum) {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new EmulatorError(`${name} must be an integer between ${minimum} and ${maximum}`);
}
}
#validateRecordingOptions(value) {
if (value === undefined || value === false)
return undefined;
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new EmulatorError("The recording option must be an object or false");
}
const bounds = {
sampleEveryFrames: [1, MAX_EMULATOR_FRAMES],
recentFrameCount: [1, MAX_EMULATOR_RECENT_FRAMES],
maxBytes: [64 * 1024, MAX_EMULATOR_RECORDING_BYTES],
maxFrames: [1, MAX_EMULATOR_RECORDING_FRAMES],
maxWallTimeMs: [1, MAX_EMULATOR_RECORDING_WALL_MS],
};
for (const [key, item] of Object.entries(value)) {
if (key === "outputPath")
continue;
const bound = bounds[key];
if (bound === undefined)
throw new EmulatorError(`Unknown recording option: ${key}`);
this.#validateInteger(item, key, bound[0], bound[1]);
}
const outputPath = this.#resolvePath(value.outputPath ?? `artifacts/playtests/session-${randomUUID()}`, { existing: false });
if (existsSync(outputPath))
throw new EmulatorError("A recording output directory must be new; existing attempts are never overwritten");
return { ...value, outputPath };
}
#resolveDirectory(path) {
const resolved = this.#resolvePath(path, { existing: false });
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
throw new EmulatorError("recordingPath must name an existing recording directory");
}
return existsSync(resolve(resolved, ".recording-archive.json")) || this.#recordingMaintenance.isArchived(resolved)
? this.#recordingMaintenance.resolveRecording(resolved) : resolved;
}
#assertRecordingPackageProtection(recordingRoot) {
try {
for (const name of [".gitignore", ".npmignore"]) {
const path = resolve(recordingRoot, name);
const entry = lstatSync(path);
if (entry.isSymbolicLink() || !entry.isFile() || entry.size !== 2 ||
!readFileSync(path).equals(Buffer.from("*\n"))) {
throw new Error("recording package-protection marker changed");
}
}
}
catch (error) {
throw new EmulatorError("The recording directory's package-protection markers are missing or changed", { cause: error });
}
}
#unredirectedArtifactsRoot() {
const lexicalRoot = resolve(this.projectRoot, "artifacts");
try {
const entry = lstatSync(lexicalRoot);
if (entry.isSymbolicLink() || !entry.isDirectory()) {
throw new EmulatorError("The top-level artifacts directory must not be a symbolic link, junction, or redirected path");
}
}
catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
throw error;
}
const canonicalRoot = this.#resolvePath(lexicalRoot, { existing: false });
// Both directions express equality using the selected platform's drive,
// separator, and case rules. Never bless a redirected packaged directory.
if (!isPathWithinRoot(lexicalRoot, canonicalRoot, this.#platform) ||
!isPathWithinRoot(canonicalRoot, lexicalRoot, this.#platform)) {
throw new EmulatorError("The top-level artifacts directory must not be a symbolic link, junction, or redirected path");
}
return lexicalRoot;
}
#recordingSelection(options) {
const selected = options.recordingPath ?? this.#activeRecordingPath ?? this.#lastRecordingPath;
if (selected === undefined)
throw new EmulatorError("A recordingPath is required when no retained recording is selected");
if (options.branchId !== undefined && (typeof options.branchId !== "string" || !/^branch-[0-9]{4,}$/u.test(options.branchId))) {
throw new EmulatorError("Invalid recording branch ID");
}
return { recordingPath: this.#resolveDirectory(selected), ...(options.branchId === undefined ? {} : { branchId: options.branchId }) };
}
async #terminateWorker(worker, grace = false) {
if (worker.exitCode !== null || worker.signalCode !== null)
return;
await new Promise((resolveExit) => {
let killed = false;
const kill = setTimeout(() => {
killed = true;
worker.kill("SIGKILL");
}, grace ? 2_000 : 1_000);
kill.unref();
const terminate = grace ? setTimeout(() => worker.kill("SIGTERM"), 1_000) : undefined;
terminate?.unref();
if (!grace)
worker.kill("SIGTERM");
const finished = () => {
clearTimeout(kill);
if (terminate !== undefined)
clearTimeout(terminate);
if (killed)
this.#diagnostics.push("The emulator required forced process cleanup");
resolveExit();
};
worker.once("exit", finished);
if (worker.exitCode !== null || worker.signalCode !== null) {
worker.off("exit", finished);
finished();
}
});
}
#recordWorkerFailure(message) {
const root = this.#activeRecordingPath;
if (!this.#recordingRequested || root === undefined)
return;
try {
if (!existsSync(root) || realpathSync(root) !== root || !statSync(root).isDirectory())
return;
const path = resolve(root, "worker-failures.jsonl");
if (existsSync(path) && (lstatSync(path).isSymbolicLink() || statSync(path).size > 8_192))
return;
appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), message: message.slice(0, 2000) })}\n`, { mode: 0o600 });
}
catch {
// The append-only worker journal still identifies an unfinished attempt.
// Cleanup must not be prevented by a full disk or inaccessible artifact.
}
}
#validateFrames(frames, minimum = 1) {
if (!Number.isSafeInteger(frames) || frames < minimum || frames > MAX_EMULATOR_FRAMES) {
throw new EmulatorError(`Emulator frame counts must be integers between ${minimum} and ${MAX_EMULATOR_FRAMES}`);
}
}
#validateTimelineActions(actions, options) {
if (!Array.isArray(actions) ||
(options.requireActions && actions.length === 0) ||
actions.length > MAX_EMULATOR_TIMELINE_ACTIONS) {
throw new EmulatorError(`An emulator timeline permits at most ${MAX_EMULATOR_TIMELINE_ACTIONS} actions${options.requireActions ? " and requires at least one action" : ""}`);
}
let advancedFrames = 0;
for (const [index, action] of actions.entries()) {
if (action === null || typeof action !== "object") {
throw new EmulatorError(`Emulator timeline action ${index} must be an object`);
}
switch (action.type) {
case "hold":
case "tap":
if (!EMULATOR_BUTTONS.includes(action.button)) {
throw new EmulatorError(`Unknown Game Boy input button in timeline action ${index}`);
}
this.#validateFrames(action.frames);
advancedFrames += action.frames;
if (action.settleFrames !== undefined) {
this.#validateFrames(action.settleFrames, 0);
advancedFrames += action.settleFrames;
}
break;
case "step":
this.#validateFrames(action.frames);
advancedFrames += action.frames;
break;
case "press":
case "release":
if (!EMULATOR_BUTTONS.includes(action.button)) {
throw new EmulatorError(`Unknown Game Boy input button in timeline action ${index}`);
}
break;
case "set_buttons":
case "hold_buttons":
if (!Array.isArray(action.buttons) || action.buttons.length > EMULATOR_BUTTONS.length ||
action.buttons.some((button) => !EMULATOR_BUTTONS.includes(button)) ||
new Set(action.buttons).size !== action.buttons.length) {
throw new EmulatorError(`Timeline action ${index} requires a unique bounded Game Boy button set`);
}
if (action.type === "hold_buttons") {
this.#validateFrames(action.frames);
advancedFrames += action.frames;
if (action.settleFrames !== undefined) {
this.#validateFrames(action.settleFrames, 0);
advancedFrames += action.settleFrames;
}
}
break;
default:
throw new EmulatorError(`Unknown emulator timeline action at index ${index}`);
}
if (advancedFrames > MAX_EMULATOR_FRAMES) {
throw new EmulatorError(`An emulator timeline may advance at most ${MAX_EMULATOR_FRAMES} total frames`);
}
}
}
#resolvePath(path, options) {
if (typeof path !== "string" || path.trim().length === 0) {
throw new EmulatorError("An emulator ROM or output path must be a non-empty string");
}
try {
assertSafePlatformPath(path, this.#platform);
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new EmulatorError(`The emulator path is not safe: ${reason}`, { cause: error });
}
const candidate = resolve(this.projectRoot, path);
let resolvedPath;
try {
if (options.existing) {
resolvedPath = realpathSync(candidate);
if (!statSync(resolvedPath).isFile()) {
throw new Error("not a regular file");
}
}
else {
let ancestor = candidate;
while (!existsSync(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) {
throw new Error("no existing parent directory");
}
ancestor = parent;
}
const resolvedAncestor = realpathSync(ancestor);
resolvedPath = resolve(resolvedAncestor, relative(ancestor, candidate));
}
}
catch (error) {
throw new EmulatorError(`The emulator path is not accessible: ${candidate}`, {
cause: error,
});
}
if (!isPathWithinRoot(this.projectRoot, resolvedPath, this.#platform)) {
throw new EmulatorError(`The emulator path must remain inside ${this.projectRoot}`);
}
return resolvedPath;
}
#startWorker() {
if (this.#worker !== undefined) {
return this.#worker;
}
if (this.#recordingWorkers.size !== 0)
throw new EmulatorError("Previous worker transport closure is unresolved; replacement is refused");
if (!existsSync(this.#workerPath)) {
throw new EmulatorError(`The bundled emulator worker is missing: ${this.#workerPath}`);
}
try {
accessSync(this.#pythonPath, constants.X_OK);
}
catch (error) {
const setupCommand = this.#platform === "win32"
? "powershell -NoProfile -File scripts/setup.ps1"
: "sh scripts/setup.sh";
throw new EmulatorError(`The direct-play PyBoy runtime is unavailable. From a trusted compiled package, run ${setupCommand} doctor, ` +
"then plan --components emulator with a separate owned --root. Apply requires explicit setup consent; binding prepared dependencies is a separate action. " +
"Alternatively, configure GB_STUDIO_PYTHON as an absolute Python executable with verified PyBoy and Pillow.", { cause: error });
}
this.#diagnostics.length = 0;
const environment = sanitizeSubprocessEnvironment({ ...process.env, PYTHONUNBUFFERED: "1" }, this.#platform);
if (process.getuid)
this.#releaseRecordingAccess ??= this.#recordingMaintenance.acquire();
// Isolated Python ignores bytecode environment flags; avoid writing into external runtimes explicitly.
const worker = spawn(this.#pythonPath, ["-I", "-B", "-u", this.#workerPath, "--root", this.projectRoot], {
cwd: dirname(this.#workerPath),
stdio: ["pipe", "pipe", "pipe"],
shell: false,
windowsHide: true,
env: environment,
});
this.#worker = worker;
this.#recordingWorkers.add(worker);
const closed = new Promise((done) => worker.once("close", () => { this.#recordingWorkers.delete(worker); done(); }));
this.#recordingWorkerClosed = Promise.all([this.#recordingWorkerClosed, closed]).then(() => undefined);
this.#responses = createInterface({ input: worker.stdout, crlfDelay: Infinity });
this.#responses.on("line", (line) => this.#handleResponse(line));
worker.stderr.setEncoding("utf8");
worker.stderr.on("data", (chunk) => {
this.#diagnostics.push(chunk.trim());
if (this.#diagnostics.length > 8) {
this.#diagnostics.shift();
}
});
worker.once("error", (error) => {
this.#failWorker(worker, new EmulatorError(`Could not start the optional PyBoy emulator: ${error.message}`, {
cause: error,
}));
});
worker.once("exit", (code, signal) => {
const details = this.#diagnostics.filter(Boolean).join(" ");
const reason = signal === null ? `exit code ${String(code)}` : `signal ${signal}`;
this.#failWorker(worker, new EmulatorError(`The PyBoy emulator stopped (${reason})${details.length > 0 ? `: ${details}` : ""}`));
});
return worker;
}
#failWorker(worker, error) {
if (this.#worker !== worker) {
return;
}
if (!this.#expectedExits.has(worker))
this.#recordWorkerFailure(error.message);
this.#clearActiveSession();
this.#worker = undefined;
this.#responses?.close();
this.#responses = undefined;
for (const [id, request] of this.#pending) {
clearTimeout(request.timeout);
this.#pending.delete(id);
request.reject(error);
}
}
#handleResponse(line) {
let response;
try {
response = JSON.parse(line);
}
catch {
this.#diagnostics.push(`Unexpected emulator output: ${line.slice(0, 300)}`);
return;
}
if (typeof response.id !== "number") {
return;
}
const request = this.#pending.get(response.id);
if (request === undefined) {
return;
}
clearTimeout(request.timeout);
this.#pending.delete(response.id);
if (response.error !== undefined) {
const message = typeof response.error.message === "string"
? response.error.message
: "The PyBoy emulator reported an unknown error";
const details = response.error;
if (typeof details.failedActionIndex === "number" &&
typeof details.advancedFrames === "number" &&
typeof details.startFrame === "number" &&
typeof details.frame === "number" &&
Array.isArray(details.activeButtons) &&
details.activeButtons.every((button) => EMULATOR_BUTTONS.includes(button))) {
request.reject(new EmulatorTimelineError(message, {
failedActionIndex: details.failedActionIndex,
advancedFrames: details.advancedFrames,
startFrame: details.startFrame,
frame: details.frame,
activeButtons: details.activeButtons,
...(details.recordingSpan !== undefined ? { recordingSpan: details.recordingSpan } : {}),
...(Array.isArray(details.sampledButtons) ? { sampledButtons: details.sampledButtons } : {}),
...(Array.isArray(details.appliedButtons) && details.appliedButtons.length <= 8 &&
details.appliedButtons.every((button) => EMULATOR_BUTTONS.includes(button)) &&
new Set(details.appliedButtons).size === details.appliedButtons.length
? { appliedButtons: details.appliedButtons } : {}),
}));
}
else {
const error = new EmulatorError(message);
if (details.executionStatus === "pre-execution-rejected")
error.execution = { status: "pre-execution-rejected" };
if (details.executionStatus === "unknown") {
error.execution = { status: "unknown" };
const prefix = details.confirmedPrefix;
if (prefix !== null && typeof prefix === "object" &&
Number.isSafeInteger(prefix.startFrame) && prefix.startFrame >= 0 &&
Number.isSafeInteger(prefix.frame) && prefix.frame >= prefix.startFrame &&
prefix.advancedFrames === prefix.frame - prefix.startFrame) {
error.execution.confirmedPrefix = prefix;
}
}
request.reject(error);
}
}
else {
request.resolve(response.result);
}
}
async #request(method, params) {
const artifactOnly = ["recording_status", "recording_export", "recording_review", "clip"].includes(method) && typeof params.recordingPath === "string";
if (method !== "open" && !artifactOnly && this.#worker === undefined) {
throw new EmulatorError("No emulator session is running; run a ROM first");
}
const worker = this.#startWorker();
const id = this.#nextRequestId++;
const result = await new Promise((resolveRequest, rejectRequest) => {
const timeout = setTimeout(() => {
const error = new EmulatorError(`The emulator ${method} request timed out`);
this.#failWorker(worker, error);
void this.#terminateWorker(worker);
}, this.#requestTimeoutMs);
timeout.unref();
this.#pending.set(id, {
resolve: resolveRequest,
reject: rejectRequest,
timeout,
});
worker.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
if (error !== null && error !== undefined) {
const failure = new EmulatorError(`Could not contact the PyBoy emulator: ${error.message}`, {
cause: error,
});
this.#failWorker(worker, failure);
void this.#terminateWorker(worker);
}
});
});
return result;
}
}
//# sourceMappingURL=emulator.js.mapSHA-256: 530decfc0b6797c21e93a19afce367320e41592fe6678b30390f89c090e34bac