← Files ModRetro Chromatic PluginARCHIVED FILE
dist/build.js
45.3 KB · Oct 2, 2026 · 00:37 UTC
import { execFile, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import fs from "node:fs/promises";
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { detectDependencies, probeDependencies } from "../scripts/dependency-doctor.mjs";
export { detectDependencies, probeDependencies } from "../scripts/dependency-doctor.mjs";
import { findGbStudioCli, resolveToolchainRoot } from "./compiler-selection.js";
import { hashDebugRom, parseGlobalsDefinitions, parseNoiDefinitions } from "./debug-symbols.js";
import { captureBuildSourceProvenance, rememberAuthenticatedSourceBuild, sameBuildSourceObservation, verifyBuildDebugAbi, } from "./build-provenance.js";
import { assertSafePlatformPath, isPathWithinRoot, readPlatformEnvironmentVariable, runtimeExecutablePaths, sanitizeSubprocessEnvironment, verifyWindowsPrivatePath, } from "./platform.js";
const execFileAsync = promisify(execFile);
const MAX_TIMEOUT_MS = 600_000;
const DEFAULT_TIMEOUT_MS = 120_000;
const HEADER_MINIMUM_BYTES = 0x150;
const MAX_ROM_BYTES = 8 * 1024 * 1024;
const NINTENDO_LOGO = Buffer.from(("ce ed 66 66 cc 0d 00 0b 03 73 00 83 00 0c 00 0d " +
"00 08 11 1f 88 89 00 0e dc cc 6e e6 dd dd d9 99 " +
"bb bb 67 63 6e 0e ec cc dd dc 99 9f bb b9 33 3e").replace(/\s+/g, ""), "hex");
const CARTRIDGE_TYPES = {
0x00: { name: "ROM ONLY", mapper: "none" },
0x01: { name: "MBC1", mapper: "MBC1" },
0x02: { name: "MBC1+RAM", mapper: "MBC1" },
0x03: { name: "MBC1+RAM+BATTERY", mapper: "MBC1" },
0x05: { name: "MBC2", mapper: "MBC2" },
0x06: { name: "MBC2+BATTERY", mapper: "MBC2" },
0x08: { name: "ROM+RAM", mapper: "none" },
0x09: { name: "ROM+RAM+BATTERY", mapper: "none" },
0x0b: { name: "MMM01", mapper: "MMM01" },
0x0c: { name: "MMM01+RAM", mapper: "MMM01" },
0x0d: { name: "MMM01+RAM+BATTERY", mapper: "MMM01" },
0x0f: { name: "MBC3+TIMER+BATTERY", mapper: "MBC3" },
0x10: { name: "MBC3+TIMER+RAM+BATTERY", mapper: "MBC3" },
0x11: { name: "MBC3", mapper: "MBC3" },
0x12: { name: "MBC3+RAM", mapper: "MBC3" },
0x13: { name: "MBC3+RAM+BATTERY", mapper: "MBC3" },
0x19: { name: "MBC5", mapper: "MBC5" },
0x1a: { name: "MBC5+RAM", mapper: "MBC5" },
0x1b: { name: "MBC5+RAM+BATTERY", mapper: "MBC5" },
0x1c: { name: "MBC5+RUMBLE", mapper: "MBC5" },
0x1d: { name: "MBC5+RUMBLE+RAM", mapper: "MBC5" },
0x1e: { name: "MBC5+RUMBLE+RAM+BATTERY", mapper: "MBC5" },
0x20: { name: "MBC6", mapper: "MBC6" },
0x22: { name: "MBC7+SENSOR+RUMBLE+RAM+BATTERY", mapper: "MBC7" },
0xfc: { name: "POCKET CAMERA", mapper: "camera" },
0xfd: { name: "BANDAI TAMA5", mapper: "TAMA5" },
0xfe: { name: "HuC3", mapper: "HuC3" },
0xff: { name: "HuC1+RAM+BATTERY", mapper: "HuC1" },
};
const RAM_SIZES = {
0x00: 0,
0x01: 2 * 1024,
0x02: 8 * 1024,
0x03: 32 * 1024,
0x04: 128 * 1024,
0x05: 64 * 1024,
};
function isInside(root, candidate) {
return isPathWithinRoot(root, candidate);
}
function samePlatformPath(first, second) {
if (process.platform !== "win32") {
return first === second;
}
return isPathWithinRoot(first, second, "win32") && isPathWithinRoot(second, first, "win32");
}
function configuredEnvironmentValue(name) {
return readPlatformEnvironmentVariable(process.env, name);
}
async function resolveExistingWithinRoot(root, input) {
assertSafePlatformPath(input);
const absolute = path.resolve(root, input);
if (!isInside(root, absolute)) {
throw new Error(`Path escapes the project root: ${input}`);
}
const canonical = await realpath(absolute);
if (!isInside(root, canonical)) {
throw new Error(`Path resolves outside the project root: ${input}`);
}
return canonical;
}
async function resolveOutputWithinRoot(root, input) {
assertSafePlatformPath(input);
const absolute = path.resolve(root, input);
if (!isInside(root, absolute)) {
throw new Error(`Output path escapes the project root: ${input}`);
}
let ancestor = absolute;
while (true) {
try {
const canonicalAncestor = await realpath(ancestor);
if (!isInside(root, canonicalAncestor)) {
throw new Error(`Output path resolves outside the project root: ${input}`);
}
return absolute;
}
catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
const parent = path.dirname(ancestor);
if (parent === ancestor || !isInside(root, parent)) {
throw new Error(`Output path escapes the project root: ${input}`);
}
ancestor = parent;
}
}
}
async function exists(candidate) {
try {
await access(candidate);
return true;
}
catch {
return false;
}
}
function isolatedCompilerEnvironment(temporaryDirectory) {
return sanitizeSubprocessEnvironment(process.env, process.platform, temporaryDirectory, {
preserveCertificateAuthority: true,
});
}
/** Passive metadata readiness unless the caller explicitly names health probes. */
export async function doctor(projectRoot, options = {}) {
options.signal?.throwIfAborted();
const root = await realpath(path.resolve(projectRoot));
const report = Object.assign(await detectDependencies(options), { projectRoot: root });
options.signal?.throwIfAborted();
return options.probes?.length ? probeDependencies(report, { probes: options.probes, signal: options.signal }) : report;
}
function romSizeFromCode(code) {
if (code >= 0x00 && code <= 0x08) {
return 32 * 1024 * 2 ** code;
}
return { 0x52: 72 * 16 * 1024, 0x53: 80 * 16 * 1024, 0x54: 96 * 16 * 1024 }[code] ?? null;
}
/** Read and validate the bytes the Game Boy boot ROM actually checks. */
export async function inspectRom(romPath, projectRoot) {
let absolute;
if (projectRoot) {
const root = await realpath(path.resolve(projectRoot));
absolute = await resolveExistingWithinRoot(root, romPath);
}
else {
absolute = await realpath(path.resolve(romPath));
}
const before = await fs.lstat(absolute);
if (!before.isFile() || before.size > MAX_ROM_BYTES) {
throw new Error("ROM must be a regular file no larger than 8 MiB");
}
const sameFile = (a, b) => ["dev", "ino", "size", "mode", "nlink", "uid", "gid", "mtimeMs", "ctimeMs"].every((key) => a[key] === b[key]);
const handle = await fs.open(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
let bytes;
try {
const opened = await handle.stat();
if (!opened.isFile() || !sameFile(before, opened))
throw new Error("ROM changed while opening");
bytes = Buffer.alloc(before.size);
let offset = 0;
while (offset < bytes.length) {
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
if (read.bytesRead === 0)
throw new Error("ROM changed while reading");
offset += read.bytesRead;
}
const extra = await handle.read(Buffer.alloc(1), 0, 1, offset);
if (extra.bytesRead !== 0 || !sameFile(opened, await handle.stat())
|| !sameFile(opened, await fs.lstat(absolute)) || await realpath(absolute) !== absolute) {
throw new Error("ROM changed while reading");
}
}
finally {
await handle.close();
}
if (bytes.length < HEADER_MINIMUM_BYTES) {
throw new Error(`ROM is too small to contain a complete Game Boy cartridge header: ${bytes.length} bytes`);
}
const colorFlag = bytes[0x143];
const colorMode = colorFlag === 0xc0 ? "gbc-only" : colorFlag === 0x80 ? "gbc" : "dmg";
const titleEnd = colorFlag === 0xc0 || colorFlag === 0x80 ? 0x143 : 0x144;
const title = bytes.subarray(0x134, titleEnd).toString("ascii").replace(/\0.*$/, "");
const cartridgeType = bytes[0x147];
const cartridge = CARTRIDGE_TYPES[cartridgeType] ?? {
name: `UNKNOWN (0x${cartridgeType.toString(16).padStart(2, "0")})`,
mapper: "unknown",
};
const romSizeCode = bytes[0x148];
const ramSizeCode = bytes[0x149];
const headerChecksum = bytes[0x14d];
let computedHeaderChecksum = 0;
for (let index = 0x134; index <= 0x14c; index += 1) {
computedHeaderChecksum = (computedHeaderChecksum - bytes[index] - 1) & 0xff;
}
const nintendoLogoValid = bytes.subarray(0x104, 0x134).equals(NINTENDO_LOGO);
const headerChecksumValid = headerChecksum === computedHeaderChecksum;
return {
path: absolute,
sizeBytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
title,
colorMode,
colorFlag,
cartridgeType,
cartridgeTypeName: cartridge.name,
mapper: cartridge.mapper,
romSizeCode,
declaredRomSizeBytes: romSizeFromCode(romSizeCode),
ramSizeCode,
declaredRamSizeBytes: RAM_SIZES[ramSizeCode] ?? null,
nintendoLogoValid,
headerChecksum,
computedHeaderChecksum,
headerChecksumValid,
valid: nintendoLogoValid && headerChecksumValid,
};
}
function readCommandFailure(error) {
if (!(error instanceof Error)) {
return { exitCode: null, stdout: "", stderr: String(error), compilerStderr: "" };
}
const processError = error;
const compilerStderr = processError.stderr?.toString() ?? "";
return {
exitCode: typeof processError.code === "number" ? processError.code : null,
stdout: processError.stdout?.toString() ?? "",
stderr: compilerStderr || processError.message,
compilerStderr,
};
}
function classifyBuildWarnings(builder, stderr) {
if (builder !== "gb-studio-cli") {
return [];
}
const warningPattern = /^(?:\(node:\d+\)\s+)?\[DEP0190\]\s+DeprecationWarning:\s*(.+)$/gm;
return Array.from(stderr.matchAll(warningPattern), ([, message]) => ({
code: "DEP0190",
category: "node-deprecation",
source: "gb-studio-cli-process",
message: message,
}));
}
/** Add a small actionable index without replacing the original process log. */
export function summarizeBuildDiagnostics(input) {
const maximum = 16;
const diagnostics = [];
const seen = new Set();
let diagnosticsTruncated = false;
const add = (diagnostic) => {
const message = diagnostic.message.trim().slice(0, 500);
const key = `${diagnostic.code}\0${message}`;
if (seen.has(key))
return;
seen.add(key);
if (diagnostics.length === maximum) {
diagnosticsTruncated = true;
return;
}
diagnostics.push({ ...diagnostic, message });
};
if (input.cancelled)
add({ severity: "error", code: "BUILD_CANCELLED", message: "The build was cancelled.", suggestedAction: "The cancelled attempt cannot authenticate new debug artifacts. Rerun the build when ready." });
else if (input.timedOut)
add({ severity: "error", code: "BUILD_TIMEOUT", message: "The compiler exceeded the requested time limit.", suggestedAction: "Inspect the retained compiler output, fix any reported source error, then retry with a bounded larger timeoutMs if needed." });
if (input.outputLimit)
add({ severity: "error", code: "BUILD_OUTPUT_LIMIT", message: "Compiler output exceeded its 8 MiB per-stream limit and was truncated.", suggestedAction: "Inspect the retained output prefix and fix a repeatedly emitted compiler error or overly verbose project plugin before retrying." });
for (const raw of `${input.stderr}\n${input.stdout}`.split(/\r?\n/)) {
const line = raw.trim();
if (!line || /\[DEP0190\]\s+DeprecationWarning:/.test(line))
continue;
let code;
let suggestedAction = "";
if (/compiler process cleanup could not be confirmed/i.test(line)) {
code = "BUILD_CLEANUP_UNCONFIRMED";
suggestedAction = "Inspect the reported compiler process before starting another build. No successful build or debug receipt is claimed.";
}
else if (/source changed.*build|source.*identity.*changed|source-debug build provenance/i.test(line)) {
code = "SOURCE_CHANGED_DURING_BUILD";
suggestedAction = "Finish or pause external edits, then rebuild with captureDebugArtifacts:true. Do not use these sidecars for source-aware debugging.";
}
else if (/same-build|debug artifact|linker \.noi|globals\.i/i.test(line) && /unavailable|missing|requires|must|mismatch|does not match|invalid|malformed|outside|refuses/i.test(line)) {
code = "DEBUG_ARTIFACTS_UNAVAILABLE";
suggestedAction = "Run the supported official game compiler CLI with captureDebugArtifacts:true again. ROM-only play does not require source-debug artifacts.";
}
else if (/(?:missing|cannot find|could not find|not found|ENOENT|no such file).*(?:asset|background|sprite|font|music|sound|\.png|\.gbsres)|(?:asset|background|sprite|font|music|sound).*(?:missing|not found)/i.test(line)) {
code = "MISSING_AUTHORED_RESOURCE";
suggestedAction = "Use project_inventory or world_dependencies to find the referenced resource, then restore its source file or fix the authored reference.";
}
else if (/too many.*(?:tile|sprite)|(?:tile|sprite).*(?:budget|limit|maximum).*exceed|exceed.*(?:tile|sprite).*(?:budget|limit|maximum)/i.test(line)) {
code = "GRAPHICS_BUDGET_EXCEEDED";
suggestedAction = "Run graphics_analyze_scene for the affected scene and reduce the reported background, sprite, palette, or OAM usage.";
}
else if (/too many.*variables|(?:VM_HEAP_SIZE|MAX_GLOBAL_VARS).*(?:exceed|error|too|unable)|not enough.*(?:variable|heap)/i.test(line)) {
code = "VARIABLE_BUDGET_EXCEEDED";
suggestedAction = "Inspect authored variable references and shared scripts; reuse or remove unnecessary variables before rebuilding.";
}
else if (/(?:ROM|bank|area|region).*(?:overflow|does not fit|out of space)|(?:overflow|not enough space).*(?:ROM|bank|area)/i.test(line)) {
code = "ROM_CAPACITY_EXCEEDED";
suggestedAction = "Inspect the named linker bank or generated resource and reduce its data size, or choose a supported larger cartridge setting.";
}
else if (/error in scene\s+['"]/i.test(line)) {
code = "SCENE_COMPILATION_FAILED";
suggestedAction = "Find the exact scene symbol in project_inventory and inspect its actors, triggers, and authored event references with world_dependencies.";
}
else if (/\b(?:fatal error|error(?:\s+\d+)?\s*[:#]|undefined symbol|unresolved symbol|syntax error)(?:\s|$)/i.test(line)) {
code = "COMPILER_ERROR";
suggestedAction = "Inspect the named source or generated symbol and the surrounding retained compiler output; correct the authored event, asset, or project plugin, then rebuild.";
}
if (!code)
continue;
const location = /(?:^|[\s'"(])((?:project|assets|plugins)[/\\][^\r\n'"()]*?\.(?:gbsres|png|c|h|s|js|ts))(?:[:(](\d+))?/i.exec(line);
const resourcePath = location?.[1]?.replaceAll("\\", "/");
const lineNumber = location?.[2] ? Number(location[2]) : undefined;
add({ severity: input.success ? "warning" : "error", code, message: line, suggestedAction,
...(resourcePath && resourcePath.length <= 1024 ? { resourcePath,
...(lineNumber !== undefined && Number.isSafeInteger(lineNumber) && lineNumber > 0 ? { line: lineNumber } : {}) } : {}) });
}
if (!input.success && !diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
add({ severity: "error", code: "BUILD_FAILED", message: input.exitCode === 0 ? "The compiler did not produce a valid requested ROM or required debug artifacts." : `The compiler failed${input.exitCode === null ? "" : ` with exit code ${input.exitCode}`}.`, suggestedAction: "Read the retained stdout/stderr, run toolchain_doctor, and fix the first reported error before rebuilding." });
}
return { diagnostics, diagnosticsTruncated };
}
function processRunning(child) {
return child.pid !== undefined && child.exitCode === null && child.signalCode === null;
}
async function waitForProcessExit(child, timeoutMs) {
if (!processRunning(child))
return true;
return new Promise((resolve) => {
const finish = (exited) => {
clearTimeout(timer);
child.off("exit", exitedListener);
resolve(exited);
};
const exitedListener = () => finish(true);
const timer = setTimeout(() => finish(!processRunning(child)), timeoutMs);
child.once("exit", exitedListener);
if (!processRunning(child))
finish(true);
});
}
function processGroupExists(pid) {
try {
process.kill(-pid, 0);
return true;
}
catch (error) {
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
}
}
async function waitForProcessGroupExit(pid, timeoutMs) {
const deadline = performance.now() + timeoutMs;
while (processGroupExists(pid)) {
if (performance.now() >= deadline)
return false;
await delay(25);
}
return true;
}
/** Terminate only the compiler process group/tree created for this invocation. */
async function terminateBuildCommand(child, environment) {
const pid = child.pid;
if (pid === undefined)
return undefined;
if (process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM");
}
catch (error) {
if (error instanceof Error && "code" in error && error.code === "ESRCH")
return undefined;
child.kill("SIGTERM");
}
if (!await waitForProcessGroupExit(pid, 750)) {
try {
process.kill(-pid, "SIGKILL");
}
catch { }
if (!await waitForProcessGroupExit(pid, 1_000)) {
return `Compiler process cleanup could not be confirmed for process group ${pid}.`;
}
}
if (!await waitForProcessExit(child, 1_000))
return `Compiler process cleanup could not be confirmed for process ${pid}.`;
return undefined;
}
// Windows has no negative-PID process groups. Use the trusted OS executable,
// before killing the parent, so taskkill can still enumerate its descendants.
let treeTerminated = false;
try {
const systemRoot = readPlatformEnvironmentVariable(environment, "SystemRoot", "win32");
if (!systemRoot)
throw new Error("No trusted Windows SystemRoot is available");
const canonicalRoot = await realpath(systemRoot);
const taskkill = path.join(canonicalRoot, "System32", "taskkill.exe");
const metadata = await lstat(taskkill);
if (!metadata.isFile() || metadata.isSymbolicLink() || !samePlatformPath(taskkill, await realpath(taskkill))) {
throw new Error("The Windows process-tree terminator is not a real system executable");
}
await execFileAsync(taskkill, ["/PID", String(pid), "/T", "/F"], {
cwd: canonicalRoot, env: environment, windowsHide: true, encoding: "utf8", timeout: 5_000, maxBuffer: 64 * 1024,
});
treeTerminated = true;
}
catch {
if (processRunning(child))
child.kill("SIGKILL");
}
const exited = await waitForProcessExit(child, 1_000);
return treeTerminated && exited ? undefined : `Compiler process cleanup could not be confirmed for Windows process tree ${pid}.`;
}
export async function runBuildCommand(command, args, options) {
if (options.signal?.aborted) {
throw Object.assign(new Error("The build was cancelled before the compiler started."), {
name: "AbortError", code: "ABORT_ERR", stdout: "", stderr: "", buildCancelled: true,
});
}
if (options.evidence)
options.evidence.startedAt = new Date().toISOString();
const child = spawn(command, args, {
cwd: options.cwd, env: options.environment, windowsHide: true,
detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
});
const evidence = options.evidence;
if (evidence) {
if (child.pid !== undefined)
evidence.pid = child.pid;
child.once("spawn", () => { evidence.spawnedAt = new Date().toISOString(); });
child.once("exit", (code, signal) => { evidence.exit = { at: new Date().toISOString(), code, signal }; });
}
let reason;
let stopping;
const maximum = 8 * 1024 * 1024;
const stdout = [];
const stderr = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let outputLimitReached = false;
let processError;
let executionSettled = false;
let closed = false;
let forcedSettlement = false;
let resolveClosed;
const childClosed = new Promise((resolve) => { resolveClosed = resolve; });
let resolveExecution;
let rejectExecution;
const execution = new Promise((resolve, reject) => {
resolveExecution = resolve;
rejectExecution = reject;
});
const finishExecution = (exitCode, signal, warning) => {
if (executionSettled)
return;
executionSettled = true;
const output = { stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") };
if (!warning && !processError && exitCode === 0 && signal === null) {
resolveExecution(output);
return;
}
const failure = processError ?? Object.assign(new Error(warning ? "The compiler did not complete its bounded shutdown." : `The compiler ${signal ? `was terminated by ${signal}` : `exited with status ${exitCode ?? "unknown"}`}.`), { code: exitCode ?? undefined });
Object.assign(failure, output, warning ? { buildCleanupWarning: warning } : {});
rejectExecution(failure);
};
const terminate = () => terminateBuildCommand(child, options.environment).catch((error) => `Compiler process cleanup could not be confirmed for process ${child.pid ?? "unknown"}: ${error instanceof Error ? error.message.slice(0, 200) : "termination failed"}.`);
const stop = (requested) => {
reason ??= requested;
stopping ??= (async () => {
let warning = await terminate();
// Descendants can inherit these pipes after escaping the process group.
// Never let a missing ChildProcess 'close' event defeat the time limit.
if (!closed)
await Promise.race([childClosed, delay(100)]);
if (!closed && !executionSettled) {
warning ??= `Compiler process cleanup could not be confirmed for process ${child.pid ?? "unknown"}; inherited output pipes remained open.`;
forcedSettlement = true;
finishExecution(child.exitCode, child.signalCode, warning);
child.stdout.destroy();
child.stderr.destroy();
child.unref();
}
return warning;
})();
};
const collect = (stream, chunk) => {
if (executionSettled)
return;
const used = stream === "stdout" ? stdoutBytes : stderrBytes;
const remaining = Math.max(0, maximum - used);
if (remaining > 0)
(stream === "stdout" ? stdout : stderr).push(chunk.subarray(0, remaining));
if (stream === "stdout")
stdoutBytes += Math.min(chunk.length, remaining);
else
stderrBytes += Math.min(chunk.length, remaining);
if (chunk.length > remaining && !outputLimitReached) {
outputLimitReached = true;
processError ??= Object.assign(new Error(`Compiler ${stream} exceeded its bounded output limit.`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" });
stop("output-limit");
}
};
const onStdout = (chunk) => collect("stdout", chunk);
const onStderr = (chunk) => collect("stderr", chunk);
const onError = (error) => {
processError ??= error;
if (evidence)
evidence.error = { name: error.name, message: error.message, ...(error.code === undefined ? {} : { code: error.code }) };
};
const onClose = (exitCode, signal) => {
closed = true;
if (evidence)
evidence.close = { at: new Date().toISOString(), code: exitCode, signal };
resolveClosed();
finishExecution(exitCode, signal);
};
child.stdout.on("data", onStdout);
child.stderr.on("data", onStderr);
child.on("error", onError);
child.once("close", onClose);
const cancel = () => stop("cancelled");
const timer = setTimeout(() => stop("timeout"), options.timeoutMs);
timer.unref();
options.signal?.addEventListener("abort", cancel, { once: true });
if (options.signal?.aborted)
cancel();
try {
const result = await execution;
if (reason === undefined)
return result;
throw Object.assign(new Error(reason === "cancelled" ? "The build was cancelled." : "The compiler exceeded the requested time limit."), result);
}
catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
// Also clean up descendants after an unexpected CLI exit or output overflow.
const warning = await (stopping ?? terminate());
if (reason === "cancelled")
failure.buildCancelled = true;
if (reason === "timeout")
failure.buildTimedOut = true;
if (outputLimitReached)
failure.buildOutputLimit = true;
if (warning)
failure.buildCleanupWarning = warning;
if (evidence && warning)
evidence.cleanupWarning = warning;
throw failure;
}
finally {
if (evidence) {
evidence.stdoutBytes = stdoutBytes;
evidence.stderrBytes = stderrBytes;
evidence.outputTruncated = outputLimitReached;
if (reason)
evidence.stopReason = reason;
}
clearTimeout(timer);
options.signal?.removeEventListener("abort", cancel);
child.stdout.off("data", onStdout);
child.stderr.off("data", onStderr);
if (forcedSettlement) {
child.off("close", onClose);
child.off("error", onError);
// The unref'd process may still emit an error after cleanup was reported.
child.on("error", () => { });
}
}
}
async function trustedTemporaryParent(candidate, projectRoot) {
try {
assertSafePlatformPath(candidate);
const absolute = path.resolve(candidate);
const requested = await lstat(absolute);
// macOS intentionally links /tmp to /private/tmp; other redirected roots
// can place compiler intermediates in attacker-controlled locations.
if (requested.isSymbolicLink() && (process.platform === "win32" || absolute !== path.resolve("/tmp")))
return null;
const canonical = await realpath(absolute);
if (isInside(projectRoot, canonical))
return null;
const metadata = await lstat(canonical);
if (metadata.isSymbolicLink() || !metadata.isDirectory())
return null;
if (process.platform === "win32") {
return await verifyWindowsPrivatePath(canonical, { purpose: "temporary", platform: "win32" })
? canonical
: null;
}
const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined;
const privateOwner = currentUid !== undefined && metadata.uid === currentUid && (metadata.mode & 0o022) === 0;
const stickySystem = (metadata.mode & 0o1000) !== 0 &&
(metadata.uid === 0 || (currentUid !== undefined && metadata.uid === currentUid));
return privateOwner || stickySystem ? canonical : null;
}
catch {
return null;
}
}
async function selectTrustedTemporaryParent(projectRoot) {
const considered = new Set();
const localApplicationData = process.platform === "win32" ? configuredEnvironmentValue("LOCALAPPDATA") : undefined;
const candidates = process.platform === "win32"
? [...(localApplicationData ? [path.join(localApplicationData, "Temp")] : []), os.tmpdir()]
: [os.tmpdir(), "/tmp"];
for (const candidate of candidates) {
const absolute = path.resolve(candidate);
const deduplicated = process.platform === "win32" ? absolute.toLowerCase() : absolute;
if (considered.has(deduplicated))
continue;
considered.add(deduplicated);
const trusted = await trustedTemporaryParent(absolute, projectRoot);
if (trusted !== null)
return trusted;
}
throw new Error("game compiler could not find a trusted temporary directory outside the selected project.");
}
async function createIsolatedBuildDirectory(projectRoot) {
// GBDK's linker silently fails with the long absolute paths common in nested projects.
// A short, trusted OS parent preserves real compilation while mkdtemp isolates runs.
const temporaryParent = await selectTrustedTemporaryParent(projectRoot);
const temporaryDirectory = await mkdtemp(path.join(temporaryParent, "gbs-"));
try {
const existing = await lstat(temporaryDirectory);
if (existing.isSymbolicLink() || !existing.isDirectory() ||
!samePlatformPath(await realpath(temporaryDirectory), temporaryDirectory) ||
!samePlatformPath(path.dirname(temporaryDirectory), temporaryParent) || isInside(projectRoot, temporaryDirectory)) {
throw new Error(`game-build temporary build directory is not a secure owned system directory: ${temporaryDirectory}`);
}
return temporaryDirectory;
}
catch (error) {
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => { });
throw error;
}
}
async function removeIsolatedBuildDirectory(directory, projectRoot) {
const expectedParent = path.dirname(directory);
const trustedParent = await trustedTemporaryParent(expectedParent, projectRoot);
if (trustedParent === null || !samePlatformPath(trustedParent, expectedParent) ||
!/^gbs-[A-Za-z0-9]{6}$/.test(path.basename(directory))) {
throw new Error(`Refusing to remove an unexpected game-build temporary directory: ${directory}`);
}
const existing = await lstat(directory);
if (existing.isSymbolicLink() || !existing.isDirectory() || !samePlatformPath(await realpath(directory), directory)) {
throw new Error(`Refusing to remove a relocated game-build temporary directory: ${directory}`);
}
await rm(directory, { recursive: true, force: true });
}
async function secureInternalBuildFile(directory, filename) {
const candidate = path.join(directory, filename);
let metadata;
try {
metadata = await lstat(candidate);
}
catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
throw new Error(`Required same-build debug artifact is unavailable: ${filename}`);
}
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isFile() || !samePlatformPath(await realpath(candidate), candidate)) {
throw new Error(`Same-build debug artifact must be a real internal file: ${filename}`);
}
return readFile(candidate);
}
async function secureDebugArtifactDirectory(root, outputPath) {
const directory = await resolveOutputWithinRoot(root, `${outputPath}.debug`);
try {
await mkdir(directory, { mode: 0o700 });
}
catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST")
throw error;
}
const metadata = await lstat(directory);
if (metadata.isSymbolicLink() || !metadata.isDirectory() || !samePlatformPath(await realpath(directory), directory)) {
throw new Error(`Debug artifact directory must be a real project-local directory: ${directory}`);
}
return directory;
}
async function assertSafeDebugArtifactDestination(destination) {
try {
const metadata = await lstat(destination);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error(`Debug artifact destination must not be a symbolic link or non-file: ${destination}`);
}
}
catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
throw error;
}
}
async function captureSameBuildDebugArtifacts(root, temporaryDirectory, outputPath) {
const directory = path.join(temporaryDirectory, "_gbsbuild", "build", "rom");
let metadata;
try {
metadata = await lstat(directory);
}
catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
throw new Error("Official game compiler CLI did not preserve its same-build internal ROM directory");
}
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory() || !samePlatformPath(await realpath(directory), directory)) {
throw new Error("Official game compiler internal ROM directory must be a real isolated directory");
}
const candidates = (await readdir(directory, { withFileTypes: true })).filter((entry) => /\.(?:gb|gbc)$/i.test(entry.name));
if (candidates.length !== 1 || !candidates[0].isFile()) {
throw new Error("Official game compiler debug capture requires exactly one regular same-build internal ROM");
}
const internalRomFilename = candidates[0].name;
const stem = path.basename(internalRomFilename, path.extname(internalRomFilename));
const [internalRom, finalRom, noi, globals] = await Promise.all([
secureInternalBuildFile(directory, internalRomFilename),
readFile(outputPath),
secureInternalBuildFile(directory, `${stem}.noi`),
secureInternalBuildFile(directory, "globals.i"),
]);
const romSha256 = hashDebugRom(internalRom);
if (romSha256 !== hashDebugRom(finalRom)) {
throw new Error("Same-build internal ROM SHA-256 does not match the final output ROM");
}
parseNoiDefinitions(noi.toString("utf8"));
parseGlobalsDefinitions(globals.toString("utf8"));
const artifactDirectory = await secureDebugArtifactDirectory(root, outputPath);
const noiPath = path.join(artifactDirectory, "symbols.noi");
const globalsPath = path.join(artifactDirectory, "globals.i");
await Promise.all([assertSafeDebugArtifactDestination(noiPath), assertSafeDebugArtifactDestination(globalsPath)]);
const stagingDirectory = await mkdtemp(path.join(artifactDirectory, ".capture-"));
try {
const stagedNoi = path.join(stagingDirectory, "symbols.noi");
const stagedGlobals = path.join(stagingDirectory, "globals.i");
await Promise.all([
writeFile(stagedNoi, noi, { flag: "wx", mode: 0o600 }),
writeFile(stagedGlobals, globals, { flag: "wx", mode: 0o600 }),
]);
await Promise.all([rename(stagedNoi, noiPath), rename(stagedGlobals, globalsPath)]);
}
finally {
await rm(stagingDirectory, { recursive: true, force: true });
}
let sourceDebugAbi;
let sourceDebugUnavailableReason;
try {
const include = path.join(temporaryDirectory, "_gbsbuild", "include");
const [bankdata, dataManager, vm] = await Promise.all([
secureInternalBuildFile(include, "bankdata.h"),
secureInternalBuildFile(include, "data_manager.h"),
secureInternalBuildFile(include, "vm.h"),
]);
sourceDebugAbi = verifyBuildDebugAbi({ bankdata, dataManager, vm });
if (!sourceDebugAbi)
sourceDebugUnavailableReason = "The same-build engine headers do not match a supported scene-pointer and global-variable layout.";
}
catch {
sourceDebugUnavailableReason = "The official build did not preserve the engine headers needed to authenticate source-debug memory layouts.";
}
return {
directory: artifactDirectory,
noiPath,
globalsPath,
romSha256,
noiSha256: hashDebugRom(noi),
globalsSha256: hashDebugRom(globals),
internalRomFilename,
...(sourceDebugAbi ? { sourceDebugAbi } : { sourceDebugUnavailableReason }),
};
}
/** Compile an existing bounded C source or an actual native game project, without a shell. */
export async function buildRom(options) {
const root = await realpath(path.resolve(options.projectRoot));
if (options.projectPath && options.sourcePath) {
throw new Error("Provide either projectPath or sourcePath, not both.");
}
if (!options.projectPath && !options.sourcePath) {
throw new Error("A .gbsproj projectPath or a C sourcePath is required.");
}
if (options.captureDebugArtifacts !== undefined && typeof options.captureDebugArtifacts !== "boolean") {
throw new Error("captureDebugArtifacts must be a boolean");
}
if (options.captureDebugArtifacts && !options.projectPath) {
throw new Error("Debug artifact capture requires an official game compiler .gbsproj build");
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > MAX_TIMEOUT_MS) {
throw new Error(`timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}.`);
}
const colorMode = options.colorMode ?? "dmg";
if (colorMode !== "dmg" && colorMode !== "gbc" && colorMode !== "gbc-only") {
throw new Error(`Unsupported Game Boy color mode: ${String(colorMode)}`);
}
const source = await resolveExistingWithinRoot(root, options.projectPath ?? options.sourcePath);
const expectedInputExtension = options.projectPath ? ".gbsproj" : ".c";
if (path.extname(source).toLowerCase() !== expectedInputExtension) {
throw new Error(`Input must be a ${expectedInputExtension} file.`);
}
if (!(await stat(source)).isFile()) {
throw new Error(`Input is not a regular file: ${source}`);
}
const defaultExtension = colorMode === "dmg" ? ".gb" : ".gbc";
const defaultOutput = path.join("build", `${path.basename(source, expectedInputExtension)}${defaultExtension}`);
const outputPath = await resolveOutputWithinRoot(root, options.outputPath ?? defaultOutput);
if (![".gb", ".gbc"].includes(path.extname(outputPath).toLowerCase())) {
throw new Error("ROM output must have a .gb or .gbc extension.");
}
if (samePlatformPath(source, outputPath)) {
throw new Error("ROM output cannot overwrite its input.");
}
let command;
let args;
let builder;
const toolchainRoot = await resolveToolchainRoot(root, options.toolchainRoot);
if (options.projectPath) {
const cli = await findGbStudioCli(toolchainRoot);
if (!cli) {
throw new Error('The official GB Studio CLI is unavailable in the selected toolchain. Use the packaged setup skill to prepare runtime,build and bind the resulting toolchain before building. toolchain_doctor with tasks:["projectBuild"] checks readiness; it does not install dependencies.');
}
command = process.execPath;
// The official CLI otherwise suppresses compiler and linker diagnostics.
args = [cli, "make:rom", source, outputPath, "--verbose"];
builder = "gb-studio-cli";
}
else {
const compiler = runtimeExecutablePaths(toolchainRoot).gbdkCompiler;
if (!(await exists(compiler))) {
throw new Error('GBDK-2020 lcc is unavailable in the selected toolchain. Use toolchain_doctor with tasks:["cBuild"] to inspect setup before compiling C source.');
}
command = await resolveExistingWithinRoot(toolchainRoot, compiler);
const colorFlags = colorMode === "gbc-only" ? ["-Wm-yC"] : colorMode === "gbc" ? ["-Wm-yc"] : [];
args = [...colorFlags, "-o", outputPath, source];
builder = "gbdk";
}
await mkdir(path.dirname(outputPath), { recursive: true });
// Recheck after creating directories to avoid traversing a concurrently inserted symlink.
await resolveOutputWithinRoot(root, outputPath);
try {
const existing = await lstat(outputPath);
if (existing.isSymbolicLink()) {
throw new Error(`ROM output must not be a symbolic link: ${outputPath}`);
}
}
catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
const temporaryDirectory = builder === "gb-studio-cli" ? await createIsolatedBuildDirectory(root) : undefined;
const startedAt = performance.now();
let exitCode = 0;
let stdout = "";
let stderr = "";
let compilerStderr = "";
let buildFailed = false;
let sourceProvenance;
try {
try {
if (options.captureDebugArtifacts) {
sourceProvenance = await captureBuildSourceProvenance(source, args[0], options.signal);
}
const result = await runBuildCommand(command, args, {
cwd: root,
timeoutMs,
signal: options.signal,
environment: isolatedCompilerEnvironment(temporaryDirectory),
});
stdout = result.stdout;
stderr = result.stderr;
compilerStderr = result.stderr;
}
catch (error) {
buildFailed = true;
({ exitCode, stdout, stderr, compilerStderr } = readCommandFailure(error));
const failure = error instanceof Error ? error : new Error(String(error));
const cancelled = failure.buildCancelled || options.signal?.aborted === true;
if (failure.buildCleanupWarning)
stderr = [stderr, failure.buildCleanupWarning].filter(Boolean).join("\n");
return {
success: false,
builder,
command,
args,
exitCode,
stdout,
stderr,
...(compilerStderr === stderr ? {} : { compilerStderr }),
...(cancelled ? { cancelled: true } : {}),
...(failure.buildOutputLimit ? { outputTruncated: true } : {}),
warnings: classifyBuildWarnings(builder, stderr),
...summarizeBuildDiagnostics({ stdout, stderr, success: false, exitCode,
timedOut: failure.buildTimedOut, cancelled, outputLimit: failure.buildOutputLimit }),
durationMs: Math.round(performance.now() - startedAt),
outputPath,
rom: null,
};
}
let rom = null;
try {
rom = await inspectRom(outputPath, root);
}
catch (error) {
stderr = [stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join("\n");
}
let debugArtifacts;
buildFailed = rom?.valid !== true;
if (!buildFailed && options.captureDebugArtifacts && temporaryDirectory) {
try {
debugArtifacts = await captureSameBuildDebugArtifacts(root, temporaryDirectory, outputPath);
const after = await captureBuildSourceProvenance(source, args[0], options.signal);
if (!sourceProvenance || !sameBuildSourceObservation(sourceProvenance, after)) {
throw new Error("game compiler source changed during the build; source-debug artifacts are stale. Rebuild after finishing external edits.");
}
debugArtifacts.sourceProvenance = sourceProvenance;
}
catch (error) {
buildFailed = true;
debugArtifacts = undefined;
stderr = [stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join("\n");
}
}
const cancelled = options.signal?.aborted === true;
if (cancelled) {
buildFailed = true;
debugArtifacts = undefined;
stderr = [stderr, "The build was cancelled."].filter(Boolean).join("\n");
}
const result = {
success: !buildFailed,
builder,
command,
args,
exitCode,
stdout,
stderr,
...(compilerStderr === stderr ? {} : { compilerStderr }),
...(cancelled ? { cancelled: true } : {}),
warnings: classifyBuildWarnings(builder, stderr),
...summarizeBuildDiagnostics({ stdout, stderr, success: !buildFailed, exitCode, cancelled }),
durationMs: Math.round(performance.now() - startedAt),
outputPath,
rom,
...(debugArtifacts ? { debugArtifacts } : {}),
};
if (sourceProvenance)
rememberAuthenticatedSourceBuild(result, args[0], sourceProvenance, debugArtifacts?.sourceDebugAbi);
return result;
}
catch (error) {
buildFailed = true;
throw error;
}
finally {
if (temporaryDirectory) {
try {
await removeIsolatedBuildDirectory(temporaryDirectory, root);
}
catch (error) {
if (!buildFailed) {
throw error;
}
}
}
}
}
//# sourceMappingURL=build.js.mapSHA-256: bb7c78bfa44d66efbd072448fbf2e24a60a82f31d322906a32672e94ff34a3dc