Files
healer-man/scripts/dungeon-pipeline/wow-native-wmo-audit.mjs
T
2026-10-07 13:05:04 -04:00

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;
});
}