Update Healer Man server

This commit is contained in:
phenom committed 2026-10-07 13:05:04 -04:00
1 parent 69809f7228
commit ff20ab8779
101 files changed
+16713 -945

No files matched your search

@@ -191,7 +191,7 @@
},
"public/assets/game/manastorm/575-utgardepinnacle/visual.glb": {
"maxEstimatedDrawCalls": 16277,
"maxUnbatchedDrawCallSavings": 14018
"maxUnbatchedDrawCallSavings": 14695
},
"public/assets/game/manastorm/576-nexus70/visual.glb": {
"maxEstimatedDrawCalls": 1500,
@@ -302,7 +302,9 @@ function textureSizeByIndex(json, images) {
});
}
export async function auditGeometry(file, textureSizes, label = path.basename(file)) {
export async function auditGeometry(file, textureSizes, label = path.basename(file), options = {}) {
const hotspotCellSizeM = Number(options.hotspotCellSizeM);
const collectHotspots = Number.isFinite(hotspotCellSizeM) && hotspotCellSizeM > 0;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
@@ -325,6 +327,7 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
untexturedMaterials: {},
};
const bySurface = { floor: emptySurfaceMetrics(), wall: emptySurfaceMetrics(), other: emptySurfaceMetrics() };
const hotspotCells = new Map();
for (const scene of document.getRoot().listScenes()) {
const sceneNodes = [];
const visit = (node) => {
@@ -400,19 +403,59 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
info.getExtension("KHR_texture_transform"),
));
const mapping = mappingMetrics(points, uvValues, size[0], size[1]);
if (!mapping || mapping.uvDoubleArea <= 1e-12) {
const uvDegenerate = !mapping || mapping.uvDoubleArea <= 1e-12;
const rankCollapsed = uvDegenerate || mapping.smallestStretchPxPerMeter <= 0.01;
if (uvDegenerate) {
for (const metric of [totals, surface]) {
metric.uvDegenerateTriangles += 1;
metric.rankCollapsedTriangles += 1;
metric.uvDegenerateAreaM2 += area;
metric.rankCollapsedAreaM2 += area;
}
} else if (mapping.smallestStretchPxPerMeter <= 0.01) {
} else if (rankCollapsed) {
for (const metric of [totals, surface]) {
metric.rankCollapsedTriangles += 1;
metric.rankCollapsedAreaM2 += area;
}
}
if (collectHotspots && rankCollapsed) {
const centroid = [0, 1, 2].map((axis) => points.reduce((sum, point) => sum + point[axis], 0) / 3);
const cellX = Math.floor(centroid[0] / hotspotCellSizeM);
const cellZ = Math.floor(centroid[2] / hotspotCellSizeM);
const key = `${cellX}:${cellZ}`;
const cell = hotspotCells.get(key) ?? {
cellX,
cellZ,
cellSizeM: hotspotCellSizeM,
uvDegenerateTriangles: 0,
rankCollapsedTriangles: 0,
uvDegenerateAreaM2: 0,
rankCollapsedAreaM2: 0,
centroidAreaWeight: 0,
centroidAreaSum: [0, 0, 0],
bySurface: {},
};
const surfaceCell = cell.bySurface[surfaceKey] ?? {
uvDegenerateTriangles: 0,
rankCollapsedTriangles: 0,
uvDegenerateAreaM2: 0,
rankCollapsedAreaM2: 0,
};
cell.rankCollapsedTriangles += 1;
cell.rankCollapsedAreaM2 += area;
surfaceCell.rankCollapsedTriangles += 1;
surfaceCell.rankCollapsedAreaM2 += area;
if (uvDegenerate) {
cell.uvDegenerateTriangles += 1;
cell.uvDegenerateAreaM2 += area;
surfaceCell.uvDegenerateTriangles += 1;
surfaceCell.uvDegenerateAreaM2 += area;
}
cell.centroidAreaWeight += area;
for (let axis = 0; axis < 3; axis += 1) cell.centroidAreaSum[axis] += centroid[axis] * area;
cell.bySurface[surfaceKey] = surfaceCell;
hotspotCells.set(key, cell);
}
}
}
}
@@ -428,7 +471,31 @@ export async function auditGeometry(file, textureSizes, label = path.basename(fi
for (const material of Object.values(materialCoverage.untexturedMaterials)) {
material.worldAreaM2 = round(material.worldAreaM2, 2);
}
return { ...totals, bySurface, materialCoverage };
const uvHotspots = collectHotspots
? [...hotspotCells.values()]
.map((cell) => ({
cellX: cell.cellX,
cellZ: cell.cellZ,
cellSizeM: cell.cellSizeM,
center: [
round((cell.cellX + 0.5) * cell.cellSizeM, 2),
round(cell.centroidAreaSum[1] / cell.centroidAreaWeight, 2),
round((cell.cellZ + 0.5) * cell.cellSizeM, 2),
],
uvDegenerateTriangles: cell.uvDegenerateTriangles,
rankCollapsedTriangles: cell.rankCollapsedTriangles,
uvDegenerateAreaM2: round(cell.uvDegenerateAreaM2, 2),
rankCollapsedAreaM2: round(cell.rankCollapsedAreaM2, 2),
bySurface: Object.fromEntries(Object.entries(cell.bySurface).map(([surface, metric]) => [surface, {
uvDegenerateTriangles: metric.uvDegenerateTriangles,
rankCollapsedTriangles: metric.rankCollapsedTriangles,
uvDegenerateAreaM2: round(metric.uvDegenerateAreaM2, 2),
rankCollapsedAreaM2: round(metric.rankCollapsedAreaM2, 2),
}])),
}))
.sort((left, right) => right.rankCollapsedAreaM2 - left.rankCollapsedAreaM2 || left.cellX - right.cellX || left.cellZ - right.cellZ)
: undefined;
return { ...totals, bySurface, materialCoverage, ...(uvHotspots ? { uvHotspots } : {}) };
}
function compareImages(source, shipped) {
@@ -479,6 +546,39 @@ function delta(after, before, key) {
return round(after[key] - before[key], 2);
}
function compareUvHotspots(sourceCells = [], runtimeCells = []) {
const sourceByCell = new Map(sourceCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
const runtimeByCell = new Map(runtimeCells.map((cell) => [`${cell.cellX}:${cell.cellZ}`, cell]));
const keys = new Set([...sourceByCell.keys(), ...runtimeByCell.keys()]);
return [...keys].map((key) => {
const source = sourceByCell.get(key);
const runtime = runtimeByCell.get(key);
const center = runtime?.center ?? source?.center;
const sourceArea = source?.rankCollapsedAreaM2 ?? 0;
const runtimeArea = runtime?.rankCollapsedAreaM2 ?? 0;
const sourceUvArea = source?.uvDegenerateAreaM2 ?? 0;
const runtimeUvArea = runtime?.uvDegenerateAreaM2 ?? 0;
return {
cellX: runtime?.cellX ?? source.cellX,
cellZ: runtime?.cellZ ?? source.cellZ,
cellSizeM: runtime?.cellSizeM ?? source.cellSizeM,
center,
rankCollapsedAreaM2Delta: round(runtimeArea - sourceArea, 2),
uvDegenerateAreaM2Delta: round(runtimeUvArea - sourceUvArea, 2),
rankCollapsedTrianglesDelta: (runtime?.rankCollapsedTriangles ?? 0) - (source?.rankCollapsedTriangles ?? 0),
uvDegenerateTrianglesDelta: (runtime?.uvDegenerateTriangles ?? 0) - (source?.uvDegenerateTriangles ?? 0),
sourceRankCollapsedAreaM2: sourceArea,
runtimeRankCollapsedAreaM2: runtimeArea,
sourceUvDegenerateAreaM2: sourceUvArea,
runtimeUvDegenerateAreaM2: runtimeUvArea,
};
})
.filter((cell) => cell.rankCollapsedAreaM2Delta > 0 || cell.uvDegenerateAreaM2Delta > 0)
.sort((left, right) => right.rankCollapsedAreaM2Delta - left.rankCollapsedAreaM2Delta
|| right.uvDegenerateAreaM2Delta - left.uvDegenerateAreaM2Delta)
.slice(0, 50);
}
function resolvePublishedPath(url) {
if (typeof url !== "string" || !url.startsWith("/assets/game/")) return null;
const file = path.resolve(publicRoot, url.replace(/^\//u, ""));
@@ -511,7 +611,7 @@ async function readWowDefinitions() {
return definitions;
}
async function auditDungeon(definition, io, visualOverride = null) {
async function auditDungeon(definition, io, visualOverride = null, hotspotCellSizeM = null) {
const visualEntry = definition.assets?.visual?.[0];
const authoredRuntimePath = definition.id === "wailing-caverns"
? path.join(projectRoot, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb")
@@ -542,12 +642,12 @@ async function auditDungeon(definition, io, visualOverride = null) {
row.shipped = await auditGlb(publishedPath, `${definition.id} shipped`);
row.shippedGeometry = await auditGeometry(publishedPath,
textureSizeByIndex(parseGlb(await readFile(publishedPath), `${definition.id} shipped`).json, row.shipped.images),
`${definition.id} shipped`);
`${definition.id} shipped`, { hotspotCellSizeM });
if (source.path) {
const sourceAudit = await auditGlb(source.path, `${definition.id} source`);
const sourceParsed = parseGlb(await readFile(source.path), `${definition.id} source`);
row.source = sourceAudit;
row.sourceGeometry = await auditGeometry(source.path, textureSizeByIndex(sourceParsed.json, sourceAudit.images), `${definition.id} source`);
row.sourceGeometry = await auditGeometry(source.path, textureSizeByIndex(sourceParsed.json, sourceAudit.images), `${definition.id} source`, { hotspotCellSizeM });
row.textureComparison = compareImages(sourceAudit, row.shipped);
row.geometryComparison = {
triangleCountDelta: row.shippedGeometry.triangleCount - row.sourceGeometry.triangleCount,
@@ -577,6 +677,7 @@ async function auditDungeon(definition, io, visualOverride = null) {
rankCollapsedAreaM2Delta: round(shippedMetric.rankCollapsedAreaM2 - sourceMetric.rankCollapsedAreaM2, 2),
}];
})),
...(hotspotCellSizeM ? { uvHotspots: compareUvHotspots(row.sourceGeometry.uvHotspots, row.shippedGeometry.uvHotspots) } : {}),
};
if (row.textureComparison.downsizedImages) {
row.issues.push(`${row.textureComparison.downsizedImages} shipped images are smaller than their matched source dimensions.`);
@@ -680,6 +781,11 @@ async function main() {
const overrideId = overrideSeparator > 0 ? overrideText.slice(0, overrideSeparator) : null;
const visualOverride = overrideId ? path.resolve(projectRoot, overrideText.slice(overrideSeparator + 1)) : null;
if (overrideText && !visualOverride) throw new Error("--visual-override must be <dungeon-id>=<glb-path>.");
const hotspotArg = process.argv.indexOf("--hotspot-cell-m");
const hotspotCellSizeM = hotspotArg >= 0 ? Number(process.argv[hotspotArg + 1]) : null;
if (hotspotArg >= 0 && (!Number.isFinite(hotspotCellSizeM) || hotspotCellSizeM <= 0)) {
throw new Error("--hotspot-cell-m must be a positive world-space cell size in meters.");
}
const selectedIds = new Set([...(onlyIds ?? []), ...(overrideId ? [overrideId] : [])]);
const unknownIds = [...selectedIds].filter((id) => !allDefinitions.some((definition) => definition.id === id));
if (unknownIds.length) throw new Error(`Unknown WoW dungeon id(s): ${unknownIds.join(", ")}.`);
@@ -690,7 +796,7 @@ async function main() {
const dungeons = [];
for (const [index, definition] of definitions.entries()) {
console.log(`[${index + 1}/${definitions.length}] auditing ${definition.id}`);
dungeons.push(await auditDungeon(definition, io, definition.id === overrideId ? visualOverride : null));
dungeons.push(await auditDungeon(definition, io, definition.id === overrideId ? visualOverride : null, hotspotCellSizeM));
}
const compared = dungeons.filter((dungeon) => dungeon.source && dungeon.shipped);
const shipped = dungeons.filter((dungeon) => dungeon.shipped);
@@ -9,7 +9,7 @@ import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { auditGeometry } from "./audit-wow-visual-quality.mjs";
function addTriangle(document, buffer, { name, offset, material = null, textured = false }) {
function addTriangle(document, buffer, { name, offset, material = null, textured = false, uvValues = null }) {
const positions = document.createAccessor(`${name}-positions`)
.setType("VEC3")
.setArray(new Float32Array([
@@ -22,7 +22,7 @@ function addTriangle(document, buffer, { name, offset, material = null, textured
if (textured) {
const texcoords = document.createAccessor(`${name}-uvs`)
.setType("VEC2")
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]))
.setArray(new Float32Array(uvValues ?? [0, 0, 1, 0, 0, 1]))
.setBuffer(buffer);
primitive.setAttribute("TEXCOORD_0", texcoords);
}
@@ -49,18 +49,31 @@ test("WoW visual audit counts untextured and material-free surfaces safely", asy
const scene = document.createScene("coverage-fixture")
.addChild(addTriangle(document, buffer, { name: "textured", offset: 0, material: texturedMaterial, textured: true }))
.addChild(addTriangle(document, buffer, { name: "plain", offset: 3, material: plainMaterial }))
.addChild(addTriangle(document, buffer, { name: "no-material", offset: 6 }));
.addChild(addTriangle(document, buffer, { name: "no-material", offset: 6 }))
.addChild(addTriangle(document, buffer, {
name: "degenerate-uv",
offset: 9,
material: texturedMaterial,
textured: true,
uvValues: [0, 0, 0, 0, 1, 1],
}));
document.getRoot().setDefaultScene(scene);
await io.write(visualFile, document);
const result = await auditGeometry(visualFile, [[1, 1]], "coverage fixture");
assert.equal(result.materialCoverage.totalTriangles, 3);
assert.equal(result.materialCoverage.baseColorTexturedTriangles, 1);
assert.equal(result.materialCoverage.totalTriangles, 4);
assert.equal(result.materialCoverage.baseColorTexturedTriangles, 2);
assert.equal(result.materialCoverage.untexturedTriangles, 2);
assert.equal(result.materialCoverage.baseColorTexturedAreaM2, 0.5);
assert.equal(result.materialCoverage.baseColorTexturedAreaM2, 1);
assert.equal(result.materialCoverage.untexturedAreaM2, 1);
assert.deepEqual(Object.keys(result.materialCoverage.untexturedMaterials).sort(), ["(no material)", "plain"]);
assert.equal(result.materialCoverage.untexturedMaterials.plain.triangles, 1);
assert.deepEqual(result.materialCoverage.untexturedMaterials.plain.baseColorFactor, [0.35, 0.31, 0.24, 1]);
assert.equal(result.materialCoverage.untexturedMaterials["(no material)"].triangles, 1);
const hotspotResult = await auditGeometry(visualFile, [[1, 1]], "coverage hotspot fixture", { hotspotCellSizeM: 2 });
assert.equal(hotspotResult.uvHotspots.length, 1);
assert.deepEqual(hotspotResult.uvHotspots[0].center, [9, 0.33, 1]);
assert.equal(hotspotResult.uvHotspots[0].uvDegenerateTriangles, 1);
assert.equal(hotspotResult.uvHotspots[0].rankCollapsedAreaM2, 0.5);
});
@@ -147,8 +147,9 @@ async function main() {
imageCount: candidateTextures.length,
comparedToPreservedBaseline: baselineTextureComparison,
},
meshoptQuantization: { positionBits: 16, texcoordBits: 16 },
finalPacking: {
command: "gltf-transform optimize <optimized.glb> <meshopt-final.glb> --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
command: "gltf-transform meshopt <optimized.glb> <meshopt-final.glb> --level high --quantize-position 16 --quantize-texcoord 16",
extensionPresent: parseGlb(candidateBytes, output).json.extensionsUsed?.includes("EXT_meshopt_compression") ?? false,
},
};
@@ -0,0 +1,573 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { Matrix4, Vector3 } from "three";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { decodeBlp2, encodePng } from "../lib/blp.mjs";
import { parseWowAdtTerrain, inspectObjMaterialContract } from "./lib/wow-adt-terrain-audit.mjs";
import { composeWowAdtTerrainWeights, wowAdtNormalizedUv } from "./lib/wow-adt-terrain-material.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultWork = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/source-export");
const defaultAdt = path.join(defaultWork, "raw/world/maps/tanarisinstance/tanarisinstance_30_29.adt");
const defaultObj = path.join(defaultWork, "adt_30_29.obj");
const defaultGlb = path.resolve(projectRoot, "../HealerMan-Storage/pipeline-work/dungeons/zulfarrak/staging/environment/zulfarrak-visual.glb");
const defaultDungeonWork = path.dirname(defaultWork);
const defaultProvenanceLock = path.join(defaultDungeonWork, "provenance.lock.json");
const defaultSourceSnapshot = path.join(defaultDungeonWork, "source-snapshot.json");
const defaultMpqDirectory = path.resolve(projectRoot, "../wow335a/Data");
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const key = process.argv[index];
if (!key.startsWith("--") || !process.argv[index + 1] || process.argv[index + 1].startsWith("--")) {
throw new Error(`Expected --name value, got ${key}.`);
}
args.set(key, process.argv[++index]);
}
const readArg = (name, fallback) => path.resolve(args.get(name) ?? fallback);
const adtPath = readArg("--adt", defaultAdt);
const objPath = readArg("--obj", defaultObj);
const glbPath = readArg("--glb", defaultGlb);
const provenanceLockPath = readArg("--provenance-lock", defaultProvenanceLock);
const sourceSnapshotPath = readArg("--source-snapshot", defaultSourceSnapshot);
const mpqDirectory = readArg("--mpq-dir", defaultMpqDirectory);
const worldAdtPath = (args.get("--world-path") ?? "World/Maps/TanarisInstance/TanarisInstance_30_29.adt").replaceAll("\\", "/");
const outputRoot = readArg("--out", path.join(
projectRoot,
"artifacts/playtest/wow-terrain-recovery",
`zulfarrak-30-29-${new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")}-${process.pid}`,
));
for (const input of [adtPath, objPath, glbPath, provenanceLockPath, sourceSnapshotPath]) {
if (!fs.existsSync(input)) throw new Error(`Missing required source: ${input}`);
}
if (!fs.existsSync(mpqDirectory)) throw new Error(`Missing MPQ directory: ${mpqDirectory}`);
const provenanceLock = JSON.parse(fs.readFileSync(provenanceLockPath, "utf8"));
const sourceSnapshot = JSON.parse(fs.readFileSync(sourceSnapshotPath, "utf8"));
const clientBuild = provenanceLock.clientBuild;
if (provenanceLock.dungeonId !== "zulfarrak" || typeof clientBuild !== "string" || !clientBuild.trim()) {
throw new Error(`The source provenance lock does not identify a Zul'Farrak client build: ${provenanceLockPath}`);
}
if (sourceSnapshot.client?.build !== clientBuild) {
throw new Error(`The source snapshot build ${sourceSnapshot.client?.build ?? "<missing>"} does not match provenance lock ${clientBuild}.`);
}
fs.mkdirSync(path.dirname(outputRoot), { recursive: true });
fs.mkdirSync(outputRoot, { recursive: false });
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const safeRelative = (value) => {
const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
if (!normalized || normalized.split("/").some(part => part === ".." || part === ".")) {
throw new Error(`Unsafe source-relative path: ${value}`);
}
return normalized;
};
const adt = fs.readFileSync(adtPath);
const parsed = parseWowAdtTerrain(adt, { label: adtPath });
if (parsed.mcnkCount !== 256 || parsed.chunks.some(chunk => chunk.heightSamples?.length !== 145)) {
throw new Error("The selected terrain input is not one complete 16x16 MCVT ADT tile.");
}
if (parsed.chunks.some(chunk => chunk.highResolutionHoles)) {
throw new Error("High-resolution MCNK holes are present; this prototype only preserves the 3.3.5 low-resolution mask.");
}
if (parsed.alphaMapDataMissing) {
throw new Error(`Refusing incomplete source alpha data: ${parsed.alphaMapDataMissing} overlay maps are unsupported or out of bounds.`);
}
const adtSha256 = sha256(adt);
const sourcePathRelative = safeRelative(worldAdtPath);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const gltf = await io.read(glbPath);
const meshNode = gltf.getRoot().listNodes().find(node => node.getName() === "ADT_30_29");
if (!meshNode?.getMesh() || meshNode.getMesh().listPrimitives().length !== 1) {
throw new Error("Could not identify the one-primitive ADT_30_29 shipping-source mesh.");
}
const primitive = meshNode.getMesh().listPrimitives()[0];
const positionAccessor = primitive.getAttribute("POSITION");
const normalAccessor = primitive.getAttribute("NORMAL");
const uvAccessor = primitive.getAttribute("TEXCOORD_0");
const indexAccessor = primitive.getIndices();
if (!positionAccessor || !normalAccessor || !uvAccessor || !indexAccessor) throw new Error("Source GLB is missing POSITION, NORMAL, TEXCOORD_0, or indices.");
const positions = positionAccessor.getArray();
const normals = normalAccessor.getArray();
const sourceUvs = uvAccessor.getArray();
const indices = indexAccessor.getArray();
if (positionAccessor.getCount() !== parsed.mcnkCount * 145 || normalAccessor.getCount() !== positionAccessor.getCount()
|| uvAccessor.getCount() !== positionAccessor.getCount()
|| indexAccessor.getCount() !== parsed.mcnkCount * 768) {
throw new Error("The source GLB vertex/index layout differs from the 256 × 145 WoW ADT chunk layout.");
}
const objText = fs.readFileSync(objPath, "utf8");
const objPositions = objText.split(/\r?\n/).filter(line => line.startsWith("v "))
.map(line => line.trim().split(/\s+/).slice(1).map(Number));
if (objPositions.length !== positionAccessor.getCount()) throw new Error("OBJ and staging GLB vertex counts differ.");
const materialContract = inspectObjMaterialContract(objPath);
const worldMatrix = new Matrix4().fromArray(meshNode.getWorldMatrix());
const worldPosition = new Vector3();
const unitsPerChunkCell = 533.33333 / 16 / 8;
const uvStream = new Float32Array(positionAccessor.getCount() * 2);
let maxSourceToObjError = 0;
let maxSourceToShippingWorldError = 0;
let sourceToObjViolations = 0;
let sourceToShippingWorldViolations = 0;
const maxSourceToObjTolerance = 0.0001;
const maxSourceToShippingWorldTolerance = 0.001;
for (const chunk of parsed.chunks) {
let vertexOffset = 0;
for (let row = 0; row <= 16; row += 1) {
const columnCount = row % 2 ? 8 : 9;
for (let column = 0; column < columnCount; column += 1) {
const height = chunk.heightSamples[vertexOffset];
const sourcePosition = [
chunk.world.x - column * unitsPerChunkCell - (row % 2 ? unitsPerChunkCell / 2 : 0),
chunk.world.y + height,
chunk.world.height - row * unitsPerChunkCell / 2,
];
const globalVertex = chunk.ordinal * 145 + vertexOffset;
const objPosition = objPositions[globalVertex];
const sourceToObjError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - objPosition[axis])));
maxSourceToObjError = Math.max(maxSourceToObjError, sourceToObjError);
if (sourceToObjError > maxSourceToObjTolerance) sourceToObjViolations += 1;
worldPosition.set(
positions[globalVertex * 3],
positions[globalVertex * 3 + 1],
positions[globalVertex * 3 + 2],
).applyMatrix4(worldMatrix);
const shippingWorld = [worldPosition.x, worldPosition.y, worldPosition.z];
const sourceToShippingError = Math.max(...sourcePosition.map((value, axis) => Math.abs(value - shippingWorld[axis])));
maxSourceToShippingWorldError = Math.max(maxSourceToShippingWorldError, sourceToShippingError);
if (sourceToShippingError > maxSourceToShippingWorldTolerance) sourceToShippingWorldViolations += 1;
const [u, v] = wowAdtNormalizedUv(row, column);
uvStream[globalVertex * 2] = u;
uvStream[globalVertex * 2 + 1] = v;
vertexOffset += 1;
}
}
}
if (sourceToObjViolations || sourceToShippingWorldViolations) {
throw new Error(`Source geometry correspondence failed: OBJ ${sourceToObjViolations}, staging GLB ${sourceToShippingWorldViolations}.`);
}
const indexElementBytes = indexAccessor.getArray().BYTES_PER_ELEMENT;
if (![1, 2, 4].includes(indexElementBytes)) throw new Error(`Unsupported index element size ${indexElementBytes}.`);
const retainedIndices = [];
const chunkIndexRanges = [];
let holeTrianglesRemoved = 0;
for (const chunk of parsed.chunks) {
const sourceIndexStart = chunk.ordinal * 768;
const sourceIndexEnd = sourceIndexStart + 768;
const rowStarts = [];
let rowStart = 0;
for (let row = 0; row <= 16; row += 1) {
rowStarts[row] = rowStart;
rowStart += row % 2 ? 8 : 9;
}
for (let fanRow = 0; fanRow < 8; fanRow += 1) {
for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) {
const groupStart = sourceIndexStart + (fanRow * 8 + fanColumn) * 12;
const groupEnd = groupStart + 12;
const centerIndex = chunk.ordinal * 145 + rowStarts[fanRow * 2 + 1] + fanColumn;
let centerMentions = 0;
for (let index = groupStart; index < groupEnd; index += 1) {
if (index < sourceIndexStart || index >= sourceIndexEnd) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} triangle group leaves its shipping index range.`);
if (indices[index] < chunk.ordinal * 145 || indices[index] >= (chunk.ordinal + 1) * 145) {
throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} index ${index} points into another chunk's vertices.`);
}
if (indices[index] === centerIndex) centerMentions += 1;
}
if (centerMentions !== 4) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} source index topology is not the verified 64-fan MCVT layout.`);
const holeBit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2);
if ((chunk.holeMask & (1 << holeBit)) !== 0) {
holeTrianglesRemoved += 4;
} else {
for (let index = groupStart; index < groupEnd; index += 1) retainedIndices.push(indices[index]);
}
}
}
const removedHoleFans = countRemovedFans(chunk.holeMask);
const outputIndexStart = retainedIndices.length - (768 - removedHoleFans * 12);
chunkIndexRanges.push({
ordinal: chunk.ordinal,
indexX: chunk.indexX,
indexY: chunk.indexY,
vertexStart: chunk.ordinal * 145,
vertexCount: 145,
sourceIndexStart,
sourceIndexCount: 768,
recoveredIndexStart: outputIndexStart,
recoveredIndexCount: 768 - removedHoleFans * 12,
sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"),
highResolutionHoles: chunk.highResolutionHoles,
});
}
if (retainedIndices.length + holeTrianglesRemoved * 3 !== indices.length) {
throw new Error("Source hole masks do not account for the recovered triangle index count.");
}
const indexArray = indexAccessor.getArray().constructor.from(retainedIndices);
const archiveNames = ["patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ"];
const directoryEntries = new Map(fs.readdirSync(mpqDirectory, { withFileTypes: true })
.filter(entry => entry.isFile()).map(entry => [entry.name.toLowerCase(), path.join(mpqDirectory, entry.name)]));
const archives = [];
const archiveErrors = [];
for (const archiveName of archiveNames) {
const archivePath = directoryEntries.get(archiveName.toLowerCase());
if (!archivePath) continue;
try {
archives.push({ file: archivePath, archive: MpqArchive.open(archivePath, { noListfile: true, noAttributes: true }) });
} catch (error) {
archiveErrors.push({ file: archivePath, message: error.message });
}
}
if (!archives.length) throw new Error(`No readable WoW MPQ archives found under ${mpqDirectory}.`);
const adtArchiveSources = [];
try {
for (const entry of archives) {
if (entry.archive.hasFile(worldAdtPath)) {
const bytes = entry.archive.extractFile(worldAdtPath);
adtArchiveSources.push({ archive: path.basename(entry.file), sha256: sha256(bytes), bytes: bytes.length });
}
}
const winningAdtSource = adtArchiveSources[0];
if (!winningAdtSource || winningAdtSource.sha256 !== adtSha256) {
throw new Error(`Source ADT does not match the highest-priority available MPQ copy for ${worldAdtPath}.`);
}
const textureCarriers = [];
const textureVariantByIndex = [];
for (const [textureIndex, textureName] of parsed.textures.entries()) {
let bytes = null;
let archiveName = null;
for (const entry of archives) {
if (!entry.archive.hasFile(textureName)) continue;
bytes = entry.archive.extractFile(textureName);
archiveName = path.basename(entry.file);
break;
}
if (!bytes) throw new Error(`Native texture missing from configured client MPQs: ${textureName}`);
const textureSha256 = sha256(bytes);
const relativeBlp = `textures/source/${safeRelative(textureName)}`;
const relativePng = `textures/decoded/${safeRelative(textureName).replace(/\.blp$/i, ".png")}`;
const blpOutput = path.join(outputRoot, relativeBlp);
const pngOutput = path.join(outputRoot, relativePng);
fs.mkdirSync(path.dirname(blpOutput), { recursive: true });
fs.mkdirSync(path.dirname(pngOutput), { recursive: true });
fs.writeFileSync(blpOutput, bytes);
const decodedImage = decodeBlp2(bytes, textureName);
const decoded = encodePng(decodedImage);
fs.writeFileSync(pngOutput, decoded);
const variant = `wowTerrain.layer.${textureSha256}`;
textureVariantByIndex[textureIndex] = variant;
textureCarriers.push({
textureIndex,
sourceTexture: textureName,
sourceArchive: archiveName,
sourceBytes: bytes.length,
sourceSha256: textureSha256,
width: decodedImage.width,
height: decodedImage.height,
sourceBlp: relativeBlp,
decodedPng: relativePng,
decodedPngSha256: sha256(decoded),
variant,
});
}
const weightCarriers = new Map();
const materials = [];
const terrainChunks = [];
for (const chunk of parsed.chunks) {
const weights = composeWowAdtTerrainWeights(adt, chunk);
const channelStats = [0, 1, 2, 3].map(channel => {
const values = new Set();
let min = 255;
let max = 0;
for (let pixel = channel; pixel < weights.rgba.length; pixel += 4) {
const value = weights.rgba[pixel];
values.add(value);
min = Math.min(min, value);
max = Math.max(max, value);
}
return { channel: "rgba"[channel], uniqueValues: values.size, min, max };
});
let weightVariant = null;
let weightImage = null;
if (chunk.layers.length > 1) {
const png = encodePng({ width: 64, height: 64, pixels: weights.rgba });
const pngSha256 = sha256(png);
weightVariant = `wowTerrain.weight.${pngSha256}`;
weightImage = `textures/weights/${pngSha256}.png`;
if (!weightCarriers.has(weightVariant)) {
fs.mkdirSync(path.dirname(path.join(outputRoot, weightImage)), { recursive: true });
fs.writeFileSync(path.join(outputRoot, weightImage), png);
weightCarriers.set(weightVariant, { variant: weightVariant, image: weightImage, sha256: pngSha256 });
}
}
const layerVariants = chunk.layers.map(layer => textureVariantByIndex[layer.textureIndex]);
const material = {
name: `wowTerrain_30_29_${String(chunk.indexX).padStart(2, "0")}_${String(chunk.indexY).padStart(2, "0")}`,
sourceChunk: {
ordinal: chunk.ordinal,
indexX: chunk.indexX,
indexY: chunk.indexY,
flags: chunk.flags,
sourceOffsets: chunk.sourceOffsets,
layers: chunk.layers.map(layer => ({
index: layer.index,
recordOffset: layer.recordOffset,
textureIndex: layer.textureIndex,
sourceTexture: layer.texture,
flags: layer.flags,
alphaOffset: layer.alphaOffset,
alphaMapEncoding: layer.alphaMapEncoding,
alphaMapDataOffset: layer.alphaMapDataOffset,
alphaMapBytes: layer.alphaMapBytes,
alphaMapSha256: layer.alphaMapAvailable
? sha256(adt.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes))
: null,
})),
alphaChunkSizeSource: chunk.alphaChunkSizeSource,
alphaChunkHeaderSize: chunk.alphaChunkHeaderSize,
alphaSizeHeader: chunk.alphaSizeHeader,
alphaSizeMatchesHeader: chunk.alphaSizeMatchesHeader,
holeMask: chunk.holeMask,
lowResolutionTextureMap: chunk.lowResolutionTextureMap,
},
wowTerrain: {
schemaVersion: 1,
sourceAdt: {
path: sourcePathRelative,
sha256: adtSha256,
clientBuild,
archive: winningAdtSource.archive,
matchingTileSha256: winningAdtSource.sha256,
},
layerVariants,
weightVariants: weightVariant ? [weightVariant] : [],
uv: {
attribute: "TEXCOORD_0",
basis: "mcnk-local-normalized",
diffuseScale: [8, 8],
weightScale: [1, 1],
vSign: 1,
},
composition: { rule: "wow-overlay-residual", normalize: false },
},
weight: {
sourceEncoding: weights.sourceEncoding,
sourcePolicy: weights.sourcePolicy,
channelLayerIndices: weights.channelLayerIndices,
maxWeightSum: weights.maxWeightSum,
channelStats,
variant: weightVariant,
image: weightImage,
rgbaSha256: sha256(weights.rgba),
},
};
materials.push(material);
terrainChunks.push({
ordinal: chunk.ordinal,
indexX: chunk.indexX,
indexY: chunk.indexY,
material: material.name,
sourceHeightSamples: chunk.heightSamples,
sourceHeightSha256: chunk.heightSha256,
sourceHoleMaskHex: chunk.holeMask.toString(16).padStart(4, "0"),
vertexUvStart: chunk.ordinal * 145,
vertexUvCount: 145,
uvMin: [0, 0],
uvMax: [1, 1],
});
}
const entrance = [Number(args.get("--entrance-x") ?? 841.59), Number(args.get("--entrance-z") ?? 1213.52)];
const nearestEntranceChunk = parsed.chunks.map(chunk => ({
indexX: chunk.indexX,
indexY: chunk.indexY,
center: [chunk.world.x - unitsPerChunkCell * 4, chunk.world.height - unitsPerChunkCell * 4],
})).map(chunk => ({ ...chunk, distance: Math.hypot(chunk.center[0] - entrance[0], chunk.center[1] - entrance[1]) }))
.sort((left, right) => left.distance - right.distance)[0];
const uvBytes = Buffer.from(uvStream.buffer, uvStream.byteOffset, uvStream.byteLength);
const indexBytes = Buffer.from(indexArray.buffer, indexArray.byteOffset, indexArray.byteLength);
const chunkGeometryByOrdinal = new Map();
const chunkGeometryFiles = [];
for (const range of chunkIndexRanges) {
const chunk = parsed.chunks[range.ordinal];
const vertexStart = range.vertexStart;
const vertexEnd = vertexStart + range.vertexCount;
const positionSlice = positions.slice(vertexStart * 3, vertexEnd * 3);
const normalSlice = normals.slice(vertexStart * 3, vertexEnd * 3);
const uvSlice = uvStream.slice(vertexStart * 2, vertexEnd * 2);
const indicesGlobal = indexArray.subarray(range.recoveredIndexStart, range.recoveredIndexStart + range.recoveredIndexCount);
const localIndices = new indexArray.constructor(indicesGlobal.length);
for (let index = 0; index < indicesGlobal.length; index += 1) {
const localIndex = indicesGlobal[index] - vertexStart;
if (localIndex < 0 || localIndex >= range.vertexCount) {
throw new Error(`MCNK ${range.indexX},${range.indexY} recovered index escapes its disjoint vertex slice.`);
}
localIndices[index] = localIndex;
}
if (localIndices.length % 3 !== 0) throw new Error(`MCNK ${range.indexX},${range.indexY} has an incomplete triangle.`);
const directory = `geometry/chunks/mcnk-${String(range.indexX).padStart(2, "0")}-${String(range.indexY).padStart(2, "0")}`;
const streams = [
["position-float32.bin", positionSlice],
["normal-float32.bin", normalSlice],
["uv0-float32.bin", uvSlice],
[`indices-uint${indexElementBytes * 8}.bin`, localIndices],
];
const files = {};
for (const [filename, array] of streams) {
const bytes = Buffer.from(array.buffer, array.byteOffset, array.byteLength);
const file = `${directory}/${filename}`;
chunkGeometryFiles.push([file, bytes]);
files[filename.replace(/-.*$/, "").replace("indices", "indices")] = {
file,
bytes: bytes.length,
count: array.length,
sha256: sha256(bytes),
};
}
chunkGeometryByOrdinal.set(range.ordinal, {
coordinateSpace: "staging-glb-node-local",
position: files.position,
normal: files.normal,
uv0: files.uv0,
indices: files.indices,
indexCount: localIndices.length,
maxLocalIndex: localIndices.length ? Math.max(...localIndices) : null,
vertexStart: range.vertexStart,
sourceIndexStart: range.sourceIndexStart,
sourceIndexCount: range.sourceIndexCount,
recoveredIndexStart: range.recoveredIndexStart,
recoveredIndexCount: range.recoveredIndexCount,
});
if (positionSlice.length !== 145 * 3 || normalSlice.length !== 145 * 3 || uvSlice.length !== 145 * 2) {
throw new Error(`MCNK ${range.indexX},${range.indexY} disjoint attribute slice has an unexpected size.`);
}
if (chunk.ordinal !== range.ordinal) throw new Error("MCNK ordinal lookup changed during chunk stream extraction.");
}
for (const terrainChunk of terrainChunks) terrainChunk.geometry = chunkGeometryByOrdinal.get(terrainChunk.ordinal);
const outputFiles = [
["source/tanarisinstance_30_29.adt", adt],
["geometry/uv0-float32.bin", uvBytes],
["geometry/indices-lowres-holes-filtered.bin", indexBytes],
...chunkGeometryFiles,
];
for (const [relativePath, bytes] of outputFiles) {
const output = path.join(outputRoot, relativePath);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, bytes);
}
const materialsFile = {
schemaVersion: 1,
title: "Zul'Farrak ADT 30_29 source-backed terrain material pilot",
sourceAdt: {
path: sourcePathRelative,
localSource: adtPath,
sha256: adtSha256,
bytes: adt.length,
mver: adt.readUInt32LE(8),
clientBuild,
provenanceEvidence: {
dungeonId: provenanceLock.dungeonId,
lockFile: path.relative(projectRoot, provenanceLockPath).replaceAll("\\", "/"),
lockSha256: sha256(fs.readFileSync(provenanceLockPath)),
sourceSnapshotFile: path.relative(projectRoot, sourceSnapshotPath).replaceAll("\\", "/"),
sourceSnapshotSha256: sha256(fs.readFileSync(sourceSnapshotPath)),
sourceSnapshotClientBuild: sourceSnapshot.client.build,
},
winningArchive: winningAdtSource.archive,
availableArchiveCopies: adtArchiveSources,
},
sourceGeometry: {
obj: objPath,
glb: glbPath,
node: meshNode.getName(),
nodeWorldMatrix: meshNode.getWorldMatrix(),
primitiveCount: 1,
vertexCount: positionAccessor.getCount(),
indexCount: indexAccessor.getCount(),
originalUvCount: uvAccessor.getCount(),
sourceUvUniqueCount: new Set(Array.from({ length: uvAccessor.getCount() }, (_, index) => `${sourceUvs[index * 2]},${sourceUvs[index * 2 + 1]}`)).size,
originalObjMaterialContract: materialContract,
sourceToObjPositionMaxAbsError: maxSourceToObjError,
sourceToShippingWorldPositionMaxAbsError: maxSourceToShippingWorldError,
sourceToObjTolerance: maxSourceToObjTolerance,
sourceToShippingWorldTolerance: maxSourceToShippingWorldTolerance,
vertexPositionViolations: { obj: sourceToObjViolations, shippingWorld: sourceToShippingWorldViolations },
uv0Output: "geometry/uv0-float32.bin",
uv0Sha256: sha256(uvBytes),
uvBasis: "MCVT index-positive local coordinates; row 0→16 maps V 0→1; staggered rows retain their half-cell U offset",
sourceIndices: { bytes: indices.byteLength, sha256: sha256(Buffer.from(indices.buffer, indices.byteOffset, indices.byteLength)) },
recoveredIndices: { file: "geometry/indices-lowres-holes-filtered.bin", bytes: indexBytes.length, sha256: sha256(indexBytes), count: indexArray.length },
holePolicy: "preserve raw low-resolution 4x4 MCNK mask and omit each source-marked 2x2 cell fan block; this tile contains no set hole bits",
holeTrianglesRemoved,
},
tileStatistics: {
mcnkCount: parsed.mcnkCount,
layerCount: parsed.layers,
alphaMappedLayerCount: parsed.alphaMappedLayers,
alphaMapsRecovered: parsed.alphaMapDataAvailable,
alphaMapMissing: parsed.alphaMapDataMissing,
alphaChunksUsingEnclosingMcnkSize: parsed.chunks.filter(chunk => chunk.alphaChunkSizeSource === "mcnk-header").length,
nonZeroLowResolutionHoleMasks: parsed.chunks.filter(chunk => chunk.holeMask !== 0).length,
referencedTextureCount: parsed.textures.length,
nearestEntranceChunk,
},
textures: textureCarriers,
weightCarriers: [...weightCarriers.values()],
materials,
chunks: terrainChunks,
shippingChunkRanges: chunkIndexRanges,
archiveOpenErrors: archiveErrors,
};
const json = Buffer.from(`${JSON.stringify(materialsFile, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(outputRoot, "terrain-materials.json"), json);
const readme = [
"# WoW native ADT terrain material pilot",
"",
"This generated bundle is a source-backed diagnostic for Zul'Farrak ADT 30_29. It does not modify or replace shipping assets.",
"",
"- `source/tanarisinstance_30_29.adt` is an exact copy of the raw native ADT identified by the source hash in `terrain-materials.json`.",
`- Client identity ${clientBuild} is read from and cross-checked between the dungeon provenance lock and source snapshot; both paths and hashes are in terrain-materials.json.`,
"- `textures/source/` preserves the original BLP files; `textures/decoded/` contains lossless PNG decodes for inspection and runtime carrier preparation.",
"- `textures/weights/` contains one 64×64 RGBA RGB-weight carrier per unique source-composed image.",
"- `geometry/uv0-float32.bin` replaces the flat source-export UVs in the same 37,120-vertex order as the staged `ADT_30_29` primitive.",
"- `geometry/indices-lowres-holes-filtered.bin` retains that primitive's verified fan topology and applies its native low-resolution hole masks.",
"- `geometry/chunks/mcnk-XX-YY/` contains disjoint 145-vertex position, normal, UV0, and local-index streams for each MCNK. Each slice has a SHA-256, making it suitable for one independently culled primitive per chunk; the source staging node world matrix is recorded in the manifest.",
"- `terrain-materials.json` includes per-MCNK material metadata, source offsets, layer maps, alpha map spans and hashes, source-vs-shipping geometry checks, and KHR variant carrier names.",
"",
"The tile currently has no low-resolution hole bits set. Geometry checks establish vertex correspondence, not visual parity or proof of the original client's lighting response.",
].join("\n") + "\n";
fs.writeFileSync(path.join(outputRoot, "README.md"), readme);
console.log(JSON.stringify({ outputRoot, adtSha256, tileStatistics: materialsFile.tileStatistics, sourceGeometry: materialsFile.sourceGeometry, textureCount: textureCarriers.length, materialCount: materials.length, uniqueWeightImages: weightCarriers.size }, null, 2));
} finally {
for (const entry of archives) entry.archive.close();
}
function countRemovedFans(holeMask) {
let count = 0;
for (let fanRow = 0; fanRow < 8; fanRow += 1) {
for (let fanColumn = 0; fanColumn < 8; fanColumn += 1) {
const bit = Math.floor(fanRow / 2) * 4 + Math.floor(fanColumn / 2);
if (holeMask & (1 << bit)) count += 1;
}
}
return count;
}
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { projectRoot, workRoot } from "./lib.mjs";
const slug = "utgarde-pinnacle";
function runNodeScript(script, args) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [script, ...args], {
cwd: projectRoot,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`${path.basename(script)} exited with code ${code}.`)));
});
}
async function buildCandidates() {
const source = path.join(workRoot, slug, "staging", "environment", `${slug}-visual.glb`);
const artifacts = path.join(projectRoot, "artifacts/dungeon-pipeline");
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), `${slug}-uv-candidates-`));
const spatialOnly = process.argv.includes("--spatial-only");
const prepared = path.join(temporaryDirectory, `${slug}-prepared.glb`);
const cli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const encoder = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
const outputs = [
{ name: "p14-u12", command: "meshopt", args: ["--quantize-position", "14", "--quantize-texcoord", "12"] },
{ name: "p16-u12", command: "meshopt", args: ["--quantize-position", "16", "--quantize-texcoord", "12"] },
{ name: "p16-u16", command: "meshopt", args: ["--quantize-position", "16", "--quantize-texcoord", "16"] },
{ name: "repeat-optimize-p14-u12", command: "optimize", args: [
"--compress", "meshopt", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3",
"--simplify", "false", "--texture-compress", "false",
] },
];
try {
const results = [];
if (!spatialOnly) {
await runNodeScript(cli, [
"optimize", source, prepared,
"--compress", "false", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3",
"--simplify", "false", "--texture-compress", "false",
]);
await runNodeScript(encoder, [prepared, "--concurrency", "1", "--jobs", "2"]);
for (const output of outputs) {
const candidate = path.join(artifacts, `${slug}-uv-${output.name}.glb`);
const args = output.command === "meshopt"
? ["meshopt", prepared, candidate, "--level", "high", ...output.args]
: ["optimize", prepared, candidate, ...output.args];
await runNodeScript(cli, args);
const bytes = await stat(candidate);
const hash = createHash("sha256");
hash.update(await readFile(candidate));
results.push({ candidate: path.relative(projectRoot, candidate).replaceAll("\\", "/"), byteLength: bytes.size, sha256: hash.digest("hex") });
}
}
const spatialPrepared = path.join(temporaryDirectory, `${slug}-spatial-prepared.glb`);
await runNodeScript(cli, [
"optimize", source, spatialPrepared,
"--compress", "false", "--instance", "false", "--flatten", "false", "--join", "false", "--weld", "false",
"--simplify", "false", "--texture-compress", "false",
]);
await runNodeScript(encoder, [spatialPrepared, "--concurrency", "1", "--jobs", "2"]);
const spatialCandidate = path.join(artifacts, `${slug}-uv-spatial-p16-u16.glb`);
await runNodeScript(cli, [
"meshopt", spatialPrepared, spatialCandidate, "--level", "high",
"--quantize-position", "16", "--quantize-texcoord", "16",
]);
const spatialBytes = await stat(spatialCandidate);
const spatialHash = createHash("sha256").update(await readFile(spatialCandidate)).digest("hex");
results.push({
candidate: path.relative(projectRoot, spatialCandidate).replaceAll("\\", "/"),
byteLength: spatialBytes.size,
sha256: spatialHash,
geometryPacking: "no instance, flatten, join, or weld; one 16-bit position/UV meshopt pass",
});
console.log(JSON.stringify({ slug, source: path.relative(projectRoot, source).replaceAll("\\", "/"), candidates: results }, null, 2));
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await buildCandidates();
}
export { buildCandidates };
@@ -0,0 +1,282 @@
const DEFAULT_LIMITS = Object.freeze({
polygons: 50_000,
directedLinks: 100_000,
detailTriangles: 200_000,
surfaceBucketReferences: 2_000_000,
});
function weakComponentData(topology) {
if (!topology || !Array.isArray(topology.polygons) || !Array.isArray(topology.links)) {
throw new Error("Detour component selection requires polygon and link arrays.");
}
const polygonCount = topology.polygons.length;
const parents = new Int32Array(polygonCount);
const sizes = new Uint32Array(polygonCount).fill(1);
const polygonById = new Array(polygonCount);
for (let index = 0; index < polygonCount; index += 1) parents[index] = index;
for (const polygon of topology.polygons) {
const id = polygon[0];
if (!Number.isInteger(id) || id < 0 || id >= polygonCount || polygonById[id]) {
throw new Error(`Detour topology has an invalid or duplicate polygon ID ${id}.`);
}
polygonById[id] = polygon;
}
if (polygonById.some((polygon) => !polygon)) {
throw new Error("Detour polygon IDs must be a contiguous zero-based range.");
}
const rootOf = (value) => {
let root = value;
while (parents[root] !== root) root = parents[root];
while (parents[value] !== value) {
const next = parents[value];
parents[value] = root;
value = next;
}
return root;
};
for (const link of topology.links) {
const [from, to] = link;
if (!Number.isInteger(from) || !Number.isInteger(to)
|| from < 0 || from >= polygonCount || to < 0 || to >= polygonCount) {
throw new Error(`Detour topology link has invalid endpoints ${from} -> ${to}.`);
}
const left = rootOf(from);
const right = rootOf(to);
if (left === right) continue;
if (sizes[left] < sizes[right]) {
parents[left] = right;
sizes[right] += sizes[left];
} else {
parents[right] = left;
sizes[left] += sizes[right];
}
}
for (let id = 0; id < polygonCount; id += 1) parents[id] = rootOf(id);
return { parents, polygonById };
}
function weakComponentSummary(polygonCount, links) {
const { parents } = weakComponentData({
polygons: Array.from({ length: polygonCount }, (_, id) => [id]),
links,
});
const counts = new Map();
for (const component of parents) counts.set(component, (counts.get(component) ?? 0) + 1);
const sorted = [...counts.values()].sort((left, right) => right - left);
return { count: sorted.length, largestPolygonCount: sorted[0] ?? 0, polygonCounts: sorted };
}
function validateAnchor(anchor) {
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()) {
throw new Error("Every Detour anchor must have a non-empty ID.");
}
if (!Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
throw new Error(`Detour anchor ${anchor.id} must provide a positive maximumDistance.`);
}
if (anchor.required !== undefined && typeof anchor.required !== "boolean") {
throw new Error(`Detour anchor ${anchor.id} has an invalid required flag.`);
}
if (anchor.position && (!Array.isArray(anchor.position) || anchor.position.length !== 3
|| !anchor.position.every(Number.isFinite))) {
throw new Error(`Detour anchor ${anchor.id} has an invalid position.`);
}
}
/**
* Keeps only actual weakly connected Detour components that contain a source
* anchor within that anchor's explicit projection limit. Polygon IDs and
* detail triangle ordering are compacted; only original directed Detour links
* whose endpoints survive are copied. No geometric-neighbor links are added.
*/
export function selectDetourAnchorComponents(topology, anchorMappings, {
limits = DEFAULT_LIMITS,
surfaceBucketReferenceCount = null,
} = {}) {
if (!Array.isArray(anchorMappings) || anchorMappings.length === 0) {
throw new Error("Detour component selection requires at least one source anchor.");
}
const activeLimits = { ...DEFAULT_LIMITS, ...limits };
for (const [name, value] of Object.entries(activeLimits)) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`Detour selection limit ${name} must be a positive integer.`);
}
}
const { parents, polygonById } = weakComponentData(topology);
const anchors = anchorMappings.map((anchor) => {
validateAnchor(anchor);
const polygonId = anchor.polygonId;
const distance = anchor.distance;
const mapped = Number.isInteger(polygonId) && polygonId >= 0 && polygonId < polygonById.length;
const accepted = mapped && Number.isFinite(distance) && distance >= 0 && distance <= anchor.maximumDistance;
return {
id: anchor.id,
kind: anchor.kind ?? null,
sourcePosition: anchor.sourcePosition ?? null,
evidence: anchor.evidence ?? null,
required: anchor.required === true,
position: anchor.position ?? null,
sourceSpawnId: anchor.sourceSpawnId ?? null,
sourceFile: anchor.sourceFile ?? null,
maximumDistance: anchor.maximumDistance,
polygonId: mapped ? polygonId : null,
distance: Number.isFinite(distance) && distance >= 0 ? distance : null,
nearestPoint: anchor.nearestPoint ?? null,
componentId: mapped ? parents[polygonId] : null,
retained: accepted,
reason: accepted ? null : mapped
? "nearest-detour-surface-exceeds-source-projection-limit"
: anchor.reason ?? "no-detour-surface-within-source-projection-limit",
};
});
const failedRequiredAnchors = anchors.filter((anchor) => anchor.required && !anchor.retained);
if (failedRequiredAnchors.length) {
throw new Error(
`Required source anchors exceed their Detour projection limits: ${failedRequiredAnchors.map((anchor) => (
`${anchor.id} (${anchor.distance ?? "unresolved"}m > ${anchor.maximumDistance}m)`
)).join(", ")}.`,
);
}
const selectedComponentIds = new Set(
anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.componentId),
);
if (selectedComponentIds.size === 0) {
throw new Error("No source anchors map to Detour surfaces within their explicit projection limits.");
}
const selectedSourcePolygons = topology.polygons.filter(([id]) => selectedComponentIds.has(parents[id]));
const sourceToSelectedPolygonId = new Map();
let nextTriangle = 0;
let polygonFanTriangleCount = 0;
const polygons = selectedSourcePolygons.map((sourcePolygon, polygonId) => {
const [sourceId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, , triangleCount, vertexCount] = sourcePolygon;
sourceToSelectedPolygonId.set(sourceId, polygonId);
nextTriangle += triangleCount;
polygonFanTriangleCount += Math.max(0, vertexCount - 2);
return [polygonId, tileIndex, tileX, tileY, tileLayer, tilePolygonIndex, nextTriangle - triangleCount, triangleCount, vertexCount];
});
const links = topology.links
.filter(([from, to]) => sourceToSelectedPolygonId.has(from) && sourceToSelectedPolygonId.has(to))
.map(([from, to, ...details]) => [sourceToSelectedPolygonId.get(from), sourceToSelectedPolygonId.get(to), ...details]);
const selectedIdSet = new Set(sourceToSelectedPolygonId.keys());
const excludedSourcePolygons = topology.polygons.filter(([id]) => !selectedIdSet.has(id));
const excludedSourceLinks = topology.links.filter(([from, to]) => !selectedIdSet.has(from) || !selectedIdSet.has(to));
const selectedComponentRecords = [...selectedComponentIds].map((componentId) => {
const componentPolygons = topology.polygons.filter(([id]) => parents[id] === componentId);
const componentIds = new Set(componentPolygons.map(([id]) => id));
return {
sourceComponentId: componentId,
polygonCount: componentPolygons.length,
detailTriangleCount: componentPolygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: topology.links.filter(([from, to]) => componentIds.has(from) && componentIds.has(to)).length,
anchorIds: anchors.filter((anchor) => anchor.retained && anchor.componentId === componentId).map((anchor) => anchor.id),
};
}).sort((left, right) => right.polygonCount - left.polygonCount);
const outputTopology = {
...topology,
polygonCount: polygons.length,
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
polygonFanTriangleCount,
geometryTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: links.length,
tileCount: new Set(polygons.map((polygon) => polygon[1])).size,
weakComponents: weakComponentSummary(polygons.length, links),
polygons,
links,
sourceAnchorSelection: {
schemaVersion: 1,
policy: "retain-complete-detour-weak-components-with-source-anchors-within-explicit-projection-limits",
sourceCoordinatePositionsOnly: true,
noInferredLinks: true,
limits: activeLimits,
anchors,
selected: {
componentCount: selectedComponentIds.size,
polygonCount: polygons.length,
directedLinkCount: links.length,
detailTriangleCount: polygons.reduce((sum, polygon) => sum + polygon[7], 0),
surfaceBucketReferenceCount,
},
selectedComponents: selectedComponentRecords,
excluded: {
weakComponentCount: Math.max(0, topology.weakComponents?.count - selectedComponentIds.size),
polygonCount: excludedSourcePolygons.length,
detailTriangleCount: excludedSourcePolygons.reduce((sum, polygon) => sum + polygon[7], 0),
directedLinkCount: excludedSourceLinks.length,
disposition: "unanchored-components-excluded; not individually proven decorative",
},
},
};
const surfaceReferences = surfaceBucketReferenceCount;
const actualCounts = {
polygons: polygons.length,
directedLinks: links.length,
detailTriangles: outputTopology.detailTriangleCount,
surfaceBucketReferences: surfaceReferences,
};
for (const key of ["polygons", "directedLinks", "detailTriangles"]) {
if (actualCounts[key] > activeLimits[key]) {
throw new Error(`Source-anchored Detour subset exceeds the ${key} budget (${actualCounts[key]} > ${activeLimits[key]}).`);
}
}
if (surfaceReferences !== null && surfaceReferences > activeLimits.surfaceBucketReferences) {
throw new Error(
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
+ `(${surfaceReferences} > ${activeLimits.surfaceBucketReferences}).`,
);
}
const retainedAnchorIds = anchors.filter((anchor) => anchor.retained).map((anchor) => anchor.id);
const unmatchedAnchors = anchors.filter((anchor) => !anchor.retained);
return {
topology: outputTopology,
sourceToSelectedPolygonId,
selectedSourcePolygonIds: selectedIdSet,
selectedSourceComponentIds: selectedComponentIds,
summary: {
source: {
polygonCount: topology.polygonCount,
directedLinkCount: topology.directedLinkCount,
detailTriangleCount: topology.detailTriangleCount,
weakComponentCount: topology.weakComponents?.count ?? null,
},
selected: {
componentCount: selectedComponentIds.size,
polygonCount: actualCounts.polygons,
directedLinkCount: actualCounts.directedLinks,
detailTriangleCount: actualCounts.detailTriangles,
surfaceBucketReferenceCount: actualCounts.surfaceBucketReferences,
},
excluded: outputTopology.sourceAnchorSelection.excluded,
limits: activeLimits,
retainedAnchorIds,
unmatchedAnchors,
components: selectedComponentRecords,
},
};
}
export function countSurfaceBucketReferences(positions, indices, bucketSize = 8) {
if (!positions || !indices || positions.length % 3 !== 0 || indices.length % 3 !== 0
|| !Number.isFinite(bucketSize) || bucketSize <= 0) {
throw new Error("Surface bucket counting requires XYZ positions, triangle indices, and positive bucket size.");
}
let references = 0;
for (let offset = 0; offset < indices.length; offset += 3) {
const points = [0, 1, 2].map((corner) => {
const index = indices[offset + corner];
if (!Number.isInteger(index) || index < 0 || index * 3 + 2 >= positions.length) {
throw new Error(`Surface bucket triangle index ${index} is invalid.`);
}
return [positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]];
});
const minCellX = Math.floor(Math.min(...points.map((point) => point[0])) / bucketSize);
const maxCellX = Math.floor(Math.max(...points.map((point) => point[0])) / bucketSize);
const minCellZ = Math.floor(Math.min(...points.map((point) => point[2])) / bucketSize);
const maxCellZ = Math.floor(Math.max(...points.map((point) => point[2])) / bucketSize);
references += (maxCellX - minCellX + 1) * (maxCellZ - minCellZ + 1);
if (!Number.isSafeInteger(references)) throw new Error("Surface bucket reference count is not a safe integer.");
}
return references;
}
export const DETOUR_COMPONENT_SELECTION_LIMITS = DEFAULT_LIMITS;
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
countSurfaceBucketReferences,
selectDetourAnchorComponents,
} from "./detour-component-selection.mjs";
function topologyFixture() {
const polygons = [
[0, 0, 0, 0, 0, 0, 0, 2, 4],
[1, 0, 0, 0, 0, 1, 2, 1, 3],
[2, 1, 1, 0, 0, 0, 3, 3, 4],
[3, 2, 2, 0, 0, 0, 6, 1, 3],
];
const link = (from, to, edge) => [from, to, edge, 0, 0, 255, 0, 1, from, 0, 0, to, 0, 0];
return {
schemaVersion: 1,
polygonCount: 4,
tileCount: 3,
detailTriangleCount: 7,
geometryTriangleCount: 7,
polygonFanTriangleCount: 5,
directedLinkCount: 2,
unrepresentedLinkCount: 0,
offMeshPolygonCount: 0,
weakComponents: { count: 3, largestPolygonCount: 2, polygonCounts: [2, 1, 1] },
polygons,
links: [link(0, 2, 0), link(2, 0, 1)],
};
}
test("source anchors retain complete weak Detour components and remap only exact source links", () => {
const selected = selectDetourAnchorComponents(topologyFixture(), [
{ id: "entry", kind: "entry", position: [0, 0, 0], polygonId: 0, distance: 0.2, maximumDistance: 0.5, required: true },
{ id: "boss", kind: "boss", position: [100, 0, 0], polygonId: 3, distance: 0.1, maximumDistance: 0.25, required: true },
{ id: "far-combat", kind: "combat", position: [50, 0, 0], polygonId: 1, distance: 7, maximumDistance: 4 },
]);
assert.deepEqual([...selected.selectedSourcePolygonIds], [0, 2, 3]);
assert.deepEqual([...selected.sourceToSelectedPolygonId], [[0, 0], [2, 1], [3, 2]]);
assert.equal(selected.topology.polygonCount, 3);
assert.equal(selected.topology.directedLinkCount, 2);
assert.equal(selected.topology.detailTriangleCount, 6);
assert.deepEqual(selected.topology.polygons.map((row) => row.slice(0, 8)), [
[0, 0, 0, 0, 0, 0, 0, 2],
[1, 1, 1, 0, 0, 0, 2, 3],
[2, 2, 2, 0, 0, 0, 5, 1],
]);
assert.deepEqual(selected.topology.links.map((row) => row.slice(0, 2)), [[0, 1], [1, 0]]);
assert.equal(selected.summary.retainedAnchorIds.includes("entry"), true);
assert.equal(selected.summary.retainedAnchorIds.includes("boss"), true);
assert.equal(selected.summary.unmatchedAnchors[0].id, "far-combat");
assert.equal(selected.summary.excluded.polygonCount, 1);
assert.equal(selected.summary.excluded.disposition, "unanchored-components-excluded; not individually proven decorative");
});
test("selection fails closed when an anchored component exceeds any runtime budget", () => {
const source = topologyFixture();
assert.throws(() => selectDetourAnchorComponents(source, [
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
], { limits: { polygons: 1 } }), /polygons budget/);
assert.throws(() => selectDetourAnchorComponents(source, [
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
], { limits: { directedLinks: 1 } }), /directedLinks budget/);
assert.throws(() => selectDetourAnchorComponents(source, [
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
], { limits: { detailTriangles: 1 } }), /detailTriangles budget/);
assert.throws(() => selectDetourAnchorComponents(source, [
{ id: "entry", polygonId: 0, distance: 0, maximumDistance: 1 },
], { limits: { surfaceBucketReferences: 3 }, surfaceBucketReferenceCount: 4 }), /surfaceBucketReferences budget/);
});
test("source anchors outside their explicit projection limit do not select components", () => {
assert.throws(() => selectDetourAnchorComponents(topologyFixture(), [
{ id: "unprojected", polygonId: 2, distance: 4.001, maximumDistance: 4 },
]), /No source anchors map/);
});
test("surface bucket references match runtime's XZ cell coverage rule", () => {
const positions = new Float32Array([
0, 0, 0,
8, 0, 0,
0, 0, 8,
-0.1, 0, -0.1,
]);
const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
assert.equal(countSurfaceBucketReferences(positions, indices, 8), 10);
});
@@ -14,6 +14,10 @@ import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMateria
import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality";
import { setDungeonShadowLightPose } from "../../src/scene/dungeonShadowLightRuntime";
import type { ShadowQuality } from "../../src/game/graphicsSettings";
import { cacheStaticMatrices, instanceRepeatedStaticMeshes } from "../../src/scene/staticSceneInstancing";
import { createStaticVisibilityCells, registerStaticVisibility } from "../../src/scene/staticSceneVisibility";
import { materialTextures } from "../../src/scene/threeResourceDisposal";
import { configureWowTerrainLoader } from "../../src/scene/wowTerrainMaterial";
// Isolated asset QA uses shipping assets and the production material/light helpers.
// It intentionally does not claim gameplay, collision or encounter certification.
@@ -59,6 +63,7 @@ const controls = new OrbitControls(camera, renderer.domElement);
const loader = new GLTFLoader();
const decoder = new KTX2Loader().setTranscoderPath("/basis/").detectSupport(renderer);
loader.setKTX2Loader(decoder).setMeshoptDecoder(MeshoptDecoder);
configureWowTerrainLoader(loader);
const root = new THREE.Group();
root.position.fromArray(definition.transform.position);
root.rotation.fromArray([...definition.transform.rotation, "XYZ"]);
@@ -66,20 +71,49 @@ root.scale.setScalar(definition.transform.scale);
scene.add(root);
const assets = resolveDungeonAssets(definition);
if (assets.visual.length !== definition.assets.visual.length) throw new Error(`Missing shipping visual for ${id}`);
const visualOverride = params.get("visualOverride");
const runtimePreparation = params.get("runtimePreparation") === "1";
const preparationReports: { batches: number; instances: number; removedMeshes: number; visibilityCells: number }[] = [];
if (visualOverride && (assets.visual.length !== 1
|| !/^\/artifacts\/dungeon-pipeline\/[a-zA-Z0-9_./-]+\.glb$/.test(visualOverride)
|| visualOverride.includes(".."))) {
throw new Error("A review override must be a single GLB in artifacts/dungeon-pipeline.");
}
for (const url of assets.visual) {
const reviewUrl = id === "shadowfang-keep" && params.get("stage") === "before-shadowfang"
const reviewUrl = visualOverride ?? (id === "shadowfang-keep" && params.get("stage") === "before-shadowfang"
? "/artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb"
: id === "shadowfang-keep" && params.get("stage") === "repacked-shadowfang"
? "/artifacts/dungeon-pipeline/shadowfang-keep-visual.reoptimized-repacked.glb"
: id === "shadowfang-keep" && params.get("stage") === "selective-shadowfang"
? "/artifacts/dungeon-pipeline/shadowfang-keep-selective-error-0012.glb" : url;
? "/artifacts/dungeon-pipeline/shadowfang-keep-selective-error-0012.glb" : url);
const gltf = await loader.loadAsync(reviewUrl);
const preparedMaterials = new Map<string, THREE.Material>();
const prepare = (material: THREE.Material) => {
if (runtimePreparation && preparedMaterials.has(material.uuid)) return preparedMaterials.get(material.uuid)!;
const next = prepareDungeonMaterial(material, p.materials, definition.environmentMode);
preparedMaterials.set(material.uuid, next);
return next;
};
gltf.scene.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
node.material = Array.isArray(node.material)
? node.material.map((material) => prepareDungeonMaterial(material, p.materials, definition.environmentMode))
: prepareDungeonMaterial(node.material, p.materials, definition.environmentMode);
? node.material.map(prepare) : prepare(node.material);
if (runtimePreparation) {
node.castShadow = false;
node.receiveShadow = true;
}
if (runtimePreparation && node instanceof THREE.InstancedMesh) {
node.computeBoundingBox();
node.computeBoundingSphere();
}
});
if (runtimePreparation) {
const instancing = instanceRepeatedStaticMeshes(gltf.scene, 3, undefined, definition.id === "scholomance");
const cells = createStaticVisibilityCells(gltf.scene);
cacheStaticMatrices(gltf.scene);
registerStaticVisibility(scene, cells);
preparationReports.push({ ...instancing, visibilityCells: cells.length });
}
root.add(gltf.scene);
}
let quality: ShadowQuality = "standard";
@@ -122,14 +156,42 @@ function diagnostics() {
meshes++;
if (node.castShadow) casters++;
for (const material of Array.isArray(node.material) ? node.material : [node.material]) {
const map = (material as THREE.MeshStandardMaterial).map;
if (map) textures.set(map.uuid, { name: map.name, width: map.image?.width, height: map.image?.height, anisotropy: map.anisotropy });
for (const texture of materialTextures(material)) {
textures.set(texture.uuid, { name: texture.name, width: texture.image?.width,
height: texture.image?.height, anisotropy: texture.anisotropy });
}
}
});
return { meshes, casters, textures: [...textures.values()], drawCalls: renderer.info.render.calls,
return { meshes, casters, textures: [...textures.values()], runtimePreparation, preparationReports, drawCalls: renderer.info.render.calls,
renderedTriangles: renderer.info.render.triangles, geometries: renderer.info.memory.geometries,
lightTarget: light.target.position.toArray(), camera: camera.position.toArray(), maxAnisotropy: renderer.capabilities.getMaxAnisotropy() };
}
function setCamera(eye: [number, number, number], target: [number, number, number]) {
camera.position.fromArray(eye);
controls.target.fromArray(target);
controls.update();
}
function groundSamples() {
root.updateMatrixWorld(true);
const ray = new THREE.Raycaster();
return viewpoints.map((entry) => {
const origin = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 20, 0));
ray.set(origin, new THREE.Vector3(0, -1, 0));
ray.far = 80;
const seen = new Set<string>();
const hits = ray.intersectObject(root, true).flatMap((hit) => {
const mesh = hit.object as THREE.Mesh;
const material = (Array.isArray(mesh.material)
? mesh.material[hit.face?.materialIndex ?? 0] : mesh.material) as THREE.MeshStandardMaterial;
const key = `${mesh.uuid}:${hit.instanceId}:${Math.round(hit.point.y * 1000)}`;
if (seen.has(key)) return [];
seen.add(key);
return [{ mesh: mesh.name, material: material.name, textured: materialTextures(material).length > 0,
height: hit.point.y, heightAboveFoot: hit.point.y - entry.position[1], point: hit.point.toArray() }];
}).slice(0, 8);
return { name: entry.name, position: entry.position, hits };
});
}
view(0);
setQuality("standard");
let frames = 0;
@@ -148,5 +210,5 @@ renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
frames++;
});
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setQuality, diagnostics, resetFrameSample, frameSample, presentation: p,
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setCamera, setQuality, diagnostics, groundSamples, resetFrameSample, frameSample, presentation: p,
catalog: definitions.filter((entry) => !entry.id.includes("bite-sized")).map((entry) => ({ id: entry.id, title: entry.title })) } });
@@ -18,6 +18,7 @@ const defaultWowDbc = path.resolve(
);
const DAMAGE_EFFECTS = new Set([1, 2, 7, 9, 17, 31, 58, 62, 121]);
const WOW_HIT_ATTRIBUTE_FIELDS = Object.freeze({ attributes0: 4, attributes3: 7, attributes7: 11 });
const WOW_SCHOOL_BITS = [
[1, "physical"],
[2, "holy"],
@@ -89,13 +90,18 @@ function parseWowDamageRecords(buffer, wantedIds) {
name: readName(base),
damageClass,
schoolMask,
hitSource: {
attributes0: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes0),
attributes3: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes3),
attributes7: unsigned(WOW_HIT_ATTRIBUTE_FIELDS.attributes7),
},
schools,
...(schools.length === 1 ? { school: schools[0] } : {}),
...(damageClass === 1 ? { attackKind: "spell" }
: damageClass === 2 ? { attackKind: "melee" }
: damageClass === 3 ? { attackKind: "ranged" } : {}),
damageEffects: [...new Set(damageEffects)].sort((left, right) => left - right),
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 213/225",
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 4/7/11/213/225",
});
}
return { records: results, recordCount, fieldCount, recordSize };
@@ -213,16 +219,20 @@ function romRecord(spell) {
function parseArguments(argv) {
let wowDbc = defaultWowDbc;
let outputPath = outputFile;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--wow-dbc") {
wowDbc = path.resolve(argv[index + 1] ?? "");
index += 1;
} else if (argv[index] === "--output") {
outputPath = path.resolve(argv[index + 1] ?? "");
index += 1;
}
}
return { wowDbc };
return { wowDbc, outputPath };
}
const { wowDbc } = parseArguments(process.argv.slice(2));
const { wowDbc, outputPath } = parseArguments(process.argv.slice(2));
const [campaignBytes, bossMechanicsBytes, runewakerSpellBytes, wowDbcBytes] = await Promise.all([
readFile(campaignFile),
readFile(bossMechanicsFile),
@@ -247,6 +257,7 @@ const output = {
runewakerNpcSpellCatalogSha256: sha256(runewakerSpellBytes),
wowRecordLayout: `${wow.fieldCount} fields; ${wow.recordSize} byte rows`,
wowSpellDamageFields: { damageClass: 213, schoolMask: 225, effects: [71, 72, 73] },
wowSpellHitFields: WOW_HIT_ATTRIBUTE_FIELDS,
runewakerCalculationTypePolicy: {
physical: [0, 3, 9],
magical: [1, 10],
@@ -280,6 +291,6 @@ const output = {
.map(([spellId, record]) => [String(spellId), record])),
runewaker: Object.fromEntries(Object.entries(runewaker).sort(([left], [right]) => Number(left) - Number(right))),
};
await writeFile(outputFile, JSON.stringify(output, null, 2) + "\n", "utf8");
await writeFile(outputPath, JSON.stringify(output, null, 2) + "\n", "utf8");
console.log(`[enemy-damage-types] exported ${output.counts.wowExportedDamageSpells} WoW and ${output.counts.runewakerExportedDamageSpells} RuneWaker source spells.`);
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputFile)}`);
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputPath)}`);
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
const scriptPath = fileURLToPath(new URL("./export-enemy-damage-types.mjs", import.meta.url));
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
function spellDbcFixture(rows) {
const fieldCount = 234;
const recordSize = fieldCount * 4;
const output = Buffer.alloc(20 + rows.length * recordSize);
output.write("WDBC", 0, "ascii");
output.writeUInt32LE(rows.length, 4);
output.writeUInt32LE(fieldCount, 8);
output.writeUInt32LE(recordSize, 12);
output.writeUInt32LE(0, 16);
rows.forEach(({ spellId, attributes0, attributes3, attributes7 }, index) => {
const base = 20 + index * recordSize;
const setField = (field, value) => output.writeUInt32LE(value >>> 0, base + field * 4);
setField(0, spellId);
setField(4, attributes0);
setField(5, 0x1111_1111);
setField(6, 0x2222_2222);
setField(7, attributes3);
setField(8, 0x3333_3333);
setField(10, 0x4444_4444);
setField(11, attributes7);
setField(71, 2);
setField(213, 1);
setField(225, 16);
});
return output;
}
test("enemy damage exporter reads raw Attributes0/3/7 from pinned Spell.dbc field indexes", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "healer-enemy-hit-source-"));
const dbcPath = path.join(tempRoot, "Spell.dbc");
const outputPath = path.join(tempRoot, "enemyDamageTypeSources.json");
try {
await writeFile(dbcPath, spellDbcFixture([
{ spellId: 38_163, attributes0: 0x1234_5678, attributes3: 0x0004_0000, attributes7: 0x0200_0000 },
{ spellId: 122, attributes0: 0x0020_0000, attributes3: 0, attributes7: 0x0100_0000 },
]));
const run = spawnSync(process.execPath, [
scriptPath,
"--wow-dbc", dbcPath,
"--output", outputPath,
], { cwd: projectRoot, encoding: "utf8" });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const generated = JSON.parse(await readFile(outputPath, "utf8"));
assert.deepEqual(generated.generatedFrom.wowSpellHitFields, {
attributes0: 4,
attributes3: 7,
attributes7: 11,
});
assert.deepEqual(generated.wow335["38163"].hitSource, {
attributes0: 0x1234_5678,
attributes3: 0x0004_0000,
attributes7: 0x0200_0000,
});
assert.deepEqual(generated.wow335["122"].hitSource, {
attributes0: 0x0020_0000,
attributes3: 0,
attributes7: 0x0100_0000,
});
assert.equal(Object.hasOwn(generated.wow335["122"].hitSource, "alwaysHit"), false);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,265 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
function chunkId(buffer, offset) {
return Buffer.from(buffer.subarray(offset, offset + 4)).reverse().toString("ascii");
}
function readChunkList(buffer, start, end, label) {
const chunks = [];
let offset = start;
while (offset + 8 <= end) {
const id = chunkId(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
const chunkEnd = dataOffset + size;
if (chunkEnd > end) throw new Error(`${label}: ${id} at ${offset} extends beyond its container.`);
chunks.push({ id, offset, dataOffset, size, end: chunkEnd });
offset = chunkEnd;
}
if (offset !== end) throw new Error(`${label}: ${end - offset} trailing bytes cannot form a chunk header.`);
return chunks;
}
function readSubchunk(buffer, mcnk, relativeOffset, label) {
if (!relativeOffset) return null;
const offset = mcnk.offset + relativeOffset;
if (offset + 8 > buffer.length) throw new Error(`${label}: subchunk offset ${relativeOffset} leaves the ADT.`);
const id = chunkId(buffer, offset);
const size = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
const end = dataOffset + size;
if (end > buffer.length) throw new Error(`${label}: ${id} subchunk leaves the ADT.`);
return { id, offset, dataOffset, size, end, withinMcnk: end <= mcnk.end };
}
function textureNames(buffer, mtex) {
if (!mtex) return [];
const value = buffer.toString("utf8", mtex.dataOffset, mtex.end);
return value.split("\0").filter(Boolean).map((name) => name.replaceAll("/", "\\"));
}
function hash(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
export function parseWowAdtTerrain(buffer, { label = "ADT" } = {}) {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const chunks = readChunkList(buffer, 0, buffer.length, label);
if (chunks[0]?.id !== "MVER") throw new Error(`${label}: missing MVER first chunk.`);
const mtex = chunks.find((chunk) => chunk.id === "MTEX");
const textures = textureNames(buffer, mtex);
const mcnks = chunks.filter((chunk) => chunk.id === "MCNK");
const terrainChunks = mcnks.map((mcnk, ordinal) => {
if (mcnk.size < 128) throw new Error(`${label}: MCNK ${ordinal} is shorter than its 128-byte header.`);
const data = mcnk.dataOffset;
const flags = buffer.readUInt32LE(data);
const indexX = buffer.readUInt32LE(data + 4);
const indexY = buffer.readUInt32LE(data + 8);
const layerCount = buffer.readUInt32LE(data + 12);
const height = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 20), `${label} MCNK ${ordinal} MCVT`);
const layersChunk = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 28), `${label} MCNK ${ordinal} MCLY`);
const alphaChunkHeader = readSubchunk(buffer, mcnk, buffer.readUInt32LE(data + 36), `${label} MCNK ${ordinal} MCAL`);
const alphaSizeHeader = buffer.readUInt32LE(data + 40);
// A subset of MVER 18 ADTs has an MCAL FourCC followed by a zero chunk
// size, while the enclosing MCNK header carries the complete MCAL size.
// The bytes after the eight-byte subchunk header are still the source map.
const alphaChunk = alphaChunkHeader?.id === "MCAL" && alphaChunkHeader.size === 0 && alphaSizeHeader > 8
? {
...alphaChunkHeader,
size: alphaSizeHeader - 8,
end: alphaChunkHeader.dataOffset + alphaSizeHeader - 8,
withinMcnk: alphaChunkHeader.dataOffset + alphaSizeHeader - 8 <= mcnk.end,
sizeSource: "mcnk-header",
}
: alphaChunkHeader && { ...alphaChunkHeader, sizeSource: "mcal-subchunk-header" };
const worldHeight = buffer.readFloatLE(data + 104);
const worldX = buffer.readFloatLE(data + 108);
const worldY = buffer.readFloatLE(data + 112);
const holeMask = buffer.readUInt16LE(data + 60);
const lowResolutionTextureMap = Array.from({ length: 8 }, (_, index) => buffer.readUInt16LE(data + 64 + index * 2));
if (layerCount && (!layersChunk || layersChunk.id !== "MCLY" || layersChunk.size < layerCount * 16)) {
throw new Error(`${label}: MCNK ${ordinal} layer header does not match its ${layerCount} layers.`);
}
if (height && height.id !== "MCVT") throw new Error(`${label}: MCNK ${ordinal} height offset points to ${height.id}.`);
if (alphaChunk && alphaChunk.id !== "MCAL") throw new Error(`${label}: MCNK ${ordinal} alpha offset points to ${alphaChunk.id}.`);
const layers = Array.from({ length: layerCount }, (_, index) => {
const offset = layersChunk.dataOffset + index * 16;
const textureIndex = buffer.readUInt32LE(offset);
const layerFlags = buffer.readUInt32LE(offset + 4);
const alphaOffset = buffer.readUInt32LE(offset + 8);
const effectId = buffer.readUInt32LE(offset + 12);
const usesAlphaMap = (layerFlags & 0x100) !== 0;
const alphaMapCompressed = (layerFlags & 0x200) !== 0;
if (textureIndex >= textures.length) {
throw new Error(`${label}: MCNK ${ordinal} layer ${index} references MTEX index ${textureIndex}/${textures.length}.`);
}
return {
index,
recordOffset: offset,
textureIndex,
texture: textures[textureIndex],
flags: layerFlags,
usesAlphaMap,
alphaMapCompressed,
alphaMapAvailable: false,
alphaMapEncoding: null,
alphaMapBytes: 0,
alphaMapDataOffset: null,
alphaOffset: usesAlphaMap ? alphaOffset : null,
effectId,
};
});
for (const layer of layers) {
if (!layer.usesAlphaMap || !alphaChunk) continue;
const laterAlphaLayer = layers.find((candidate) => candidate.usesAlphaMap && candidate.index > layer.index);
const alphaBytes = laterAlphaLayer
? laterAlphaLayer.alphaOffset - layer.alphaOffset
: alphaChunk.size - layer.alphaOffset;
const alphaMapDataOffset = alphaChunk.dataOffset + layer.alphaOffset;
const encoding = layer.alphaMapCompressed
? "rle-8"
: alphaBytes === 4096
? "uncompressed-8"
: alphaBytes === 2048
? "packed-4"
: "unsupported";
const alphaMapAvailable = Boolean(
alphaChunk.withinMcnk
&& alphaChunk.size + 8 === buffer.readUInt32LE(data + 40)
&& layer.alphaOffset >= 0
&& alphaBytes > 0
&& alphaMapDataOffset + alphaBytes <= alphaChunk.end
&& encoding !== "unsupported",
);
layer.alphaMapBytes = Math.max(0, alphaBytes);
layer.alphaMapDataOffset = alphaMapDataOffset;
layer.alphaMapEncoding = encoding;
layer.alphaMapAvailable = alphaMapAvailable;
}
const heightData = height?.id === "MCVT" ? buffer.subarray(height.dataOffset, height.end) : null;
return {
ordinal,
indexX,
indexY,
flags,
holeMask,
highResolutionHoles: (flags & 0x10000) !== 0,
lowResolutionTextureMap,
world: { x: worldX, y: worldY, height: worldHeight },
sourceOffsets: {
mcnk: mcnk.offset,
mcvt: height?.dataOffset ?? null,
mcly: layersChunk?.dataOffset ?? null,
mcal: alphaChunk?.dataOffset ?? null,
},
heightSha256: heightData ? hash(heightData) : null,
heightBytes: height?.size ?? 0,
heightSamples: height?.id === "MCVT" && height.size % 4 === 0
? Array.from({ length: height.size / 4 }, (_, index) => buffer.readFloatLE(height.dataOffset + index * 4))
: null,
alphaBytes: alphaChunk?.size ?? 0,
alphaChunkWithinMcnk: alphaChunk?.withinMcnk ?? null,
alphaSizeHeader,
alphaChunkHeaderSize: alphaChunkHeader?.size ?? null,
alphaChunkSizeSource: alphaChunk?.sizeSource ?? null,
// In WotLK MCNK headers sizeMCAL includes the 8-byte MCAL chunk header.
// MVER 18 zero-sized MCAL headers use that enclosing span as the source.
alphaSizeMatchesHeader: alphaChunk ? alphaChunk.size + 8 === buffer.readUInt32LE(data + 40) : buffer.readUInt32LE(data + 40) === 0,
materialLayerChunkWithinMcnk: layersChunk?.withinMcnk ?? null,
layers,
};
});
const layerReferences = new Map();
for (const chunk of terrainChunks) {
for (const layer of chunk.layers) {
const existing = layerReferences.get(layer.texture);
if (existing) existing.count += 1;
else layerReferences.set(layer.texture, { texture: layer.texture, count: 1 });
}
}
return {
sha256: createHash("sha256").update(buffer).digest("hex"),
bytes: buffer.length,
topLevelChunks: chunks.map(({ id, size }) => ({ id, size })),
textureTableCount: textures.length,
textures,
mcnkCount: terrainChunks.length,
chunks: terrainChunks,
layerReferences: [...layerReferences.values()].sort((left, right) => left.texture.localeCompare(right.texture)),
layers: terrainChunks.reduce((count, chunk) => count + chunk.layers.length, 0),
alphaMappedLayers: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap).length, 0),
alphaMapDataAvailable: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.alphaMapAvailable).length, 0),
alphaMapDataMissing: terrainChunks.reduce((count, chunk) => count + chunk.layers.filter((layer) => layer.usesAlphaMap && !layer.alphaMapAvailable).length, 0),
alphaChunksOutOfBounds: terrainChunks.filter((chunk) => chunk.alphaChunkWithinMcnk === false).length,
alphaSizeHeaderMismatches: terrainChunks.filter((chunk) => chunk.alphaSizeMatchesHeader === false).length,
};
}
export function inspectObjMaterialContract(file) {
const obj = fs.readFileSync(file, "utf8");
const materialFile = file.replace(/\.obj$/i, ".mtl");
const mtl = fs.existsSync(materialFile) ? fs.readFileSync(materialFile, "utf8") : "";
const uvLines = obj.split(/\r?\n/).filter((line) => line.startsWith("vt "));
const uniqueUvs = new Set(uvLines.map((line) => line.trim()));
const usedMaterials = [...new Set([...obj.matchAll(/^usemtl\s+(.+)$/gm)].map((match) => match[1].trim()))];
return {
obj: path.basename(file),
mtl: path.basename(materialFile),
vertexUvCount: uvLines.length,
uniqueVertexUvs: uniqueUvs.size,
usedMaterials,
mtlHasDiffuseMap: /^\s*map_Kd\s+/im.test(mtl),
mtlHasTextureName: /\.blp|\.png|\.jpg|\.dds/i.test(mtl),
};
}
export function inspectWowAdtTerrainExport(sourceExportDir) {
const rawRoot = path.join(sourceExportDir, "raw", "world", "maps");
if (!fs.existsSync(rawRoot)) throw new Error(`Missing raw ADT directory: ${rawRoot}`);
const adtFiles = [];
const walk = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const child = path.join(directory, entry.name);
if (entry.isDirectory()) walk(child);
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".adt")) adtFiles.push(child);
}
};
walk(rawRoot);
if (!adtFiles.length) throw new Error(`No raw ADT files found under ${rawRoot}`);
const terrainFiles = adtFiles.sort().map((file) => {
const parsed = parseWowAdtTerrain(fs.readFileSync(file), { label: file });
const basename = path.basename(file).replace(/\.adt$/i, "");
const coordinates = basename.match(/_(\d+)_(\d+)$/);
const exporterObj = coordinates
? path.join(sourceExportDir, `adt_${coordinates[1]}_${coordinates[2]}.obj`)
: null;
return {
file: path.relative(sourceExportDir, file).replaceAll("\\", "/"),
...parsed,
exporterContract: exporterObj && fs.existsSync(exporterObj) ? inspectObjMaterialContract(exporterObj) : null,
};
});
return {
sourceExportDir: path.resolve(sourceExportDir),
tileCount: terrainFiles.length,
mcnkCount: terrainFiles.reduce((sum, tile) => sum + tile.mcnkCount, 0),
layerCount: terrainFiles.reduce((sum, tile) => sum + tile.layers, 0),
alphaMappedLayerCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMappedLayers, 0),
alphaMapDataAvailableCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataAvailable, 0),
alphaMapDataMissingCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaMapDataMissing, 0),
alphaChunksOutOfBoundsCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaChunksOutOfBounds, 0),
alphaSizeHeaderMismatchCount: terrainFiles.reduce((sum, tile) => sum + tile.alphaSizeHeaderMismatches, 0),
maxLayerCountPerChunk: Math.max(0, ...terrainFiles.flatMap((tile) => tile.chunks.map((chunk) => chunk.layers.length))),
referencedTextures: [...new Set(terrainFiles.flatMap((tile) => tile.textures))].sort((a, b) => a.localeCompare(b)),
tiles: terrainFiles,
};
}
@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseWowAdtTerrain } from "./wow-adt-terrain-audit.mjs";
import { decodeWowAdtAlphaMap } from "./wow-adt-terrain-material.mjs";
function chunk(name, payload) {
const header = Buffer.alloc(8);
Buffer.from(name, "ascii").reverse().copy(header);
header.writeUInt32LE(payload.length, 4);
return Buffer.concat([header, payload]);
}
function rleFill(value) {
const bytes = [];
let remaining = 4096;
while (remaining) {
const count = Math.min(127, remaining);
bytes.push(0x80 | count, value);
remaining -= count;
}
return Buffer.from(bytes);
}
function zeroSizedMcalAdt({ declaredPayloadBytes = null } = {}) {
const mtex = Buffer.from("Base.blp\0Overlay.blp\0", "utf8");
const layers = Buffer.alloc(32);
layers.writeUInt32LE(0, 0);
layers.writeUInt32LE(0, 4);
layers.writeUInt32LE(1, 16);
layers.writeUInt32LE(0x300, 20);
layers.writeUInt32LE(0, 24);
const alpha = rleFill(91);
const payloadLength = declaredPayloadBytes ?? alpha.length;
const alphaSubchunk = Buffer.alloc(8 + alpha.length);
Buffer.from("MCAL", "ascii").reverse().copy(alphaSubchunk);
alphaSubchunk.writeUInt32LE(0, 4);
alpha.copy(alphaSubchunk, 8);
const mcnkHeader = Buffer.alloc(128);
mcnkHeader.writeUInt32LE(0, 4);
mcnkHeader.writeUInt32LE(0, 8);
mcnkHeader.writeUInt32LE(2, 12);
mcnkHeader.writeUInt32LE(136, 28);
mcnkHeader.writeUInt32LE(176, 36);
mcnkHeader.writeUInt32LE(8 + payloadLength, 40);
mcnkHeader.writeUInt16LE(0xa55a, 60);
mcnkHeader.writeUInt16LE(0x1234, 62);
mcnkHeader.writeUInt16LE(0xabcd, 64);
const mcnk = Buffer.concat([mcnkHeader, chunk("MCLY", layers), alphaSubchunk]);
const mver = Buffer.alloc(4);
mver.writeUInt32LE(18);
return Buffer.concat([chunk("MVER", mver), chunk("MTEX", mtex), chunk("MCNK", mcnk)]);
}
test("MVER 18 zero-sized MCAL headers use the enclosing MCNK size span", () => {
const source = zeroSizedMcalAdt();
const parsed = parseWowAdtTerrain(source, { label: "zero-sized MCAL fixture" });
const target = parsed.chunks[0];
const overlay = target.layers[1];
assert.equal(target.alphaChunkHeaderSize, 0);
assert.equal(target.alphaChunkSizeSource, "mcnk-header");
assert.equal(target.holeMask, 0xa55a);
assert.equal(target.highResolutionHoles, false);
assert.equal(target.lowResolutionTextureMap[0], 0xabcd);
assert.equal(target.alphaSizeMatchesHeader, true);
assert.equal(target.alphaChunkWithinMcnk, true);
assert.equal(overlay.alphaMapEncoding, "rle-8");
assert.equal(overlay.alphaMapAvailable, true);
assert.equal(overlay.alphaMapBytes, target.alphaBytes);
const decoded = decodeWowAdtAlphaMap(source, target, overlay);
assert.equal(decoded.length, 4096);
assert.equal(decoded[0], 91);
assert.equal(decoded[4095], 91);
});
test("MVER 18 MCAL spans still fail closed when the declared payload leaves MCNK", () => {
const source = zeroSizedMcalAdt({ declaredPayloadBytes: 0xffff });
const parsed = parseWowAdtTerrain(source, { label: "overrun MCAL fixture" });
const target = parsed.chunks[0];
assert.equal(target.alphaChunkWithinMcnk, false);
assert.equal(target.layers[1].alphaMapAvailable, false);
assert.throws(() => decodeWowAdtAlphaMap(source, target, target.layers[1]), /unsupported or invalid MCAL span/);
});
@@ -0,0 +1,121 @@
const WEIGHT_SIZE = 64 * 64;
function assertKnownAlphaLayer(layer, chunk) {
if (!layer.usesAlphaMap) return;
if (!layer.alphaMapAvailable || layer.alphaMapDataOffset == null || !layer.alphaMapBytes) {
throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index} has an unsupported or invalid MCAL span.`);
}
}
function decodeRle8(data, label) {
const output = new Uint8Array(WEIGHT_SIZE);
let inputOffset = 0;
let outputOffset = 0;
while (outputOffset < output.length) {
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL RLE stream.`);
const control = data[inputOffset++];
const count = control & 0x7f;
if (!count) throw new Error(`${label}: zero-count MCAL RLE command cannot progress.`);
if (outputOffset + count > output.length) throw new Error(`${label}: MCAL RLE expands beyond 4096 samples.`);
if (control & 0x80) {
if (inputOffset >= data.length) throw new Error(`${label}: truncated MCAL fill command.`);
output.fill(data[inputOffset++], outputOffset, outputOffset + count);
} else {
if (inputOffset + count > data.length) throw new Error(`${label}: truncated MCAL copy command.`);
output.set(data.subarray(inputOffset, inputOffset + count), outputOffset);
inputOffset += count;
}
outputOffset += count;
}
if (inputOffset !== data.length) throw new Error(`${label}: ${data.length - inputOffset} trailing MCAL RLE bytes are unaccounted for.`);
return output;
}
function decodePacked4(data, noEdgeRepair, label) {
if (data.length !== 2048) throw new Error(`${label}: packed 4-bit MCAL must be exactly 2048 bytes.`);
const output = new Uint8Array(WEIGHT_SIZE);
let source = 0;
for (let row = 0; row < 64; row += 1) {
for (let column = 0; column < 64; column += 2) {
const value = data[source++];
output[row * 64 + column] = (value & 0x0f) * 17;
output[row * 64 + column + 1] = (value >>> 4) * 17;
}
}
if (!noEdgeRepair) {
for (let index = 0; index < 64; index += 1) {
output[index * 64 + 63] = output[index * 64 + 62];
output[63 * 64 + index] = output[62 * 64 + index];
}
output[63 * 64 + 63] = output[62 * 64 + 62];
}
return output;
}
export function decodeWowAdtAlphaMap(adtBuffer, chunk, layer) {
assertKnownAlphaLayer(layer, chunk);
if (!layer.usesAlphaMap) return new Uint8Array(WEIGHT_SIZE);
const payload = adtBuffer.subarray(layer.alphaMapDataOffset, layer.alphaMapDataOffset + layer.alphaMapBytes);
const label = `MCNK ${chunk.indexX},${chunk.indexY} layer ${layer.index}`;
switch (layer.alphaMapEncoding) {
case "packed-4":
return decodePacked4(payload, (chunk.flags & 0x8000) !== 0, label);
case "uncompressed-8":
if (payload.length !== WEIGHT_SIZE) throw new Error(`${label}: 8-bit MCAL must be exactly 4096 bytes.`);
return new Uint8Array(payload);
case "rle-8":
return decodeRle8(payload, label);
default:
throw new Error(`${label}: unsupported alpha encoding ${layer.alphaMapEncoding ?? "missing"}.`);
}
}
/**
* Decode source overlays into the effective RGB weights expected by the
* wow-overlay-residual shader. Classic 4-bit maps are source opacity maps;
* their overlay order must be resolved before the shader consumes them.
*/
export function composeWowAdtTerrainWeights(adtBuffer, chunk) {
const overlays = chunk.layers.slice(1);
if (overlays.length > 3) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} has more than three overlay layers.`);
const encodings = [...new Set(overlays.filter((layer) => layer.usesAlphaMap).map((layer) => layer.alphaMapEncoding))];
if (encodings.length > 1) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} mixes MCAL alpha encodings.`);
const sourceEncoding = encodings[0] ?? "none";
const alphaMaps = overlays.map((layer) => decodeWowAdtAlphaMap(adtBuffer, chunk, layer));
const packed = sourceEncoding === "packed-4";
const effective = alphaMaps.map((map) => new Uint8Array(map));
if (packed) {
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
let remaining = 255;
for (let index = effective.length - 1; index >= 0; index -= 1) {
const weight = Math.floor((effective[index][pixel] * remaining + 127) / 255);
effective[index][pixel] = weight;
remaining -= weight;
}
}
}
let maxWeightSum = 0;
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
const sum = effective.reduce((value, map) => value + map[pixel], 0);
maxWeightSum = Math.max(maxWeightSum, sum);
if (sum > 255) throw new Error(`MCNK ${chunk.indexX},${chunk.indexY} effective overlays exceed 255 (${sum} at sample ${pixel}).`);
}
const rgba = Buffer.alloc(WEIGHT_SIZE * 4, 255);
for (let pixel = 0; pixel < WEIGHT_SIZE; pixel += 1) {
for (let channel = 0; channel < 3; channel += 1) rgba[pixel * 4 + channel] = effective[channel]?.[pixel] ?? 0;
}
return {
rgba,
sourceEncoding,
sourcePolicy: packed ? "classic-nibble-to-big-alpha" : "direct-8bit-overlay-weights",
channelLayerIndices: overlays.map((layer) => layer.index),
maxWeightSum,
};
}
export function wowAdtNormalizedUv(row, column) {
if (!Number.isInteger(row) || row < 0 || row > 16) throw new RangeError("MCVT row must be in [0,16].");
const columns = row % 2 ? 8 : 9;
if (!Number.isInteger(column) || column < 0 || column >= columns) throw new RangeError(`MCVT column must be in [0,${columns - 1}].`);
return [(column + (row % 2 ? 0.5 : 0)) / 8, row / 16];
}
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { composeWowAdtTerrainWeights, decodeWowAdtAlphaMap, wowAdtNormalizedUv } from "./wow-adt-terrain-material.mjs";
function layer(index, alphaMapEncoding, alphaMapDataOffset, alphaMapBytes, flags = 0x100) {
return {
index,
usesAlphaMap: (flags & 0x100) !== 0,
alphaMapCompressed: (flags & 0x200) !== 0,
alphaMapAvailable: true,
alphaMapEncoding,
alphaMapDataOffset,
alphaMapBytes,
alphaOffset: alphaMapDataOffset,
flags,
};
}
test("classic 4-bit maps expand and get deterministic edge repair", () => {
const bytes = Buffer.alloc(2048, 0x21);
const chunk = { indexX: 4, indexY: 7, flags: 0, layers: [] };
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
assert.equal(decoded[0], 17);
assert.equal(decoded[1], 34);
assert.equal(decoded[63], decoded[62]);
assert.equal(decoded[63 * 64], decoded[62 * 64]);
chunk.flags = 0x8000;
const untouched = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "packed-4", 0, 2048));
assert.equal(untouched[63], 34);
assert.equal(untouched[63 * 64], 17);
});
test("classic 4-bit opacity layers become ordered residual weights", () => {
const bytes = Buffer.alloc(4096, 0x88);
const chunk = {
indexX: 0,
indexY: 0,
flags: 0x8000,
layers: [
{ index: 0, usesAlphaMap: false },
layer(1, "packed-4", 0, 2048),
layer(2, "packed-4", 2048, 2048),
],
};
const weights = composeWowAdtTerrainWeights(bytes, chunk);
assert.equal(weights.sourcePolicy, "classic-nibble-to-big-alpha");
assert.deepEqual(weights.channelLayerIndices, [1, 2]);
assert.equal(weights.rgba[0], 63); // earlier overlay receives only remaining coverage
assert.equal(weights.rgba[1], 136); // last layer remains topmost
assert.equal(weights.rgba[2], 0);
assert.equal(weights.rgba[3], 255);
assert.equal(weights.maxWeightSum, 199);
});
test("compressed 8-bit MCAL fills exactly one 64x64 map", () => {
const packed = [];
let remaining = 4096;
while (remaining) {
const count = Math.min(127, remaining);
packed.push(0x80 | count, 37);
remaining -= count;
}
const bytes = Buffer.from(packed);
const chunk = { indexX: 1, indexY: 2, flags: 0x8000, layers: [] };
const decoded = decodeWowAdtAlphaMap(bytes, chunk, layer(1, "rle-8", 0, bytes.length, 0x300));
assert.equal(decoded.length, 4096);
assert.equal(decoded[0], 37);
assert.equal(decoded[4095], 37);
assert.throws(() => decodeWowAdtAlphaMap(Buffer.from([0x81]), chunk, layer(1, "rle-8", 0, 1, 0x300)), /truncated MCAL fill/);
});
test("normalized MCVT UVs follow source-positive local rows and stagger", () => {
assert.deepEqual(wowAdtNormalizedUv(0, 0), [0, 0]);
assert.deepEqual(wowAdtNormalizedUv(1, 0), [0.0625, 0.0625]);
assert.deepEqual(wowAdtNormalizedUv(16, 8), [1, 1]);
});
@@ -32,19 +32,25 @@ function compressKtx2(file) {
});
}
export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile) {
export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile, { preserveSpatialBoundaries = false } = {}) {
return [
"optimize",
sourceFile,
outputFile,
"--compress",
"meshopt",
"false",
"--meshopt-level",
"high",
"--instance",
"true",
preserveSpatialBoundaries ? "false" : "true",
"--instance-min",
"3",
"--flatten",
preserveSpatialBoundaries ? "false" : "true",
"--join",
preserveSpatialBoundaries ? "false" : "true",
"--weld",
preserveSpatialBoundaries ? "false" : "true",
"--simplify",
"false",
"--texture-compress",
@@ -53,7 +59,17 @@ export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile)
}
export function dungeonEnvironmentFinalPackingArguments(sourceFile, outputFile) {
return dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
return [
"meshopt",
sourceFile,
outputFile,
"--level",
"high",
"--quantize-position",
"16",
"--quantize-texcoord",
"16",
];
}
export async function optimizeDungeonEnvironment(slug) {
@@ -71,6 +87,7 @@ export async function optimizeDungeonEnvironment(slug) {
if (slug !== "shadowfang-keep" && selectiveProfile) {
throw new Error(`The Shadowfang-only selective fallback profile cannot be applied to ${slug}.`);
}
const preserveSpatialBoundaries = recipe.visualOptimization?.preserveSpatialBoundaries === true;
const optimized = path.join(directory, `${slug}-visual.optimized.glb`);
const packed = path.join(directory, `${slug}-visual.meshopt-final.glb`);
let temporaryDirectory = null;
@@ -83,13 +100,13 @@ export async function optimizeDungeonEnvironment(slug) {
sourcePreprocessing = await preprocessShadowfangVisual(source, optimizationSource);
}
await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized));
await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized, { preserveSpatialBoundaries }));
await compressKtx2(optimized);
await rm(packed, { force: true });
await runCli(dungeonEnvironmentFinalPackingArguments(optimized, packed));
await copyFile(packed, optimized);
await rm(packed, { force: true });
const finalPackingCommand = "gltf-transform optimize <optimized.glb> <meshopt-final.glb> --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false";
const finalPackingCommand = "gltf-transform meshopt <optimized.glb> <meshopt-final.glb> --level high --quantize-position 16 --quantize-texcoord 16";
const report = {
schemaVersion: 1,
dungeonId: slug,
@@ -105,6 +122,8 @@ export async function optimizeDungeonEnvironment(slug) {
} : null,
optimized: { file: path.basename(optimized), size: (await stat(optimized)).size },
compression: "meshopt+ktx2",
meshoptQuantization: { positionBits: 16, texcoordBits: 16 },
preserveSpatialBoundaries,
meshoptPackingRestoredAfterTextureCompression: true,
finalPackedStage: {
status: "complete",
@@ -115,7 +134,7 @@ export async function optimizeDungeonEnvironment(slug) {
},
commands: [
...(sourcePreprocessing ? ["source-pinned Shadowfang fallback preprocessing (see sourcePreprocessing profile)"] : []),
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
`gltf-transform optimize --compress false --instance ${preserveSpatialBoundaries ? "false" : "true"} --flatten ${preserveSpatialBoundaries ? "false" : "true"} --join ${preserveSpatialBoundaries ? "false" : "true"} --weld ${preserveSpatialBoundaries ? "false" : "true"} --simplify false --texture-compress false`,
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2",
finalPackingCommand,
],
@@ -13,6 +13,7 @@ import {
dungeonEnvironmentFinalPackingArguments,
dungeonEnvironmentOptimizationArguments,
} from "./optimize-environment.mjs";
import { loadRecipe } from "./lib.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
@@ -94,6 +95,37 @@ function geometryUvStats(document) {
return { triangles, degenerateUvTriangles };
}
function precisionFixture(document, buffer) {
const positions = new Float32Array([
0, 0, 0, 1, 0, 0, 0, 1, 0,
2, 0, 0, 3, 0, 0, 2, 1, 0,
]);
const texcoords = new Float32Array([
0, 0, 1, 0, 0, 1,
0.3141, 0.1, 0.31415, 0.1, 0.3141, 0.9,
]);
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("precision-positions").setType("VEC3").setArray(positions).setBuffer(buffer))
.setAttribute("TEXCOORD_0", document.createAccessor("precision-texcoords").setType("VEC2").setArray(texcoords).setBuffer(buffer))
.setIndices(document.createAccessor("precision-indices").setType("SCALAR").setArray(new Uint16Array([0, 1, 2, 3, 4, 5])).setBuffer(buffer));
const scene = document.createScene("precision-scene").addChild(
document.createNode("precision-mesh").setMesh(document.createMesh("precision-mesh").addPrimitive(primitive)),
);
document.getRoot().setDefaultScene(scene);
}
test("Utgarde Pinnacle's recipe opts into spatial boundary preservation", async () => {
const recipe = await loadRecipe("utgarde-pinnacle");
assert.equal(recipe.visualOptimization?.preserveSpatialBoundaries, true);
const args = dungeonEnvironmentOptimizationArguments("source.glb", "optimized.glb", {
preserveSpatialBoundaries: recipe.visualOptimization.preserveSpatialBoundaries,
});
assert.equal(args[args.indexOf("--instance") + 1], "false");
assert.equal(args[args.indexOf("--flatten") + 1], "false");
assert.equal(args[args.indexOf("--join") + 1], "false");
assert.equal(args[args.indexOf("--weld") + 1], "false");
});
test("WoW visual optimization preserves source texture dimensions and UV topology", async (context) => {
const width = 4096;
const height = 8;
@@ -132,10 +164,21 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
const args = dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
assert.deepEqual(args.slice(3), [
"--compress", "meshopt", "--meshopt-level", "high",
"--compress", "false", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--flatten", "true", "--join", "true", "--weld", "true",
"--simplify", "false", "--texture-compress", "false",
]);
assert.deepEqual(
dungeonEnvironmentOptimizationArguments(sourceFile, outputFile, { preserveSpatialBoundaries: true }).slice(3),
[
"--compress", "false", "--meshopt-level", "high",
"--instance", "false", "--instance-min", "3",
"--flatten", "false", "--join", "false", "--weld", "false",
"--simplify", "false", "--texture-compress", "false",
],
"a recipe can opt out of global instancing and geometry regrouping while retaining the common non-lossy preparation steps",
);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
@@ -163,10 +206,9 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
const packedFile = path.join(workingDirectory, "meshopt-final.glb");
const finalPackingArgs = dungeonEnvironmentFinalPackingArguments(outputFile, packedFile);
assert.deepEqual(finalPackingArgs.slice(3), [
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--simplify", "false", "--texture-compress", "false",
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", "16",
]);
assert.equal(finalPackingArgs[0], "meshopt");
const finalPacking = spawnSync(process.execPath, [gltfTransformCli, ...finalPackingArgs], {
cwd: projectRoot,
encoding: "utf8",
@@ -200,4 +242,30 @@ test("WoW visual optimization preserves source texture dimensions and UV topolog
simplifiedStats.triangles < optimizedStats.triangles || simplifiedStats.degenerateUvTriangles > 0,
"the atlas fixture must detect topology loss or UV collapse when simplification is enabled",
);
const precisionSource = path.join(workingDirectory, "precision-source.glb");
const precision12File = path.join(workingDirectory, "precision-12bit.glb");
const precision16File = path.join(workingDirectory, "precision-16bit.glb");
const precisionDocument = new Document();
precisionFixture(precisionDocument, precisionDocument.createBuffer());
await io.write(precisionSource, precisionDocument);
const precisionSourceStats = geometryUvStats(await io.read(precisionSource));
assert.deepEqual(precisionSourceStats, { triangles: 2, degenerateUvTriangles: 0 });
for (const [bits, targetFile] of [[12, precision12File], [16, precision16File]]) {
const precisionRun = spawnSync(process.execPath, [gltfTransformCli,
"meshopt", precisionSource, targetFile,
"--level", "high", "--quantize-position", "16", "--quantize-texcoord", String(bits)], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(precisionRun.status, 0, `${precisionRun.stdout}\n${precisionRun.stderr}`);
}
const lowPrecisionStats = geometryUvStats(await io.read(precision12File));
const highPrecisionStats = geometryUvStats(await io.read(precision16File));
assert.equal(lowPrecisionStats.degenerateUvTriangles, 1,
"12-bit atlas quantization must collapse the narrowly separated fixture UVs");
assert.equal(highPrecisionStats.degenerateUvTriangles, 0,
"16-bit atlas quantization must preserve the same non-zero UV triangle");
});
@@ -0,0 +1,132 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
// Diagnostic lossless append: preserve existing images/geometry byte-for-byte.
// Do not run solid-texture pruning on RGB weight carriers stored through AO.
const [input, bundleDirectory, output] = process.argv.slice(2);
if (!input || !bundleDirectory || !output) throw new Error("Provide shipping GLB, source bundle directory, and diagnostic output GLB.");
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
const original = await readFile(input);
if (original.toString("ascii", 0, 4) !== "glTF" || original.readUInt32LE(4) !== 2
|| original.readUInt32LE(8) !== original.length) throw new Error("Invalid GLB.");
const jsonLength = original.readUInt32LE(12);
const json = JSON.parse(original.subarray(20, 20 + jsonLength).toString().trim());
const binaryHeader = 20 + jsonLength;
if (original.readUInt32LE(binaryHeader + 4) !== 0x004e4942 || json.buffers.length !== 1) throw new Error("Expected one embedded BIN buffer.");
const originalBinary = original.subarray(binaryHeader + 8, binaryHeader + 8 + original.readUInt32LE(binaryHeader));
const manifest = JSON.parse(await readFile(path.join(bundleDirectory, "terrain-materials.json"), "utf8"));
if (manifest.sourceAdt.sha256 !== "8ad75da6b9485d61537ee3b451c08b79e25b1b1b2c7d4e91c8e57ad0a65ff75b"
|| manifest.sourceAdt.clientBuild !== "3.3.5a-Ascension"
|| hash(await readFile(path.join(bundleDirectory, "source/tanarisinstance_30_29.adt"))) !== manifest.sourceAdt.sha256
|| hash(original) !== "a45ad3ad072fce73d657fb668cab691fc87059a8a0701630465249ce8670d8f0") {
throw new Error("Pilot inputs must match the verified Ascension source tile and reviewed shipping asset.");
}
const targetNodes = json.nodes.filter(node => node.name === manifest.sourceGeometry.node);
if (targetNodes.length !== 1 || targetNodes[0].children?.length) throw new Error("Ambiguous or nested target terrain node.");
const target = targetNodes[0];
const oldMesh = target.mesh;
const oldPrimitives = json.meshes[oldMesh]?.primitives;
if (oldPrimitives?.length !== 1 || !/^ADT_Terrain(?:\.\d+)?$/.test(json.materials[oldPrimitives[0].material]?.name ?? "")
|| json.accessors[oldPrimitives[0].indices].count !== manifest.sourceGeometry.indexCount
|| json.nodes.filter(node => node.mesh === oldMesh).length !== 1) throw new Error("Target is not the verified single terrain primitive.");
if (json.nodes.some(node => node.children?.includes(json.nodes.indexOf(target)))) throw new Error("Expected terrain at scene root.");
// This pilot supports the verified no-hole tile. Other tiles must be recovered independently.
if (manifest.chunks.length !== 256 || manifest.chunks.some(chunk => chunk.sourceHoleMaskHex !== "0000")) throw new Error("Unverified tile topology.");
const parts = [originalBinary];
let byteLength = originalBinary.length;
function append(bytes, targetKind) {
const padding = (4 - byteLength % 4) % 4;
if (padding) { parts.push(Buffer.alloc(padding)); byteLength += padding; }
const view = json.bufferViews.length;
json.bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: bytes.length, ...(targetKind ? { target: targetKind } : {}) });
parts.push(bytes); byteLength += bytes.length;
return view;
}
async function verified(file, expected) {
const absolute = path.resolve(bundleDirectory, file);
if (!absolute.startsWith(`${path.resolve(bundleDirectory)}${path.sep}`)) throw new Error("Source path escapes bundle.");
const bytes = await readFile(absolute);
if (hash(bytes) !== expected) throw new Error(`Source hash mismatch: ${file}`);
return bytes;
}
json.textures ??= []; json.samplers ??= []; json.images ??= [];
const repeat = json.samplers.push({ wrapS: 10497, wrapT: 10497, minFilter: 9987, magFilter: 9729 }) - 1;
const clamp = json.samplers.push({ wrapS: 33071, wrapT: 33071, minFilter: 9987, magFilter: 9729 }) - 1;
json.extensions ??= {};
json.extensions.KHR_materials_variants ??= { variants: [] };
json.extensionsUsed = [...new Set([...(json.extensionsUsed ?? []), "KHR_materials_variants"])];
const variants = json.extensions.KHR_materials_variants.variants;
const carriers = new Map();
async function carrier(name, file, sha256, weight) {
if (carriers.has(name)) throw new Error("Duplicate carrier.");
const bytes = await verified(file, sha256);
const image = json.images.push({ name, mimeType: "image/png", bufferView: append(bytes) }) - 1;
const texture = json.textures.push({ source: image, sampler: weight ? clamp : repeat }) - 1;
const material = json.materials.push({ name, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1,
...(!weight ? { baseColorTexture: { index: texture } } : {}) }, ...(weight ? { occlusionTexture: { index: texture } } : {}) }) - 1;
const variant = variants.push({ name }) - 1;
carriers.set(name, { material, variants: [variant] });
}
for (const texture of manifest.textures) await carrier(texture.variant, texture.decodedPng, texture.decodedPngSha256, false);
for (const weight of manifest.weightCarriers) await carrier(weight.variant, weight.image, weight.sha256, true);
json.meshes.splice(oldMesh, 1);
for (const node of json.nodes) if (node.mesh > oldMesh) node.mesh -= 1;
delete target.mesh; delete target.translation; delete target.rotation; delete target.scale;
target.matrix = manifest.sourceGeometry.nodeWorldMatrix;
target.children = [];
const definitions = new Map(manifest.materials.map(material => [material.name, material]));
for (const chunk of manifest.chunks) {
const definition = definitions.get(chunk.material);
const material = json.materials.push({ name: definition.name, doubleSided: true, pbrMetallicRoughness: { metallicFactor: 0, roughnessFactor: 1 },
extras: { wowTerrain: definition.wowTerrain, sourceChunk: { indexX: chunk.indexX, indexY: chunk.indexY } } }) - 1;
async function accessor(record, type, componentType, targetKind, bounds = false) {
const bytes = await verified(record.file, record.sha256);
if (bytes.length !== record.bytes) throw new Error("Attribute size mismatch.");
const arity = type === "SCALAR" ? 1 : type === "VEC2" ? 2 : 3;
const value = { bufferView: append(bytes, targetKind), componentType, type, count: record.count / arity };
if (bounds) {
value.min = Array(arity).fill(Infinity); value.max = Array(arity).fill(-Infinity);
for (let i = 0; i < record.count; i++) {
const v = bytes.readFloatLE(i * 4); const k = i % arity;
value.min[k] = Math.min(value.min[k], v); value.max[k] = Math.max(value.max[k], v);
}
}
return json.accessors.push(value) - 1;
}
const geometry = chunk.geometry;
const attributes = {
POSITION: await accessor(geometry.position, "VEC3", 5126, 34962, true),
NORMAL: await accessor(geometry.normal, "VEC3", 5126, 34962),
TEXCOORD_0: await accessor(geometry.uv0, "VEC2", 5126, 34962),
};
const mappings = [...definition.wowTerrain.layerVariants, ...definition.wowTerrain.weightVariants].map(name => {
const mapping = carriers.get(name); if (!mapping) throw new Error(`Missing ${name}`); return mapping;
});
const mesh = json.meshes.push({ name: definition.name, primitives: [{ attributes, material,
indices: await accessor(geometry.indices, "SCALAR", 5123, 34963),
extensions: { KHR_materials_variants: { mappings } } }] }) - 1;
target.children.push(json.nodes.push({ name: definition.name, mesh }) - 1);
}
json.buffers[0].byteLength = byteLength;
const binary = Buffer.concat(parts);
const binaryPadding = (4 - binary.length % 4) % 4;
const jsonBytes = Buffer.from(JSON.stringify(json));
const jsonPadding = (4 - jsonBytes.length % 4) % 4;
const result = Buffer.alloc(28 + jsonBytes.length + jsonPadding + binary.length + binaryPadding);
result.write("glTF"); result.writeUInt32LE(2, 4); result.writeUInt32LE(result.length, 8);
result.writeUInt32LE(jsonBytes.length + jsonPadding, 12); result.writeUInt32LE(0x4e4f534a, 16);
jsonBytes.copy(result, 20); result.fill(0x20, 20 + jsonBytes.length, 20 + jsonBytes.length + jsonPadding);
const offset = 20 + jsonBytes.length + jsonPadding;
result.writeUInt32LE(binary.length + binaryPadding, offset); result.writeUInt32LE(0x004e4942, offset + 4);
binary.copy(result, offset + 8);
await writeFile(output, result);
const report = { status: "diagnostic-only", input: { path: input, sha256: hash(original), bytes: original.length },
output: { path: output, sha256: hash(result), bytes: result.length }, sourceAdt: manifest.sourceAdt,
sourceBundle: bundleDirectory, chunkCount: manifest.chunks.length, recoveredTriangleCount: manifest.sourceGeometry.indexCount / 3,
originalBinaryPrefixUnchanged: binary.subarray(0, originalBinary.length).equals(originalBinary),
originalImagesUnchanged: true, sourceTextures: manifest.textures.length, weightImages: manifest.weightCarriers.length,
compression: "original payloads retained; new source textures are lossless PNG and disjoint geometry is float32/uint16",
limits: "One source-backed tile. No original-client color/shadow reconstruction or fleet publication." };
await writeFile(`${output}.report.json`, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
@@ -91,6 +91,38 @@ test("real tiled Recast export covers Detour polygon 0 and keeps disconnected is
assert.equal(route.start.found, true);
assert.equal(route.end.found, true);
assert.equal(route.reachable, false, "independent source surfaces must not gain an inferred connection");
const subsetFile = path.join(directory, "source-anchored-navigation.glb");
const subset = await bakeNavigationFromCollisionAssets({
slug: "recast-anchor-fixture",
collisionAssets: [{ role: "collision", path: collisionFile }],
settings: result.settings,
includeDetourTopology: true,
detourAnchorPositions: [{ id: "second-island", position: [100, 2.2, 0], sourcePosition: [1000, 22, 0], evidence: "fixture source table", maximumDistance: 0.5, required: true }],
output: subsetFile,
reportFile: path.join(directory, "source-anchored-report.json"),
});
assert.equal(subset.detourTopology.weakComponents.count, 1);
assert.ok(subset.geometry.bounds[0][0] > 90, "unanchored first island must be excluded");
assert.equal(subset.detourTopology.detailTriangleCount, subset.geometry.triangles);
const subsetDocument = await new NodeIO().read(subsetFile);
const selected = subsetDocument.getRoot().getExtras().runewakerNavigationTopology;
assert.equal(selected.polygons[0][0], 0, "retained nonzero source IDs must compact to zero");
assert.equal(selected.polygons[0][6], 0, "retained detail ranges must start at the selected geometry");
assert.equal(selected.sourceAnchorSelection.anchors[0].retained, true);
assert.deepEqual(selected.sourceAnchorSelection.anchors[0].sourcePosition, [1000, 22, 0]);
assert.equal(selected.sourceAnchorSelection.anchors[0].evidence, "fixture source table");
assert.equal(selected.sourceAnchorSelection.anchors[0].required, true);
await assert.rejects(() => bakeNavigationFromCollisionAssets({
slug: "recast-required-anchor-fixture",
collisionAssets: [{ role: "collision", path: collisionFile }],
settings: result.settings,
includeDetourTopology: true,
detourAnchorPositions: [{ id: "missing-entry", position: [500, 20, 0], maximumDistance: 0.5, required: true }],
output: path.join(directory, "invalid-navigation.glb"),
reportFile: path.join(directory, "invalid-report.json"),
}), /required source navigation anchor missing-entry is outside/);
} finally {
await rm(directory, { recursive: true, force: true });
}
+152 -26
View File
@@ -15,6 +15,7 @@ import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "meshoptimizer";
import { unpackMeshesForRecast } from "./recast-mesh-adapter.mjs";
import {
exists,
fixturesRoot,
@@ -39,6 +40,11 @@ import {
extractDetourTopology,
stableSha256,
} from "./detour-topology.mjs";
import {
countSurfaceBucketReferences,
DETOUR_COMPONENT_SELECTION_LIMITS,
selectDetourAnchorComponents,
} from "./detour-component-selection.mjs";
globalThis.self = globalThis;
globalThis.ProgressEvent ??= class ProgressEvent {
@@ -263,21 +269,95 @@ function queryDetourRouteProbes(navMesh, probes) {
}
}
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = null) {
function mapDetourAnchorsToPolygons(navMesh, topology, anchors) {
if (!Array.isArray(anchors) || anchors.length === 0) return [];
const polygonIdByReference = new Map();
for (const [polygonId, tileIndex, , , , polygonIndex] of topology.polygons) {
const tile = navMesh.getTile(tileIndex);
const reference = navMesh.getPolyRefBase(tile) + polygonIndex;
polygonIdByReference.set(String(reference), polygonId);
}
const query = new NavMeshQuery(navMesh);
try {
return anchors.map((anchor) => {
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()
|| !Array.isArray(anchor.position) || anchor.position.length !== 3
|| !anchor.position.every(Number.isFinite)
|| !Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
throw new Error("Detour source anchors require an ID, finite XYZ position, and positive maximumDistance.");
}
const radius = anchor.maximumDistance;
const nearest = query.findNearestPoly(
{ x: anchor.position[0], y: anchor.position[1], z: anchor.position[2] },
{ halfExtents: { x: radius, y: radius, z: radius } },
);
const found = nearest.success && String(nearest.nearestRef) !== "0";
if (!found) {
return {
...anchor,
id: anchor.id,
kind: anchor.kind ?? null,
position: [...anchor.position],
maximumDistance: radius,
polygonId: null,
distance: null,
nearestPoint: null,
reason: "no-detour-surface-within-source-projection-search-box",
};
}
const nearestPoint = [nearest.nearestPoint.x, nearest.nearestPoint.y, nearest.nearestPoint.z];
const distance = Math.hypot(
anchor.position[0] - nearestPoint[0],
anchor.position[1] - nearestPoint[1],
anchor.position[2] - nearestPoint[2],
);
const polygonId = polygonIdByReference.get(String(nearest.nearestRef));
if (polygonId === undefined) {
return {
...anchor,
id: anchor.id,
kind: anchor.kind ?? null,
position: [...anchor.position],
maximumDistance: radius,
polygonId: null,
distance,
nearestPoint,
reason: "nearest-detour-reference-is-not-a-topology-surface-polygon",
};
}
return {
...anchor,
id: anchor.id,
kind: anchor.kind ?? null,
position: [...anchor.position],
maximumDistance: radius,
polygonId,
distance,
nearestPoint,
};
});
} finally {
query.destroy();
}
}
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = null, detourSelection = null) {
const sourcePositions = geometry.getAttribute("position")?.array;
const sourceIndices = geometry.getIndex()?.array;
if (!sourcePositions) throw new Error("Recast helper produced no position buffer.");
const sourceElementCount = sourceIndices?.length ?? sourcePositions.length / 3;
if (detourTopology && detourTopology.detailTriangleCount !== sourceElementCount / 3) {
const sourceTopology = detourSelection?.sourceTopology ?? detourTopology;
const outputTopology = detourSelection?.topology ?? detourTopology;
if (sourceTopology && sourceTopology.detailTriangleCount !== sourceElementCount / 3) {
throw new Error(
`Detour topology describes ${detourTopology.detailTriangleCount} detail triangles, `
`Detour topology describes ${sourceTopology.detailTriangleCount} detail triangles, `
+ `but the helper emitted ${sourceElementCount / 3}.`,
);
}
const [minimumInput, maximumInput] = inputBounds;
const compactPositions = [];
const compactIndices = [];
const exportedTrianglePolygonIds = detourTopology ? [] : null;
const exportedTrianglePolygonIds = outputTopology ? [] : null;
const remappedVertices = new Map();
let rejectedOutOfBoundsTriangles = 0;
@@ -295,11 +375,11 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
return true;
};
const sourceTrianglePolygonIds = detourTopology
const sourceTrianglePolygonIds = sourceTopology
? new Uint32Array(sourceElementCount / 3)
: null;
if (detourTopology) {
for (const [polygonId, , , , , , firstTriangle, triangleCount] of detourTopology.polygons) {
if (sourceTopology) {
for (const [polygonId, , , , , , firstTriangle, triangleCount] of sourceTopology.polygons) {
if (firstTriangle < 0 || triangleCount < 0 || firstTriangle + triangleCount > sourceTrianglePolygonIds.length) {
throw new Error(`Detour topology polygon ${polygonId} has an invalid detail-triangle range.`);
}
@@ -319,6 +399,9 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
}
for (let offset = 0; offset < sourceElementCount; offset += 3) {
const sourceTriangleIndex = offset / 3;
const sourcePolygonId = sourceTrianglePolygonIds?.[sourceTriangleIndex];
if (detourSelection && !detourSelection.selectedSourcePolygonIds.has(sourcePolygonId)) continue;
const triangle = [
sourceIndices?.[offset] ?? offset,
sourceIndices?.[offset + 1] ?? offset + 1,
@@ -328,8 +411,11 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
rejectedOutOfBoundsTriangles += 1;
continue;
}
if (detourTopology) {
const polygonId = sourceTrianglePolygonIds[offset / 3];
if (outputTopology) {
const polygonId = detourSelection
? detourSelection.sourceToSelectedPolygonId.get(sourcePolygonId)
: sourcePolygonId;
if (!Number.isInteger(polygonId)) throw new Error(`Selected detail triangle ${sourceTriangleIndex} has no remapped polygon ID.`);
exportedTrianglePolygonIds.push(polygonId);
}
for (const sourceIndex of triangle) {
@@ -360,19 +446,32 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
if (!compactIndices.length) {
throw new Error("Recast helper produced no triangles within the authoritative collision bounds.");
}
if (detourTopology) {
if (outputTopology) {
const geometryRanges = buildDetourGeometryRanges(
detourTopology.polygonCount,
outputTopology.polygonCount,
exportedTrianglePolygonIds,
);
for (const [polygonId, polygonRecord] of detourTopology.polygons.entries()) {
for (const [polygonId, polygonRecord] of outputTopology.polygons.entries()) {
[polygonRecord[6], polygonRecord[7]] = geometryRanges[polygonId];
}
detourTopology.geometryTriangleCount = compactIndices.length / 3;
outputTopology.geometryTriangleCount = compactIndices.length / 3;
}
const positions = new Float32Array(compactPositions);
const indices = new Uint32Array(compactIndices);
const surfaceBucketReferenceCount = outputTopology
? countSurfaceBucketReferences(positions, indices, 8)
: null;
if (detourSelection) {
if (surfaceBucketReferenceCount > DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences) {
throw new Error(
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
+ `(${surfaceBucketReferenceCount} > ${DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences}).`,
);
}
detourSelection.summary.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
outputTopology.sourceAnchorSelection.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
}
const minimum = [Infinity, Infinity, Infinity];
const maximum = [-Infinity, -Infinity, -Infinity];
for (let offset = 0; offset < positions.length; offset += 3) {
@@ -389,9 +488,9 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
document.createMesh("navigation").addPrimitive(primitive);
document.createNode("navigation").setMesh(document.getRoot().listMeshes()[0]);
document.createScene("navigation").addChild(document.getRoot().listNodes()[0]);
if (detourTopology) {
if (outputTopology) {
document.getRoot().setExtras({
runewakerNavigationTopology: detourTopology,
runewakerNavigationTopology: outputTopology,
});
}
await new NodeIO().write(file, document);
@@ -401,6 +500,7 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detou
sourceVertices: sourcePositions.length / 3,
sourceTriangles: sourceElementCount / 3,
rejectedOutOfBoundsTriangles,
...(surfaceBucketReferenceCount !== null ? { surfaceBucketReferenceCount } : {}),
bounds: [minimum, maximum],
boundsTolerance,
};
@@ -415,9 +515,13 @@ export async function bakeNavigationFromCollisionAssets({
reportFile = path.join(workRoot, slug, "recast-report.json"),
source = undefined,
includeDetourTopology = false,
detourAnchorPositions = [],
detourRouteProbes = [],
}) {
if (!collisionAssets.length) throw new Error(`${slug}: no collision chunks are available for Recast.`);
if (detourAnchorPositions.length && !includeDetourTopology) {
throw new Error(`${slug}: source-anchor Detour selection requires includeDetourTopology.`);
}
const normalizedPaths = collisionAssets.map((asset) => asset.path.replace(/\\/g, "/"));
if (new Set(normalizedPaths.map((value) => value.toLowerCase())).size !== normalizedPaths.length) {
throw new Error(`${slug}: collision chunks contain duplicate paths.`);
@@ -431,8 +535,10 @@ export async function bakeNavigationFromCollisionAssets({
await sha256(resolvedInputs[index]),
])));
const scenes = await Promise.all(resolvedInputs.map(loadScene));
const meshes = scenes.flatMap(meshesIn);
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
const sourceMeshes = scenes.flatMap(meshesIn);
if (!sourceMeshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
const adapted = unpackMeshesForRecast(sourceMeshes);
const meshes = adapted.meshes;
const input = inspectNavigationInput(meshes);
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
tileSize,
@@ -469,6 +575,7 @@ export async function bakeNavigationFromCollisionAssets({
let helper = null;
let geometry;
let detourTopology = null;
let detourSelection = null;
let detourRoutes = [];
try {
if (build.mode === "tiled") {
@@ -566,6 +673,22 @@ export async function bakeNavigationFromCollisionAssets({
sourceCollisionSha256: stableSha256(inputChunkChecksums),
settingsSha256: stableSha256({ settings, generatorConfig }),
});
if (detourAnchorPositions.length) {
const anchorMappings = mapDetourAnchorsToPolygons(
generated.navMesh,
detourTopology,
detourAnchorPositions,
);
for (const anchor of detourAnchorPositions.filter(anchor => anchor.required === true)) {
const mapping = anchorMappings.find(mapping => mapping.id === anchor.id);
if (mapping?.polygonId === null || mapping?.polygonId === undefined
|| !Number.isFinite(mapping.distance) || mapping.distance > anchor.maximumDistance) {
throw new Error(`${slug}: required source navigation anchor ${anchor.id} is outside its projection limit.`);
}
}
detourSelection = selectDetourAnchorComponents(detourTopology, anchorMappings);
detourSelection.sourceTopology = detourTopology;
}
}
detourRoutes = queryDetourRouteProbes(generated.navMesh, detourRouteProbes);
await mkdir(path.dirname(output), { recursive: true });
@@ -575,6 +698,7 @@ export async function bakeNavigationFromCollisionAssets({
input.bounds,
Math.max(settings.cellSize, settings.cellHeight) * 2,
detourTopology,
detourSelection,
);
} finally {
helper?.navMeshGeometry.dispose();
@@ -588,6 +712,7 @@ export async function bakeNavigationFromCollisionAssets({
generator: "@recast-navigation/three@0.43.1",
settings,
input,
navigationGeometryAdapter: adapted.report,
build: {
...build,
partition: build.mode === "tiled" ? tiledPartition : "watershed",
@@ -606,22 +731,23 @@ export async function bakeNavigationFromCollisionAssets({
...(detourTopology ? {
detourTopology: {
schemaVersion: detourTopology.schemaVersion,
tileCount: detourTopology.tileCount,
polygonCount: detourTopology.polygonCount,
detailTriangleCount: detourTopology.detailTriangleCount,
polygonFanTriangleCount: detourTopology.polygonFanTriangleCount,
geometryTriangleCount: detourTopology.geometryTriangleCount,
directedLinkCount: detourTopology.directedLinkCount,
tileCount: detourSelection?.topology.tileCount ?? detourTopology.tileCount,
polygonCount: detourSelection?.topology.polygonCount ?? detourTopology.polygonCount,
detailTriangleCount: detourSelection?.topology.detailTriangleCount ?? detourTopology.detailTriangleCount,
polygonFanTriangleCount: detourSelection?.topology.polygonFanTriangleCount ?? detourTopology.polygonFanTriangleCount,
geometryTriangleCount: detourSelection?.topology.geometryTriangleCount ?? detourTopology.geometryTriangleCount,
directedLinkCount: detourSelection?.topology.directedLinkCount ?? detourTopology.directedLinkCount,
unrepresentedLinkCount: detourTopology.unrepresentedLinkCount,
offMeshPolygonCount: detourTopology.offMeshPolygonCount,
offMeshPolygonCount: detourSelection?.topology.offMeshPolygonCount ?? detourTopology.offMeshPolygonCount,
weakComponents: {
count: detourTopology.weakComponents.count,
largestPolygonCount: detourTopology.weakComponents.largestPolygonCount,
count: detourSelection?.topology.weakComponents.count ?? detourTopology.weakComponents.count,
largestPolygonCount: detourSelection?.topology.weakComponents.largestPolygonCount ?? detourTopology.weakComponents.largestPolygonCount,
},
sourceCollisionSha256: detourTopology.sourceCollisionSha256,
settingsSha256: detourTopology.settingsSha256,
},
} : {}),
...(detourSelection ? { detourComponentSelection: detourSelection.summary } : {}),
...(source ? { source } : {}),
};
await writeJson(reportFile, report);
@@ -0,0 +1,75 @@
import { BufferAttribute, BufferGeometry, Mesh } from "three";
/**
* @recast-navigation/three currently assumes position/index attributes are
* tightly-packed Float32/Uint arrays. glTFLoader may return interleaved or
* normalized BufferAttributes, so copy decoded accessor values into a plain
* geometry before handing meshes to its generator helpers.
*/
export function unpackMeshesForRecast(meshes) {
let interleavedPositionAttributeCount = 0;
let normalizedPositionAttributeCount = 0;
let nonFloatPositionAttributeCount = 0;
let unpackedPositionAccessorCount = 0;
let decodedIndexAccessorCount = 0;
const preparedMeshes = meshes.map((sourceMesh) => {
const sourceGeometry = sourceMesh.geometry;
const sourcePositions = sourceGeometry?.getAttribute("position");
if (!sourcePositions || sourcePositions.itemSize !== 3) {
throw new Error(`${sourceMesh.name || "collision mesh"}: POSITION must be a VEC3 attribute.`);
}
const positions = new Float32Array(sourcePositions.count * 3);
for (let index = 0; index < sourcePositions.count; index += 1) {
const offset = index * 3;
positions[offset] = sourcePositions.getX(index);
positions[offset + 1] = sourcePositions.getY(index);
positions[offset + 2] = sourcePositions.getZ(index);
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positions, 3));
if (sourcePositions.isInterleavedBufferAttribute) interleavedPositionAttributeCount += 1;
if (sourcePositions.normalized) normalizedPositionAttributeCount += 1;
if (!(sourcePositions.array instanceof Float32Array)) nonFloatPositionAttributeCount += 1;
unpackedPositionAccessorCount += 1;
const sourceIndices = sourceGeometry.getIndex();
if (sourceIndices) {
const indices = new Uint32Array(sourceIndices.count);
for (let index = 0; index < sourceIndices.count; index += 1) {
const value = sourceIndices.getX(index);
if (!Number.isInteger(value) || value < 0 || value >= sourcePositions.count) {
throw new Error(
`${sourceMesh.name || "collision mesh"}: index ${index} references invalid position ${value}.`,
);
}
indices[index] = value;
}
geometry.setIndex(new BufferAttribute(indices, 1));
decodedIndexAccessorCount += 1;
}
sourceMesh.updateWorldMatrix(true, false);
const preparedMesh = new Mesh(geometry);
preparedMesh.name = sourceMesh.name;
preparedMesh.matrixAutoUpdate = false;
preparedMesh.matrix.copy(sourceMesh.matrixWorld);
preparedMesh.matrixWorld.copy(sourceMesh.matrixWorld);
return preparedMesh;
});
return {
meshes: preparedMeshes,
report: {
adapter: "decoded-three-buffer-attributes-v1",
sourceMeshCount: meshes.length,
unpackedPositionAccessorCount,
interleavedPositionAttributeCount,
normalizedPositionAttributeCount,
nonFloatPositionAttributeCount,
decodedIndexAccessorCount,
},
};
}
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { getPositionsAndIndices } from "@recast-navigation/three";
import {
BufferAttribute,
BufferGeometry,
InterleavedBuffer,
InterleavedBufferAttribute,
Mesh,
Object3D,
Vector3,
} from "three";
import { unpackMeshesForRecast } from "./recast-mesh-adapter.mjs";
test("Recast adapter decodes interleaved positions, indices, normalized positions, and parent transforms", () => {
const parent = new Object3D();
parent.position.set(10, 2, -4);
parent.rotation.y = Math.PI / 2;
parent.scale.set(2, 3, 4);
const interleavedGeometry = new BufferGeometry();
const interleavedData = new InterleavedBuffer(new Float32Array([
0, 0, 0, 91, 1,
1, 0, 0, 92, 2,
0, 0, 1, 93, 3,
]), 5);
interleavedGeometry.setAttribute("position", new InterleavedBufferAttribute(interleavedData, 3, 0));
interleavedGeometry.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2]), 1));
const interleavedMesh = new Mesh(interleavedGeometry);
interleavedMesh.name = "interleaved";
interleavedMesh.position.set(1, 0, 2);
parent.add(interleavedMesh);
const normalizedGeometry = new BufferGeometry();
normalizedGeometry.setAttribute("position", new BufferAttribute(new Uint16Array([
0, 0, 0,
65535, 0, 0,
0, 65535, 0,
]), 3, true));
const normalizedMesh = new Mesh(normalizedGeometry);
normalizedMesh.name = "normalized";
normalizedMesh.position.set(-3, 5, 7);
parent.add(normalizedMesh);
parent.updateMatrixWorld(true);
const originalInterleaved = Array.from(interleavedData.array);
const originalNormalized = Array.from(normalizedGeometry.getAttribute("position").array);
const result = unpackMeshesForRecast([interleavedMesh, normalizedMesh]);
assert.deepEqual(result.report, {
adapter: "decoded-three-buffer-attributes-v1",
sourceMeshCount: 2,
unpackedPositionAccessorCount: 2,
interleavedPositionAttributeCount: 1,
normalizedPositionAttributeCount: 1,
nonFloatPositionAttributeCount: 1,
decodedIndexAccessorCount: 1,
});
for (const [index, sourceMesh] of [interleavedMesh, normalizedMesh].entries()) {
assert.deepEqual(
result.meshes[index].matrixWorld.toArray(),
sourceMesh.matrixWorld.toArray(),
"inspection and generator transforms must agree before generator mutation",
);
}
const [positions, indices] = getPositionsAndIndices(result.meshes);
const expectedWorld = [];
const expectedIndices = [];
for (const [sourceMeshIndex, sourceMesh] of [interleavedMesh, normalizedMesh].entries()) {
const attr = sourceMesh.geometry.getAttribute("position");
for (let i = 0; i < attr.count; i += 1) {
const point = new Vector3(attr.getX(i), attr.getY(i), attr.getZ(i))
.applyMatrix4(sourceMesh.matrixWorld);
expectedWorld.push(point.x, point.y, point.z);
if (sourceMeshIndex === 1) expectedIndices.push(3 + i);
}
if (sourceMeshIndex === 0) expectedIndices.push(0, 1, 2);
}
assert.equal(positions.length, expectedWorld.length);
for (let i = 0; i < expectedWorld.length; i += 1) {
assert.ok(Math.abs(positions[i] - expectedWorld[i]) < 1e-5, `world position component ${i}`);
}
assert.deepEqual(Array.from(indices), expectedIndices);
assert.deepEqual(Array.from(interleavedData.array), originalInterleaved);
assert.deepEqual(Array.from(normalizedGeometry.getAttribute("position").array), originalNormalized);
assert.equal(result.meshes[0].geometry.getAttribute("position").array.length, 9);
assert.equal(result.meshes[1].geometry.getAttribute("position").array.length, 9);
});
test("Recast adapter rejects malformed indices before Uint32 conversion can truncate them", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array([
0, 0, 0,
1, 0, 0,
0, 0, 1,
]), 3));
geometry.setIndex(new BufferAttribute(new Float32Array([0, 1.5, 2]), 1));
const mesh = new Mesh(geometry);
mesh.name = "malformed-index-fixture";
assert.throws(
() => unpackMeshesForRecast([mesh]),
/malformed-index-fixture: index 1 references invalid position 1\.5/,
);
});
@@ -0,0 +1,689 @@
#!/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;
});
}
@@ -0,0 +1,246 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
assertMatchingRootDigests,
auditWmoAsset,
documentedFixColorVertexAlpha,
mapExactObjMocv,
parseGroupWmo,
parseRootWmo,
sourceToObjPoint,
} from "./wow-native-wmo-audit.mjs";
function reversedChunk(id, payload) {
const header = Buffer.alloc(8);
header.write(id.split("").reverse().join(""), 0, "ascii");
header.writeUInt32LE(payload.length, 4);
return Buffer.concat([header, payload]);
}
function floats(values) {
const result = Buffer.alloc(values.length * 4);
values.forEach((value, index) => result.writeFloatLE(value, index * 4));
return result;
}
function makeLightRow() {
const row = Buffer.alloc(48);
row.set([0, 1, 1, 1, 230, 160, 90, 255], 0);
[2, 3, 4].forEach((value, index) => row.writeFloatLE(value, 8 + index * 4));
row.writeFloatLE(0.65, 20);
row.writeFloatLE(0.5, 24);
row.writeFloatLE(8, 28);
return row;
}
function makeRoot({ flags = 0, lightPayload = makeLightRow(), ambient = [20, 30, 40, 255] } = {}) {
const mver = Buffer.alloc(4);
mver.writeUInt32LE(17);
const mohd = Buffer.alloc(64);
mohd.writeUInt32LE(1, 4); // one group
mohd.writeUInt32LE(lightPayload.length / 48, 12);
mohd.set(ambient, 28);
mohd.writeUInt32LE(723, 32);
floats([-10, -20, -30, 10, 20, 30]).copy(mohd, 36);
mohd.writeUInt16LE(flags, 60);
const mogi = Buffer.alloc(32);
mogi.writeUInt32LE(0x2000, 0);
floats([-1, -1, -1, 1, 1, 1]).copy(mogi, 4);
mogi.writeInt32LE(1, 28);
return Buffer.concat([
reversedChunk("MVER", mver),
reversedChunk("MOHD", mohd),
reversedChunk("MOGN", Buffer.from("\0JailRoom\0Interior\0", "latin1")),
reversedChunk("MOGI", mogi),
reversedChunk("MOLT", lightPayload),
]);
}
function makeGroup({
flags = 0x2204,
colors = [[100, 90, 80, 0], [110, 100, 90, 0], [120, 110, 100, 0]],
refs = [0],
batchLastVertex = 0,
} = {}) {
const positions = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
const normals = [[0, 1, 0], [0, 0, 1], [1, 0, 0]];
const uvs = [[0.25, 0.75], [0.5, 0.25], [0.125, 0.875]];
const header = Buffer.alloc(68);
header.writeUInt32LE(1, 0);
header.writeUInt32LE(10, 4);
header.writeUInt32LE(flags, 8);
floats([-10, -20, -30, 10, 20, 30]).copy(header, 12);
header.writeUInt16LE(1, 40); // batch A
header.writeUInt16LE(1, 42); // interior batch
header.writeUInt16LE(0, 44); // exterior batch
const nested = [
reversedChunk("MOVT", floats(positions.flat())),
reversedChunk("MONR", floats(normals.flat())),
reversedChunk("MOTV", floats(uvs.flat())),
reversedChunk("MOLR", Buffer.from(refs.flatMap((value) => {
const row = Buffer.alloc(2);
row.writeInt16LE(value);
return [...row];
}))),
reversedChunk("MOCV", Buffer.from(colors.flat())),
reversedChunk("MOBA", (() => {
const batch = Buffer.alloc(24);
batch.writeUInt16LE(batchLastVertex, 20);
return batch;
})()),
];
const version = Buffer.alloc(4);
version.writeUInt32LE(17);
return {
bytes: Buffer.concat([reversedChunk("MVER", version), reversedChunk("MOGP", Buffer.concat([header, ...nested]))]),
positions,
normals,
uvs,
colors,
};
}
function makeObj({ positions, normals, uvs }) {
const lines = [];
for (const [x, y, z] of positions) lines.push(`v ${x} ${z} ${-y}`);
for (const [x, y, z] of normals) lines.push(`vn ${x} ${z} ${-y}`);
for (const [u, v] of uvs) lines.push(`vt ${u} ${1 - v}`);
return `${lines.join("\n")}\n`;
}
function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
test("WMO v17 parsers decode MOLT rows, MOGP flags, batches, MOLR, and raw MOCV bytes", () => {
const rootBytes = makeRoot();
const group = makeGroup();
const root = parseRootWmo(rootBytes);
const parsedGroup = parseGroupWmo(group.bytes, 0);
assert.equal(root.header.groupCount, 1);
assert.equal(root.header.lightCount, 1);
assert.deepEqual(root.header.ambientColorBytes, [20, 30, 40, 255]);
assert.deepEqual(root.lights[0], {
index: 0,
lightType: 0,
secondaryType: 1,
useAttenuation: 1,
padding: 1,
colorBytes: [230, 160, 90, 255],
position: [2, 3, 4],
intensity: Math.fround(0.65),
attenuationStart: 0.5,
attenuationEnd: 8,
unknownFloats: [0, 0, 0, 0],
});
assert.equal(parsedGroup.header.flags, 0x2204);
assert.deepEqual(parsedGroup.header.batchCounts, { unknownA: 1, interior: 1, exterior: 0, unknownD: 0 });
assert.equal(parsedGroup.hasLightFlag, true);
assert.equal(parsedGroup.indoorFlag, true);
assert.deepEqual(parsedGroup.lightRefs, [0]);
assert.deepEqual(parsedGroup.vertices[0].mocvBytes, [100, 90, 80, 0]);
});
test("source coordinate basis is exact WMO (x,y,z) to OBJ (x,z,-y)", () => {
assert.deepEqual(sourceToObjPoint([2, 3, 4]), [2, 4, -3]);
});
test("exact MOCV mapping uses full position, normal, and UV signatures and preserves raw BGRA", () => {
const group = makeGroup();
const obj = makeObj(group);
const mapping = mapExactObjMocv(parseRootWmo(makeRoot()), [parseGroupWmo(group.bytes)], obj);
assert.equal(mapping.exactCompleteMapping, true);
assert.equal(mapping.mappedVertexCount, 3);
assert.equal(mapping.ambiguousVertexCount, 0);
assert.equal(mapping.unmatchedVertexCount, 0);
assert.equal(mapping.rawMocvByteOrder, "rawColorBytesByObjVertex preserves BGRA storage bytes exactly; alpha semantics are deliberately not applied");
assert.deepEqual([...mapping.rawColorBytesByObjVertex], group.colors.flat());
assert.equal(mapping.documentedFixColorAlphaMapping.exactCompleteMapping, true);
assert.deepEqual([...mapping.documentedFixColorAlphaMapping.colorsByObjVertex].slice(3, 4), [0]);
assert.deepEqual(mapping.rawAlphaHistogram, { 0: 3 });
});
test("documented MOCV fixup treats alpha as lighting data and rewrites indoor/outdoor alpha, never opacity", () => {
const root = parseRootWmo(makeRoot());
const colors = [[110, 90, 70, 128], [100, 80, 60, 64], [40, 50, 60, 0]];
const indoor = parseGroupWmo(makeGroup({ flags: 0x2204, colors }).bytes);
const outdoor = parseGroupWmo(makeGroup({ flags: 0x220c, colors }).bytes);
const indoorResult = documentedFixColorVertexAlpha(root, indoor);
const outdoorResult = documentedFixColorVertexAlpha(root, outdoor);
assert.equal(indoorResult.secondBatchVertexStart, 1);
assert.deepEqual([...indoorResult.rawColorBytes], colors.flat());
assert.deepEqual([...indoorResult.adaptedColorBytes], [22, 14, 7, 128, 90, 65, 40, 0, 10, 10, 10, 0]);
assert.deepEqual([...outdoorResult.adaptedColorBytes], [22, 14, 7, 128, 90, 65, 40, 255, 10, 10, 10, 255]);
assert.match(indoorResult.alphaSemantics, /not opacity/u);
assert.equal(indoorResult.productionStatus.includes("diagnostic-only"), true);
});
test("documented MOHD 0x0008 path is marked disputed and does not claim production colors", () => {
const root = parseRootWmo(makeRoot({ flags: 0x8 }));
const group = parseGroupWmo(makeGroup({ flags: 0x2204, colors: [[100, 80, 60, 64], [50, 40, 30, 32], [20, 10, 5, 7]] }).bytes);
const result = documentedFixColorVertexAlpha(root, group);
assert.equal(result.fixupPath, "documented-special-mohd-0x0008-disputed");
assert.deepEqual([...result.adaptedColorBytes], [100, 80, 60, 64, 50, 40, 30, 0, 20, 10, 5, 0]);
assert.match(result.productionStatus, /diagnostic-only/u);
});
test("exact mapping fails closed on changed geometry instead of nearest-vertex guessing", () => {
const group = makeGroup();
const obj = makeObj(group).replace("v 1 3 -2", "v 1.0001 3 -2");
const mapping = mapExactObjMocv(parseRootWmo(makeRoot()), [parseGroupWmo(group.bytes)], obj);
assert.equal(mapping.exactCompleteMapping, false);
assert.equal(mapping.unmatchedVertexCount, 1);
});
test("group data is rejected unless it is paired with the byte-identical expected root", () => {
const rootBytes = makeRoot();
const expected = digest(rootBytes);
assert.deepEqual(assertMatchingRootDigests({
rootBytes,
groupCacheRootBytes: Buffer.from(rootBytes),
expectedRootSha256: expected,
}), { rootSha256: expected, groupCacheRootSha256: expected });
const differentRoot = Buffer.from(rootBytes);
differentRoot[differentRoot.length - 1] ^= 1;
assert.throws(() => assertMatchingRootDigests({
rootBytes,
groupCacheRootBytes: differentRoot,
expectedRootSha256: expected,
}), /not byte-identical/u);
assert.throws(() => assertMatchingRootDigests({
rootBytes,
groupCacheRootBytes: rootBytes,
expectedRootSha256: "",
}), /expected 64-character/u);
});
test("MOLT references are eligible only with HasLight and without DoNotUseLocalLighting", () => {
const rootBytes = makeRoot();
const group = makeGroup({ flags: 0x2244 });
const result = auditWmoAsset({
rootBytes,
groupCacheRootBytes: rootBytes,
expectedRootSha256: digest(rootBytes),
groupBuffers: [group.bytes],
objText: makeObj(group),
});
assert.equal(result.lighting.totalMoltRows, 1);
assert.equal(result.lighting.groupCountWithHasLightAndMolr, 0);
assert.equal(result.lighting.eligibleMolrReferenceCount, 0);
assert.deepEqual(result.lighting.eligibleUniqueMoltRows, []);
assert.match(result.lighting.runtimeLightClaim, /no Three\.js dynamic lights/u);
assert.match(result.sourceGeometry.mocv.alphaSemantics, /not glTF opacity/u);
});
test("malformed MOLT rows are rejected", () => {
assert.throws(() => parseRootWmo(makeRoot({ lightPayload: Buffer.alloc(47) })), /not divisible/u);
});
+144
View File
@@ -0,0 +1,144 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve('artifacts/playtest/healer-mana-recovery');
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-status-'));
fs.mkdirSync(directory, { recursive: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4198, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(180_000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const moduleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((urls) => {
window.romStatusStores = async () => {
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = exports.filter((value) => value && typeof value.getState === 'function');
return {
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
&& Array.isArray(store.getState()?.members)),
};
};
}, moduleUrls);
try {
await page.goto('http://127.0.0.1:4198', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('ManaAuraQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
if (!character) throw new Error('Priest character was not selected.');
shell.setState({ activeCharacter: character });
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
shell.setState({ activeCharacter: null });
shell.getState().openMainMenu();
});
await page.locator('.main-menu-screen').waitFor();
await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
shell.getState().openDungeons();
});
await page.locator('.dungeon-select-screen').waitFor();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.evaluate(async () => {
const { game } = await window.romStatusStores();
game.getState().closeOverlay();
});
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' });
await page.waitForFunction(async () => {
const { combat } = await window.romStatusStores();
return combat.getState().level >= 30 && Object.values(combat.getState().mobs)
.some((mob) => !mob.dead && mob.homePosition);
});
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Boss guide', exact: true }).click();
await page.getByRole('heading', { name: 'Boss Guide', exact: true }).waitFor();
const bossGuide = await page.locator('.boss-guide-panel__bosses').innerText();
const selectedBoss = await page.locator('.boss-guide-panel__bosses button.is-active strong').innerText();
await page.screenshot({ path: path.join(directory, 'selected-boss.png') });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Resume game' }).click();
const metadata = await page.evaluate(async () => {
const { combat, game, party } = await window.romStatusStores();
const state = combat.getState();
return { dungeonId: game.getState().activeDungeonId,
boss: Object.values(state.mobs).filter((mob) => mob.boss).map((mob) => mob.name),
classId: state.classId, level: state.level, mana: state.resourcePools.mana,
maximum: state.maxResourcePools.mana, members: party.getState().members.map((member) => ({ name: member.name, role: member.role })),
aura: state.auras.filter((aura) => aura.definition.id === 'healer-mana-recovery'), assets: performance.getEntriesByType('resource').filter((entry) => /assets\/.*\.js/.test(entry.name)).map((entry) => entry.name) };
});
metadata.boss = [selectedBoss];
assert.equal(metadata.dungeonId, 'bite-sized-stormwind-stockade');
assert(metadata.aura.length > 0, 'Recovery buff is visible out of combat');
await page.screenshot({ path: path.join(directory, 'buff-enabled.png') });
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Options', exact: true }).click();
const row = page.locator('.option-row').filter({ hasText: 'Healer mana recovery' });
await row.scrollIntoViewIfNeeded();
assert(await row.getByRole('button', { name: 'On', exact: true }).count());
await page.screenshot({ path: path.join(directory, 'menu-enabled.png') });
await page.setViewportSize({ width: 960, height: 540 });
await row.scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(directory, 'menu-compact.png') });
await page.setViewportSize({ width: 1440, height: 900 });
await row.getByRole('button', { name: 'On', exact: true }).click();
await row.getByRole('button', { name: 'Off', exact: true }).waitFor();
const off = await page.evaluate(async () => {
const { combat, shell } = await window.romStatusStores();
return { enabled: combat.getState().settings.healerManaRecovery,
auraCount: combat.getState().auras.filter((aura) => aura.definition.id === 'healer-mana-recovery').length,
saved: shell.getState().activeCharacter?.settings?.healerManaRecovery };
});
assert.equal(off.enabled, false); assert.equal(off.auraCount, 0); assert.equal(off.saved, false);
await page.screenshot({ path: path.join(directory, 'menu-disabled.png') });
await row.getByRole('button', { name: 'Off', exact: true }).click();
await page.getByRole('button', { name: 'Return to pause menu' }).click();
await page.getByRole('button', { name: 'Resume game' }).click();
const labels = await page.locator('button.action-slot--friendly').evaluateAll((buttons) => buttons.map((button) => button.getAttribute('aria-label')));
let healButton = page.getByRole('button', { name: /^Urgent Heal,/ }).first();
if (!await healButton.count()) healButton = page.locator('button.action-slot--friendly').first();
const before = await page.evaluate(async () => { const { combat } = await window.romStatusStores(); return combat.getState().resource; });
await healButton.click();
const samples = [];
for (let index = 0; index < 40; index++) {
await page.waitForTimeout(100);
samples.push(await page.evaluate(async () => { const { combat } = await window.romStatusStores(); const state = combat.getState(); return { now: Date.now(), resource: state.resource, max: state.maxResource, casting: state.activeCast?.abilityId, engaged: Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead) }; }));
}
assert(samples.some((sample) => sample.resource < before), 'Ordinary healing cast spends mana');
const lowIndex = samples.findIndex((sample) => sample.resource === Math.min(...samples.map((entry) => entry.resource)));
assert(samples.slice(lowIndex + 1).some((sample) => sample.resource > samples[lowIndex].resource), 'Mana recovers between fights');
await page.screenshot({ path: path.join(directory, 'after-heal.png') });
fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify({ metadata, bossGuide, off, labels, before, samples, errors }, null, 2)+'\n');
assert.deepEqual(errors, []);
console.log(JSON.stringify({ dungeonId: metadata.dungeonId, boss: metadata.boss, off, before, minimum: Math.min(...samples.map((entry) => entry.resource)), after: samples.at(-1).resource, errors, directory }, null, 2));
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+88
View File
@@ -0,0 +1,88 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const id = process.argv[2];
if (!id || !/^[a-z0-9-]+$/.test(id)) throw new Error('Provide a dungeon ID for this targeted asset review.');
function option(name) {
const index = process.argv.indexOf(name);
if (index < 0) return null;
if (!process.argv[index + 1]) throw new Error(`${name} requires a value.`);
return process.argv[index + 1];
}
const override = option('--visual-override');
const hotspotsFile = option('--hotspots');
const runtimePreparation = process.argv.includes('--runtime-preparation');
const sampleMs = Number(option('--sample-ms') || 0);
if (!Number.isFinite(sampleMs) || sampleMs < 0 || sampleMs > 10_000) {
throw new Error('--sample-ms must be between 0 and 10000.');
}
const label = override ? path.basename(override, '.glb') : 'shipping';
const output = path.resolve(option('--output-dir')
|| `artifacts/playtest/wow-environment-quality/continuation/${id}-${label}${runtimePreparation ? '-runtime-prepared' : ''}`);
const hotspots = hotspotsFile ? JSON.parse(fs.readFileSync(hotspotsFile, 'utf8')) : [];
fs.mkdirSync(output, { recursive: true });
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const params = new URLSearchParams({ dungeon: id });
if (override) params.set('visualOverride', override);
if (runtimePreparation) params.set('runtimePreparation', '1');
const baseUrl = process.env.WOW_ENVIRONMENT_REVIEW_URL || 'http://127.0.0.1:4208';
try {
await page.goto(`${baseUrl}/scripts/dungeon-pipeline/environment-preview.html?${params}`, {
waitUntil: 'domcontentloaded', timeout: 180_000,
});
await page.waitForFunction(() => window.wowEnvironmentReview?.ready, null, { timeout: 180_000 });
if (runtimePreparation && !(await page.evaluate(() => window.wowEnvironmentReview.diagnostics().runtimePreparation === true))) {
throw new Error('The preview renderer is stale; restart the preview server before runtime-prepared checks.');
}
await page.evaluate(() => window.wowEnvironmentReview.setQuality('high'));
const ground = await page.evaluate(() => window.wowEnvironmentReview.groundSamples());
const viewpoints = await page.evaluate(() => window.wowEnvironmentReview.viewpoints);
const captures = [];
async function diagnostics() {
if (sampleMs) {
await page.evaluate(() => window.wowEnvironmentReview.resetFrameSample());
await page.waitForTimeout(sampleMs);
}
return page.evaluate((sampled) => ({ ...window.wowEnvironmentReview.diagnostics(),
...(sampled ? { frameSample: window.wowEnvironmentReview.frameSample() } : {}) }), Boolean(sampleMs));
}
const indices = [...new Set([0, Math.floor((viewpoints.length - 1) / 2), viewpoints.length - 1])];
for (const index of indices) {
for (const [heading, yaw] of [['forward', 0], ['side', Math.PI / 2], ['reverse', Math.PI]]) {
await page.evaluate(({ index, yaw }) => window.wowEnvironmentReview.view(index, yaw), { index, yaw });
await page.waitForTimeout(500);
const file = `view-${index}-${heading}.png`;
await page.screenshot({ path: path.join(output, file) });
captures.push({ name: viewpoints[index].name, heading, file, diagnostics: await diagnostics() });
}
}
for (const [index, hotspot] of hotspots.entries()) {
if (!Array.isArray(hotspot.eye) || !Array.isArray(hotspot.target)
|| [...hotspot.eye, ...hotspot.target].length !== 6
|| ![...hotspot.eye, ...hotspot.target].every(Number.isFinite)) {
throw new Error(`Hotspot ${index} requires finite eye and target triples.`);
}
await page.evaluate(({ eye, target }) => window.wowEnvironmentReview.setCamera(eye, target), hotspot);
await page.waitForTimeout(500);
const file = `hotspot-${index}.png`;
await page.screenshot({ path: path.join(output, file) });
captures.push({ ...hotspot, file, diagnostics: await diagnostics() });
}
const result = { scope: 'targeted isolated asset review; no gameplay certification', id, override,
runtimePreparation, sampleMs, errors, ground, captures };
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
if (errors.length) throw new Error(errors.join('\n'));
console.log(JSON.stringify({ id, override, output, captures: captures.length, errors }));
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png') }).catch(() => {});
throw error;
} finally {
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -28,6 +28,7 @@ import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-su
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
import { writeBoundedCollisionGlbs } from "./lib/bounded-collision-glb.mjs";
import { validateNativeAlphaResourceCache } from "./lib/native-alpha-cache-validation.mjs";
import { sourceNavigationAnchors } from "./lib/source-navigation-anchors.mjs";
const argv = process.argv.slice(2);
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
@@ -889,11 +890,15 @@ async function bakeNavigation() {
}));
const navigationFile = path.join(convertedDirectory, slug + "-navigation.glb");
const reportFile = path.join(convertedDirectory, "recast-report.json");
const detourAnchorPositions = recipe.navigation.sourceAnchorComponents
? sourceNavigationAnchors(recipe, (await primaryPlacement()).placement)
: [];
await bakeNavigationFromCollisionAssets({
slug,
collisionAssets,
settings: recipe.navigation,
includeDetourTopology: recipe.navigation.includeDetourTopology === true,
detourAnchorPositions,
output: navigationFile,
reportFile,
source: {
@@ -0,0 +1,55 @@
import { Quaternion, Vector3 } from "three";
export function sourceNavigationAnchors(recipe, placement) {
const policy = recipe.navigation?.sourceAnchorComponents;
if (!policy) return [];
if (policy.kind !== "native-source-anchor-components-v1"
|| recipe.navigation.includeDetourTopology !== true) {
throw new Error("Source anchor components require the supported policy and Detour topology.");
}
const finiteVector = (values, length) => Array.isArray(values)
&& values.length === length && values.every(Number.isFinite);
if (!finiteVector(placement.translation, 3) || !finiteVector(placement.rotation, 4)
|| !finiteVector(placement.scale, 3) || placement.scale.some(value => value === 0)
|| !Number.isFinite(recipe.coordinateSystem.metersPerSourceUnit)
|| recipe.coordinateSystem.metersPerSourceUnit <= 0) {
throw new Error("Source navigation anchors require a finite invertible primary WDB placement.");
}
const transform = sourcePosition => {
if (!finiteVector(sourcePosition, 3)) throw new Error("Source navigation anchor requires finite XYZ coordinates.");
const point = new Vector3(...sourcePosition).sub(new Vector3(...placement.translation))
.applyQuaternion(new Quaternion(...placement.rotation).invert())
.divide(new Vector3(...placement.scale));
const meters = recipe.coordinateSystem.metersPerSourceUnit;
return [point.x * meters, point.y * meters, -point.z * meters];
};
if (!Array.isArray(recipe.source.sourceAnchorSupport?.points)
|| !Number.isFinite(policy.maximumProjectionDistance)
|| policy.maximumProjectionDistance <= 0
|| !Array.isArray(policy.requiredSourcePoints) || !policy.requiredSourcePoints.length) {
throw new Error("Source navigation selection needs native anchor points, a projection limit and required source guards.");
}
const anchors = recipe.source.sourceAnchorSupport.points.map((sourcePosition, index) => ({
id: `native-source-anchor-${index}`, kind: "native-source-anchor",
sourcePosition: [...sourcePosition], position: transform(sourcePosition),
maximumDistance: policy.maximumProjectionDistance,
}));
for (const guard of policy.requiredSourcePoints) {
if (typeof guard.id !== "string" || !guard.id.trim() || !guard.evidence
|| !Number.isFinite(guard.maximumDistance) || guard.maximumDistance <= 0) {
throw new Error("Required source navigation guards need an ID, evidence and a projection limit.");
}
if (guard.kind === "source-entry"
&& JSON.stringify(guard.sourcePosition) !== JSON.stringify(recipe.source.entryCoordinates?.position)) {
throw new Error("The navigation entry guard must match the authoritative source entry.");
}
anchors.push({
...guard, sourcePosition: [...guard.sourcePosition],
position: transform(guard.sourcePosition), required: true,
});
}
if (new Set(anchors.map(anchor => anchor.id)).size !== anchors.length) {
throw new Error("Source navigation anchor IDs must be unique.");
}
return anchors;
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import { sourceNavigationAnchors } from "./source-navigation-anchors.mjs";
test("Raksha anchor policy reproduces source entrance and boss positions with strict guards", async () => {
const recipe = JSON.parse(await readFile(new URL("../recipes/raksha-temple.json", import.meta.url), "utf8"));
const anchors = sourceNavigationAnchors(recipe, {
translation: [1857.20312, 86.8668671, 1274.34656], rotation: [0, 0, 0, 1], scale: [1, 1, 1],
});
const entry = anchors.find(anchor => anchor.id === "lua-entry");
const boss = anchors.find(anchor => anchor.id === "shadoj-source-spawn");
assert.ok(Math.abs(entry.position[1] - 21.01331329) < 1e-8);
assert.ok(Math.abs(entry.position[2] - 11.334656) < 1e-8);
assert.ok(Math.abs(boss.position[0] + 115.04822948) < 1e-8);
assert.equal(entry.maximumDistance, 0.5);
assert.equal(boss.maximumDistance, 0.25);
assert.equal(entry.required, true);
assert.equal(boss.required, true);
const altered = structuredClone(recipe);
altered.source.entryCoordinates.position[1] += 1;
assert.throws(() => sourceNavigationAnchors(altered, {
translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1],
}), /must match the authoritative source entry/);
});
test("source anchor conversion includes inverse parent rotation, scale and handedness", () => {
const recipe = {
coordinateSystem: { metersPerSourceUnit: 0.1 },
source: { sourceAnchorSupport: { points: [[14, 8, 18]] } },
navigation: { includeDetourTopology: true, sourceAnchorComponents: {
kind: "native-source-anchor-components-v1", maximumProjectionDistance: 4,
requiredSourcePoints: [{ id: "guard", sourcePosition: [14, 8, 18], maximumDistance: 0.5, evidence: "fixture" }],
} },
};
const anchors = sourceNavigationAnchors(recipe, {
translation: [10, 2, 20], rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2], scale: [2, 3, 4],
});
for (const [axis, expected] of [0.1, 0.2, -0.1].entries()) {
assert.ok(Math.abs(anchors[0].position[axis] - expected) < 1e-8);
}
});
@@ -689,6 +689,27 @@
"agentRadius": 0.36,
"agentMaxClimb": 0.5,
"agentMaxSlope": 50,
"includeDetourTopology": true,
"sourceAnchorComponents": {
"kind": "native-source-anchor-components-v1",
"maximumProjectionDistance": 4,
"requiredSourcePoints": [
{
"id": "lua-entry",
"kind": "source-entry",
"sourcePosition": [1857, 297, 1161],
"maximumDistance": 0.5,
"evidence": "Resource/luascript/03246.lua:MS_TEST_DUNGEONS"
},
{
"id": "shadoj-source-spawn",
"kind": "native-boss",
"sourcePosition": [706.7208251953125, 186.57839965820312, -1572.79296875],
"maximumDistance": 0.25,
"evidence": "ROM_Global.dbo.NPCData source spawn 41884920; raksha-temple-zone-130 snapshot"
}
]
},
"tileSize": 64,
"retainTileIntermediates": false
},