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

881 lines
46 KiB
JavaScript

#!/usr/bin/env node
import { gunzipSync } from "node:zlib";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import {
mappingMetrics,
nodeInstanceTransforms,
transformInstancedPoint,
triangleArea,
} from "../runewaker-pipeline/audit-environment-quality.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const campaignCatalogPath = path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.compressed.ts");
const publicRoot = path.join(projectRoot, "public");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK
?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const defaultJsonPath = path.join(projectRoot, "artifacts/dungeon-pipeline/wow-visual-quality-audit.json");
const defaultMarkdownPath = path.join(projectRoot, "artifacts/dungeon-pipeline/wow-visual-quality-audit.md");
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
const WRAP_NAMES = new Map([[10497, "repeat"], [33071, "clamp-to-edge"], [33648, "mirrored-repeat"]]);
const MIN_FILTER_NAMES = new Map([[9728, "nearest"], [9729, "linear"], [9984, "nearest-mipmap-nearest"], [9985, "linear-mipmap-nearest"], [9986, "nearest-mipmap-linear"], [9987, "linear-mipmap-linear"]]);
const MAG_FILTER_NAMES = new Map([[9728, "nearest"], [9729, "linear"]]);
function projectPath(file) {
return path.relative(projectRoot, file).split(path.sep).join("/");
}
function round(value, digits = 3) {
if (!Number.isFinite(value)) return null;
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
async function exists(file) {
try {
return (await stat(file)).isFile();
} catch {
return false;
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
function parseGlb(buffer, label) {
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF"
|| buffer.readUInt32LE(4) !== 2 || buffer.readUInt32LE(8) > buffer.length) {
throw new Error(`${label} is not a valid binary glTF 2.0 file.`);
}
let json = null;
let binary = null;
let offset = 12;
while (offset + 8 <= buffer.length) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
if (dataEnd > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`);
const chunk = buffer.subarray(dataStart, dataEnd);
if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = chunk;
offset = dataEnd;
}
if (!json || !binary) throw new Error(`${label} must contain JSON and BIN chunks.`);
return { json, binary };
}
function canonicalImageName(value) {
const basename = path.posix.basename(String(value ?? "").replaceAll("\\", "/"));
return basename.replace(/\.[^.]+$/u, "").replace(/^[0-9a-f]{8}_/iu, "").toLowerCase();
}
function parseKtx2(bytes) {
if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) return null;
const vkFormat = bytes.readUInt32LE(12);
const supercompressionScheme = bytes.readUInt32LE(44);
return {
width: bytes.readUInt32LE(20),
height: bytes.readUInt32LE(24),
mipLevels: Math.max(1, bytes.readUInt32LE(40)),
vkFormat,
supercompressionScheme,
codec: vkFormat === 0 && supercompressionScheme === 1
? "ETC1S/BasisLZ"
: vkFormat === 0 && supercompressionScheme === 2
? "UASTC/Zstandard"
: "KTX2-other",
byteLength: bytes.length,
};
}
function imageBytes(parsed, image, label) {
if (image.bufferView === undefined) return null;
const view = parsed.json.bufferViews?.[image.bufferView];
if (!view) throw new Error(`${label} references missing image bufferView ${image.bufferView}.`);
const start = view.byteOffset ?? 0;
const end = start + view.byteLength;
if (end > parsed.binary.length) throw new Error(`${label} image bufferView exceeds the GLB BIN chunk.`);
return parsed.binary.subarray(start, end);
}
async function inspectImage(parsed, image, imageIndex, label) {
const bytes = imageBytes(parsed, image, `${label} image ${image.name ?? imageIndex}`);
if (!bytes) return {
imageIndex,
name: image.name ?? image.uri ?? `image-${imageIndex}`,
key: canonicalImageName(image.name ?? image.uri),
mimeType: image.mimeType ?? null,
width: null,
height: null,
codec: "external-or-missing",
mipLevels: null,
byteLength: null,
};
const ktx = image.mimeType === "image/ktx2" ? parseKtx2(bytes) : null;
if (image.mimeType === "image/ktx2" && !ktx) {
throw new Error(`${label} image ${image.name ?? imageIndex} declares KTX2 but has an invalid header.`);
}
if (ktx) return {
imageIndex,
name: image.name ?? image.uri ?? `image-${imageIndex}`,
key: canonicalImageName(image.name ?? image.uri),
mimeType: image.mimeType,
...ktx,
};
const dimensions = await sharp(bytes).metadata().catch(() => ({}));
return {
imageIndex,
name: image.name ?? image.uri ?? `image-${imageIndex}`,
key: canonicalImageName(image.name ?? image.uri),
mimeType: image.mimeType ?? null,
width: dimensions.width ?? null,
height: dimensions.height ?? null,
codec: "non-KTX2 image",
mipLevels: null,
byteLength: bytes.length,
};
}
function textureImageIndex(texture) {
return texture.extensions?.KHR_texture_basisu?.source ?? texture.source;
}
function textureRoles(json) {
const roles = new Map();
const baseColorInfos = [];
const add = (index, role) => {
if (!Number.isInteger(index)) return;
const entry = roles.get(index) ?? new Set();
entry.add(role);
roles.set(index, entry);
};
for (const [materialIndex, material] of (json.materials ?? []).entries()) {
const pbr = material.pbrMetallicRoughness;
const base = pbr?.baseColorTexture;
if (base) {
add(base.index, "baseColor");
const transform = base.extensions?.KHR_texture_transform;
baseColorInfos.push({
materialIndex,
textureIndex: base.index,
texCoord: transform?.texCoord ?? base.texCoord ?? 0,
transform,
});
}
add(pbr?.metallicRoughnessTexture?.index, "metallicRoughness");
add(material.normalTexture?.index, "normal");
add(material.occlusionTexture?.index, "occlusion");
add(material.emissiveTexture?.index, "emissive");
}
return { roles, baseColorInfos };
}
function textureSamplerSummary(json, textureIndex) {
const samplerIndex = json.textures?.[textureIndex]?.sampler;
const sampler = Number.isInteger(samplerIndex) ? json.samplers?.[samplerIndex] ?? {} : {};
const minFilter = sampler.minFilter ?? null;
const magFilter = sampler.magFilter ?? null;
const wrapS = sampler.wrapS ?? 10497;
const wrapT = sampler.wrapT ?? 10497;
return {
minFilter: minFilter === null ? "default" : (MIN_FILTER_NAMES.get(minFilter) ?? String(minFilter)),
magFilter: magFilter === null ? "default" : (MAG_FILTER_NAMES.get(magFilter) ?? String(magFilter)),
wrapS: WRAP_NAMES.get(wrapS) ?? String(wrapS),
wrapT: WRAP_NAMES.get(wrapT) ?? String(wrapT),
explicitSampler: Number.isInteger(samplerIndex),
};
}
async function auditGlb(file, label) {
const bytes = await readFile(file);
const parsed = parseGlb(bytes, label);
const [images, fileStats] = await Promise.all([
Promise.all((parsed.json.images ?? []).map((image, index) => inspectImage(parsed, image, index, label))),
stat(file),
]);
const { roles, baseColorInfos } = textureRoles(parsed.json);
const textures = (parsed.json.textures ?? []).map((texture, textureIndex) => {
const imageIndex = textureImageIndex(texture);
const image = images[imageIndex];
const textureRolesForImage = [...(roles.get(textureIndex) ?? [])].sort();
return {
textureIndex,
name: texture.name ?? image?.name ?? `texture-${textureIndex}`,
imageIndex: imageIndex ?? null,
imageName: image?.name ?? null,
imageKey: image?.key ?? "",
width: image?.width ?? null,
height: image?.height ?? null,
mimeType: image?.mimeType ?? null,
codec: image?.codec ?? "missing-image",
mipLevels: image?.mipLevels ?? null,
roles: textureRolesForImage,
sampler: textureSamplerSummary(parsed.json, textureIndex),
};
});
const codecs = {};
for (const texture of textures) {
const role = texture.roles.includes("baseColor") ? "baseColor" : "nonBaseColor";
const key = `${role}:${texture.codec}`;
codecs[key] = (codecs[key] ?? 0) + 1;
}
const samplerModes = {};
for (const base of baseColorInfos) {
const sampler = textureSamplerSummary(parsed.json, base.textureIndex);
const key = `${sampler.minFilter}/${sampler.magFilter}/${sampler.wrapS}/${sampler.wrapT}`;
samplerModes[key] = (samplerModes[key] ?? 0) + 1;
}
const uvSets = {};
let singularTextureTransforms = 0;
for (const info of baseColorInfos) {
uvSets[info.texCoord] = (uvSets[info.texCoord] ?? 0) + 1;
const scale = info.transform?.scale ?? [1, 1];
if (Math.abs((scale[0] ?? 1) * (scale[1] ?? 1)) <= 1e-12) singularTextureTransforms += 1;
}
const imageRows = images.map((image) => ({
...image,
roles: [...new Set(textures.filter((texture) => texture.imageIndex === image.imageIndex)
.flatMap((texture) => texture.roles))].sort(),
}));
return {
file: projectPath(file),
bytes: fileStats.size,
imageCount: images.length,
textureCount: textures.length,
baseColorTextureCount: baseColorInfos.length,
ktx2ImageCount: images.filter((image) => image.codec.startsWith("ETC1S") || image.codec.startsWith("UASTC") || image.codec === "KTX2-other").length,
fallbackImageCount: images.filter((image) => image.codec === "non-KTX2 image" || image.codec === "external-or-missing").length,
codecs,
samplerModes,
baseColorUvSets: uvSets,
singularTextureTransforms,
images: imageRows,
textures,
};
}
function applyTextureTransform(uv, transform) {
if (!transform) return uv;
const scale = transform.getScale();
const offset = transform.getOffset();
const rotation = transform.getRotation();
const x = uv[0] * scale[0];
const y = uv[1] * scale[1];
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
return [offset[0] + cosine * x - sine * y, offset[1] + sine * x + cosine * y];
}
function cross(a, b) {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
function subtract(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function emptySurfaceMetrics() {
return {
triangleCount: 0,
uvMissingTriangles: 0,
uvDegenerateTriangles: 0,
rankCollapsedTriangles: 0,
worldAreaM2: 0,
uvDegenerateAreaM2: 0,
rankCollapsedAreaM2: 0,
};
}
function textureSizeByIndex(json, images) {
return (json.textures ?? []).map((texture) => {
const imageIndex = textureImageIndex(texture);
const image = images[imageIndex];
return image?.width && image?.height ? [image.width, image.height] : null;
});
}
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 });
const document = await io.read(file);
const totals = {
triangleCount: 0,
uvMissingTriangles: 0,
uvDegenerateTriangles: 0,
rankCollapsedTriangles: 0,
worldAreaM2: 0,
uvDegenerateAreaM2: 0,
rankCollapsedAreaM2: 0,
};
const materialCoverage = {
totalTriangles: 0,
baseColorTexturedTriangles: 0,
untexturedTriangles: 0,
baseColorTexturedAreaM2: 0,
untexturedAreaM2: 0,
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) => {
sceneNodes.push(node);
for (const child of node.listChildren()) visit(child);
};
for (const node of scene.listChildren()) visit(node);
for (const node of sceneNodes) {
const mesh = node.getMesh();
if (!mesh) continue;
const worldMatrix = node.getWorldMatrix();
const instances = nodeInstanceTransforms(node);
for (const instance of instances) {
for (const primitive of mesh.listPrimitives()) {
if (primitive.getMode() !== 4) continue;
const material = primitive.getMaterial();
const texture = material?.getBaseColorTexture();
const info = material?.getBaseColorTextureInfo();
const hasBaseColorTexture = Boolean(texture && info);
const textureIndex = document.getRoot().listTextures().indexOf(texture);
const size = textureSizes[textureIndex];
const texCoord = info?.getExtension("KHR_texture_transform")?.getTexCoord()
?? info?.getTexCoord()
?? 0;
const positions = primitive.getAttribute("POSITION");
const uvs = primitive.getAttribute(`TEXCOORD_${texCoord}`);
const indices = primitive.getIndices();
if (!positions) continue;
const indexCount = indices?.getCount() ?? positions.getCount();
if (indexCount % 3 !== 0) throw new Error(`${label}: non-triangle index count in ${node.getName() || "unnamed node"}.`);
for (let offset = 0; offset < indexCount; offset += 3) {
const vertexIndices = [0, 1, 2].map((step) => indices?.getScalar(offset + step) ?? offset + step);
const points = vertexIndices.map((vertexIndex) => transformInstancedPoint(
worldMatrix,
positions.getElement(vertexIndex, [0, 0, 0]),
instance,
));
const area = triangleArea(points);
if (!(area > 1e-12)) continue;
materialCoverage.totalTriangles += 1;
if (hasBaseColorTexture) {
materialCoverage.baseColorTexturedTriangles += 1;
materialCoverage.baseColorTexturedAreaM2 += area;
} else {
materialCoverage.untexturedTriangles += 1;
materialCoverage.untexturedAreaM2 += area;
const name = material?.getName() || "(no material)";
const row = materialCoverage.untexturedMaterials[name] ?? {
triangles: 0,
worldAreaM2: 0,
baseColorFactor: material?.getBaseColorFactor() ?? null,
};
row.triangles += 1;
row.worldAreaM2 += area;
materialCoverage.untexturedMaterials[name] = row;
}
if (!hasBaseColorTexture) continue;
const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0]));
const normalLength = Math.hypot(...normal);
const normalY = normalLength > 0 ? normal[1] / normalLength : 0;
const surfaceKey = normalY >= 0.7 ? "floor" : Math.abs(normalY) <= 0.3 ? "wall" : "other";
const surface = bySurface[surfaceKey];
for (const metric of [totals, surface]) {
metric.triangleCount += 1;
metric.worldAreaM2 += area;
}
if (!uvs || !size) {
for (const metric of [totals, surface]) metric.uvMissingTriangles += 1;
continue;
}
const uvValues = vertexIndices.map((vertexIndex) => applyTextureTransform(
uvs.getElement(vertexIndex, [0, 0]),
info.getExtension("KHR_texture_transform"),
));
const mapping = mappingMetrics(points, uvValues, size[0], size[1]);
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 (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);
}
}
}
}
}
}
for (const metric of [totals, ...Object.values(bySurface)]) {
metric.worldAreaM2 = round(metric.worldAreaM2, 2);
metric.uvDegenerateAreaM2 = round(metric.uvDegenerateAreaM2, 2);
metric.rankCollapsedAreaM2 = round(metric.rankCollapsedAreaM2, 2);
}
materialCoverage.baseColorTexturedAreaM2 = round(materialCoverage.baseColorTexturedAreaM2, 2);
materialCoverage.untexturedAreaM2 = round(materialCoverage.untexturedAreaM2, 2);
for (const material of Object.values(materialCoverage.untexturedMaterials)) {
material.worldAreaM2 = round(material.worldAreaM2, 2);
}
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) {
const sourceByKey = new Map();
for (const image of source.images) {
if (!image.width || !image.height || !image.key) continue;
const matches = sourceByKey.get(image.key) ?? [];
matches.push(image);
sourceByKey.set(image.key, matches);
}
const rows = [];
for (const image of shipped.images) {
const candidates = sourceByKey.get(image.key) ?? [];
const sourceImage = candidates.find((candidate) => candidate.width === image.width && candidate.height === image.height)
?? candidates[0]
?? null;
rows.push({
name: image.name,
key: image.key,
sourceWidth: sourceImage?.width ?? null,
sourceHeight: sourceImage?.height ?? null,
shippedWidth: image.width,
shippedHeight: image.height,
matched: Boolean(sourceImage),
dimensionsRetained: Boolean(sourceImage && image.width === sourceImage.width && image.height === sourceImage.height),
downsized: Boolean(sourceImage && image.width && image.height
&& (image.width < sourceImage.width || image.height < sourceImage.height)),
capHitAt2048: Boolean(sourceImage && Math.max(sourceImage.width, sourceImage.height) > 2048
&& Math.max(image.width ?? 0, image.height ?? 0) === 2048),
ambiguousSourceName: candidates.length > 1,
});
}
return {
shippedImages: rows.length,
matchedImages: rows.filter((row) => row.matched).length,
dimensionsRetained: rows.filter((row) => row.dimensionsRetained).length,
downsizedImages: rows.filter((row) => row.downsized).length,
capHitsAt2048: rows.filter((row) => row.capHitAt2048).length,
unmatchedImages: rows.filter((row) => !row.matched).slice(0, 20),
downsizedExamples: rows.filter((row) => row.downsized).slice(0, 30),
ambiguousSourceNames: rows.filter((row) => row.ambiguousSourceName).length,
rows,
};
}
function delta(after, before, key) {
if (!after || !before) return null;
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, ""));
const relative = path.relative(publicRoot, file);
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
return file;
}
async function findCanonicalSource(slug) {
const candidates = [
path.join(workRoot, slug, "staging", "environment", `${slug}-visual.glb`),
path.join(workRoot, slug, "staging", "environment", "conversion-report.json"),
];
const source = candidates[0];
if (await exists(source)) return { path: source, kind: "lossless-converted-source-glb", candidates };
return { path: null, kind: null, candidates };
}
async function readWowDefinitions() {
const source = await readFile(campaignCatalogPath, "utf8");
const base64 = source.match(/export default "([^"]+)"/u)?.[1];
if (!base64) throw new Error("Could not read generated WoW campaign catalog payload.");
const catalog = JSON.parse(gunzipSync(Buffer.from(base64, "base64")));
const definitions = (catalog.definitions ?? [])
.filter((definition) => definition.expansion !== "runewaker")
.sort((left, right) => left.id.localeCompare(right.id));
if (definitions.length !== 54) {
throw new Error(`Expected 54 WoW campaign definitions, found ${definitions.length}.`);
}
return definitions;
}
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")
: null;
const publishedPath = visualOverride ?? authoredRuntimePath ?? resolvePublishedPath(visualEntry?.url);
const source = await findCanonicalSource(definition.id);
const packageManifest = path.join(publicRoot, "assets/game/dungeons", definition.id, "dungeon-pack.json");
const row = {
id: definition.id,
expansion: definition.expansion,
mapId: definition.mapId ?? null,
publishedUrl: visualOverride
? `audit override: ${projectPath(visualOverride)}`
: authoredRuntimePath ? "bundled runtime asset (authored Wailing Caverns visual)" : visualEntry?.url ?? null,
runtimeAssetKind: visualOverride ? "audit-override-candidate"
: authoredRuntimePath ? "bundled-authored-source-asset" : "public-generated-visual",
packageManifest: await exists(packageManifest) ? projectPath(packageManifest) : null,
sourceStatus: source.path ? "canonical-lossless-source-available" : "canonical-lossless-source-missing",
sourceFile: source.path ? projectPath(source.path) : null,
publishedStatus: publishedPath && await exists(publishedPath) ? "published-visual-found" : "published-visual-missing",
issues: [],
};
if (!publishedPath || !(await exists(publishedPath))) {
row.issues.push("Runtime visual asset does not resolve to a shipped GLB.");
return row;
}
try {
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`, { 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`, { hotspotCellSizeM });
row.textureComparison = compareImages(sourceAudit, row.shipped);
row.geometryComparison = {
triangleCountDelta: row.shippedGeometry.triangleCount - row.sourceGeometry.triangleCount,
uvMissingTriangleDelta: row.shippedGeometry.uvMissingTriangles - row.sourceGeometry.uvMissingTriangles,
uvDegenerateTriangleDelta: row.shippedGeometry.uvDegenerateTriangles - row.sourceGeometry.uvDegenerateTriangles,
rankCollapsedTriangleDelta: row.shippedGeometry.rankCollapsedTriangles - row.sourceGeometry.rankCollapsedTriangles,
worldAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "worldAreaM2"),
uvDegenerateAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "uvDegenerateAreaM2"),
rankCollapsedAreaM2Delta: delta(row.shippedGeometry, row.sourceGeometry, "rankCollapsedAreaM2"),
materialCoverage: {
totalTrianglesDelta: row.shippedGeometry.materialCoverage.totalTriangles - row.sourceGeometry.materialCoverage.totalTriangles,
baseColorTexturedTrianglesDelta: row.shippedGeometry.materialCoverage.baseColorTexturedTriangles - row.sourceGeometry.materialCoverage.baseColorTexturedTriangles,
untexturedTrianglesDelta: row.shippedGeometry.materialCoverage.untexturedTriangles - row.sourceGeometry.materialCoverage.untexturedTriangles,
baseColorTexturedAreaM2Delta: round(row.shippedGeometry.materialCoverage.baseColorTexturedAreaM2
- row.sourceGeometry.materialCoverage.baseColorTexturedAreaM2, 2),
untexturedAreaM2Delta: round(row.shippedGeometry.materialCoverage.untexturedAreaM2
- row.sourceGeometry.materialCoverage.untexturedAreaM2, 2),
},
bySurface: Object.fromEntries(["floor", "wall", "other"].map((surface) => {
const shippedMetric = row.shippedGeometry.bySurface[surface];
const sourceMetric = row.sourceGeometry.bySurface[surface];
return [surface, {
triangleCountDelta: shippedMetric.triangleCount - sourceMetric.triangleCount,
uvDegenerateTriangleDelta: shippedMetric.uvDegenerateTriangles - sourceMetric.uvDegenerateTriangles,
rankCollapsedTriangleDelta: shippedMetric.rankCollapsedTriangles - sourceMetric.rankCollapsedTriangles,
uvDegenerateAreaM2Delta: round(shippedMetric.uvDegenerateAreaM2 - sourceMetric.uvDegenerateAreaM2, 2),
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.`);
}
if (row.shipped.fallbackImageCount) row.issues.push(`${row.shipped.fallbackImageCount} shipped images are not embedded KTX2.`);
if (row.geometryComparison.uvDegenerateTriangleDelta > 0) {
row.issues.push(`Shipped base-color geometry adds ${row.geometryComparison.uvDegenerateTriangleDelta} UV-degenerate triangles versus source.`);
}
if (row.geometryComparison.rankCollapsedTriangleDelta > 0) {
row.issues.push(`Shipped base-color geometry adds ${row.geometryComparison.rankCollapsedTriangleDelta} rank-collapsed triangles versus source.`);
}
} else if (row.shipped.fallbackImageCount) {
row.issues.push(`${row.shipped.fallbackImageCount} shipped images are not embedded KTX2.`);
}
} catch (error) {
row.status = "audit-error";
row.issues.push(error.message);
}
return row;
}
function sumBy(rows, getter) {
return rows.reduce((sum, row) => sum + getter(row), 0);
}
function markdownTable(rows) {
const lines = [
"| WoW dungeon | Type | Source | Texture match | Downsized | KTX2 color ETC1S | KTX2 data UASTC | UV triangles source → runtime | Degenerate UV Δ | Rank collapse Δ m² floor/wall | Untextured material area source → runtime m² |",
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
];
for (const row of rows) {
const comparison = row.textureComparison;
const geometry = row.geometryComparison;
const colorCount = row.shipped?.codecs?.["baseColor:ETC1S/BasisLZ"] ?? 0;
const dataCount = row.shipped?.codecs?.["nonBaseColor:UASTC/Zstandard"] ?? 0;
const sourceTriangles = row.sourceGeometry?.triangleCount;
const shippedTriangles = row.shippedGeometry?.triangleCount;
const floorDelta = geometry?.bySurface.floor.rankCollapsedAreaM2Delta;
const wallDelta = geometry?.bySurface.wall.rankCollapsedAreaM2Delta;
const untexturedSource = row.sourceGeometry?.materialCoverage.untexturedAreaM2;
const untexturedRuntime = row.shippedGeometry?.materialCoverage.untexturedAreaM2;
lines.push(`| ${row.id} | ${row.expansion} | ${row.source ? "available" : "missing"} | ${comparison ? `${comparison.matchedImages}/${comparison.shippedImages}` : "—"} | ${comparison?.downsizedImages ?? "—"} | ${colorCount} | ${dataCount} | ${sourceTriangles === undefined ? "—" : `${sourceTriangles.toLocaleString()} → ${shippedTriangles.toLocaleString()}`} | ${geometry?.uvDegenerateTriangleDelta ?? "—"} | ${floorDelta === undefined || wallDelta === undefined ? "—" : `${floorDelta} / ${wallDelta}`} | ${untexturedSource === undefined ? "—" : `${untexturedSource} → ${untexturedRuntime}`} |`);
}
return lines.join("\n");
}
function makeMarkdown(report) {
const coverageSummary = report.selection.completeWoWCatalog
? `Coverage: **${report.summary.publishedVisuals}/${report.summary.definitionCount}** runtime visuals found; **${report.summary.canonicalSources}/${report.summary.definitionCount}** matching lossless converted source GLBs available; **${report.summary.auditedComparisons}** source-to-runtime comparisons completed.`
: `Selected audit: **${report.summary.publishedVisuals}** runtime visual(s) inspected (${report.selection.ids.join(", ")}); the full catalog contains 54. **${report.summary.canonicalSources}** canonical source(s) were compared.`;
const lines = [
"# WoW dungeon visual texture and UV quality audit",
"",
`Generated: ${report.generatedAt}`,
"",
coverageSummary,
"",
report.selection.completeWoWCatalog
? "Scope follows all 54 non-RoM definitions in the generated WoW campaign catalog, including the four fallback/custom definitions. Fifty-three runtime visuals resolve to public Manastorm GLBs; the registry replaces Wailing Caverns with its bundled authored visual at `src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb`. Source comparisons use the pipeline's pre-optimization converted visual GLB under `HealerMan-Storage/pipeline-work/dungeons/<id>/staging/environment/`. Wailing Caverns has no canonical staged source GLB in the configured work root, so its dimension and geometry deltas are unverified."
: `Scope is limited to the selected runtime visual(s). Canonical source comparisons use the pre-optimization converted GLB under the configured dungeon pipeline work root. ${report.selection.visualOverride ? "The runtime path was overridden for a staged candidate comparison." : ""}`,
"",
"The inventory records source and runtime image dimensions, KTX2 codec and mip levels, material texture roles, glTF sampler modes, base-color UV sets, and singular UV transforms. Material coverage also counts textured and untextured triangle area by material. Geometry comparisons expand scene-node and GPU-instance transforms and use the same per-triangle texture-to-world singular-value calculation as the RoM environment quality audit. A rank-collapsed triangle has a minimum mapped stretch at or below 0.01 px/m; degenerate UV triangles have zero mapped area. Positive source-to-runtime deltas are candidate regressions. Triangle totals include repeated GPU instances and all base-color textured surfaces, with floor/wall separation for rank deltas.",
"",
`Across ${report.summary.publishedVisuals} runtime visuals, ${report.summary.shippedImages} image entries were found: ${report.summary.shippedKtx2Images} KTX2 and ${report.summary.shippedFallbackImages} non-KTX2 or missing images. ${report.summary.matchedSourceImages}/${report.summary.shippedImagesWithSource} source-comparable runtime images matched a source basename; ${report.summary.downsizedImages} matched images are smaller than source and ${report.summary.capHitsAt2048} hit the old 2048-pixel cap.`,
"",
`Published KTX2 role counts: **${report.summary.baseColorEtc1s} base-color ETC1S**, **${report.summary.baseColorUastc} base-color UASTC**, **${report.summary.dataUastc} non-color UASTC**, **${report.summary.dataEtc1s} non-color ETC1S**. This reports codec selection and dimensions; it does not decode KTX2 pixels for PSNR/MAE. The run-time anisotropy option is applied by scene material preparation and is not serialized in GLB samplers.`,
"",
"## Per-dungeon results",
"",
markdownTable(report.dungeons),
"",
"## Texture sampling inventory",
"",
`Base-color sampler modes: ${Object.entries(report.summary.baseColorSamplerModes).map(([mode, count]) => `\`${mode}\` (${count})`).join(", ") || "none"}. Base-color UV-set usage: ${Object.entries(report.summary.baseColorUvSets).map(([set, count]) => `UV${set} (${count})`).join(", ") || "none"}. Singular texture transforms: ${report.summary.singularTextureTransforms}.`,
"",
"## Residuals and limits",
"",
`Source dimension matches are exact basename joins, not pixel identity checks. ${report.summary.unmatchedSourceImages} shipped image names did not match a source image. ${report.summary.sourceDimensionComparisons} dimension comparisons were available. Numeric KTX2 pixel fidelity and visual review are not included in this report.`,
"",
"The generated source GLBs are the reviewed conversion outputs before the dungeon optimizer. They establish regeneration inputs for those packages; source `.blp`/`.dds` client archives are not needed for reoptimization. Missing-source entries require a source-export recovery before any shipped rewrite can be considered proven safe. Wailing Caverns' bundled runtime GLB can be inventoried but lacks a canonical comparison source.",
];
if (report.summary.positiveUvDegenerateRegressions || report.summary.positiveRankCollapseRegressions) {
lines.push("", `Source comparison flagged ${report.summary.positiveUvDegenerateRegressions} packages with more UV-degenerate area and ${report.summary.positiveRankCollapseRegressions} packages with more rank-collapsed area in runtime output. Review those rows and their source/runtime hotspot details before regeneration.`);
} else {
lines.push("", "No source-to-runtime increase in UV-degenerate or rank-collapsed area was found in the matched source comparisons. This supports preserving current packages while making the optimizer safe for future builds.");
}
return `${lines.join("\n")}\n`;
}
async function main() {
const jsonArg = process.argv.indexOf("--report-json");
const markdownArg = process.argv.indexOf("--report-markdown");
const reportJsonPath = path.resolve(jsonArg >= 0 ? process.argv[jsonArg + 1] : defaultJsonPath);
const reportMarkdownPath = path.resolve(markdownArg >= 0 ? process.argv[markdownArg + 1] : defaultMarkdownPath);
const allDefinitions = await readWowDefinitions();
const onlyArg = process.argv.indexOf("--only");
const onlyIds = onlyArg >= 0 ? process.argv[onlyArg + 1].split(",").map((id) => id.trim()).filter(Boolean) : null;
const overrideArg = process.argv.indexOf("--visual-override");
const overrideText = overrideArg >= 0 ? process.argv[overrideArg + 1] : null;
const overrideSeparator = overrideText?.indexOf("=") ?? -1;
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(", ")}.`);
const definitions = allDefinitions.filter((definition) => !selectedIds.size || selectedIds.has(definition.id));
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
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, hotspotCellSizeM));
}
const compared = dungeons.filter((dungeon) => dungeon.source && dungeon.shipped);
const shipped = dungeons.filter((dungeon) => dungeon.shipped);
const textureComparisons = compared.flatMap((dungeon) => dungeon.textureComparison.rows);
const surfaces = ["floor", "wall", "other"];
const summary = {
definitionCount: definitions.length,
publishedVisuals: shipped.length,
canonicalSources: dungeons.filter((dungeon) => dungeon.source).length,
auditedComparisons: compared.length,
packageManifests: dungeons.filter((dungeon) => dungeon.packageManifest).length,
missingPublishedVisuals: dungeons.filter((dungeon) => !dungeon.shipped).map((dungeon) => dungeon.id),
missingCanonicalSources: dungeons.filter((dungeon) => !dungeon.source).map((dungeon) => dungeon.id),
shippedImages: sumBy(shipped, (dungeon) => dungeon.shipped.imageCount),
shippedImagesWithSource: textureComparisons.length,
shippedKtx2Images: sumBy(shipped, (dungeon) => dungeon.shipped.ktx2ImageCount),
shippedFallbackImages: sumBy(shipped, (dungeon) => dungeon.shipped.fallbackImageCount),
matchedSourceImages: textureComparisons.filter((row) => row.matched).length,
sourceDimensionComparisons: textureComparisons.filter((row) => row.matched && row.sourceWidth && row.shippedWidth).length,
dimensionsRetained: textureComparisons.filter((row) => row.dimensionsRetained).length,
downsizedImages: textureComparisons.filter((row) => row.downsized).length,
capHitsAt2048: textureComparisons.filter((row) => row.capHitAt2048).length,
unmatchedSourceImages: textureComparisons.filter((row) => !row.matched).length,
positiveUvDegenerateRegressions: compared.filter((dungeon) => dungeon.geometryComparison.uvDegenerateAreaM2Delta > 1e-3).length,
positiveRankCollapseRegressions: compared.filter((dungeon) => dungeon.geometryComparison.rankCollapsedAreaM2Delta > 1e-3).length,
uvDegenerateTriangleDelta: sumBy(compared, (dungeon) => dungeon.geometryComparison.uvDegenerateTriangleDelta),
rankCollapsedTriangleDelta: sumBy(compared, (dungeon) => dungeon.geometryComparison.rankCollapsedTriangleDelta),
baseColorEtc1s: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["baseColor:ETC1S/BasisLZ"] ?? 0),
baseColorUastc: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["baseColor:UASTC/Zstandard"] ?? 0),
dataEtc1s: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["nonBaseColor:ETC1S/BasisLZ"] ?? 0),
dataUastc: sumBy(shipped, (dungeon) => dungeon.shipped.codecs["nonBaseColor:UASTC/Zstandard"] ?? 0),
singularTextureTransforms: sumBy(shipped, (dungeon) => dungeon.shipped.singularTextureTransforms),
baseColorSamplerModes: {},
baseColorUvSets: {},
bySurface: Object.fromEntries(surfaces.map((surface) => [surface, {
sourceRankCollapsedAreaM2: round(sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].rankCollapsedAreaM2), 2),
shippedRankCollapsedAreaM2: round(sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].rankCollapsedAreaM2), 2),
sourceUvDegenerateAreaM2: round(sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].uvDegenerateAreaM2), 2),
shippedUvDegenerateAreaM2: round(sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].uvDegenerateAreaM2), 2),
sourceTriangles: sumBy(compared, (dungeon) => dungeon.sourceGeometry.bySurface[surface].triangleCount),
shippedTriangles: sumBy(compared, (dungeon) => dungeon.shippedGeometry.bySurface[surface].triangleCount),
}])),
};
for (const dungeon of shipped) {
for (const [mode, count] of Object.entries(dungeon.shipped.samplerModes)) {
summary.baseColorSamplerModes[mode] = (summary.baseColorSamplerModes[mode] ?? 0) + count;
}
for (const [uvSet, count] of Object.entries(dungeon.shipped.baseColorUvSets)) {
summary.baseColorUvSets[uvSet] = (summary.baseColorUvSets[uvSet] ?? 0) + count;
}
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
sourceRoot: path.relative(projectRoot, workRoot).split(path.sep).join("/"),
shippedRoot: projectPath(publicRoot),
generatedBy: "scripts/dungeon-pipeline/audit-wow-visual-quality.mjs",
selection: {
completeWoWCatalog: definitions.length === allDefinitions.length,
ids: definitions.map((definition) => definition.id),
visualOverride: overrideId ? { dungeonId: overrideId, file: projectPath(visualOverride) } : null,
},
summary,
dungeons,
};
await mkdir(path.dirname(reportJsonPath), { recursive: true });
await mkdir(path.dirname(reportMarkdownPath), { recursive: true });
await Promise.all([
writeFile(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
writeFile(reportMarkdownPath, makeMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
...summary,
json: path.relative(projectRoot, reportJsonPath).split(path.sep).join("/"),
markdown: path.relative(projectRoot, reportMarkdownPath).split(path.sep).join("/"),
}, null, 2));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}