#!/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 --group-root ", " --group-dir --group-base --obj --expected-root-sha ", " [--report ] [--raw-color-bytes ]", " [--adapted-color-bytes ]", ].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; }); }