import fs from "node:fs"; import path from "node:path"; import { readI32LE, readU16LE, readU24LE, readU32LE } from "./binary.js"; export const FLEX_TABLE_OFFSET = 0x80; export const FLEX_COUNT_OFFSET = 0x54; export const FIXED_MAP_COUNT_OFFSET = 0x54; export const FIXED_MAP_TABLE_OFFSET = 0x80; export const CRUSADER_COORD_SCALE = 2; export const GLOB_COORD_MASK = ~0x3ff; export const GLOB_COORD_SHIFT = 2; export const GLOB_COORD_OFFSET = 2; export const FLAG_INVISIBLE = 0x0010; export const FLAG_FLIPPED = 0x0020; export const EGG_FAMILIES = new Set([3, 4, 7, 8]); export const SI_FIXED = 0x0001; export const SI_SOLID = 0x0002; export const SI_LAND = 0x0008; export const SI_OCCL = 0x0010; export const SI_NOISY = 0x0080; export const SI_DRAW = 0x0100; export const SI_ROOF = 0x0400; export const SI_TRANSL = 0x0800; export function getMapCount(fixedDatPath) { const data = fs.readFileSync(fixedDatPath); return readU16LE(data, FIXED_MAP_COUNT_OFFSET); } export function getMapSummaries(fixedDatPath) { const data = fs.readFileSync(fixedDatPath); const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET); const maps = []; for (let mapId = 0; mapId < mapCount; mapId += 1) { const tableOffset = FIXED_MAP_TABLE_OFFSET + mapId * 8; const mapOffset = readU32LE(data, tableOffset); const mapSize = readU32LE(data, tableOffset + 4); maps.push({ id: mapId, offset: mapOffset, byteSize: mapSize, rawItemCount: Math.floor(mapSize / 16), isValid: mapSize >= 16 }); } return maps; } export class FlexArchive { constructor(filePath) { this.path = filePath; this.data = fs.readFileSync(filePath); this.entries = FlexArchive.readEntries(this.data); } static readEntries(data) { const count = readU32LE(data, FLEX_COUNT_OFFSET); const entries = []; for (let index = 0; index < count; index += 1) { const base = FLEX_TABLE_OFFSET + index * 8; entries.push({ offset: readU32LE(data, base), size: readU32LE(data, base + 4) }); } return entries; } get(index) { const entry = this.entries[index]; if (!entry || entry.size === 0) { return Buffer.alloc(0); } return this.data.subarray(entry.offset, entry.offset + entry.size); } get length() { return this.entries.length; } } export class ShapeArchive { constructor(filePath) { this.archive = new FlexArchive(filePath); this.shapeCache = new Map(); this.decodedFrameCache = new Map(); } getFrame(shapeIndex, frameIndex) { const frames = this.getShape(shapeIndex); if (frameIndex < 0 || frameIndex >= frames.length) { throw new RangeError(`shape ${shapeIndex} frame ${frameIndex} out of range`); } return frames[frameIndex]; } decodeFrame(shapeIndex, frameIndex) { const cacheKey = `${shapeIndex}:${frameIndex}`; let decoded = this.decodedFrameCache.get(cacheKey); const frame = this.getFrame(shapeIndex, frameIndex); if (!decoded) { decoded = decodePixels(frame); this.decodedFrameCache.set(cacheKey, decoded); } return { frame, pixels: decoded }; } getShape(shapeIndex) { if (this.shapeCache.has(shapeIndex)) { return this.shapeCache.get(shapeIndex); } const raw = this.archive.get(shapeIndex); if (!raw.length) { throw new Error(`shape ${shapeIndex} has no data`); } const frames = parseShape(raw); this.shapeCache.set(shapeIndex, frames); return frames; } } function parseShape(data) { const frameCount = readU16LE(data, 4); const frames = []; for (let index = 0; index < frameCount; index += 1) { const headerOffset = 6 + index * 8; const frameOffset = readU24LE(data, headerOffset); const frameSize = readU32LE(data, headerOffset + 4); const frameData = data.subarray(frameOffset, frameOffset + frameSize); if (frameData.length < 28) { throw new Error(`frame ${index} too small: ${frameData.length}`); } const compressed = Boolean(readU32LE(frameData, 8)); const width = readU32LE(frameData, 12); const height = readU32LE(frameData, 16); const xoff = readI32LE(frameData, 20); const yoff = readI32LE(frameData, 24); const lineOffsets = []; for (let row = 0; row < height; row += 1) { lineOffsets.push(readU32LE(frameData, 28 + row * 4) - ((height - row) * 4)); } const rleOffset = 28 + height * 4; frames.push({ compressed, width, height, xoff, yoff, lineOffsets, rleData: frameData.subarray(rleOffset) }); } return frames; } function decodePixels(frame) { const pixels = new Int16Array(frame.width * frame.height); pixels.fill(-1); const rle = frame.rleData; for (let row = 0; row < frame.height; row += 1) { let pos = frame.lineOffsets[row]; let xpos = 0; while (xpos < frame.width) { if (pos >= rle.length) { throw new Error(`row ${row} overran RLE data`); } xpos += rle[pos]; pos += 1; if (xpos === frame.width) { break; } if (pos >= rle.length) { throw new Error(`row ${row} missing run header`); } let dlen = rle[pos]; pos += 1; let runType = 0; if (frame.compressed) { runType = dlen & 1; dlen >>= 1; } if (dlen <= 0 || xpos + dlen > frame.width) { throw new Error(`invalid run length ${dlen} at row ${row}`); } const rowBase = row * frame.width + xpos; if (runType === 0) { const end = pos + dlen; if (end > rle.length) { throw new Error(`row ${row} literal run overruns RLE data`); } for (let index = 0; index < dlen; index += 1) { pixels[rowBase + index] = rle[pos + index]; } pos = end; } else { if (pos >= rle.length) { throw new Error(`row ${row} repeated-color run missing color byte`); } const color = rle[pos]; pos += 1; for (let index = 0; index < dlen; index += 1) { pixels[rowBase + index] = color; } } xpos += dlen; } } return pixels; } export function loadPalette(filePath) { const data = fs.readFileSync(filePath); if (data.length < 768) { throw new Error(`palette too small: ${filePath}`); } const palette = []; for (let index = 0; index < 256; index += 1) { const r = Math.floor((data[index * 3] * 255) / 63); const g = Math.floor((data[index * 3 + 1] * 255) / 63); const b = Math.floor((data[index * 3 + 2] * 255) / 63); palette.push([r, g, b]); } return palette; } export function loadTypeflags(filePath) { const data = fs.readFileSync(filePath); const infos = []; for (let base = 0; base + 9 <= data.length; base += 9) { const block = data.subarray(base, base + 9); let flags = 0; if (block[0] & 0x01) flags |= 0x0001; if (block[0] & 0x02) flags |= 0x0002; if (block[0] & 0x04) flags |= 0x0004; if (block[0] & 0x08) flags |= 0x0008; if (block[0] & 0x10) flags |= 0x0010; if (block[0] & 0x20) flags |= 0x0020; if (block[0] & 0x40) flags |= 0x0040; if (block[0] & 0x80) flags |= 0x0080; if (block[1] & 0x01) flags |= 0x0100; if (block[1] & 0x02) flags |= 0x0200; if (block[1] & 0x04) flags |= 0x0400; if (block[1] & 0x08) flags |= 0x0800; if (block[6] & 0x01) flags |= 0x1000; if (block[6] & 0x02) flags |= 0x2000; if (block[6] & 0x04) flags |= 0x4000; if (block[6] & 0x08) flags |= 0x8000; if (block[6] & 0x10) flags |= 0x10000; if (block[6] & 0x20) flags |= 0x20000; if (block[6] & 0x40) flags |= 0x40000; if (block[6] & 0x80) flags |= 0x80000; const family = (block[1] >> 4) + ((block[2] & 1) << 4); const x = ((block[3] << 3) | (block[2] >> 5)) & 0x1f; const y = (block[3] >> 2) & 0x1f; const z = ((block[4] << 1) | (block[3] >> 7)) & 0x1f; const animType = block[4] >> 4; infos.push({ family, flags, x, y, z, animType, isEditor: Boolean(flags & 0x1000), isFixed: Boolean(flags & SI_FIXED), isSolid: Boolean(flags & SI_SOLID), isLand: Boolean(flags & SI_LAND), isOccl: Boolean(flags & SI_OCCL), isNoisy: Boolean(flags & SI_NOISY), isDraw: Boolean(flags & SI_DRAW), isRoof: Boolean(flags & SI_ROOF), isTranslucent: Boolean(flags & SI_TRANSL), isInvitem: family === 13 }); } return infos; } export function loadGlobs(filePath) { const archive = new FlexArchive(filePath); const globs = []; for (let index = 0; index < archive.length; index += 1) { const raw = archive.get(index); if (!raw.length) { globs.push([]); continue; } const count = readU16LE(raw, 0); const items = []; for (let itemIndex = 0; itemIndex < count; itemIndex += 1) { const base = 2 + itemIndex * 6; items.push({ x: raw[base], y: raw[base + 1], z: raw[base + 2], shape: readU16LE(raw, base + 3), frame: raw[base + 5] }); } globs.push(items); } return globs; } export function loadMapItems(filePath, mapIndex) { if (!fs.existsSync(filePath)) { throw new Error(`Missing file: ${filePath}`); } const data = fs.readFileSync(filePath); const mapCount = readU16LE(data, FIXED_MAP_COUNT_OFFSET); if (mapIndex < 0 || mapIndex >= mapCount) { throw new Error(`map index ${mapIndex} out of range 0..${mapCount - 1}`); } const tableOffset = FIXED_MAP_TABLE_OFFSET + mapIndex * 8; const mapOffset = readU32LE(data, tableOffset); const mapSize = readU32LE(data, tableOffset + 4); const payload = data.subarray(mapOffset, mapOffset + mapSize); if (payload.length !== mapSize) { throw new Error(`map ${mapIndex} payload truncated`); } const items = []; for (let base = 0; base + 16 <= payload.length; base += 16) { const record = payload.subarray(base, base + 16); items.push({ x: readU16LE(record, 0) * CRUSADER_COORD_SCALE, y: readU16LE(record, 2) * CRUSADER_COORD_SCALE, z: record[4], shape: readU16LE(record, 5), frame: record[7], flags: readU16LE(record, 8), quality: readU16LE(record, 10), npcNum: record[12], mapNum: record[13], nextItem: readU16LE(record, 14), source: "fixed" }); } return items; } export function expandGlobItem(item, globs) { if (item.quality < 0 || item.quality >= globs.length) { return []; } return globs[item.quality].map((globItem) => ({ x: (item.x & GLOB_COORD_MASK) + (globItem.x << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, y: (item.y & GLOB_COORD_MASK) + (globItem.y << GLOB_COORD_SHIFT) + GLOB_COORD_OFFSET, z: item.z + globItem.z, shape: globItem.shape, frame: globItem.frame, flags: 0, quality: 0, npcNum: 0, mapNum: item.mapNum, nextItem: 0, source: "glob" })); } export function collectRenderItems(baseItems, shapeInfos, globs, options) { const { includeEditor, expandGlobs, worldRect, includeRoofs, includeHiddenMarkers, progress, checkpointEvery = 0 } = options; const renderItems = []; const pending = [...baseItems]; let index = 0; let skippedInvisible = 0; let skippedWorldRect = 0; let skippedInvalidShape = 0; let skippedEditor = 0; let skippedEgg = 0; let skippedRoof = 0; let skippedHidden = 0; let expandedGlobs = 0; while (index < pending.length) { const item = pending[index]; index += 1; if (item.flags & FLAG_INVISIBLE) { if (!includeHiddenMarkers) { skippedHidden += 1; continue; } skippedInvisible += 1; } if (worldRect) { const [minX, minY, maxX, maxY] = worldRect; if (item.x < minX || item.y < minY || item.x > maxX || item.y > maxY) { skippedWorldRect += 1; continue; } } if (item.shape >= shapeInfos.length) { skippedInvalidShape += 1; continue; } const info = shapeInfos[item.shape]; if (info.isEditor && !includeEditor) { skippedEditor += 1; continue; } if (info.isRoof && !includeRoofs) { skippedRoof += 1; continue; } if (expandGlobs && info.family === 3 && item.source === "fixed") { pending.push(...expandGlobItem(item, globs)); expandedGlobs += 1; if (!includeHiddenMarkers) { continue; } } if (EGG_FAMILIES.has(info.family) && !includeHiddenMarkers) { skippedEgg += 1; continue; } renderItems.push(item); if (progress && checkpointEvery > 0 && index % checkpointEvery === 0) { progress( `collect processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` + `skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` + `roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})` ); } } if (progress) { progress( `collect complete processed=${index} pending=${pending.length} rendered=${renderItems.length} expanded_globs=${expandedGlobs} ` + `skipped=(invisible:${skippedInvisible}, world:${skippedWorldRect}, shape:${skippedInvalidShape}, editor:${skippedEditor}, ` + `roof:${skippedRoof}, hidden:${skippedHidden}, egg:${skippedEgg})` ); } return renderItems; } export function summarizeRenderClasses(baseItems, shapeInfos) { const summary = { roofItems: 0, editorItems: 0, eggFamilyItems: 0, invisibleFlaggedItems: 0, npcLinkedItems: 0 }; for (const item of baseItems) { if (item.flags & FLAG_INVISIBLE) { summary.invisibleFlaggedItems += 1; } if (item.npcNum !== 0) { summary.npcLinkedItems += 1; } if (item.shape >= shapeInfos.length) { continue; } const info = shapeInfos[item.shape]; if (info.isRoof) summary.roofItems += 1; if (info.isEditor) summary.editorItems += 1; if (EGG_FAMILIES.has(info.family)) summary.eggFamilyItems += 1; } return summary; } export function resolveStaticFile(staticDir, name) { return path.join(staticDir, name); }