import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import sharp from "sharp"; import { TILE_CACHE_ROOT, TILE_SIZE } from "../config.js"; import { FLAG_FLIPPED, ShapeArchive, collectRenderItems, loadGlobs, loadMapItems, loadPalette, loadTypeflags, resolveStaticFile, summarizeRenderClasses } from "./formats.js"; import { blitFrame, encodePng, rgbaBuffer } from "./png.js"; import { prepareSortedItems } from "./sorting.js"; function nowIso() { return new Date().toISOString(); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } const DOWNLOAD_CACHE_ROOT = path.join(TILE_CACHE_ROOT, "downloads"); sharp.cache(false); function normalizeBuildOptions(options = {}) { return { includeEditor: options.includeEditor !== false, includeRoofs: options.includeRoofs === true }; } function buildOptionSuffix(options) { return `editor-${options.includeEditor ? "on" : "off"}_roofs-${options.includeRoofs ? "on" : "off"}`; } function makeUsageInfo(gameId, mapId, baseItems, renderItems) { const itemMapNums = [...new Set(baseItems.map((item) => item.mapNum))].sort((left, right) => left - right); return { status: "unknown", confidence: "unknown", knownHints: [], itemMapNums, nonzeroItemMapNums: itemMapNums.filter((value) => value !== 0), npcLinkedItemCount: baseItems.filter((item) => item.npcNum !== 0).length, note: "No authoritative mission-to-map ownership table has been extracted yet. Unknown does not imply orphaned.", hasRenderableContent: renderItems.length > 0, game: gameId, map: mapId }; } function buildEmptyMetadata(gameConfig, mapId, baseItems, reason) { return { game: gameConfig.id, gameLabel: gameConfig.label, map: mapId, rawItemCount: baseItems.length, itemCount: 0, paintedItemCount: 0, occludedItemCount: 0, invalidItemCount: 0, invalidItems: [], usage: makeUsageInfo(gameConfig.id, mapId, baseItems, []), baseItemSummary: { roofItems: 0, editorItems: 0, eggFamilyItems: 0, invisibleFlaggedItems: 0, npcLinkedItems: 0 }, sorter: "scummvm_dependency_graph", isEmpty: true, emptyReason: reason, filters: { includeEditor: true, includeRoofs: false }, bounds: { screenLeft: 0, screenTop: 0, screenRight: TILE_SIZE, screenBottom: TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE }, tileSize: TILE_SIZE, tileCountX: 1, tileCountY: 1, zoom: { min: 0.01, max: 8, step: 0.1, initial: 1 } }; } export class BuildManager { constructor(catalog) { this.catalog = catalog; this.assetCache = new Map(); this.jobs = new Map(); this.jobsByKey = new Map(); this.tileCache = new Map(); ensureDir(TILE_CACHE_ROOT); } listCatalog() { return this.catalog; } getJob(jobId) { return this.jobs.get(jobId) ?? null; } async createOrReuseBuild(gameConfig, mapId, rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); const key = `${gameConfig.id}:${mapId}:${buildOptionSuffix(options)}`; const existing = this.jobsByKey.get(key); if (existing) { if (existing.status === "ready" || existing.status === "building") { return existing; } this.jobsByKey.delete(key); } const job = { id: crypto.randomUUID(), key, game: gameConfig.id, mapId, options, status: "queued", phase: "queued", createdAt: nowIso(), updatedAt: nowIso(), progress: [], error: null, metadata: null, build: null }; this.jobs.set(job.id, job); this.jobsByKey.set(key, job); void this.runBuild(job, gameConfig); return job; } async runBuild(job, gameConfig) { try { job.status = "building"; job.phase = "loading-assets"; this.touchJob(job, `Loading ${gameConfig.label} assets`); const assets = this.getAssets(gameConfig); job.phase = "loading-map"; this.touchJob(job, `Loading map ${job.mapId}`); const fixedDatPath = resolveStaticFile(gameConfig.staticDir, "FIXED.DAT"); const baseItems = loadMapItems(fixedDatPath, job.mapId); this.touchJob(job, `Loaded ${baseItems.length} fixed records`); job.phase = "collecting-items"; const renderItems = collectRenderItems(baseItems, assets.shapeInfos, assets.globs, { includeEditor: job.options.includeEditor, expandGlobs: true, worldRect: null, includeRoofs: job.options.includeRoofs, includeHiddenMarkers: true, checkpointEvery: 2000, progress: (message) => this.touchJob(job, message) }); if (!renderItems.length) { job.build = { assets, prepared: [], minLeft: 0, minTop: 0, width: TILE_SIZE, height: TILE_SIZE }; job.metadata = buildEmptyMetadata(gameConfig, job.mapId, baseItems, "This map has no renderable items in FIXED.DAT."); job.metadata.filters = { includeEditor: job.options.includeEditor, includeRoofs: job.options.includeRoofs }; job.status = "ready"; job.phase = "ready"; this.touchJob(job, "Build ready: map is empty, serving a blank placeholder tile"); return; } job.phase = "sorting"; const sorted = prepareSortedItems(renderItems, assets.shapeArchive, assets.shapeInfos, { checkpointEvery: 2000, maxInvalidDetails: 20, progress: (message) => this.touchJob(job, message) }); if (!sorted.prepared.length) { job.build = { assets, prepared: [], minLeft: 0, minTop: 0, width: TILE_SIZE, height: TILE_SIZE }; job.metadata = buildEmptyMetadata( gameConfig, job.mapId, baseItems, "This map resolved to no valid shape or frame pairs after decoding." ); job.metadata.filters = { includeEditor: job.options.includeEditor, includeRoofs: job.options.includeRoofs }; job.status = "ready"; job.phase = "ready"; this.touchJob(job, "Build ready: no valid frames were renderable, serving a blank placeholder tile"); return; } const width = sorted.maxRight - sorted.minLeft; const height = sorted.maxBottom - sorted.minTop; if (width <= 0 || height <= 0) { throw new Error("Computed image bounds are invalid"); } const metadata = { game: gameConfig.id, gameLabel: gameConfig.label, map: job.mapId, rawItemCount: baseItems.length, itemCount: renderItems.length, paintedItemCount: sorted.prepared.length, occludedItemCount: sorted.occludedCount, invalidItemCount: sorted.invalidItemCount, invalidItems: sorted.invalidItems, usage: makeUsageInfo(gameConfig.id, job.mapId, baseItems, renderItems), baseItemSummary: summarizeRenderClasses(baseItems, assets.shapeInfos), sorter: "scummvm_dependency_graph", isEmpty: false, emptyReason: null, filters: { includeEditor: job.options.includeEditor, includeRoofs: job.options.includeRoofs }, bounds: { screenLeft: sorted.minLeft, screenTop: sorted.minTop, screenRight: sorted.maxRight, screenBottom: sorted.maxBottom, width, height }, tileSize: TILE_SIZE, tileCountX: Math.ceil(width / TILE_SIZE), tileCountY: Math.ceil(height / TILE_SIZE), zoom: { min: 0.01, max: 8, step: 0.1, initial: 1 } }; job.build = { assets, prepared: sorted.prepared, minLeft: sorted.minLeft, minTop: sorted.minTop, width, height }; job.metadata = metadata; job.status = "ready"; job.phase = "ready"; this.touchJob(job, `Build ready with ${metadata.tileCountX}x${metadata.tileCountY} tiles`); } catch (error) { job.status = "failed"; job.phase = "failed"; job.error = error instanceof Error ? error.message : String(error); this.touchJob(job, `Build failed: ${job.error}`); } } getAssets(gameConfig) { if (this.assetCache.has(gameConfig.id)) { return this.assetCache.get(gameConfig.id); } const assets = { palette: loadPalette(resolveStaticFile(gameConfig.staticDir, "GAMEPAL.PAL")), shapeInfos: loadTypeflags(resolveStaticFile(gameConfig.staticDir, "TYPEFLAG.DAT")), globs: loadGlobs(resolveStaticFile(gameConfig.staticDir, "GLOB.FLX")), shapeArchive: new ShapeArchive(resolveStaticFile(gameConfig.staticDir, "SHAPES.FLX")) }; this.assetCache.set(gameConfig.id, assets); return assets; } touchJob(job, message) { job.updatedAt = nowIso(); job.progress.push({ at: job.updatedAt, phase: job.phase, message }); if (job.progress.length > 100) { job.progress.splice(0, job.progress.length - 100); } } getPublicJob(job) { return { id: job.id, game: job.game, mapId: job.mapId, options: job.options, status: job.status, phase: job.phase, createdAt: job.createdAt, updatedAt: job.updatedAt, error: job.error, metadata: job.status === "ready" ? job.metadata : null, progress: job.progress }; } getMetadata(jobId, gameId, mapId) { const job = this.requireReadyJob(jobId, gameId, mapId); return job.metadata; } async renderFullMap(jobId, gameId, mapId) { const job = this.requireReadyJob(jobId, gameId, mapId); const outputPath = path.join( DOWNLOAD_CACHE_ROOT, gameId, `map-${mapId}`, `${buildOptionSuffix(job.options)}.png` ); if (fs.existsSync(outputPath)) { return outputPath; } ensureDir(path.dirname(outputPath)); const composites = []; for (let tileY = 0; tileY < job.metadata.tileCountY; tileY += 1) { for (let tileX = 0; tileX < job.metadata.tileCountX; tileX += 1) { composites.push({ input: this.renderTile(jobId, gameId, mapId, tileX, tileY), left: tileX * job.metadata.tileSize, top: tileY * job.metadata.tileSize }); } } await sharp({ create: { width: job.metadata.bounds.width, height: job.metadata.bounds.height, channels: 4, background: { r: 10, g: 12, b: 18, alpha: 1 } }, limitInputPixels: false }) .composite(composites) .png() .toFile(outputPath); return outputPath; } renderTile(jobId, gameId, mapId, tileX, tileY) { const job = this.requireReadyJob(jobId, gameId, mapId); const tileKey = `${job.id}:${tileX}:${tileY}`; if (this.tileCache.has(tileKey)) { return this.tileCache.get(tileKey); } const tilePath = path.join( TILE_CACHE_ROOT, gameId, `map-${mapId}`, buildOptionSuffix(job.options), `${tileX}-${tileY}.png` ); if (fs.existsSync(tilePath)) { const cached = fs.readFileSync(tilePath); this.tileCache.set(tileKey, cached); return cached; } const tileLeft = tileX * TILE_SIZE; const tileTop = tileY * TILE_SIZE; const tileWidth = Math.max(0, Math.min(TILE_SIZE, job.build.width - tileLeft)); const tileHeight = Math.max(0, Math.min(TILE_SIZE, job.build.height - tileTop)); if (tileWidth <= 0 || tileHeight <= 0) { throw new Error("Requested tile is outside the rendered map bounds"); } const buffer = rgbaBuffer(tileWidth, tileHeight); const screenLeft = job.build.minLeft + tileLeft; const screenTop = job.build.minTop + tileTop; const screenRight = screenLeft + tileWidth; const screenBottom = screenTop + tileHeight; for (const node of job.build.prepared) { if (node.right <= screenLeft || node.left >= screenRight || node.bottom <= screenTop || node.top >= screenBottom) { continue; } blitFrame( buffer, tileWidth, tileHeight, node.left - screenLeft, node.top - screenTop, node.frame, node.pixels, job.build.assets.palette, Boolean(node.item.flags & FLAG_FLIPPED) ); } const png = encodePng(tileWidth, tileHeight, buffer); ensureDir(path.dirname(tilePath)); fs.writeFileSync(tilePath, png); this.tileCache.set(tileKey, png); return png; } requireReadyJob(jobId, gameId, mapId) { const job = this.getJob(jobId); if (!job) { throw new Error("Unknown build id"); } if (job.game !== gameId || job.mapId !== mapId) { throw new Error("Build id does not match the requested map"); } if (job.status !== "ready") { throw new Error("Build is not ready yet"); } return job; } }