Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

@@ -0,0 +1,231 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { copyFile, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const publicRoot = path.join(projectRoot, "public");
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
async function writeJson(file, value) {
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "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 complete 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 start = offset + 8;
const end = start + length;
if (end > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`);
const chunk = buffer.subarray(start, end);
if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = chunk;
offset = end;
}
if (!json || !binary) throw new Error(`${label} must contain JSON and BIN chunks.`);
return { json, binary };
}
function inspectCandidate(buffer, label) {
const { json, binary } = parseGlb(buffer, label);
if (!json.extensionsUsed?.includes("EXT_meshopt_compression")
|| !json.bufferViews?.some((view) => view.extensions?.EXT_meshopt_compression?.byteLength > 0)) {
throw new Error(`${label} must retain EXT_meshopt_compression buffer views after KTX2 encoding.`);
}
let triangleCount = 0;
for (const mesh of json.meshes ?? []) {
for (const primitive of mesh.primitives ?? []) {
if (primitive.mode !== undefined && primitive.mode !== 4) continue;
const accessorIndex = primitive.indices ?? primitive.attributes?.POSITION;
const accessor = json.accessors?.[accessorIndex];
if (!Number.isInteger(accessor?.count) || accessor.count % 3 !== 0) {
throw new Error(`${label} has an invalid triangle accessor.`);
}
triangleCount += accessor.count / 3;
}
}
const images = json.images ?? [];
if (!images.length || images.some((image) => image.mimeType !== "image/ktx2")) {
throw new Error(`${label} must contain only embedded KTX2 images.`);
}
for (const [index, image] of images.entries()) {
const view = json.bufferViews?.[image.bufferView];
if (!view) throw new Error(`${label} image ${index} references a missing buffer view.`);
const start = view.byteOffset ?? 0;
const bytes = binary.subarray(start, start + view.byteLength);
if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) {
throw new Error(`${label} image ${index} has an invalid KTX2 header.`);
}
}
return {
triangleCount,
imageCount: images.length,
meshoptBufferViewCount: json.bufferViews.filter((view) => view.extensions?.EXT_meshopt_compression).length,
};
}
function updateVisualDescriptors(targets, descriptor) {
for (const { label, packageDefinition } of targets) {
const visual = packageDefinition?.visual?.[0];
if (!visual || visual.url !== descriptor.url) {
throw new Error(`${label} visual URL does not match target ${descriptor.url}.`);
}
}
for (const { packageDefinition } of targets) {
Object.assign(packageDefinition.visual[0], {
checksum: descriptor.checksum,
byteLength: descriptor.byteLength,
triangleCount: descriptor.triangleCount,
compression: "meshopt+ktx2",
});
}
}
function resolvePublicAsset(url, label) {
if (typeof url !== "string" || !url.startsWith("/assets/game/")) {
throw new Error(`${label} does not have a canonical public asset URL.`);
}
const file = path.resolve(publicRoot, url.replace(/^\//u, ""));
const relative = path.relative(publicRoot, file);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} URL escapes the public asset root.`);
}
return file;
}
async function collisionNavigationHashes(stagePack) {
const assets = [
...(stagePack.collision ?? []).map((asset) => ({ label: asset.id, ...asset })),
...(stagePack.navigation ? [{ label: "navigation", ...stagePack.navigation }] : []),
];
return Promise.all(assets.map(async (asset) => {
const file = resolvePublicAsset(asset.url, asset.label);
const bytes = await readFile(file);
const checksum = createHash("sha256").update(bytes).digest("hex");
if (asset.checksum && checksum !== asset.checksum) {
throw new Error(`${asset.label} checksum does not match stage-pack metadata before visual publish.`);
}
return { id: asset.label, url: asset.url, checksum, byteLength: bytes.length };
}));
}
async function main() {
const [, , slug, candidateArgument, ...flags] = process.argv;
if (!slug || !candidateArgument || flags.some((flag) => flag !== "--apply")) {
throw new Error("Usage: node scripts/dungeon-pipeline/apply-wow-visual-optimization.mjs <slug> <candidate.glb> [--apply]");
}
const apply = flags.includes("--apply");
const candidatePath = path.resolve(projectRoot, candidateArgument);
const candidateBytes = await readFile(candidatePath);
const candidateStats = inspectCandidate(candidateBytes, candidatePath);
const checksum = createHash("sha256").update(candidateBytes).digest("hex");
const byteLength = (await stat(candidatePath)).size;
const dungeonPackPath = path.join(publicRoot, "assets/game/dungeons", slug, "dungeon-pack.json");
const dungeonPack = await readJson(dungeonPackPath);
const environmentVisualUrl = dungeonPack.environment?.visual?.[0]?.url;
if (!environmentVisualUrl) throw new Error(`${dungeonPackPath} has no canonical environment visual URL.`);
const stagePackPath = path.resolve(publicRoot, path.posix.join(
path.posix.dirname(environmentVisualUrl.replace(/^\/+/, "")),
"stage-pack.json",
));
if (!await stat(stagePackPath).then(() => true, () => false)) {
throw new Error(`${slug} does not resolve to a canonical public Manastorm package.`);
}
const stagePack = await readJson(stagePackPath);
const visual = stagePack.visual?.[0];
if (!visual?.url) throw new Error(`${stagePackPath} has no canonical visual descriptor.`);
const visualPath = path.resolve(publicRoot, visual.url.replace(/^\/+/, ""));
const relativeVisual = path.relative(publicRoot, visualPath);
if (relativeVisual.startsWith("..") || path.isAbsolute(relativeVisual)) {
throw new Error(`${slug} visual URL escapes the public asset root.`);
}
const descriptor = {
url: visual.url,
checksum,
byteLength,
triangleCount: candidateStats.triangleCount,
};
const assetRegistryPath = path.join(projectRoot, "src/game/generated/manastormAssetPackages.json");
const assetRegistry = await readJson(assetRegistryPath);
const registryPackage = assetRegistry.packages.find((entry) => entry.id === stagePack.id);
if (!registryPackage) throw new Error(`Generated Manastorm registry has no package ${stagePack.id}.`);
const importRegistryPath = path.join(projectRoot, "src/game/generated/fivePlayerDungeonImports.json");
const importRegistry = await readJson(importRegistryPath);
const dungeonImport = importRegistry.imports.find((entry) => entry.slug === slug);
if (!dungeonImport || dungeonImport.environment?.id !== stagePack.id) {
throw new Error(`Generated five-player imports have no matching ${slug} environment ${stagePack.id}.`);
}
if (dungeonPack.environment?.id !== stagePack.id) {
throw new Error(`${dungeonPackPath} does not point to ${stagePack.id}.`);
}
const updateTargets = [
{ label: "Manastorm stage pack", packageDefinition: stagePack },
{ label: "Generated Manastorm registry", packageDefinition: registryPackage },
{ label: "Generated five-player import", packageDefinition: dungeonImport.environment },
{ label: "Dungeon package", packageDefinition: dungeonPack.environment },
];
for (const target of updateTargets) {
const targetVisual = target.packageDefinition?.visual?.[0];
if (!targetVisual || targetVisual.url !== descriptor.url) {
throw new Error(`${target.label} visual URL does not match target ${descriptor.url}.`);
}
}
const collisionNavigationBefore = await collisionNavigationHashes(stagePack);
const pendingVisualPath = `${visualPath}.pending`;
const plan = {
slug,
candidate: path.relative(projectRoot, candidatePath).replaceAll("\\", "/"),
publishedVisual: path.relative(projectRoot, visualPath).replaceAll("\\", "/"),
checksum,
byteLength,
triangleCount: candidateStats.triangleCount,
imageCount: candidateStats.imageCount,
meshoptBufferViewCount: candidateStats.meshoptBufferViewCount,
collisionNavigationHashesBefore: collisionNavigationBefore,
apply,
};
if (!apply) {
console.log(JSON.stringify(plan, null, 2));
return;
}
updateVisualDescriptors(updateTargets, descriptor);
await copyFile(candidatePath, pendingVisualPath);
await copyFile(pendingVisualPath, visualPath);
await rm(pendingVisualPath, { force: true });
await Promise.all([
writeJson(stagePackPath, stagePack),
writeJson(assetRegistryPath, assetRegistry),
writeJson(importRegistryPath, importRegistry),
writeJson(dungeonPackPath, dungeonPack),
]);
const collisionNavigationAfter = await collisionNavigationHashes(await readJson(stagePackPath));
if (JSON.stringify(collisionNavigationAfter) !== JSON.stringify(collisionNavigationBefore)) {
throw new Error("Collision or navigation assets changed during visual publishing.");
}
console.log(JSON.stringify({ ...plan, collisionNavigationHashesAfter: collisionNavigationAfter }, null, 2));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
export { updateVisualDescriptors };
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { updateVisualDescriptors } from "./apply-wow-visual-optimization.mjs";
test("visual publisher rejects a descriptor URL mismatch before updating any package", () => {
const targets = [
{ label: "stage pack", packageDefinition: { visual: [{ url: "/assets/game/manastorm/33-shadowfang/visual.glb", checksum: "old-stage" }] } },
{ label: "registry", packageDefinition: { visual: [{ url: "/assets/game/other/visual.glb", checksum: "old-registry" }] } },
{ label: "import", packageDefinition: { visual: [{ url: "/assets/game/manastorm/33-shadowfang/visual.glb", checksum: "old-import" }] } },
{ label: "dungeon pack", packageDefinition: { visual: [{ url: "/assets/game/manastorm/33-shadowfang/visual.glb", checksum: "old-dungeon" }] } },
];
const before = structuredClone(targets);
const descriptor = {
url: "/assets/game/manastorm/33-shadowfang/visual.glb",
checksum: "new",
byteLength: 123,
triangleCount: 456,
};
assert.throws(() => updateVisualDescriptors(targets, descriptor), /registry visual URL does not match/u);
assert.deepEqual(targets, before, "a mismatch in any of the four targets must prevent partial metadata updates");
});
test("visual publisher updates all prevalidated visual descriptors together", () => {
const targets = ["stage pack", "registry", "import", "dungeon pack"].map((label) => ({
label,
packageDefinition: { visual: [{ url: "/assets/game/manastorm/33-shadowfang/visual.glb", checksum: "old" }] },
}));
const descriptor = {
url: "/assets/game/manastorm/33-shadowfang/visual.glb",
checksum: "new",
byteLength: 123,
triangleCount: 456,
};
updateVisualDescriptors(targets, descriptor);
for (const { packageDefinition } of targets) {
assert.deepEqual(packageDefinition.visual[0], {
...descriptor,
compression: "meshopt+ktx2",
});
}
});
@@ -0,0 +1,774 @@
#!/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)) {
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() };
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]);
if (!mapping || mapping.uvDoubleArea <= 1e-12) {
for (const metric of [totals, surface]) {
metric.uvDegenerateTriangles += 1;
metric.rankCollapsedTriangles += 1;
metric.uvDegenerateAreaM2 += area;
metric.rankCollapsedAreaM2 += area;
}
} else if (mapping.smallestStretchPxPerMeter <= 0.01) {
for (const metric of [totals, surface]) {
metric.rankCollapsedTriangles += 1;
metric.rankCollapsedAreaM2 += area;
}
}
}
}
}
}
}
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);
}
return { ...totals, bySurface, materialCoverage };
}
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 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) {
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`);
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.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),
}];
})),
};
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 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));
}
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();
}
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
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 }) {
const positions = document.createAccessor(`${name}-positions`)
.setType("VEC3")
.setArray(new Float32Array([
offset, 0, 0,
offset + 1, 0, 0,
offset, 1, 0,
]))
.setBuffer(buffer);
const primitive = document.createPrimitive().setAttribute("POSITION", positions);
if (textured) {
const texcoords = document.createAccessor(`${name}-uvs`)
.setType("VEC2")
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]))
.setBuffer(buffer);
primitive.setAttribute("TEXCOORD_0", texcoords);
}
if (material) primitive.setMaterial(material);
const mesh = document.createMesh(`${name}-mesh`).addPrimitive(primitive);
return document.createNode(name).setMesh(mesh);
}
test("WoW visual audit counts untextured and material-free surfaces safely", async (context) => {
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "wow-visual-audit-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const visualFile = path.join(workingDirectory, "coverage-fixture.glb");
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = new Document();
const buffer = document.createBuffer();
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 180, g: 140, b: 90, alpha: 1 } },
}).png().toBuffer();
const texture = document.createTexture("textured-surface").setMimeType("image/png").setImage(image);
const texturedMaterial = document.createMaterial("textured").setBaseColorTexture(texture);
const plainMaterial = document.createMaterial("plain").setBaseColorFactor([0.35, 0.31, 0.24, 1]);
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 }));
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.untexturedTriangles, 2);
assert.equal(result.materialCoverage.baseColorTexturedAreaM2, 0.5);
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);
});
@@ -0,0 +1,165 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dungeonEnvironmentFinalPackingArguments, dungeonEnvironmentOptimizationArguments } from "./optimize-environment.mjs";
import { preprocessShadowfangVisual } from "./shadowfang-fallback-preprocess.mjs";
import { projectRoot, workRoot } from "./lib.mjs";
const DEFAULT_OUTPUT = path.join(projectRoot, "artifacts/dungeon-pipeline/shadowfang-keep-visual.selective-untextured.glb");
const BASELINE_VISUAL = path.join(projectRoot, "artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb");
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function relative(file) {
return path.relative(projectRoot, file).replaceAll("\\", "/");
}
function parseGlb(bytes, label) {
if (bytes.length < 20 || bytes.toString("utf8", 0, 4) !== "glTF"
|| bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) {
throw new Error(`${label} is not a complete binary glTF 2.0 file.`);
}
let json = null;
let binary = null;
let offset = 12;
while (offset + 8 <= bytes.length) {
const length = bytes.readUInt32LE(offset);
const type = bytes.readUInt32LE(offset + 4);
const start = offset + 8;
const end = start + length;
if (end > bytes.length) throw new Error(`${label} contains a truncated GLB chunk.`);
if (type === 0x4e4f534a) json = JSON.parse(bytes.toString("utf8", start, end).replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = bytes.subarray(start, end);
offset = end;
}
if (!json || !binary) throw new Error(`${label} must contain JSON and BIN GLB chunks.`);
return { json, binary };
}
function embeddedImageHashes(buffer, label) {
const parsed = parseGlb(buffer, label);
return (parsed.json.images ?? []).map((image, index) => {
const view = parsed.json.bufferViews?.[image.bufferView];
if (!view) throw new Error(`${label} image ${index} references a missing buffer view.`);
const start = view.byteOffset ?? 0;
const end = start + view.byteLength;
const bytes = parsed.binary.subarray(start, end);
if (end > parsed.binary.length) throw new Error(`${label} image ${index} exceeds the GLB BIN chunk.`);
if (image.mimeType !== "image/ktx2" || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) {
throw new Error(`${label} image ${index} is not a valid embedded KTX2 texture.`);
}
return { index, name: image.name ?? "", hash: sha256(bytes), byteLength: bytes.length };
});
}
function runCli(projectDirectory, args) {
const cli = path.join(projectDirectory, "node_modules/@gltf-transform/cli/bin/cli.js");
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, ...args], { cwd: projectDirectory, stdio: "inherit", windowsHide: true });
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`gltf-transform exited with code ${code}.`)));
});
}
function compressKtx2(projectDirectory, file) {
const compressor = path.join(projectDirectory, "scripts/asset-pipeline/compress-ktx2.mjs");
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [compressor, file, "--concurrency", "1", "--jobs", "2"], {
cwd: projectDirectory,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`KTX2 compression exited with code ${code}.`)));
});
}
async function main() {
const outputArgument = process.argv.indexOf("--output");
const output = path.resolve(outputArgument >= 0 ? process.argv[outputArgument + 1] : DEFAULT_OUTPUT);
const errorArgument = process.argv.indexOf("--simplify-error");
const simplifyError = errorArgument >= 0 ? Number(process.argv[errorArgument + 1]) : undefined;
if (simplifyError !== undefined && (!Number.isFinite(simplifyError) || simplifyError < 0)) {
throw new Error("--simplify-error must be a non-negative number.");
}
const artifactRoot = path.join(projectRoot, "artifacts");
const outputRelative = path.relative(artifactRoot, output);
if (!output.toLowerCase().endsWith(".glb") || outputRelative.startsWith("..") || path.isAbsolute(outputRelative)) {
throw new Error("Output must be a .glb candidate inside the repository artifacts directory.");
}
const source = path.join(workRoot, "shadowfang-keep/staging/environment/shadowfang-keep-visual.glb");
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "shadowfang-selective-"));
const preprocessed = path.join(temporaryDirectory, "shadowfang-selective.glb");
const optimized = path.join(temporaryDirectory, "shadowfang-optimized.glb");
const finalPacked = path.join(temporaryDirectory, "shadowfang-meshopt-final.glb");
try {
const preprocessing = await preprocessShadowfangVisual(source, preprocessed, { simplifyError });
await runCli(projectRoot, dungeonEnvironmentOptimizationArguments(preprocessed, optimized));
await compressKtx2(projectRoot, optimized);
await runCli(projectRoot, dungeonEnvironmentFinalPackingArguments(optimized, finalPacked));
await mkdir(path.dirname(output), { recursive: true });
await copyFile(finalPacked, output);
const candidateBytes = await readFile(output);
const candidateTextures = embeddedImageHashes(candidateBytes, output);
let baselineTextureComparison = null;
if (await stat(BASELINE_VISUAL).then(() => true, () => false)) {
const baselineTextures = embeddedImageHashes(await readFile(BASELINE_VISUAL), BASELINE_VISUAL);
const baselineByName = new Map(baselineTextures.map((image) => [image.name, image]));
const comparisons = candidateTextures.map((image) => {
const baseline = baselineByName.get(image.name);
return { name: image.name, matched: Boolean(baseline), identicalBytes: Boolean(baseline && baseline.hash === image.hash) };
});
baselineTextureComparison = {
baseline: relative(BASELINE_VISUAL),
matched: comparisons.filter((row) => row.matched).length,
identicalBytes: comparisons.filter((row) => row.identicalBytes).length,
candidateImageCount: candidateTextures.length,
};
if (baselineTextureComparison.matched !== candidateTextures.length
|| baselineTextureComparison.identicalBytes !== candidateTextures.length) {
throw new Error("Selective candidate KTX2 image bytes differ from the preserved published Shadowfang baseline.");
}
}
const report = {
schemaVersion: 1,
dungeonId: "shadowfang-keep",
source: preprocessing.source,
output: { file: relative(output), sha256: sha256(candidateBytes), byteLength: candidateBytes.length },
simplification: {
...preprocessing.profile,
api: "@gltf-transform/functions simplifyPrimitive",
texturedPrimitiveAccessorHashesUnchanged: preprocessing.texturedPrimitiveAccessorHashesUnchanged,
sourceTextureBytesUnchanged: preprocessing.sourceTextureBytesUnchanged,
selectedByMaterial: preprocessing.selectedByMaterial,
},
textureEncoding: {
codec: "ETC1S/BasisLZ from the existing KTX2 compressor policy",
imageCount: candidateTextures.length,
comparedToPreservedBaseline: baselineTextureComparison,
},
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",
extensionPresent: parseGlb(candidateBytes, output).json.extensionsUsed?.includes("EXT_meshopt_compression") ?? false,
},
};
const reportFile = `${output}.report.json`;
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ...report, reportFile: relative(reportFile) }, null, 2));
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import sharp from "sharp";
import test from "node:test";
import { Document } from "@gltf-transform/core";
import { MeshoptSimplifier } from "meshoptimizer";
import { eligibleFallbackMaterial, simplifyEligibleFallbackPrimitive } from "./shadowfang-fallback-preprocess.mjs";
function gridPrimitive(document, buffer, material, name, segments = 20) {
const positions = new Float32Array((segments + 1) * (segments + 1) * 3);
const indices = new Uint16Array(segments * segments * 6);
let indexOffset = 0;
for (let row = 0; row <= segments; row += 1) {
for (let column = 0; column <= segments; column += 1) {
const vertex = row * (segments + 1) + column;
positions.set([column, row, Math.sin(column * 0.2) * Math.cos(row * 0.2) * 0.1], vertex * 3);
if (row < segments && column < segments) {
const topLeft = vertex;
const topRight = vertex + 1;
const bottomLeft = vertex + segments + 1;
const bottomRight = bottomLeft + 1;
indices.set([topLeft, bottomLeft, topRight, topRight, bottomLeft, bottomRight], indexOffset);
indexOffset += 6;
}
}
}
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor(`${name}-position`).setType("VEC3").setArray(positions).setBuffer(buffer))
.setIndices(document.createAccessor(`${name}-indices`).setType("SCALAR").setArray(indices).setBuffer(buffer))
.setMaterial(material);
return primitive;
}
function triangleCount(primitive) {
return (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3;
}
function accessorHash(accessor) {
const array = accessor.getArray();
return Buffer.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
}
test("selective Shadowfang simplification skips textured lookalikes and unrelated materials", async () => {
await MeshoptSimplifier.ready;
const document = new Document();
const buffer = document.createBuffer();
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 160, g: 120, b: 80, alpha: 1 } },
}).png().toBuffer();
const texture = document.createTexture("fallback-test").setMimeType("image/png").setImage(image);
const texturedTerrain = document.createMaterial("ADT_Terrain").setBaseColorTexture(texture);
const untexturedTerrain = document.createMaterial("ADT_Terrain.001");
const unrelated = document.createMaterial("WMO_Wall");
const profile = {
materialNamePattern: "^ADT_(Terrain|Liquid_B2)(?:\\.\\d+)?$",
sourceExpandedTriangles: { ADT_Terrain: 800 },
targetExpandedTriangles: { ADT_Terrain: 160 },
simplifyError: 1,
lockBorder: false,
};
const textured = gridPrimitive(document, buffer, texturedTerrain, "textured-terrain");
const fallback = gridPrimitive(document, buffer, untexturedTerrain, "fallback-terrain");
const wall = gridPrimitive(document, buffer, unrelated, "unrelated-wall");
const texturedBefore = {
positions: accessorHash(textured.getAttribute("POSITION")),
indices: accessorHash(textured.getIndices()),
};
const texturedTrianglesBefore = triangleCount(textured);
const fallbackTrianglesBefore = triangleCount(fallback);
const wallTrianglesBefore = triangleCount(wall);
assert.equal(eligibleFallbackMaterial(texturedTerrain, profile), null);
assert.equal(eligibleFallbackMaterial(untexturedTerrain, profile), "ADT_Terrain");
assert.equal(eligibleFallbackMaterial(unrelated, profile), null);
assert.equal(simplifyEligibleFallbackPrimitive(textured, profile), false);
assert.equal(simplifyEligibleFallbackPrimitive(wall, profile), false);
assert.equal(simplifyEligibleFallbackPrimitive(fallback, profile), true);
assert.equal(triangleCount(textured), texturedTrianglesBefore);
assert.deepEqual({
positions: accessorHash(textured.getAttribute("POSITION")),
indices: accessorHash(textured.getIndices()),
}, texturedBefore, "the textured same-name primitive must keep every accessor byte");
assert.ok(triangleCount(fallback) < fallbackTrianglesBefore, "the allowed untextured terrain must simplify");
assert.equal(triangleCount(wall), wallTrianglesBefore, "the allowlist must leave unrelated geometry alone");
});
@@ -1003,13 +1003,37 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
spells.set(id, {
id,
sourceProfile,
spellMechanic: unsigned(3),
attributes0: unsigned(4),
attributes1: unsigned(5),
attributes2: unsigned(6),
attributes3: unsigned(7),
attributes4: unsigned(8),
attributes5: unsigned(9),
attributes6: unsigned(10),
attributes7: unsigned(11),
stanceMask0: unsigned(12),
stanceMask1: unsigned(13),
stanceExcludeMask0: unsigned(14),
stanceExcludeMask1: unsigned(15),
equippedItemClass: signed(68),
equippedItemSubclassMask: unsigned(69),
equippedItemInventoryMask: unsigned(70),
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
schoolMask: unsigned(225),
damageClass: unsigned(213),
maxAffectedTargets: unsigned(212),
rangeIndex: unsigned(46),
durationMs: durations.get(unsigned(40)) ?? 0,
durationIndex: unsigned(40),
baseLevel: unsigned(38),
spellLevel: unsigned(39),
maxLevel: unsigned(37),
stackAmount: unsigned(49),
scalesWithCreatureLevel: (unsigned(4) & 0x00080000) !== 0,
dispel: unsigned(2),
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
auraType: unsigned(shifted(95 + effectIndex)),
@@ -1018,8 +1042,15 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
implicitTargetA: unsigned(shifted(86 + effectIndex)),
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
radiusIndex: unsigned(shifted(92 + effectIndex)),
miscValue: signed(shifted(110 + effectIndex)),
triggerSpellId: unsigned(shifted(116 + effectIndex)),
periodMs: unsigned(98 + effectIndex),
chainTargets: unsigned(shifted(104 + effectIndex)),
chainAmplitude: buffer.readFloatLE(base + shifted(216 + effectIndex) * 4),
chainFromCaster: (unsigned(6) & 0x00001000) !== 0,
dieSides: signed(74 + effectIndex),
pointsPerLevel: buffer.readFloatLE(base + shifted(77 + effectIndex) * 4),
})).filter((effect) => effect.effectType || effect.auraType),
});
}
@@ -1053,44 +1084,131 @@ const pinnedEffect = (effectType, auraType, basePoints, targetA, targetB, option
periodMs: options.periodMs ?? 0,
});
const pinnedSourceSpell = (rangeIndex, schoolMask, damageClass, durationMs, effects, contract = {}) => {
const { epochSignature, ...attackContract } = contract;
const signature = { rangeIndex, schoolMask, damageClass, durationMs, effects };
const { epochSignature, sourceScalars, ...attackContract } = contract;
const signature = { rangeIndex, schoolMask, damageClass, durationMs, effects, ...sourceScalars };
return {
signatures: {
ascension: signature,
epoch: epochSignature ?? signature,
epoch: epochSignature ? { ...signature, ...epochSignature } : signature,
},
...attackContract,
};
};
const shoutSourceScalars = Object.freeze({
spellMechanic: 5, attributes0: 0, attributes1: 0, attributes2: 0,
attributes3: 0, attributes4: 0, attributes5: 0, attributes6: 0, attributes7: 0,
stackAmount: 0, dispel: 0, maxAffectedTargets: 0,
});
const stanceSourceScalars = Object.freeze({
attributes0: 327_696, dispel: 0, stackAmount: 0,
stanceMask0: 0, stanceMask1: 0, stanceExcludeMask0: 0, stanceExcludeMask1: 0,
equippedItemClass: -1, equippedItemSubclassMask: 0, equippedItemInventoryMask: 0,
});
const stancePassiveScalars = Object.freeze({ ...stanceSourceScalars, attributes0: 327_888, durationIndex: 0 });
const stanceEffect = (auraType, basePoints, miscValue) => ({
...pinnedEffect(6, auraType, basePoints, 1, 0, { miscValue }), triggerSpellId: 0, radiusIndex: 0,
});
const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
[7164, pinnedSourceSpell(1, 1, 0, 180_000, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 18 })], { unsupportedUtility: true })],
[7165, pinnedSourceSpell(1, 1, 2, -1, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 })], { unsupportedUtility: true })],
[8599, pinnedSourceSpell(1, 1, 0, 120_000, [
pinnedEffect(6, 79, 30, 1, 0, { miscValue: 1 }), pinnedEffect(6, 138, 30, 1, 0),
], {
sourceAura: true, target: "self", delivery: "area", range: 0,
sourceScalars: { stackAmount: 0, dispel: 9 },
epochSignature: { rangeIndex: 1, schoolMask: 1, damageClass: 0, durationMs: 120_000,
effects: [pinnedEffect(6, 79, 10, 1, 0, { miscValue: 1 }), pinnedEffect(6, 138, 30, 1, 0)] },
})],
[6547, pinnedSourceSpell(2, 1, 2, 15_000, [{ ...pinnedEffect(6, 3, 14, 6, 0, { mechanic: 15, periodMs: 3_000 }), dieSides: 1, pointsPerLevel: 0 }], {
target: "primary", delivery: "melee", animation: "attack", range: 5, periodicDamage: true, preserveDamageMultiplier: true,
sourceScalars: { baseLevel: 20, spellLevel: 20, maxLevel: 0, stackAmount: 0, scalesWithCreatureLevel: false },
epochSignature: { rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 15_000,
effects: [{ ...pinnedEffect(6, 3, 10, 6, 0, { mechanic: 15, periodMs: 3_000 }), dieSides: 1, pointsPerLevel: 0 }] },
})],
[16509, pinnedSourceSpell(2, 1, 2, 15_000, [{ ...pinnedEffect(6, 3, 27, 6, 0, { mechanic: 15, periodMs: 3_000 }), dieSides: 1, pointsPerLevel: 0 }], {
target: "primary", delivery: "melee", animation: "attack", range: 5, periodicDamage: true, preserveDamageMultiplier: true,
sourceScalars: { baseLevel: 20, spellLevel: 20, maxLevel: 0, stackAmount: 0, scalesWithCreatureLevel: true },
})],
[7164, pinnedSourceSpell(1, 1, 0, 180_000, [stanceEffect(36, 0, 18)], {
sourceAura: true, target: "self", delivery: "area", range: 0, radius: 0,
sourceScalars: { ...stanceSourceScalars, durationIndex: 25 },
})],
[7165, pinnedSourceSpell(1, 1, 2, -1, [stanceEffect(36, 0, 17)], {
sourceAura: true, target: "self", delivery: "area", range: 0, radius: 0,
sourceScalars: { ...stanceSourceScalars, durationIndex: 21 },
})],
// Core HandleShapeshiftBoosts owns these child passives. Pin them even though
// they are not imported as independently cast attacks.
[7376, pinnedSourceSpell(1, 1, 0, 0, [
stanceEffect(87, -10, 127), stanceEffect(79, -5, 127), stanceEffect(4, 42, 127),
], { sourceScalars: stancePassiveScalars,
epochSignature: { rangeIndex: 1, schoolMask: 1, damageClass: 0, durationMs: 0,
effects: [stanceEffect(87, -10, 127), stanceEffect(79, -5, 127), stanceEffect(10, 45, 127)] },
})],
[21156, pinnedSourceSpell(1, 1, 0, 0, [stanceEffect(10, 0, 127), stanceEffect(280, 0, 0)], {
sourceScalars: stancePassiveScalars,
epochSignature: { rangeIndex: 1, schoolMask: 1, damageClass: 0, durationMs: 0,
effects: [stanceEffect(10, -20, 127)] },
})],
[16244, pinnedSourceSpell(1, 1, 1, 15_000, [pinnedEffect(6, 79, -10, 22, 15, { radius: 10, miscValue: 1 })], {
unsupportedUtility: true,
sourceAura: true, target: "nearby-party", delivery: "area", range: 10, radius: 10,
sourceScalars: { stackAmount: 0, dispel: 0 },
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -20, 22, 15, { radius: 10 })],
},
})],
[13730, pinnedSourceSpell(1, 1, 1, 30_000, [pinnedEffect(6, 166, -15, 22, 15, { radius: 10 })], {
unsupportedUtility: true,
sourceAura: true, target: "nearby-party", delivery: "area", range: 10, radius: 10,
sourceScalars: { stackAmount: 0, dispel: 0 },
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -10, 22, 15, { radius: 10 })],
},
})],
[3419, pinnedSourceSpell(1, 1, 0, 6_000, [pinnedEffect(6, 51, 75, 1, 0)], { unsupportedUtility: true })],
[3419, pinnedSourceSpell(1, 1, 0, 6_000, [pinnedEffect(6, 51, 75, 1, 0)], {
sourceAura: true, target: "self", delivery: "area", range: 0, radius: 0,
sourceScalars: { stackAmount: 0, dispel: 0 },
})],
[9128, pinnedSourceSpell(1, 1, 1, 120_000, [pinnedEffect(6, 79, 15, 20, 0, { radius: 20, miscValue: 1 })], {
unsupportedUtility: true,
sourceAura: true, target: "nearby-friendly", delivery: "area", range: 0, radius: 20,
sourceScalars: { stackAmount: 0, dispel: 0 },
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 120_000,
effects: [pinnedEffect(6, 99, 4, 20, 0, { radius: 20 })],
},
})],
[674, pinnedSourceSpell(1, 1, 1, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[29651, pinnedSourceSpell(1, 1, 0, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[674, pinnedSourceSpell(1, 1, 1, 0, [pinnedEffect(40, 0, 1, 1, 0)], {
target: "self", delivery: "area", range: 0, radius: 0,
weaponCapability: "dual-wield",
sourceScalars: { attributes0: 336 },
epochSignature: { attributes0: 80 },
})],
...[
[47294, 4, 8, 20, 5],
[47295, 7, 16, 40, 10],
[47296, 10, 25, 60, 15],
].map(([id, criticalChanceModifier, effectModifier, epochChance, epochModifier]) => [id,
pinnedSourceSpell(1, 1, 0, 0, [
pinnedEffect(6, 253, 100, 1, 0),
pinnedEffect(6, 107, criticalChanceModifier, 1, 0, { miscValue: 7 }),
pinnedEffect(6, 107, effectModifier, 1, 0, { miscValue: 3 }),
], {
// Duration index 0 is serialized as 0 here; the guarded passive import is permanent.
sourceAura: true, target: "self", delivery: "area", range: 0, radius: 0,
sourceScalars: { attributes0: 464, dispel: 0, stackAmount: 0, durationIndex: 0 },
epochSignature: { effects: [
pinnedEffect(6, 253, epochChance, 1, 0),
pinnedEffect(6, 107, epochModifier, 1, 0, { miscValue: 7 }),
] },
}),
]),
[29651, pinnedSourceSpell(1, 1, 0, 0, [pinnedEffect(40, 0, 1, 1, 0)], {
target: "self", delivery: "area", range: 0, radius: 0,
weaponCapability: "dual-wield",
sourceScalars: { attributes0: 80 },
})],
[2457, pinnedSourceSpell(1, 1, 0, -1, [
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
pinnedEffect(6, 280, 10, 1, 0),
@@ -1105,19 +1223,29 @@ const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
],
},
})],
[3391, pinnedSourceSpell(1, 1, 2, 0, [pinnedEffect(19, 0, 2, 1, 0)], { unsupportedUtility: true })],
[6742, pinnedSourceSpell(4, 8, 1, 30_000, [pinnedEffect(6, 138, 30, 21, 0)], { unsupportedUtility: true })],
[3391, pinnedSourceSpell(1, 1, 2, 0, [{ ...pinnedEffect(19, 0, 2, 1, 0), dieSides: 1 }], {
target: "self", delivery: "area", range: 0, radius: 0, extraAttackCount: 2,
sourceScalars: { attributes0: 262_160 },
})],
[6742, pinnedSourceSpell(4, 8, 1, 30_000, [pinnedEffect(6, 138, 30, 21, 0)], {
sourceAura: true, target: "selected-friendly", delivery: "area", range: 30, radius: 0,
sourceScalars: { stackAmount: 0, dispel: 1 },
})],
[6713, pinnedSourceSpell(2, 1, 2, 5_000, [
pinnedEffect(6, 67, 0, 6, 0),
pinnedEffect(6, 278, 1, 6, 0),
], {
unsupportedUtility: true,
sourceAura: true, target: "primary", delivery: "melee", range: 5, radius: 0,
sourceScalars: { stackAmount: 0, dispel: 0 },
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 5_000,
effects: [pinnedEffect(6, 67, 0, 6, 0)],
},
})],
[17230, pinnedSourceSpell(2, 8, 0, 300_000, [pinnedEffect(6, 14, 6, 6, 0, { miscValue: 1 })], { unsupportedUtility: true })],
[17230, pinnedSourceSpell(2, 8, 0, 300_000, [pinnedEffect(6, 14, 6, 6, 0, { miscValue: 1 })], {
sourceAura: true, target: "primary", delivery: "melee", range: 5, radius: 0,
sourceScalars: { stackAmount: 0, dispel: 3 },
})],
[6253, pinnedSourceSpell(2, 1, 2, 2_000, [pinnedEffect(6, 12, 1, 6, 0)], {
target: "primary",
delivery: "melee",
@@ -1171,36 +1299,45 @@ const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
],
},
})],
[421, pinnedSourceSpell(4, 8, 1, 0, [pinnedEffect(2, 0, 287, 6, 0)], {
[421, pinnedSourceSpell(4, 8, 1, 0, [{ ...pinnedEffect(2, 0, 287, 6, 0), chainTargets: 3, chainAmplitude: Math.fround(0.6), chainFromCaster: false }], {
range: 30,
chain: { targetCount: 3, jumpRange: 10, damageMultiplier: 0.6 },
epochChain: { targetCount: 3, jumpRange: 10, damageMultiplier: 0.7 },
epochSignature: {
rangeIndex: 4, schoolMask: 8, damageClass: 1, durationMs: 0,
effects: [pinnedEffect(2, 0, 191, 6, 0)],
effects: [{ ...pinnedEffect(2, 0, 191, 6, 0), chainTargets: 3, chainAmplitude: Math.fround(0.7), chainFromCaster: false }],
},
})],
[5246, pinnedSourceSpell(137, 1, 1, 5_000, [
pinnedEffect(64, 0, 1, 6, 0),
{ ...pinnedEffect(64, 0, 1, 6, 0), triggerSpellId: 20511 },
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
triggeredShout: true,
sourceScalars: { ...shoutSourceScalars, attributes6: 33_554_432, maxAffectedTargets: 5 },
target: "nearby-party",
delivery: "area",
range: 8,
radius: 8,
epochSignature: {
rangeIndex: 137, schoolMask: 1, damageClass: 1, durationMs: 8_000,
attributes1: 136,
attributes0: 327_696,
attributes6: 8_388_608,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
{ ...pinnedEffect(64, 0, 1, 6, 0), triggerSpellId: 20511 },
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[19134, pinnedSourceSpell(7, 1, 1, 4_000, [
pinnedEffect(64, 0, 1, 6, 0),
{ ...pinnedEffect(64, 0, 1, 6, 0), triggerSpellId: 29544 },
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
triggeredShout: true,
sourceScalars: { ...shoutSourceScalars, attributes0: 327_696 },
target: "nearby-party",
delivery: "area",
range: 10,
@@ -1208,12 +1345,27 @@ const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
epochSignature: {
rangeIndex: 7, schoolMask: 1, damageClass: 1, durationMs: 6_000,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
{ ...pinnedEffect(64, 0, 1, 6, 0), triggerSpellId: 29544 },
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[20511, pinnedSourceSpell(11, 1, 1, 5_000, [
pinnedEffect(6, 12, 0, 6, 0),
pinnedEffect(6, 87, 15, 6, 0, { miscValue: 1 }),
], {
sourceScalars: { ...shoutSourceScalars, attributes3: 589_824, attributes4: 8_388_608 },
epochSignature: {
rangeIndex: 11, schoolMask: 1, damageClass: 0, durationMs: 8_000,
attributes3: 0, attributes4: 0,
effects: [pinnedEffect(6, 12, 0, 6, 0)],
},
})],
[29544, pinnedSourceSpell(11, 1, 0, 3_000, [pinnedEffect(6, 7, 0, 6, 0)], {
sourceScalars: shoutSourceScalars,
epochSignature: { durationMs: 6_000 },
})],
[7964, pinnedSourceSpell(1, 1, 1, 4_000, [
pinnedEffect(6, 12, 1, 22, 15, { mechanic: 12, radius: 5 }),
pinnedEffect(3, 0, 1, 18, 0),
@@ -1362,7 +1514,7 @@ function pinnedSourceSpellFor(spell) {
`Pinned Spell.dbc contract for spell ${spell.id} has no ${spell.sourceProfile} source profile.`,
);
}
const scalarFieldsMatch = ["rangeIndex", "schoolMask", "damageClass", "durationMs"]
const scalarFieldsMatch = Object.keys(signature).filter((field) => field !== "effects")
.every((field) => spell[field] === signature[field]);
const effectsMatch = spell.effects.length === signature.effects.length
&& signature.effects.every((expected, index) => {
@@ -1377,16 +1529,11 @@ function pinnedSourceSpellFor(spell) {
"radius",
"miscValue",
"periodMs",
].every((field) => actual[field] === expected[field]);
].every((field) => actual[field] === expected[field])
&& ["chainTargets", "chainAmplitude", "chainFromCaster", "dieSides", "pointsPerLevel", "triggerSpellId", "radiusIndex"].every((field) => expected[field] === undefined || actual[field] === expected[field]);
});
if (!scalarFieldsMatch || !effectsMatch) {
const actualSignature = {
rangeIndex: spell.rangeIndex,
schoolMask: spell.schoolMask,
damageClass: spell.damageClass,
durationMs: spell.durationMs,
effects: spell.effects,
};
const actualSignature = Object.fromEntries(Object.keys(signature).map((field) => [field, spell[field]]));
throw new Error(
`Pinned ${spell.sourceProfile} Spell.dbc contract drift for spell ${spell.id}; `
+ `review its source effects before changing the import. `
@@ -1438,7 +1585,22 @@ function spellAttack(spell, hash, boss) {
.test(spell.name);
const hasDamage = spell.effects.some((entry) => DIRECT_DAMAGE_EFFECT_TYPES.includes(entry.effectType));
const defaultDamageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const semanticEffect = pinnedSource?.interruptControl
const semanticEffect = pinnedSource?.weaponCapability
? { kind: "weapon-capability", capability: pinnedSource.weaponCapability,
sourceSpellId: spell.id, sourceProfile: spell.sourceProfile }
: pinnedSource?.extraAttackCount
? { kind: "extra-attacks", sourceSpellId: spell.id,
sourceProfile: spell.sourceProfile, count: pinnedSource.extraAttackCount }
: pinnedSource?.triggeredShout
? { kind: "triggered-shout", sourceSpellId: spell.id, sourceProfile: spell.sourceProfile }
: pinnedSource?.sourceAura
? { kind: ["nearby-party", "primary"].includes(pinnedSource.target) ? "target-aura" : "aura", aura: `spell-${spell.id}`,
durationMs: spell.durationMs < 0 ? 0 : spell.durationMs,
wowAuraSource: { spellId: spell.id, sourceProfile: spell.sourceProfile } }
: pinnedSource?.periodicDamage
? { kind: "dot", multiplier: 0, ticks: 5, intervalMs: 3_000,
wowPeriodicSource: { spellId: spell.id, sourceProfile: spell.sourceProfile === "epoch" ? "epoch-client" : "ascension-patch-d" } }
: pinnedSource?.interruptControl
? {
kind: "control",
mechanic: "interrupt",
@@ -1505,7 +1667,8 @@ function spellAttack(spell, hash, boss) {
animation: pinnedSource?.animation ?? (pinnedHostileMelee || melee ? "attack" : "cast"),
range: pinnedSource?.range ?? (target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28),
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
damageMultiplier: effect && !pinnedSource?.preserveDamageMultiplier ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
...(pinnedSource?.chain ? { chain: spell.sourceProfile === "epoch" ? pinnedSource.epochChain : pinnedSource.chain } : {}),
...(area ? { radius: pinnedSource?.radius ?? (dbcRadius || (boss ? 11 : 8)) } : {}),
...(effect ? { effect } : {}),
...(healing
@@ -1574,13 +1737,31 @@ function validateBossMechanicsCatalog(catalog) {
}
function smartAiEventProperties(row) {
const reviewedSelfAura = Number(row.action_type) === 11
&& [7164, 7165, 3419, 6742, 674, 29651].includes(Number(row.action_param1))
&& Number(row.target_type) === 1;
const reviewedOnce = reviewedSelfAura && (Number(row.event_flags) & 1) !== 0
? { maximumCasts: 1 } : {};
const weaponCapability = [674, 29651].includes(Number(row.action_param1));
if (row.event_type === 1 && reviewedSelfAura) {
return {
activation: "out-of-combat",
initialCooldownMs: [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0],
repeatCooldownMs: weaponCapability ? [Number(row.event_param3) || 0, Number(row.event_param4) || 0]
: [Math.max(1, Number(row.event_param3) || 1_000), Math.max(1, Number(row.event_param4) || Number(row.event_param3) || 1_000)],
cooldownMs: Math.max(1_000, Number(row.event_param3) || 1_000),
...reviewedOnce,
};
}
if (row.event_type === 0) {
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
const repeat = weaponCapability ? [Number(row.event_param3) || 0, Number(row.event_param4) || 0]
: [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
return {
initialCooldownMs: initial,
repeatCooldownMs: repeat,
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
...reviewedOnce,
};
}
if (row.event_type === 2) {
@@ -1604,6 +1785,17 @@ function smartAiEventProperties(row) {
cooldownMs: 1_000,
};
}
if (row.event_type === 16 && Number(row.action_type) === 11
&& Number(row.action_param1) === 6742 && Number(row.target_type) === 7) {
return {
activation: Number(row.event_param5) ? undefined : "always",
initialCooldownMs: [0, 0],
repeatCooldownMs: [Number(row.event_param3) || 10_000, Number(row.event_param4) || 10_000],
cooldownMs: Number(row.event_param3) || 10_000,
condition: { kind: "friendly-missing-aura", spellId: Number(row.event_param1),
range: Number(row.event_param2), onlyInCombat: Boolean(Number(row.event_param5)) },
};
}
if (row.event_type === 14) {
return {
initialCooldownMs: [0, 0],
@@ -1620,9 +1812,11 @@ function smartAiEventProperties(row) {
}
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!boss) return [];
const entry = Number(entity.entry);
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
const rows = (worldAi?.smartScriptsByEntry?.[String(entry)] ?? []).filter((row) => boss
|| Number(row.action_type) === 11 && (Number(row.target_type) === 1
&& [7164, 7165, 3419].includes(Number(row.action_param1))
|| [6742, 6713, 17230, 674, 29651, 3391].includes(Number(row.action_param1))));
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
const mechanics = [];
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
@@ -1645,6 +1839,7 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
eventType: Number(trigger.event_type) || 0,
actionType,
targetType: Number(action.target_type) || 0,
...(Number(action.action_type) === 11 ? { castFlags: Number(action.action_param2) || 0 } : {}),
comment: String(action.comment ?? trigger.comment ?? ""),
};
if (actionType === 11) {
@@ -1657,14 +1852,14 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
const targetType = Number(action.target_type);
const pinnedSource = PINNED_SOURCE_SPELLS.get(spellId);
const sourceTarget = pinnedSource?.target;
const target = sourceTarget
const target = spellId === 6742 && targetType === 1 ? "self" : sourceTarget
?? ([5, 6].includes(targetType) ? "random-party" : attack.target);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target,
range: pinnedSource?.range !== undefined || target === "self"
range: target === "self" ? 0 : pinnedSource?.range !== undefined
? attack.range
: Math.max(attack.range, 28),
source,
@@ -1728,7 +1923,10 @@ function uniqueMechanics(mechanics) {
const seenSpellIds = new Set();
return mechanics.filter((mechanic) => {
if (seenIds.has(mechanic.id)) return false;
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
// Hamhock has separate self-once and selected-friendly missing-buff events.
// Keep each sourced event while still suppressing the generic template cast.
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)
&& !(mechanic.spellId === 6742 && mechanic.source?.actionType === 11)) return false;
seenIds.add(mechanic.id);
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
return true;
@@ -2039,7 +2237,7 @@ async function compile() {
runtimeOverrides.set(recipe.slug, override);
}
const drafts = new Map();
const wantedSpellIds = new Set();
const wantedSpellIds = new Set([7164, 7165, 7376, 21156, 3419, 5246, 19134, 20511, 29544, 47294, 47295, 47296]);
for (const recipe of recipes) {
const file = path.join(workRoot, recipe.slug, "runtime-draft.json");
if (!await exists(file)) continue;
@@ -2081,6 +2279,15 @@ async function compile() {
"epoch",
)
: ascensionSpells;
for (const [profile, spells] of [["ascension", ascensionSpells], ["epoch", epochSpells]]) {
for (const spellId of [7164, 7165, 7376, 21156, 3419, 5246, 19134, 20511, 29544, 47294, 47295, 47296]) {
const spell = spells.get(spellId);
if (!spell || spell.sourceProfile !== profile) {
throw new Error(`Missing pinned ${profile} stance/block source spell ${spellId}.`);
}
pinnedSourceSpellFor(spell);
}
}
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
: new Map();
@@ -2361,6 +2568,7 @@ async function compile() {
members: [{
id: "member",
entityId: spawn.entityId,
...(Number.isSafeInteger(spawn.guid) && spawn.guid > 0 ? { sourceCreatureGuid: spawn.guid } : {}),
role: "leader",
formationOffset: [0, 0, 0],
}],
@@ -2371,6 +2579,7 @@ async function compile() {
staticSpawns.push({
id: spawn.id,
entityId: spawn.entityId,
...(Number.isSafeInteger(spawn.guid) && spawn.guid > 0 ? { sourceCreatureGuid: spawn.guid } : {}),
position: transformedPosition,
yaw: spawn.yaw,
spawnMask: spawn.spawnMask,
@@ -0,0 +1,219 @@
import { createHash } from "node:crypto";
const NULL_LINK_VALUES = new Set([-1, 0xffffffff]);
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
);
}
export function stableSha256(value) {
const bytes = typeof value === "string" ? value : JSON.stringify(canonicalize(value));
return createHash("sha256").update(bytes).digest("hex");
}
export function buildDetourGeometryRanges(polygonCount, trianglePolygonIds) {
const starts = new Int32Array(polygonCount).fill(-1);
const counts = new Uint32Array(polygonCount);
let previousPolygonId = -1;
for (let triangleIndex = 0; triangleIndex < trianglePolygonIds.length; triangleIndex += 1) {
const polygonId = trianglePolygonIds[triangleIndex];
if (!Number.isInteger(polygonId) || polygonId < 0 || polygonId >= polygonCount) {
throw new Error(`Exported triangle ${triangleIndex} references invalid Detour polygon ${polygonId}.`);
}
if (polygonId < previousPolygonId) {
throw new Error("Exported Detour triangles are not in stable polygon order.");
}
previousPolygonId = polygonId;
if (counts[polygonId] === 0) starts[polygonId] = triangleIndex;
counts[polygonId] += 1;
}
return Array.from({ length: polygonCount }, (_, polygonId) => [
starts[polygonId],
counts[polygonId],
]);
}
function findComponents(nodeCount, links) {
const parents = new Int32Array(nodeCount);
const sizes = new Int32Array(nodeCount);
for (let id = 0; id < nodeCount; id += 1) {
parents[id] = id;
sizes[id] = 1;
}
const rootOf = (node) => {
let root = node;
while (parents[root] !== root) root = parents[root];
while (parents[node] !== node) {
const next = parents[node];
parents[node] = root;
node = next;
}
return root;
};
for (const [from, to] of links) {
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];
}
}
const components = new Map();
for (let id = 0; id < nodeCount; id += 1) {
const root = rootOf(id);
components.set(root, (components.get(root) ?? 0) + 1);
}
const sizesByDescending = [...components.values()].sort((left, right) => right - left);
return {
count: sizesByDescending.length,
largestPolygonCount: sizesByDescending[0] ?? 0,
polygonCounts: sizesByDescending,
};
}
/**
* Copies the source Detour surface-polygon order and directed link chain into
* compact, stable-ID records. Triangle ranges follow NavMeshHelper's emitted
* tile/polygon/detail-triangle iteration order; links are only actual Detour
* links and never inferred from near-coincident geometry.
*/
export function extractDetourTopology(navMesh, {
sourceCollisionSha256,
settingsSha256,
} = {}) {
if (!navMesh || typeof navMesh.getMaxTiles !== "function") {
throw new Error("Detour topology requires a populated NavMesh.");
}
const polygons = [];
const polygonIdByReference = new Map();
let triangleCount = 0;
let polygonFanTriangleCount = 0;
let tileCount = 0;
let offMeshPolygonCount = 0;
for (let tileIndex = 0; tileIndex < navMesh.getMaxTiles(); tileIndex += 1) {
const tile = navMesh.getTile(tileIndex);
const header = tile.header();
if (!header || header.polyCount() === 0) continue;
tileCount += 1;
const polyRefBase = navMesh.getPolyRefBase(tile);
for (let polygonIndex = 0; polygonIndex < header.polyCount(); polygonIndex += 1) {
const polygon = tile.polys(polygonIndex);
if (polygon.getType() !== 0) {
offMeshPolygonCount += 1;
continue;
}
const detail = tile.detailMeshes(polygonIndex);
const triangleCountForPolygon = detail.triCount();
const polygonVertexCount = polygon.vertCount();
const id = polygons.length;
polygons.push([
id,
tileIndex,
header.x(),
header.y(),
header.layer(),
polygonIndex,
triangleCount,
triangleCountForPolygon,
polygonVertexCount,
]);
triangleCount += triangleCountForPolygon;
polygonFanTriangleCount += Math.max(0, polygonVertexCount - 2);
polygonIdByReference.set(polyRefBase + polygonIndex, id);
}
}
const links = [];
let unrepresentedLinkCount = 0;
for (const polygonRecord of polygons) {
const [fromId, tileIndex, , , , polygonIndex] = polygonRecord;
const tile = navMesh.getTile(tileIndex);
const polygon = tile.polys(polygonIndex);
const header = tile.header();
const maxLinkCount = header.maxLinkCount();
let linkIndex = polygon.firstLink();
let traversed = 0;
while (!NULL_LINK_VALUES.has(linkIndex)) {
if (!Number.isInteger(linkIndex) || linkIndex < 0 || linkIndex >= maxLinkCount) {
throw new Error(
`Detour polygon ${tileIndex}:${polygonIndex} has invalid link index ${linkIndex}.`,
);
}
if (traversed >= maxLinkCount) {
throw new Error(`Detour polygon ${tileIndex}:${polygonIndex} contains a link cycle.`);
}
traversed += 1;
const link = tile.links(linkIndex);
const toId = polygonIdByReference.get(link.ref());
if (toId === undefined) {
unrepresentedLinkCount += 1;
} else {
const edgeIndex = link.edge();
const polygonVertexCount = polygon.vertCount();
if (edgeIndex < 0 || edgeIndex >= polygonVertexCount) {
throw new Error(
`Detour polygon ${tileIndex}:${polygonIndex} has invalid linked edge ${edgeIndex}.`,
);
}
const edgeStartVertex = polygon.verts(edgeIndex);
const edgeEndVertex = polygon.verts((edgeIndex + 1) % polygonVertexCount);
const edgeStartOffset = edgeStartVertex * 3;
const edgeEndOffset = edgeEndVertex * 3;
links.push([
fromId,
toId,
edgeIndex,
link.side(),
link.bmin(),
link.bmax(),
edgeStartVertex,
edgeEndVertex,
tile.verts(edgeStartOffset),
tile.verts(edgeStartOffset + 1),
tile.verts(edgeStartOffset + 2),
tile.verts(edgeEndOffset),
tile.verts(edgeEndOffset + 1),
tile.verts(edgeEndOffset + 2),
]);
}
linkIndex = link.next();
}
}
const weakComponents = findComponents(polygons.length, links.map(([from, to]) => [from, to]));
return {
schemaVersion: 1,
kind: "recast-detour-surface-links",
coordinateSpace: "GLB navigation POSITION coordinates (meters, Three.js Y-up)",
encoding: {
polygons: "[id,tileIndex,tileX,tileY,tileLayer,tilePolygonIndex,firstGeometryTriangle,geometryTriangleCount,vertexCount]",
links: "[fromPolygonId,toPolygonId,fromEdge,side,bmin,bmax,fromEdgeStartVertex,fromEdgeEndVertex,startX,startY,startZ,endX,endY,endZ] (directed Detour link records)",
},
sourceCollisionSha256: sourceCollisionSha256 ?? null,
settingsSha256: settingsSha256 ?? null,
tileCount,
polygonCount: polygons.length,
detailTriangleCount: triangleCount,
polygonFanTriangleCount,
geometryTriangleCount: null,
directedLinkCount: links.length,
unrepresentedLinkCount,
offMeshPolygonCount,
weakComponents,
polygons,
links,
};
}
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDetourGeometryRanges,
extractDetourTopology,
stableSha256,
} from "./detour-topology.mjs";
const NULL_LINK = 0xffffffff;
function fakePoly({
type = 0,
firstLink = NULL_LINK,
vertexCount = 4,
vertexIndices = [0, 1, 2, 3],
} = {}) {
return {
getType: () => type,
firstLink: () => firstLink,
vertCount: () => vertexCount,
verts: (index) => vertexIndices[index],
};
}
function fakeTile({ x, y, layer = 0, polys, details, links, vertices = [] }) {
return {
header: () => ({
polyCount: () => polys.length,
maxLinkCount: () => links.length,
x: () => x,
y: () => y,
layer: () => layer,
}),
polys: (index) => polys[index],
detailMeshes: (index) => details[index],
links: (index) => links[index],
verts: (index) => vertices[index],
};
}
test("extracts stable surface polygon IDs and only actual directed Detour links", () => {
const tiles = [
fakeTile({
x: 4,
y: 8,
polys: [
fakePoly({ firstLink: 0 }),
fakePoly({ type: 1 }), // Detour off-mesh connection polygons have no surface triangles.
],
details: [{ triCount: () => 2 }, { triCount: () => 0 }],
vertices: [0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1],
links: [{
ref: () => 2000,
next: () => NULL_LINK,
edge: () => 2,
side: () => 255,
bmin: () => 0,
bmax: () => 0,
}],
}),
fakeTile({
x: 5,
y: 8,
polys: [fakePoly({ firstLink: 0 }), fakePoly()],
details: [{ triCount: () => 1 }, { triCount: () => 4 }],
links: [{
ref: () => 1001, // Points to the omitted off-mesh polygon, not a floor polygon.
next: () => NULL_LINK,
edge: () => 3,
side: () => 1,
bmin: () => 32,
bmax: () => 192,
}],
}),
];
const topology = extractDetourTopology({
getMaxTiles: () => tiles.length,
getTile: (index) => tiles[index],
getPolyRefBase: (tile) => tile === tiles[0] ? 1000 : 2000,
});
assert.deepEqual(topology.polygons, [
[0, 0, 4, 8, 0, 0, 0, 2, 4],
[1, 1, 5, 8, 0, 0, 2, 1, 4],
[2, 1, 5, 8, 0, 1, 3, 4, 4],
]);
assert.deepEqual(topology.links, [[
0, 1, 2, 255, 0, 0,
2, 3,
1, 0, 1,
0, 0, 1,
]]);
assert.equal(topology.detailTriangleCount, 7);
assert.equal(topology.polygonFanTriangleCount, 6);
assert.equal(topology.offMeshPolygonCount, 1);
assert.equal(topology.unrepresentedLinkCount, 1);
assert.deepEqual(topology.weakComponents, {
count: 2,
largestPolygonCount: 2,
polygonCounts: [2, 1],
});
});
test("rejects invalid Detour link chains instead of emitting unverifiable topology", () => {
const tile = fakeTile({
x: 0,
y: 0,
polys: [fakePoly({ firstLink: 2 })],
details: [{ triCount: () => 1 }],
links: [{
ref: () => 1000,
next: () => NULL_LINK,
edge: () => 0,
side: () => 255,
bmin: () => 0,
bmax: () => 0,
}],
});
assert.throws(() => extractDetourTopology({
getMaxTiles: () => 1,
getTile: () => tile,
getPolyRefBase: () => 1000,
}), /invalid link index 2/);
});
test("fingerprints ignore object key order but include source values", () => {
assert.equal(stableSha256({ b: 2, a: 1 }), stableSha256({ a: 1, b: 2 }));
assert.notEqual(stableSha256({ a: 1 }), stableSha256({ a: 2 }));
});
test("maps filtered output triangles to contiguous per-polygon ranges", () => {
assert.deepEqual(buildDetourGeometryRanges(4, [0, 0, 2, 2, 3, 3]), [
[0, 2],
[-1, 0],
[2, 2],
[4, 2],
]);
assert.throws(() => buildDetourGeometryRanges(2, [1, 0]), /stable polygon order/);
assert.throws(() => buildDetourGeometryRanges(2, [2]), /invalid Detour polygon/);
});
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><link rel="icon" href="data:,"><title>WoW environment review</title><style>body{margin:0;background:#161820;color:white;font:14px sans-serif}canvas{display:block}#status{position:absolute;top:10px;left:10px;background:#111c;padding:10px;pointer-events:none}</style></head>
<body><div id="status">Loading environment…</div><script type="module" src="./environment-preview.ts"></script></body>
</html>
@@ -0,0 +1,152 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import compressedCampaign from "../../src/game/generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate";
import { WAILING_CAVERNS_DEFINITION } from "../../src/game/dungeons/wailingCaverns";
import { createBiteSizedStockadesDefinition } from "../../src/game/dungeons/biteSizedStockades";
import { withWowDungeonEnvironment } from "../../src/game/wowDungeonEnvironment";
import type { DungeonDefinition } from "../../src/game/dungeonTypes";
import { resolveDungeonAssets } from "../../src/game/dungeonAssets";
import { prepareDungeonMaterial } from "../../src/scene/productionDungeonMaterials";
import { applyDungeonMeshGraphics } from "../../src/scene/graphicsMaterialQuality";
import { setDungeonShadowLightPose } from "../../src/scene/dungeonShadowLightRuntime";
import type { ShadowQuality } from "../../src/game/graphicsSettings";
// Isolated asset QA uses shipping assets and the production material/light helpers.
// It intentionally does not claim gameplay, collision or encounter certification.
const params = new URLSearchParams(location.search);
const id = params.get("dungeon") ?? "bite-sized-stormwind-stockade";
// Only environment data is needed. Avoid importing combat/encounter adapters
// into this isolated renderer; the registry uses this same environment adapter.
const campaign = JSON.parse(strFromU8(gunzipSync(Uint8Array.from(atob(compressedCampaign), (char) => char.charCodeAt(0))))) as { definitions: DungeonDefinition[] };
const definitions = campaign.definitions.filter((entry) => entry.expansion !== "runewaker")
.map((entry) => withWowDungeonEnvironment(entry.id === "wailing-caverns" ? WAILING_CAVERNS_DEFINITION : entry));
const stockade = definitions.find((entry) => entry.id === "stormwind-stockade")!;
definitions.push(createBiteSizedStockadesDefinition(stockade, "wow-environment-review-2026-10-06"));
function findDefinition(): DungeonDefinition {
const entry = definitions.find((candidate) => candidate.id === id);
if (!entry) throw new Error(`Unknown WoW dungeon: ${id}`);
return entry;
}
const definition = findDefinition();
if (definition.expansion === "runewaker") throw new Error(`Expected WoW dungeon: ${id}`);
const p = definition.presentation;
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(1);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(p.fog.color);
scene.fog = new THREE.Fog(p.fog.color, p.fog.near, p.fog.far);
scene.add(new THREE.AmbientLight(p.ambientLight.color, p.ambientLight.intensity));
scene.add(new THREE.HemisphereLight(p.hemisphereLight.skyColor, p.hemisphereLight.groundColor, p.hemisphereLight.intensity));
const light = new THREE.DirectionalLight(p.directionalLight.color, p.directionalLight.intensity);
light.shadow.bias = -0.00025;
scene.add(light, light.target);
for (const entry of p.pointLights ?? []) {
const point = new THREE.PointLight(entry.color, entry.intensity, entry.distance, 2);
point.position.fromArray(entry.position);
scene.add(point);
}
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);
const controls = new OrbitControls(camera, renderer.domElement);
const loader = new GLTFLoader();
const decoder = new KTX2Loader().setTranscoderPath("/basis/").detectSupport(renderer);
loader.setKTX2Loader(decoder).setMeshoptDecoder(MeshoptDecoder);
const root = new THREE.Group();
root.position.fromArray(definition.transform.position);
root.rotation.fromArray([...definition.transform.rotation, "XYZ"]);
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}`);
for (const url of assets.visual) {
const reviewUrl = 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;
const gltf = await loader.loadAsync(reviewUrl);
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);
});
root.add(gltf.scene);
}
let quality: ShadowQuality = "standard";
function setQuality(value: ShadowQuality) {
quality = value;
const extent = quality === "high" ? 26 : 5;
light.castShadow = quality !== "off";
light.shadow.map?.dispose();
light.shadow.map = null;
light.shadow.mapSize.set(quality === "high" ? 2048 : 1024, quality === "high" ? 2048 : 1024);
Object.assign(light.shadow.camera, { left: -extent, right: extent, top: extent, bottom: -extent, near: 0.5, far: 90 });
light.shadow.camera.updateProjectionMatrix();
applyDungeonMeshGraphics(root, "environment", quality, quality === "high" ? 16 : 1, renderer.capabilities.getMaxAnisotropy());
setDungeonShadowLightPose(light, new THREE.Vector3(...definition.entrance.footPosition), new THREE.Vector3(...p.directionalLight.offset));
updateStatus();
}
const viewpoints = [
{ name: "entrance", position: definition.entrance.footPosition, forward: definition.entrance.forward },
...definition.bosses.map((boss) => ({ name: boss.name, position: boss.position, forward: definition.entrance.forward })),
];
let selectedView = 0;
function updateStatus() {
document.querySelector("#status")!.textContent = `${definition.title} · ${viewpoints[selectedView].name} · ${quality} shadows / ${quality === "high" ? "16×" : "1×"} filtering`;
}
function view(index: number, yaw = 0) {
selectedView = Math.max(0, Math.min(index, viewpoints.length - 1));
const entry = viewpoints[selectedView];
const eye = new THREE.Vector3(...entry.position).add(new THREE.Vector3(0, 2.2, 0));
const forward = new THREE.Vector3(...entry.forward).normalize().applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
camera.position.copy(eye);
controls.target.copy(eye).add(forward.multiplyScalar(8)).add(new THREE.Vector3(0, -1.2, 0));
controls.update();
updateStatus();
}
function diagnostics() {
let meshes = 0, casters = 0;
const textures = new Map<string, { name: string; width: number; height: number; anisotropy: number }>();
root.traverse((node) => {
if (!(node instanceof THREE.Mesh)) return;
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 });
}
});
return { meshes, casters, textures: [...textures.values()], 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() };
}
view(0);
setQuality("standard");
let frames = 0;
let frameStart = performance.now();
function resetFrameSample() {
frames = 0;
frameStart = performance.now();
}
function frameSample() {
const elapsedMs = performance.now() - frameStart;
return { frames, elapsedMs, fps: elapsedMs > 0 ? frames * 1000 / elapsedMs : null };
}
renderer.setAnimationLoop(() => {
controls.update();
if (quality === "high") setDungeonShadowLightPose(light, camera.position, new THREE.Vector3(...p.directionalLight.offset));
renderer.render(scene, camera);
frames++;
});
Object.assign(window, { wowEnvironmentReview: { ready: true, id, viewpoints, view, setQuality, diagnostics, resetFrameSample, frameSample, presentation: p,
catalog: definitions.filter((entry) => !entry.id.includes("bite-sized")).map((entry) => ({ id: entry.id, title: entry.title })) } });
@@ -15,6 +15,15 @@ const defaultReferenceOutput = path.join(
"generated",
"azerothCoreReference.json",
);
const defaultItemDbc = path.resolve(
projectRoot,
"..",
"wow335a",
"Data",
"patch-A.MPQ",
"DBFilesClient",
"Item.dbc",
);
const defaultBaseStatsOutput = path.join(
projectRoot,
"src",
@@ -55,11 +64,48 @@ export const PINNED_CREATURE_IMMUNITIES_SOURCE = Object.freeze({
),
});
export const PINNED_CREATURE_EQUIPMENT_SOURCE = Object.freeze({
repository: "https://github.com/azerothcore/azerothcore-wotlk",
commit: DEFAULT_AZEROTHCORE_COMMIT,
path: "data/sql/base/db_world/creature_equip_template.sql",
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${DEFAULT_AZEROTHCORE_COMMIT}/data/sql/base/db_world/creature_equip_template.sql`,
sha256: "877205c149fd447b08f0ff2c65976f20c862cd42f068a6fcc294a76c7108936a",
fixtureRoot: path.join(
projectRoot,
"dungeon-pipeline",
"source-fixtures",
`azerothcore-${DEFAULT_AZEROTHCORE_COMMIT}`,
),
});
export const PINNED_ITEM_DBC_SOURCE = Object.freeze({
label: "WoW 3.3.5a Item.dbc",
path: "../wow335a/Data/patch-A.MPQ/DBFilesClient/Item.dbc",
sha256: "22061a811523790ddd2ee95b36ade2ac6e0c79a8b3121324d32a639acf50567f",
fields: 8,
recordSize: 32,
classField: 1,
inventoryTypeField: 6,
});
const EQUIPMENT_HAND_INVENTORY_TYPES = Object.freeze(new Set([
13, // INVTYPE_WEAPON
14, // INVTYPE_SHIELD
15, // INVTYPE_RANGED
17, // INVTYPE_2HWEAPON
21, // INVTYPE_WEAPONMAINHAND
22, // INVTYPE_WEAPONOFFHAND
23, // INVTYPE_HOLDABLE
25, // INVTYPE_THROWN
26, // INVTYPE_RANGEDRIGHT
]));
const SOURCE_TABLES = Object.freeze([
"creature",
"creature_template",
"creature_template_model",
"creature_template_spell",
"creature_equip_template",
"creature_classlevelstats",
"creature_summon_groups",
"creature_addon",
@@ -319,6 +365,11 @@ function bitMaskNames(maskValue, names, bitOffset = 0) {
export function creatureTemplateReference(row, creatureImmunitiesById = new Map()) {
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
const sourceFlagsExtra = firstValue(row, ["flags_extra", "flagsExtra"]);
const flagsExtra = sourceFlagsExtra === undefined ? undefined : Number(sourceFlagsExtra);
if (flagsExtra !== undefined && (!Number.isSafeInteger(flagsExtra) || flagsExtra < 0 || flagsExtra > 0xffff_ffff)) {
throw new Error(`Creature ${entry} has unsupported flags_extra ${sourceFlagsExtra}.`);
}
const legacyMechanicImmuneMask = firstValue(row, ["mechanic_immune_mask", "mechanicImmuneMask"]);
const creatureImmunitiesId = firstValue(row, ["CreatureImmunitiesId", "creatureImmunitiesId"]);
let mechanicImmuneMask = numeric(legacyMechanicImmuneMask);
@@ -360,6 +411,7 @@ export function creatureTemplateReference(row, creatureImmunitiesById = new Map(
baseAttackTimeMs: numeric(firstValue(row, ["BaseAttackTime", "baseAttackTime"])) || 2_000,
baseVariance: numeric(firstValue(row, ["BaseVariance", "baseVariance"])) || 1,
unitClass: numeric(firstValue(row, ["unit_class", "unitClass"])) || 1,
...(flagsExtra === undefined ? {} : { flagsExtra }),
healthModifier: numeric(firstValue(row, ["HealthModifier", "ModHealth"])) || 1,
manaModifier: numeric(firstValue(row, ["ManaModifier", "ModMana"])) || 1,
armorModifier: numeric(firstValue(row, ["ArmorModifier", "ModArmor"])) || 1,
@@ -645,6 +697,82 @@ export async function parseTable(sourceRoot, tableName, visitor) {
};
}
/**
* Reads the pinned WotLK Item.dbc fields used by AzerothCore's equipment
* loader. Item.dbc field 1 is Class and field 6 is InventoryType.
*/
export function parseItemDbc(buffer) {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
if (buffer.length < 20 || buffer.toString("ascii", 0, 4) !== "WDBC") {
throw new Error("Item.dbc has an invalid WDBC header.");
}
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringBlockSize = buffer.readUInt32LE(16);
if (fieldCount !== PINNED_ITEM_DBC_SOURCE.fields
|| recordSize !== PINNED_ITEM_DBC_SOURCE.recordSize
|| recordSize !== fieldCount * 4
|| 20 + recordCount * recordSize + stringBlockSize !== buffer.length) {
throw new Error(
`Item.dbc schema is unsupported (records=${recordCount}, fields=${fieldCount}, `
+ `recordSize=${recordSize}, stringBlockSize=${stringBlockSize}).`,
);
}
const itemsById = new Map();
for (let index = 0; index < recordCount; index += 1) {
const offset = 20 + index * recordSize;
const id = buffer.readUInt32LE(offset);
if (id === 0 || itemsById.has(id)) {
throw new Error(`Item.dbc contains an invalid or duplicate item ID ${id}.`);
}
itemsById.set(id, Object.freeze({
itemClass: buffer.readUInt32LE(offset + PINNED_ITEM_DBC_SOURCE.classField * 4),
inventoryType: buffer.readUInt32LE(offset + PINNED_ITEM_DBC_SOURCE.inventoryTypeField * 4),
}));
}
return Object.freeze({
recordCount,
fieldCount,
recordSize,
stringBlockSize,
itemsById,
});
}
/**
* Mirrors ObjectMgr::LoadEquipmentTemplates item lookup and hand-slot
* InventoryType validation. Invalid or absent Item.dbc rows become empty
* virtual item slots before Creature::HasWeapon can inspect them.
*/
export function creatureEquipmentTemplateReference(row, itemMetadataById) {
const creatureId = numeric(firstValue(row, ["CreatureID", "creatureId", "entry"]));
const equipmentId = numeric(firstValue(row, ["ID", "id", "equipmentId"]));
if (!Number.isSafeInteger(creatureId) || creatureId <= 0
|| !Number.isSafeInteger(equipmentId) || equipmentId <= 0 || equipmentId > 255) {
throw new Error(`Creature equipment row has invalid key ${creatureId}/${equipmentId}.`);
}
const sourceItems = [
numeric(firstValue(row, ["ItemID1", "itemId1"])),
numeric(firstValue(row, ["ItemID2", "itemId2"])),
numeric(firstValue(row, ["ItemID3", "itemId3"])),
];
if (sourceItems.some((itemId) => !Number.isSafeInteger(itemId) || itemId < 0 || itemId > 0xffff_ffff)) {
throw new Error(`Creature equipment row ${creatureId}/${equipmentId} has an invalid item ID.`);
}
const items = sourceItems.map((itemId) => {
if (itemId === 0) return 0;
const item = itemMetadataById.get(itemId);
return item && EQUIPMENT_HAND_INVENTORY_TYPES.has(item.inventoryType) ? itemId : 0;
});
return Object.freeze({
creatureId,
equipmentId,
sourceItems,
items,
});
}
async function fetchSources(sourceRoot, commit) {
await mkdir(sourceRoot, { recursive: true });
const base = `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world`;
@@ -855,6 +983,7 @@ export async function importAzerothCoreWorld({
outputRoot = defaultOutputRoot,
referenceOutput = defaultReferenceOutput,
baseStatsOutput = defaultBaseStatsOutput,
itemDbcFile = defaultItemDbc,
commit = DEFAULT_AZEROTHCORE_COMMIT,
branch = "master",
dirty = false,
@@ -865,6 +994,15 @@ export async function importAzerothCoreWorld({
const resolvedOutputRoot = path.resolve(outputRoot);
const resolvedReferenceOutput = path.resolve(referenceOutput);
if (fetch) await fetchSources(resolvedSourceRoot, commit);
const itemDbcBuffer = await readFile(path.resolve(itemDbcFile));
const itemDbcSha256 = sha256(itemDbcBuffer);
if (itemDbcSha256 !== PINNED_ITEM_DBC_SOURCE.sha256) {
throw new Error(
`Item.dbc SHA-256 ${itemDbcSha256} does not match pinned `
+ `${PINNED_ITEM_DBC_SOURCE.sha256}.`,
);
}
const itemDbc = parseItemDbc(itemDbcBuffer);
const recipes = await loadDungeonRecipes();
const recipesByMap = groupByMap(recipes);
@@ -979,6 +1117,110 @@ export async function importAzerothCoreWorld({
const entry = numeric(firstValue(row, ["CreatureID", "entry", "creatureId"]));
if (creatureEntries.has(entry)) groupedPush(spellsByEntry, entry, row);
}));
let equipmentSourceRoot = resolvedSourceRoot;
try {
await readFile(path.join(equipmentSourceRoot, "creature_equip_template.sql"));
} catch (error) {
if (error?.code !== "ENOENT" || commit !== PINNED_CREATURE_EQUIPMENT_SOURCE.commit) throw error;
equipmentSourceRoot = PINNED_CREATURE_EQUIPMENT_SOURCE.fixtureRoot;
}
const equipmentCreatureEntries = new Set(creatureEntries);
for (const rows of creaturesByMap.values()) {
for (const row of rows) {
for (const column of ["id1", "id2", "id3"]) {
const entry = numeric(firstValue(row, [column]));
if (entry > 0) equipmentCreatureEntries.add(entry);
}
}
}
const equipmentTemplateRowsByEntry = new Map();
const equipmentTemplateReport = await parseTable(
equipmentSourceRoot,
"creature_equip_template",
(row) => {
const entry = numeric(firstValue(row, ["CreatureID", "creatureId", "entry"]));
const equipmentId = numeric(firstValue(row, ["ID", "id", "equipmentId"]));
if (!equipmentCreatureEntries.has(entry)) return;
const rows = equipmentTemplateRowsByEntry.get(entry) ?? new Map();
if (rows.has(equipmentId)) {
throw new Error(`creature_equip_template has duplicate key ${entry}/${equipmentId}.`);
}
rows.set(equipmentId, row);
equipmentTemplateRowsByEntry.set(entry, rows);
},
);
if (commit === PINNED_CREATURE_EQUIPMENT_SOURCE.commit
&& equipmentTemplateReport.sha256 !== PINNED_CREATURE_EQUIPMENT_SOURCE.sha256) {
throw new Error(
`creature_equip_template.sql does not match pinned ${commit} SHA-256 `
+ `${PINNED_CREATURE_EQUIPMENT_SOURCE.sha256}.`,
);
}
tableReports.push({
...equipmentTemplateReport,
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world/creature_equip_template.sql`,
});
const normalizedEquipmentTemplatesByEntry = new Map();
const equipmentItemsById = new Map();
for (const [entry, equipmentRows] of equipmentTemplateRowsByEntry) {
const normalizedRows = new Map();
for (const [equipmentId, row] of equipmentRows) {
const normalized = creatureEquipmentTemplateReference(row, itemDbc.itemsById);
normalizedRows.set(equipmentId, normalized);
for (const itemId of normalized.sourceItems) {
if (!itemId || equipmentItemsById.has(itemId)) continue;
equipmentItemsById.set(itemId, itemDbc.itemsById.get(itemId) ?? null);
}
}
normalizedEquipmentTemplatesByEntry.set(entry, normalizedRows);
}
const equipmentAssignmentsBySpawnGuid = new Map();
for (const rows of creaturesByMap.values()) {
for (const row of rows) {
const guid = numeric(firstValue(row, ["guid", "GUID"]));
const entry = numeric(firstValue(row, ["id1", "entry", "creatureId"]));
const alternateEntries = ["id2", "id3"]
.map((column) => numeric(firstValue(row, [column])))
.filter((alternateEntry) => alternateEntry > 0);
const selectorValue = firstValue(row, ["equipment_id", "equipmentId"]);
const selector = Number(selectorValue);
if (!Number.isSafeInteger(guid) || guid <= 0
|| !Number.isSafeInteger(entry) || entry <= 0
|| !Number.isInteger(selector) || selector < -128 || selector > 127) {
throw new Error(`Creature spawn ${guid} has an invalid equipment assignment.`);
}
if (equipmentAssignmentsBySpawnGuid.has(guid)) {
throw new Error(`Creature spawn GUID ${guid} appears more than once in imported maps.`);
}
equipmentAssignmentsBySpawnGuid.set(guid, { entry, alternateEntries, selector });
}
}
const equipmentTemplatesReference = Object.fromEntries(
[...normalizedEquipmentTemplatesByEntry.entries()]
.sort(([left], [right]) => left - right)
.map(([entry, rows]) => [
entry,
Object.fromEntries([...rows.entries()]
.sort(([left], [right]) => left - right)
.map(([equipmentId, row]) => [equipmentId, {
sourceItems: row.sourceItems,
items: row.items,
}])),
]),
);
const equipmentItemsReference = Object.fromEntries(
[...equipmentItemsById.entries()]
.sort(([left], [right]) => left - right)
.map(([itemId, item]) => [itemId, item]),
);
const equipmentAssignmentsReference = Object.fromEntries(
[...equipmentAssignmentsBySpawnGuid.entries()]
.sort(([left], [right]) => left - right),
);
for (const [entry, rows] of spellsByEntry) {
const template = templateByEntry.get(entry);
if (!template) continue;
@@ -1152,6 +1394,13 @@ export async function importAzerothCoreWorld({
const objectEntries = new Set(
gameObjectRows.map((row) => numeric(firstValue(row, ["id", "entry", "gameObjectId"]))),
);
const mapEquipmentEntries = new Set(entries);
for (const row of creatureRows) {
for (const column of ["id2", "id3"]) {
const entry = numeric(firstValue(row, [column]));
if (entry > 0) mapEquipmentEntries.add(entry);
}
}
const output = {
schemaVersion: 2,
revision: `azerothcore-wotlk@${commit}`,
@@ -1171,6 +1420,9 @@ export async function importAzerothCoreWorld({
},
tables: {
creature: creatureRows,
creature_equip_template: [...mapEquipmentEntries].flatMap(
(entry) => [...(equipmentTemplateRowsByEntry.get(entry)?.values() ?? [])],
),
creature_template: [...entries].flatMap((entry) => templateByEntry.get(entry) ?? []),
creature_template_model: [...entries].flatMap((entry) => modelsByEntry.get(entry) ?? []),
creature_template_spell: [...entries].flatMap((entry) => spellsByEntry.get(entry) ?? []),
@@ -1305,6 +1557,21 @@ export async function importAzerothCoreWorld({
creature: {
classLevelStats: normalizedCreatureClassStats,
templates: normalizedTemplates,
equipmentTemplatesByEntry: equipmentTemplatesReference,
equipmentAssignmentsBySpawnGuid: equipmentAssignmentsReference,
equipmentItemsById: equipmentItemsReference,
equipmentSource: {
creatureEquipTemplate: {
commit,
path: "data/sql/base/db_world/creature_equip_template.sql",
sha256: equipmentTemplateReport.sha256,
},
itemDbc: {
...PINNED_ITEM_DBC_SOURCE,
recordCount: itemDbc.recordCount,
byteLength: itemDbcBuffer.length,
},
},
},
wailingCaverns: {
requiredBossEntries: WAILING_CAVERNS_REQUIRED_ENCOUNTERS
@@ -1387,6 +1654,13 @@ export async function importAzerothCoreWorld({
playerBaseStats: 2,
},
files: sourceFiles,
externalInputs: {
itemDbc: {
...PINNED_ITEM_DBC_SOURCE,
recordCount: itemDbc.recordCount,
byteLength: itemDbcBuffer.length,
},
},
},
);
if (baseStatsOutput) {
@@ -1429,6 +1703,7 @@ function commandOptions(argv) {
else if (argument === "--source") options.sourceRoot = argv[++index];
else if (argument === "--output") options.outputRoot = argv[++index];
else if (argument === "--reference-output") options.referenceOutput = argv[++index];
else if (argument === "--item-dbc") options.itemDbcFile = argv[++index];
else if (argument === "--base-stats-output") options.baseStatsOutput = argv[++index];
else if (argument === "--commit") options.commit = argv[++index];
else if (argument === "--branch") options.branch = argv[++index];
@@ -1,10 +1,14 @@
import assert from "node:assert/strict";
import { before, describe, it } from "node:test";
import {
creatureEquipmentTemplateReference,
creatureTemplateReference,
creatureTemplateUsesImmunitiesReference,
parseItemDbc,
parseTable,
PINNED_CREATURE_EQUIPMENT_SOURCE,
PINNED_CREATURE_IMMUNITIES_SOURCE,
PINNED_ITEM_DBC_SOURCE,
} from "./import-azerothcore-world.mjs";
describe("AzerothCore creature immunity import", () => {
@@ -94,3 +98,105 @@ describe("AzerothCore creature immunity import", () => {
assert.equal(creatureTemplateUsesImmunitiesReference(fkSchema), true);
});
});
describe("AzerothCore creature blocking flags import", () => {
it("preserves NO_BLOCK and other source bits without an equipment requirement", () => {
assert.equal(creatureTemplateReference({ entry: 1666, flags_extra: 0 }).flagsExtra, 0);
assert.equal(creatureTemplateReference({ entry: 2, flags_extra: 0x110 }).flagsExtra, 0x110);
assert.equal(creatureTemplateReference({ entry: 3, flagsExtra: 0x10 }).flagsExtra, 0x10);
});
it("leaves unknown flags absent and rejects invalid source masks", () => {
assert.equal(creatureTemplateReference({ entry: 4 }).flagsExtra, undefined);
for (const flags_extra of [-1, 0.5, 0x1_0000_0000, "invalid"]) {
assert.throws(() => creatureTemplateReference({ entry: 4, flags_extra }), /unsupported flags_extra/);
}
});
});
describe("AzerothCore creature equipment import", () => {
it("pins and parses the exact creature equipment template SQL", async () => {
const rows = [];
const report = await parseTable(
PINNED_CREATURE_EQUIPMENT_SOURCE.fixtureRoot,
"creature_equip_template",
(row) => {
if (Number(row.CreatureID) === 32239) rows.push(row);
},
);
assert.equal(PINNED_CREATURE_EQUIPMENT_SOURCE.commit, "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3");
assert.equal(PINNED_CREATURE_EQUIPMENT_SOURCE.path,
"data/sql/base/db_world/creature_equip_template.sql");
assert.equal(PINNED_CREATURE_EQUIPMENT_SOURCE.sha256,
"877205c149fd447b08f0ff2c65976f20c862cd42f068a6fcc294a76c7108936a");
assert.equal(report.sha256, PINNED_CREATURE_EQUIPMENT_SOURCE.sha256);
assert.equal(report.rows, 10_854);
assert.deepEqual(rows.map((row) => ({
CreatureID: row.CreatureID,
ID: row.ID,
ItemID1: row.ItemID1,
ItemID2: row.ItemID2,
ItemID3: row.ItemID3,
})), [{ CreatureID: 32239, ID: 2, ItemID1: 13262, ItemID2: 0, ItemID3: 0 }]);
assert.ok(["CreatureID", "ID", "ItemID1", "ItemID2", "ItemID3"]
.every((column) => report.columns.includes(column)));
});
it("parses Item.dbc Class/InventoryType and applies the core hand-item whitelist", () => {
const rows = [
{ id: 100, itemClass: 2, inventoryType: 13 },
{ id: 101, itemClass: 4, inventoryType: 14 },
{ id: 102, itemClass: 2, inventoryType: 2 },
];
const buffer = Buffer.alloc(20 + rows.length * 32 + 1);
buffer.write("WDBC", 0, "ascii");
buffer.writeUInt32LE(rows.length, 4);
buffer.writeUInt32LE(PINNED_ITEM_DBC_SOURCE.fields, 8);
buffer.writeUInt32LE(PINNED_ITEM_DBC_SOURCE.recordSize, 12);
buffer.writeUInt32LE(1, 16);
rows.forEach((row, index) => {
const offset = 20 + index * PINNED_ITEM_DBC_SOURCE.recordSize;
buffer.writeUInt32LE(row.id, offset);
buffer.writeUInt32LE(row.itemClass, offset + PINNED_ITEM_DBC_SOURCE.classField * 4);
buffer.writeUInt32LE(row.inventoryType, offset + PINNED_ITEM_DBC_SOURCE.inventoryTypeField * 4);
});
const itemDbc = parseItemDbc(buffer);
assert.deepEqual(itemDbc.itemsById.get(100), { itemClass: 2, inventoryType: 13 });
assert.deepEqual(itemDbc.itemsById.get(101), { itemClass: 4, inventoryType: 14 });
assert.equal(itemDbc.itemsById.get(102).inventoryType, 2);
const equipment = creatureEquipmentTemplateReference({
CreatureID: 99,
ID: 1,
ItemID1: 100,
ItemID2: 101,
ItemID3: 102,
}, itemDbc.itemsById);
assert.deepEqual(equipment, {
creatureId: 99,
equipmentId: 1,
sourceItems: [100, 101, 102],
items: [100, 101, 0],
});
assert.equal(PINNED_ITEM_DBC_SOURCE.sha256,
"22061a811523790ddd2ee95b36ade2ac6e0c79a8b3121324d32a639acf50567f");
});
it("rejects unsupported DBC shapes, duplicate item IDs, and invalid equipment keys", () => {
const invalid = Buffer.from("not DBC");
assert.throws(() => parseItemDbc(invalid), /invalid WDBC header/);
const duplicate = Buffer.alloc(20 + 2 * 32 + 1);
duplicate.write("WDBC", 0, "ascii");
duplicate.writeUInt32LE(2, 4);
duplicate.writeUInt32LE(8, 8);
duplicate.writeUInt32LE(32, 12);
duplicate.writeUInt32LE(1, 16);
duplicate.writeUInt32LE(5, 20);
duplicate.writeUInt32LE(5, 52);
assert.throws(() => parseItemDbc(duplicate), /duplicate item ID 5/);
assert.throws(() => creatureEquipmentTemplateReference({
CreatureID: 99,
ID: 0,
}, new Map()), /invalid key/);
});
});
@@ -0,0 +1,31 @@
import { readFile, mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
const directory = path.resolve("artifacts/playtest/wow-environment-quality");
const { results } = JSON.parse(await readFile(path.join(directory, "results.json"), "utf8"));
const loaded = results.filter((entry) => entry.status === "loaded");
const width = 384, height = 240, labelHeight = 34, rowsPerSheet = 6;
const escaped = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
await mkdir(directory, { recursive: true });
for (let start = 0; start < loaded.length; start += rowsPerSheet) {
const entries = loaded.slice(start, start + rowsPerSheet);
const composites = [];
for (const [row, entry] of entries.entries()) {
const columns = [
{ file: `${entry.id}-standard.png`, label: "Standard entrance" },
{ file: `${entry.id}-high-view-0.png`, label: "High entrance" },
{ file: `${entry.id}-high-view-${entry.viewpoints.length - 1}.png`, label: "High final boss area" },
{ file: `${entry.id}-entrance-reverse.png`, label: "High reverse entrance" },
];
for (const [column, capture] of columns.entries()) {
const top = row * (height + labelHeight), left = column * width;
const thumbnail = await sharp(path.join(directory, capture.file)).resize(width, height).png().toBuffer();
const label = Buffer.from(`<svg width="${width}" height="${labelHeight}"><rect width="100%" height="100%" fill="#171a21"/><text x="8" y="14" fill="white" font-family="sans-serif" font-size="12">${escaped(entry.id)}</text><text x="8" y="28" fill="#b6c3d4" font-family="sans-serif" font-size="11">${capture.label}</text></svg>`);
composites.push({ input: label, top, left }, { input: thumbnail, top: top + labelHeight, left });
}
}
const output = path.join(directory, `review-sheet-${String(start / rowsPerSheet + 1).padStart(2, "0")}.png`);
await sharp({ create: { width: width * 4, height: entries.length * (height + labelHeight), channels: 3, background: "#171a21" } }).composite(composites).png().toFile(output);
console.log(path.relative(process.cwd(), output));
}
@@ -1,7 +1,9 @@
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import { copyFile, mkdtemp, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { exists, loadRecipe, projectRoot, readJson, workRoot, writeJson } from "./lib.mjs";
import { preprocessShadowfangVisual } from "./shadowfang-fallback-preprocess.mjs";
function runCli(args) {
const cli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
@@ -30,19 +32,11 @@ function compressKtx2(file) {
});
}
export async function optimizeDungeonEnvironment(slug) {
await loadRecipe(slug);
const directory = path.join(workRoot, slug, "staging", "environment");
const conversionFile = path.join(directory, "conversion-report.json");
if (!await exists(conversionFile)) return { status: "blocked", dungeonId: slug, blockers: ["Run dungeon:convert first."] };
const conversion = await readJson(conversionFile);
const source = path.join(directory, conversion.visual.file);
if (!await exists(source)) return { status: "blocked", dungeonId: slug, blockers: ["The lossless visual GLB is missing."] };
const optimized = path.join(directory, `${slug}-visual.optimized.glb`);
await runCli([
export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile) {
return [
"optimize",
source,
optimized,
sourceFile,
outputFile,
"--compress",
"meshopt",
"--meshopt-level",
@@ -51,22 +45,84 @@ export async function optimizeDungeonEnvironment(slug) {
"true",
"--instance-min",
"3",
"--texture-size",
"2048",
]);
await compressKtx2(optimized);
const report = {
schemaVersion: 1,
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: (await stat(source)).size },
optimized: { file: path.basename(optimized), size: (await stat(optimized)).size },
compression: "meshopt+ktx2",
commands: [
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --texture-size 2048",
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2",
],
};
await writeJson(path.join(directory, "optimization-report.json"), report);
return report;
"--simplify",
"false",
"--texture-compress",
"false",
];
}
export function dungeonEnvironmentFinalPackingArguments(sourceFile, outputFile) {
return dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
}
export async function optimizeDungeonEnvironment(slug) {
const recipe = await loadRecipe(slug);
const directory = path.join(workRoot, slug, "staging", "environment");
const conversionFile = path.join(directory, "conversion-report.json");
if (!await exists(conversionFile)) return { status: "blocked", dungeonId: slug, blockers: ["Run dungeon:convert first."] };
const conversion = await readJson(conversionFile);
const source = path.join(directory, conversion.visual.file);
if (!await exists(source)) return { status: "blocked", dungeonId: slug, blockers: ["The lossless visual GLB is missing."] };
const selectiveProfile = recipe.visualOptimization?.selectiveFallbackSimplification;
if (slug === "shadowfang-keep" && !selectiveProfile) {
throw new Error("Shadowfang optimization requires its source-pinned selective fallback recipe profile.");
}
if (slug !== "shadowfang-keep" && selectiveProfile) {
throw new Error(`The Shadowfang-only selective fallback profile cannot be applied to ${slug}.`);
}
const optimized = path.join(directory, `${slug}-visual.optimized.glb`);
const packed = path.join(directory, `${slug}-visual.meshopt-final.glb`);
let temporaryDirectory = null;
let optimizationSource = source;
let sourcePreprocessing = null;
try {
if (slug === "shadowfang-keep") {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "shadowfang-optimizer-source-"));
optimizationSource = path.join(temporaryDirectory, "shadowfang-selective-source.glb");
sourcePreprocessing = await preprocessShadowfangVisual(source, optimizationSource);
}
await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized));
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 report = {
schemaVersion: 1,
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: (await stat(source)).size },
sourcePreprocessing: sourcePreprocessing ? {
method: "source-pinned selective simplification of untextured ADT_Terrain and ADT_Liquid_B2 fallback materials",
sourceSha256: sourcePreprocessing.source.sha256,
texturedPrimitiveAccessorHashesUnchanged: sourcePreprocessing.texturedPrimitiveAccessorHashesUnchanged,
sourceTextureBytesUnchanged: sourcePreprocessing.sourceTextureBytesUnchanged,
...sourcePreprocessing.profile,
selectedByMaterial: sourcePreprocessing.selectedByMaterial,
} : null,
optimized: { file: path.basename(optimized), size: (await stat(optimized)).size },
compression: "meshopt+ktx2",
meshoptPackingRestoredAfterTextureCompression: true,
finalPackedStage: {
status: "complete",
file: path.basename(optimized),
size: (await stat(optimized)).size,
extension: "EXT_meshopt_compression",
command: finalPackingCommand,
},
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",
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2",
finalPackingCommand,
],
};
await writeJson(path.join(directory, "optimization-report.json"), report);
return report;
} finally {
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true });
}
}
@@ -0,0 +1,203 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import {
dungeonEnvironmentFinalPackingArguments,
dungeonEnvironmentOptimizationArguments,
} from "./optimize-environment.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const ktx2Compressor = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
async function readGlb(file) {
const buffer = await readFile(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 chunk = buffer.subarray(offset + 8, offset + 8 + length);
if (type === 0x4e4f534a) json = JSON.parse(chunk.toString("utf8").replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = chunk;
offset += 8 + length;
}
assert.ok(json && binary, `${file} must contain JSON and BIN GLB chunks`);
return { json, binary };
}
function atlasGridGeometry(document, buffer, segments) {
const positions = new Float32Array((segments + 1) * (segments + 1) * 3);
const texcoords = new Float32Array((segments + 1) * (segments + 1) * 2);
const indices = new Uint16Array(segments * segments * 6);
const atlasColumns = [0.03, 0.94, 0.08, 0.89];
let indexOffset = 0;
for (let row = 0; row <= segments; row += 1) {
for (let column = 0; column <= segments; column += 1) {
const vertex = row * (segments + 1) + column;
positions.set([column, row, 0], vertex * 3);
texcoords.set([atlasColumns[column % atlasColumns.length], row / segments], vertex * 2);
if (row < segments && column < segments) {
const topLeft = vertex;
const topRight = vertex + 1;
const bottomLeft = vertex + segments + 1;
const bottomRight = bottomLeft + 1;
indices.set([
topLeft, bottomLeft, topRight,
topRight, bottomLeft, bottomRight,
], indexOffset);
indexOffset += 6;
}
}
}
return {
positions: document.createAccessor("positions").setType("VEC3").setArray(positions).setBuffer(buffer),
texcoords: document.createAccessor("texcoords").setType("VEC2").setArray(texcoords).setBuffer(buffer),
indices: document.createAccessor("indices").setType("SCALAR").setArray(indices).setBuffer(buffer),
};
}
function geometryUvStats(document) {
let triangles = 0;
let degenerateUvTriangles = 0;
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const texcoords = primitive.getAttribute("TEXCOORD_0")?.getArray();
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices()?.getArray();
if (!texcoords || !position) continue;
const indexCount = indices?.length ?? position.getCount();
triangles += indexCount / 3;
for (let offset = 0; offset < indexCount; offset += 3) {
const ia = indices?.[offset] ?? offset;
const ib = indices?.[offset + 1] ?? offset + 1;
const ic = indices?.[offset + 2] ?? offset + 2;
const a = ia * 2;
const b = ib * 2;
const c = ic * 2;
const area = (texcoords[b] - texcoords[a]) * (texcoords[c + 1] - texcoords[a + 1])
- (texcoords[c] - texcoords[a]) * (texcoords[b + 1] - texcoords[a + 1]);
if (Math.abs(area) < 1e-8) degenerateUvTriangles += 1;
}
}
}
return { triangles, degenerateUvTriangles };
}
test("WoW visual optimization preserves source texture dimensions and UV topology", async (context) => {
const width = 4096;
const height = 8;
const gridSegments = 24;
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "wow-visual-quality-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const sourceFile = path.join(workingDirectory, "source.glb");
const outputFile = path.join(workingDirectory, "optimized.glb");
const simplifiedControlFile = path.join(workingDirectory, "simplified-control.glb");
const pixels = Buffer.alloc(width * height * 4);
for (let pixel = 0; pixel < width * height; pixel += 1) {
pixels[pixel * 4] = pixel % 256;
pixels[pixel * 4 + 1] = (pixel * 17) % 256;
pixels[pixel * 4 + 2] = (pixel * 31) % 256;
pixels[pixel * 4 + 3] = 255;
}
const sourceImage = await sharp(pixels, { raw: { width, height, channels: 4 } }).png().toBuffer();
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = new Document();
const buffer = document.createBuffer();
const texture = document.createTexture("wall-atlas").setMimeType("image/png").setImage(sourceImage);
const material = document.createMaterial("wall").setBaseColorTexture(texture);
const geometry = atlasGridGeometry(document, buffer, gridSegments);
const primitive = document.createPrimitive()
.setAttribute("POSITION", geometry.positions)
.setAttribute("TEXCOORD_0", geometry.texcoords)
.setIndices(geometry.indices)
.setMaterial(material);
const mesh = document.createMesh("wall").addPrimitive(primitive);
const scene = document.createScene("scene").addChild(document.createNode("wall").setMesh(mesh));
document.getRoot().setDefaultScene(scene);
await io.write(sourceFile, document);
const args = dungeonEnvironmentOptimizationArguments(sourceFile, outputFile);
assert.deepEqual(args.slice(3), [
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--simplify", "false", "--texture-compress", "false",
]);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const optimized = await io.read(outputFile);
const optimizedStats = geometryUvStats(optimized);
assert.deepEqual(optimizedStats, {
triangles: gridSegments * gridSegments * 2,
degenerateUvTriangles: 0,
}, "atlas geometry and source UV seams must survive optimize intact");
const outputTexture = optimized.getRoot().listTextures()[0];
assert.deepEqual(await sharp(outputTexture.getImage()).metadata().then(({ width: w, height: h }) => [w, h]), [width, height]);
const compression = spawnSync(process.execPath, [ktx2Compressor, outputFile, "--concurrency", "1", "--jobs", "2"], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(compression.status, 0, `${compression.stdout}\n${compression.stderr}`);
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",
]);
const finalPacking = spawnSync(process.execPath, [gltfTransformCli, ...finalPackingArgs], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(finalPacking.status, 0, `${finalPacking.stdout}\n${finalPacking.stderr}`);
const packed = await readGlb(packedFile);
assert.ok(packed.json.extensionsUsed?.includes("EXT_meshopt_compression"),
"the final pack must restore EXT_meshopt_compression after KTX2 processing");
assert.ok(packed.json.bufferViews?.some((view) => view.extensions?.EXT_meshopt_compression?.byteLength > 0),
"the final GLB must contain compressed meshopt buffer views");
const ktxImage = packed.json.images?.find((image) => image.mimeType === "image/ktx2");
assert.ok(ktxImage, "the source color texture must remain encoded as KTX2");
const ktxView = packed.json.bufferViews[ktxImage.bufferView];
const ktxBytes = packed.binary.subarray(ktxView.byteOffset ?? 0, (ktxView.byteOffset ?? 0) + ktxView.byteLength);
assert.deepEqual([ktxBytes.readUInt32LE(20), ktxBytes.readUInt32LE(24)], [width, height],
"the final meshopt pass must retain KTX2 source dimensions");
const simplifyOption = args.indexOf("--simplify");
const simplifiedControlArgs = [...args];
simplifiedControlArgs[2] = simplifiedControlFile;
simplifiedControlArgs[simplifyOption + 1] = "true";
const simplifiedControl = spawnSync(process.execPath, [gltfTransformCli, ...simplifiedControlArgs], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(simplifiedControl.status, 0, `${simplifiedControl.stdout}\n${simplifiedControl.stderr}`);
const simplifiedStats = geometryUvStats(await io.read(simplifiedControlFile));
assert.ok(
simplifiedStats.triangles < optimizedStats.triangles || simplifiedStats.degenerateUvTriangles > 0,
"the atlas fixture must detect topology loss or UV collapse when simplification is enabled",
);
});
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";
import vm from "node:vm";
// Evaluate the compiler's actual pure contracts and validator without its
// filesystem-heavy catalog generation. Source DBCs stay read-only.
const source = fs.readFileSync(new URL("./compile-campaign-catalog.mjs", import.meta.url), "utf8");
function section(startMarker, endMarker) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert(start >= 0 && end > start, `Missing compiler section ${startMarker}`);
return source.slice(start, end);
}
const { contracts, validate } = vm.runInNewContext([
section("const pinnedEffect = ", "const AUXILIARY_NON_BOSS_CASTS = "),
section("function pinnedSourceSpellFor(spell)", "function basicAttack("),
"({ contracts: PINNED_SOURCE_SPELLS, validate: pinnedSourceSpellFor })",
].join("\n"));
function spellRow(id, sourceProfile) {
const signature = contracts.get(id).signatures[sourceProfile];
return { id, sourceProfile, ...JSON.parse(JSON.stringify(signature)) };
}
test("rejects attribute drift in both pinned Dual Wield profiles and Thrash", () => {
for (const [id, ascension, epoch] of [[674, 336, 80], [29651, 80, 80], [3391, 262_160, 262_160]]) {
for (const [profile, attributes0] of [["ascension", ascension], ["epoch", epoch]]) {
const row = spellRow(id, profile);
assert.equal(row.attributes0, attributes0);
assert.equal(validate(row), contracts.get(id));
assert.throws(() => validate({ ...row, attributes0: attributes0 ^ 1 }), /contract drift/);
}
}
});
test("rejects a changed Thrash die roll instead of silently changing its two-swing contract", () => {
for (const profile of ["ascension", "epoch"]) {
const row = spellRow(3391, profile);
assert.equal(row.effects[0].basePoints, 2);
assert.equal(row.effects[0].dieSides, 1);
assert.equal(contracts.get(3391).extraAttackCount, 2);
row.effects[0].dieSides = 0;
assert.throws(() => validate(row), /contract drift/);
}
});
test("fails closed for an unknown source profile on a pinned weapon utility", () => {
assert.throws(() => validate({ ...spellRow(674, "ascension"), sourceProfile: "unknown" }), /no unknown source profile/);
});
test("rejects changed shout trigger links, child effects, and shared area caps", () => {
for (const profile of ["ascension", "epoch"]) {
for (const [parent, child, maximum] of [[5246, 20511, 5], [19134, 29544, 0]]) {
const row = spellRow(parent, profile);
assert.equal(row.effects[0].triggerSpellId, child);
assert.equal(row.maxAffectedTargets, maximum);
assert.equal(validate(row), contracts.get(parent));
assert.throws(() => validate({ ...row, maxAffectedTargets: maximum + 1 }), /contract drift/);
row.effects[0].triggerSpellId = child + 1;
assert.throws(() => validate(row), /contract drift/);
const childRow = spellRow(child, profile);
assert.equal(validate(childRow), contracts.get(child));
childRow.effects[0].auraType += 1;
assert.throws(() => validate(childRow), /contract drift/);
}
}
});
test("rejects shout spell-mechanic and any raw attribute drift", () => {
for (const profile of ["ascension", "epoch"]) {
for (const id of [5246, 20511, 19134, 29544]) {
const row = spellRow(id, profile);
assert.equal(row.spellMechanic, 5);
assert.throws(() => validate({ ...row, spellMechanic: 0 }), /contract drift/);
for (let word = 0; word < 8; word += 1) {
const field = `attributes${word}`;
assert.equal(typeof row[field], "number");
assert.throws(() => validate({ ...row, [field]: row[field] ^ 1 }), /contract drift/);
}
}
}
});
test("pins complete Critical Block ranks including aura107 siblings without assigning a creature", () => {
for (const profile of ["ascension", "epoch"]) {
for (const id of [47294, 47295, 47296]) {
const row = spellRow(id, profile);
assert.equal(validate(row), contracts.get(id));
assert.equal(row.durationIndex, 0);
assert.equal(row.effects[0].auraType, 253);
assert.equal(row.effects[1].auraType, 107);
const changed = structuredClone(row);
changed.effects[1].basePoints += 1;
assert.throws(() => validate(changed), /contract drift/);
}
}
});
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { bakeNavigationFromCollisionAssets } from "./recast-bake.mjs";
function square(positions, centerX, y, halfExtent) {
const x0 = centerX - halfExtent;
const x1 = centerX + halfExtent;
const z0 = -halfExtent;
const z1 = halfExtent;
positions.push(
x0, y, z0, x0, y, z1, x1, y, z1,
x0, y, z0, x1, y, z1, x1, y, z0,
);
}
test("real tiled Recast export covers Detour polygon 0 and keeps disconnected islands disconnected", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "origin-recast-topology-"));
try {
const collisionFile = path.join(directory, "two-islands.glb");
const navigationFile = path.join(directory, "two-islands-navigation.glb");
const reportFile = path.join(directory, "two-islands-recast.json");
const positions = [];
square(positions, 0, 0, 10);
square(positions, 100, 2, 10);
const document = new Document();
const buffer = document.createBuffer("two-islands-buffer");
const scene = document.createScene("two-islands-scene");
document.getRoot().setDefaultScene(scene);
const position = document.createAccessor("two-islands-position", buffer)
.setType("VEC3")
.setArray(new Float32Array(positions));
const primitive = document.createPrimitive().setAttribute("POSITION", position);
const mesh = document.createMesh("two-islands-mesh").addPrimitive(primitive);
scene.addChild(document.createNode("two-islands-node").setMesh(mesh));
await new NodeIO().write(collisionFile, document);
const result = await bakeNavigationFromCollisionAssets({
slug: "recast-topology-fixture",
collisionAssets: [{ id: "two-islands", role: "collision", path: collisionFile }],
settings: {
cellSize: 0.3,
cellHeight: 0.1,
agentHeight: 1.8,
agentRadius: 0.36,
agentMaxClimb: 0.5,
agentMaxSlope: 50,
tileSize: 512,
partition: "monotone",
forceTiled: true,
retainTileIntermediates: false,
},
output: navigationFile,
reportFile,
includeDetourTopology: true,
detourRouteProbes: [{
id: "separated-islands",
start: [0, 0.2, 0],
end: [100, 2.2, 0],
maxStartSnapDistance: 3,
maxEndSnapDistance: 3,
}],
});
assert.equal(result.status, "green");
assert.equal(result.detourTopology.detailTriangleCount, result.geometry.sourceTriangles);
assert.equal(result.detourTopology.geometryTriangleCount, result.geometry.triangles);
assert.ok(result.detourTopology.polygonCount > 0);
assert.ok(result.detourTopology.weakComponents.count >= 2);
assert.equal(result.geometry.rejectedOutOfBoundsTriangles, 0);
const exported = await new NodeIO().read(navigationFile);
const topology = exported.getRoot().getExtras().runewakerNavigationTopology;
assert.ok(topology.polygons.some((polygon) => polygon[0] === 0), "stable Detour polygon ID 0 must be retained");
const ranges = topology.polygons
.filter((polygon) => polygon[7] > 0)
.map((polygon) => [polygon[6], polygon[7]])
.sort((left, right) => left[0] - right[0]);
let nextTriangle = 0;
for (const [firstTriangle, triangleCount] of ranges) {
assert.equal(firstTriangle, nextTriangle, "per-polygon detail ranges must be contiguous after bounds filtering");
nextTriangle += triangleCount;
}
assert.equal(nextTriangle, result.geometry.triangles, "polygon ranges must cover every exported navigation triangle");
const route = result.detourRouteProbes[0];
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");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+189 -1
View File
@@ -8,6 +8,7 @@ import {
freePolyMesh,
freePolyMeshDetail,
init,
NavMeshQuery,
Recast,
} from "@recast-navigation/core";
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
@@ -33,6 +34,11 @@ import {
validateTiledBuild,
} from "./recast-policy.mjs";
import { threeToMonotoneTiledNavMesh } from "./recast-monotone-fallback.mjs";
import {
buildDetourGeometryRanges,
extractDetourTopology,
stableSha256,
} from "./detour-topology.mjs";
globalThis.self = globalThis;
globalThis.ProgressEvent ??= class ProgressEvent {
@@ -156,14 +162,122 @@ function populatedNavMeshTileCount(navMesh) {
return populated;
}
async function writeGeometry(file, geometry, inputBounds, boundsTolerance) {
function queryDetourRouteProbes(navMesh, probes) {
if (!probes.length) return [];
const query = new NavMeshQuery(navMesh);
try {
return probes.map((probe) => {
const startRadius = probe.maxStartSnapDistance ?? 3;
const endRadius = probe.maxEndSnapDistance ?? 6;
const start = query.findNearestPoly(
{ x: probe.start[0], y: probe.start[1], z: probe.start[2] },
{ halfExtents: { x: startRadius, y: startRadius, z: startRadius } },
);
const end = query.findNearestPoly(
{ x: probe.end[0], y: probe.end[1], z: probe.end[2] },
{ halfExtents: { x: endRadius, y: endRadius, z: endRadius } },
);
const pointDistance = (source, target) => Math.hypot(
source[0] - target.x,
source[1] - target.y,
source[2] - target.z,
);
const startFound = start.success && start.nearestRef !== 0;
const endFound = end.success && end.nearestRef !== 0;
const startDistance = startFound ? pointDistance(probe.start, start.nearestPoint) : null;
const endDistance = endFound ? pointDistance(probe.end, end.nearestPoint) : null;
if (!startFound || !endFound
|| startDistance > startRadius
|| endDistance > endRadius) {
return {
id: probe.id,
from: probe.from ?? null,
to: probe.to ?? null,
start: {
found: startFound,
polygonRef: startFound ? start.nearestRef : null,
distance: startDistance,
maximumDistance: startRadius,
status: start.status,
},
end: {
found: endFound,
polygonRef: endFound ? end.nearestRef : null,
distance: endDistance,
maximumDistance: endRadius,
status: end.status,
},
reachable: false,
reason: "endpoint-not-found-within-query-snap-limit",
};
}
const path = query.findPath(
start.nearestRef,
end.nearestRef,
start.nearestPoint,
end.nearestPoint,
{ maxPathPolys: 100_000 },
);
try {
const refs = path.polys.toTypedArray();
const usedRefs = [];
for (const ref of refs) {
if (ref === 0) break;
usedRefs.push(ref);
}
const complete = path.success
&& usedRefs.length > 0
&& usedRefs[usedRefs.length - 1] === end.nearestRef;
return {
id: probe.id,
from: probe.from ?? null,
to: probe.to ?? null,
start: {
found: true,
polygonRef: start.nearestRef,
point: [start.nearestPoint.x, start.nearestPoint.y, start.nearestPoint.z],
distance: startDistance,
maximumDistance: startRadius,
},
end: {
found: true,
polygonRef: end.nearestRef,
point: [end.nearestPoint.x, end.nearestPoint.y, end.nearestPoint.z],
distance: endDistance,
maximumDistance: endRadius,
},
reachable: complete,
partial: path.success && usedRefs.length > 0 && !complete,
status: path.status,
polygonCount: usedRefs.length,
// Raw Detour refs are useful for tracing this exact bake, but are not stable asset IDs.
polygonRefs: usedRefs,
};
} finally {
path.polys.destroy();
}
});
} finally {
query.destroy();
}
}
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = 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) {
throw new Error(
`Detour topology describes ${detourTopology.detailTriangleCount} detail triangles, `
+ `but the helper emitted ${sourceElementCount / 3}.`,
);
}
const [minimumInput, maximumInput] = inputBounds;
const compactPositions = [];
const compactIndices = [];
const exportedTrianglePolygonIds = detourTopology ? [] : null;
const remappedVertices = new Map();
let rejectedOutOfBoundsTriangles = 0;
@@ -181,6 +295,29 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance) {
return true;
};
const sourceTrianglePolygonIds = detourTopology
? new Uint32Array(sourceElementCount / 3)
: null;
if (detourTopology) {
for (const [polygonId, , , , , , firstTriangle, triangleCount] of detourTopology.polygons) {
if (firstTriangle < 0 || triangleCount < 0 || firstTriangle + triangleCount > sourceTrianglePolygonIds.length) {
throw new Error(`Detour topology polygon ${polygonId} has an invalid detail-triangle range.`);
}
for (let triangle = firstTriangle; triangle < firstTriangle + triangleCount; triangle += 1) {
if (sourceTrianglePolygonIds[triangle] !== 0) {
throw new Error(`Detour topology polygons overlap at detail triangle ${triangle}.`);
}
sourceTrianglePolygonIds[triangle] = polygonId + 1;
}
}
for (let triangle = 0; triangle < sourceTrianglePolygonIds.length; triangle += 1) {
if (sourceTrianglePolygonIds[triangle] === 0) {
throw new Error(`Detour topology is missing source detail triangle ${triangle}.`);
}
sourceTrianglePolygonIds[triangle] -= 1;
}
}
for (let offset = 0; offset < sourceElementCount; offset += 3) {
const triangle = [
sourceIndices?.[offset] ?? offset,
@@ -191,6 +328,10 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance) {
rejectedOutOfBoundsTriangles += 1;
continue;
}
if (detourTopology) {
const polygonId = sourceTrianglePolygonIds[offset / 3];
exportedTrianglePolygonIds.push(polygonId);
}
for (const sourceIndex of triangle) {
if (!sourceIndices) {
const nextIndex = compactPositions.length / 3;
@@ -219,6 +360,16 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance) {
if (!compactIndices.length) {
throw new Error("Recast helper produced no triangles within the authoritative collision bounds.");
}
if (detourTopology) {
const geometryRanges = buildDetourGeometryRanges(
detourTopology.polygonCount,
exportedTrianglePolygonIds,
);
for (const [polygonId, polygonRecord] of detourTopology.polygons.entries()) {
[polygonRecord[6], polygonRecord[7]] = geometryRanges[polygonId];
}
detourTopology.geometryTriangleCount = compactIndices.length / 3;
}
const positions = new Float32Array(compactPositions);
const indices = new Uint32Array(compactIndices);
@@ -238,6 +389,11 @@ async function writeGeometry(file, geometry, inputBounds, boundsTolerance) {
document.createMesh("navigation").addPrimitive(primitive);
document.createNode("navigation").setMesh(document.getRoot().listMeshes()[0]);
document.createScene("navigation").addChild(document.getRoot().listNodes()[0]);
if (detourTopology) {
document.getRoot().setExtras({
runewakerNavigationTopology: detourTopology,
});
}
await new NodeIO().write(file, document);
return {
vertices: positions.length / 3,
@@ -258,6 +414,8 @@ export async function bakeNavigationFromCollisionAssets({
output = path.join(workRoot, slug, "staging", `${slug}-navigation.glb`),
reportFile = path.join(workRoot, slug, "recast-report.json"),
source = undefined,
includeDetourTopology = false,
detourRouteProbes = [],
}) {
if (!collisionAssets.length) throw new Error(`${slug}: no collision chunks are available for Recast.`);
const normalizedPaths = collisionAssets.map((asset) => asset.path.replace(/\\/g, "/"));
@@ -310,6 +468,8 @@ export async function bakeNavigationFromCollisionAssets({
let monotoneProvenance = null;
let helper = null;
let geometry;
let detourTopology = null;
let detourRoutes = [];
try {
if (build.mode === "tiled") {
while (true) {
@@ -401,12 +561,20 @@ export async function bakeNavigationFromCollisionAssets({
if (!helper.navMeshGeometry.getAttribute("position")?.count) {
throw new Error(`${slug}: Recast produced no navigation geometry.`);
}
if (includeDetourTopology) {
detourTopology = extractDetourTopology(generated.navMesh, {
sourceCollisionSha256: stableSha256(inputChunkChecksums),
settingsSha256: stableSha256({ settings, generatorConfig }),
});
}
detourRoutes = queryDetourRouteProbes(generated.navMesh, detourRouteProbes);
await mkdir(path.dirname(output), { recursive: true });
geometry = await writeGeometry(
output,
helper.navMeshGeometry,
input.bounds,
Math.max(settings.cellSize, settings.cellHeight) * 2,
detourTopology,
);
} finally {
helper?.navMeshGeometry.dispose();
@@ -434,6 +602,26 @@ export async function bakeNavigationFromCollisionAssets({
inputChunkChecksums,
output: relativeProjectPath(output),
geometry,
...(detourRouteProbes.length ? { detourRouteProbes: detourRoutes } : {}),
...(detourTopology ? {
detourTopology: {
schemaVersion: detourTopology.schemaVersion,
tileCount: detourTopology.tileCount,
polygonCount: detourTopology.polygonCount,
detailTriangleCount: detourTopology.detailTriangleCount,
polygonFanTriangleCount: detourTopology.polygonFanTriangleCount,
geometryTriangleCount: detourTopology.geometryTriangleCount,
directedLinkCount: detourTopology.directedLinkCount,
unrepresentedLinkCount: detourTopology.unrepresentedLinkCount,
offMeshPolygonCount: detourTopology.offMeshPolygonCount,
weakComponents: {
count: detourTopology.weakComponents.count,
largestPolygonCount: detourTopology.weakComponents.largestPolygonCount,
},
sourceCollisionSha256: detourTopology.sourceCollisionSha256,
settingsSha256: detourTopology.settingsSha256,
},
} : {}),
...(source ? { source } : {}),
};
await writeJson(reportFile, report);
@@ -0,0 +1,11 @@
import { createServer } from "vite";
import { fileURLToPath } from "node:url";
// A fixed asset-review server must not watch unrelated native build outputs.
const root = fileURLToPath(new URL("../../", import.meta.url));
const server = await createServer({ root, server: { host: "127.0.0.1", port: 4208, strictPort: true, watch: { ignored: ["**/*"] } } });
await server.listen();
server.printUrls();
for (const signal of ["SIGINT", "SIGTERM"]) {
process.once(signal, () => { void server.close().then(() => process.exit(0)); });
}
@@ -0,0 +1,182 @@
import { createHash } from "node:crypto";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptSimplifier } from "meshoptimizer";
import { nodeInstanceTransforms } from "../runewaker-pipeline/audit-environment-quality.mjs";
import { loadRecipe, projectRoot, workRoot } from "./lib.mjs";
const PINNED_SHADOWFANG_SOURCE_SHA256 = "d78aaa98b1367cc0dda4b7fce81f143b0753a96917cb2aff5f530fb9ef513c74";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function hashAccessor(accessor) {
const array = accessor?.getArray();
if (!array) return null;
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
}
function primitiveTriangles(primitive) {
const count = primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0;
if (primitive.getMode() !== 4 || count % 3 !== 0) return 0;
return count / 3;
}
function sourceHashSnapshot(document) {
const hashes = new Map();
for (const [meshIndex, mesh] of document.getRoot().listMeshes().entries()) {
for (const [primitiveIndex, primitive] of mesh.listPrimitives().entries()) {
if (!primitive.getMaterial()?.getBaseColorTexture()) continue;
const key = `${meshIndex}:${primitiveIndex}:${primitive.getMaterial()?.getName() ?? ""}`;
const attributes = Object.fromEntries(primitive.listSemantics()
.sort()
.map((semantic) => [semantic, hashAccessor(primitive.getAttribute(semantic))]));
hashes.set(key, { attributes, indices: hashAccessor(primitive.getIndices()) });
}
}
return hashes;
}
function textureHashSnapshot(document) {
return document.getRoot().listTextures().map((texture, index) => ({
index,
name: texture.getName() ?? "",
hash: texture.getImage() ? sha256(texture.getImage()) : null,
}));
}
function meshInstanceCounts(document) {
const counts = new Map(document.getRoot().listMeshes().map((mesh) => [mesh, 0]));
const visit = (node) => {
const mesh = node.getMesh();
if (mesh) counts.set(mesh, (counts.get(mesh) ?? 0) + nodeInstanceTransforms(node).length);
for (const child of node.listChildren()) visit(child);
};
for (const scene of document.getRoot().listScenes()) {
for (const node of scene.listChildren()) visit(node);
}
return counts;
}
export function eligibleFallbackMaterial(material, profile) {
if (!profile?.materialNamePattern) return null;
const match = material?.getName()?.match(new RegExp(profile.materialNamePattern, "u"));
if (!match || material.getBaseColorTexture()) return null;
const category = `ADT_${match[1]}`;
return profile.targetExpandedTriangles?.[category] ? category : null;
}
export function simplifyEligibleFallbackPrimitive(primitive, profile) {
const category = eligibleFallbackMaterial(primitive.getMaterial(), profile);
if (!category || primitive.getMode() !== 4 || primitiveTriangles(primitive) === 0) return false;
const target = profile.targetExpandedTriangles[category];
const source = profile.sourceExpandedTriangles?.[category];
if (!Number.isFinite(source) || source <= 0 || target <= 0 || target > source) {
throw new Error(`${category} needs a valid source and target triangle count in the recipe.`);
}
weldPrimitive(primitive, { overwrite: false });
simplifyPrimitive(primitive, {
simplifier: MeshoptSimplifier,
ratio: target / source,
error: profile.simplifyError,
lockBorder: Boolean(profile.lockBorder),
});
return true;
}
export async function preprocessShadowfangVisual(sourceFile, outputFile, { simplifyError } = {}) {
const canonicalSource = path.join(workRoot, "shadowfang-keep/staging/environment/shadowfang-keep-visual.glb");
if (path.resolve(sourceFile) !== path.resolve(canonicalSource)) {
throw new Error("Selective fallback preprocessing only accepts the canonical Shadowfang source GLB.");
}
const recipe = await loadRecipe("shadowfang-keep");
const profile = recipe.visualOptimization?.selectiveFallbackSimplification;
if (!profile || profile.sourceSha256 !== PINNED_SHADOWFANG_SOURCE_SHA256) {
throw new Error("Shadowfang recipe must opt in with the pinned selective fallback source checksum.");
}
const sourceBytes = await readFile(canonicalSource);
const sourceChecksum = sha256(sourceBytes);
if (sourceChecksum !== profile.sourceSha256) {
throw new Error(`Pinned Shadowfang source checksum changed: expected ${profile.sourceSha256}, got ${sourceChecksum}.`);
}
const effectiveError = simplifyError ?? profile.simplifyError;
if (!Number.isFinite(effectiveError) || effectiveError < 0) {
throw new Error("Selective fallback simplification error must be a non-negative number.");
}
const effectiveProfile = { ...profile, simplifyError: effectiveError };
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = await io.read(canonicalSource);
const texturedBefore = sourceHashSnapshot(document);
const sourceTextures = textureHashSnapshot(document);
const instanceCounts = meshInstanceCounts(document);
const selected = Object.fromEntries(Object.keys(profile.targetExpandedTriangles).map((name) => [name, {
primitiveCount: 0,
trianglesBefore: 0,
trianglesAfter: 0,
expandedTrianglesBefore: 0,
expandedTrianglesAfter: 0,
}]));
await MeshoptSimplifier.ready;
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const category = eligibleFallbackMaterial(primitive.getMaterial(), effectiveProfile);
const counters = selected[category];
if (!category || !counters) continue;
const trianglesBefore = primitiveTriangles(primitive);
if (trianglesBefore === 0) continue;
const instances = instanceCounts.get(mesh) ?? 0;
simplifyEligibleFallbackPrimitive(primitive, effectiveProfile);
const trianglesAfter = primitiveTriangles(primitive);
counters.primitiveCount += 1;
counters.trianglesBefore += trianglesBefore;
counters.trianglesAfter += trianglesAfter;
counters.expandedTrianglesBefore += trianglesBefore * instances;
counters.expandedTrianglesAfter += trianglesAfter * instances;
}
}
const selectedPrimitiveCount = Object.values(selected).reduce((sum, row) => sum + row.primitiveCount, 0);
if (selectedPrimitiveCount === 0) throw new Error("Pinned Shadowfang source has no eligible fallback primitives.");
const expandedFallbackTriangles = Object.values(selected)
.reduce((sum, row) => sum + row.expandedTrianglesAfter, 0);
const overBudget = Object.entries(selected)
.filter(([material, row]) => row.expandedTrianglesAfter > profile.perMaterialMaximumExpandedTriangles?.[material])
.map(([material, row]) => `${material} ${row.expandedTrianglesAfter} > ${profile.perMaterialMaximumExpandedTriangles?.[material]}`);
if (expandedFallbackTriangles > profile.maximumExpandedTriangles || overBudget.length) {
throw new Error(`Selective Shadowfang fallback geometry exceeds its enforced triangle budget (${expandedFallbackTriangles} > ${profile.maximumExpandedTriangles}; ${overBudget.join(", ")}).`);
}
if (JSON.stringify([...sourceHashSnapshot(document)]) !== JSON.stringify([...texturedBefore])) {
throw new Error("Selective simplification changed a textured primitive accessor hash.");
}
if (JSON.stringify(textureHashSnapshot(document)) !== JSON.stringify(sourceTextures)) {
throw new Error("Selective simplification changed source texture bytes.");
}
await mkdir(path.dirname(outputFile), { recursive: true });
await io.write(outputFile, document);
return {
source: { file: path.relative(projectRoot, canonicalSource).replaceAll("\\", "/"), sha256: sourceChecksum, byteLength: sourceBytes.length },
preprocessedFile: outputFile,
profile: {
materialNamePattern: profile.materialNamePattern,
sourceExpandedTriangles: profile.sourceExpandedTriangles,
targetExpandedTriangles: profile.targetExpandedTriangles,
maximumExpandedTriangles: profile.maximumExpandedTriangles,
perMaterialMaximumExpandedTriangles: profile.perMaterialMaximumExpandedTriangles,
expandedFallbackTriangles,
simplifyError: effectiveError,
lockBorder: Boolean(profile.lockBorder),
},
selectedByMaterial: selected,
texturedPrimitiveAccessorHashesUnchanged: true,
sourceTextureBytesUnchanged: true,
};
}