#!/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//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 =."); 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(); }