Added node based web map renderer
This commit is contained in:
parent
82ae89865a
commit
24a4d90a3e
19 changed files with 3970 additions and 0 deletions
475
map_renderer/src/lib/formats.js
Normal file
475
map_renderer/src/lib/formats.js
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue