← Files ModRetro Chromatic PluginARCHIVED FILE
dist/world-script-graph.js
49.4 KB · Oct 2, 2026 · 00:37 UTC
import { createHash } from "node:crypto";
import { readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { PROJECT_RESPONSE_LIMITS, } from "./project-access.js";
import { resolveProjectPath } from "./project-files.js";
import { inventoryProject } from "./project.js";
import { extractReviewedEvent, REVIEWED_CUSTOM_EVENTS, } from "./custom-event-dependencies.js";
const SCRIPT_KEY = /^(?:script|[A-Za-z][A-Za-z0-9]*Script)$/;
const FORMAL_VARIABLE = /^V\d+$/;
const LOCAL_VARIABLE = /^L\d+$/;
// Official native handler IDs from GB Studio 4.3.2 src/lib/events, captured
// statically so graph inspection never depends on or executes installed code.
const NATIVE_EVENT_COMMANDS = new Set(`
EVENT_ACTOR_ACTIVATE EVENT_ACTOR_COLLISIONS_DISABLE EVENT_ACTOR_COLLISIONS_ENABLE
EVENT_ACTOR_DEACTIVATE EVENT_ACTOR_EFFECTS EVENT_ACTOR_EMOTE
EVENT_ACTOR_GET_DIRECTION EVENT_ACTOR_GET_POSITION EVENT_ACTOR_HIDE
EVENT_ACTOR_INVOKE EVENT_ACTOR_MOVE_CANCEL EVENT_ACTOR_MOVE_RELATIVE
EVENT_ACTOR_MOVE_TO EVENT_ACTOR_MOVE_TO_VALUE EVENT_ACTOR_PUSH
EVENT_ACTOR_SET_ANIMATE EVENT_ACTOR_SET_ANIMATION_SPEED EVENT_ACTOR_SET_COLLISION_BOX
EVENT_ACTOR_SET_DIRECTION EVENT_ACTOR_SET_FRAME EVENT_ACTOR_SET_FRAME_TO_VALUE
EVENT_ACTOR_SET_MOVEMENT_SPEED EVENT_ACTOR_SET_POSITION EVENT_ACTOR_SET_POSITION_RELATIVE
EVENT_ACTOR_SET_POSITION_TO_VALUE EVENT_ACTOR_SET_SPRITE EVENT_ACTOR_SET_STATE
EVENT_ACTOR_SHOW EVENT_ACTOR_START_UPDATE EVENT_ACTOR_STOP_UPDATE
EVENT_ADD_FLAGS EVENT_ADVENTURE_STATE_SET EVENT_AWAIT_INPUT
EVENT_CALL_CUSTOM_EVENT EVENT_CAMERA_LOCK EVENT_CAMERA_MOVE_TO
EVENT_CAMERA_PROPERTY_SET EVENT_CAMERA_SET_BOUNDS EVENT_CAMERA_SET_LOCK
EVENT_CAMERA_SET_POSITION EVENT_CAMERA_SHAKE EVENT_CHOICE
EVENT_CLEAR_DATA EVENT_CLEAR_FLAGS EVENT_COMMENT
EVENT_DATA_TABLE EVENT_DEC_VALUE EVENT_DEFINE_LABEL
EVENT_DIALOGUE_CLOSE_NONMODAL EVENT_ENGINE_FIELD_SET EVENT_ENGINE_FIELD_STORE
EVENT_FADE_IN EVENT_FADE_OUT EVENT_GBVM_SCRIPT
EVENT_GOTO_LABEL EVENT_GROUP EVENT_HIDE_SPRITES
EVENT_IDLE EVENT_IF EVENT_IF_ACTOR_AT_POSITION
EVENT_IF_ACTOR_DIRECTION EVENT_IF_ACTOR_DISTANCE_FROM_ACTOR EVENT_IF_ACTOR_RELATIVE_TO_ACTOR
EVENT_IF_COLOR_SUPPORTED EVENT_IF_CURRENT_SCENE_IS EVENT_IF_DEVICE_GBA
EVENT_IF_DEVICE_SGB EVENT_IF_ENGINE_FIELD EVENT_IF_ENGINE_FIELD_COMPARE
EVENT_IF_EXPRESSION EVENT_IF_FALSE EVENT_IF_FLAGS_COMPARE
EVENT_IF_INPUT EVENT_IF_SAVED_DATA EVENT_IF_TRUE
EVENT_IF_VALUE EVENT_IF_VALUE_COMPARE EVENT_INC_VALUE
EVENT_LAUNCH_PROJECTILE EVENT_LAUNCH_PROJECTILE_SLOT EVENT_LINK_CLOSE
EVENT_LINK_HOST EVENT_LINK_JOIN EVENT_LINK_TRANSFER
EVENT_LOAD_DATA EVENT_LOAD_PROJECTILE_SLOT EVENT_LOOP
EVENT_LOOP_FOR EVENT_LOOP_WHILE EVENT_LOOP_WHILE_EXPRESSION
EVENT_MENU EVENT_MUSIC_PLAY EVENT_MUSIC_STOP
EVENT_MUTE_CHANNEL EVENT_OVERLAY_HIDE EVENT_OVERLAY_MOVE_TO
EVENT_OVERLAY_SET_SCANLINE_CUTOFF EVENT_OVERLAY_SHOW EVENT_PALETTE_SET_BACKGROUND
EVENT_PALETTE_SET_EMOTE EVENT_PALETTE_SET_SGB EVENT_PALETTE_SET_SPRITE
EVENT_PALETTE_SET_UI EVENT_PEEK_DATA EVENT_PLATFORMER_STATE_SET
EVENT_PLAYER_BOUNCE EVENT_PLAYER_SET_SPRITE EVENT_PRINT
EVENT_RATE_LIMIT EVENT_REMOVE_ADVENTURE_CALLBACK_SCRIPT EVENT_REMOVE_INPUT_SCRIPT
EVENT_REMOVE_PLATFORMER_CALLBACK_SCRIPT EVENT_REPLACE_TILE_XY EVENT_REPLACE_TILE_XY_SEQUENCE
EVENT_RESET_VARIABLES EVENT_RNG_SEED EVENT_SAVE_DATA
EVENT_SCENE_POP_ALL_STATE EVENT_SCENE_POP_STATE EVENT_SCENE_PUSH_STATE
EVENT_SCENE_RESET_STATE EVENT_SCENE_UPDATE_PAUSE EVENT_SCENE_UPDATE_RESUME
EVENT_SCRIPT_LOCK EVENT_SCRIPT_UNLOCK EVENT_SET_ADVENTURE_CALLBACK_SCRIPT
EVENT_SET_DIALOGUE_FRAME EVENT_SET_FALSE EVENT_SET_FLAGS
EVENT_SET_FONT EVENT_SET_INPUT_SCRIPT EVENT_SET_MUSIC_ROUTINE
EVENT_SET_PLATFORMER_CALLBACK_SCRIPT EVENT_SET_SGB_COLOR_AREA EVENT_SET_TIMER_SCRIPT
EVENT_SET_TRUE EVENT_SET_VALUE EVENT_SHOW_SPRITES
EVENT_SOUND_PLAY_EFFECT EVENT_STOP EVENT_SWITCH
EVENT_SWITCH_SCENE EVENT_TEXT EVENT_TEXT_DRAW
EVENT_TEXT_SET_ANIMATION_SPEED EVENT_TEXT_SET_SOUND_EFFECT EVENT_THREAD_START
EVENT_THREAD_STOP EVENT_TIMER_DISABLE EVENT_TIMER_RESTART
EVENT_VARIABLE_MATH EVENT_VARIABLE_MATH_EVALUATE EVENT_WAIT
EVENT_WEAPON_ATTACK
`.trim().split(/\s+/u));
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonempty(value) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function numberValue(value) {
if (typeof value === "number" && Number.isFinite(value))
return value;
if (isObject(value) && value.type === "number" && typeof value.value === "number")
return value.value;
return undefined;
}
function owner(resource, definitionPath) {
return {
...(resource.sceneId === undefined ? {} : { sceneId: resource.sceneId }),
resourceType: resource.type,
resourceId: resource.id,
resourcePath: resource.resourcePath,
...(definitionPath === undefined ? {} : { definitionPath }),
};
}
function identity(resource) {
return {
type: resource.type,
id: resource.id,
...(resource.sceneId === undefined ? {} : { sceneId: resource.sceneId }),
resourcePath: resource.resourcePath,
};
}
async function resourceFiles(root, directory) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
}
catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
return [];
throw error;
}
const result = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (entry.isSymbolicLink()) {
// Resolve explicitly so an escaping plugin/resource link cannot be traversed.
await resolveProjectPath(root, path.join(directory, entry.name), { mustExist: true });
continue;
}
if (entry.isDirectory())
result.push(...await resourceFiles(root, path.join(directory, entry.name)));
else if (entry.isFile() && entry.name.endsWith(".gbsres"))
result.push(path.join(directory, entry.name));
}
return result;
}
async function supplementalResources(root, options) {
const result = [];
const candidates = [];
if (options.filesByPath) {
for (const [candidate, file] of options.filesByPath) {
if (candidate.endsWith(".gbsres") && file.json) {
const relative = path.isAbsolute(candidate) ? path.relative(root, candidate) : candidate;
candidates.push([relative.split(path.sep).join("/"), file.json]);
}
}
}
else {
for (const directory of [
"project/scripts", "project/customEvents", "project/prefabs/actors", "project/actorPrefabs",
"project/prefabs/triggers", "project/triggerPrefabs", "plugins",
]) {
for (const filename of await resourceFiles(root, path.join(root, directory))) {
const safe = await resolveProjectPath(root, filename, { mustExist: true, allowRoot: false });
let parsed;
try {
parsed = JSON.parse(await readFile(safe, "utf8"));
}
catch {
continue;
}
if (isObject(parsed))
candidates.push([path.relative(root, safe).split(path.sep).join("/"), parsed]);
}
}
}
for (const [resourcePath, resource] of candidates) {
const rawKind = resource._resourceType;
const kind = rawKind === "customEvent" ? "script" : rawKind;
if (kind !== "script" && kind !== "actorPrefab" && kind !== "triggerPrefab")
continue;
const id = nonempty(resource.id);
if (id)
result.push({ type: kind, id, resourcePath, resource });
}
return result;
}
async function indexedProjectEngineFields(root, files) {
// loadEngineSchema reads this local base and plugins/**/engine/engine.json.
// Project definitions are appended after the stock schema, so a unique,
// enabled project declaration proves that the compiler has this key. A
// stock-only key remains unproved here; graph inspection never runs a loader.
if (!files)
return undefined;
const schemas = [...files].map(([name, file]) => [
(path.isAbsolute(name) ? path.relative(root, name) : name).split(path.sep).join("/"), file,
]).filter(([name]) => name === "assets/engine/engine.json"
|| (/^plugins\/(?:[^/]+\/)*engine\/engine\.json$/.test(name)
&& name.split("/").every((part) => part && !part.startsWith("."))));
if (schemas.length === 0 || schemas.length > 64)
return undefined;
const fields = [];
try {
for (const [name, file] of schemas) {
if (!file.identity || file.identity.size > 1024n * 1024n)
return undefined;
const safe = await resolveProjectPath(root, name, { mustExist: true, allowRoot: false });
const bytes = await readFile(safe);
if (BigInt(bytes.length) !== file.identity.size
|| createHash("sha256").update(bytes).digest("hex") !== file.sha256)
return undefined;
const schema = JSON.parse(bytes.toString("utf8"));
if (!isObject(schema))
return undefined;
if (schema.fields === undefined || schema.fields === null)
continue;
if (!Array.isArray(schema.fields) || !schema.fields.every(isObject))
return undefined;
fields.push(...schema.fields);
}
}
catch {
return undefined;
}
return fields;
}
export async function buildWorldScriptGraph(projectRoot, options = {}) {
const registry = options.reviewedCustomEvents ?? REVIEWED_CUSTOM_EVENTS;
const files = options.filesByPath && new Map([...options.filesByPath].map(([name, file]) => [
(path.isAbsolute(name) ? path.relative(projectRoot, name) : name).split(path.sep).join("/"), file,
]));
const resolution = registry.resolve(files, options.reviewedCompiler);
const previous = options.previousGraph;
const changes = options.changedPaths;
if (previous && options.inventory && changes && changes.length > 0
&& (!files || !registry.needsDependencyRefresh(files, changes))
&& previous.customEventFingerprint === resolution.fingerprint
&& changes.every((candidate) => /^project\/scenes\/.+\/(?:actors|triggers)\/[^/]+\.gbsres$/.test(candidate))) {
const changed = new Set(changes);
const changedOwners = new Set(previous.nodes.filter((node) => node.resourcePath && changed.has(node.resourcePath)).map((node) => node.resourcePath));
const currentOwners = new Set();
for (const scene of options.inventory.scenes) {
for (const resource of [...scene.actors, ...scene.triggers]) {
if (changed.has(resource.resourcePath))
currentOwners.add(resource.resourcePath);
}
}
// Existing owners preserve stable IDs. Additions and removals can change
// opaque references authored elsewhere, so rebuild them conservatively.
if (changed.size === changedOwners.size && changed.size === currentOwners.size) {
const partial = await buildWorldScriptGraphInternal(projectRoot, options, resolution, changed);
const staleReference = (reference) => changed.has(reference.owner.resourcePath)
|| ((reference.relation === "contains-actor" || reference.relation === "contains-trigger")
&& reference.target.resourcePath !== undefined && changed.has(reference.target.resourcePath));
const retainedLimitations = previous.coverage.limitations.filter((limitation) => !(limitation.owner && changed.has(limitation.owner.resourcePath))
&& !(limitation.resourcePath && changed.has(limitation.resourcePath)));
const retainedNodes = previous.nodes.filter((node) => !node.resourcePath || !changed.has(node.resourcePath));
const addedNodes = partial.nodes.filter((node) => node.resourcePath && changed.has(node.resourcePath));
const limitations = [...retainedLimitations, ...partial.coverage.limitations];
return {
references: [...previous.references.filter((reference) => !staleReference(reference)), ...partial.references],
structuralReferences: [
...previous.structuralReferences.filter((reference) => !changed.has(reference.owner.resourcePath)),
...partial.structuralReferences,
],
nodes: [...retainedNodes, ...addedNodes],
coverage: { complete: limitations.length === 0, limitations },
customEventFingerprint: resolution.fingerprint,
};
}
}
return buildWorldScriptGraphInternal(projectRoot, options, resolution);
}
async function buildWorldScriptGraphInternal(projectRoot, options, resolution, ownerPaths) {
const inventory = options.inventory ?? await inventoryProject(projectRoot);
const root = inventory.projectRoot ?? await realpath(projectRoot);
const engineFields = await indexedProjectEngineFields(root, options.filesByPath);
const compilerResources = options.filesByPath && [...options.filesByPath]
.map(([name, file]) => ({ path: (path.isAbsolute(name) ? path.relative(root, name) : name).split(path.sep).join("/"),
type: file.json?._resourceType, id: file.json?.id }))
.filter((file) => /^(?:project|assets|plugins)\/.+\.gbsres$/.test(file.path));
const authored = [];
const nodes = [];
const references = [];
const structuralReferences = [];
const limitations = [];
const reviewedEvents = new Map();
const limitationKeys = new Set();
const maxDepth = options.maxEventDepth ?? PROJECT_RESPONSE_LIMITS.eventNestingDepth;
const maxCallDepth = options.maxCustomScriptDepth ?? PROJECT_RESPONSE_LIMITS.customScriptDepth;
const maxEvents = options.maxEventsPerOwner ?? PROJECT_RESPONSE_LIMITS.eventsPerOwner;
const supplemental = await supplementalResources(root, options);
const scriptResources = new Map(supplemental.filter((resource) => resource.type === "script").map((resource) => [resource.id, resource]));
const prefabs = new Map(supplemental.filter((resource) => resource.type === "actorPrefab" || resource.type === "triggerPrefab").map((resource) => [resource.id, resource]));
const variablesPath = inventory.format === "legacy"
? path.basename(inventory.projectPath)
: "project/variables.gbsres";
let variables = inventory.format === "legacy"
? inventory.descriptor.variables
: (options.filesByPath?.get(variablesPath) ?? options.filesByPath?.get(path.join(root, variablesPath)))?.json?.variables;
if (variables === undefined && inventory.format !== "legacy" && !options.filesByPath) {
try {
const safe = await resolveProjectPath(root, variablesPath, { mustExist: true, allowRoot: false });
variables = JSON.parse(await readFile(safe, "utf8")).variables;
}
catch {
variables = [];
}
}
const globalVariables = new Set((Array.isArray(variables) ? variables : []).filter(isObject).map((variable) => nonempty(variable.id)).filter((id) => id !== undefined));
function limit(code, message, affected) {
const key = `${code}:${affected?.resourcePath ?? ""}:${message}`;
if (limitationKeys.has(key))
return;
limitationKeys.add(key);
limitations.push({ code, message, ...(affected ? { resourcePath: affected.resourcePath, owner: affected } : {}) });
}
function addResource(type, resource, resourcePath, sceneId) {
const id = nonempty(resource.id);
if (!id)
return undefined;
const entry = { type, id, resourcePath, resource, ...(sceneId === undefined ? {} : { sceneId }) };
authored.push(entry);
nodes.push(identity(entry));
return entry;
}
const settings = {
type: "settings",
id: "settings",
resourcePath: inventory.format === "legacy" ? path.basename(inventory.projectPath) : "project/settings.gbsres",
resource: inventory.settings,
};
authored.push(settings);
nodes.push(identity(settings));
for (const scene of inventory.scenes) {
addResource("scene", scene, scene.resourcePath, scene.id);
for (const actor of scene.actors)
addResource("actor", actor, actor.resourcePath, scene.id);
for (const trigger of scene.triggers)
addResource("trigger", trigger, trigger.resourcePath, scene.id);
}
for (const palette of inventory.palettes)
addResource("palette", palette, palette.resourcePath);
for (const asset of inventory.assets)
addResource("asset", asset, asset.resourcePath);
for (const variable of Array.isArray(variables) ? variables.filter(isObject) : [])
addResource("variable", variable, variablesPath);
for (const resource of supplemental) {
authored.push(resource);
nodes.push(identity(resource));
}
const byType = new Map();
const byId = new Map();
const childrenByScene = new Map();
for (const resource of authored) {
let lookup = byType.get(resource.type);
if (!lookup)
byType.set(resource.type, lookup = new Map());
if (!lookup.has(resource.id))
lookup.set(resource.id, resource);
const identities = byId.get(resource.id);
if (identities)
identities.push(resource);
else
byId.set(resource.id, [resource]);
if (resource.sceneId && (resource.type === "actor" || resource.type === "trigger")) {
const children = childrenByScene.get(resource.sceneId);
if (children)
children.push(resource);
else
childrenByScene.set(resource.sceneId, [resource]);
}
}
function target(type, id, sceneId) {
const resource = byType.get(type)?.get(id);
return resource ? identity(resource) : { type, id, ...(sceneId === undefined ? {} : { sceneId }) };
}
function edge(source, destination, relation, provenance = "authored", event, access, extra) {
references.push({
owner: source,
target: destination,
relation,
provenance,
confidence: access === "unknown" || provenance === "heuristic" ? "partial" : "verified",
...(event ? { event } : {}),
...(access ? { access } : {}),
...extra,
});
}
function structural(resource) {
if (resource.type === "asset" || resource.type === "palette" || resource.type === "variable")
return;
const source = owner(resource);
let count = 0;
function walk(value, propertyPath, parentKey, variableUnion = false) {
if (++count > maxEvents * 40) {
limit("STRUCTURAL_SCAN_LIMIT", "Authored reference scan exceeded its bounded traversal limit", source);
return;
}
if (propertyPath.length > maxDepth * 2) {
limit("STRUCTURAL_DEPTH_LIMIT", "Authored reference nesting exceeded its bounded traversal depth", source);
return;
}
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++)
walk(value[index], [...propertyPath, index], parentKey, variableUnion);
return;
}
if (isObject(value)) {
const union = value.type === "variable";
for (const [key, child] of Object.entries(value)) {
if (propertyPath.length === 0 && (key === "id" || key === "_resourceType" || key === "resourcePath" || key === "actors" || key === "triggers"))
continue;
if ((resource.type === "script") && propertyPath.length === 0 && (key === "variables" || key === "actors"))
continue;
walk(child, [...propertyPath, key], key, union && key === "value");
}
return;
}
if (typeof value !== "string" || !value)
return;
if (parentKey === "id" && !variableUnion)
return;
const variableField = variableUnion || /^(?:variable|variableId|vectorX|vectorY)$/i.test(parentKey ?? "") || /^\$variable\[V\d+\]\$$/.test(parentKey ?? "");
for (const candidate of byId.get(value) ?? []) {
if (candidate.type === "settings")
continue;
if (candidate.type === "variable" && (!variableField || LOCAL_VARIABLE.test(value) || FORMAL_VARIABLE.test(value)))
continue;
if (candidate.type !== "variable" && /^\d+$/.test(value))
continue;
if ((candidate.type === "actor" || candidate.type === "trigger") && source.sceneId && candidate.sceneId && source.sceneId !== candidate.sceneId)
continue;
structuralReferences.push({
owner: source,
target: identity(candidate),
relation: variableField ? "variable" : "authored-reference",
resourcePath: resource.resourcePath,
propertyPath,
confidence: variableField || /(?:Id|Ids|Palette|Sprites)$/i.test(parentKey ?? "") ? "verified" : "conservative",
});
}
}
walk(resource.resource, []);
}
function resolveVariable(raw, context, access) {
const value = typeof raw === "string" ? raw : isObject(raw) && raw.type === "variable" ? nonempty(raw.value) : undefined;
if (!value) {
if (raw !== undefined)
limit("UNRESOLVED_VARIABLE", "A variable expression cannot be resolved statically", context.owner);
return undefined;
}
if (FORMAL_VARIABLE.test(value)) {
const binding = context.variableBindings.get(value);
if (!binding?.id) {
limit("UNRESOLVED_FORMAL_VARIABLE", `Reusable-script variable ${value} cannot be resolved statically`, context.owner);
return undefined;
}
// A by-value formal reads its caller argument once; writes stay private.
if (!binding.byReference && (access === "write" || access === "read-write"))
return undefined;
return { id: binding.id, access: binding.byReference ? access : "read" };
}
if (LOCAL_VARIABLE.test(value))
return { id: `${context.owner.resourceId}:${value}`, access };
return { id: value, access };
}
function variable(raw, access, context, event, seen) {
const resolved = resolveVariable(raw, context, access);
if (!resolved)
return false;
const key = `${resolved.id}:${resolved.access}`;
if (seen.has(key))
return true;
seen.add(key);
const variableTarget = resolved.id.includes(":L")
? { type: "variable", id: resolved.id, sceneId: context.owner.sceneId }
: target("variable", resolved.id);
edge(context.owner, variableTarget, "variable", context.provenance, event, resolved.access);
return true;
}
function actorReference(raw, context, event) {
let value = nonempty(raw);
if (!value || value === "player")
return;
if (value === "$self$")
value = context.owner.resourceId;
else if (context.actorBindings.has(value))
value = context.actorBindings.get(value);
if (!value) {
limit("UNRESOLVED_ACTOR", "A reusable-script actor argument cannot be resolved statically", context.owner);
return;
}
edge(context.owner, target("actor", value, context.owner.sceneId), "actor", context.provenance, event);
}
function expandCall(args, context, event) {
const scriptId = nonempty(args.customEventId);
if (!scriptId) {
limit("UNRESOLVED_SHARED_SCRIPT", "Reusable script call has no statically known customEventId", context.owner);
return;
}
edge(context.owner, target("script", scriptId), "calls-script", context.provenance, event);
const script = scriptResources.get(scriptId);
if (!script) {
limit("MISSING_SHARED_SCRIPT", `Reusable script ${scriptId} was not found`, context.owner);
return;
}
if (context.callStack.includes(scriptId)) {
limit("RECURSIVE_SHARED_SCRIPT", `Reusable script ${scriptId} recursively invokes itself`, context.owner);
return;
}
if (context.callStack.length >= maxCallDepth) {
limit("SHARED_SCRIPT_DEPTH_LIMIT", "Reusable script calls exceeded their bounded expansion depth", context.owner);
return;
}
const variableBindings = new Map();
const formals = isObject(script.resource.variables) ? script.resource.variables : {};
for (const [key, definition] of Object.entries(formals)) {
if (!isObject(definition))
continue;
const formal = nonempty(definition.id) ?? key;
const argument = args[`$variable[${formal}]$`] ?? args.$variable;
const resolved = resolveVariable(argument, context, "read");
if (!resolved) {
limit("UNRESOLVED_FORMAL_BINDING", `Reusable script ${scriptId} argument ${formal} cannot be resolved`, context.owner);
variableBindings.set(formal, { byReference: definition.passByReference !== false });
continue;
}
variableBindings.set(formal, { id: resolved.id, byReference: definition.passByReference !== false });
if (definition.passByReference === false) {
edge(context.owner, target("variable", resolved.id), "variable", "expanded-shared-script", event, "read");
}
}
const actorBindings = new Map();
const actors = isObject(script.resource.actors) ? script.resource.actors : {};
for (const [key, definition] of Object.entries(actors)) {
const formal = isObject(definition) ? nonempty(definition.id) ?? key : key;
const argument = args[`$actor[${formal}]$`];
const value = nonempty(argument) ?? (isObject(argument) ? nonempty(argument.value) : undefined);
if (value)
actorBindings.set(formal, value === "$self$" ? context.owner.resourceId : value);
else
limit("UNRESOLVED_FORMAL_ACTOR", `Reusable script ${scriptId} actor ${formal} cannot be resolved`, context.owner);
}
const events = script.resource.script;
if (!Array.isArray(events)) {
limit("INVALID_SHARED_SCRIPT", `Reusable script ${scriptId} has no valid script events`, context.owner);
return;
}
walkEvents(events, {
...context,
owner: { ...context.owner, definitionPath: script.resourcePath },
provenance: "expanded-shared-script",
variableBindings,
actorBindings,
callStack: [...context.callStack, scriptId],
depth: 0,
overrides: undefined,
});
}
function walkEvents(events, context) {
if (context.depth > maxDepth) {
limit("EVENT_DEPTH_LIMIT", "Script event nesting exceeded its bounded traversal depth", context.owner);
return;
}
for (let index = 0; index < events.length; index++) {
if (++context.counter.value > maxEvents) {
limit("EVENT_COUNT_LIMIT", "Script owner exceeded its bounded event expansion count", context.owner);
return;
}
const candidate = events[index];
if (!isObject(candidate)) {
limit("INVALID_EVENT", "Script contains an event that is not an object", context.owner);
continue;
}
const eventId = nonempty(candidate.id);
const command = nonempty(candidate.command);
if (!eventId || !command) {
limit("INVALID_EVENT", "Script event has no stable id or command", context.owner);
continue;
}
let args = isObject(candidate.args) ? candidate.args : {};
const override = context.overrides?.[eventId];
if (isObject(override) && isObject(override.args))
args = { ...args, ...override.args };
if (args.__comment)
continue;
const event = { id: eventId, command, scriptKey: context.scriptKey, branchPath: context.branchPath };
const seen = new Set();
const reviewed = resolution.byCommand.get(command);
if (reviewed) {
const contract = reviewed.contract;
const result = contract && extractReviewedEvent(contract, args, candidate.children, { owner: context.owner, inventory,
resourceFiles: compilerResources, event });
if (contract && result?.status === "complete") {
const previousLimitations = limitations.length;
let targetsResolved = true;
for (const effect of result.effects) {
if (effect.kind === "variable") {
const formal = FORMAL_VARIABLE.test(effect.id) ? context.variableBindings.get(effect.id) : undefined;
const boundId = FORMAL_VARIABLE.test(effect.id) ? formal?.id : effect.id;
const local = LOCAL_VARIABLE.test(effect.id) || (boundId !== undefined && /:L\d+$/.test(boundId));
if (boundId !== undefined && !local && (!globalVariables.has(boundId)
|| (byId.get(boundId) ?? []).filter((resource) => resource.type === "variable").length !== 1)) {
targetsResolved = false;
limit("UNRESOLVED_REVIEWED_VARIABLE", `Reviewed variable ${effect.id} is missing or ambiguous`, context.owner);
}
else {
// The caller's initial by-value read is already represented;
// writes to its private copy are not unresolved global effects.
if (formal?.id && !formal.byReference && effect.access !== "read")
continue;
const before = references.length;
if (!variable(effect.id, effect.access, context, event, seen))
targetsResolved = false;
const resolved = references[before];
if (resolved)
structuralReferences.push({ owner: resolved.owner, target: resolved.target,
relation: resolved.relation, resourcePath: contract.handler.path,
propertyPath: ["reviewed-contract", contract.id, contract.version, eventId], confidence: "verified",
reviewedContract: { id: contract.id, version: contract.version } });
}
continue;
}
if (effect.ownerId !== undefined) {
const owners = (byId.get(effect.ownerId) ?? []).filter((resource) => ["scene", "actor", "trigger", "script"].includes(resource.type));
if (owners.length !== 1 || owners[0].type !== "actor" || owners[0].sceneId !== effect.sceneId) {
targetsResolved = false;
limit("UNRESOLVED_REVIEWED_RESOURCE", "A native structural local has no unique actor owner", context.owner);
continue;
}
const destination = { type: "variable",
id: `${effect.ownerId}:${effect.id}`, sceneId: effect.sceneId };
edge(context.owner, destination, effect.relation, context.provenance, event);
structuralReferences.push({ owner: context.owner, target: destination,
relation: effect.relation, resourcePath: contract.handler.path,
propertyPath: ["reviewed-contract", contract.id, contract.version, eventId], confidence: "verified",
reviewedContract: { id: contract.id, version: contract.version } });
continue;
}
let id = effect.id;
if (effect.type === "actor") {
if (id === "$self$")
id = context.owner.resourceId;
else
id = context.actorBindings.get(id) ?? id;
}
const candidates = (byId.get(id) ?? []).filter((resource) => resource.type === effect.type
&& (effect.sceneId === undefined || resource.sceneId === effect.sceneId)
&& (effect.type !== "actor" || effect.sceneId !== undefined || resource.sceneId === context.owner.sceneId));
if (candidates.length !== 1) {
targetsResolved = false;
limit("UNRESOLVED_REVIEWED_RESOURCE", `Reviewed ${effect.type} ${id} is missing or ambiguous`, context.owner);
continue;
}
const destination = identity(candidates[0]);
edge(context.owner, destination, effect.relation, context.provenance, event);
// Hidden native/helper references must also protect native-output
// ownership; complete semantic coverage is not permission to erase them.
structuralReferences.push({ owner: context.owner, target: destination,
relation: effect.relation, resourcePath: contract.handler.path,
propertyPath: ["reviewed-contract", contract.id, contract.version, eventId], confidence: "verified",
reviewedContract: { id: contract.id, version: contract.version } });
}
if (targetsResolved && limitations.length === previousLimitations)
reviewedEvents.set(contract.id, {
id: contract.id, version: contract.version, command,
resolutionProfile: contract.resolutionProfile, handler: contract.handler, dependencies: contract.dependencies,
...(contract.compilerProfile ? { compilerProfile: contract.compilerProfile.id,
compilerFingerprint: options.reviewedCompiler?.fingerprint } : {}),
...(contract.compilerDependencies ? { compilerDependencies: contract.compilerDependencies } : {}),
});
const input = { ...args, ...candidate.children };
for (const branch of result.childBranches) {
walkEvents((input[branch] ?? []), {
...context, branchPath: [...context.branchPath, { eventId, branch, index }], depth: context.depth + 1,
});
}
continue;
}
limit("UNVERIFIED_CUSTOM_EVENT_CONTRACT", `${command}: ${reviewed.reason ?? (result?.status === "incomplete" ? result.reason : "No reviewed effects")}`, context.owner);
// Do not reinterpret an uncertain plugin override using the native
// command's semantics. The separate structural scan still preserves
// every authored reference, including opaque child branches.
continue;
}
if (resolution.unreviewedExports.length && NATIVE_EVENT_COMMANDS.has(command)) {
limit("UNVERIFIED_CUSTOM_EVENT_CONTRACT", `${command}: Unreviewed project event exports may replace this handler`, context.owner);
continue;
}
switch (command) {
case "EVENT_IF":
variable(args.variable, "read", context, event, seen);
if (isObject(args.condition) && args.condition.type === "variable")
variable(args.condition.value, "read", context, event, seen);
break;
case "EVENT_SET_VALUE":
case "EVENT_VARIABLE_SET":
variable(args.variable, "write", context, event, seen);
if (isObject(args.value) && args.value.type === "variable")
variable(args.value.value, "read", context, event, seen);
break;
case "EVENT_INC_VALUE":
case "EVENT_DEC_VALUE":
variable(args.variable, "read-write", context, event, seen);
break;
case "EVENT_ENGINE_FIELD_STORE":
// The stock handler calls engineFieldStoreInVariable(key, value):
// value is the destination variable, not a script-value expression.
if (!nonempty(args.engineFieldKey) || !nonempty(args.value)) {
limit("UNRESOLVED_ENGINE_FIELD_STORE", "Engine-field capture requires an explicit field and destination variable", context.owner);
}
else {
const matches = engineFields?.filter((field) => field.key === args.engineFieldKey);
const field = matches?.length === 1 ? matches[0] : undefined;
const disabled = inventory.settings.disabledSceneTypeIds ?? [];
// compileData retains LOGO and used scene types, then filters fields
// by unused/disabled types before keyBy. A used type is sufficient
// even without consulting the stock schema; other types stay opaque.
const enabled = field && Array.isArray(disabled) && disabled.every((type) => typeof type === "string")
&& (field.sceneType === undefined || field.sceneType === null || field.sceneType === ""
|| (typeof field.sceneType === "string" && !disabled.includes(field.sceneType)
&& (field.sceneType === "LOGO" || inventory.scenes.some((scene) => scene.type === field.sceneType))));
if (!enabled) {
limit("UNRESOLVED_ENGINE_FIELD_STORE", "An enabled project engine-field definition could not be established", context.owner);
}
else
variable(args.value, "write", context, event, seen);
}
break;
case "EVENT_SWITCH_SCENE": {
const sceneId = nonempty(args.sceneId);
if (sceneId) {
const x = numberValue(args.x);
const y = numberValue(args.y);
edge(context.owner, target("scene", sceneId), "transition", context.provenance, event, undefined, {
...(x === undefined ? {} : { x }),
...(y === undefined ? {} : { y }),
});
}
else
limit("UNRESOLVED_SCENE_TRANSITION", "Scene transition destination cannot be resolved statically", context.owner);
break;
}
case "EVENT_ACTOR_GET_POSITION":
actorReference(args.actorId, context, event);
variable(args.vectorX ?? args.variableX, "write", context, event, seen);
variable(args.vectorY ?? args.variableY, "write", context, event, seen);
break;
case "EVENT_RESET_VARIABLES":
for (const id of globalVariables)
variable(id, "write", context, event, seen);
break;
case "EVENT_CALL_CUSTOM_EVENT":
expandCall(args, context, event);
break;
case "EVENT_TEXT": {
const values = Array.isArray(args.text) ? args.text.filter((value) => typeof value === "string") : typeof args.text === "string" ? [args.text] : [];
if (values.length > 0)
edge(context.owner, target(context.owner.resourceType, context.owner.resourceId, context.owner.sceneId), "dialogue", context.provenance, event, undefined, { text: values.join("\n") });
break;
}
default:
if (!NATIVE_EVENT_COMMANDS.has(command)) {
limit("UNKNOWN_CUSTOM_COMMAND", `Custom command ${command} has unverified runtime semantics`, context.owner);
for (const [key, value] of Object.entries(args)) {
if (/^(?:variable|variableId|vectorX|vectorY)$/.test(key))
variable(value, "unknown", { ...context, provenance: "heuristic" }, event, seen);
}
}
break;
}
if (command !== "EVENT_ACTOR_GET_POSITION") {
for (const key of ["actorId", "otherActorId", "targetActorId"])
if (key in args)
actorReference(args[key], context, event);
}
for (const key of ["spriteSheetId", "spriteId", "playerSpriteSheetId"]) {
const sprite = nonempty(args[key]);
if (sprite)
edge(context.owner, target("asset", sprite), "sprite", context.provenance, event);
}
// Call events can carry GB Studio's editor-only inline preview; the indexed
// reusable script is authoritative and must not be counted a second time.
if (command === "EVENT_CALL_CUSTOM_EVENT")
continue;
if (candidate.children !== undefined && !isObject(candidate.children)) {
limit("INVALID_EVENT_CHILDREN", `Event ${eventId} has malformed child branches`, context.owner);
continue;
}
if (isObject(candidate.children)) {
for (const [branch, children] of Object.entries(candidate.children)) {
if (!Array.isArray(children)) {
limit("INVALID_EVENT_BRANCH", `Event ${eventId} has malformed ${branch} branch`, context.owner);
continue;
}
walkEvents(children, {
...context,
branchPath: [...context.branchPath, { eventId, branch, index }],
depth: context.depth + 1,
});
}
}
}
}
function scriptOwner(resource) {
options.onOwnerWalk?.(owner(resource));
const prefabId = nonempty(resource.resource.prefabId);
const expectedType = resource.type === "actor" ? "actorPrefab" : resource.type === "trigger" ? "triggerPrefab" : undefined;
const prefab = prefabId && expectedType ? prefabs.get(prefabId) : undefined;
if (prefabId && expectedType) {
edge(owner(resource), target(expectedType, prefabId), "prefab");
if (!prefab || prefab.type !== expectedType)
limit("MISSING_PREFAB", `Prefab ${prefabId} was not found for ${resource.type} ${resource.id}`, owner(resource));
}
const effective = prefab && prefab.type === expectedType ? prefab : resource;
const effectiveOwner = owner(resource, effective !== resource ? effective.resourcePath : undefined);
const provenance = effective !== resource ? "effective-prefab" : "authored";
const counter = { value: 0 };
if (resource.type === "actor") {
const spriteId = nonempty(effective.resource.spriteSheetId) ?? nonempty(resource.resource.spriteSheetId);
if (spriteId)
edge(effectiveOwner, target("asset", spriteId), "sprite", provenance);
const paletteId = nonempty(effective.resource.paletteId) ?? nonempty(resource.resource.paletteId);
if (paletteId)
edge(effectiveOwner, target("palette", paletteId), "palette", provenance);
}
for (const [scriptKey, events] of Object.entries(effective.resource)) {
if (!SCRIPT_KEY.test(scriptKey) || !Array.isArray(events))
continue;
walkEvents(events, {
owner: effectiveOwner,
scriptKey,
branchPath: [],
provenance,
variableBindings: new Map(),
actorBindings: new Map(),
callStack: [],
depth: 0,
counter,
...(effective !== resource && isObject(resource.resource.prefabScriptOverrides) ? { overrides: resource.resource.prefabScriptOverrides } : {}),
});
}
}
const players = isObject(settings.resource.defaultPlayerSprites) ? settings.resource.defaultPlayerSprites : {};
if (!ownerPaths) {
const settingsOwner = owner(settings);
const startScene = nonempty(settings.resource.startSceneId);
if (startScene)
edge(settingsOwner, target("scene", startScene), "start-scene");
const font = nonempty(settings.resource.defaultFontId);
if (font)
edge(settingsOwner, target("asset", font), "font");
for (const sprite of new Set(Object.values(players).map(nonempty).filter((value) => value !== undefined)))
edge(settingsOwner, target("asset", sprite), "sprite");
for (const key of ["defaultBackgroundPaletteIds", "defaultSpritePaletteIds"]) {
const ids = settings.resource[key];
if (Array.isArray(ids))
for (const palette of new Set(ids.map(nonempty).filter((value) => value !== undefined)))
edge(settingsOwner, target("palette", palette), "palette");
}
for (const key of ["defaultSpritePaletteId", "defaultUIPaletteId", "playerPaletteId"]) {
const palette = nonempty(settings.resource[key]);
if (palette)
edge(settingsOwner, target("palette", palette), "palette");
}
}
for (const scene of byType.get("scene")?.values() ?? []) {
const sceneOwner = owner(scene);
for (const child of childrenByScene.get(scene.id) ?? []) {
if (ownerPaths && !ownerPaths.has(child.resourcePath))
continue;
edge(sceneOwner, identity(child), child.type === "actor" ? "contains-actor" : "contains-trigger");
}
if (ownerPaths)
continue;
const background = nonempty(scene.resource.backgroundId);
if (background)
edge(sceneOwner, target("asset", background), "background");
const scenePlayer = nonempty(scene.resource.playerSpriteSheetId);
const inheritedPlayer = nonempty(players[nonempty(scene.resource.type) ?? ""]);
if (scenePlayer ?? inheritedPlayer)
edge(sceneOwner, target("asset", (scenePlayer ?? inheritedPlayer)), "sprite", scenePlayer ? "authored" : "inherited-setting");
const paletteKeys = [
["paletteIds", "defaultBackgroundPaletteIds"],
["spritePaletteIds", "defaultSpritePaletteIds"],
];
for (const [actual, defaults] of paletteKeys) {
const declared = Array.isArray(scene.resource[actual]) ? scene.resource[actual] : [];
const fallback = Array.isArray(settings.resource[defaults]) ? settings.resource[defaults] : [];
const seen = new Set();
for (let index = 0; index < Math.max(declared.length, fallback.length); index++) {
const explicit = nonempty(declared[index]);
const inherited = nonempty(fallback[index]);
const palette = explicit ?? inherited;
if (!palette || seen.has(palette))
continue;
seen.add(palette);
edge(sceneOwner, target("palette", palette), "palette", explicit ? "authored" : "inherited-setting");
}
if (seen.size === 0 && inventory.palettes[0])
edge(sceneOwner, target("palette", inventory.palettes[0].id), "palette", "inherited-setting");
}
scriptOwner(scene);
}
for (const resource of authored) {
if (resource.type !== "actor" && resource.type !== "trigger")
continue;
if (ownerPaths && !ownerPaths.has(resource.resourcePath))
continue;
scriptOwner(resource);
}
for (const resource of authored) {
if (ownerPaths && !ownerPaths.has(resource.resourcePath))
continue;
structural(resource);
}
return {
references,
structuralReferences,
nodes,
coverage: { complete: limitations.length === 0, limitations,
...(reviewedEvents.size ? { reviewedCustomEvents: [...reviewedEvents.values()] } : {}) },
customEventFingerprint: resolution.fingerprint,
};
}
//# sourceMappingURL=world-script-graph.js.mapSHA-256: 3e936ade67791592d8a94f65d2c13493aee10c7f25f6e82ab25341ae6671990d