690 lines
33 KiB
JavaScript
690 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// Diagnostic-only WMO v17 reader for source-backed lighting investigations.
|
|
// Chunk IDs are stored as reversed FourCCs in the client files. Layout and
|
|
// flags follow wowdev/pywowlib's WMO v17 readers:
|
|
// https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_root.py
|
|
// https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_group.py
|
|
const MOGP_FLAGS = Object.freeze({
|
|
hasVertexColor: 0x4,
|
|
doNotUseLocalLighting: 0x40,
|
|
hasLight: 0x200,
|
|
indoor: 0x2000,
|
|
hasTwoMocv: 0x1000000,
|
|
});
|
|
const MOLT_ROW_BYTES = 48;
|
|
const MOGP_HEADER_BYTES = 68;
|
|
const MOBA_ROW_BYTES = 24;
|
|
const DEFAULT_AXIS_BASIS = "(source x, source z, -source y)";
|
|
|
|
function hash(buffer) {
|
|
return createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
function readChunks(buffer, start = 0, end = buffer.length) {
|
|
const chunks = [];
|
|
for (let offset = start; offset < end;) {
|
|
if (offset + 8 > end) throw new Error(`Truncated WMO chunk header at byte ${offset}.`);
|
|
const id = buffer.toString("ascii", offset, offset + 4).split("").reverse().join("");
|
|
const size = buffer.readUInt32LE(offset + 4);
|
|
const dataStart = offset + 8;
|
|
const dataEnd = dataStart + size;
|
|
if (dataEnd > end) throw new Error(`WMO ${id} chunk at byte ${offset} exceeds its container.`);
|
|
chunks.push({ id, size, dataStart, dataEnd, offset });
|
|
offset = dataEnd;
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function oneChunk(chunks, id, { required = false } = {}) {
|
|
const matches = chunks.filter((chunk) => chunk.id === id);
|
|
if (matches.length > 1) throw new Error(`Expected at most one ${id} chunk; found ${matches.length}.`);
|
|
if (required && matches.length !== 1) throw new Error(`Missing required ${id} chunk.`);
|
|
return matches[0] ?? null;
|
|
}
|
|
|
|
function readStringAt(table, offset) {
|
|
if (offset >= table.length) return null;
|
|
const end = table.indexOf(0, offset);
|
|
if (end < 0) return null;
|
|
return table.toString("latin1", offset, end);
|
|
}
|
|
|
|
export function parseRootWmo(buffer) {
|
|
const chunks = readChunks(buffer);
|
|
const version = oneChunk(chunks, "MVER", { required: true });
|
|
if (version.size !== 4 || buffer.readUInt32LE(version.dataStart) !== 17) {
|
|
throw new Error("Only WMO v17 roots are supported by this diagnostic reader.");
|
|
}
|
|
const headerChunk = oneChunk(chunks, "MOHD", { required: true });
|
|
if (headerChunk.size !== 64) throw new Error(`Unexpected MOHD size ${headerChunk.size}.`);
|
|
const p = headerChunk.dataStart;
|
|
const header = {
|
|
materialCount: buffer.readUInt32LE(p),
|
|
groupCount: buffer.readUInt32LE(p + 4),
|
|
portalCount: buffer.readUInt32LE(p + 8),
|
|
lightCount: buffer.readUInt32LE(p + 12),
|
|
modelCount: buffer.readUInt32LE(p + 16),
|
|
doodadCount: buffer.readUInt32LE(p + 20),
|
|
doodadSetCount: buffer.readUInt32LE(p + 24),
|
|
ambientColorBytes: [...buffer.subarray(p + 28, p + 32)],
|
|
wmoId: buffer.readUInt32LE(p + 32),
|
|
boundsMin: [buffer.readFloatLE(p + 36), buffer.readFloatLE(p + 40), buffer.readFloatLE(p + 44)],
|
|
boundsMax: [buffer.readFloatLE(p + 48), buffer.readFloatLE(p + 52), buffer.readFloatLE(p + 56)],
|
|
flags: buffer.readUInt16LE(p + 60),
|
|
lodCount: buffer.readUInt16LE(p + 62),
|
|
};
|
|
const lightsChunk = oneChunk(chunks, "MOLT", { required: true });
|
|
if (lightsChunk.size % MOLT_ROW_BYTES !== 0) {
|
|
throw new Error(`MOLT byte count ${lightsChunk.size} is not divisible by ${MOLT_ROW_BYTES}.`);
|
|
}
|
|
const lights = [];
|
|
for (let offset = lightsChunk.dataStart; offset < lightsChunk.dataEnd; offset += MOLT_ROW_BYTES) {
|
|
lights.push({
|
|
index: lights.length,
|
|
lightType: buffer.readUInt8(offset),
|
|
secondaryType: buffer.readUInt8(offset + 1),
|
|
useAttenuation: buffer.readUInt8(offset + 2),
|
|
padding: buffer.readUInt8(offset + 3),
|
|
colorBytes: [...buffer.subarray(offset + 4, offset + 8)],
|
|
position: [buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12), buffer.readFloatLE(offset + 16)],
|
|
intensity: buffer.readFloatLE(offset + 20),
|
|
attenuationStart: buffer.readFloatLE(offset + 24),
|
|
attenuationEnd: buffer.readFloatLE(offset + 28),
|
|
unknownFloats: [32, 36, 40, 44].map((delta) => buffer.readFloatLE(offset + delta)),
|
|
});
|
|
}
|
|
if (header.lightCount !== lights.length) {
|
|
throw new Error(`MOHD declares ${header.lightCount} lights but MOLT contains ${lights.length}.`);
|
|
}
|
|
|
|
const namesChunk = oneChunk(chunks, "MOGN", { required: true });
|
|
const names = buffer.subarray(namesChunk.dataStart, namesChunk.dataEnd);
|
|
const infoChunk = oneChunk(chunks, "MOGI", { required: true });
|
|
if (infoChunk.size % 32 !== 0) throw new Error(`MOGI byte count ${infoChunk.size} is not divisible by 32.`);
|
|
const groupInfos = [];
|
|
for (let offset = infoChunk.dataStart; offset < infoChunk.dataEnd; offset += 32) {
|
|
groupInfos.push({
|
|
flags: buffer.readUInt32LE(offset),
|
|
boundsMin: [buffer.readFloatLE(offset + 4), buffer.readFloatLE(offset + 8), buffer.readFloatLE(offset + 12)],
|
|
boundsMax: [buffer.readFloatLE(offset + 16), buffer.readFloatLE(offset + 20), buffer.readFloatLE(offset + 24)],
|
|
nameIndex: buffer.readInt32LE(offset + 28),
|
|
});
|
|
}
|
|
if (groupInfos.length !== header.groupCount) {
|
|
throw new Error(`MOHD declares ${header.groupCount} groups but MOGI contains ${groupInfos.length}.`);
|
|
}
|
|
return { header, lights, groupInfos, groupNameTable: names };
|
|
}
|
|
|
|
export function parseGroupWmo(buffer, groupIndex = 0) {
|
|
const chunks = readChunks(buffer);
|
|
const version = oneChunk(chunks, "MVER", { required: true });
|
|
if (version.size !== 4 || buffer.readUInt32LE(version.dataStart) !== 17) {
|
|
throw new Error(`Group ${groupIndex} is not WMO v17.`);
|
|
}
|
|
const outer = oneChunk(chunks, "MOGP", { required: true });
|
|
if (outer.size < MOGP_HEADER_BYTES) throw new Error(`Group ${groupIndex} has a truncated MOGP header.`);
|
|
const p = outer.dataStart;
|
|
const header = {
|
|
groupNameOffset: buffer.readUInt32LE(p),
|
|
descriptionNameOffset: buffer.readUInt32LE(p + 4),
|
|
flags: buffer.readUInt32LE(p + 8),
|
|
boundsMin: [buffer.readFloatLE(p + 12), buffer.readFloatLE(p + 16), buffer.readFloatLE(p + 20)],
|
|
boundsMax: [buffer.readFloatLE(p + 24), buffer.readFloatLE(p + 28), buffer.readFloatLE(p + 32)],
|
|
portalStart: buffer.readUInt16LE(p + 36),
|
|
portalCount: buffer.readUInt16LE(p + 38),
|
|
batchCounts: {
|
|
unknownA: buffer.readUInt16LE(p + 40),
|
|
interior: buffer.readUInt16LE(p + 42),
|
|
exterior: buffer.readUInt16LE(p + 44),
|
|
unknownD: buffer.readUInt16LE(p + 46),
|
|
},
|
|
};
|
|
const nested = readChunks(buffer, p + MOGP_HEADER_BYTES, outer.dataEnd);
|
|
const movt = oneChunk(nested, "MOVT", { required: true });
|
|
const monr = oneChunk(nested, "MONR", { required: true });
|
|
const motv = oneChunk(nested, "MOTV", { required: true });
|
|
const molr = oneChunk(nested, "MOLR");
|
|
const mocv = oneChunk(nested, "MOCV");
|
|
const moba = oneChunk(nested, "MOBA");
|
|
if (movt.size % 12 || monr.size % 12 || motv.size % 8) {
|
|
throw new Error(`Group ${groupIndex} has a malformed vertex attribute stride.`);
|
|
}
|
|
const vertexCount = movt.size / 12;
|
|
if (monr.size / 12 !== vertexCount || motv.size / 8 !== vertexCount) {
|
|
throw new Error(`Group ${groupIndex} position, normal, and UV vertex counts differ.`);
|
|
}
|
|
if (molr && molr.size % 2) throw new Error(`Group ${groupIndex} MOLR has an incomplete light reference.`);
|
|
if (moba && moba.size % MOBA_ROW_BYTES) throw new Error(`Group ${groupIndex} MOBA has an incomplete batch row.`);
|
|
const hasTwoMocv = Boolean(header.flags & MOGP_FLAGS.hasTwoMocv);
|
|
if (mocv && (mocv.size % 4 || (hasTwoMocv && mocv.size !== vertexCount * 8)
|
|
|| (!hasTwoMocv && mocv.size !== vertexCount * 4))) {
|
|
throw new Error(`Group ${groupIndex} has an unsupported MOCV stride or count.`);
|
|
}
|
|
const lightRefs = [];
|
|
if (molr) {
|
|
for (let offset = molr.dataStart; offset < molr.dataEnd; offset += 2) {
|
|
lightRefs.push(buffer.readInt16LE(offset));
|
|
}
|
|
}
|
|
const batches = [];
|
|
if (moba) {
|
|
for (let offset = moba.dataStart; offset < moba.dataEnd; offset += MOBA_ROW_BYTES) {
|
|
batches.push({ lastVertex: buffer.readUInt16LE(offset + 20) });
|
|
}
|
|
}
|
|
const vertices = [];
|
|
for (let index = 0; index < vertexCount; index += 1) {
|
|
const vp = movt.dataStart + index * 12;
|
|
const np = monr.dataStart + index * 12;
|
|
const tp = motv.dataStart + index * 8;
|
|
vertices.push({
|
|
position: [buffer.readFloatLE(vp), buffer.readFloatLE(vp + 4), buffer.readFloatLE(vp + 8)],
|
|
normal: [buffer.readFloatLE(np), buffer.readFloatLE(np + 4), buffer.readFloatLE(np + 8)],
|
|
uv: [buffer.readFloatLE(tp), buffer.readFloatLE(tp + 4)],
|
|
mocvBytes: mocv ? [...buffer.subarray(mocv.dataStart + index * (hasTwoMocv ? 8 : 4), mocv.dataStart + index * (hasTwoMocv ? 8 : 4) + 4)] : null,
|
|
});
|
|
}
|
|
return {
|
|
groupIndex,
|
|
header,
|
|
hasVertexColorFlag: Boolean(header.flags & MOGP_FLAGS.hasVertexColor),
|
|
hasLightFlag: Boolean(header.flags & MOGP_FLAGS.hasLight),
|
|
doNotUseLocalLighting: Boolean(header.flags & MOGP_FLAGS.doNotUseLocalLighting),
|
|
indoorFlag: Boolean(header.flags & MOGP_FLAGS.indoor),
|
|
hasTwoMocv,
|
|
lightRefs,
|
|
batches,
|
|
vertices,
|
|
};
|
|
}
|
|
|
|
export function assertMatchingRootDigests({ rootBytes, groupCacheRootBytes, expectedRootSha256 }) {
|
|
const rootSha256 = hash(rootBytes);
|
|
const groupCacheRootSha256 = hash(groupCacheRootBytes);
|
|
const expected = String(expectedRootSha256 ?? "").toLowerCase();
|
|
if (!/^[0-9a-f]{64}$/u.test(expected)) throw new Error("An expected 64-character root SHA-256 is required.");
|
|
if (rootSha256 !== expected) throw new Error(`Source root SHA-256 mismatch: expected ${expected}, got ${rootSha256}.`);
|
|
if (groupCacheRootSha256 !== rootSha256) {
|
|
throw new Error(`Group-cache root is not byte-identical to source root (${groupCacheRootSha256} != ${rootSha256}).`);
|
|
}
|
|
return { rootSha256, groupCacheRootSha256 };
|
|
}
|
|
|
|
export function sourceToObjPoint([x, y, z]) {
|
|
return [x, z, -y];
|
|
}
|
|
|
|
function clampByte(value) {
|
|
return Math.max(0, Math.min(255, Math.trunc(value)));
|
|
}
|
|
|
|
/**
|
|
* Diagnostic transcription of the WotLK CMapObjGroup::FixColorVertexAlpha
|
|
* behavior documented at https://www.getmangos.eu/wiki/referenceinfo/clientfiles/wmo-file-r20030/.
|
|
* It is intentionally not a production renderer/material conversion: the
|
|
* native pixel shader, color-space path, and subsequent portal attenuation
|
|
* are not modeled here. MOCV alpha is consumed as a fixup input and/or
|
|
* rewritten metadata; it is never treated as opacity.
|
|
*/
|
|
export function documentedFixColorVertexAlpha(root, group) {
|
|
if (!root?.header || !group?.header || !Array.isArray(group.vertices)) {
|
|
throw new Error("FixColorVertexAlpha diagnostic requires parsed root and group records.");
|
|
}
|
|
if (group.hasTwoMocv) throw new Error("Two-layer MOCV fixup is unsupported by this diagnostic.");
|
|
if (!group.vertices.length || group.vertices.some((vertex) => !vertex.mocvBytes)) {
|
|
throw new Error("FixColorVertexAlpha diagnostic requires one MOCV color for every group vertex.");
|
|
}
|
|
const batchA = group.header.batchCounts.unknownA;
|
|
if (!Number.isInteger(batchA) || batchA < 0) throw new Error("Invalid MOGP first-batch count.");
|
|
if (batchA > group.batches.length) {
|
|
throw new Error(`MOGP first-batch count ${batchA} exceeds parsed MOBA rows ${group.batches.length}.`);
|
|
}
|
|
const secondBatchVertexStart = batchA > 0 ? group.batches[batchA - 1].lastVertex + 1 : 0;
|
|
if (secondBatchVertexStart > group.vertices.length) {
|
|
throw new Error(`MOBA boundary ${secondBatchVertexStart} exceeds group vertex count ${group.vertices.length}.`);
|
|
}
|
|
|
|
const rawColorBytes = Buffer.alloc(group.vertices.length * 4);
|
|
const adaptedColorBytes = Buffer.alloc(group.vertices.length * 4);
|
|
const ambient = root.header.ambientColorBytes;
|
|
const outdoor = Boolean(group.header.flags & 0x8);
|
|
const specialRootFlagPath = Boolean(root.header.flags & 0x8);
|
|
for (let index = 0; index < group.vertices.length; index += 1) {
|
|
const [b, g, r, alpha] = group.vertices[index].mocvBytes;
|
|
const offset = index * 4;
|
|
[b, g, r, alpha].forEach((value, channel) => { rawColorBytes[offset + channel] = value; });
|
|
if (specialRootFlagPath) {
|
|
// The client-reference description says MOHD 0x8 bypasses the RGB
|
|
// correction while still applying the outdoor-dependent alpha rewrite
|
|
// to vertices after the first-batch region. pywowlib names the same bit
|
|
// DoNotFixVertexColorAlpha; report this disputed path, do not bless it.
|
|
const fixedAlpha = index < secondBatchVertexStart ? alpha : outdoor ? 255 : 0;
|
|
[b, g, r, fixedAlpha].forEach((value, channel) => { adaptedColorBytes[offset + channel] = value; });
|
|
continue;
|
|
}
|
|
|
|
let fixed;
|
|
if (index < secondBatchVertexStart) {
|
|
// First region: subtract root ambient, remove the MOCV-alpha weighted
|
|
// component, then apply the documented half-scale adjustment.
|
|
fixed = [b, g, r].map((value, channel) => {
|
|
const afterAmbient = value - ambient[channel];
|
|
return (afterAmbient - (alpha / 255) * afterAmbient) / 2;
|
|
});
|
|
fixed.push(alpha);
|
|
} else {
|
|
// Second region: alpha weights the added component; it remains lighting
|
|
// data, after which the client rewrites it from MOGP outdoor status.
|
|
fixed = [b, g, r].map((value, channel) => (value * alpha / 64 + value - ambient[channel]) / 2);
|
|
fixed.push(outdoor ? 255 : 0);
|
|
}
|
|
fixed.map(clampByte).forEach((value, channel) => { adaptedColorBytes[offset + channel] = value; });
|
|
}
|
|
return {
|
|
rawColorBytes,
|
|
adaptedColorBytes,
|
|
secondBatchVertexStart,
|
|
fixupPath: specialRootFlagPath ? "documented-special-mohd-0x0008-disputed" : "documented-standard-mohd-flags-path",
|
|
alphaSemantics: "MOCV alpha participates in RGB correction and can be rewritten by indoor/outdoor group status; it is not opacity.",
|
|
productionStatus: "diagnostic-only; native pixel-shader composition, color space, and later portal attenuation remain unverified",
|
|
quantization: "Diagnostic byte output uses truncation and [0,255] clamping; source client byte-cast rounding was not independently verified.",
|
|
};
|
|
}
|
|
|
|
function signature(position, normal, uv) {
|
|
const [x, y, z] = sourceToObjPoint(position);
|
|
const [nx, ny, nz] = sourceToObjPoint(normal);
|
|
return JSON.stringify([x, y, z, nx, ny, nz, uv[0], 1 - uv[1]]);
|
|
}
|
|
|
|
export function mapExactObjMocv(root, groups, objText) {
|
|
const source = new Map();
|
|
let mocvVertexCount = 0;
|
|
const rawAlphaHistogram = {};
|
|
const adaptedAlphaHistogram = {};
|
|
for (const group of groups) {
|
|
if (!group.vertices.some((vertex) => vertex.mocvBytes)) continue;
|
|
if (group.hasTwoMocv) throw new Error(`Group ${group.groupIndex} has two MOCV layers; mapping is intentionally unsupported.`);
|
|
const fixed = documentedFixColorVertexAlpha(root, group);
|
|
for (let index = 0; index < group.vertices.length; index += 1) {
|
|
const vertex = group.vertices[index];
|
|
const key = signature(vertex.position, vertex.normal, vertex.uv);
|
|
const rawColor = vertex.mocvBytes ? vertex.mocvBytes.join(",") : null;
|
|
const offset = index * 4;
|
|
const adaptedColor = vertex.mocvBytes ? [...fixed.adaptedColorBytes.subarray(offset, offset + 4)].join(",") : null;
|
|
const color = rawColor === null || adaptedColor === null ? null : { rawColor, adaptedColor };
|
|
if (color === null) continue;
|
|
mocvVertexCount += 1;
|
|
const alpha = vertex.mocvBytes[3];
|
|
rawAlphaHistogram[alpha] = (rawAlphaHistogram[alpha] ?? 0) + 1;
|
|
const adaptedAlpha = fixed.adaptedColorBytes[offset + 3];
|
|
adaptedAlphaHistogram[adaptedAlpha] = (adaptedAlphaHistogram[adaptedAlpha] ?? 0) + 1;
|
|
if (!source.has(key)) source.set(key, { rawColors: new Set(), adaptedColors: new Set() });
|
|
source.get(key).rawColors.add(color.rawColor);
|
|
source.get(key).adaptedColors.add(color.adaptedColor);
|
|
}
|
|
}
|
|
const obj = { positions: [], normals: [], uvs: [] };
|
|
for (const line of objText.split(/\r?\n/u)) {
|
|
if (line.startsWith("v ")) obj.positions.push(line.trim().split(/\s+/u).slice(1, 4).map(Number));
|
|
else if (line.startsWith("vn ")) obj.normals.push(line.trim().split(/\s+/u).slice(1, 4).map(Number));
|
|
else if (line.startsWith("vt ")) obj.uvs.push(line.trim().split(/\s+/u).slice(1, 3).map(Number));
|
|
}
|
|
if (!obj.positions.length || obj.positions.length !== obj.normals.length || obj.positions.length !== obj.uvs.length) {
|
|
throw new Error("OBJ must have equal, non-empty position, normal, and UV arrays for exact vertex mapping.");
|
|
}
|
|
const rawBytes = Buffer.alloc(obj.positions.length * 4);
|
|
const adaptedBytes = Buffer.alloc(obj.positions.length * 4);
|
|
let rawMapped = 0;
|
|
let rawAmbiguous = 0;
|
|
let rawUnmatched = 0;
|
|
let adaptedMapped = 0;
|
|
let adaptedAmbiguous = 0;
|
|
let adaptedUnmatched = 0;
|
|
for (let index = 0; index < obj.positions.length; index += 1) {
|
|
const key = JSON.stringify([...obj.positions[index], ...obj.normals[index], ...obj.uvs[index]]);
|
|
const colors = source.get(key);
|
|
if (!colors || colors.rawColors.size === 0) {
|
|
rawUnmatched += 1;
|
|
adaptedUnmatched += 1;
|
|
continue;
|
|
}
|
|
if (colors.rawColors.size !== 1) {
|
|
rawAmbiguous += 1;
|
|
} else {
|
|
const color = [...colors.rawColors][0].split(",").map(Number);
|
|
color.forEach((value, channel) => { rawBytes[index * 4 + channel] = value; });
|
|
rawMapped += 1;
|
|
}
|
|
if (colors.adaptedColors.size !== 1) {
|
|
adaptedAmbiguous += 1;
|
|
} else {
|
|
const color = [...colors.adaptedColors][0].split(",").map(Number);
|
|
color.forEach((value, channel) => { adaptedBytes[index * 4 + channel] = value; });
|
|
adaptedMapped += 1;
|
|
}
|
|
}
|
|
return {
|
|
sourceMocvVertexCount: mocvVertexCount,
|
|
objVertexCount: obj.positions.length,
|
|
mappedVertexCount: rawMapped,
|
|
ambiguousVertexCount: rawAmbiguous,
|
|
unmatchedVertexCount: rawUnmatched,
|
|
exactCompleteMapping: rawMapped === obj.positions.length && rawAmbiguous === 0 && rawUnmatched === 0,
|
|
rawMocvByteOrder: "rawColorBytesByObjVertex preserves BGRA storage bytes exactly; alpha semantics are deliberately not applied",
|
|
rawAlphaHistogram,
|
|
rawColorBytesByObjVertex: rawBytes,
|
|
documentedFixColorAlphaMapping: {
|
|
mappedVertexCount: adaptedMapped,
|
|
ambiguousVertexCount: adaptedAmbiguous,
|
|
unmatchedVertexCount: adaptedUnmatched,
|
|
exactCompleteMapping: adaptedMapped === obj.positions.length && adaptedAmbiguous === 0 && adaptedUnmatched === 0,
|
|
adaptedAlphaHistogram,
|
|
byteOrder: "adaptedColorBytesByObjVertex is diagnostic BGRA; its alpha is the documented client fixup result, not opacity",
|
|
outputLabel: "documentedFixColorVertexAlpha diagnostic; not productionColors",
|
|
colorsByObjVertex: adaptedBytes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function boundsInObjBasis(boundsMin, boundsMax) {
|
|
return {
|
|
min: [boundsMin[0], boundsMin[2], -boundsMax[1]],
|
|
max: [boundsMax[0], boundsMax[2], -boundsMin[1]],
|
|
};
|
|
}
|
|
|
|
function colorHistogram(rows, field) {
|
|
const values = new Map();
|
|
for (const row of rows) {
|
|
const key = row[field].join(",");
|
|
values.set(key, (values.get(key) ?? 0) + 1);
|
|
}
|
|
return Object.fromEntries([...values.entries()].sort((a, b) => b[1] - a[1]));
|
|
}
|
|
|
|
function numberHistogram(rows, field) {
|
|
const values = new Map();
|
|
for (const row of rows) {
|
|
const key = String(row[field]);
|
|
values.set(key, (values.get(key) ?? 0) + 1);
|
|
}
|
|
return Object.fromEntries([...values.entries()].sort((a, b) => Number(a[0]) - Number(b[0])));
|
|
}
|
|
|
|
function makeLightInventory(roots, groups, groupNameTable) {
|
|
const eligible = groups.filter((group) => group.hasLightFlag && group.lightRefs.length > 0 && !group.doNotUseLocalLighting);
|
|
const referenced = new Set();
|
|
const invalidRefs = [];
|
|
for (const group of groups) {
|
|
for (const index of group.lightRefs) {
|
|
if (index < 0 || index >= roots.lights.length) invalidRefs.push({ groupIndex: group.groupIndex, lightIndex: index });
|
|
else if (group.hasLightFlag && !group.doNotUseLocalLighting) referenced.add(index);
|
|
}
|
|
}
|
|
const representatives = [];
|
|
for (const group of eligible) {
|
|
const groupName = readStringAt(groupNameTable, group.header.groupNameOffset);
|
|
const description = readStringAt(groupNameTable, group.header.descriptionNameOffset);
|
|
const playableLabel = `${groupName ?? ""} ${description ?? ""}`;
|
|
if (!/(jail|cell|hall|room|nursery|boss|foyer|entrance|kitchen|stable|chapel|great)/iu.test(playableLabel)) continue;
|
|
const centerSource = group.header.boundsMin.map((value, axis) => (value + group.header.boundsMax[axis]) / 2);
|
|
const nearest = group.lightRefs
|
|
.filter((index) => index >= 0 && index < roots.lights.length)
|
|
.map((index) => {
|
|
const light = roots.lights[index];
|
|
const distance = Math.hypot(...light.position.map((value, axis) => value - centerSource[axis]));
|
|
return { index, distance, light };
|
|
})
|
|
.sort((a, b) => a.distance - b.distance)[0];
|
|
if (!nearest) continue;
|
|
representatives.push({
|
|
groupIndex: group.groupIndex,
|
|
groupName,
|
|
description,
|
|
referencedLightIndex: nearest.index,
|
|
distanceToGroupBoundsCenterSourceUnits: Number(nearest.distance.toFixed(4)),
|
|
sourceWmoPosition: nearest.light.position,
|
|
convertedObjLocalPosition: sourceToObjPoint(nearest.light.position),
|
|
rawColorBytes: nearest.light.colorBytes,
|
|
lightType: nearest.light.lightType,
|
|
useAttenuation: nearest.light.useAttenuation,
|
|
intensity: nearest.light.intensity,
|
|
attenuationStart: nearest.light.attenuationStart,
|
|
attenuationEnd: nearest.light.attenuationEnd,
|
|
});
|
|
if (representatives.length === 3) break;
|
|
}
|
|
const byType = {};
|
|
const byAttenuation = {};
|
|
for (const light of roots.lights) {
|
|
byType[light.lightType] = (byType[light.lightType] ?? 0) + 1;
|
|
byAttenuation[light.useAttenuation] = (byAttenuation[light.useAttenuation] ?? 0) + 1;
|
|
}
|
|
return {
|
|
totalMoltRows: roots.lights.length,
|
|
lightTypeHistogram: byType,
|
|
useAttenuationHistogram: byAttenuation,
|
|
rawColorByteHistogram: colorHistogram(roots.lights, "colorBytes"),
|
|
intensityHistogram: numberHistogram(roots.lights, "intensity"),
|
|
attenuationStartHistogram: numberHistogram(roots.lights, "attenuationStart"),
|
|
attenuationEndHistogram: numberHistogram(roots.lights, "attenuationEnd"),
|
|
groupCountWithMocv: groups.filter((group) => group.vertices.some((vertex) => vertex.mocvBytes)).length,
|
|
groupCountWithHasLightAndMolr: eligible.length,
|
|
eligibleMolrReferenceCount: eligible.reduce((sum, group) => sum + group.lightRefs.length, 0),
|
|
eligibleUniqueMoltRows: [...referenced].sort((a, b) => a - b),
|
|
unreferencedMoltRowCount: roots.lights.length - referenced.size,
|
|
invalidLightReferences: invalidRefs,
|
|
representativeRoomLights: representatives,
|
|
runtimeLightClaim: "Diagnostic only: MOLT/MOLR references are inventoried; no Three.js dynamic lights are emitted.",
|
|
};
|
|
}
|
|
|
|
function parseObjBounds(objText) {
|
|
const min = [Infinity, Infinity, Infinity];
|
|
const max = [-Infinity, -Infinity, -Infinity];
|
|
for (const line of objText.split(/\r?\n/u)) {
|
|
if (!line.startsWith("v ")) continue;
|
|
const p = line.trim().split(/\s+/u).slice(1, 4).map(Number);
|
|
p.forEach((value, axis) => {
|
|
min[axis] = Math.min(min[axis], value);
|
|
max[axis] = Math.max(max[axis], value);
|
|
});
|
|
}
|
|
if (!Number.isFinite(min[0])) throw new Error("OBJ has no position vertices.");
|
|
return { min, max };
|
|
}
|
|
|
|
export function auditWmoAsset({ rootBytes, groupCacheRootBytes, expectedRootSha256, groupBuffers, objText }) {
|
|
const identity = assertMatchingRootDigests({ rootBytes, groupCacheRootBytes, expectedRootSha256 });
|
|
const roots = parseRootWmo(rootBytes);
|
|
if (groupBuffers.length !== roots.header.groupCount) {
|
|
throw new Error(`Expected ${roots.header.groupCount} group files, received ${groupBuffers.length}.`);
|
|
}
|
|
const groups = groupBuffers.map((buffer, index) => parseGroupWmo(buffer, index));
|
|
for (const group of groups) {
|
|
for (const lightIndex of group.lightRefs) {
|
|
if (lightIndex < 0 || lightIndex >= roots.lights.length) {
|
|
throw new Error(`Group ${group.groupIndex} references out-of-range MOLT row ${lightIndex}.`);
|
|
}
|
|
}
|
|
}
|
|
const mapped = mapExactObjMocv(roots, groups, objText);
|
|
const convertedRootBounds = boundsInObjBasis(roots.header.boundsMin, roots.header.boundsMax);
|
|
const objBounds = parseObjBounds(objText);
|
|
const boundsDelta = [0, 1, 2].flatMap((axis) => [
|
|
Math.abs(convertedRootBounds.min[axis] - objBounds.min[axis]),
|
|
Math.abs(convertedRootBounds.max[axis] - objBounds.max[axis]),
|
|
]);
|
|
return {
|
|
sourceIdentity: identity,
|
|
sourceGeometry: {
|
|
formatVersion: 17,
|
|
rootGroupCount: roots.header.groupCount,
|
|
loadedGroupCount: groups.length,
|
|
groupCacheFilesAreBoundToRootByHash: true,
|
|
rootBoundsInSourceAxes: { min: roots.header.boundsMin, max: roots.header.boundsMax },
|
|
exportedObjBounds: objBounds,
|
|
verifiedSourceToObjBasis: DEFAULT_AXIS_BASIS,
|
|
rootBoundsTransformMaxDelta: Math.max(...boundsDelta),
|
|
sourceToObjBasisEvidence: "Full exact OBJ vertex mapping matches the corresponding group MOVT/MONR/MOTV signatures after this axis transform.",
|
|
groupFlagsHistogram: Object.fromEntries([...groups.reduce((counts, group) => {
|
|
counts.set(`0x${group.header.flags.toString(16)}`, (counts.get(`0x${group.header.flags.toString(16)}`) ?? 0) + 1);
|
|
return counts;
|
|
}, new Map()).entries()]),
|
|
mocv: {
|
|
alphaSemantics: "Raw alpha is provenance only. It is not glTF opacity and is not fed to a material alpha channel.",
|
|
rendererFixupContext: {
|
|
mohdFlags: roots.header.flags,
|
|
mohdAmbientBytes: roots.header.ambientColorBytes,
|
|
perGroup: groups.map((group) => ({
|
|
groupIndex: group.groupIndex,
|
|
groupFlags: group.header.flags,
|
|
hasVertexColorFlag: group.hasVertexColorFlag,
|
|
indoorFlag: group.indoorFlag,
|
|
batchCounts: group.header.batchCounts,
|
|
mocvVertexCount: group.vertices.filter((vertex) => vertex.mocvBytes).length,
|
|
})),
|
|
interpretation: "The WotLK-era client FixColorVertexAlpha path is documented as batch-dependent: it uses MOHD flags/ambient and MOGP batch counts, mutates MOCV RGB (including a divide-by-two step), then rewrites alpha. The diagnostic transcription below is not productionColors: native pixel-shader composition, color-space domain, and later portal attenuation remain unverified. The meaning of MOHD 0x0008 conflicts between the client behavior description and pywowlib naming, so that path is specifically disputed.",
|
|
},
|
|
rawMapping: {
|
|
objVertexCount: mapped.objVertexCount,
|
|
sourceMocvVertexCount: mapped.sourceMocvVertexCount,
|
|
mappedVertexCount: mapped.mappedVertexCount,
|
|
ambiguousVertexCount: mapped.ambiguousVertexCount,
|
|
unmatchedVertexCount: mapped.unmatchedVertexCount,
|
|
exactCompleteMapping: mapped.exactCompleteMapping,
|
|
packedRawByteSha256: hash(mapped.rawColorBytesByObjVertex),
|
|
rawAlphaHistogram: mapped.rawAlphaHistogram,
|
|
byteOrder: mapped.rawMocvByteOrder,
|
|
alphaUse: "Raw alpha remains uninterpreted provenance; not opacity or material alpha.",
|
|
outputLabel: "rawColorBytesByObjVertex",
|
|
},
|
|
documentedFixColorVertexAlphaDiagnostic: {
|
|
objVertexCount: mapped.objVertexCount,
|
|
mappedVertexCount: mapped.documentedFixColorAlphaMapping.mappedVertexCount,
|
|
ambiguousVertexCount: mapped.documentedFixColorAlphaMapping.ambiguousVertexCount,
|
|
unmatchedVertexCount: mapped.documentedFixColorAlphaMapping.unmatchedVertexCount,
|
|
exactCompleteMapping: mapped.documentedFixColorAlphaMapping.exactCompleteMapping,
|
|
packedAdaptedDiagnosticByteSha256: hash(mapped.documentedFixColorAlphaMapping.colorsByObjVertex),
|
|
adaptedAlphaHistogram: mapped.documentedFixColorAlphaMapping.adaptedAlphaHistogram,
|
|
byteOrder: mapped.documentedFixColorAlphaMapping.byteOrder,
|
|
outputLabel: mapped.documentedFixColorAlphaMapping.outputLabel,
|
|
productionColors: false,
|
|
mohd0008Interpretation: "Disputed: getMaNGOS describes a special alpha path; pywowlib labels the bit DoNotFixVertexColorAlpha. Diagnostic only.",
|
|
quantization: "Diagnostic byte output truncates and clamps to [0,255]; exact source client byte-cast rounding is not independently verified.",
|
|
},
|
|
},
|
|
},
|
|
lighting: makeLightInventory(roots, groups, roots.groupNameTable),
|
|
sourceSemantics: {
|
|
molt: "WMO v17 MOLT records are preserved for diagnosis; classic-era clients do not consume these as realtime point lights and wowlib describes them as exporter-baked into vertex colors.",
|
|
mocv: "MOCV is stored BGRA and indoor groups use it as baked lighting. A diagnostic-only transcription of the documented WotLK-era CPU color fix is emitted as adaptedColorBytesByObjVertex; it is not a production material color because native pixel-shader composition, sRGB/linear conversion, source byte quantization, and later portal attenuation are not fully verified.",
|
|
sources: [
|
|
"https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_root.py",
|
|
"https://github.com/wowdev/pywowlib/blob/master/file_formats/wmo_format_group.py",
|
|
"https://skarndev.github.io/wowlib/python/wmo/root-chunks/",
|
|
"https://www.getmangos.eu/wiki/referenceinfo/clientfiles/wmo-file-r20030/",
|
|
],
|
|
},
|
|
rawColorBytesByObjVertex: mapped.rawColorBytesByObjVertex,
|
|
adaptedColorBytesByObjVertex: mapped.documentedFixColorAlphaMapping.colorsByObjVertex,
|
|
};
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
"Usage:",
|
|
" node scripts/dungeon-pipeline/wow-native-wmo-audit.mjs --root <source-root.wmo> --group-root <cache-root.wmo>",
|
|
" --group-dir <directory> --group-base <stem> --obj <wow-export.obj> --expected-root-sha <sha256>",
|
|
" [--report <audit.json>] [--raw-color-bytes <raw-bg-r-a-sidecar.bin>]",
|
|
" [--adapted-color-bytes <documented-fix-diagnostic-bg-r-a-sidecar.bin>]",
|
|
].join("\n");
|
|
}
|
|
|
|
function cliArgs(argv) {
|
|
const args = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const key = argv[index];
|
|
if (!key.startsWith("--")) throw new Error(`Unexpected argument ${key}.\n${usage()}`);
|
|
if (key === "--help") return { help: true };
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${key}.\n${usage()}`);
|
|
args[key.slice(2)] = value;
|
|
index += 1;
|
|
}
|
|
for (const key of ["root", "group-root", "group-dir", "group-base", "obj", "expected-root-sha"]) {
|
|
if (!args[key]) throw new Error(`Missing --${key}.\n${usage()}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
async function runCli() {
|
|
const args = cliArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return;
|
|
}
|
|
const rootPath = path.resolve(args.root);
|
|
const groupRootPath = path.resolve(args["group-root"]);
|
|
const groupDirectory = path.resolve(args["group-dir"]);
|
|
const rootBytes = await readFile(rootPath);
|
|
const groupCacheRootBytes = await readFile(groupRootPath);
|
|
const parsedRoot = parseRootWmo(rootBytes);
|
|
const groupBuffers = [];
|
|
const groupHashes = [];
|
|
for (let index = 0; index < parsedRoot.header.groupCount; index += 1) {
|
|
const filename = `${args["group-base"]}_${String(index).padStart(3, "0")}.wmo`;
|
|
const bytes = await readFile(path.join(groupDirectory, filename));
|
|
groupBuffers.push(bytes);
|
|
groupHashes.push(hash(bytes));
|
|
}
|
|
const objPath = path.resolve(args.obj);
|
|
const objText = await readFile(objPath, "utf8");
|
|
const result = auditWmoAsset({
|
|
rootBytes,
|
|
groupCacheRootBytes,
|
|
expectedRootSha256: args["expected-root-sha"],
|
|
groupBuffers,
|
|
objText,
|
|
});
|
|
result.sourceIdentity.sourceRootPath = rootPath;
|
|
result.sourceIdentity.groupCacheRootPath = groupRootPath;
|
|
result.sourceIdentity.groupCacheFileHashes = groupHashes;
|
|
result.sourceIdentity.exportedObjPath = objPath;
|
|
result.sourceIdentity.exportedObjSha256 = hash(Buffer.from(objText));
|
|
const printable = JSON.stringify({
|
|
...result,
|
|
rawColorBytesByObjVertex: undefined,
|
|
adaptedColorBytesByObjVertex: undefined,
|
|
}, null, 2);
|
|
if (args.report) await writeFile(path.resolve(args.report), `${printable}\n`);
|
|
if (args["raw-color-bytes"]) {
|
|
if (!result.sourceGeometry.mocv.rawMapping.exactCompleteMapping) {
|
|
throw new Error("Refusing to write an incomplete/ambiguous raw MOCV color mapping.");
|
|
}
|
|
await writeFile(path.resolve(args["raw-color-bytes"]), result.rawColorBytesByObjVertex);
|
|
}
|
|
if (args["adapted-color-bytes"]) {
|
|
if (!result.sourceGeometry.mocv.documentedFixColorVertexAlphaDiagnostic.exactCompleteMapping) {
|
|
throw new Error("Refusing to write an incomplete/ambiguous adapted diagnostic color mapping.");
|
|
}
|
|
await writeFile(path.resolve(args["adapted-color-bytes"]), result.adaptedColorBytesByObjVertex);
|
|
}
|
|
process.stdout.write(`${printable}\n`);
|
|
}
|
|
|
|
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
runCli().catch((error) => {
|
|
process.stderr.write(`${error.stack ?? error}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|