forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
69809f7228
commit
ff20ab8779
101 files changed
+16713
-945
No files matched your search
@@ -0,0 +1,265 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function chunkId(buffer, offset) {
|
||||
return Buffer.from(buffer.subarray(offset, offset + 4)).reverse().toString("ascii");
|
||||
}
|
||||
|
||||
function readChunkList(buffer, start, end, label) {
|
||||
const chunks = [];
|
||||
let offset = start;
|
||||
while (offset + 8 <= end) {
|
||||
const id = chunkId(buffer, offset);
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
const chunkEnd = dataOffset + size;
|
||||
if (chunkEnd > end) throw new Error(`${label}: ${id} at ${offset} extends beyond its container.`);
|
||||
chunks.push({ id, offset, dataOffset, size, end: chunkEnd });
|
||||
offset = chunkEnd;
|
||||
}
|
||||
if (offset !== end) throw new Error(`${label}: ${end - offset} trailing bytes cannot form a chunk header.`);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function readSubchunk(buffer, mcnk, relativeOffset, label) {
|
||||
if (!relativeOffset) return null;
|
||||
const offset = mcnk.offset + relativeOffset;
|
||||
if (offset + 8 > buffer.length) throw new Error(`${label}: subchunk offset ${relativeOffset} leaves the ADT.`);
|
||||
const id = chunkId(buffer, offset);
|
||||
const size = buffer.readUInt32LE(offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
const end = dataOffset + size;
|
||||
if (end > buffer.length) throw new Error(`${label}: ${id} subchunk leaves the ADT.`);
|
||||
return { id, offset, dataOffset, size, end, withinMcnk: end <= mcnk.end };
|
||||
}
|
||||
|
||||
function textureNames(buffer, mtex) {
|
||||
if (!mtex) return [];
|
||||
const value = buffer.toString("utf8", mtex.dataOffset, mtex.end);
|
||||
return value.split("\0").filter(Boolean).map((name) => name.replaceAll("/", "\\"));
|
||||
}
|
||||
|
||||
function hash(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
export function parseWowAdtTerrain(buffer, { label = "ADT" } = {}) {
|
||||
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
|
||||
const chunks = readChunkList(buffer, 0, buffer.length, label);
|
||||
if (chunks[0]?.id !== "MVER") throw new Error(`${label}: missing MVER first chunk.`);
|
||||
const mtex = chunks.find((chunk) => chunk.id === "MTEX");
|
||||
const textures = textureNames(buffer, mtex);
|
||||
const mcnks = chunks.filter((chunk) => chunk.id === "MCNK");
|
||||
const terrainChunks = mcnks.map((mcnk, ordinal) => {
|
||||
if (mcnk.size < 128) throw new Error(`${label}: MCNK ${ordinal} is shorter than its 128-byte header.`);
|
||||
const data = mcnk.dataOffset;
|
||||
const flags = buffer.readUInt32LE(data);
|
||||
const indexX = buffer.readUInt32LE(data + 4);
|
||||
const indexY = buffer.readUInt32LE(data + 8);
|
||||
const layerCount = buffer.readUInt32LE(data + 12);
|
||||
const height = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 20), `${label} MCNK ${ordinal} MCVT`);
|
||||
const layersChunk = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 28), `${label} MCNK ${ordinal} MCLY`);
|
||||
const alphaChunkHeader = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 36), `${label} MCNK ${ordinal} MCAL`);
|
||||
const alphaSizeHeader = buffer.readUInt32LE(data + 40);
|
||||
// A subset of MVER 18 ADTs has an MCAL FourCC followed by a zero chunk
|
||||
// size, while the enclosing MCNK header carries the complete MCAL size.
|
||||
// The bytes after the eight-byte subchunk header are still the source map.
|
||||
const alphaChunk = alphaChunkHeader?.id === "MCAL" && alphaChunkHeader.size === 0 && alphaSizeHeader > 8
|
||||
? {
|
||||
...alphaChunkHeader,
|
||||
size: alphaSizeHeader - 8,
|
||||
end: alphaChunkHeader.dataOffset + alphaSizeHeader - 8,
|
||||
withinMcnk: alphaChunkHeader.dataOffset + alphaSizeHeader - 8 <= mcnk.end,
|
||||
sizeSource: "mcnk-header",
|
||||
}
|
||||
: alphaChunkHeader && { ...alphaChunkHeader, sizeSource: "mcal-subchunk-header" };
|
||||
const worldHeight = buffer.readFloatLE(data + 104);
|
||||
const worldX = buffer.readFloatLE(data + 108);
|
||||
const worldY = buffer.readFloatLE(data + 112);
|
||||
const holeMask = buffer.readUInt16LE(data + 60);
|
||||
const lowResolutionTextureMap = Array.from({ length: 8 }, (_, index) => buffer.readUInt16LE(data + 64 + index * 2));
|
||||
|
||||
if (layerCount && (!layersChunk || layersChunk.id !== "MCLY" || layersChunk.size < layerCount * 16)) {
|
||||
throw new Error(`${label}: MCNK ${ordinal} layer header does not match its ${layerCount} layers.`);
|
||||
}
|
||||
if (height && height.id !== "MCVT") throw new Error(`${label}: MCNK ${ordinal} height offset points to ${height.id}.`);
|
||||
if (alphaChunk && alphaChunk.id !== "MCAL") throw new Error(`${label}: MCNK ${ordinal} alpha offset points to ${alphaChunk.id}.`);
|
||||
|
||||
const layers = Array.from({ length: layerCount }, (_, index) => {
|
||||
const offset = layersChunk.dataOffset + index * 16;
|
||||
const textureIndex = buffer.readUInt32LE(offset);
|
||||
const layerFlags = buffer.readUInt32LE(offset + 4);
|
||||
const alphaOffset = buffer.readUInt32LE(offset + 8);
|
||||
const effectId = buffer.readUInt32LE(offset + 12);
|
||||
const usesAlphaMap = (layerFlags & 0x100) !== 0;
|
||||
const alphaMapCompressed = (layerFlags & 0x200) !== 0;
|
||||
if (textureIndex >= textures.length) {
|
||||
throw new Error(`${label}: MCNK ${ordinal} layer ${index} references MTEX index ${textureIndex}/${textures.length}.`);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
recordOffset: offset,
|
||||
textureIndex,
|
||||
texture: textures[textureIndex],
|
||||
flags: layerFlags,
|
||||
usesAlphaMap,
|
||||
alphaMapCompressed,
|
||||
alphaMapAvailable: false,
|
||||
alphaMapEncoding: null,
|
||||
alphaMapBytes: 0,
|
||||
alphaMapDataOffset: null,
|
||||
alphaOffset: usesAlphaMap ? alphaOffset : null,
|
||||
effectId,
|
||||
};
|
||||
});
|
||||
|
||||
for (const layer of layers) {
|
||||
if (!layer.usesAlphaMap || !alphaChunk) continue;
|
||||
const laterAlphaLayer = layers.find((candidate) => candidate.usesAlphaMap && candidate.index > layer.index);
|
||||
const alphaBytes = laterAlphaLayer
|
||||
? laterAlphaLayer.alphaOffset - layer.alphaOffset
|
||||
: alphaChunk.size - layer.alphaOffset;
|
||||
const alphaMapDataOffset = alphaChunk.dataOffset + layer.alphaOffset;
|
||||
const encoding = layer.alphaMapCompressed
|
||||
? "rle-8"
|
||||
: alphaBytes === 4096
|
||||
? "uncompressed-8"
|
||||
: alphaBytes === 2048
|
||||
? "packed-4"
|
||||
: "unsupported";
|
||||
const alphaMapAvailable = Boolean(
|
||||
alphaChunk.withinMcnk
|
||||
&& alphaChunk.size + 8 === buffer.readUInt32LE(data + 40)
|
||||
&& layer.alphaOffset >= 0
|
||||
&& alphaBytes > 0
|
||||
&& alphaMapDataOffset + alphaBytes <= alphaChunk.end
|
||||
&& encoding !== "unsupported",
|
||||
);
|
||||
layer.alphaMapBytes = Math.max(0, alphaBytes);
|
||||
layer.alphaMapDataOffset = alphaMapDataOffset;
|
||||
layer.alphaMapEncoding = encoding;
|
||||
layer.alphaMapAvailable = alphaMapAvailable;
|
||||
}
|
||||
|
||||
const heightData = height?.id === "MCVT" ? buffer.subarray(height.dataOffset, height.end) : null;
|
||||
|
||||
return {
|
||||
ordinal,
|
||||
indexX,
|
||||
indexY,
|
||||
flags,
|
||||
holeMask,
|
||||
highResolutionHoles: (flags & 0x10000) !== 0,
|
||||
lowResolutionTextureMap,
|
||||
world: { x: worldX, y: worldY, height: worldHeight },
|
||||
sourceOffsets: {
|
||||
mcnk: mcnk.offset,
|
||||
mcvt: height?.dataOffset ?? null,
|
||||
mcly: layersChunk?.dataOffset ?? null,
|
||||
mcal: alphaChunk?.dataOffset ?? null,
|
||||
},
|
||||
heightSha256: heightData ? hash(heightData) : null,
|
||||
heightBytes: height?.size ?? 0,
|
||||
heightSamples: height?.id === "MCVT" && height.size % 4 === 0
|
||||
? Array.from({ length: height.size / 4 }, (_, index) => buffer.readFloatLE(height.dataOffset + index * 4))
|
||||
: null,
|
||||
alphaBytes: alphaChunk?.size ?? 0,
|
||||
alphaChunkWithinMcnk: alphaChunk?.withinMcnk ?? null,
|
||||
alphaSizeHeader,
|
||||
alphaChunkHeaderSize: alphaChunkHeader?.size ?? null,
|
||||
alphaChunkSizeSource: alphaChunk?.sizeSource ?? null,
|
||||
// In WotLK MCNK headers sizeMCAL includes the 8-byte MCAL chunk header.
|
||||
// MVER 18 zero-sized MCAL headers use that enclosing span as the source.
|
||||
alphaSizeMatchesHeader: alphaChunk ? alphaChunk.size + 8 === buffer.readUInt32LE(data + 40) : buffer.readUInt32LE(data + 40) === 0,
|
||||
materialLayerChunkWithinMcnk: layersChunk?.withinMcnk ?? null,
|
||||
layers,
|
||||
};
|
||||
});
|
||||
|
||||
const layerReferences = new Map();
|
||||
for (const chunk of terrainChunks) {
|
||||
for (const layer of chunk.layers) {
|
||||
const existing = layerReferences.get(layer.texture);
|
||||
if (existing) existing.count += 1;
|
||||
else layerReferences.set(layer.texture, { texture: layer.texture, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||
bytes: buffer.length,
|
||||
topLevelChunks: chunks.map(({ id, size }) => ({ id, size })),
|
||||
textureTableCount: textures.length,
|
||||
textures,
|
||||
mcnkCount: terrainChunks.length,
|
||||
chunks: terrainChunks,
|
||||
layerReferences: [...layerReferences.values()].sort((left, right) => left.texture.localeCompare(right.texture)),
|
||||
layers: terrainChunks.reduce((count, chunk) => count + chunk.layers.length, 0),
|
||||
alphaMappedLayers: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap).length, 0),
|
||||
alphaMapDataAvailable: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.alphaMapAvailable).length, 0),
|
||||
alphaMapDataMissing: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap && !layer.alphaMapAvailable).length, 0),
|
||||
alphaChunksOutOfBounds: terrainChunks.filter((chunk) => chunk.alphaChunkWithinMcnk === false).length,
|
||||
alphaSizeHeaderMismatches: terrainChunks.filter((chunk) => chunk.alphaSizeMatchesHeader === false).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectObjMaterialContract(file) {
|
||||
const obj = fs.readFileSync(file, "utf8");
|
||||
const materialFile = file.replace(/\.obj$/i, ".mtl");
|
||||
const mtl = fs.existsSync(materialFile) ? fs.readFileSync(materialFile, "utf8") : "";
|
||||
const uvLines = obj.split(/\r?\n/).filter((line) => line.startsWith("vt "));
|
||||
const uniqueUvs = new Set(uvLines.map((line) => line.trim()));
|
||||
const usedMaterials = [...new Set([...obj.matchAll(/^usemtl\s+(.+)$/gm)].map((match) => match[1].trim()))];
|
||||
return {
|
||||
obj: path.basename(file),
|
||||
mtl: path.basename(materialFile),
|
||||
vertexUvCount: uvLines.length,
|
||||
uniqueVertexUvs: uniqueUvs.size,
|
||||
usedMaterials,
|
||||
mtlHasDiffuseMap: /^\s*map_Kd\s+/im.test(mtl),
|
||||
mtlHasTextureName: /\.blp|\.png|\.jpg|\.dds/i.test(mtl),
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectWowAdtTerrainExport(sourceExportDir) {
|
||||
const rawRoot = path.join(sourceExportDir, "raw", "world", "maps");
|
||||
if (!fs.existsSync(rawRoot)) throw new Error(`Missing raw ADT directory: ${rawRoot}`);
|
||||
const adtFiles = [];
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const child = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(child);
|
||||
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".adt")) adtFiles.push(child);
|
||||
}
|
||||
};
|
||||
walk(rawRoot);
|
||||
if (!adtFiles.length) throw new Error(`No raw ADT files found under ${rawRoot}`);
|
||||
const terrainFiles = adtFiles.sort().map((file) => {
|
||||
const parsed = parseWowAdtTerrain(fs.readFileSync(file), { label: file });
|
||||
const basename = path.basename(file).replace(/\.adt$/i, "");
|
||||
const coordinates = basename.match(/_(\d+)_(\d+)$/);
|
||||
const exporterObj = coordinates
|
||||
? path.join(sourceExportDir, `adt_${coordinates[1]}_${coordinates[2]}.obj`)
|
||||
: null;
|
||||
return {
|
||||
file: path.relative(sourceExportDir, file).replaceAll("\\", "/"),
|
||||
...parsed,
|
||||
exporterContract: exporterObj && fs.existsSync(exporterObj) ? inspectObjMaterialContract(exporterObj) : null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
sourceExportDir: path.resolve(sourceExportDir),
|
||||
tileCount: terrainFiles.length,
|
||||
mcnkCount: terrainFiles.reduce((sum, tile) => sum + tile.mcnkCount, 0),
|
||||
layerCount: terrainFiles.reduce((sum, tile) => sum + tile.layers, 0),
|
||||
alphaMappedLayerCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMappedLayers, 0),
|
||||
alphaMapDataAvailableCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataAvailable, 0),
|
||||
alphaMapDataMissingCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataMissing, 0),
|
||||
alphaChunksOutOfBoundsCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaChunksOutOfBounds, 0),
|
||||
alphaSizeHeaderMismatchCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaSizeHeaderMismatches, 0),
|
||||
maxLayerCountPerChunk: Math.max(0, ...terrainFiles.flatMap((tile) => tile.chunks.map((chunk) => chunk.layers.length))),
|
||||
referencedTextures: [...new Set(terrainFiles.flatMap((tile) => tile.textures))].sort((a, b) => a.localeCompare(b)),
|
||||
tiles: terrainFiles,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user