← Files ModRetro Chromatic PluginARCHIVED FILE

dist/source-debug.js

20.8 KB · Oct 2, 2026 · 00:37 UTC

↓ Download file

import { constants } from "node:fs";
import { lstat, open, realpath } from "node:fs/promises";
import path from "node:path";
import { authenticatedSourceBuild, captureBuildSourceProvenance, sameBuildSourceProvenance, } from "./build-provenance.js";
import { createDebugManifest, decodeCurrentScene, hashDebugRom, parseGlobalsDefinitions, parseNoiDefinitions, validateDebugManifest, } from "./debug-symbols.js";
import { isPathWithinRoot } from "./platform.js";
import { PROJECT_RESPONSE_LIMITS } from "./project-access.js";
import { loadProjectSnapshot } from "./project-snapshot.js";
import { decodeResourceBytes } from "./resource-codec.js";
import { worldDependencies } from "./world-queries.js";
export const MAX_SOURCE_DEBUG_VARIABLES = 32;
export const MAX_SOURCE_DEBUG_COLLISION_CELLS = 256;
export const MAX_SOURCE_DEBUG_REFERENCES = 64;
const WRAM_BASE = 0xc000;
const SHA256 = /^[a-f0-9]{64}$/;
const SCENE_SYMBOL = /^scene_[A-Za-z0-9_]+$/;
const VARIABLE_SYMBOL = /^var_[A-Za-z0-9_]+$/;
export class SourceDebugError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.code = code;
        this.name = "SourceDebugError";
    }
}
function fail(code, message) {
    throw new SourceDebugError(code, message);
}
function samePath(left, right) {
    return isPathWithinRoot(left, right) && isPathWithinRoot(right, left);
}
function validSelector(value) {
    return typeof value === "string" && value.length > 0 && value.length <= 256 && !value.includes("\0");
}
function variableSummary(variable, reason) {
    return { id: variable.id, variableId: variable.id, name: variable.name,
        ...(typeof variable.symbol === "string" ? { symbol: variable.symbol } : {}), reason };
}
async function authenticatedFile(root, filename, expectedHash) {
    if (!SHA256.test(expectedHash) || !isPathWithinRoot(root, filename)) {
        fail("SOURCE_DEBUG_ARTIFACT_MISMATCH", "The captured debug artifact is outside its selected project or has an invalid digest.");
    }
    let handle;
    try {
        const metadata = await lstat(filename, { bigint: true });
        if (!metadata.isFile() || metadata.isSymbolicLink() || !samePath(await realpath(filename), filename)) {
            fail("SOURCE_DEBUG_ARTIFACT_MISMATCH", "A captured debug artifact was replaced by a symbolic link or non-file.");
        }
        handle = await open(filename, constants.O_RDONLY | (process.platform === "win32" ? 0 : (constants.O_NOFOLLOW ?? 0)));
        const opened = await handle.stat({ bigint: true });
        if (opened.dev !== metadata.dev || opened.ino !== metadata.ino || opened.size !== metadata.size
            || opened.mtimeNs !== metadata.mtimeNs || opened.ctimeNs !== metadata.ctimeNs) {
            fail("SOURCE_DEBUG_ARTIFACT_MISMATCH", "A captured debug artifact changed while it was opened.");
        }
        const bytes = await handle.readFile();
        const after = await handle.stat({ bigint: true });
        if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
            || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) {
            fail("SOURCE_DEBUG_ARTIFACT_MISMATCH", "A captured debug artifact changed while it was read.");
        }
        if (hashDebugRom(bytes) !== expectedHash) {
            fail("SOURCE_DEBUG_ARTIFACT_MISMATCH", "Captured ROM or debug sidecar bytes no longer match this successful build. Rebuild with captureDebugArtifacts:true.");
        }
        return bytes;
    }
    catch (error) {
        if (error instanceof SourceDebugError)
            throw error;
        throw new SourceDebugError("SOURCE_DEBUG_ARTIFACT_MISMATCH", "A captured ROM or debug sidecar is no longer readable. Rebuild with captureDebugArtifacts:true.");
    }
    finally {
        await handle?.close();
    }
}
function sourceScene(scene, symbol) {
    return { id: scene.id, sceneId: scene.id, name: scene.name, symbol, resourcePath: scene.resourcePath,
        type: typeof scene.type === "string" ? scene.type : null,
        width: typeof scene.width === "number" ? scene.width : null,
        height: typeof scene.height === "number" ? scene.height : null };
}
function collisionRegion(scene, region) {
    const width = scene.width;
    const height = scene.height;
    if (typeof width !== "number" || typeof height !== "number" || !Number.isInteger(width) || !Number.isInteger(height)
        || width < 1 || height < 1 || width > 255 || height > 255 || typeof scene.collisions !== "string") {
        fail("SOURCE_DEBUG_COLLISIONS_UNAVAILABLE", "This scene does not have a supported authored collision grid.");
    }
    if (!region || typeof region !== "object" || ![region.x, region.y, region.width, region.height].every(Number.isInteger)
        || region.x < 0 || region.y < 0 || region.width < 1 || region.height < 1
        || region.x + region.width > width || region.y + region.height > height
        || region.width * region.height > MAX_SOURCE_DEBUG_COLLISION_CELLS) {
        fail("SOURCE_DEBUG_INVALID_QUERY", `Collision rectangles must fit inside the current scene and contain at most ${MAX_SOURCE_DEBUG_COLLISION_CELLS} tiles.`);
    }
    const decoded = decodeResourceBytes(scene.collisions, { maximumValues: width * height, allowImplicitZeroGrid: true });
    const cells = [];
    for (let y = region.y; y < region.y + region.height; y++) {
        for (let x = region.x; x < region.x + region.width; x++)
            cells.push(decoded[y * width + x] ?? 0);
    }
    return { source: "authored", sceneId: scene.id, resourcePath: scene.resourcePath, ...region, cells };
}
export class SourceDebugContext {
    identity;
    projectPath;
    #receipt;
    #snapshot;
    #manifest;
    #variables;
    #unavailable;
    #limitations;
    #access;
    constructor(receipt, snapshot, manifest, unavailable, limitations, access) {
        this.#receipt = receipt;
        this.#snapshot = snapshot;
        this.#manifest = manifest;
        this.#variables = new Map((manifest.variables ?? []).map((variable) => [variable.variableId, variable]));
        this.#unavailable = unavailable;
        this.#limitations = limitations;
        this.#access = access;
        this.projectPath = receipt.source.projectPath;
        this.identity = Object.freeze({ romSha256: manifest.romSha256, projectRevision: manifest.projectRevision,
            sourceFingerprint: receipt.source.sourceFingerprint, compilerSha256: receipt.source.compilerSha256,
            engineAbiVersion: manifest.engineAbiVersion });
    }
    static async create(options) {
        const receipt = authenticatedSourceBuild(options.build);
        if (!receipt)
            fail("SOURCE_DEBUG_UNAVAILABLE", "Source debugging requires a successful official captureDebugArtifacts build retained by this MCP session. A supplied ROM or copied manifest is ROM-only.");
        if (!receipt.abi)
            fail("SOURCE_DEBUG_ABI_UNSUPPORTED", receipt.artifacts.sourceDebugUnavailableReason ?? "The same-build engine layout is unsupported for source debugging.");
        if (options.projectPath && !samePath(await realpath(path.resolve(path.dirname(receipt.source.projectPath), options.projectPath)), receipt.source.projectPath)) {
            fail("SOURCE_DEBUG_PROJECT_MISMATCH", "The captured build belongs to a different selected project.");
        }
        if (options.access) {
            if (!samePath(options.access.projectPath, receipt.source.projectPath))
                fail("SOURCE_DEBUG_PROJECT_MISMATCH", "The selected world index belongs to a different project.");
            await options.access.strongRefresh();
        }
        const snapshot = await loadProjectSnapshot(receipt.source.projectPath, {}, options.access);
        if (snapshot.inventory.scenes.length !== snapshot.scenesById.size || snapshot.counts.variables !== snapshot.variablesById.size) {
            fail("SOURCE_DEBUG_SYMBOLS_UNAVAILABLE", "Authored scenes or global variables have missing or duplicate IDs; source-debug mappings would be ambiguous.");
        }
        const current = await captureBuildSourceProvenance(receipt.source.projectPath, receipt.compilerPath);
        if (snapshot.revision !== receipt.source.projectRevision || !sameBuildSourceProvenance(receipt.source, current)) {
            fail("SOURCE_DEBUG_STALE_SOURCE", "Authored sources or project plugins changed after this build. Rebuild before enabling source-aware debugging.");
        }
        const root = path.dirname(receipt.source.projectPath);
        const [rom, noi, globals] = await Promise.all([
            authenticatedFile(root, receipt.outputPath, receipt.artifacts.romSha256),
            authenticatedFile(root, receipt.artifacts.noiPath, receipt.artifacts.noiSha256),
            authenticatedFile(root, receipt.artifacts.globalsPath, receipt.artifacts.globalsSha256),
        ]);
        const definitions = parseNoiDefinitions(noi.toString("utf8"));
        const globalDefinitions = parseGlobalsDefinitions(globals.toString("utf8"));
        const scenes = [];
        let unavailableScenes = 0;
        for (const scene of snapshot.scenesById.values()) {
            if (typeof scene.symbol !== "string" || !SCENE_SYMBOL.test(scene.symbol)
                || !definitions.has(`_${scene.symbol}`) || !definitions.has(`___bank_${scene.symbol}`)) {
                unavailableScenes++;
                continue;
            }
            scenes.push({ id: scene.id, symbol: scene.symbol });
        }
        if (scenes.length === 0)
            fail("SOURCE_DEBUG_SYMBOLS_UNAVAILABLE", "No exact authored scene symbols occur in this build's linker sidecar.");
        const variables = [];
        const unavailable = [];
        for (const variable of snapshot.variablesById.values()) {
            if (typeof variable.symbol !== "string" || !VARIABLE_SYMBOL.test(variable.symbol)) {
                unavailable.push(variableSummary(variable, "No supported exact authored variable symbol."));
            }
            else if (!globalDefinitions.has(variable.symbol.toUpperCase())) {
                unavailable.push(variableSummary(variable, "Not allocated in this build's globals.i; it may be unused or optimized out."));
            }
            else
                variables.push({ id: variable.id, symbol: variable.symbol });
        }
        const engineAbiVersion = `${receipt.abi.layout}:${receipt.abi.sha256}`;
        const manifest = createDebugManifest({ rom, noi: noi.toString("utf8"), globals: globals.toString("utf8"),
            scenes, variables, projectRevision: receipt.source.projectRevision, engineAbiVersion, globalHeapWords: receipt.globalHeapWords });
        validateDebugManifest(manifest, rom, { projectRevision: receipt.source.projectRevision, engineAbiVersion, globalHeapWords: receipt.globalHeapWords });
        const limitations = ["Authored collision grids and event references describe source, not the currently executing VM instruction or dynamically modified collision state."];
        if (unavailableScenes)
            limitations.push(`${unavailableScenes} authored scene(s) have no exact compiled mapping in this build.`);
        if (!options.access)
            limitations.push("Authored event links require the selected project's existing world index.");
        return new SourceDebugContext(receipt, snapshot, manifest, unavailable, limitations, options.access);
    }
    async #validateSource() {
        let current;
        try {
            current = await captureBuildSourceProvenance(this.projectPath, this.#receipt.compilerPath);
        }
        catch {
            fail("SOURCE_DEBUG_STALE_SOURCE", "The source or compiler identity is no longer readable. Rebuild before using source-aware debugging.");
        }
        if (!sameBuildSourceProvenance(this.#receipt.source, current)) {
            fail("SOURCE_DEBUG_STALE_SOURCE", "Authored sources, project plugins, or the compiler changed after this build. Rebuild before using source-aware debugging.");
        }
    }
    /** Called when source mode is explicitly enabled for a worker-authenticated ROM. */
    async assertCompatibleRom(romSha256) {
        if (typeof romSha256 !== "string" || romSha256 !== this.identity.romSha256) {
            fail("SOURCE_DEBUG_ROM_MISMATCH", "The running emulator does not contain the exact ROM bytes from this source-debug build.");
        }
        await this.#validateSource();
        const root = path.dirname(this.projectPath);
        await Promise.all([
            authenticatedFile(root, this.#receipt.outputPath, this.#receipt.artifacts.romSha256),
            authenticatedFile(root, this.#receipt.artifacts.noiPath, this.#receipt.artifacts.noiSha256),
            authenticatedFile(root, this.#receipt.artifacts.globalsPath, this.#receipt.artifacts.globalsSha256),
        ]);
    }
    #selectedVariables(query) {
        const ids = query.variableIds ?? [];
        const names = query.variableNames ?? [];
        if (!Array.isArray(ids) || !Array.isArray(names) || ids.length + names.length > MAX_SOURCE_DEBUG_VARIABLES
            || [...ids, ...names].some((value) => !validSelector(value))) {
            fail("SOURCE_DEBUG_INVALID_QUERY", `Select at most ${MAX_SOURCE_DEBUG_VARIABLES} variables by bounded ID or exact name.`);
        }
        if (query.variableIds === undefined && query.variableNames === undefined) {
            const available = [...this.#variables.keys()];
            return { ids: available.slice(0, MAX_SOURCE_DEBUG_VARIABLES), truncated: available.length > MAX_SOURCE_DEBUG_VARIABLES };
        }
        const selected = new Set(ids);
        for (const name of names) {
            const matches = [...this.#snapshot.variablesById.values()].filter((variable) => variable.name === name);
            if (matches.length !== 1)
                fail("SOURCE_DEBUG_VARIABLE_UNAVAILABLE", `Variable name ${JSON.stringify(name)} is ${matches.length ? "ambiguous; select its authored ID" : "not present in the captured source"}.`);
            selected.add(matches[0].id);
        }
        for (const id of selected) {
            if (!this.#variables.has(id)) {
                const unavailable = this.#unavailable.find((variable) => variable.id === id);
                fail("SOURCE_DEBUG_VARIABLE_UNAVAILABLE", unavailable ? `${unavailable.name || id}: ${unavailable.reason}` : `No authored global variable with ID ${JSON.stringify(id)} exists in this build.`);
            }
        }
        return { ids: [...selected], truncated: false };
    }
    async inspect(emulator, query = {}) {
        if (!query || typeof query !== "object" || (query.includeScene !== undefined && typeof query.includeScene !== "boolean")) {
            fail("SOURCE_DEBUG_INVALID_QUERY", "Source debugging requires a bounded query.");
        }
        const referenceLimit = query.referenceLimit ?? 16;
        if (!Number.isInteger(referenceLimit) || referenceLimit < 0 || referenceLimit > MAX_SOURCE_DEBUG_REFERENCES) {
            fail("SOURCE_DEBUG_INVALID_QUERY", `referenceLimit must be between 0 and ${MAX_SOURCE_DEBUG_REFERENCES}.`);
        }
        const selected = this.#selectedVariables(query);
        const initial = await emulator.status();
        await this.assertCompatibleRom(initial.romSha256 ?? "");
        if (initial.paused !== true)
            fail("SOURCE_DEBUG_REQUIRES_PAUSE", "Pause the emulator before reading a coherent source-debug snapshot.");
        const readWram = async (address, length) => {
            const result = await emulator.inspect({ view: "memory", region: "wram", offset: address - WRAM_BASE, length });
            if (result.frame !== initial.frame)
                fail("SOURCE_DEBUG_FRAME_CHANGED", "The emulator advanced during source inspection; pause and retry.");
            if (result.view !== "memory" || result.region !== "wram" || result.offset !== address - WRAM_BASE
                || result.length !== length || !new RegExp(`^[a-fA-F0-9]{${length * 2}}$`).test(result.hex)) {
                fail("SOURCE_DEBUG_INVALID_MEMORY", "The emulator did not return the exact bounded WRAM bytes requested by authenticated symbols.");
            }
            return Buffer.from(result.hex, "hex");
        };
        let scene;
        let authoredScene;
        if (query.includeScene !== false || query.collisions) {
            const mapping = decodeCurrentScene(this.#manifest, await readWram(this.#manifest.currentSceneAddress, 3));
            authoredScene = this.#snapshot.scenesById.get(mapping.sceneId);
            scene = sourceScene(authoredScene, mapping.sceneSymbol);
        }
        const mappings = selected.ids.map((id) => this.#variables.get(id)).sort((a, b) => a.address - b.address);
        const spans = [];
        for (const mapping of mappings) {
            let span = spans.at(-1);
            if (!span || mapping.address + 2 - span.start > 256) {
                span = { start: mapping.address, end: mapping.address + 2, variables: [] };
                spans.push(span);
            }
            span.end = mapping.address + 2;
            span.variables.push(mapping);
        }
        const values = new Map();
        for (const span of spans) {
            const bytes = await readWram(span.start, span.end - span.start);
            for (const mapping of span.variables) {
                const resource = this.#snapshot.variablesById.get(mapping.variableId);
                const offset = mapping.address - span.start;
                values.set(resource.id, { id: resource.id, variableId: resource.id, name: resource.name,
                    symbol: mapping.variableSymbol, value: bytes.readInt16LE(offset), unsignedValue: bytes.readUInt16LE(offset),
                    resourcePath: resource.resourcePath });
            }
        }
        const references = [];
        let referencesTruncated = false;
        if (this.#access && referenceLimit > 0) {
            await this.#access.strongRefresh();
            if (this.#access.revision !== this.identity.projectRevision)
                fail("SOURCE_DEBUG_STALE_SOURCE", "The selected project changed during source inspection.");
            const seen = new Set();
            const nodes = [...selected.ids.map((id) => ({ type: "variable", id })), ...(scene ? [{ type: "scene", id: scene.id }] : [])];
            for (const node of nodes) {
                if (references.length >= referenceLimit) {
                    referencesTruncated = true;
                    break;
                }
                const related = await worldDependencies(this.#access, { node, direction: "both", depth: 1, limit: referenceLimit });
                for (const reference of related.references) {
                    const key = JSON.stringify(reference);
                    if (seen.has(key))
                        continue;
                    seen.add(key);
                    if (references.length >= referenceLimit) {
                        referencesTruncated = true;
                        break;
                    }
                    references.push(reference);
                }
                if (related.nextCursor)
                    referencesTruncated = true;
            }
        }
        const collisions = query.collisions && authoredScene ? collisionRegion(authoredScene, query.collisions) : undefined;
        await this.#validateSource();
        const final = await emulator.status();
        if (final.romSha256 !== this.identity.romSha256)
            fail("SOURCE_DEBUG_ROM_MISMATCH", "The emulator ROM changed during source inspection.");
        if (final.paused !== true || final.frame !== initial.frame)
            fail("SOURCE_DEBUG_FRAME_CHANGED", "The emulator advanced during source inspection; pause and retry.");
        const result = { mode: "source", identity: this.identity, frame: initial.frame,
            ...(scene && query.includeScene !== false ? { scene } : {}), variables: selected.ids.map((id) => values.get(id)),
            variablesTruncated: selected.truncated, unavailableVariables: this.#unavailable.slice(0, MAX_SOURCE_DEBUG_VARIABLES),
            unavailableVariableCount: this.#unavailable.length, ...(collisions ? { collisions } : {}), references, referencesTruncated,
            limitations: [...this.#limitations] };
        while (Buffer.byteLength(JSON.stringify(result)) > PROJECT_RESPONSE_LIMITS.semanticQueryBytes && result.references.length > 0) {
            result.references.pop();
            result.referencesTruncated = true;
        }
        while (Buffer.byteLength(JSON.stringify(result)) > PROJECT_RESPONSE_LIMITS.semanticQueryBytes && result.unavailableVariables.length > 0) {
            result.unavailableVariables.pop();
        }
        if (Buffer.byteLength(JSON.stringify(result)) > PROJECT_RESPONSE_LIMITS.semanticQueryBytes) {
            fail("SOURCE_DEBUG_RESPONSE_TOO_LARGE", "Source diagnostics exceed the bounded response size; request fewer variables or a smaller collision window.");
        }
        return result;
    }
}
export async function createSourceDebugContext(options) {
    return SourceDebugContext.create(options);
}
//# sourceMappingURL=source-debug.js.map

SHA-256: 8fdc87c38e4920e10bf3da111dc10c7730f0b58d2e251b4ea6c6a19c97fe0f98