← Files ModRetro Chromatic PluginARCHIVED FILE
dist/tilemaps.js
40 KB · Oct 2, 2026 · 00:37 UTC
import { constants } from "node:fs";
import { lstat, open } from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { PNG } from "pngjs";
import { createBackgroundMetadata, decodePngBytes, validateBackgroundPng } from "./assets.js";
import { applyCollisionEdits } from "./collisions.js";
import { analyzeGraphicsImage } from "./graphics.js";
import { applyBackgroundPaletteEdits, decodeBackgroundPaletteTiles, resolvePaletteSlots, resolveUiPalette } from "./palettes.js";
import { resolveProjectPath, stableResourceId } from "./project-files.js";
import { ProjectWorldIndex } from "./project-world-index.js";
import { reviewedNativeBackgroundOwner } from "./wrecklight-dependencies.js";
import { decodeResourceBytes } from "./resource-codec.js";
import { applyPreparedProjectTransaction } from "./scene-batch.js";
import { applyTilemapOperations, prepareAtlasPixels, renderTilemap, replaceTilemapCells, setTilemapCollisionCells, sha256, tilemapDiff, tilemapError, tilePixels, validateRoomCells, validateRoomDimensions } from "./tilemap-compose.js";
import { atlasSchema, MAX_TILEMAP_JSON_BYTES, roomSchema, tilemapSchemas } from "./tilemap-schema.js";
const MAX_PNG_BYTES = 16 * 1024 * 1024;
const jsonBytes = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
function parse(schema, value) {
const result = schema.safeParse(value);
if (!result.success)
tilemapError("INVALID_TILEMAP_INPUT", result.error.issues.slice(0, 6).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
return result.data;
}
/** Authoring manifests and sources participate in the existing project revision domain. */
async function ownedPath(root, candidate, kind) {
const prefix = kind === "background" ? "assets/backgrounds/" : "project/tilemaps/";
const suffix = kind === "atlas" ? ".atlas.json" : kind === "recipe" ? ".room.json" : ".png";
if (path.isAbsolute(candidate) || candidate.includes("\\") || candidate.includes(":") || !candidate.startsWith(prefix) || !candidate.endsWith(suffix) ||
candidate.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
tilemapError("INVALID_TILEMAP_PATH", `${kind} paths must be normalized project-relative ${prefix}…${suffix} paths.`);
}
const absolute = path.resolve(root, candidate);
const resolved = await resolveProjectPath(root, absolute);
if (absolute !== resolved)
tilemapError("UNSAFE_SYMLINK", "Tilemap ownership paths cannot use symbolic-link aliases.");
// An internal symlink is legal for some legacy readers but not for revision-owned recipes.
let current = root;
for (const segment of candidate.split("/")) {
current = path.join(current, segment);
try {
if ((await lstat(current)).isSymbolicLink())
tilemapError("UNSAFE_SYMLINK", "Tilemap ownership paths cannot contain symbolic links.");
}
catch (error) {
if (error.code === "ENOENT")
break;
throw error;
}
}
return absolute;
}
async function readBytes(root, candidate, maximum) {
const filename = await resolveProjectPath(root, candidate, { mustExist: true });
const before = await lstat(filename);
if (filename !== path.resolve(root, candidate) || before.isSymbolicLink()) {
tilemapError("UNSAFE_SYMLINK", "Tilemap inputs cannot use untracked symbolic-link aliases.");
}
if (!before.isFile() || before.size > maximum)
tilemapError("TILEMAP_LIMIT", `Tilemap input must be a regular file no larger than ${maximum} bytes.`);
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
const handle = await open(filename, flags).catch((error) => {
if (error.code === "ELOOP")
tilemapError("UNSAFE_SYMLINK", "Tilemap input was exchanged for a symbolic link before opening.");
throw error;
});
try {
const info = await handle.stat();
const confirmedPath = await resolveProjectPath(root, candidate, { mustExist: true });
const confirmed = await lstat(confirmedPath);
if (confirmedPath !== filename || confirmed.isSymbolicLink() || info.dev !== before.dev || info.ino !== before.ino || info.dev !== confirmed.dev || info.ino !== confirmed.ino) {
tilemapError("UNSAFE_SYMLINK", "Tilemap input identity changed before its confined read.");
}
if (!info.isFile() || info.size > maximum)
tilemapError("TILEMAP_LIMIT", `Tilemap input must be a regular file no larger than ${maximum} bytes.`);
// The extra byte detects growth without an unbounded readFile allocation.
const bytes = Buffer.alloc(info.size + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await handle.read(bytes, length, bytes.length - length, length);
if (bytesRead === 0)
break;
length += bytesRead;
}
const after = await handle.stat();
if (length !== info.size || after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
tilemapError("STALE_PROJECT_REVISION", "Tilemap input changed during its bounded read.");
}
return bytes.subarray(0, length);
}
finally {
await handle.close();
}
}
function parseJson(bytes) {
try {
return JSON.parse(bytes.toString("utf8"));
}
catch {
tilemapError("INVALID_TILEMAP_INPUT", "Tilemap JSON could not be parsed.");
}
}
async function requireMissing(root, filename) {
const safe = await resolveProjectPath(root, filename);
try {
await lstat(safe);
}
catch (error) {
if (error.code === "ENOENT")
return;
throw error;
}
tilemapError("TILEMAP_ALREADY_EXISTS", `Refusing to overwrite existing resource ${filename}.`);
}
async function rejectMonoCompanion(root, filename) {
const companion = filename.replace(/\.png$/u, ".mono.png");
const safe = await resolveProjectPath(root, companion);
try {
await lstat(safe);
}
catch (error) {
if (error.code === "ENOENT")
return;
throw error;
}
tilemapError("UNSUPPORTED_TILEMAP_BACKGROUND", "A separate monochrome companion requires its own authoring contract; tilemap v1 will not omit or overwrite it.");
}
async function readAtlas(root, atlasPath, binding) {
await ownedPath(root, atlasPath, "atlas");
const bytes = await readBytes(root, atlasPath, MAX_TILEMAP_JSON_BYTES);
const rawAtlas = parseJson(bytes);
parse(atlasSchema, rawAtlas);
const atlas = rawAtlas;
if (binding && (binding.path !== atlasPath || binding.sha256 !== sha256(bytes) || binding.sourceSha256 !== atlas.sourceSha256 ||
(binding.id !== undefined && binding.id !== atlas.id) || (binding.sourcePath !== undefined && binding.sourcePath !== atlas.sourcePath))) {
tilemapError("STALE_TILEMAP_ATLAS", "The atlas differs from its explicit recipe-owned registration.");
}
if (!binding && atlas.id !== stableResourceId("tilemap-atlas", atlasPath))
tilemapError("STALE_TILEMAP_ATLAS", "Atlas identity no longer matches its registered path.");
await ownedPath(root, atlas.sourcePath, "source");
const source = await readBytes(root, atlas.sourcePath, MAX_PNG_BYTES);
if (sha256(source) !== atlas.sourceSha256)
tilemapError("STALE_TILEMAP_ATLAS", "Atlas source PNG changed; explicitly register a new atlas version instead of repainting existing rooms.");
return { atlas, bytes, sourceBytes: source, pixels: prepareAtlasPixels(atlas, decodePngBytes(source)) };
}
function selectedScene(snapshot, sceneId) {
const scene = snapshot.scenesById.get(sceneId);
if (!scene || snapshot.inventory.scenes.filter((candidate) => candidate.id === sceneId).length !== 1)
tilemapError("SCENE_NOT_FOUND", `Scene ${sceneId} must resolve uniquely in the selected project.`);
validateRoomDimensions(Number(scene.width), Number(scene.height));
if (typeof scene.tilesetId === "string" && scene.tilesetId !== "") {
tilemapError("UNSUPPORTED_TILEMAP_SCENE", "Scenes with an explicit native tileset binding require a separate integration contract.");
}
return scene;
}
function decodeCollisions(scene, count) {
if (typeof scene.collisions !== "string")
tilemapError("INVALID_COLLISIONS", "Collisions must be a native byte-run string; tilemaps never reset invalid grids.");
const decoded = decodeResourceBytes(scene.collisions, { maximumValues: count, allowImplicitZeroGrid: true });
const cells = new Uint8Array(count);
cells.set(decoded);
return cells;
}
async function readBackground(snapshot, scene) {
const asset = snapshot.assetsById.get(String(scene.backgroundId));
if (!asset || asset.type !== "background" || !asset.metadataPath || snapshot.inventory.assets.filter((candidate) => candidate.id === asset.id).length !== 1)
tilemapError("BACKGROUND_NOT_FOUND", "The selected scene must use a unique registered native background.");
await ownedPath(snapshot.projectRoot, asset.resourcePath, "background");
await rejectMonoCompanion(snapshot.projectRoot, asset.resourcePath);
const pngBytes = await readBytes(snapshot.projectRoot, asset.resourcePath, MAX_PNG_BYTES);
const metadataBytes = await readBytes(snapshot.projectRoot, asset.metadataPath, MAX_TILEMAP_JSON_BYTES);
const metadata = parseJson(metadataBytes);
if (!metadata || metadata._resourceType !== "background" || metadata.id !== asset.id || metadata.autoColor === true || metadata.autoColor === 1) {
tilemapError("UNSUPPORTED_TILEMAP_BACKGROUND", "Tilemap v1 requires a registered manual-color background with matching identity.");
}
const image = decodePngBytes(pngBytes);
validateBackgroundPng(image, false);
if (image.width !== Number(scene.width) * 8 || image.height !== Number(scene.height) * 8 ||
metadata.width !== scene.width || metadata.height !== scene.height || metadata.imageWidth !== image.width || metadata.imageHeight !== image.height) {
tilemapError("TILEMAP_DIMENSION_MISMATCH", "Scene, PNG and native metadata dimensions must already match; no resize is inferred.");
}
return { asset, pngBytes, metadataBytes, metadata, image };
}
function palettePolicy(snapshot, scene) {
const reservedUiSlot = resolveUiPalette(snapshot.inventory).slot;
const assignedSlots = resolvePaletteSlots(snapshot.inventory, scene, "background")
.flatMap((palette, index) => palette === null ? [] : [index]);
return { reservedUiSlot, assignedSlots };
}
function checkPalette(slot, policy, reserved) {
if (slot === policy.reservedUiSlot)
tilemapError("UI_PALETTE_SLOT_RESERVED", `Background palette slot ${slot} is reserved for UI/fonts.`);
if (reserved.includes(slot))
tilemapError("TILEMAP_RESERVED_PALETTE", `Background palette slot ${slot} is reserved by this recipe.`);
if (!policy.assignedSlots.includes(slot))
tilemapError("UNASSIGNED_PALETTE_SLOT", `Scene background palette slot ${slot} has no native assignment.`);
}
function captureCells(image, pixels, palettes, collisions) {
const firstTileByPattern = new Map();
for (const [id, pattern] of pixels.patterns)
if (!firstTileByPattern.has(pattern))
firstTileByPattern.set(pattern, id);
const width = image.width / 8;
return Array.from({ length: width * (image.height / 8) }, (_, index) => {
const tileId = firstTileByPattern.get(sha256(tilePixels(image, index % width, Math.floor(index / width))));
if (!tileId)
tilemapError("TILEMAP_CAPTURE_TILE_MISSING", `Existing tile (${index % width}, ${Math.floor(index / width)}) has no exact named atlas match; capture wrote nothing. Add an explicit source tile and register a new atlas version.`);
return { tileId, paletteByte: palettes[index], collisionByte: collisions[index] };
});
}
function sourceAnalysis(snapshot, scene, room, image) {
const override = scene.colorModeOverride;
const mode = (override === "mono" || override === "mixed" || override === "color" ? override : snapshot.settings.colorMode ?? "mixed");
const analysis = analyzeGraphicsImage(image, room.backgroundPath, { kind: "background", colorMode: mode, allowFlipDeduplication: false });
if (!analysis.valid || analysis.validationStatus !== "valid")
tilemapError("TILEMAP_GRAPHICS_INVALID", `Native background analysis failed: ${JSON.stringify(analysis.violationCounts)}.`);
const cap = Math.min(room.maxUniqueTiles ?? analysis.tileBudget.limit, analysis.tileBudget.limit);
if (analysis.uniqueTiles > cap)
tilemapError("TILEMAP_TILE_BUDGET", `${analysis.uniqueTiles} exact 8 × 8 patterns exceed the caller/native cap ${cap}.`);
const tileSlotHistogram = {};
for (const cell of room.cells)
tileSlotHistogram[cell.paletteByte] = (tileSlotHistogram[cell.paletteByte] ?? 0) + 1;
return { uniqueTiles: analysis.uniqueTiles, totalTiles: analysis.totalTiles, nativeLimit: analysis.tileBudget.limit, receivingCap: cap, colorMode: mode, flipDeduplication: false, tileSlotHistogram, validation: "authored-source-only" };
}
function outputReceipt(room, recipePath, recipeBytes = jsonBytes(room)) {
return {
recipePath, atlasPath: room.atlasPath, sceneId: room.sceneId, backgroundId: room.backgroundId,
predecessorBackgroundId: room.predecessorBackgroundId, width: room.width, height: room.height, units: room.units,
outputs: { png: { path: room.backgroundPath, sha256: room.native.pngSha256 }, metadata: { path: `${room.backgroundPath}.gbsres`, sha256: room.native.metadataSha256 }, recipe: { path: recipePath, sha256: sha256(recipeBytes) } },
};
}
async function createTilemapAtlasIndexed(projectPath, rawInput, access) {
const input = parse(tilemapSchemas.atlasCreate, rawInput);
const { value, ...transaction } = await applyPreparedProjectTransaction(projectPath, input, async (snapshot) => {
await ownedPath(snapshot.projectRoot, input.atlasPath, "atlas");
await ownedPath(snapshot.projectRoot, input.sourcePath, "source");
await requireMissing(snapshot.projectRoot, input.atlasPath);
const source = await readBytes(snapshot.projectRoot, input.sourcePath, MAX_PNG_BYTES);
const atlas = parse(atlasSchema, { format: "gb-studio-tile-atlas", version: 1, id: stableResourceId("tilemap-atlas", input.atlasPath), name: input.name, sourcePath: input.sourcePath, sourceSha256: sha256(source), tiles: input.tiles, primitives: input.primitives });
const pixels = prepareAtlasPixels(atlas, decodePngBytes(source));
const bytes = jsonBytes(atlas);
if (bytes.length > MAX_TILEMAP_JSON_BYTES)
tilemapError("TILEMAP_LIMIT", "Atlas definition exceeds its bounded JSON size.");
return { files: [{ relativePath: input.atlasPath, before: null, after: bytes }], value: { atlasPath: input.atlasPath, id: atlas.id, atlasSha256: sha256(bytes), sourceSha256: atlas.sourceSha256, tiles: atlas.tiles.length, primitives: atlas.primitives.length, uniquePatterns: new Set(pixels.patterns.values()).size } };
}, access);
return { ...value, ...transaction };
}
async function createTilemapIndexed(projectPath, rawInput, access) {
const input = parse(tilemapSchemas.create, rawInput);
const { value, ...transaction } = await applyPreparedProjectTransaction(projectPath, input, async (snapshot) => {
await ownedPath(snapshot.projectRoot, input.recipePath, "recipe");
await ownedPath(snapshot.projectRoot, input.backgroundPath, "background");
await rejectMonoCompanion(snapshot.projectRoot, input.backgroundPath);
for (const filename of [input.recipePath, input.backgroundPath, `${input.backgroundPath}.gbsres`])
await requireMissing(snapshot.projectRoot, filename);
const scene = selectedScene(snapshot, input.sceneId);
const existing = await readBackground(snapshot, scene);
const { atlas, bytes: atlasBytes, pixels } = await readAtlas(snapshot.projectRoot, input.atlasPath);
const width = Number(scene.width), height = Number(scene.height);
const collisions = decodeCollisions(scene, width * height);
const palettes = decodeBackgroundPaletteTiles(existing.metadata, width * height);
const policy = palettePolicy(snapshot, scene);
const reserved = [...new Set([...(input.reservedPaletteSlots ?? []), policy.reservedUiSlot])].sort();
let cells;
if (input.base.type === "existing") {
cells = captureCells(existing.image, pixels, palettes, collisions);
}
else {
if (!pixels.tiles.has(input.base.tileId))
tilemapError("TILE_NOT_FOUND", `No source tile ${input.base.tileId} exists.`);
checkPalette(input.base.paletteSlot, policy, reserved);
if ((input.protectedRegions?.length ?? 0) > 0)
tilemapError("TILEMAP_PROTECTED_REGION", "A full initial fill cannot claim to preserve protected source regions; use exact existing-pattern capture.");
const base = input.base;
cells = Array.from({ length: width * height }, (_, index) => ({ tileId: base.tileId, paletteByte: base.paletteSlot, collisionByte: collisions[index] }));
}
const backgroundId = stableResourceId("asset", "background", input.backgroundPath);
if (snapshot.inventory.assets.some((asset) => asset.id === backgroundId))
tilemapError("DUPLICATE_RESOURCE_ID", "The output background identity already exists.");
const nativeMetadata = createBackgroundMetadata({ id: backgroundId, name: input.name, filename: path.basename(input.backgroundPath), widthPx: width * 8, heightPx: height * 8 });
const metadata = input.base.type === "existing" ? { ...existing.metadata, ...nativeMetadata } : nativeMetadata;
// Avoid scene-local name-derived symbols. IDs are deterministic and project-global.
metadata.symbol = `bg_tilemap_${backgroundId.replaceAll("-", "")}`;
const existingResources = [...snapshot.inventory.scenes, ...snapshot.inventory.actors, ...snapshot.inventory.triggers, ...snapshot.inventory.palettes];
for (const resource of existingResources)
if (resource.symbol === metadata.symbol)
tilemapError("DUPLICATE_RESOURCE_SYMBOL", `Native symbol ${String(metadata.symbol)} already exists.`);
for (const asset of snapshot.inventory.assets) {
if (!asset.metadataPath)
continue;
const resource = parseJson(await readBytes(snapshot.projectRoot, asset.metadataPath, MAX_TILEMAP_JSON_BYTES));
if (resource.symbol === metadata.symbol)
tilemapError("DUPLICATE_RESOURCE_SYMBOL", `Native symbol ${String(metadata.symbol)} already exists.`);
}
let room = {
format: "gb-studio-tilemap-room", version: 1, id: stableResourceId("tilemap-room", input.recipePath), name: input.name, units: "8px-tiles",
atlasPath: input.atlasPath, atlasSha256: sha256(atlasBytes), sceneId: scene.id, backgroundId, backgroundPath: input.backgroundPath,
predecessorBackgroundId: String(scene.backgroundId), width, height, cells,
protectedRegions: input.protectedRegions ?? [], reservedPaletteSlots: reserved,
...(input.maxUniqueTiles === undefined ? {} : { maxUniqueTiles: input.maxUniqueTiles }),
native: { pngSha256: "0".repeat(64), metadataSha256: "0".repeat(64), collisions: String(scene.collisions) },
};
validateRoomCells(room, pixels);
const image = renderTilemap(room, pixels);
const analysis = sourceAnalysis(snapshot, scene, room, image);
const png = input.base.type === "existing" ? existing.pngBytes : PNG.sync.write(image);
// Capture preserves the exact native encoding, including opaque palette attribute bytes.
if (input.base.type === "existing")
metadata.tileColors = existing.metadata.tileColors;
else {
const base = input.base;
metadata.tileColors = applyBackgroundPaletteEdits(metadata, { width, height, edits: [{ x: 0, y: 0, width, height, slot: base.paletteSlot }], ...policy }).metadata.tileColors;
}
const metadataBytes = jsonBytes(metadata);
room = { ...room, native: { ...room.native, pngSha256: sha256(png), metadataSha256: sha256(metadataBytes) } };
const asset = { id: backgroundId, name: input.name, type: "background", filename: path.basename(input.backgroundPath), resourcePath: input.backgroundPath, metadataPath: `${input.backgroundPath}.gbsres`, hasMetadata: true, width: width * 8, height: height * 8 };
return {
files: [
{ relativePath: input.recipePath, before: null, after: jsonBytes(room) },
{ relativePath: input.backgroundPath, before: null, after: png },
{ relativePath: `${input.backgroundPath}.gbsres`, before: null, after: metadataBytes },
],
sceneBatch: { sceneId: scene.id, operations: [{ type: "scene.update", properties: { backgroundId } }], newAssets: [asset] },
value: { ...outputReceipt(room, input.recipePath), analysis, initialComposition: input.base.type, sceneFields: ["backgroundId"], predecessorRetained: true, collisionsChanged: 0 },
};
}, access);
return { ...value, ...transaction };
}
async function loadBoundRoom(snapshot, recipePath, access, adoption) {
await ownedPath(snapshot.projectRoot, recipePath, "recipe");
const recipeBytes = await readBytes(snapshot.projectRoot, recipePath, MAX_TILEMAP_JSON_BYTES);
const rawRoom = parseJson(recipeBytes);
parse(roomSchema, rawRoom);
// Schema validation must not normalize full retained records (for example names).
const room = rawRoom;
if (adoption && (room.adoptedBinding || sha256(recipeBytes) !== adoption.before.recipeSha256 ||
room.id !== adoption.identity.roomId || room.backgroundId !== adoption.identity.backgroundId || room.sceneId !== adoption.identity.sceneId)) {
tilemapError("TILEMAP_ADOPTION_PREIMAGE", "Adoption requires the exact unadopted recipe preimage.");
}
const binding = room.adoptedBinding;
if (binding ? binding.atlas.sha256 !== binding.preimages.atlasSha256 || binding.atlas.sourceSha256 !== binding.preimages.sourceSha256 ||
binding.recipePath !== recipePath || binding.roomId !== room.id || binding.sceneId !== room.sceneId ||
binding.backgroundId !== room.backgroundId || binding.backgroundPath !== room.backgroundPath :
!adoption && (room.id !== stableResourceId("tilemap-room", recipePath) || room.backgroundId !== stableResourceId("asset", "background", room.backgroundPath))) {
tilemapError("STALE_TILEMAP_BINDING", "Recipe or background identity no longer matches its registered path.");
}
const scene = selectedScene(snapshot, room.sceneId);
if (scene.backgroundId !== room.backgroundId || scene.width !== room.width || scene.height !== room.height)
tilemapError("STALE_TILEMAP_BINDING", "The scene binding/dimensions changed outside this recipe; no automatic rebind or resize is permitted.");
if (scene.collisions !== room.native.collisions)
tilemapError("STALE_TILEMAP_OUTPUT", "Native collisions changed outside this recipe; no collision overwrite is permitted.");
const background = await readBackground(snapshot, scene);
if (background.asset.resourcePath !== room.backgroundPath || sha256(background.pngBytes) !== room.native.pngSha256 || sha256(background.metadataBytes) !== room.native.metadataSha256)
tilemapError("STALE_TILEMAP_OUTPUT", "The native PNG or sidecar changed outside this recipe; preserve the edit and explicitly capture a new version.");
if (snapshot.inventory.scenes.some((other) => other.id !== scene.id && other.backgroundId === room.backgroundId))
tilemapError("TILEMAP_SHARED_BACKGROUND", "This background is now shared; create an additive room version instead of repainting other scenes.");
if (!access.coverage.complete)
tilemapError("TILEMAP_DEPENDENCY_UNCERTAIN", "The selected project's dependency coverage is incomplete; no native tilemap output was changed. Use world_dependencies and its coverageCursor for the actual reasons, including unresolved native references or an unsupported custom-event source, authored contract, or invocation. Successful compilation, retrying or adopt_binding cannot establish an unsupported custom-event contract. Atlas-only inspection remains available.");
const references = access.structuralReferences({ type: "asset", id: room.backgroundId, resourcePath: background.asset.metadataPath });
if (references.some((reference) => reference.owner.sceneId !== scene.id && reference.owner.resourcePath !== background.asset.metadataPath
&& reviewedNativeBackgroundOwner(reference, background.asset.metadataPath) !== scene.id))
tilemapError("TILEMAP_SHARED_BACKGROUND", "Another authored owner references this background; replacement is not confined to the selected scene.");
const atlasBinding = adoption
? { path: room.atlasPath, id: adoption.identity.atlasId, sha256: adoption.before.atlasSha256, sourceSha256: adoption.before.sourceSha256 }
: binding?.atlas.path === room.atlasPath ? binding.atlas : undefined;
const { atlas, bytes: atlasBytes, sourceBytes, pixels } = await readAtlas(snapshot.projectRoot, room.atlasPath, atlasBinding);
if (sha256(atlasBytes) !== room.atlasSha256)
tilemapError("STALE_TILEMAP_ATLAS", "Atlas manifest changed; register/capture a new version explicitly.");
validateRoomCells(room, pixels);
const currentPalettes = decodeBackgroundPaletteTiles(background.metadata, room.cells.length);
const currentCollisions = decodeCollisions(scene, room.cells.length);
if (!renderTilemap(room, pixels).data.equals(background.image.data) || room.cells.some((cell, index) => cell.paletteByte !== currentPalettes[index] || cell.collisionByte !== currentCollisions[index])) {
tilemapError("TILEMAP_RECIPE_DIVERGED", "Recipe cells differ from their compiled native state; use tilemap_edit or explicitly capture a new recipe, never an implicit repaint.");
}
return { room, recipeBytes, scene, background, atlas, atlasBytes, sourceBytes, pixels };
}
async function adoptTilemapBinding(projectPath, input, operation, access) {
const { value, ...transaction } = await applyPreparedProjectTransaction(projectPath, input, async (snapshot) => {
const bound = await loadBoundRoom(snapshot, input.recipePath, access, operation);
const sceneBytes = await readBytes(snapshot.projectRoot, bound.scene.resourcePath, MAX_TILEMAP_JSON_BYTES);
const preimages = {
recipeSha256: sha256(bound.recipeBytes), atlasSha256: sha256(bound.atlasBytes), sourceSha256: sha256(bound.sourceBytes),
pngSha256: sha256(bound.background.pngBytes), metadataSha256: sha256(bound.background.metadataBytes), sceneSha256: sha256(sceneBytes),
};
for (const key of Object.keys(preimages)) {
if (operation.before[key] !== preimages[key])
tilemapError("TILEMAP_ADOPTION_PREIMAGE", `Adoption preimage mismatch: ${key}.`);
}
const room = { ...bound.room, adoptedBinding: {
version: 1, recipePath: input.recipePath, roomId: bound.room.id, sceneId: bound.room.sceneId,
backgroundId: bound.room.backgroundId, backgroundPath: bound.room.backgroundPath,
atlas: { path: bound.room.atlasPath, id: bound.atlas.id, sha256: preimages.atlasSha256, sourcePath: bound.atlas.sourcePath, sourceSha256: preimages.sourceSha256 },
preimages,
} };
const recipeBytes = jsonBytes(room);
if (recipeBytes.length > MAX_TILEMAP_JSON_BYTES)
tilemapError("TILEMAP_LIMIT", "Adopted recipe exceeds its bounded JSON size.");
const analysis = sourceAnalysis(snapshot, bound.scene, room, bound.background.image);
// Only the recipe changes. Exact no-op entries recheck ALL input preimages at
// commit without repainting, re-encoding collisions, or invalidating other
// recipes that pin the same immutable atlas. No newAssets or scene rebind.
const unchanged = [
[bound.room.atlasPath, bound.atlasBytes], [bound.atlas.sourcePath, bound.sourceBytes],
[bound.room.backgroundPath, bound.background.pngBytes], [bound.background.asset.metadataPath, bound.background.metadataBytes],
[bound.scene.resourcePath, sceneBytes],
];
return {
files: [{ relativePath: input.recipePath, before: bound.recipeBytes, after: recipeBytes },
...unchanged.map(([relativePath, bytes]) => ({ relativePath, before: bytes, after: bytes }))],
value: { ...outputReceipt(room, input.recipePath, recipeBytes), analysis, adoptedBinding: room.adoptedBinding,
difference: tilemapDiff(room.cells, room.cells, room.width, bound.pixels),
sceneFields: [], nativeFilesChanged: false, atlasChanged: false, cellRecordsChanged: 0 },
};
}, access);
return { ...value, ...transaction };
}
async function compileRoom(projectPath, input, operations, access) {
const { value, ...transaction } = await applyPreparedProjectTransaction(projectPath, input, async (snapshot) => {
const bound = await loadBoundRoom(snapshot, input.recipePath, access);
const policy = palettePolicy(snapshot, bound.scene);
let pixels = bound.pixels;
let room;
if (operations.some((operation) => operation.type === "set_collision_cells")) {
const operation = operations[0];
if (operations.length !== 1 || operation.type !== "set_collision_cells")
tilemapError("INVALID_TILEMAP_OPERATION", "Selected collision changes must be the sole operation in their revision-bound batch.");
room = setTilemapCollisionCells(bound.room, pixels, operation);
}
else if (operations.some((operation) => operation.type === "replace_cells")) {
const operation = operations[0];
if (operations.length !== 1 || operation.type !== "replace_cells")
tilemapError("INVALID_TILEMAP_OPERATION", "Selected-cell replacement must be the sole operation in its revision-bound batch.");
const target = operation.atlasPath === bound.room.atlasPath
? { atlas: bound.atlas, pixels: bound.pixels, bytes: bound.atlasBytes }
: await readAtlas(snapshot.projectRoot, operation.atlasPath);
if (sha256(target.bytes) !== operation.atlasSha256)
tilemapError("STALE_TILEMAP_ATLAS", "The selected replacement atlas does not match its explicit hash.");
// A separately registered atlas may add patterns, but must not rebind any
// existing tile or primitive. Diff/render then share the same old meanings.
for (const [id, pattern] of bound.pixels.patterns) {
if (target.pixels.patterns.get(id) !== pattern)
tilemapError("TILEMAP_ATLAS_REBIND", `Replacement atlas changes or removes existing tile ${id}.`);
}
for (const primitive of bound.atlas.primitives) {
if (!isDeepStrictEqual(target.atlas.primitives.find((entry) => entry.id === primitive.id), primitive)) {
tilemapError("TILEMAP_ATLAS_REBIND", `Replacement atlas changes or removes existing primitive ${primitive.id}.`);
}
}
pixels = target.pixels;
room = replaceTilemapCells(bound.room, pixels, operation);
for (const cell of operation.cells) {
if (cell.before.paletteByte !== cell.after.paletteByte)
checkPalette(cell.after.paletteByte, policy, room.reservedPaletteSlots);
}
room = { ...room, atlasPath: operation.atlasPath, atlasSha256: operation.atlasSha256 };
}
else {
// Validate every explicit palette action, including no-op slots and overlapping stamps.
for (const operation of operations) {
if (operation.type === "replace_cells" || operation.type === "adopt_binding" || operation.type === "set_collision_cells")
continue;
const primitive = bound.atlas.primitives.find((entry) => entry.id === operation.primitiveId);
for (const cell of primitive?.cells ?? [])
if (cell?.paletteSlot !== undefined)
checkPalette(cell.paletteSlot, policy, bound.room.reservedPaletteSlots);
}
room = applyTilemapOperations(bound.room, bound.atlas, operations);
}
const difference = tilemapDiff(bound.room.cells, room.cells, room.width, pixels);
const image = renderTilemap(room, pixels);
const analysis = sourceAnalysis(snapshot, bound.scene, room, image);
const png = difference.pixelTiles === 0 ? bound.background.pngBytes : PNG.sync.write(image);
const paletteEdits = room.cells.flatMap((cell, index) => cell.paletteByte === bound.room.cells[index].paletteByte ? [] : [{ x: index % room.width, y: Math.floor(index / room.width), slot: cell.paletteByte }]);
let metadata = bound.background.metadata;
for (let start = 0; start < paletteEdits.length; start += 1_024) {
metadata = applyBackgroundPaletteEdits(metadata, { width: room.width, height: room.height, edits: paletteEdits.slice(start, start + 1_024), ...policy }).metadata;
}
const collisionEdits = room.cells.flatMap((cell, index) => cell.collisionByte === bound.room.cells[index].collisionByte ? [] : [{ shape: "tile", x: index % room.width, y: Math.floor(index / room.width), value: cell.collisionByte }]);
const sceneBytes = await readBytes(snapshot.projectRoot, bound.scene.resourcePath, MAX_TILEMAP_JSON_BYTES);
let sceneResource = parseJson(sceneBytes);
const sceneOperations = [];
for (let start = 0; start < collisionEdits.length; start += 1_024) {
const edits = collisionEdits.slice(start, start + 1_024);
sceneResource = applyCollisionEdits(sceneResource, { sceneId: bound.scene.id, edits }).scene;
sceneOperations.push({ type: "collision.edit", edits });
}
const metadataBytes = difference.paletteCells === 0 ? bound.background.metadataBytes : jsonBytes(metadata);
room = { ...room, native: { pngSha256: sha256(png), metadataSha256: sha256(metadataBytes), collisions: String(sceneResource.collisions) } };
const recipeBytes = isDeepStrictEqual(room, bound.room) ? bound.recipeBytes : jsonBytes(room);
const files = [
{ relativePath: input.recipePath, before: bound.recipeBytes, after: recipeBytes },
{ relativePath: room.backgroundPath, before: bound.background.pngBytes, after: png },
{ relativePath: `${room.backgroundPath}.gbsres`, before: bound.background.metadataBytes, after: metadataBytes },
];
return { files, ...(sceneOperations.length === 0 ? {} : { sceneBatch: { sceneId: room.sceneId, operations: sceneOperations } }), value: { ...outputReceipt(room, input.recipePath, recipeBytes), analysis, difference, sceneFields: sceneOperations.length === 0 ? [] : ["collisions"] } };
}, access);
return { ...value, ...transaction };
}
async function editTilemapIndexed(projectPath, rawInput, access) {
const input = parse(tilemapSchemas.edit, rawInput);
if (input.operations.some((operation) => operation.type === "adopt_binding")) {
const operation = input.operations[0];
if (input.operations.length !== 1 || operation.type !== "adopt_binding")
tilemapError("INVALID_TILEMAP_OPERATION", "Binding adoption must be the sole operation in its revision-bound batch.");
return adoptTilemapBinding(projectPath, input, operation, access);
}
return compileRoom(projectPath, input, input.operations, access);
}
async function compileTilemapIndexed(projectPath, rawInput, access) {
const input = parse(tilemapSchemas.compile, rawInput);
return compileRoom(projectPath, input, [], access);
}
async function inspectTilemapIndexed(projectPath, rawInput, access) {
const input = parse(tilemapSchemas.inspect, rawInput);
if (!input.recipePath && !input.atlasPath)
tilemapError("INVALID_TILEMAP_INPUT", "Inspect a recipePath or atlasPath; use both only for that recipe's bound atlas.");
await access.strongRefresh();
const snapshot = await access.snapshot();
if (input.atlasPath) {
const bound = input.recipePath ? await loadBoundRoom(snapshot, input.recipePath, access) : undefined;
if (bound && bound.room.atlasPath !== input.atlasPath)
tilemapError("STALE_TILEMAP_ATLAS", "Requested atlas is not the selected recipe's bound atlas.");
const { atlas, bytes, pixels } = bound
? { atlas: bound.atlas, bytes: bound.atlasBytes, pixels: bound.pixels }
: await readAtlas(snapshot.projectRoot, input.atlasPath);
const offset = input.offset ?? 0, limit = input.limit ?? 32;
return { atlasPath: input.atlasPath, revision: snapshot.revision, atlasSha256: sha256(bytes), sourceSha256: atlas.sourceSha256, name: atlas.name, counts: { tiles: atlas.tiles.length, primitives: atlas.primitives.length, uniquePatterns: new Set(pixels.patterns.values()).size }, tiles: atlas.tiles.slice(offset, offset + limit).map((tile) => ({ ...tile, pixelsSha256: pixels.patterns.get(tile.id) })), primitives: atlas.primitives.slice(offset, offset + limit).map(({ cells, ...primitive }) => ({ ...primitive, opaqueCells: cells.filter((cell) => cell !== null).length })), offset, limit, truncated: Math.max(atlas.tiles.length, atlas.primitives.length) > offset + limit };
}
const { room, pixels, recipeBytes } = await loadBoundRoom(snapshot, input.recipePath, access);
const x = input.x ?? 0, y = input.y ?? 0, width = input.width ?? Math.min(room.width - x, 20), height = input.height ?? Math.min(room.height - y, 18);
if (width < 1 || height < 1 || x + width > room.width || y + height > room.height || width * height > 1_024)
tilemapError("TILEMAP_INSPECTION_LIMIT", "Inspect a valid region of at most 1,024 room cells.");
const cells = [];
for (let row = y; row < y + height; row++)
cells.push(...room.cells.slice(row * room.width + x, row * room.width + x + width));
return { ...outputReceipt(room, input.recipePath, recipeBytes), revision: snapshot.revision, region: { x, y, width, height }, cells: cells.map((cell) => ({ ...cell, pixelsSha256: pixels.patterns.get(cell.tileId) })), protectedRegions: room.protectedRegions, reservedPaletteSlots: room.reservedPaletteSlots, uniquePatterns: new Set(room.cells.map((cell) => pixels.patterns.get(cell.tileId))).size, nativeState: "verified-authored-source" };
}
/** Direct library calls use the same dependency graph and semantic revision fence as MCP. */
async function withTilemapIndex(projectPath, access, action) {
if (access) {
const requested = path.resolve(projectPath);
if (requested !== access.projectPath && requested !== access.projectRoot)
tilemapError("STALE_PROJECT", "Indexed access belongs to another project.");
return action(access);
}
const index = new ProjectWorldIndex(projectPath);
try {
await index.ensureFresh();
return await action(index);
}
finally {
index.dispose();
}
}
export async function createTilemapAtlas(projectPath, input, access) {
return withTilemapIndex(projectPath, access, (index) => createTilemapAtlasIndexed(projectPath, input, index));
}
export async function createTilemap(projectPath, input, access) {
return withTilemapIndex(projectPath, access, (index) => createTilemapIndexed(projectPath, input, index));
}
export async function editTilemap(projectPath, input, access) {
return withTilemapIndex(projectPath, access, (index) => editTilemapIndexed(projectPath, input, index));
}
export async function compileTilemap(projectPath, input, access) {
return withTilemapIndex(projectPath, access, (index) => compileTilemapIndexed(projectPath, input, index));
}
export async function inspectTilemap(projectPath, input, access) {
return withTilemapIndex(projectPath, access, (index) => inspectTilemapIndexed(projectPath, input, index));
}
//# sourceMappingURL=tilemaps.js.mapSHA-256: a46dd6e3d88230cccc72755d8df44cebe8c01d2ce23b5b7df1445a72ef6e754c