Update Healer Man server
This commit is contained in:
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user