← Files ModRetro Chromatic PluginARCHIVED FILE
dist/palettes.js
23.2 KB · Oct 3, 2026 · 06:38 UTC
import { createHash } from "node:crypto";
import { realpath } from "node:fs/promises";
import path from "node:path";
import { GameStudioProjectError, inventoryProject, } from "./project.js";
import { projectPathExists, readProjectJson, resourceSlug, stableResourceId, writeProjectJsonAtomic, } from "./project-files.js";
import { decodeSemanticCursor, encodeSemanticCursor, isSemanticCursorCurrent, PROJECT_REVISION_ALGORITHM, } from "./project-access.js";
import { projectRevision } from "./project-snapshot.js";
import { decodeResourceBytes, encodeResourceBytes } from "./resource-codec.js";
export const HARDWARE_PALETTE_SLOTS = 8;
export const DEFAULT_UI_PALETTE_SLOT = 7;
export const FALLBACK_PALETTE_COLORS = ["E0F8CF", "86C06C", "306850", "071821"];
const PALETTE_INSPECTION_PAGE_LIMIT = 250;
const PALETTE_INSPECTION_BYTE_LIMIT = 30 * 1024;
const PALETTE_INSPECTION_CURSOR_QUERY = "palette-inspect-v1";
async function paletteCursorQuery(projectPath) {
const canonicalDescriptor = await realpath(projectPath);
return `${PALETTE_INSPECTION_CURSOR_QUERY}:${createHash("sha256").update(canonicalDescriptor).digest("hex")}`;
}
async function paletteProject(projectPath, access) {
if (access === undefined)
return inventoryProject(projectPath);
await access.ensureFresh();
const inspection = access.projectInspection();
const completeSettings = access.settings?.() ?? inspection.settings;
return {
projectPath: access.projectPath,
projectRoot: access.projectRoot,
format: inspection.format,
settings: completeSettings,
palettes: [],
scenes: [],
assets: [],
counts: inspection.counts,
};
}
function normalizeColors(colors) {
if (colors.length !== 4) {
throw new GameStudioProjectError("INVALID_PALETTE_COLORS", "A Game Boy Color palette must contain exactly four RGB colors.");
}
return colors.map((color, index) => {
if (typeof color !== "string" || !/^#?[\da-f]{6}$/i.test(color)) {
throw new GameStudioProjectError("INVALID_PALETTE_COLOR", `Palette color ${index} must be a six-digit RGB hexadecimal value.`);
}
return color.replace(/^#/, "").toUpperCase();
});
}
function requirePalette(inventory, id, access) {
const palette = access === undefined
? inventory.palettes.find((candidate) => candidate.id === id)
: access.palette(id);
if (palette === undefined) {
throw new GameStudioProjectError("PALETTE_NOT_FOUND", `No palette exists with id ${id}.`);
}
return palette;
}
function requireScene(inventory, id, access) {
const scene = access === undefined
? inventory.scenes.find((candidate) => candidate.id === id)
: access.scene(id);
if (scene === undefined) {
throw new GameStudioProjectError("SCENE_NOT_FOUND", `No scene exists with id ${id}.`);
}
return scene;
}
function paletteIds(value) {
return Array.isArray(value) ? value.map((id) => typeof id === "string" ? id : "") : [];
}
export function resolvePaletteSlots(inventory, scene, target, access) {
const defaults = paletteIds(target === "background" ? inventory.settings.defaultBackgroundPaletteIds : inventory.settings.defaultSpritePaletteIds);
const overrides = paletteIds(target === "background" ? scene.paletteIds : scene.spritePaletteIds);
return Array.from({ length: HARDWARE_PALETTE_SLOTS }, (_, slot) => {
const firstPaletteId = slot === 0 && !overrides[slot] && !defaults[slot]
? (access === undefined ? inventory.palettes[0]?.id : access.resourcesByKind("palette")[0]?.id)
: undefined;
const id = overrides[slot] || defaults[slot] || firstPaletteId;
return id === undefined
? null
: access === undefined
? inventory.palettes.find((palette) => palette.id === id) ?? null
: access.palette(id) ?? null;
});
}
export function resolveUiPalette(inventory, access) {
const id = typeof inventory.settings.defaultUIPaletteId === "string"
? inventory.settings.defaultUIPaletteId
: null;
const defaults = paletteIds(inventory.settings.defaultBackgroundPaletteIds);
const detectedSlot = id === null ? -1 : defaults.indexOf(id);
const slot = detectedSlot < 0 ? DEFAULT_UI_PALETTE_SLOT : detectedSlot;
const palette = id === null
? undefined
: access === undefined
? inventory.palettes.find((candidate) => candidate.id === id)
: access.palette(id);
return {
id,
colors: Array.isArray(palette?.colors) && palette.colors.length === 4
? normalizeColors(palette.colors)
: [...FALLBACK_PALETTE_COLORS],
slot,
source: palette === undefined ? "fallback" : "project-palette",
};
}
function referencedSceneCount(inventory, paletteId, access) {
if (access !== undefined) {
const sceneIds = new Set();
for (const reference of access.effectiveReferences({ type: "palette", id: paletteId })) {
if (reference.owner.resourceType === "scene") {
sceneIds.add(reference.owner.sceneId ?? reference.owner.resourceId);
}
}
return sceneIds.size;
}
return inventory.scenes.filter((scene) => [...resolvePaletteSlots(inventory, scene, "background"), ...resolvePaletteSlots(inventory, scene, "sprite")]
.some((palette) => palette?.id === paletteId)).length;
}
function validateSlot(slot) {
if (!Number.isInteger(slot) || slot < 0 || slot >= HARDWARE_PALETTE_SLOTS) {
throw new GameStudioProjectError("INVALID_PALETTE_SLOT", "Palette slots must be integers from 0 through 7.");
}
}
function validatePositiveInteger(value, field) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new GameStudioProjectError("INVALID_PALETTE_EDIT", `${field} must be a positive integer.`);
}
}
export async function inspectPalettes(projectPath, access, input = {}) {
if (input.paletteId !== undefined && (typeof input.paletteId !== "string"
|| input.paletteId.trim().length === 0 || input.paletteId.trim().length > 256)) {
throw new GameStudioProjectError("INVALID_PALETTE_INSPECTION", "paletteId must be a nonempty palette ID of at most 256 characters.");
}
if (input.limit !== undefined && (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > PALETTE_INSPECTION_PAGE_LIMIT)) {
throw new GameStudioProjectError("INVALID_PALETTE_INSPECTION", `limit must be an integer from 1 through ${PALETTE_INSPECTION_PAGE_LIMIT}.`);
}
if (input.paletteId !== undefined && (input.limit !== undefined || input.cursor !== undefined)) {
throw new GameStudioProjectError("INVALID_PALETTE_INSPECTION", "Use paletteId for one palette, or use limit and cursor to list palettes; do not combine them.");
}
const standaloneRevision = access === undefined ? await projectRevision(projectPath) : undefined;
const inventory = await paletteProject(projectPath, access);
const cursorIdentity = {
revision: access?.revision ?? standaloneRevision,
revisionAlgorithm: access?.revisionAlgorithm ?? PROJECT_REVISION_ALGORITHM,
selectionGeneration: access?.selectionGeneration ?? 0,
semanticGeneration: access?.semanticGeneration ?? 0,
};
let offset = 0;
let cursorQuery;
if (input.cursor !== undefined) {
const cursor = decodeSemanticCursor(input.cursor);
if (cursor === undefined) {
throw new GameStudioProjectError("INVALID_CURSOR", "The palette inspection cursor is malformed. Restart the list without cursor.");
}
cursorQuery = await paletteCursorQuery(inventory.projectPath);
if (!isSemanticCursorCurrent(cursor, cursorIdentity, cursorQuery)) {
throw new GameStudioProjectError("STALE_CURSOR", "The palette inspection cursor no longer matches this project, selection, revision, or reference index. Restart the list without cursor.");
}
offset = cursor.offset;
}
const focused = input.paletteId !== undefined;
const indexedPalettes = focused ? [] : access === undefined ? inventory.palettes : access.resourcesByKind("palette");
const paletteCount = focused ? inventory.counts.palettes : indexedPalettes.length;
if (offset > paletteCount) {
throw new GameStudioProjectError("STALE_CURSOR", "The palette inspection cursor points beyond the current list. Restart the list without cursor.");
}
const candidates = focused ? [requirePalette(inventory, input.paletteId.trim(), access)] : indexedPalettes.slice(offset);
const limit = input.limit ?? PALETTE_INSPECTION_PAGE_LIMIT;
const palettes = [];
let responseBytes = 0;
for (const palette of candidates) {
if (palettes.length >= limit)
break;
const entry = { ...palette, affectedSceneCount: referencedSceneCount(inventory, palette.id, access) };
const entryBytes = Buffer.byteLength(JSON.stringify(entry));
if (entryBytes > PALETTE_INSPECTION_BYTE_LIMIT) {
if (palettes.length > 0)
break;
throw new GameStudioProjectError("PALETTE_RESPONSE_TOO_LARGE", `Authored palette ${palette.id} exceeds the bounded inspection response; inspect its resource directly.`);
}
if (responseBytes + entryBytes > PALETTE_INSPECTION_BYTE_LIMIT)
break;
responseBytes += entryBytes;
palettes.push(entry);
}
const nextOffset = offset + palettes.length;
const truncated = !focused && nextOffset < paletteCount;
const includePaging = focused || input.limit !== undefined || input.cursor !== undefined || truncated;
if (includePaging && access === undefined && standaloneRevision !== await projectRevision(projectPath)) {
throw new GameStudioProjectError("STALE_PROJECT", "The project changed while inspecting palettes. Restart the list without cursor.");
}
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
palettes,
reservedUiPalette: resolveUiPalette(inventory, access),
...(includePaging ? {
revision: cursorIdentity.revision,
paletteCount,
returned: palettes.length,
truncated,
nextCursor: truncated ? encodeSemanticCursor({
...cursorIdentity, query: cursorQuery ?? await paletteCursorQuery(inventory.projectPath), offset: nextOffset,
}) : null,
} : {}),
};
}
export async function createPalette(projectPath, input, access) {
const inventory = await paletteProject(projectPath, access);
const name = input.name.trim();
if (name.length === 0) {
throw new GameStudioProjectError("INVALID_PALETTE_NAME", "Palette names must not be empty.");
}
const colors = normalizeColors(input.colors);
const id = input.id?.trim() || stableResourceId("palette", name);
if (access === undefined ? inventory.palettes.some((palette) => palette.id === id) : access.palette(id) !== undefined) {
throw new GameStudioProjectError("DUPLICATE_PALETTE_ID", `A palette already exists with id ${id}.`);
}
const palette = {
_resourceType: "palette",
id,
name,
colors,
defaultName: name,
defaultColors: [...colors],
};
if (inventory.format === "legacy") {
const descriptor = await readProjectJson(inventory.projectRoot, inventory.projectPath);
const existing = Array.isArray(descriptor.palettes) ? descriptor.palettes : [];
descriptor.palettes = [...existing, palette];
await writeProjectJsonAtomic(inventory.projectRoot, inventory.projectPath, descriptor);
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
resourcePath: path.basename(inventory.projectPath),
palette,
affectedSceneCount: 0,
};
}
const slug = resourceSlug(name, "palette");
let resourcePath = path.join("project", "palettes", `${slug}.gbsres`);
let suffix = 2;
while (await projectPathExists(inventory.projectRoot, path.join(inventory.projectRoot, resourcePath))) {
resourcePath = path.join("project", "palettes", `${slug}_${suffix}.gbsres`);
suffix += 1;
}
await writeProjectJsonAtomic(inventory.projectRoot, path.join(inventory.projectRoot, resourcePath), palette);
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
resourcePath: resourcePath.split(path.sep).join("/"),
palette,
affectedSceneCount: 0,
};
}
export async function updatePalette(projectPath, input, access) {
const inventory = await paletteProject(projectPath, access);
const existing = requirePalette(inventory, input.paletteId, access);
if (input.name !== undefined && input.name.trim().length === 0) {
throw new GameStudioProjectError("INVALID_PALETTE_NAME", "Palette names must not be empty.");
}
if (input.updateDefaultColors === true && input.colors === undefined) {
throw new GameStudioProjectError("INVALID_PALETTE_COLORS", "Updating default colors requires replacement palette colors.");
}
const colors = input.colors === undefined ? undefined : normalizeColors(input.colors);
const update = (palette) => ({
...palette,
...(input.name === undefined ? {} : { name: input.name.trim() }),
...(colors === undefined ? {} : { colors }),
...(input.updateDefaultColors === true ? { defaultColors: colors } : {}),
});
let palette;
if (inventory.format === "legacy") {
const descriptor = await readProjectJson(inventory.projectRoot, inventory.projectPath);
const palettes = Array.isArray(descriptor.palettes) ? descriptor.palettes : [];
let changed;
descriptor.palettes = palettes.map((candidate) => {
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate) || candidate.id !== input.paletteId) {
return candidate;
}
changed = update(candidate);
return changed;
});
palette = changed;
await writeProjectJsonAtomic(inventory.projectRoot, inventory.projectPath, descriptor);
}
else {
const absolute = path.join(inventory.projectRoot, existing.resourcePath);
palette = update(await readProjectJson(inventory.projectRoot, absolute));
await writeProjectJsonAtomic(inventory.projectRoot, absolute, palette);
}
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
resourcePath: existing.resourcePath,
palette,
affectedSceneCount: referencedSceneCount(inventory, input.paletteId, access),
};
}
export async function assignScenePalette(projectPath, input, access) {
const inventory = await paletteProject(projectPath, access);
const scene = requireScene(inventory, input.sceneId, access);
requirePalette(inventory, input.paletteId, access);
validateSlot(input.slot);
const uiPalette = resolveUiPalette(inventory, access);
if (input.target === "background" && input.slot === uiPalette.slot && input.allowUiSlot !== true) {
throw new GameStudioProjectError("UI_PALETTE_SLOT_RESERVED", `Background palette slot ${input.slot} is reserved for the UI/font palette; set allowUiSlot to override it explicitly.`);
}
const field = input.target === "background" ? "paletteIds" : "spritePaletteIds";
const update = (resource) => {
const ids = paletteIds(resource[field]);
while (ids.length <= input.slot)
ids.push("");
ids[input.slot] = input.paletteId;
return { ...resource, [field]: ids };
};
let updated;
if (inventory.format === "legacy") {
const descriptor = await readProjectJson(inventory.projectRoot, inventory.projectPath);
const scenes = Array.isArray(descriptor.scenes) ? descriptor.scenes : [];
let changed;
descriptor.scenes = scenes.map((candidate) => {
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate) || candidate.id !== input.sceneId) {
return candidate;
}
changed = update(candidate);
return changed;
});
updated = changed;
await writeProjectJsonAtomic(inventory.projectRoot, inventory.projectPath, descriptor);
}
else {
const absolute = path.join(inventory.projectRoot, scene.resourcePath);
updated = update(await readProjectJson(inventory.projectRoot, absolute));
await writeProjectJsonAtomic(inventory.projectRoot, absolute, updated);
}
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
resourcePath: scene.resourcePath,
scene: updated,
target: input.target,
slot: input.slot,
paletteId: input.paletteId,
};
}
export function decodeBackgroundPaletteTiles(metadata, totalTiles) {
const encoded = typeof metadata.tileColors === "string" ? metadata.tileColors : "";
const decoded = decodeResourceBytes(encoded, { maximumValues: totalTiles, allowImplicitZeroGrid: true });
if (decoded.length === 0)
return new Uint8Array(totalTiles);
if (decoded.length !== totalTiles) {
throw new GameStudioProjectError("INVALID_TILE_COLOR_GRID", `Background tile colors decode to ${decoded.length} entries, but the background contains ${totalTiles} tiles.`);
}
return decoded;
}
/** Apply bounded palette edits without mutating metadata or accessing a project. */
export function applyBackgroundPaletteEdits(metadata, input) {
const { width, height } = input;
if (input.edits.length === 0 || input.edits.length > 1_024) {
throw new GameStudioProjectError("INVALID_PALETTE_EDIT", "Provide between 1 and 1,024 bounded palette edits.");
}
validatePositiveInteger(width, "Background tile width");
validatePositiveInteger(height, "Background tile height");
const totalTiles = width * height;
if (!Number.isSafeInteger(totalTiles) || totalTiles > 1_048_576) {
throw new GameStudioProjectError("INVALID_TILE_COLOR_GRID", "The background palette grid exceeds its safe size limit.");
}
validateSlot(input.reservedUiSlot);
const tileColors = decodeBackgroundPaletteTiles(metadata, totalTiles);
const changed = new Set();
for (const edit of input.edits) {
validateSlot(edit.slot);
if (edit.slot === input.reservedUiSlot) {
throw new GameStudioProjectError("UI_PALETTE_SLOT_RESERVED", `Background palette slot ${edit.slot} is reserved for the UI/font palette.`);
}
if (input.assignedSlots !== undefined && !input.assignedSlots.includes(edit.slot)) {
throw new GameStudioProjectError("UNASSIGNED_PALETTE_SLOT", `Scene background palette slot ${edit.slot} has no assigned palette.`);
}
const editWidth = edit.width ?? 1;
const editHeight = edit.height ?? 1;
validatePositiveInteger(editWidth, "Palette edit width");
validatePositiveInteger(editHeight, "Palette edit height");
if (!Number.isSafeInteger(edit.x) || !Number.isSafeInteger(edit.y) || edit.x < 0 || edit.y < 0 ||
edit.x + editWidth > width || edit.y + editHeight > height) {
throw new GameStudioProjectError("PALETTE_EDIT_OUT_OF_BOUNDS", `Palette edit (${edit.x}, ${edit.y}) exceeds the ${width}×${height} background tile grid.`);
}
for (let y = edit.y; y < edit.y + editHeight; y += 1) {
for (let x = edit.x; x < edit.x + editWidth; x += 1) {
const index = y * width + x;
const previous = tileColors[index];
if (previous >= HARDWARE_PALETTE_SLOTS) {
throw new GameStudioProjectError("UNSUPPORTED_TILE_COLOR_ATTRIBUTES", `Tile (${x}, ${y}) contains opaque unverified attribute bits (${previous}); refusing to alter its native metadata.`);
}
if (previous !== edit.slot) {
tileColors[index] = edit.slot;
changed.add(index);
}
}
}
}
const tileSlotHistogram = {};
for (const slot of tileColors)
tileSlotHistogram[String(slot)] = (tileSlotHistogram[String(slot)] ?? 0) + 1;
return {
metadata: changed.size > 0 ? { ...metadata, tileColors: encodeResourceBytes(tileColors) } : metadata,
affectedTileCount: changed.size,
tileSlotHistogram,
};
}
export async function paintBackgroundPalette(projectPath, input, access) {
const inventory = await paletteProject(projectPath, access);
const background = access === undefined
? inventory.assets.find((asset) => asset.type === "background" && asset.id === input.backgroundId)
: access.asset({ assetId: input.backgroundId }).asset;
if (background === undefined || background.type !== "background" || background.metadataPath === null) {
throw new GameStudioProjectError("BACKGROUND_NOT_FOUND", `No editable background exists with id ${input.backgroundId}.`);
}
if (input.edits.length === 0 || input.edits.length > 1_024) {
throw new GameStudioProjectError("INVALID_PALETTE_EDIT", "Provide between 1 and 1,024 bounded palette edits.");
}
const scene = input.sceneId === undefined ? undefined : requireScene(inventory, input.sceneId, access);
if (scene !== undefined && scene.backgroundId !== background.id) {
throw new GameStudioProjectError("SCENE_BACKGROUND_MISMATCH", "The selected scene does not use the selected background.");
}
const metadataPath = path.join(inventory.projectRoot, background.metadataPath);
const metadata = await readProjectJson(inventory.projectRoot, metadataPath);
const width = typeof metadata.width === "number" ? metadata.width : Math.ceil((background.width ?? 0) / 8);
const height = typeof metadata.height === "number" ? metadata.height : Math.ceil((background.height ?? 0) / 8);
const sceneSlots = scene === undefined ? undefined : resolvePaletteSlots(inventory, scene, "background", access);
const applied = applyBackgroundPaletteEdits(metadata, {
width,
height,
edits: input.edits,
reservedUiSlot: resolveUiPalette(inventory, access).slot,
...(sceneSlots === undefined ? {} : {
assignedSlots: sceneSlots.flatMap((palette, slot) => palette === null ? [] : [slot]),
}),
});
if (applied.affectedTileCount > 0) {
await writeProjectJsonAtomic(inventory.projectRoot, metadataPath, applied.metadata);
}
const affectedSceneCount = access === undefined
? inventory.scenes.filter((candidate) => candidate.backgroundId === background.id).length
: new Set(access.assetUsers(background.id)
.filter((reference) => reference.owner.resourceType === "scene")
.map((reference) => reference.owner.sceneId ?? reference.owner.resourceId)
.filter((sceneId) => access.scene(sceneId)?.backgroundId === background.id)).size;
return {
projectPath: inventory.projectPath,
projectRoot: inventory.projectRoot,
resourcePath: background.metadataPath,
backgroundId: background.id,
affectedTileCount: applied.affectedTileCount,
affectedSceneCount,
tileSlotHistogram: applied.tileSlotHistogram,
};
}
//# sourceMappingURL=palettes.js.mapSHA-256: 327b0d41712a0a8a02619244dce12de8472cab265960b8335bc07453fba28398