← Files ModRetro Chromatic PluginARCHIVED FILE
dist/project.js
54.8 KB · Oct 2, 2026 · 00:37 UTC
import { createHash } from "node:crypto";
import { access, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, } from "node:fs/promises";
import path from "node:path";
import { PROJECT_REVISION_ALGORITHM, } from "./project-access.js";
import { inspectProjectSnapshot, loadProjectSnapshot } from "./project-snapshot.js";
import { decodeResourceBytes, ResourceCodecError } from "./resource-codec.js";
import { explicitlyPatchedNativeScriptCoordinates, findInvalidNativeScriptEvent, } from "./native-script-events.js";
import { validateActorResource } from "./actors.js";
import { readProjectJsonWithRevision, resourceRevision, withProjectResourceWriteLock, writeProjectBytesAtomic, } from "./project-files.js";
import { legacyPhysicalAssetMetadataLookup, legacyPluginAssetCandidates } from "./legacy-project-assets.js";
/** Stable native IDs must resolve to exactly one physical project asset across resource kinds. */
export function resolveNativeAssetIdentity(source, id) {
const indexed = "asset" in source ? source.asset({ assetId: id }) : undefined;
const matches = indexed?.matches ?? ("assets" in source ? source.assets.filter((asset) => asset.id === id) : []);
if (indexed?.status === "ambiguous" || matches.length > 1) {
throw new GameStudioProjectError("AMBIGUOUS_ASSET", `No unique project asset exists with id ${id}`);
}
return matches[0];
}
export class GameStudioProjectError extends Error {
code;
resourcePath;
constructor(code, message, resourcePath) {
super(message);
this.code = code;
this.resourcePath = resourcePath;
this.name = "GameStudioProjectError";
}
}
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const IMMUTABLE_RESOURCE_KEYS = new Set(["id", "_resourceType", "_index"]);
const ASSET_EXTENSIONS = new Set([
".png",
".wav",
".uge",
".mod",
".vgm",
".vgz",
]);
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function asNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function assertWithinRoot(root, candidate) {
const relative = path.relative(root, candidate);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new GameStudioProjectError("PATH_OUTSIDE_PROJECT", `Refusing to access a path outside the native game project: ${candidate}`, candidate);
}
}
function toRelative(root, candidate) {
assertWithinRoot(root, candidate);
return path.relative(root, candidate).split(path.sep).join("/");
}
async function ensureSafeExistingPath(root, candidate) {
const absolute = path.resolve(candidate);
assertWithinRoot(root, absolute);
let canonical;
try {
canonical = await realpath(absolute);
}
catch (error) {
if (isSystemError(error, "ENOENT")) {
throw new GameStudioProjectError("RESOURCE_NOT_FOUND", `Resource does not exist: ${absolute}`, absolute);
}
throw error;
}
assertWithinRoot(root, canonical);
return canonical;
}
function isSystemError(error, code) {
return isObject(error) && error.code === code;
}
async function exists(candidate) {
try {
await access(candidate);
return true;
}
catch (error) {
if (isSystemError(error, "ENOENT"))
return false;
throw error;
}
}
async function readJson(root, candidate) {
const safePath = await ensureSafeExistingPath(root, candidate);
let parsed;
try {
parsed = JSON.parse(await readFile(safePath, "utf8"));
}
catch (error) {
throw new GameStudioProjectError("INVALID_RESOURCE", `Could not parse native project resource ${toRelative(root, safePath)}: ${error instanceof Error ? error.message : String(error)}`, safePath);
}
if (!isObject(parsed)) {
throw new GameStudioProjectError("INVALID_RESOURCE", `Native project resource must contain a JSON object: ${safePath}`, safePath);
}
return parsed;
}
async function writeJsonAtomic(root, candidate, value) {
const absolute = path.resolve(candidate);
assertWithinRoot(root, absolute);
const parent = path.dirname(absolute);
let existingAncestor = parent;
while (!(await exists(existingAncestor))) {
existingAncestor = path.dirname(existingAncestor);
}
await ensureSafeExistingPath(root, existingAncestor);
await mkdir(parent, { recursive: true });
await ensureSafeExistingPath(root, parent);
if (await exists(absolute)) {
const targetStat = await lstat(absolute);
if (targetStat.isSymbolicLink()) {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Refusing to overwrite symbolic link ${absolute}`, absolute);
}
await ensureSafeExistingPath(root, absolute);
}
const temporary = path.join(parent, `.${path.basename(absolute)}.${process.pid}.${Date.now()}.tmp`);
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, absolute);
}
catch (error) {
if (handle)
await handle.close();
await rm(temporary, { force: true });
throw error;
}
}
function safeSlug(value, fallback) {
const slug = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || fallback;
}
function stableId(...parts) {
const bytes = createHash("sha256").update(parts.join("\u0000")).digest();
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const hex = bytes.subarray(0, 16).toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function assertSafeObject(value, label, disallowImmutable = false) {
for (const [key, child] of Object.entries(value)) {
if (FORBIDDEN_KEYS.has(key) || (disallowImmutable && IMMUTABLE_RESOURCE_KEYS.has(key))) {
throw new GameStudioProjectError("INVALID_PROPERTY", `${label} cannot modify reserved property ${key}`);
}
if (isObject(child))
assertSafeObject(child, label);
}
}
function mergeObjects(existing, updates) {
assertSafeObject(updates, "Project update");
const result = { ...existing };
for (const [key, value] of Object.entries(updates)) {
const original = result[key];
result[key] = isObject(original) && isObject(value) ? mergeObjects(original, value) : value;
}
return result;
}
function validateName(value, label) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new GameStudioProjectError("INVALID_INPUT", `${label} must be a non-empty string`);
}
return value.trim();
}
async function walkFiles(root, directory) {
if (!(await exists(directory)))
return [];
await ensureSafeExistingPath(root, directory);
const result = [];
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
let destination;
try {
destination = await realpath(candidate);
}
catch {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Project contains an unreadable symbolic link: ${candidate}`, candidate);
}
assertWithinRoot(root, destination);
continue;
}
if (entry.isDirectory())
result.push(...(await walkFiles(root, candidate)));
else if (entry.isFile())
result.push(candidate);
}
return result;
}
async function descriptorsIn(directory) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
}
catch (error) {
if (isSystemError(error, "ENOENT") || isSystemError(error, "ENOTDIR"))
return [];
throw error;
}
return entries
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && entry.name.toLowerCase().endsWith(".gbsproj"))
.map((entry) => path.join(directory, entry.name))
.sort((left, right) => left.localeCompare(right));
}
export async function discoverProject(startPath = process.cwd()) {
const requested = path.resolve(startPath);
let descriptorPath;
if (requested.toLowerCase().endsWith(".gbsproj")) {
descriptorPath = requested;
}
else {
let directory = requested;
try {
if ((await stat(requested)).isFile())
directory = path.dirname(requested);
}
catch (error) {
if (isSystemError(error, "ENOENT")) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `Project search path does not exist: ${requested}`, requested);
}
throw error;
}
while (true) {
const matches = await descriptorsIn(directory);
if (matches.length > 1) {
throw new GameStudioProjectError("AMBIGUOUS_PROJECT", `More than one .gbsproj file exists in ${directory}; provide an explicit project file`, directory);
}
if (matches[0]) {
descriptorPath = matches[0];
break;
}
const parent = path.dirname(directory);
if (parent === directory)
break;
directory = parent;
}
}
if (!descriptorPath) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `No .gbsproj project was found at or above ${requested}`, requested);
}
let projectRoot;
try {
projectRoot = await realpath(path.dirname(descriptorPath));
}
catch (error) {
if (isSystemError(error, "ENOENT")) {
throw new GameStudioProjectError("PROJECT_NOT_FOUND", `Native game project does not exist: ${descriptorPath}`, descriptorPath);
}
throw error;
}
const descriptor = await readJson(projectRoot, path.join(projectRoot, path.basename(descriptorPath)));
const projectPath = path.join(projectRoot, path.basename(descriptorPath));
const format = Array.isArray(descriptor.scenes) ? "legacy" : "distributed";
return {
projectPath,
projectRoot,
descriptor,
version: typeof descriptor._version === "string" ? descriptor._version : null,
format,
};
}
function materializeResource(resource, resourcePath) {
return {
...resource,
id: asString(resource.id),
name: asString(resource.name),
resourcePath,
};
}
async function pngDimensions(filename) {
const handle = await open(filename, "r");
try {
const header = Buffer.alloc(24);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (bytesRead < 24 || !header.subarray(0, 8).equals(signature))
return null;
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
}
finally {
await handle.close();
}
}
function assetType(folder, metadata) {
if (typeof metadata?._resourceType === "string")
return metadata._resourceType;
const lookup = {
backgrounds: "background",
sprites: "sprite",
avatars: "avatar",
emotes: "emote",
fonts: "font",
tilesets: "tileset",
music: "music",
sounds: "sound",
};
return lookup[folder] ?? folder;
}
async function collectAssets(discovery, diagnostics) {
const assetsRoot = path.join(discovery.projectRoot, "assets");
const files = await walkFiles(discovery.projectRoot, assetsRoot);
const physicalAssets = files.filter((file) => ASSET_EXTENSIONS.has(path.extname(file).toLowerCase()));
const physicalSet = new Set(physicalAssets);
const result = [];
const physicalPngDimensions = new Map();
const legacyRootFilenames = new Map(["backgrounds", "sprites"].map((folder) => [folder, new Set()]));
for (const file of physicalAssets) {
if (path.extname(file).toLowerCase() !== ".png")
continue;
const dimensions = await pngDimensions(file);
physicalPngDimensions.set(file, dimensions);
if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0)
continue;
const [folder, ...filename] = path.relative(assetsRoot, file).split(path.sep);
if (folder)
legacyRootFilenames.get(folder)?.add(filename.join("/"));
}
const legacyRootMetadata = new Map([...legacyRootFilenames].map(([folder, filenames]) => [folder, legacyPhysicalAssetMetadataLookup(discovery, folder, undefined, filenames)]));
for (const file of physicalAssets) {
const relative = toRelative(discovery.projectRoot, file);
const relativeParts = relative.split("/");
const metadataFile = `${file}.gbsres`;
const metadata = (await exists(metadataFile)) ? await readJson(discovery.projectRoot, metadataFile) : null;
const dimensions = physicalPngDimensions.get(file) ?? null;
const nativeMetadata = metadata ?? (dimensions && dimensions.width > 0 && dimensions.height > 0
? legacyRootMetadata.get(relativeParts[1] ?? "")?.(relativeParts.slice(2).join("/"))
: null);
// GB Studio's built-in UI frame/cursor files are intentionally plain PNGs;
// they are not imported resources and never receive .gbsres sidecars.
if (!metadata && discovery.format === "distributed" && relativeParts[1] !== "ui") {
diagnostics.push({
severity: "info",
code: "ASSET_METADATA_MISSING",
message: `Asset ${relative} has no .gbsres metadata yet; the game editor can import it on refresh`,
resourcePath: relative,
});
}
result.push({
...(nativeMetadata ?? {}),
id: asString(nativeMetadata?.id),
name: asString(nativeMetadata?.name, path.basename(file, path.extname(file))),
type: assetType(relativeParts[1] ?? "asset", metadata),
filename: asString(nativeMetadata?.filename, path.basename(file)),
resourcePath: relative,
metadataPath: metadata ? toRelative(discovery.projectRoot, metadataFile) : null,
hasMetadata: metadata !== null,
width: asNumber(nativeMetadata?.imageWidth) ?? dimensions?.width ?? asNumber(nativeMetadata?.width),
height: asNumber(nativeMetadata?.imageHeight) ?? dimensions?.height ?? asNumber(nativeMetadata?.height),
});
}
for (const candidate of legacyPluginAssetCandidates(discovery)) {
const physical = path.join(discovery.projectRoot, candidate.relativePath);
const folder = path.join(discovery.projectRoot, ...candidate.relativePath.split("/").slice(0, 3));
let canonical;
try {
const entry = await lstat(physical);
const canonicalFolder = await ensureSafeExistingPath(discovery.projectRoot, folder);
canonical = await ensureSafeExistingPath(discovery.projectRoot, physical);
assertWithinRoot(canonicalFolder, canonical);
if (!entry.isFile() || canonicalFolder !== folder || canonical !== physical)
continue;
}
catch (error) {
if (isSystemError(error, "ENOENT") || (error instanceof GameStudioProjectError && error.code === "RESOURCE_NOT_FOUND"))
continue;
throw error;
}
const dimensions = await pngDimensions(canonical);
if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0)
continue;
const metadata = candidate.metadata;
result.push({
...metadata,
id: asString(metadata.id),
name: asString(metadata.name, path.basename(physical, path.extname(physical))),
type: assetType(candidate.folder, null),
filename: asString(metadata.filename, path.basename(physical)),
resourcePath: candidate.relativePath,
metadataPath: null,
hasMetadata: false,
width: asNumber(metadata.imageWidth) ?? dimensions.width,
height: asNumber(metadata.imageHeight) ?? dimensions.height,
});
}
for (const file of files.filter((candidate) => candidate.endsWith(".gbsres"))) {
const expectedAsset = file.slice(0, -".gbsres".length);
if (!physicalSet.has(expectedAsset)) {
diagnostics.push({
severity: "warning",
code: "ASSET_FILE_MISSING",
message: `Asset metadata ${toRelative(discovery.projectRoot, file)} has no corresponding asset file`,
resourcePath: toRelative(discovery.projectRoot, file),
});
}
}
return result;
}
export async function inventoryProject(projectPath, indexedAccess) {
if (indexedAccess) {
assertIndexedProjectAccess(projectPath, indexedAccess);
await indexedAccess.ensureFresh();
return indexedAccess.legacyInventory();
}
const discovery = await discoverProject(projectPath);
const { projectRoot, descriptor, format } = discovery;
const diagnostics = [];
const scenes = [];
const palettes = [];
let settings = {};
if (format === "legacy") {
settings = isObject(descriptor.settings) ? descriptor.settings : {};
for (const value of Array.isArray(descriptor.scenes) ? descriptor.scenes : []) {
if (!isObject(value))
continue;
const scene = materializeResource(value, toRelative(projectRoot, discovery.projectPath));
scenes.push({
...scene,
actors: (Array.isArray(value.actors) ? value.actors : [])
.filter(isObject)
.map((actor) => materializeResource(actor, scene.resourcePath)),
triggers: (Array.isArray(value.triggers) ? value.triggers : [])
.filter(isObject)
.map((trigger) => materializeResource(trigger, scene.resourcePath)),
});
}
for (const palette of Array.isArray(descriptor.palettes) ? descriptor.palettes : []) {
if (isObject(palette))
palettes.push(materializeResource(palette, toRelative(projectRoot, discovery.projectPath)));
}
}
else {
const projectResourcesRoot = path.join(projectRoot, "project");
if (!(await exists(projectResourcesRoot))) {
diagnostics.push({ severity: "error", code: "PROJECT_RESOURCES_MISSING", message: "Distributed project is missing its project/ resource directory" });
}
const settingsPath = path.join(projectResourcesRoot, "settings.gbsres");
if (await exists(settingsPath))
settings = await readJson(projectRoot, settingsPath);
else
diagnostics.push({ severity: "warning", code: "SETTINGS_MISSING", message: "Distributed project has no project/settings.gbsres resource" });
const resourceFiles = await walkFiles(projectRoot, projectResourcesRoot);
const sceneFiles = resourceFiles.filter((file) => path.basename(file) === "scene.gbsres");
for (const sceneFile of sceneFiles) {
const sceneData = await readJson(projectRoot, sceneFile);
const sceneDirectory = path.dirname(sceneFile);
const actors = [];
const triggers = [];
for (const [kind, destination] of [["actors", actors], ["triggers", triggers]]) {
for (const resourceFile of await walkFiles(projectRoot, path.join(sceneDirectory, kind))) {
if (!resourceFile.endsWith(".gbsres"))
continue;
const resource = await readJson(projectRoot, resourceFile);
destination.push(materializeResource(resource, toRelative(projectRoot, resourceFile)));
}
}
scenes.push({
...materializeResource(sceneData, toRelative(projectRoot, sceneFile)),
actors,
triggers,
});
}
for (const file of resourceFiles.filter((candidate) => {
const relative = toRelative(projectRoot, candidate);
return relative.startsWith("project/palettes/") && candidate.endsWith(".gbsres");
})) {
const palette = await readJson(projectRoot, file);
palettes.push(materializeResource(palette, toRelative(projectRoot, file)));
if (Array.isArray(palette.colors) && palette.colors.length !== 4) {
diagnostics.push({
severity: "warning",
code: "INVALID_PALETTE_SIZE",
message: `Palette ${asString(palette.name, asString(palette.id))} has ${palette.colors.length} colors; Game Boy Color palettes require exactly four`,
resourcePath: toRelative(projectRoot, file),
});
}
}
}
const assets = await collectAssets(discovery, diagnostics);
const backgroundIds = new Set(assets.filter((asset) => asset.type === "background").map((asset) => asset.id));
const spriteIds = new Set(assets.filter((asset) => asset.type === "sprite").map((asset) => asset.id));
for (const scene of scenes) {
const backgroundId = asString(scene.backgroundId);
if (backgroundId && !backgroundIds.has(backgroundId)) {
diagnostics.push({ severity: "warning", code: "BACKGROUND_NOT_FOUND", message: `Scene ${scene.name || scene.id} references missing background ${backgroundId}`, resourcePath: scene.resourcePath });
}
const sceneWidth = asNumber(scene.width);
const sceneHeight = asNumber(scene.height);
if (typeof scene.collisions === "string" && sceneWidth !== null && sceneHeight !== null) {
const tileCount = sceneWidth * sceneHeight;
if (Number.isSafeInteger(tileCount) && tileCount >= 0 && tileCount <= 1_000_000) {
try {
decodeResourceBytes(scene.collisions, { maximumValues: tileCount, allowImplicitZeroGrid: true });
}
catch (error) {
if (!(error instanceof ResourceCodecError))
throw error;
diagnostics.push({
severity: "error",
code: error.code === "RESOURCE_VALUE_LIMIT" ? "COLLISION_MAP_TOO_LARGE" : "INVALID_COLLISION_MAP",
message: `Scene ${scene.name || scene.id} has invalid collision data: ${error.message}`,
resourcePath: scene.resourcePath,
});
}
}
}
for (const actor of scene.actors) {
const spriteSheetId = asString(actor.spriteSheetId);
if (spriteSheetId && !spriteIds.has(spriteSheetId)) {
diagnostics.push({ severity: "warning", code: "SPRITE_NOT_FOUND", message: `Actor ${actor.name || actor.id} references missing sprite ${spriteSheetId}`, resourcePath: actor.resourcePath });
}
const actorX = asNumber(actor.x);
const actorY = asNumber(actor.y);
const sceneWidth = asNumber(scene.width);
const sceneHeight = asNumber(scene.height);
if (actorX !== null && actorY !== null && sceneWidth !== null && sceneHeight !== null
&& (actorX < 0 || actorY < 0 || actorX >= sceneWidth || actorY >= sceneHeight)) {
diagnostics.push({
severity: "error",
code: "ACTOR_OUT_OF_BOUNDS",
message: `Actor ${actor.name || actor.id} is outside the ${sceneWidth} by ${sceneHeight} scene`,
resourcePath: actor.resourcePath,
});
}
}
for (const trigger of scene.triggers) {
const triggerX = asNumber(trigger.x);
const triggerY = asNumber(trigger.y);
const triggerWidth = asNumber(trigger.width);
const triggerHeight = asNumber(trigger.height);
const sceneWidth = asNumber(scene.width);
const sceneHeight = asNumber(scene.height);
if (triggerX !== null && triggerY !== null && triggerWidth !== null && triggerHeight !== null
&& sceneWidth !== null && sceneHeight !== null
&& (triggerX < 0 || triggerY < 0 || triggerWidth <= 0 || triggerHeight <= 0
|| triggerX + triggerWidth > sceneWidth || triggerY + triggerHeight > sceneHeight)) {
diagnostics.push({
severity: "error",
code: "TRIGGER_OUT_OF_BOUNDS",
message: `Trigger ${trigger.name || trigger.id} extends outside the ${sceneWidth} by ${sceneHeight} scene`,
resourcePath: trigger.resourcePath,
});
}
}
}
const startSceneId = asString(settings.startSceneId);
const startScene = scenes.find((scene) => scene.id === startSceneId);
if (startSceneId && !startScene) {
diagnostics.push({
severity: "error",
code: "START_SCENE_NOT_FOUND",
message: `Project settings reference missing start scene ${startSceneId}`,
resourcePath: format === "distributed" ? "project/settings.gbsres" : toRelative(projectRoot, discovery.projectPath),
});
}
if (startScene) {
const startX = asNumber(settings.startX);
const startY = asNumber(settings.startY);
const sceneWidth = asNumber(startScene.width);
const sceneHeight = asNumber(startScene.height);
if (startX !== null && startY !== null && sceneWidth !== null && sceneHeight !== null
&& (startX < 0 || startY < 0 || startX >= sceneWidth || startY >= sceneHeight)) {
diagnostics.push({
severity: "error",
code: "START_POSITION_OUT_OF_BOUNDS",
message: `Player start position is outside the ${sceneWidth} by ${sceneHeight} start scene`,
resourcePath: startScene.resourcePath,
});
}
const mappings = isObject(settings.defaultPlayerSprites) ? settings.defaultPlayerSprites : {};
const sceneType = asString(startScene.type, asString(settings.defaultSceneTypeId, "TOPDOWN"));
const effectiveSpriteId = asString(mappings[sceneType]);
if (!effectiveSpriteId || !spriteIds.has(effectiveSpriteId)) {
diagnostics.push({
severity: "error",
code: "PLAYER_SPRITE_NOT_FOUND",
message: effectiveSpriteId
? `Start scene type ${sceneType} references missing player sprite ${effectiveSpriteId}`
: `Start scene type ${sceneType} has no default player sprite`,
resourcePath: format === "distributed" ? "project/settings.gbsres" : toRelative(projectRoot, discovery.projectPath),
});
}
}
if (!discovery.version)
diagnostics.push({ severity: "warning", code: "VERSION_MISSING", message: "Project descriptor does not declare a project _version" });
else if (format === "distributed" && Number(discovery.version.split(".")[0]) < 4) {
diagnostics.push({ severity: "warning", code: "UNEXPECTED_PROJECT_VERSION", message: `Distributed resources usually require project format version 4 or newer; project declares ${discovery.version}` });
}
const actors = scenes.flatMap((scene) => scene.actors);
const triggers = scenes.flatMap((scene) => scene.triggers);
return {
...discovery,
name: asString(descriptor.name, path.basename(discovery.projectPath, ".gbsproj")),
author: asString(descriptor.author),
settings,
scenes,
actors,
triggers,
palettes,
assets,
diagnostics,
counts: { scenes: scenes.length, actors: actors.length, triggers: triggers.length, palettes: palettes.length, assets: assets.length },
};
}
export async function inspectProject(projectPath, options = {}, indexedAccess) {
if (indexedAccess) {
assertIndexedProjectAccess(projectPath, indexedAccess);
await indexedAccess.ensureFresh();
if (options.diagnosticLimit === undefined)
return indexedAccess.projectInspection();
return inspectProjectSnapshot(await indexedAccess.snapshot(), {
diagnosticLimit: options.diagnosticLimit,
});
}
const snapshot = await loadProjectSnapshot(projectPath, {
selectionGeneration: options.selectionGeneration,
});
return inspectProjectSnapshot(snapshot, { diagnosticLimit: options.diagnosticLimit });
}
function assertIndexedProjectAccess(projectPath, indexedAccess) {
const requestedPath = path.resolve(projectPath);
if (requestedPath !== indexedAccess.projectPath && requestedPath !== indexedAccess.projectRoot) {
throw new GameStudioProjectError("STALE_PROJECT", "Indexed project access does not belong to the requested native game project", requestedPath);
}
if (indexedAccess.revisionAlgorithm !== PROJECT_REVISION_ALGORITHM) {
throw new GameStudioProjectError("STALE_PROJECT", "Indexed project access uses an obsolete project revision algorithm");
}
}
async function authoringContext(projectPath, indexedAccess, options = {}) {
if (!indexedAccess)
return inventoryProject(projectPath);
assertIndexedProjectAccess(projectPath, indexedAccess);
await indexedAccess.ensureFresh();
const inspection = indexedAccess.projectInspection();
const legacy = inspection.format === "legacy";
const descriptor = legacy && options.includeLegacyDescriptor !== false
? await readJson(indexedAccess.projectRoot, indexedAccess.projectPath)
: { name: inspection.name };
let settings = {};
if (options.includeSettings) {
const cachedSettings = indexedAccess.settings;
if (typeof cachedSettings === "function") {
settings = cachedSettings.call(indexedAccess);
}
else if (legacy) {
settings = isObject(descriptor.settings) ? descriptor.settings : {};
}
else {
const settingsPath = path.join(indexedAccess.projectRoot, "project", "settings.gbsres");
settings = await exists(settingsPath) ? await readJson(indexedAccess.projectRoot, settingsPath) : {};
}
}
return {
projectPath: indexedAccess.projectPath,
projectRoot: indexedAccess.projectRoot,
format: inspection.format,
descriptor,
settings,
scenes: options.includeScenes
? indexedAccess.resourcesByKind("scene")
: [],
actors: [],
// Indexed edits query their exact asset IDs directly rather than materializing all assets.
assets: [],
};
}
function findScene(inventory, sceneId, indexedAccess) {
const scene = indexedAccess?.scene(sceneId)
?? (!indexedAccess ? inventory.scenes.find((candidate) => candidate.id === sceneId) : undefined);
if (!scene)
throw new GameStudioProjectError("SCENE_NOT_FOUND", `No scene with id ${sceneId} exists in this project`);
return scene;
}
function resourceWithoutInventoryFields(resource) {
const { resourcePath: _resourcePath, actors: _actors, triggers: _triggers, ...original } = resource;
return original;
}
function sceneDefaults(inventory, input, id, index, slug) {
return {
_resourceType: "scene",
id,
_index: index,
type: input.type ?? asString(inventory.settings.defaultSceneTypeId, "TOPDOWN"),
name: input.name.trim(),
symbol: input.symbol ?? `scene_${slug}`,
x: input.x ?? 0,
y: input.y ?? 0,
width: input.width ?? 20,
height: input.height ?? 18,
backgroundId: input.backgroundId ?? "",
tilesetId: "",
colorModeOverride: "none",
paletteIds: [],
spritePaletteIds: [],
autoFadeSpeed: 1,
script: [],
playerHit1Script: [],
playerHit2Script: [],
playerHit3Script: [],
collisions: "",
};
}
function validateDimensions(input) {
for (const key of ["width", "height"]) {
const value = input[key];
if (value !== undefined && (!Number.isInteger(value) || value < 1 || value > 255)) {
throw new GameStudioProjectError("INVALID_INPUT", `Scene ${key} must be an integer between 1 and 255`);
}
}
}
/** Shared by direct and transactional scene edits; inspect the merged native resource. */
export function validateNativeSceneUpdate(previous, updated, changes, backgroundExists) {
if (updated.name !== undefined)
validateName(updated.name, "Scene name");
for (const key of ["width", "height"]) {
const value = updated[key];
if (!Number.isInteger(value) || value < 1 || value > 255) {
throw new GameStudioProjectError("INVALID_INPUT", `Scene ${key} must be an integer between 1 and 255`);
}
}
const collisions = previous.collisions;
const hasExistingCollisionGrid = typeof collisions === "string" || Array.isArray(collisions)
? collisions.length > 0
: collisions !== undefined && collisions !== null;
if ((updated.width !== previous.width || updated.height !== previous.height) && hasExistingCollisionGrid) {
throw new GameStudioProjectError("INVALID_INPUT", "Resizing a scene with an existing collision grid requires explicit collision resizing semantics");
}
if (Object.prototype.hasOwnProperty.call(changes, "backgroundId") || updated.backgroundId !== previous.backgroundId) {
if (updated.backgroundId !== undefined && typeof updated.backgroundId !== "string") {
throw new GameStudioProjectError("INVALID_INPUT", "Scene backgroundId must be a string");
}
if (typeof updated.backgroundId === "string" && updated.backgroundId !== "" && !backgroundExists(updated.backgroundId)) {
throw new GameStudioProjectError("BACKGROUND_NOT_FOUND", `No background with id ${updated.backgroundId} exists`);
}
}
const invalid = findInvalidNativeScriptEvent(updated, previous, {
explicitlyPatchedCoordinates: explicitlyPatchedNativeScriptCoordinates(changes),
});
if (invalid)
throw new GameStudioProjectError("INVALID_EVENT", invalid.message);
}
function nextIndex(resources) {
return resources.reduce((highest, resource) => Math.max(highest, asNumber(resource._index) ?? -1), -1) + 1;
}
async function availablePath(root, parent, base, extension) {
for (let index = 1; index < 10_000; index++) {
const slug = index === 1 ? base : `${base}_${index}`;
const candidate = path.join(parent, `${slug}${extension}`);
assertWithinRoot(root, candidate);
if (!(await exists(candidate)))
return { absolute: candidate, slug };
}
throw new GameStudioProjectError("RESOURCE_NAME_EXHAUSTED", `Could not choose a unique resource path for ${base}`);
}
export async function createScene(projectPath, input, indexedAccess) {
validateName(input.name, "Scene name");
validateDimensions(input);
if (input.properties)
assertSafeObject(input.properties, "Scene properties", true);
const inventory = await authoringContext(projectPath, indexedAccess, {
includeScenes: true,
includeSettings: true,
includeLegacyDescriptor: false,
});
const slugBase = safeSlug(input.name, "scene");
if (inventory.format === "legacy") {
const target = inventory.projectPath;
return withProjectResourceWriteLock(inventory.projectRoot, target, async () => {
const loaded = await readProjectJsonWithRevision(inventory.projectRoot, target);
const originalScenes = Array.isArray(loaded.json.scenes) ? loaded.json.scenes : [];
const scenes = originalScenes.filter(isObject).map((scene) => materializeResource(scene, toRelative(inventory.projectRoot, target)));
const settings = isObject(loaded.json.settings) ? loaded.json.settings : {};
const projectName = asString(loaded.json.name, "project");
let slug = slugBase;
let suffix = 1;
while (scenes.some((scene) => safeSlug(scene.name, "scene") === slug || scene.symbol === `scene_${slug}`
|| (input.id === undefined && scene.id === stableId(projectName, "scene", slug))))
slug = `${slugBase}_${++suffix}`;
const id = input.id ?? stableId(projectName, "scene", slug);
if (scenes.some((scene) => scene.id === id)) {
throw new GameStudioProjectError("DUPLICATE_RESOURCE_ID", `A scene with id ${id} already exists`);
}
const scene = mergeObjects(sceneDefaults({ settings }, input, id, nextIndex(scenes), slug), input.properties ?? {});
const invalid = findInvalidNativeScriptEvent(scene);
if (invalid)
throw new GameStudioProjectError("INVALID_EVENT", invalid.message);
if (await resourceRevision(inventory.projectRoot, target) !== loaded.revision) {
throw new GameStudioProjectError("STALE_RESOURCE", "The project descriptor changed before the requested scene creation", target);
}
await writeJsonAtomic(inventory.projectRoot, target, {
...loaded.json, scenes: [...originalScenes, { ...scene, actors: [], triggers: [] }],
});
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, resourcePath: toRelative(inventory.projectRoot, target), scene };
});
}
const chosen = await availablePath(inventory.projectRoot, path.join(inventory.projectRoot, "project", "scenes"), slugBase, "");
const { slug } = chosen;
const target = path.join(chosen.absolute, "scene.gbsres");
const id = input.id ?? stableId(inventory.descriptor.name ?? "project", "scene", slug);
if (indexedAccess ? indexedAccess.scene(id) !== undefined : inventory.scenes.some((scene) => scene.id === id)) {
throw new GameStudioProjectError("DUPLICATE_RESOURCE_ID", `A scene with id ${id} already exists`);
}
const scene = mergeObjects(sceneDefaults(inventory, input, id, nextIndex(inventory.scenes), slug), input.properties ?? {});
const invalid = findInvalidNativeScriptEvent(scene);
if (invalid)
throw new GameStudioProjectError("INVALID_EVENT", invalid.message);
await writeJsonAtomic(inventory.projectRoot, target, scene);
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, resourcePath: toRelative(inventory.projectRoot, target), scene };
}
export async function updateScene(projectPath, input, indexedAccess) {
if (input.name !== undefined)
validateName(input.name, "Scene name");
validateDimensions(input);
if (input.properties)
assertSafeObject(input.properties, "Scene properties", true);
const inventory = await authoringContext(projectPath, indexedAccess);
const existing = findScene(inventory, input.sceneId, indexedAccess);
const updates = { ...(input.properties ?? {}) };
for (const key of ["name", "type", "width", "height", "x", "y", "backgroundId"]) {
if (input[key] !== undefined)
updates[key] = input[key];
}
const target = path.join(inventory.projectRoot, existing.resourcePath);
const backgroundExists = (id) => resolveNativeAssetIdentity(indexedAccess ?? inventory, id)?.type === "background";
return withProjectResourceWriteLock(inventory.projectRoot, target, async () => {
const loaded = await readProjectJsonWithRevision(inventory.projectRoot, target);
let scene;
let owner;
if (inventory.format === "distributed") {
scene = mergeObjects(loaded.json, updates);
validateNativeSceneUpdate(loaded.json, scene, updates, backgroundExists);
owner = scene;
}
else {
const originalScenes = Array.isArray(loaded.json.scenes) ? loaded.json.scenes : [];
const original = originalScenes.find((value) => isObject(value) && value.id === input.sceneId);
if (!isObject(original))
throw new GameStudioProjectError("SCENE_NOT_FOUND", `Scene ${input.sceneId} disappeared during update`);
const updated = mergeObjects(original, updates);
validateNativeSceneUpdate(original, updated, updates, backgroundExists);
scene = resourceWithoutInventoryFields(updated);
owner = { ...loaded.json, scenes: originalScenes.map((value) => value === original ? updated : value) };
}
if (await resourceRevision(inventory.projectRoot, target) !== loaded.revision) {
throw new GameStudioProjectError("STALE_RESOURCE", "The target resource changed before the requested scene edit", target);
}
await writeJsonAtomic(inventory.projectRoot, target, owner);
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, resourcePath: existing.resourcePath, scene };
});
}
function actorDefaults(input, id, index, slug) {
const script = [];
if (input.dialogue !== undefined) {
script.push({
id: stableId(id, "script", "0", "EVENT_TEXT"),
command: "EVENT_TEXT",
args: { text: input.dialogue, avatarId: "" },
__type: "event",
});
}
return {
_resourceType: "actor",
id,
_index: index,
symbol: `actor_${slug}`,
prefabId: "",
name: input.name.trim(),
coordinateType: "tiles",
x: input.x ?? 0,
y: input.y ?? 0,
frame: 0,
animate: false,
spriteSheetId: input.spriteSheetId ?? "",
paletteId: "",
direction: input.direction ?? "down",
moveSpeed: 1,
animSpeed: 15,
isPinned: false,
persistent: false,
collisionGroup: "",
collisionExtraFlags: [],
prefabScriptOverrides: {},
script,
startScript: [],
updateScript: [],
hit1Script: [],
hit2Script: [],
hit3Script: [],
};
}
export async function createActor(projectPath, input, indexedAccess) {
validateName(input.name, "Actor name");
if (input.direction && !["up", "down", "left", "right"].includes(input.direction)) {
throw new GameStudioProjectError("INVALID_INPUT", "Actor direction must be up, down, left, or right");
}
if (input.properties)
assertSafeObject(input.properties, "Actor properties", true);
const inventory = await authoringContext(projectPath, indexedAccess, { includeLegacyDescriptor: false });
const scene = findScene(inventory, input.sceneId, indexedAccess);
const base = safeSlug(input.name, "actor");
const owner = path.join(inventory.projectRoot, scene.resourcePath);
return withProjectResourceWriteLock(inventory.projectRoot, owner, async () => {
const loaded = await readProjectJsonWithRevision(inventory.projectRoot, owner);
let target;
let slug = base;
let ownerScene;
let existingActors;
let existingId;
let ownerWithActor;
if (inventory.format === "distributed") {
if (loaded.json.id !== scene.id)
throw new GameStudioProjectError("SCENE_NOT_FOUND", `Scene ${scene.id} disappeared during actor creation`);
const actorsDirectory = path.join(path.dirname(owner), "actors");
existingActors = await Promise.all((await walkFiles(inventory.projectRoot, actorsDirectory)).filter((filename) => filename.endsWith(".gbsres"))
.map(async (filename) => materializeResource(await readJson(inventory.projectRoot, filename), toRelative(inventory.projectRoot, filename))));
const chosen = await availablePath(inventory.projectRoot, actorsDirectory, base, ".gbsres");
target = chosen.absolute;
slug = chosen.slug;
ownerScene = { ...scene, ...loaded.json, actors: existingActors };
existingId = (id) => existingActors.some((actor) => actor.id === id) || (indexedAccess
? indexedAccess.resource({ type: "actor", id }) !== undefined
: inventory.actors.some((actor) => actor.id === id));
}
else {
target = owner;
const originalScenes = Array.isArray(loaded.json.scenes) ? loaded.json.scenes : [];
const original = originalScenes.find((value) => isObject(value) && value.id === scene.id);
if (!isObject(original))
throw new GameStudioProjectError("SCENE_NOT_FOUND", `Scene ${scene.id} disappeared during actor creation`);
const originalActors = Array.isArray(original.actors) ? original.actors : [];
existingActors = originalActors.filter(isObject).map((actor) => materializeResource(actor, scene.resourcePath));
let suffix = 1;
while (existingActors.some((actor) => safeSlug(actor.name, "actor") === slug || actor.symbol === `actor_${slug}`
|| (input.id === undefined && actor.id === stableId(scene.id, "actor", slug))))
slug = `${base}_${++suffix}`;
ownerScene = { ...scene, ...original, actors: existingActors };
existingId = (id) => originalScenes.some((value) => isObject(value) && Array.isArray(value.actors)
&& value.actors.some((actor) => isObject(actor) && actor.id === id));
ownerWithActor = (actor) => ({ ...loaded.json, scenes: originalScenes.map((value) => value === original
? { ...original, actors: [...originalActors, actor] } : value) });
}
const id = input.id ?? stableId(scene.id, "actor", slug);
if (existingId(id))
throw new GameStudioProjectError("DUPLICATE_RESOURCE_ID", `An actor with id ${id} already exists`);
const actor = mergeObjects(actorDefaults(input, id, nextIndex(existingActors), slug), input.properties ?? {});
validateActorResource(indexedAccess ?? inventory, ownerScene, actor);
if (await resourceRevision(inventory.projectRoot, owner) !== loaded.revision) {
throw new GameStudioProjectError("STALE_RESOURCE", "The target scene changed before the requested actor creation", owner);
}
await writeJsonAtomic(inventory.projectRoot, target, ownerWithActor ? ownerWithActor(actor) : actor);
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, sceneId: scene.id, resourcePath: toRelative(inventory.projectRoot, target), actor };
});
}
function findTextEvent(events, eventId) {
for (const value of events) {
if (!isObject(value))
continue;
if (eventId && value.id === eventId) {
if (value.command !== "EVENT_TEXT") {
throw new GameStudioProjectError("INVALID_DIALOGUE_EVENT", `Event ${eventId} uses ${asString(value.command, "an unknown command")} and is not a supported dialogue event`);
}
return value;
}
if (!eventId && value.command === "EVENT_TEXT")
return value;
if (isObject(value.children)) {
for (const child of Object.values(value.children)) {
if (Array.isArray(child)) {
const nested = findTextEvent(child, eventId);
if (nested)
return nested;
}
}
}
}
return undefined;
}
function applyDialogue(target, input) {
const scriptKey = input.scriptKey ?? "script";
if (!/^(?:script|[A-Za-z][A-Za-z0-9]*Script)$/.test(scriptKey)) {
throw new GameStudioProjectError("INVALID_INPUT", `Unsupported script property ${scriptKey}`);
}
if (target[scriptKey] !== undefined && !Array.isArray(target[scriptKey])) {
throw new GameStudioProjectError("INVALID_RESOURCE", `Resource ${asString(target.id)} has a non-array ${scriptKey}`);
}
const script = Array.isArray(target[scriptKey]) ? target[scriptKey] : [];
target[scriptKey] = script;
let event = findTextEvent(script, input.eventId);
if (!event && input.eventId) {
throw new GameStudioProjectError("EVENT_NOT_FOUND", `No dialogue event with id ${input.eventId} exists on this resource`);
}
const created = !event;
if (!event) {
event = {
id: stableId(asString(target.id), scriptKey, String(script.length), "EVENT_TEXT"),
command: "EVENT_TEXT",
args: { text: input.text, avatarId: "" },
__type: "event",
};
script.push(event);
}
else {
event.args = { ...(isObject(event.args) ? event.args : {}), text: input.text };
}
return { event, created };
}
export async function updateDialogue(projectPath, input, indexedAccess) {
if (input.actorId && input.triggerId) {
throw new GameStudioProjectError("INVALID_INPUT", "Specify either actorId or triggerId, not both");
}
if (!(typeof input.text === "string" || (Array.isArray(input.text) && input.text.every((line) => typeof line === "string")))) {
throw new GameStudioProjectError("INVALID_INPUT", "Dialogue text must be a string or an array of strings");
}
const inventory = await authoringContext(projectPath, indexedAccess, { includeLegacyDescriptor: false });
const scene = findScene(inventory, input.sceneId, indexedAccess);
const actor = input.actorId
? indexedAccess?.actor(scene.id, input.actorId)
?? (!indexedAccess ? scene.actors.find((candidate) => candidate.id === input.actorId) : undefined)
: undefined;
const trigger = input.triggerId
? indexedAccess?.trigger(scene.id, input.triggerId)
?? (!indexedAccess ? scene.triggers.find((candidate) => candidate.id === input.triggerId) : undefined)
: undefined;
if (input.actorId && !actor)
throw new GameStudioProjectError("ACTOR_NOT_FOUND", `No actor with id ${input.actorId} exists in scene ${scene.id}`);
if (input.triggerId && !trigger)
throw new GameStudioProjectError("TRIGGER_NOT_FOUND", `No trigger with id ${input.triggerId} exists in scene ${scene.id}`);
const selected = actor ?? trigger ?? scene;
const target = path.join(inventory.projectRoot, selected.resourcePath);
return withProjectResourceWriteLock(inventory.projectRoot, target, async () => {
const loaded = await readProjectJsonWithRevision(inventory.projectRoot, target);
const stale = () => {
throw new GameStudioProjectError("STALE_RESOURCE", "The target resource changed before the requested dialogue edit", target);
};
if (input.expectedRevision !== undefined && loaded.revision !== input.expectedRevision)
stale();
const owner = loaded.json;
let result;
if (inventory.format === "distributed") {
result = applyDialogue(owner, input);
}
else {
const existingScene = (Array.isArray(owner.scenes) ? owner.scenes : []).find((candidate) => isObject(candidate) && candidate.id === scene.id);
if (!isObject(existingScene))
throw new GameStudioProjectError("SCENE_NOT_FOUND", `Scene ${scene.id} disappeared during update`);
let selectedResource = existingScene;
if (actor || trigger) {
const collection = existingScene[actor ? "actors" : "triggers"];
const matching = (Array.isArray(collection) ? collection : []).find((candidate) => isObject(candidate) && candidate.id === selected.id);
if (!isObject(matching))
throw new GameStudioProjectError("RESOURCE_NOT_FOUND", `Resource ${selected.id} disappeared during update`);
selectedResource = matching;
}
result = applyDialogue(selectedResource, input);
}
const output = Buffer.from(`${JSON.stringify(owner, null, 2)}\n`, "utf8");
if (await resourceRevision(inventory.projectRoot, target) !== loaded.revision)
stale();
await writeProjectBytesAtomic(inventory.projectRoot, target, output);
const revision = createHash("sha256").update(output).digest("hex");
await indexedAccess?.noteCommittedPaths([target]);
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
sceneId: scene.id,
...(actor ? { actorId: actor.id } : {}),
...(trigger ? { triggerId: trigger.id } : {}),
resourcePath: selected.resourcePath,
...result,
revision,
};
});
}
export async function updateSettings(projectPath, updates, indexedAccess) {
if (!isObject(updates))
throw new GameStudioProjectError("INVALID_INPUT", "Settings updates must be a JSON object");
assertSafeObject(updates, "Settings updates", true);
const inventory = await authoringContext(projectPath, indexedAccess, { includeSettings: true, includeLegacyDescriptor: false });
const target = inventory.format === "distributed"
? path.join(inventory.projectRoot, "project", "settings.gbsres")
: inventory.projectPath;
if (inventory.format === "distributed" && !(await exists(target))) {
const settings = mergeObjects({ _resourceType: "settings", ...inventory.settings }, updates);
await writeJsonAtomic(inventory.projectRoot, target, settings);
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, resourcePath: toRelative(inventory.projectRoot, target), settings };
}
return withProjectResourceWriteLock(inventory.projectRoot, target, async () => {
const loaded = await readProjectJsonWithRevision(inventory.projectRoot, target);
const original = inventory.format === "distributed"
? { _resourceType: "settings", ...loaded.json }
: (isObject(loaded.json.settings) ? loaded.json.settings : {});
const settings = mergeObjects(original, updates);
if (await resourceRevision(inventory.projectRoot, target) !== loaded.revision) {
throw new GameStudioProjectError("STALE_RESOURCE", "The target resource changed before the requested settings edit", target);
}
await writeJsonAtomic(inventory.projectRoot, target, inventory.format === "distributed" ? settings : { ...loaded.json, settings });
await indexedAccess?.noteCommittedPaths([target]);
return { projectPath: inventory.projectPath, projectRoot: inventory.projectRoot, resourcePath: toRelative(inventory.projectRoot, target), settings };
});
}
//# sourceMappingURL=project.js.mapSHA-256: 273c2262519c84813e3dfd82f7e10eefd2d7b59aa9775e47eecdd48e24dc21fa