← Files ModRetro Chromatic PluginARCHIVED FILE
dist/project-world-index.js
65.1 KB · Oct 2, 2026 · 00:37 UTC
import { createHash } from "node:crypto";
import { watch } from "node:fs";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { PROJECT_REVISION_ALGORITHM, } from "./project-access.js";
import { isPathWithinRoot } from "./platform.js";
import { assertWithinProject, projectRelativePath } from "./project-files.js";
import { ProjectRevisionTree, isPublicProjectRevisionPath } from "./project-revision.js";
import { inspectProjectSnapshot, inventoryProjectSnapshot, } from "./project-snapshot.js";
import { discoverProject, GameStudioProjectError, } from "./project.js";
import { decodeResourceBytes, ResourceCodecError } from "./resource-codec.js";
import { buildWorldScriptGraph } from "./world-script-graph.js";
import { observeReviewedCompiler } from "./reviewed-compiler.js";
import { REVIEWED_CUSTOM_EVENTS } from "./custom-event-dependencies.js";
import { legacyPhysicalAssetMetadataLookup, legacyPluginAssetCandidates } from "./legacy-project-assets.js";
const ASSET_EXTENSIONS = new Set([".png", ".wav", ".uge", ".mod", ".vgm", ".vgz"]);
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const MAXIMUM_DIRTY_PATHS = 2_048;
const EMPTY_COVERAGE = { complete: true, limitations: [] };
class FrozenResourceMap {
#entries;
constructor(entries) {
this.#entries = new Map(entries);
Object.freeze(this);
}
get size() { return this.#entries.size; }
get(key) { return this.#entries.get(key); }
has(key) { return this.#entries.has(key); }
entries() { return this.#entries.entries(); }
keys() { return this.#entries.keys(); }
values() { return this.#entries.values(); }
[Symbol.iterator]() { return this.#entries[Symbol.iterator](); }
forEach(callback, receiver) {
this.#entries.forEach((value, key) => callback.call(receiver, value, key, this));
}
}
function deepFreeze(value) {
if (typeof value !== "object" || value === null || Object.isFrozen(value))
return value;
for (const child of Object.values(value))
deepFreeze(child);
return Object.freeze(value);
}
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 systemError(error, code) {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
function category(relativePath, descriptorPath) {
if (relativePath === descriptorPath)
return "descriptor";
if (relativePath === "project/settings.gbsres")
return "settings";
if (relativePath === "project/variables.gbsres")
return "variables";
if (relativePath.startsWith("plugins/"))
return relativePath.endsWith(".js") ? "plugin-event" : "plugin-resource";
if (relativePath.startsWith("assets/"))
return relativePath.endsWith(".gbsres") ? "asset-metadata" : "asset";
if (relativePath.startsWith("project/prefabs/actors/"))
return "actor-prefab";
if (relativePath.startsWith("project/prefabs/triggers/"))
return "trigger-prefab";
if (relativePath.startsWith("project/scripts/"))
return "script";
if (relativePath.startsWith("project/palettes/") && relativePath.endsWith(".gbsres"))
return "palette";
if (relativePath.endsWith("/scene.gbsres"))
return "scene";
if (relativePath.includes("/actors/") && relativePath.endsWith(".gbsres"))
return "actor";
if (relativePath.includes("/triggers/") && relativePath.endsWith(".gbsres"))
return "trigger";
return "other";
}
function assetType(folder, metadata) {
if (typeof metadata?._resourceType === "string")
return metadata._resourceType;
const names = {
backgrounds: "background", sprites: "sprite", avatars: "avatar", emotes: "emote",
fonts: "font", tilesets: "tileset", music: "music", sounds: "sound",
};
return names[folder] ?? folder;
}
function isLegacyImagePath(relative) {
return /^(?:assets\/(?:backgrounds|sprites)|plugins\/[^/]+\/(?:backgrounds|sprites))\/.+\.png$/iu.test(relative);
}
function isPhysicalPng(file) {
return file.pngHeader !== undefined && file.pngHeader.width > 0 && file.pngHeader.height > 0;
}
function materialize(resource, resourcePath) {
return { ...resource, id: asString(resource.id), name: asString(resource.name), resourcePath };
}
function health(diagnostics) {
const byCode = {};
let errorCount = 0;
let warningCount = 0;
let infoCount = 0;
for (const item of diagnostics) {
byCode[item.code] = (byCode[item.code] ?? 0) + 1;
if (item.severity === "error")
errorCount += 1;
else if (item.severity === "warning")
warningCount += 1;
else
infoCount += 1;
}
return { errorCount, warningCount, infoCount, byCode };
}
function graphKey(identity) {
return `${identity.type}\u0000${identity.id}`;
}
/** One selected project's in-memory, incremental authored-resource census. */
export class ProjectWorldIndex {
projectPath;
projectRoot;
selectionGeneration;
revisionAlgorithm = PROJECT_REVISION_ALGORITHM;
indexGeneration = 0;
semanticGeneration = 0;
revision = "";
coverage = EMPTY_COVERAGE;
#requestedPath;
#watcherFactory;
#platform;
#reviewedCustomEvents;
#toolchainRoot;
#compilerObservation;
#compilerRefresh;
#files = new Map();
#windowsPaths = new Map();
#dirty = new Set();
#actors = new Map();
#triggers = new Map();
#sceneIdsByResource = new Map();
#incoming = new Map();
#outgoing = new Map();
#structural = new Map();
#assetsById = new Map();
#assetsByPath = new Map();
#palettesById = new Map();
#kind = new Map();
#state = "initializing";
#watcher;
#bootstrap;
#refresh;
#degraded = false;
#watchUnavailable = false;
#watchUnsupported = false;
#inAuthoritativeRebuild = false;
#discovery;
#inventory;
#snapshot;
#detachedSnapshot;
#graph;
#tree = new ProjectRevisionTree();
#counters = {
fullInventoryBuilds: 0, fullHashPasses: 0, authoredFileReads: 0,
authoredBytesRead: 0, directoryReads: 0, resourceParses: 0,
cacheHits: 0, dirtyEvents: 0, changedPaths: 0, fallbackFullRebuilds: 0,
};
constructor(projectPath, selectionGeneration = 0, options = {}) {
this.#requestedPath = path.resolve(projectPath);
this.#watcherFactory = options.watcherFactory ?? watch;
this.#platform = options.platform ?? process.platform;
this.#reviewedCustomEvents = options.reviewedCustomEvents ?? REVIEWED_CUSTOM_EVENTS;
this.#toolchainRoot = options.toolchainRoot;
this.projectPath = this.#requestedPath;
this.projectRoot = this.#requestedPath.toLowerCase().endsWith(".gbsproj")
? path.dirname(this.#requestedPath) : this.#requestedPath;
this.selectionGeneration = selectionGeneration;
}
async ensureFresh() {
if (this.#state === "disposed")
throw new GameStudioProjectError("STALE_PROJECT", "The selected project index was disposed");
if (!this.#bootstrap) {
this.#bootstrap = this.#initialize().catch((error) => {
this.#bootstrap = undefined;
this.#watcher?.close();
this.#watcher = undefined;
throw error;
});
}
await this.#bootstrap;
if (this.#degraded || this.#state === "invalidated") {
await this.strongRefresh();
}
else if (this.#dirty.size > 0) {
await this.#refreshDirty();
}
else {
this.#counters.cacheHits += 1;
await this.#refreshCompiler();
}
}
async strongRefresh() {
if (!this.#bootstrap) {
await this.ensureFresh();
return;
}
await this.#bootstrap;
if (this.#refresh) {
await this.#refresh;
return;
}
const pending = this.#rebuild(true);
this.#refresh = pending;
try {
await pending;
}
catch (error) {
this.invalidate("The authoritative authored refresh failed");
throw error;
}
finally {
this.#refresh = undefined;
}
}
projectInspection() {
this.#hit();
return inspectProjectSnapshot(this.#requiredSnapshot());
}
normalizedInventory(options = {}) {
this.#hit();
const snapshot = this.#requiredSnapshot();
let selected = snapshot;
if (options.sceneId && options.layout !== "legacy") {
const scene = snapshot.scenesById.get(options.sceneId);
if (!scene)
throw new GameStudioProjectError("SCENE_NOT_FOUND", `No scene with id ${options.sceneId} exists in this project`);
const selectedKinds = new Set(options.resourceTypes ?? []);
const referencedAssetIds = new Set();
const referencedPaletteIds = new Set();
if (typeof scene.backgroundId === "string")
referencedAssetIds.add(scene.backgroundId);
for (const actor of scene.actors) {
if (typeof actor.spriteSheetId === "string")
referencedAssetIds.add(actor.spriteSheetId);
}
for (const field of ["paletteIds", "spritePaletteIds"]) {
for (const value of Array.isArray(scene[field]) ? scene[field] : []) {
if (typeof value === "string")
referencedPaletteIds.add(value);
}
}
const palettes = [...referencedPaletteIds].flatMap((id) => this.#palettesById.get(id) ?? []);
const assets = [...referencedAssetIds].flatMap((id) => this.#assetsById.get(id) ?? []);
const wantsVariables = selectedKinds.has("variable");
const wantsDiagnostics = selectedKinds.has("diagnostic");
const prefix = path.posix.dirname(scene.resourcePath);
const diagnostics = wantsDiagnostics
? snapshot.diagnostics.filter((item) => !item.resourcePath || item.resourcePath.startsWith(prefix))
: [];
selected = {
...snapshot,
variablesById: wantsVariables ? snapshot.variablesById : new Map(),
diagnostics,
inventory: { ...snapshot.inventory, scenes: [scene], actors: scene.actors,
triggers: scene.triggers, palettes, assets, diagnostics },
};
}
const result = inventoryProjectSnapshot(selected, options);
if (!("revision" in result)) {
throw new GameStudioProjectError("INVALID_INPUT", "Use explicit legacyInventory for full legacy layout");
}
return result;
}
async snapshot() {
this.#hit();
if (this.#detachedSnapshot?.revision === this.revision)
return this.#detachedSnapshot;
const current = this.#requiredSnapshot();
const inventory = structuredClone(current.inventory);
deepFreeze(inventory);
const variables = structuredClone([...current.variablesById.values()]);
deepFreeze(variables);
const frozenMap = (entries) => new FrozenResourceMap(entries.filter((resource) => resource.id).map((resource) => [resource.id, resource]));
const snapshot = {
projectPath: current.projectPath, projectRoot: current.projectRoot,
revision: current.revision, revisionAlgorithm: current.revisionAlgorithm,
selectionGeneration: current.selectionGeneration,
descriptor: inventory.descriptor, settings: inventory.settings,
scenesById: frozenMap(inventory.scenes), actorsById: frozenMap(inventory.actors),
triggersById: frozenMap(inventory.triggers), assetsById: frozenMap(inventory.assets),
palettesById: frozenMap(inventory.palettes), variablesById: frozenMap(variables),
diagnostics: inventory.diagnostics,
counts: deepFreeze({ ...current.counts }),
health: deepFreeze({ ...current.health, byCode: { ...current.health.byCode } }),
inventory,
};
this.#detachedSnapshot = Object.freeze(snapshot);
return this.#detachedSnapshot;
}
async legacyInventory() {
this.#hit();
return this.#requiredInventory();
}
settings() {
this.#hit();
return this.#requiredInventory().settings;
}
authoredFile(relativePath) {
this.#hit();
return this.#files.get(this.#canonicalRelativePath(relativePath));
}
sceneForResource(resourcePath) {
this.#hit();
const sceneId = this.#sceneIdsByResource.get(resourcePath);
return sceneId ? this.#requiredSnapshot().scenesById.get(sceneId) : undefined;
}
scene(sceneId) {
this.#hit();
return this.#requiredSnapshot().scenesById.get(sceneId);
}
actor(sceneId, actorId) {
this.#hit();
return this.#actors.get(`${sceneId}\u0000${actorId}`);
}
trigger(sceneId, triggerId) {
this.#hit();
return this.#triggers.get(`${sceneId}\u0000${triggerId}`);
}
asset(query) {
this.#hit();
let matches;
if (query.assetId && query.assetPath) {
matches = (this.#assetsById.get(query.assetId) ?? []).filter((item) => item.resourcePath === query.assetPath || item.metadataPath === query.assetPath || item.filename === query.assetPath);
}
else if (query.assetId)
matches = this.#assetsById.get(query.assetId) ?? [];
else if (query.assetPath)
matches = this.#assetsByPath.get(query.assetPath) ?? [];
else
matches = [];
const first = matches[0];
if (matches.length === 1 && first)
return { status: "unique", matches, asset: first };
return matches.length === 0 ? { status: "missing", matches } : { status: "ambiguous", matches };
}
variable(variableId) {
this.#hit();
return this.#requiredSnapshot().variablesById.get(variableId);
}
palette(paletteId) {
this.#hit();
return this.#requiredSnapshot().palettesById.get(paletteId);
}
resource(identity) {
this.#hit();
const snapshot = this.#requiredSnapshot();
switch (identity.type) {
case "scene": return snapshot.scenesById.get(identity.id);
case "actor": return identity.sceneId
? this.#actors.get(`${identity.sceneId}\u0000${identity.id}`) : snapshot.actorsById.get(identity.id);
case "trigger": return identity.sceneId
? this.#triggers.get(`${identity.sceneId}\u0000${identity.id}`) : snapshot.triggersById.get(identity.id);
case "asset": return snapshot.assetsById.get(identity.id);
case "palette": return snapshot.palettesById.get(identity.id);
case "variable": return snapshot.variablesById.get(identity.id);
default: return undefined;
}
}
resourcesByKind(kind) {
this.#hit();
return this.#kind.get(kind) ?? [];
}
resourcesByScene(sceneId) {
this.#hit();
const scene = this.#requiredSnapshot().scenesById.get(sceneId);
return scene ? [scene, ...scene.actors, ...scene.triggers] : [];
}
assetUsers(assetId) {
return this.effectiveReferences({ type: "asset", id: assetId });
}
effectiveReferences(target) {
this.#hit();
const refs = this.#incoming.get(graphKey(target)) ?? [];
return target.sceneId ? refs.filter((item) => item.target.sceneId === target.sceneId) : refs;
}
effectiveOutgoingReferences(owner) {
this.#hit();
const refs = this.#outgoing.get(graphKey(owner)) ?? [];
return owner.sceneId ? refs.filter((item) => item.owner.sceneId === owner.sceneId) : refs;
}
structuralReferences(target) {
this.#hit();
const refs = this.#structural.get(graphKey(target)) ?? [];
return target.sceneId ? refs.filter((item) => item.target.sceneId === target.sceneId) : refs;
}
async noteCommittedPaths(paths) {
await this.ensureFresh();
for (const candidate of paths) {
assertWithinProject(this.projectRoot, candidate, this.#platform);
const platformCandidate = this.#platform === "win32"
? candidate.replace(/\\/g, path.sep)
: candidate;
const absolute = path.isAbsolute(platformCandidate)
? platformCandidate
: path.join(this.projectRoot, platformCandidate);
assertWithinProject(this.projectRoot, absolute, this.#platform);
const relative = this.#canonicalRelativePath(projectRelativePath(this.projectRoot, absolute, this.#platform));
if (this.#isObserved(relative))
this.#dirty.add(relative);
}
if (this.#dirty.size > 0)
await this.#refreshDirty();
}
invalidate(_reason) {
if (this.#state !== "disposed") {
this.#state = "invalidated";
this.#degraded = true;
}
}
stats() {
return {
state: this.#state,
selectionGeneration: this.selectionGeneration,
indexGeneration: this.indexGeneration,
semanticGeneration: this.semanticGeneration,
...this.#counters,
};
}
dispose() {
this.#state = "disposed";
this.#dirty.clear();
this.#watcher?.close();
this.#watcher = undefined;
}
#hit() {
this.#counters.cacheHits += 1;
}
#requiredInventory() {
if (!this.#inventory)
throw new GameStudioProjectError("STALE_PROJECT", "The project world index has not been initialized");
return this.#inventory;
}
#requiredSnapshot() {
if (!this.#snapshot)
throw new GameStudioProjectError("STALE_PROJECT", "The project world index has not been initialized");
return this.#snapshot;
}
async #initialize() {
let discovery = await discoverProject(this.#requestedPath);
this.projectRoot = discovery.projectRoot;
if (this.#platform === "win32") {
const canonicalDescriptor = await realpath(discovery.projectPath);
assertWithinProject(this.projectRoot, canonicalDescriptor, this.#platform);
if (!this.#pathsMatch(canonicalDescriptor, discovery.projectPath)) {
throw new GameStudioProjectError("STALE_PROJECT", "The selected native game project descriptor changed during collection", discovery.projectPath);
}
discovery = { ...discovery, projectPath: canonicalDescriptor };
}
this.#discovery = discovery;
this.projectPath = discovery.projectPath;
this.#startWatcher();
await this.#rebuild(false);
if (this.#dirty.size > 0)
await this.#applyDirty([...this.#dirty]);
}
#startWatcher() {
try {
this.#watcher = this.#watcherFactory(this.projectRoot, { recursive: true, persistent: false }, (_event, filename) => {
if (this.#state === "disposed")
return;
if (!filename) {
this.#degraded = true;
this.#state = "invalidated";
return;
}
const relative = this.#canonicalRelativePath(filename.toString());
if (!this.#isObserved(relative))
return;
this.#counters.dirtyEvents += 1;
this.#dirty.add(relative);
if (this.#dirty.size > MAXIMUM_DIRTY_PATHS) {
this.#degraded = true;
this.#state = "invalidated";
}
else if (this.#state === "ready")
this.#state = "dirty";
});
this.#watcher.on("error", () => {
if (this.#state !== "disposed") {
this.#watchUnavailable = true;
this.#degraded = true;
this.#state = "invalidated";
}
});
}
catch {
this.#watchUnavailable = true;
this.#watchUnsupported = true;
this.#degraded = true;
}
}
#isObserved(relative) {
if (!relative || relative.includes("\u0000") || relative === ".")
return false;
const observed = this.#platform === "win32" ? relative.toLowerCase() : relative;
const descriptor = this.#platform === "win32"
? path.basename(this.projectPath).toLowerCase()
: path.basename(this.projectPath);
return observed === descriptor || observed.startsWith("project/")
|| observed.startsWith("assets/") || observed.startsWith("plugins/")
|| observed === "project" || observed === "assets" || observed === "plugins";
}
#canonicalRelativePath(candidate) {
const normalized = this.#platform === "win32"
? candidate.replace(/\\/g, "/")
: candidate.split(path.sep).join("/");
if (this.#platform !== "win32")
return normalized;
return this.#windowsPaths.get(normalized.toLowerCase()) ?? normalized;
}
#pathsMatch(left, right) {
if (left === right)
return true;
return this.#platform === "win32" &&
isPathWithinRoot(left, right, this.#platform) &&
isPathWithinRoot(right, left, this.#platform);
}
#setIndexedFile(relative, file) {
this.#files.set(relative, file);
if (this.#platform === "win32")
this.#windowsPaths.set(relative.toLowerCase(), relative);
}
#deleteIndexedFile(relative) {
this.#files.delete(relative);
if (this.#platform === "win32")
this.#windowsPaths.delete(relative.toLowerCase());
}
async #walk(directory, discovered) {
let canonical;
try {
canonical = await realpath(directory);
}
catch (error) {
if (systemError(error, "ENOENT") || systemError(error, "ENOTDIR"))
return;
throw error;
}
assertWithinProject(this.projectRoot, canonical, this.#platform);
const entries = await readdir(canonical, { withFileTypes: true });
this.#counters.directoryReads += 1;
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const candidate = path.join(canonical, 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);
}
assertWithinProject(this.projectRoot, destination, this.#platform);
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Project contains an unsupported authored symbolic link: ${candidate}`, candidate);
}
else if (entry.isDirectory()) {
await this.#walk(candidate, discovered);
}
else if (entry.isFile()) {
const indexed = await this.#readFile(candidate);
discovered.set(indexed.relativePath, indexed);
}
}
}
async #readFile(filename) {
const canonical = await realpath(filename);
assertWithinProject(this.projectRoot, canonical, this.#platform);
if (!this.#pathsMatch(canonical, filename)) {
throw new GameStudioProjectError("STALE_PROJECT", "An authored project resource changed during collection", filename);
}
const info = await stat(canonical, { bigint: true });
const bytes = await readFile(canonical);
this.#counters.authoredFileReads += 1;
this.#counters.authoredBytesRead += bytes.byteLength;
const relativePath = projectRelativePath(this.projectRoot, canonical, this.#platform);
const fileCategory = category(relativePath, path.basename(this.projectPath));
const semanticResource = relativePath.endsWith(".gbsproj") || relativePath.endsWith(".gbsres");
// Font mappings are useful cached projections, but arbitrary authored JSON
// remains revision-significant even when it is opaque or malformed.
const optionalJson = relativePath.endsWith(".json") && relativePath.startsWith("assets/fonts/");
const parseJson = semanticResource || optionalJson;
let json;
if (parseJson) {
try {
const parsed = JSON.parse(bytes.toString("utf8"));
if (!isObject(parsed))
throw new Error("Native project resource must contain a JSON object");
json = parsed;
this.#counters.resourceParses += 1;
}
catch (error) {
if (optionalJson)
json = undefined;
else if (fileCategory === "asset-metadata") {
const physical = filename.slice(0, -".gbsres".length);
let hasPhysical = true;
try {
await lstat(physical);
}
catch (missing) {
if (!systemError(missing, "ENOENT") && !systemError(missing, "ENOTDIR"))
throw missing;
hasPhysical = false;
}
if (hasPhysical) {
throw new GameStudioProjectError("INVALID_RESOURCE", `Could not parse native project resource ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, filename);
}
json = undefined;
}
else {
throw new GameStudioProjectError("INVALID_RESOURCE", `Could not parse native project resource ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, filename);
}
}
}
const pngHeader = relativePath.toLowerCase().endsWith(".png") && bytes.length >= 24
&& bytes.subarray(0, 8).equals(PNG_SIGNATURE)
? { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } : undefined;
return {
relativePath,
category: fileCategory,
identity: { dev: info.dev, ino: info.ino, size: info.size, mtimeNs: info.mtimeNs, ctimeNs: info.ctimeNs },
sha256: createHash("sha256").update(bytes).digest("hex"),
...(json ? { json } : {}),
...(pngHeader ? { pngHeader } : {}),
};
}
async #rebuild(fallback) {
const discovery = this.#discovery;
if (!discovery)
throw new GameStudioProjectError("STALE_PROJECT", "The project selection is unavailable");
const root = await realpath(this.projectRoot);
if (!this.#pathsMatch(root, this.projectRoot)) {
throw new GameStudioProjectError("STALE_PROJECT", "The selected project root changed", root);
}
const descriptor = await realpath(this.projectPath);
if (!this.#pathsMatch(descriptor, this.projectPath)) {
throw new GameStudioProjectError("STALE_PROJECT", "The selected project descriptor changed", descriptor);
}
const discovered = new Map();
const descriptorFile = await this.#readFile(descriptor);
discovered.set(descriptorFile.relativePath, descriptorFile);
for (const folder of ["project", "assets", "plugins"]) {
await this.#walk(path.join(root, folder), discovered);
}
const previousPluginHashes = new Map([...this.#files].filter(([name]) => name.startsWith("plugins/"))
.map(([name, item]) => [name, item.sha256]));
this.#files.clear();
this.#windowsPaths.clear();
for (const [relative, item] of discovered)
this.#setIndexedFile(relative, item);
const descriptorRelative = path.basename(this.projectPath);
this.#tree = new ProjectRevisionTree([...discovered.values()]
.filter((item) => isPublicProjectRevisionPath(item.relativePath, descriptorRelative)));
this.#counters.fullHashPasses += 1;
this.#counters.fullInventoryBuilds += 1;
if (fallback)
this.#counters.fallbackFullRebuilds += 1;
const nextPluginHashes = new Map([...discovered].filter(([name]) => name.startsWith("plugins/"))
.map(([name, item]) => [name, item.sha256]));
if (this.semanticGeneration === 0 || previousPluginHashes.size !== nextPluginHashes.size
|| [...nextPluginHashes].some(([name, digest]) => previousPluginHashes.get(name) !== digest)) {
this.semanticGeneration += 1;
}
this.#inAuthoritativeRebuild = true;
try {
await this.#publish();
}
finally {
this.#inAuthoritativeRebuild = false;
}
// An editor can save while the cold census or verification is underway. Do
// not discard those watcher events: reconcile them against their final
// authored bytes before advertising a coherent generation.
if (this.#dirty.size > 0)
await this.#applyDirty([...this.#dirty]);
// A completed authoritative scan already reconciles a watcher that failed
// during that same scan. Retain its unavailability separately: callers can
// explicitly request another strong verification without duplicating the
// cold census or turning every cache hit into a project-wide scan.
this.#degraded = this.#watchUnsupported;
this.#state = this.#degraded ? "invalidated" : "ready";
}
async #refreshDirty() {
if (this.#refresh) {
await this.#refresh;
return;
}
const dirty = [...this.#dirty];
this.#dirty.clear();
const pending = this.#applyDirty(dirty);
this.#refresh = pending;
try {
await pending;
}
catch (error) {
this.invalidate("The authored project changed ambiguously");
throw error;
}
finally {
this.#refresh = undefined;
}
}
async #applyDirty(dirty) {
let semanticChange = false;
const changedPaths = new Set();
const descriptorRelative = path.basename(this.projectPath);
for (const observed of new Set(dirty)) {
this.#dirty.delete(observed);
let relative = this.#canonicalRelativePath(observed);
let absolute = path.join(this.projectRoot, relative);
assertWithinProject(this.projectRoot, absolute, this.#platform);
let entry;
try {
entry = await lstat(absolute, { bigint: true });
}
catch (error) {
if (!systemError(error, "ENOENT") && !systemError(error, "ENOTDIR"))
throw error;
for (const existing of [...this.#files.keys()]) {
if (existing !== relative && !existing.startsWith(`${relative}/`))
continue;
this.#deleteIndexedFile(existing);
if (isPublicProjectRevisionPath(existing, descriptorRelative))
this.#tree.remove(existing);
if (existing.startsWith("plugins/"))
semanticChange = true;
this.#counters.changedPaths += 1;
changedPaths.add(existing);
}
if (relative === descriptorRelative)
throw new GameStudioProjectError("STALE_PROJECT", "The selected project descriptor was removed", absolute);
continue;
}
if (entry.isSymbolicLink()) {
const destination = await realpath(absolute).catch(() => {
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Project contains an unreadable symbolic link: ${absolute}`, absolute);
});
assertWithinProject(this.projectRoot, destination, this.#platform);
throw new GameStudioProjectError("UNSAFE_SYMLINK", `Project contains an unsupported authored symbolic link: ${absolute}`, absolute);
}
if (this.#platform === "win32") {
const canonical = await realpath(absolute);
assertWithinProject(this.projectRoot, canonical, this.#platform);
if (!this.#pathsMatch(canonical, absolute)) {
throw new GameStudioProjectError("STALE_PROJECT", "An authored project resource changed during collection", absolute);
}
absolute = canonical;
relative = this.#canonicalRelativePath(projectRelativePath(this.projectRoot, canonical, this.#platform));
}
if (entry.isDirectory()) {
const descendants = new Map();
await this.#walk(absolute, descendants);
if (this.#discovery?.format === "legacy") {
for (const existing of this.#files.keys()) {
if (!existing.startsWith(`${relative}/`) || !isLegacyImagePath(existing) || descendants.has(existing))
continue;
this.#deleteIndexedFile(existing);
if (isPublicProjectRevisionPath(existing, descriptorRelative))
this.#tree.remove(existing);
if (existing.startsWith("plugins/"))
semanticChange = true;
this.#counters.changedPaths += 1;
changedPaths.add(existing);
}
}
for (const indexed of descendants.values()) {
const previous = this.#files.get(indexed.relativePath);
if (previous?.sha256 === indexed.sha256)
continue;
this.#setIndexedFile(indexed.relativePath, indexed);
if (isPublicProjectRevisionPath(indexed.relativePath, descriptorRelative))
this.#tree.upsert(indexed.relativePath, indexed.sha256);
if (indexed.relativePath.startsWith("plugins/"))
semanticChange = true;
this.#counters.changedPaths += 1;
changedPaths.add(indexed.relativePath);
}
continue;
}
if (!entry.isFile())
continue;
const previous = this.#files.get(relative);
if (previous && previous.identity.dev === entry.dev && previous.identity.ino === entry.ino
&& previous.identity.size === entry.size && previous.identity.mtimeNs === entry.mtimeNs
&& previous.identity.ctimeNs === entry.ctimeNs) {
const canonical = await realpath(absolute);
assertWithinProject(this.projectRoot, canonical, this.#platform);
if (!this.#pathsMatch(canonical, absolute)) {
throw new GameStudioProjectError("STALE_PROJECT", "An authored project resource changed during collection", absolute);
}
continue;
}
const indexed = await this.#readFile(absolute);
if (previous?.sha256 === indexed.sha256) {
this.#setIndexedFile(relative, indexed);
continue;
}
this.#setIndexedFile(relative, indexed);
if (isPublicProjectRevisionPath(relative, descriptorRelative))
this.#tree.upsert(relative, indexed.sha256);
if (relative.startsWith("plugins/"))
semanticChange = true;
this.#counters.changedPaths += 1;
changedPaths.add(relative);
}
if (semanticChange)
this.semanticGeneration += 1;
if (changedPaths.size > 0)
await this.#publish([...changedPaths]);
this.#state = "ready";
}
async #publish(changedPaths = []) {
const discovery = this.#discovery;
if (!discovery)
throw new GameStudioProjectError("STALE_PROJECT", "The project selection is unavailable");
const descriptorFile = this.#files.get(path.basename(this.projectPath));
if (!descriptorFile?.json)
throw new GameStudioProjectError("STALE_PROJECT", "The selected project descriptor is unavailable");
const descriptor = descriptorFile.json;
const format = Array.isArray(descriptor.scenes) ? "legacy" : "distributed";
const currentDiscovery = {
projectPath: this.projectPath, projectRoot: this.projectRoot, descriptor,
version: typeof descriptor._version === "string" ? descriptor._version : null, format,
};
this.#discovery = currentDiscovery;
if (await this.#publishChangedOwners(currentDiscovery, changedPaths))
return;
if (changedPaths.length > 0 && !this.#inAuthoritativeRebuild) {
this.#counters.fullInventoryBuilds += 1;
}
const inventory = this.#buildInventory(currentDiscovery);
const variablesFile = format === "legacy" ? descriptorFile : this.#files.get("project/variables.gbsres");
const entries = variablesFile?.json?.variables;
const variables = (Array.isArray(entries) ? entries : [])
.filter(isObject).map((item) => materialize(item, variablesFile?.relativePath ?? "project/variables.gbsres"));
const map = (values) => new Map(values.filter((item) => item.id).map((item) => [item.id, item]));
this.revision = this.#tree.revision();
this.#inventory = inventory;
this.#snapshot = {
projectPath: this.projectPath, projectRoot: this.projectRoot,
revision: this.revision, revisionAlgorithm: PROJECT_REVISION_ALGORITHM,
selectionGeneration: this.selectionGeneration, descriptor, settings: inventory.settings,
scenesById: map(inventory.scenes), actorsById: map(inventory.actors),
triggersById: map(inventory.triggers), assetsById: map(inventory.assets),
palettesById: map(inventory.palettes), variablesById: map(variables),
diagnostics: inventory.diagnostics,
counts: { ...inventory.counts, variables: variables.length },
health: health(inventory.diagnostics), inventory,
};
this.#reindex(inventory, variables);
await this.#publishGraph(inventory, changedPaths);
this.indexGeneration += 1;
}
async #publishChangedOwners(discovery, changedPaths) {
const inventory = this.#inventory;
const previousSnapshot = this.#snapshot;
if (!inventory || !previousSnapshot || discovery.format !== "distributed" || changedPaths.length === 0)
return false;
// Opaque project/assets files can be reviewed helper inputs. Their warm
// updates must publish coverage and semantic freshness, not only revision.
if (!this.#reviewedCustomEvents.needsDependencyRefresh(this.#files, changedPaths) && changedPaths.every((relative) => {
const file = this.#files.get(relative);
return file ? file.category === "other" : !relative.endsWith(".gbsres")
&& !ASSET_EXTENSIONS.has(path.extname(relative).toLowerCase());
})) {
this.revision = this.#tree.revision();
this.#snapshot = { ...previousSnapshot, revision: this.revision };
this.indexGeneration += 1;
return true;
}
const replacements = [];
for (const relative of new Set(changedPaths)) {
const file = this.#files.get(relative);
if (!file?.json || (file.category !== "actor" && file.category !== "trigger"))
return false;
const sceneId = this.#sceneIdsByResource.get(relative);
if (!sceneId)
return false;
const scene = previousSnapshot.scenesById.get(sceneId);
if (!scene)
return false;
const previous = (file.category === "actor" ? scene.actors : scene.triggers)
.find((resource) => resource.resourcePath === relative);
if (!previous)
return false;
const next = materialize(file.json, relative);
if (next.id !== previous.id)
return false;
replacements.push({ previous, next, scene, kind: file.category });
}
if (replacements.length === 0)
return false;
const replacementByPath = new Map(replacements.map((item) => [item.previous.resourcePath, item]));
const diagnostics = inventory.diagnostics.filter((item) => !item.resourcePath || !replacementByPath.has(item.resourcePath));
const spriteIds = new Set(inventory.assets.filter((asset) => asset.type === "sprite").map((asset) => asset.id));
const actorsById = previousSnapshot.actorsById;
const triggersById = previousSnapshot.triggersById;
for (const replacement of replacements) {
const { previous, next, scene, kind } = replacement;
const sceneResources = kind === "actor" ? scene.actors : scene.triggers;
const scenePosition = sceneResources.indexOf(previous);
if (scenePosition < 0)
return false;
sceneResources[scenePosition] = next;
const flatResources = kind === "actor" ? inventory.actors : inventory.triggers;
const flatPosition = flatResources.indexOf(previous);
if (flatPosition < 0)
return false;
flatResources[flatPosition] = next;
const scoped = `${scene.id}\u0000${next.id}`;
if (kind === "actor") {
this.#actors.set(scoped, next);
actorsById.set(next.id, next);
const spriteId = asString(next.spriteSheetId);
if (spriteId && !spriteIds.has(spriteId))
diagnostics.push({ severity: "warning", code: "SPRITE_NOT_FOUND",
message: `Actor ${next.name || next.id} references missing sprite ${spriteId}`, resourcePath: next.resourcePath });
const x = asNumber(next.x), y = asNumber(next.y);
const width = asNumber(scene.width), height = asNumber(scene.height);
if (x !== null && y !== null && width !== null && height !== null && (x < 0 || y < 0 || x >= width || y >= height)) {
diagnostics.push({ severity: "error", code: "ACTOR_OUT_OF_BOUNDS",
message: `Actor ${next.name || next.id} is outside the ${width} by ${height} scene`, resourcePath: next.resourcePath });
}
}
else {
this.#triggers.set(scoped, next);
triggersById.set(next.id, next);
const x = asNumber(next.x), y = asNumber(next.y);
const width = asNumber(next.width), height = asNumber(next.height);
const sceneWidth = asNumber(scene.width), sceneHeight = asNumber(scene.height);
if (x !== null && y !== null && width !== null && height !== null && sceneWidth !== null && sceneHeight !== null
&& (x < 0 || y < 0 || width <= 0 || height <= 0 || x + width > sceneWidth || y + height > sceneHeight)) {
diagnostics.push({ severity: "error", code: "TRIGGER_OUT_OF_BOUNDS",
message: `Trigger ${next.name || next.id} extends outside the ${sceneWidth} by ${sceneHeight} scene`, resourcePath: next.resourcePath });
}
}
}
inventory.diagnostics = diagnostics;
this.revision = this.#tree.revision();
this.#snapshot = { ...previousSnapshot, revision: this.revision, diagnostics,
health: health(diagnostics), inventory };
await this.#publishGraph(inventory, changedPaths);
this.indexGeneration += 1;
return true;
}
async #observeCompiler() {
const profiles = this.#reviewedCustomEvents.activeCompilerProfiles(this.#files);
return profiles.length ? observeReviewedCompiler(this.projectRoot, profiles, this.#toolchainRoot, this.#reviewedCustomEvents.activeCompilerDependencies(this.#files)) : undefined;
}
async #refreshCompiler() {
if (!this.#reviewedCustomEvents.activeCompilerProfiles(this.#files).length)
return;
if (this.#compilerRefresh)
return this.#compilerRefresh;
const pending = (async () => {
const next = await this.#observeCompiler();
if (next?.fingerprint === this.#compilerObservation?.fingerprint)
return;
if (this.#state === "disposed" || !this.#inventory)
throw new GameStudioProjectError("STALE_PROJECT", "The selected project index is unavailable");
await this.#publishGraph(this.#inventory, [], next);
this.indexGeneration += 1;
})();
this.#compilerRefresh = pending;
try {
await pending;
}
finally {
this.#compilerRefresh = undefined;
}
}
async #publishGraph(inventory, changedPaths, compiler) {
this.#compilerObservation = compiler ?? await this.#observeCompiler();
const previousGraph = this.#graph;
this.#graph = await buildWorldScriptGraph(this.projectRoot, {
inventory,
filesByPath: this.#files,
changedPaths,
reviewedCustomEvents: this.#reviewedCustomEvents,
...(this.#compilerObservation ? { reviewedCompiler: this.#compilerObservation } : {}),
...(previousGraph ? { previousGraph } : {}),
});
if (previousGraph && this.#reviewedCustomEvents.contracts.length > 0
&& previousGraph.customEventFingerprint !== this.#graph.customEventFingerprint) {
// Required definitions can be public resources as well as plugin files.
// Their semantic binding changes even when the handler bytes do not.
this.semanticGeneration += 1;
}
this.coverage = this.#graph.coverage;
if (!this.#reviewedCustomEvents.needsDependencyRefresh(this.#files, changedPaths) && previousGraph && changedPaths.length > 0
&& changedPaths.every((relative) => {
const file = this.#files.get(relative);
return file?.category === "actor" || file?.category === "trigger";
})) {
const changed = new Set(changedPaths);
const removeReferences = (map) => {
for (const [key, references] of map) {
const remaining = references.filter((item) => !changed.has(item.owner.resourcePath));
if (remaining.length !== references.length) {
if (remaining.length > 0)
map.set(key, remaining);
else
map.delete(key);
}
}
};
removeReferences(this.#incoming);
removeReferences(this.#outgoing);
removeReferences(this.#structural);
for (const ref of this.#graph.references) {
if (!changed.has(ref.owner.resourcePath))
continue;
const incoming = this.#incoming.get(graphKey(ref.target)) ?? [];
incoming.push(ref);
this.#incoming.set(graphKey(ref.target), incoming);
const owner = { type: ref.owner.resourceType, id: ref.owner.resourceId };
const outgoing = this.#outgoing.get(graphKey(owner)) ?? [];
outgoing.push(ref);
this.#outgoing.set(graphKey(owner), outgoing);
}
for (const ref of this.#graph.structuralReferences) {
if (!changed.has(ref.owner.resourcePath))
continue;
const existing = this.#structural.get(graphKey(ref.target)) ?? [];
existing.push(ref);
this.#structural.set(graphKey(ref.target), existing);
}
return;
}
for (const map of [this.#incoming, this.#outgoing, this.#structural])
map.clear();
for (const ref of this.#graph.references) {
const incoming = this.#incoming.get(graphKey(ref.target)) ?? [];
incoming.push(ref);
this.#incoming.set(graphKey(ref.target), incoming);
const owner = { type: ref.owner.resourceType, id: ref.owner.resourceId };
const outgoing = this.#outgoing.get(graphKey(owner)) ?? [];
outgoing.push(ref);
this.#outgoing.set(graphKey(owner), outgoing);
}
for (const ref of this.#graph.structuralReferences) {
const existing = this.#structural.get(graphKey(ref.target)) ?? [];
existing.push(ref);
this.#structural.set(graphKey(ref.target), existing);
}
}
#reindex(inventory, variables) {
this.#actors.clear();
this.#triggers.clear();
this.#sceneIdsByResource.clear();
this.#assetsById.clear();
this.#assetsByPath.clear();
this.#palettesById.clear();
this.#kind.clear();
this.#kind.set("scene", inventory.scenes);
this.#kind.set("actor", inventory.actors);
this.#kind.set("trigger", inventory.triggers);
this.#kind.set("palette", inventory.palettes);
this.#kind.set("asset", inventory.assets);
this.#kind.set("variable", variables);
for (const palette of inventory.palettes) {
const matches = this.#palettesById.get(palette.id) ?? [];
matches.push(palette);
this.#palettesById.set(palette.id, matches);
}
for (const scene of inventory.scenes) {
this.#sceneIdsByResource.set(scene.resourcePath, scene.id);
for (const actor of scene.actors) {
this.#actors.set(`${scene.id}\u0000${actor.id}`, actor);
this.#sceneIdsByResource.set(actor.resourcePath, scene.id);
}
for (const trigger of scene.triggers) {
this.#triggers.set(`${scene.id}\u0000${trigger.id}`, trigger);
this.#sceneIdsByResource.set(trigger.resourcePath, scene.id);
}
}
for (const asset of inventory.assets) {
if (asset.id) {
const matches = this.#assetsById.get(asset.id) ?? [];
matches.push(asset);
this.#assetsById.set(asset.id, matches);
}
for (const key of new Set([asset.resourcePath, asset.metadataPath, asset.filename].filter((item) => Boolean(item)))) {
const matches = this.#assetsByPath.get(key) ?? [];
matches.push(asset);
this.#assetsByPath.set(key, matches);
}
}
}
#buildInventory(discovery) {
const { descriptor, format } = discovery;
const diagnostics = [];
const scenes = [];
const palettes = [];
let settings = {};
const projectFiles = [...this.#files.values()].filter((item) => item.relativePath.startsWith("project/"))
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
if (format === "legacy") {
settings = isObject(descriptor.settings) ? descriptor.settings : {};
for (const candidate of Array.isArray(descriptor.scenes) ? descriptor.scenes : []) {
if (!isObject(candidate))
continue;
const resourcePath = path.basename(this.projectPath);
scenes.push({ ...materialize(candidate, resourcePath),
actors: (Array.isArray(candidate.actors) ? candidate.actors : []).filter(isObject)
.map((actor) => materialize(actor, resourcePath)),
triggers: (Array.isArray(candidate.triggers) ? candidate.triggers : []).filter(isObject)
.map((trigger) => materialize(trigger, resourcePath)),
});
}
for (const item of Array.isArray(descriptor.palettes) ? descriptor.palettes : []) {
if (isObject(item))
palettes.push(materialize(item, path.basename(this.projectPath)));
}
}
else {
if (projectFiles.length === 0)
diagnostics.push({ severity: "error", code: "PROJECT_RESOURCES_MISSING", message: "Distributed project is missing its project/ resource directory" });
const settingsFile = this.#files.get("project/settings.gbsres");
if (settingsFile?.json)
settings = settingsFile.json;
else
diagnostics.push({ severity: "warning", code: "SETTINGS_MISSING", message: "Distributed project has no project/settings.gbsres resource" });
const sceneFiles = [];
const actorsBySceneDirectory = new Map();
const triggersBySceneDirectory = new Map();
for (const file of projectFiles) {
if (file.category === "scene")
sceneFiles.push(file);
else if (file.category === "actor" || file.category === "trigger") {
const marker = file.category === "actor" ? "/actors/" : "/triggers/";
const markerPosition = file.relativePath.indexOf(marker);
if (markerPosition < 0 || !file.json)
continue;
const directory = file.relativePath.slice(0, markerPosition);
const buckets = file.category === "actor" ? actorsBySceneDirectory : triggersBySceneDirectory;
const entries = buckets.get(directory) ?? [];
entries.push(file);
buckets.set(directory, entries);
}
}
for (const sceneFile of sceneFiles) {
if (!sceneFile.json)
continue;
const directory = path.posix.dirname(sceneFile.relativePath);
const actors = (actorsBySceneDirectory.get(directory) ?? [])
.map((item) => materialize(item.json, item.relativePath));
const triggers = (triggersBySceneDirectory.get(directory) ?? [])
.map((item) => materialize(item.json, item.relativePath));
scenes.push({ ...materialize(sceneFile.json, sceneFile.relativePath), actors, triggers });
}
for (const file of projectFiles.filter((item) => item.category === "palette")) {
if (!file.json)
continue;
const palette = file.json;
palettes.push(materialize(palette, file.relativePath));
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: file.relativePath,
});
}
}
const files = [...this.#files.values()].filter((item) => item.relativePath.startsWith("assets/"))
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
const physical = files.filter((item) => ASSET_EXTENSIONS.has(path.extname(item.relativePath).toLowerCase()));
const physicalPaths = new Set(physical.map((item) => item.relativePath));
const assets = [];
const legacyRootMetadata = new Map();
if (format === "legacy") {
for (const folder of ["backgrounds", "sprites"]) {
const prefix = `assets/${folder}/`;
const physicalFilenames = new Set(physical.filter((file) => file.relativePath.startsWith(prefix) && isPhysicalPng(file))
.map((file) => file.relativePath.slice(prefix.length)));
legacyRootMetadata.set(folder, legacyPhysicalAssetMetadataLookup(discovery, folder, undefined, physicalFilenames));
}
}
for (const file of physical) {
const parts = file.relativePath.split("/");
const metadata = this.#files.get(`${file.relativePath}.gbsres`)?.json;
const nativeMetadata = metadata ?? (isPhysicalPng(file)
? legacyRootMetadata.get(parts[1] ?? "")?.(parts.slice(2).join("/")) ?? undefined
: undefined);
if (!metadata && format === "distributed" && parts[1] !== "ui")
diagnostics.push({
severity: "info", code: "ASSET_METADATA_MISSING",
message: `Asset ${file.relativePath} has no .gbsres metadata yet; the game editor can import it on refresh`, resourcePath: file.relativePath,
});
const extension = path.extname(file.relativePath);
assets.push({ ...nativeMetadata, id: asString(nativeMetadata?.id),
name: asString(nativeMetadata?.name, path.basename(file.relativePath, extension)),
type: assetType(parts[1] ?? "asset", metadata),
filename: asString(nativeMetadata?.filename, path.basename(file.relativePath)),
resourcePath: file.relativePath, metadataPath: metadata ? `${file.relativePath}.gbsres` : null,
hasMetadata: Boolean(metadata),
width: asNumber(nativeMetadata?.imageWidth) ?? file.pngHeader?.width ?? asNumber(nativeMetadata?.width),
height: asNumber(nativeMetadata?.imageHeight) ?? file.pngHeader?.height ?? asNumber(nativeMetadata?.height),
});
}
// The cold/warm census only caches canonical regular files and rejects symlinks.
// Legacy descriptors identify plugin images by plugin name plus relative filename.
for (const candidate of legacyPluginAssetCandidates(discovery)) {
const file = this.#files.get(candidate.relativePath);
if (!file || !isPhysicalPng(file))
continue;
const { metadata, folder } = candidate;
const extension = path.posix.extname(file.relativePath);
assets.push({ ...metadata, id: asString(metadata.id),
name: asString(metadata.name, path.posix.basename(file.relativePath, extension)),
type: assetType(folder, undefined),
filename: asString(metadata.filename, path.posix.basename(file.relativePath)),
resourcePath: file.relativePath, metadataPath: null, hasMetadata: false,
width: asNumber(metadata.imageWidth) ?? file.pngHeader.width,
height: asNumber(metadata.imageHeight) ?? file.pngHeader.height,
});
}
for (const file of files.filter((item) => item.relativePath.endsWith(".gbsres"))) {
const expectedAsset = file.relativePath.slice(0, -".gbsres".length);
if (!physicalPaths.has(expectedAsset))
diagnostics.push({ severity: "warning", code: "ASSET_FILE_MISSING",
message: `Asset metadata ${file.relativePath} has no corresponding asset file`, resourcePath: file.relativePath });
}
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 width = asNumber(scene.width);
const height = asNumber(scene.height);
if (typeof scene.collisions === "string" && width !== null && height !== null) {
const tiles = width * height;
if (Number.isSafeInteger(tiles) && tiles >= 0 && tiles <= 1_000_000) {
try {
decodeResourceBytes(scene.collisions, { maximumValues: tiles, 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 spriteId = asString(actor.spriteSheetId);
if (spriteId && !spriteIds.has(spriteId))
diagnostics.push({ severity: "warning", code: "SPRITE_NOT_FOUND",
message: `Actor ${actor.name || actor.id} references missing sprite ${spriteId}`, resourcePath: actor.resourcePath });
const x = asNumber(actor.x), y = asNumber(actor.y);
if (x !== null && y !== null && width !== null && height !== null && (x < 0 || y < 0 || x >= width || y >= height)) {
diagnostics.push({ severity: "error", code: "ACTOR_OUT_OF_BOUNDS",
message: `Actor ${actor.name || actor.id} is outside the ${width} by ${height} scene`, resourcePath: actor.resourcePath });
}
}
for (const trigger of scene.triggers) {
const x = asNumber(trigger.x), y = asNumber(trigger.y);
const triggerWidth = asNumber(trigger.width), triggerHeight = asNumber(trigger.height);
if (x !== null && y !== null && triggerWidth !== null && triggerHeight !== null && width !== null && height !== null
&& (x < 0 || y < 0 || triggerWidth <= 0 || triggerHeight <= 0 || x + triggerWidth > width || y + triggerHeight > height)) {
diagnostics.push({ severity: "error", code: "TRIGGER_OUT_OF_BOUNDS",
message: `Trigger ${trigger.name || trigger.id} extends outside the ${width} by ${height} scene`, resourcePath: trigger.resourcePath });
}
}
}
const settingsPath = format === "distributed" ? "project/settings.gbsres" : path.basename(this.projectPath);
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: settingsPath });
if (startScene) {
const x = asNumber(settings.startX), y = asNumber(settings.startY);
const width = asNumber(startScene.width), height = asNumber(startScene.height);
if (x !== null && y !== null && width !== null && height !== null && (x < 0 || y < 0 || x >= width || y >= height)) {
diagnostics.push({ severity: "error", code: "START_POSITION_OUT_OF_BOUNDS",
message: `Player start position is outside the ${width} by ${height} start scene`, resourcePath: startScene.resourcePath });
}
const mappings = isObject(settings.defaultPlayerSprites) ? settings.defaultPlayerSprites : {};
const sceneType = asString(startScene.type, asString(settings.defaultSceneTypeId, "TOPDOWN"));
const spriteId = asString(mappings[sceneType]);
if (!spriteId || !spriteIds.has(spriteId))
diagnostics.push({ severity: "error", code: "PLAYER_SPRITE_NOT_FOUND",
message: spriteId ? `Start scene type ${sceneType} references missing player sprite ${spriteId}`
: `Start scene type ${sceneType} has no default player sprite`, resourcePath: settingsPath });
}
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 },
};
}
}
//# sourceMappingURL=project-world-index.js.mapSHA-256: c1263d617e7643a188e80cdf286709836872a8ec5ec2b29b484dde75c3604f91