1859 lines
106 KiB
JavaScript
1859 lines
106 KiB
JavaScript
#!/usr/bin/env node
|
||
import { createHash } from "node:crypto";
|
||
import { spawnSync } from "node:child_process";
|
||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
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 { verifySourcePixelCrop } from "./lib/source-pixel-crop-proof.mjs";
|
||
|
||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||
const pipelineDirectory = path.join(projectRoot, "scripts/runewaker-pipeline");
|
||
const optionValue = (name) => {
|
||
const index = process.argv.indexOf(name);
|
||
return index >= 0 ? process.argv[index + 1] ?? null : null;
|
||
};
|
||
const reportJsonPath = path.resolve(optionValue("--report-json") ?? path.join(pipelineDirectory, "RUNEWAKER_ENVIRONMENT_QUALITY_REPORT.json"));
|
||
const reportMarkdownPath = path.resolve(optionValue("--report-markdown") ?? path.join(pipelineDirectory, "RUNEWAKER_ENVIRONMENT_QUALITY_REPORT.md"));
|
||
const preReoptimizationReportPath = path.resolve(optionValue("--comparison-baseline")
|
||
?? path.join(pipelineDirectory, "RUNEWAKER_ENVIRONMENT_QUALITY_REPORT_PRE_REOPTIMIZATION.json"));
|
||
const generatedDefinitionsPath = path.join(projectRoot, "src/game/generated/runewakerDungeonDefinitions.generated.ts");
|
||
const dungeonRegistryPath = path.join(projectRoot, "src/game/dungeonRegistry.ts");
|
||
const forsakenDefinitionPath = path.join(projectRoot, "src/game/dungeons/forsakenAbbey.ts");
|
||
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
||
const exportedWorkRoot = path.join(projectRoot, "runewaker-export-work");
|
||
const shippedAssetRoot = path.resolve(optionValue("--asset-root") ?? path.join(projectRoot, "src/assets/game/dungeons"));
|
||
const MIN_SURFACE_TRIANGLE_M2 = 0.02;
|
||
const HOTSPOT_MIN_AREA_M2 = 10;
|
||
const SPATIAL_CELL_METERS = 20;
|
||
const DENSITY_HISTOGRAM = { lowLog2: -12, highLog2: 20, bins: 128 };
|
||
const ANISOTROPY_HISTOGRAM = { lowLog2: 0, highLog2: 16, bins: 64 };
|
||
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 SURFACES = ["floor", "ceiling", "wall", "slope", "other"];
|
||
|
||
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;
|
||
}
|
||
|
||
function sum(values) {
|
||
return values.reduce((total, value) => total + value, 0);
|
||
}
|
||
|
||
async function exists(file) {
|
||
try {
|
||
await stat(file);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function readJson(file) {
|
||
return JSON.parse(await readFile(file, "utf8"));
|
||
}
|
||
|
||
function readGlbJson(buffer, label) {
|
||
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
||
throw new Error(label + " is not a GLB file.");
|
||
}
|
||
const version = buffer.readUInt32LE(4);
|
||
const totalLength = buffer.readUInt32LE(8);
|
||
const jsonLength = buffer.readUInt32LE(12);
|
||
if (version !== 2 || totalLength > buffer.length || jsonLength > buffer.length - 20) {
|
||
throw new Error(label + " has an invalid GLB v2 header.");
|
||
}
|
||
return JSON.parse(buffer.toString("utf8", 20, 20 + jsonLength));
|
||
}
|
||
|
||
function getRegisteredRunewakerIds(generatedSource, registrySource, forsakenSource) {
|
||
if (!registrySource.includes("...RUNEWAKER_DUNGEON_DEFINITIONS.map")) {
|
||
throw new Error("dungeonRegistry.ts no longer registers RUNEWAKER_DUNGEON_DEFINITIONS in the expected way.");
|
||
}
|
||
if (!registrySource.includes("[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION]")) {
|
||
throw new Error("dungeonRegistry.ts no longer registers the explicit Forsaken Abbey definition in the expected way.");
|
||
}
|
||
const generatedIds = [...generatedSource.matchAll(/^ "id": "([^\"]+)",$/gm)].map((match) => match[1]);
|
||
if (!generatedIds.length) throw new Error("Could not read registered generated RoM IDs from the generated registry.");
|
||
const forsakenId = forsakenSource.match(/\bid:\s*"([^\"]+)"/i)?.[1];
|
||
if (!forsakenId) throw new Error("Could not read the explicit Forsaken Abbey ID.");
|
||
const ids = [...new Set([...generatedIds, forsakenId])].sort();
|
||
if (ids.length !== generatedIds.length + 1) throw new Error("The explicit RoM definition duplicates a generated ID.");
|
||
return { ids, generatedIds, explicitIds: [forsakenId] };
|
||
}
|
||
|
||
async function readDungeonRecipes() {
|
||
const files = (await readdir(recipesDirectory))
|
||
.filter((name) => name.endsWith(".json") && !name.endsWith("-population.json")
|
||
&& name !== "runewaker-player-avatars.json" && name !== "runewaker-starter-weapons.json")
|
||
.sort();
|
||
return Promise.all(files.map(async (name) => ({ file: name, value: await readJson(path.join(recipesDirectory, name)) })));
|
||
}
|
||
|
||
function parseKtx2(bytes) {
|
||
const image = Buffer.from(bytes);
|
||
if (image.length < 80 || !image.subarray(0, 12).equals(KTX2_IDENTIFIER)) return null;
|
||
const kvdOffset = image.readUInt32LE(56);
|
||
const kvdLength = image.readUInt32LE(60);
|
||
const metadata = {};
|
||
if (kvdOffset > 0 && kvdLength > 0 && kvdOffset + kvdLength <= image.length) {
|
||
let cursor = kvdOffset;
|
||
const end = kvdOffset + kvdLength;
|
||
while (cursor + 4 <= end) {
|
||
const length = image.readUInt32LE(cursor);
|
||
cursor += 4;
|
||
if (!length || cursor + length > end) break;
|
||
const entry = image.subarray(cursor, cursor + length);
|
||
const separator = entry.indexOf(0);
|
||
if (separator >= 0) {
|
||
metadata[entry.toString("utf8", 0, separator)] = entry.toString("utf8", separator + 1);
|
||
}
|
||
cursor += Math.ceil(length / 4) * 4;
|
||
}
|
||
}
|
||
const supercompressionScheme = image.readUInt32LE(44);
|
||
return {
|
||
width: image.readUInt32LE(20),
|
||
height: image.readUInt32LE(24),
|
||
depth: image.readUInt32LE(28),
|
||
levelCount: Math.max(1, image.readUInt32LE(40)),
|
||
vkFormat: image.readUInt32LE(12),
|
||
supercompressionScheme,
|
||
encoding: image.readUInt32LE(12) === 0 && supercompressionScheme === 1
|
||
? "ETC1S/BasisLZ"
|
||
: image.readUInt32LE(12) === 0 && supercompressionScheme === 2
|
||
? "UASTC/Zstandard"
|
||
: "KTX2-other",
|
||
writerParameters: metadata.KTXwriterScParams ?? null,
|
||
writer: metadata.KTXwriter ?? null,
|
||
byteLength: image.length,
|
||
};
|
||
}
|
||
|
||
function parseDds(bytes) {
|
||
const image = Buffer.from(bytes);
|
||
if (image.length < 128 || image.toString("ascii", 0, 4) !== "DDS ") return null;
|
||
return {
|
||
width: image.readUInt32LE(16),
|
||
height: image.readUInt32LE(12),
|
||
levelCount: Math.max(1, image.readUInt32LE(28)),
|
||
byteLength: image.length,
|
||
};
|
||
}
|
||
|
||
function imageKey(value) {
|
||
return path.basename(String(value ?? "")).replace(/\.[^.]+$/, "").toLowerCase();
|
||
}
|
||
|
||
function makeHistogram(definition) {
|
||
return new Float64Array(definition.bins);
|
||
}
|
||
|
||
function addHistogram(histogram, definition, value, weight) {
|
||
if (!(value > 0) || !(weight > 0)) return;
|
||
const log = Math.log2(value);
|
||
const fraction = Math.min(0.999999, Math.max(0, (log - definition.lowLog2) / (definition.highLog2 - definition.lowLog2)));
|
||
histogram[Math.floor(fraction * definition.bins)] += weight;
|
||
}
|
||
|
||
function histogramPercentile(histogram, definition, percentile) {
|
||
const totalWeight = sum([...histogram]);
|
||
if (totalWeight <= 0) return null;
|
||
const target = totalWeight * percentile;
|
||
let accumulated = 0;
|
||
for (let index = 0; index < histogram.length; index += 1) {
|
||
accumulated += histogram[index];
|
||
if (accumulated >= target) {
|
||
const binCenter = definition.lowLog2 + ((index + 0.5) / definition.bins) * (definition.highLog2 - definition.lowLog2);
|
||
return 2 ** binCenter;
|
||
}
|
||
}
|
||
return 2 ** definition.highLog2;
|
||
}
|
||
|
||
function newMetricAccumulator() {
|
||
return {
|
||
worldAreaM2: 0,
|
||
texturedAreaM2: 0,
|
||
unmappedAreaM2: 0,
|
||
unmappedBaseColorAreaM2: 0,
|
||
unusableBaseColorMappingAreaM2: 0,
|
||
uvMissingAreaM2: 0,
|
||
uvDegenerateAreaM2: 0,
|
||
uvOutsideAreaM2: 0,
|
||
uvOutsideClampAreaM2: 0,
|
||
texturedTriangles: 0,
|
||
uvDegenerateTriangles: 0,
|
||
lowRankTriangles: 0,
|
||
densityLog2AreaSum: 0,
|
||
anisotropyLog2AreaSum: 0,
|
||
densityMin: Infinity,
|
||
densityMax: 0,
|
||
anisotropyMax: 0,
|
||
densityHistogram: makeHistogram(DENSITY_HISTOGRAM),
|
||
anisotropyHistogram: makeHistogram(ANISOTROPY_HISTOGRAM),
|
||
uvBounds: { min: [Infinity, Infinity], max: [-Infinity, -Infinity] },
|
||
worldBounds: { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
|
||
positionSum: [0, 0, 0],
|
||
nodeNames: new Set(),
|
||
};
|
||
}
|
||
|
||
function mergeMinMax(target, vector) {
|
||
for (let axis = 0; axis < vector.length; axis += 1) {
|
||
target.min[axis] = Math.min(target.min[axis], vector[axis]);
|
||
target.max[axis] = Math.max(target.max[axis], vector[axis]);
|
||
}
|
||
}
|
||
|
||
function transformPoint(matrix, vector) {
|
||
const [x, y, z] = vector;
|
||
const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15] || 1;
|
||
return [
|
||
(matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w,
|
||
(matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w,
|
||
(matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) / w,
|
||
];
|
||
}
|
||
|
||
export function nodeInstanceTransforms(node) {
|
||
const extension = node.getExtension("EXT_mesh_gpu_instancing");
|
||
if (!extension) return [null];
|
||
const translation = extension.getAttribute("TRANSLATION");
|
||
const rotation = extension.getAttribute("ROTATION");
|
||
const scale = extension.getAttribute("SCALE");
|
||
const count = Math.max(translation?.getCount() ?? 0, rotation?.getCount() ?? 0, scale?.getCount() ?? 0);
|
||
if (!count) return [null];
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
translation: translation?.getElement(index, [0, 0, 0]) ?? [0, 0, 0],
|
||
rotation: rotation?.getElement(index, [0, 0, 0, 1]) ?? [0, 0, 0, 1],
|
||
scale: scale?.getElement(index, [1, 1, 1]) ?? [1, 1, 1],
|
||
}));
|
||
}
|
||
|
||
export function transformInstancedPoint(worldMatrix, point, instanceTransform) {
|
||
if (!instanceTransform) return transformPoint(worldMatrix, point);
|
||
const scaled = point.map((value, axis) => value * instanceTransform.scale[axis]);
|
||
const [qx, qy, qz, qw] = instanceTransform.rotation;
|
||
const firstCross = [
|
||
qy * scaled[2] - qz * scaled[1],
|
||
qz * scaled[0] - qx * scaled[2],
|
||
qx * scaled[1] - qy * scaled[0],
|
||
];
|
||
const secondCross = [
|
||
qy * firstCross[2] - qz * firstCross[1],
|
||
qz * firstCross[0] - qx * firstCross[2],
|
||
qx * firstCross[1] - qy * firstCross[0],
|
||
];
|
||
const rotated = scaled.map((value, axis) => value + 2 * (qw * firstCross[axis] + secondCross[axis]));
|
||
return transformPoint(worldMatrix, rotated.map((value, axis) => value + instanceTransform.translation[axis]));
|
||
}
|
||
|
||
export function triangleArea(points) {
|
||
return length(cross(subtract(points[1], points[0]), subtract(points[2], points[0]))) / 2;
|
||
}
|
||
|
||
function subtract(a, b) {
|
||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||
}
|
||
|
||
function dot(a, b) {
|
||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||
}
|
||
|
||
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 length(a) {
|
||
return Math.hypot(a[0], a[1], a[2]);
|
||
}
|
||
|
||
function classifySurface(normalY) {
|
||
if (normalY >= 0.7) return "floor";
|
||
if (normalY <= -0.7) return "ceiling";
|
||
if (Math.abs(normalY) <= 0.3) return "wall";
|
||
return "slope";
|
||
}
|
||
|
||
function applyUvTransform(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];
|
||
}
|
||
|
||
export function mappingMetrics(points, uvs, width, height) {
|
||
const e1 = subtract(points[1], points[0]);
|
||
const e2 = subtract(points[2], points[0]);
|
||
const len1 = length(e1);
|
||
if (len1 <= 1e-9) return null;
|
||
const t1 = e1.map((value) => value / len1);
|
||
const projection = dot(e2, t1);
|
||
const orthogonal = e2.map((value, axis) => value - projection * t1[axis]);
|
||
const len2 = length(orthogonal);
|
||
if (len2 <= 1e-9) return null;
|
||
const tex1 = [(uvs[1][0] - uvs[0][0]) * width, (uvs[1][1] - uvs[0][1]) * height];
|
||
const tex2 = [(uvs[2][0] - uvs[0][0]) * width, (uvs[2][1] - uvs[0][1]) * height];
|
||
const j00 = tex1[0] / len1;
|
||
const j10 = tex1[1] / len1;
|
||
const j01 = (tex2[0] - tex1[0] * projection / len1) / len2;
|
||
const j11 = (tex2[1] - tex1[1] * projection / len1) / len2;
|
||
const trace = j00 * j00 + j01 * j01 + j10 * j10 + j11 * j11;
|
||
const determinantSquared = (j00 * j11 - j01 * j10) ** 2;
|
||
const discriminant = Math.sqrt(Math.max(0, trace * trace - 4 * determinantSquared));
|
||
const largest = Math.sqrt(Math.max(0, (trace + discriminant) / 2));
|
||
const smallest = Math.sqrt(Math.max(0, (trace - discriminant) / 2));
|
||
const density = Math.sqrt(largest * smallest);
|
||
const uvDoubleArea = Math.abs(
|
||
(uvs[1][0] - uvs[0][0]) * (uvs[2][1] - uvs[0][1])
|
||
- (uvs[2][0] - uvs[0][0]) * (uvs[1][1] - uvs[0][1]),
|
||
);
|
||
return {
|
||
densityPxPerMeter: density,
|
||
anisotropy: smallest > 1e-6 ? largest / smallest : Infinity,
|
||
uvDoubleArea,
|
||
smallestStretchPxPerMeter: smallest,
|
||
};
|
||
}
|
||
|
||
function includeTriangleMetrics(accumulator, {
|
||
area,
|
||
density,
|
||
anisotropy,
|
||
smallestStretch,
|
||
uvs,
|
||
points,
|
||
wrapS,
|
||
wrapT,
|
||
}) {
|
||
accumulator.texturedAreaM2 += area;
|
||
accumulator.texturedTriangles += 1;
|
||
accumulator.densityMin = Math.min(accumulator.densityMin, density);
|
||
accumulator.densityMax = Math.max(accumulator.densityMax, density);
|
||
accumulator.anisotropyMax = Math.max(accumulator.anisotropyMax, anisotropy);
|
||
accumulator.densityLog2AreaSum += Math.log2(Math.max(density, 1e-9)) * area;
|
||
accumulator.anisotropyLog2AreaSum += Math.log2(Math.max(anisotropy, 1)) * area;
|
||
addHistogram(accumulator.densityHistogram, DENSITY_HISTOGRAM, density, area);
|
||
addHistogram(accumulator.anisotropyHistogram, ANISOTROPY_HISTOGRAM, anisotropy, area);
|
||
if (smallestStretch <= 0.01) accumulator.lowRankTriangles += 1;
|
||
let outside = false;
|
||
let outsideClamp = false;
|
||
for (const uv of uvs) {
|
||
accumulator.uvBounds.min[0] = Math.min(accumulator.uvBounds.min[0], uv[0]);
|
||
accumulator.uvBounds.min[1] = Math.min(accumulator.uvBounds.min[1], uv[1]);
|
||
accumulator.uvBounds.max[0] = Math.max(accumulator.uvBounds.max[0], uv[0]);
|
||
accumulator.uvBounds.max[1] = Math.max(accumulator.uvBounds.max[1], uv[1]);
|
||
if (uv[0] < -1e-4 || uv[0] > 1.0001 || uv[1] < -1e-4 || uv[1] > 1.0001) outside = true;
|
||
if ((wrapS === 33071 && (uv[0] < -1e-4 || uv[0] > 1.0001))
|
||
|| (wrapT === 33071 && (uv[1] < -1e-4 || uv[1] > 1.0001))) outsideClamp = true;
|
||
}
|
||
if (outside) accumulator.uvOutsideAreaM2 += area;
|
||
if (outsideClamp) accumulator.uvOutsideClampAreaM2 += area;
|
||
for (const point of points) mergeMinMax(accumulator.worldBounds, point);
|
||
}
|
||
|
||
function summarizeAccumulator(accumulator) {
|
||
const area = accumulator.worldAreaM2;
|
||
const center = accumulator.weightedCentroidAreaM2 > 0
|
||
? accumulator.positionSum.map((value) => value / accumulator.weightedCentroidAreaM2) : null;
|
||
return {
|
||
worldAreaM2: round(area),
|
||
texturedAreaM2: round(accumulator.texturedAreaM2),
|
||
unmappedAreaM2: round(accumulator.unmappedAreaM2),
|
||
unmappedBaseColorAreaM2: round(accumulator.unmappedBaseColorAreaM2),
|
||
unusableBaseColorMappingAreaM2: round(accumulator.unusableBaseColorMappingAreaM2),
|
||
uvMissingAreaM2: round(accumulator.uvMissingAreaM2),
|
||
uvDegenerateAreaM2: round(accumulator.uvDegenerateAreaM2),
|
||
lowRankWorldAreaM2: round(accumulator.lowRankWorldAreaM2 ?? 0),
|
||
uvOutsideAreaM2: round(accumulator.uvOutsideAreaM2),
|
||
uvOutsideClampAreaM2: round(accumulator.uvOutsideClampAreaM2),
|
||
texturedTriangles: accumulator.texturedTriangles,
|
||
uvDegenerateTriangles: accumulator.uvDegenerateTriangles,
|
||
lowRankTriangles: accumulator.lowRankTriangles,
|
||
texelDensityPxPerMeter: area > 0 && accumulator.texturedAreaM2 > 0
|
||
? round(2 ** (accumulator.densityLog2AreaSum / accumulator.texturedAreaM2), 2) : null,
|
||
texelDensityPxPerMeterP10: histogramPercentile(accumulator.densityHistogram, DENSITY_HISTOGRAM, 0.10),
|
||
texelDensityPxPerMeterP50: histogramPercentile(accumulator.densityHistogram, DENSITY_HISTOGRAM, 0.50),
|
||
texelDensityPxPerMeterP90: histogramPercentile(accumulator.densityHistogram, DENSITY_HISTOGRAM, 0.90),
|
||
anisotropyP90: histogramPercentile(accumulator.anisotropyHistogram, ANISOTROPY_HISTOGRAM, 0.90),
|
||
anisotropyMax: Number.isFinite(accumulator.anisotropyMax) ? round(accumulator.anisotropyMax, 2) : null,
|
||
uvBounds: accumulator.uvBounds.min[0] === Infinity ? null : {
|
||
min: accumulator.uvBounds.min.map((value) => round(value, 4)),
|
||
max: accumulator.uvBounds.max.map((value) => round(value, 4)),
|
||
},
|
||
worldBounds: accumulator.worldBounds.min[0] === Infinity ? null : {
|
||
min: accumulator.worldBounds.min.map((value) => round(value)),
|
||
max: accumulator.worldBounds.max.map((value) => round(value)),
|
||
},
|
||
centroid: center?.map((value) => round(value)),
|
||
nodeNames: [...accumulator.nodeNames].slice(0, 8),
|
||
};
|
||
}
|
||
|
||
export async function auditConvertedSourceGeometry({ id, io, sharedSourceTextures = { pngByKey: new Map() }, sourceImageIdentityCache = new Map() }) {
|
||
const visualFile = path.join(exportedWorkRoot, id, "converted", id + "-visual.glb");
|
||
if (!await exists(visualFile)) return { status: "converted-source-missing", file: projectPath(visualFile), triangleCount: 0, groups: [] };
|
||
let document;
|
||
try {
|
||
document = await io.read(visualFile);
|
||
} catch (error) {
|
||
return { status: "converted-source-unreadable", file: projectPath(visualFile), triangleCount: 0, groups: [], error: error.message };
|
||
}
|
||
const root = document.getRoot();
|
||
const preparedTextureDirectory = path.join(exportedWorkRoot, id, "prepared/textures");
|
||
const preparedTextureFiles = await exists(preparedTextureDirectory) ? await readdir(preparedTextureDirectory) : [];
|
||
const localPngByKey = new Map(preparedTextureFiles.filter((file) => file.toLowerCase().endsWith(".png"))
|
||
.map((file) => [imageKey(file), path.join(preparedTextureDirectory, file)]));
|
||
const imageDescriptors = new Map();
|
||
for (const [index, texture] of root.listTextures().entries()) {
|
||
const descriptor = await outputImageDescriptor(texture, index);
|
||
const sourcePng = await resolveSourceTexturePath(descriptor.basename, localPngByKey, sharedSourceTextures.pngByKey);
|
||
imageDescriptors.set(texture, { ...descriptor, sourcePngFile: sourcePng.path });
|
||
}
|
||
const groups = new Map();
|
||
let triangleCount = 0;
|
||
const scene = root.getDefaultScene() ?? root.listScenes()[0];
|
||
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();
|
||
for (const instanceTransform of nodeInstanceTransforms(node)) {
|
||
for (const primitive of mesh.listPrimitives()) {
|
||
if (primitive.getMode() !== 4) continue;
|
||
const positions = primitive.getAttribute("POSITION");
|
||
if (!positions) continue;
|
||
const material = primitive.getMaterial();
|
||
const baseTexture = material?.getBaseColorTexture() ?? null;
|
||
const baseInfo = material?.getBaseColorTextureInfo() ?? null;
|
||
const alphaMode = material?.getAlphaMode() ?? "OPAQUE";
|
||
const baseColorFactor = material?.getBaseColorFactor() ?? [1, 1, 1, 1];
|
||
const transform = textureTransformFor(baseInfo);
|
||
const texCoord = transform?.getTexCoord() ?? baseInfo?.getTexCoord() ?? 0;
|
||
const uvAccessor = primitive.getAttribute("TEXCOORD_" + texCoord);
|
||
const descriptor = baseTexture ? imageDescriptors.get(baseTexture) : null;
|
||
const width = descriptor?.width ?? 0;
|
||
const height = descriptor?.height ?? 0;
|
||
const imageAliasKey = await resolveSourceImageAliasKey(descriptor?.sourcePngFile, alphaMode, baseColorFactor, sourceImageIdentityCache);
|
||
const indices = primitive.getIndices();
|
||
const indexCount = indices?.getCount() ?? positions.getCount();
|
||
if (indexCount % 3 !== 0) continue;
|
||
const positionCache = new Map();
|
||
const uvCache = new Map();
|
||
const getPosition = (vertexIndex) => {
|
||
if (!positionCache.has(vertexIndex)) {
|
||
positionCache.set(vertexIndex, transformInstancedPoint(worldMatrix, positions.getElement(vertexIndex, [0, 0, 0]), instanceTransform));
|
||
}
|
||
return positionCache.get(vertexIndex);
|
||
};
|
||
const getUv = (vertexIndex) => {
|
||
if (!uvCache.has(vertexIndex)) {
|
||
const uv = uvAccessor?.getElement(vertexIndex, [0, 0]) ?? null;
|
||
uvCache.set(vertexIndex, uv ? applyUvTransform(uv, transform) : null);
|
||
}
|
||
return uvCache.get(vertexIndex);
|
||
};
|
||
const materialName = material?.getName() || "(unmaterialed)";
|
||
const textureName = baseTexture?.getName() || "(no base-color texture)";
|
||
const wrapS = baseInfo?.getWrapS() ?? 10497;
|
||
const wrapT = baseInfo?.getWrapT() ?? 10497;
|
||
for (let index = 0; index < indexCount; index += 3) {
|
||
const vertexIndices = [0, 1, 2].map((offset) => indices?.getScalar(index + offset) ?? index + offset);
|
||
const points = vertexIndices.map(getPosition);
|
||
const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0]));
|
||
const twiceArea = length(normal);
|
||
if (twiceArea <= 1e-12) continue;
|
||
triangleCount += 1;
|
||
const area = twiceArea / 2;
|
||
if (area < MIN_SURFACE_TRIANGLE_M2) continue;
|
||
const surface = classifySurface(normal[1] / twiceArea);
|
||
const center = points.reduce((result, point) => result.map((value, axis) => value + point[axis] / 3), [0, 0, 0]);
|
||
const cell = center.map((value) => Math.floor(value / SPATIAL_CELL_METERS));
|
||
const key = [surface, imageKey(textureName), cell.join(",")].join("\u0000");
|
||
const group = groups.get(key) ?? {
|
||
surface,
|
||
texture: textureName,
|
||
textureNames: [textureName],
|
||
sourceImageAliasKey: imageAliasKey,
|
||
alphaMode,
|
||
baseColorFactor: baseColorFactor.map((value) => round(value, 6)),
|
||
cell,
|
||
areaM2: 0,
|
||
texturedAreaM2: 0,
|
||
uvMissingAreaM2: 0,
|
||
uvDegenerateAreaM2: 0,
|
||
uvDegenerateTriangles: 0,
|
||
rankCollapsedAreaM2: 0,
|
||
rankCollapsedTriangles: 0,
|
||
triangleCount: 0,
|
||
worldBounds: { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
|
||
};
|
||
groups.set(key, group);
|
||
group.areaM2 += area;
|
||
group.triangleCount += 1;
|
||
for (const point of points) mergeMinMax(group.worldBounds, point);
|
||
if (!baseTexture || !descriptor || !width || !height || !uvAccessor) {
|
||
group.uvMissingAreaM2 += area;
|
||
continue;
|
||
}
|
||
const uvs = vertexIndices.map(getUv);
|
||
if (uvs.some((uv) => !uv)) {
|
||
group.uvMissingAreaM2 += area;
|
||
continue;
|
||
}
|
||
const mapping = mappingMetrics(points, uvs, width, height);
|
||
if (!mapping || mapping.uvDoubleArea <= 1e-12) {
|
||
group.uvDegenerateAreaM2 += area;
|
||
group.uvDegenerateTriangles += 1;
|
||
group.rankCollapsedAreaM2 += area;
|
||
group.rankCollapsedTriangles += 1;
|
||
continue;
|
||
}
|
||
group.texturedAreaM2 += area;
|
||
if (mapping.smallestStretchPxPerMeter <= 0.01) {
|
||
group.rankCollapsedAreaM2 += area;
|
||
group.rankCollapsedTriangles += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const surfaceTotals = Object.fromEntries(SURFACES.map((surface) => [surface, {
|
||
worldAreaM2: 0,
|
||
texturedAreaM2: 0,
|
||
uvMissingAreaM2: 0,
|
||
uvDegenerateAreaM2: 0,
|
||
lowRankWorldAreaM2: 0,
|
||
triangleCount: 0,
|
||
}]));
|
||
for (const group of groups.values()) {
|
||
const total = surfaceTotals[group.surface];
|
||
total.worldAreaM2 += group.areaM2;
|
||
total.texturedAreaM2 += group.texturedAreaM2;
|
||
total.uvMissingAreaM2 += group.uvMissingAreaM2;
|
||
total.uvDegenerateAreaM2 += group.uvDegenerateAreaM2;
|
||
total.lowRankWorldAreaM2 += group.rankCollapsedAreaM2;
|
||
total.triangleCount += group.triangleCount;
|
||
}
|
||
return {
|
||
status: "audited",
|
||
file: projectPath(visualFile),
|
||
triangleCount,
|
||
surfaceMetrics: Object.fromEntries(Object.entries(surfaceTotals).map(([surface, total]) => [surface, {
|
||
worldAreaM2: round(total.worldAreaM2),
|
||
texturedAreaM2: round(total.texturedAreaM2),
|
||
uvMissingAreaM2: round(total.uvMissingAreaM2),
|
||
uvDegenerateAreaM2: round(total.uvDegenerateAreaM2),
|
||
lowRankWorldAreaM2: round(total.lowRankWorldAreaM2),
|
||
triangleCount: total.triangleCount,
|
||
}])),
|
||
groups: [...groups.values()].map((group) => ({
|
||
...group,
|
||
areaM2: round(group.areaM2),
|
||
texturedAreaM2: round(group.texturedAreaM2),
|
||
uvMissingAreaM2: round(group.uvMissingAreaM2),
|
||
uvDegenerateAreaM2: round(group.uvDegenerateAreaM2),
|
||
rankCollapsedAreaM2: round(group.rankCollapsedAreaM2),
|
||
worldBounds: {
|
||
min: group.worldBounds.min.map((value) => round(value)),
|
||
max: group.worldBounds.max.map((value) => round(value)),
|
||
},
|
||
})),
|
||
};
|
||
}
|
||
|
||
function addNonMappedArea(accumulator, area, field) {
|
||
accumulator[field] += area;
|
||
}
|
||
|
||
function getMaterialTextureInfos(material) {
|
||
return [
|
||
["baseColor", material.getBaseColorTexture(), material.getBaseColorTextureInfo()],
|
||
["emissive", material.getEmissiveTexture(), material.getEmissiveTextureInfo()],
|
||
["normal", material.getNormalTexture(), material.getNormalTextureInfo()],
|
||
["occlusion", material.getOcclusionTexture(), material.getOcclusionTextureInfo()],
|
||
["metallicRoughness", material.getMetallicRoughnessTexture(), material.getMetallicRoughnessTextureInfo()],
|
||
].filter(([, texture]) => texture);
|
||
}
|
||
|
||
function textureTransformFor(info) {
|
||
return info?.getExtension("KHR_texture_transform") ?? null;
|
||
}
|
||
|
||
function createMaterialStats(material) {
|
||
return {
|
||
name: material.getName() || "(unnamed)",
|
||
baseColor: material.getBaseColorFactor().map((value) => round(value, 4)),
|
||
alphaMode: material.getAlphaMode(),
|
||
doubleSided: material.getDoubleSided(),
|
||
emissiveFactor: material.getEmissiveFactor().map((value) => round(value, 4)),
|
||
emissiveTexture: material.getEmissiveTexture()?.getName() ?? null,
|
||
textureSlots: getMaterialTextureInfos(material).map(([slot, texture, info]) => ({
|
||
slot,
|
||
texture: texture.getName() || "(unnamed)",
|
||
texCoord: textureTransformFor(info)?.getTexCoord() ?? info.getTexCoord(),
|
||
wrapS: WRAP_NAMES.get(info.getWrapS()) ?? String(info.getWrapS()),
|
||
wrapT: WRAP_NAMES.get(info.getWrapT()) ?? String(info.getWrapT()),
|
||
minFilter: info.getMinFilter(),
|
||
magFilter: info.getMagFilter(),
|
||
transformed: Boolean(textureTransformFor(info)),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function textureSurfaceDescriptor(textureInfo, imageDescriptors) {
|
||
if (!textureInfo) return null;
|
||
const texture = textureInfo.texture;
|
||
const image = imageDescriptors.get(texture);
|
||
const transform = textureTransformFor(textureInfo.info);
|
||
return {
|
||
texture,
|
||
image,
|
||
info: textureInfo.info,
|
||
texCoord: transform?.getTexCoord() ?? textureInfo.info.getTexCoord(),
|
||
transform,
|
||
wrapS: textureInfo.info.getWrapS(),
|
||
wrapT: textureInfo.info.getWrapT(),
|
||
};
|
||
}
|
||
|
||
async function compareTexturePixels(textureBytes, sourcePath, width, height, tempDirectory, index, ktxExecutable) {
|
||
if (!sourcePath || !ktxExecutable) return { status: !sourcePath ? "no-source-png" : "ktx-tool-unavailable" };
|
||
const inputPath = path.join(tempDirectory, "texture-" + index + ".ktx2");
|
||
const outputPath = path.join(tempDirectory, "texture-" + index + ".png");
|
||
await writeFile(inputPath, textureBytes);
|
||
const result = spawnSync(ktxExecutable, ["extract", "--transcode", "rgba8", inputPath, outputPath], {
|
||
encoding: "utf8",
|
||
windowsHide: true,
|
||
maxBuffer: 2 * 1024 * 1024,
|
||
});
|
||
if (result.status !== 0) {
|
||
return { status: "decode-failed", error: (result.stderr || result.stdout || "KTX extraction failed.").trim().slice(0, 300) };
|
||
}
|
||
try {
|
||
const sourceMetadata = await sharp(sourcePath).metadata();
|
||
const sourceStats = await sharp(sourcePath).stats();
|
||
const decodedMetadata = await sharp(outputPath).metadata();
|
||
if (decodedMetadata.width !== width || decodedMetadata.height !== height) {
|
||
return { status: "decoded-dimension-mismatch", expected: [width, height],
|
||
decoded: [decodedMetadata.width, decodedMetadata.height] };
|
||
}
|
||
const sourceRaw = await sharp(sourcePath)
|
||
.toColourspace("srgb")
|
||
.resize(width, height, { fit: "fill", kernel: "lanczos3" })
|
||
.removeAlpha()
|
||
.raw()
|
||
.toBuffer();
|
||
const decodedRaw = await sharp(outputPath)
|
||
.toColourspace("srgb")
|
||
.resize(width, height, { fit: "fill", kernel: "lanczos3" })
|
||
.removeAlpha()
|
||
.raw()
|
||
.toBuffer();
|
||
const count = Math.min(sourceRaw.length, decodedRaw.length);
|
||
let squaredError = 0;
|
||
let absoluteError = 0;
|
||
let maxError = 0;
|
||
for (let pixel = 0; pixel < count; pixel += 1) {
|
||
const error = Math.abs(sourceRaw[pixel] - decodedRaw[pixel]);
|
||
squaredError += error * error;
|
||
absoluteError += error;
|
||
maxError = Math.max(maxError, error);
|
||
}
|
||
const mse = count ? squaredError / count : null;
|
||
const alphaStats = sourceStats.channels[3] ?? null;
|
||
const alphaVaries = Boolean(alphaStats && (alphaStats.min < 254.5 || alphaStats.max > 255));
|
||
return {
|
||
status: "compared",
|
||
rgbIdentical: mse === 0,
|
||
rgbPsnrDb: mse === 0 ? null : round(10 * Math.log10((255 * 255) / mse), 2),
|
||
rgbMae: round(count ? absoluteError / count : 0, 3),
|
||
rgbMaxError: maxError,
|
||
sourceAlphaRange: alphaStats ? [round(alphaStats.min, 1), round(alphaStats.max, 1)] : null,
|
||
sourceAlphaVaries: alphaVaries,
|
||
decodedHasAlpha: decodedMetadata.hasAlpha ?? false,
|
||
comparedAtWidth: width,
|
||
comparedAtHeight: height,
|
||
sourcePngWidth: sourceMetadata.width,
|
||
sourcePngHeight: sourceMetadata.height,
|
||
resizeForCodecMetric: sourceMetadata.width !== width || sourceMetadata.height !== height,
|
||
};
|
||
} finally {
|
||
await Promise.all([inputPath, outputPath].map((file) => rm(file, { force: true })));
|
||
}
|
||
}
|
||
|
||
function hashBytes(bytes) {
|
||
return createHash("sha256").update(bytes).digest("hex");
|
||
}
|
||
|
||
export function sourceImageAliasKey({ width, height, rgba, alphaMode = "OPAQUE", alphaUsed = null, baseColorFactor = [1, 1, 1, 1] }) {
|
||
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0
|
||
|| !rgba || rgba.length !== width * height * 4) return null;
|
||
let alphaMayMatter = false;
|
||
if (alphaMode === "OPAQUE" && alphaUsed === null) {
|
||
for (let offset = 3; offset < rgba.length; offset += 4) {
|
||
if (rgba[offset] !== 255) {
|
||
alphaMayMatter = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const preserveAlpha = alphaMode !== "OPAQUE" || alphaUsed === true || (alphaUsed === null && alphaMayMatter);
|
||
const pixels = preserveAlpha ? Buffer.from(rgba) : Buffer.allocUnsafe(width * height * 3);
|
||
if (!preserveAlpha) {
|
||
for (let source = 0, destination = 0; source < rgba.length; source += 4, destination += 3) {
|
||
pixels[destination] = rgba[source];
|
||
pixels[destination + 1] = rgba[source + 1];
|
||
pixels[destination + 2] = rgba[source + 2];
|
||
}
|
||
}
|
||
const factorKey = baseColorFactor.map((value) => round(Number(value), 6)).join(",");
|
||
return `${preserveAlpha ? "rgba" : "rgb"}:${width}x${height}:${hashBytes(pixels)}:factor=${factorKey}`;
|
||
}
|
||
|
||
async function resolveSourceImageAliasKey(file, alphaMode, baseColorFactor, cache, nativeAlphaUsed = null) {
|
||
if (!file) return null;
|
||
const factorKey = baseColorFactor.map((value) => round(Number(value), 6)).join(",");
|
||
const cacheKey = `${file}\u0000${alphaMode}\u0000${nativeAlphaUsed}\u0000${factorKey}`;
|
||
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
||
try {
|
||
const { data, info } = await sharp(file).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||
const aliasKey = sourceImageAliasKey({
|
||
width: info.width,
|
||
height: info.height,
|
||
rgba: data,
|
||
alphaMode,
|
||
alphaUsed: nativeAlphaUsed,
|
||
baseColorFactor,
|
||
});
|
||
cache.set(cacheKey, aliasKey);
|
||
return aliasKey;
|
||
} catch {
|
||
cache.set(cacheKey, null);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function combineConvertedSourceGroups(target, sourceGroup) {
|
||
if (!target) return { ...sourceGroup, textureNames: [...(sourceGroup.textureNames ?? [sourceGroup.texture])] };
|
||
for (const field of ["areaM2", "texturedAreaM2", "uvMissingAreaM2", "uvDegenerateAreaM2", "rankCollapsedAreaM2", "triangleCount"]) {
|
||
target[field] += sourceGroup[field];
|
||
}
|
||
target.textureNames = [...new Set([...target.textureNames, ...(sourceGroup.textureNames ?? [sourceGroup.texture])])].sort();
|
||
for (let axis = 0; axis < 3; axis += 1) {
|
||
target.worldBounds.min[axis] = Math.min(target.worldBounds.min[axis], sourceGroup.worldBounds.min[axis]);
|
||
target.worldBounds.max[axis] = Math.max(target.worldBounds.max[axis], sourceGroup.worldBounds.max[axis]);
|
||
}
|
||
return target;
|
||
}
|
||
|
||
async function outputImageDescriptor(texture, index) {
|
||
const bytes = texture.getImage();
|
||
if (!bytes) return { index, name: texture.getName() || "(unnamed)", mimeType: texture.getMimeType(), error: "No embedded image bytes." };
|
||
const ktx = parseKtx2(bytes);
|
||
const otherMetadata = ktx ? null : await sharp(Buffer.from(bytes)).metadata().catch(() => null);
|
||
return {
|
||
index,
|
||
name: texture.getName() || "(unnamed)",
|
||
basename: imageKey(texture.getName()),
|
||
mimeType: texture.getMimeType(),
|
||
byteLength: bytes.length,
|
||
sha256: hashBytes(bytes),
|
||
...(ktx ?? {
|
||
width: otherMetadata?.width ?? null,
|
||
height: otherMetadata?.height ?? null,
|
||
levelCount: null,
|
||
encoding: "non-KTX2 image",
|
||
error: "Embedded image is not KTX2.",
|
||
}),
|
||
bytes,
|
||
};
|
||
}
|
||
|
||
async function addTextureDirectoryToIndex(directory, extension, index) {
|
||
if (!await exists(directory)) return;
|
||
const files = (await readdir(directory)).filter((file) => file.toLowerCase().endsWith(extension)).sort();
|
||
for (const file of files) {
|
||
const key = imageKey(file);
|
||
const candidates = index.get(key) ?? [];
|
||
candidates.push(path.join(directory, file));
|
||
index.set(key, candidates);
|
||
}
|
||
}
|
||
|
||
async function buildSharedSourceTextureIndex() {
|
||
const ddsByKey = new Map();
|
||
const pngByKey = new Map();
|
||
const topLevel = (await readdir(exportedWorkRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory());
|
||
for (const entry of topLevel) {
|
||
if (entry.name === "resource-cache") {
|
||
const cacheDirectories = (await readdir(path.join(exportedWorkRoot, entry.name), { withFileTypes: true }))
|
||
.filter((cacheEntry) => cacheEntry.isDirectory());
|
||
for (const cacheDirectory of cacheDirectories) {
|
||
const cacheRoot = path.join(exportedWorkRoot, entry.name, cacheDirectory.name);
|
||
await addTextureDirectoryToIndex(path.join(cacheRoot, "raw/textures"), ".dds", ddsByKey);
|
||
await addTextureDirectoryToIndex(path.join(cacheRoot, "prepared/textures"), ".png", pngByKey);
|
||
}
|
||
} else if (entry.name !== "shared-actor-cache") {
|
||
const dungeonRoot = path.join(exportedWorkRoot, entry.name);
|
||
await addTextureDirectoryToIndex(path.join(dungeonRoot, "raw/textures"), ".dds", ddsByKey);
|
||
await addTextureDirectoryToIndex(path.join(dungeonRoot, "prepared/textures"), ".png", pngByKey);
|
||
}
|
||
}
|
||
return { ddsByKey, pngByKey };
|
||
}
|
||
|
||
async function resolveSourceTexturePath(key, localIndex, sharedIndex) {
|
||
if (localIndex.has(key)) return { path: localIndex.get(key), matchMethod: "dungeon-local-exact-basename", ambiguous: [] };
|
||
const candidates = sharedIndex.get(key) ?? [];
|
||
if (candidates.length === 1) return { path: candidates[0], matchMethod: "shared-cache-exact-basename", ambiguous: [] };
|
||
if (!candidates.length) return { path: null, matchMethod: null, ambiguous: [] };
|
||
const digests = await Promise.all(candidates.map(async (candidate) => hashBytes(await readFile(candidate))));
|
||
const uniqueDigests = new Set(digests);
|
||
if (uniqueDigests.size === 1) {
|
||
return { path: candidates[0], matchMethod: "shared-cache-identical-content", ambiguous: [] };
|
||
}
|
||
return { path: null, matchMethod: null, ambiguous: candidates.map(projectPath) };
|
||
}
|
||
|
||
async function compareReportTextureDimensions(report, sharedDdsIndex) {
|
||
const rows = [];
|
||
for (const dungeon of report?.dungeons ?? []) {
|
||
for (const texture of dungeon.textures ?? []) {
|
||
const sourcePath = texture.source?.file
|
||
? path.resolve(projectRoot, ...texture.source.file.split("/"))
|
||
: null;
|
||
const resolved = sourcePath && await exists(sourcePath)
|
||
? { path: sourcePath, matchMethod: "baseline-source-file", ambiguous: [] }
|
||
: await resolveSourceTexturePath(imageKey(texture.name), new Map(), sharedDdsIndex);
|
||
const source = resolved.path ? parseDds(await readFile(resolved.path)) : null;
|
||
const width = Number(texture.shipped?.width ?? 0);
|
||
const height = Number(texture.shipped?.height ?? 0);
|
||
rows.push({
|
||
dungeonId: dungeon.id,
|
||
name: texture.name,
|
||
sourceFile: resolved.path ? projectPath(resolved.path) : null,
|
||
matchMethod: resolved.matchMethod,
|
||
sourceWidth: source?.width ?? null,
|
||
sourceHeight: source?.height ?? null,
|
||
shippedWidth: width || null,
|
||
shippedHeight: height || null,
|
||
downsized: Boolean(source && width && height && (width < source.width || height < source.height)),
|
||
capHitAt2048: Boolean(source && Math.max(source.width, source.height) > 2048 && Math.max(width, height) === 2048),
|
||
ambiguousSourceCandidates: resolved.ambiguous,
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
imageCount: rows.length,
|
||
matchedSourceTextures: rows.filter((row) => row.sourceFile).length,
|
||
downsizedTextures: rows.filter((row) => row.downsized).length,
|
||
capHitsAt2048: rows.filter((row) => row.capHitAt2048).length,
|
||
missingSourceMatches: rows.filter((row) => !row.sourceFile).map(({ dungeonId, name }) => ({ dungeonId, name })),
|
||
ambiguousSourceMatches: rows.filter((row) => row.ambiguousSourceCandidates.length)
|
||
.map(({ dungeonId, name, ambiguousSourceCandidates }) => ({ dungeonId, name, candidates: ambiguousSourceCandidates })),
|
||
downsizedExamples: rows.filter((row) => row.downsized).slice(0, 50),
|
||
};
|
||
}
|
||
|
||
function summarizeTextureUsage(materials) {
|
||
const byTexture = new Map();
|
||
for (const material of materials) {
|
||
for (const [slot, texture, info] of getMaterialTextureInfos(material)) {
|
||
const key = imageKey(texture.getName()) || "(unnamed)";
|
||
const entry = byTexture.get(key) ?? { name: texture.getName() || "(unnamed)", slots: {}, wrapModes: new Set(), materialCount: 0 };
|
||
entry.slots[slot] = (entry.slots[slot] ?? 0) + 1;
|
||
entry.wrapModes.add((WRAP_NAMES.get(info.getWrapS()) ?? info.getWrapS()) + "/" + (WRAP_NAMES.get(info.getWrapT()) ?? info.getWrapT()));
|
||
entry.materialCount += 1;
|
||
byTexture.set(key, entry);
|
||
}
|
||
}
|
||
return byTexture;
|
||
}
|
||
|
||
async function auditDungeon({ id, io, ktxExecutable, doPixelCompare, sharedSourceTextures, sourceImageIdentityCache }) {
|
||
const issues = [];
|
||
const sourceDirectory = path.join(exportedWorkRoot, id);
|
||
const shippedDirectory = path.join(shippedAssetRoot, id);
|
||
const metadataPath = path.join(shippedDirectory, "import-metadata.json");
|
||
const metadata = await exists(metadataPath) ? await readJson(metadataPath) : null;
|
||
const visualEntry = metadata?.assets?.find((asset) => asset.role === "visual");
|
||
const visualFile = path.join(shippedDirectory, visualEntry?.fileName ?? id + "-visual.glb");
|
||
if (!await exists(visualFile)) {
|
||
return {
|
||
id,
|
||
status: "missing-shipped-visual",
|
||
issues: [projectPath(visualFile) + " does not exist."],
|
||
sourceTextureCount: 0,
|
||
textureCount: 0,
|
||
surfaceMetrics: {},
|
||
hotspotGroups: [],
|
||
textures: [],
|
||
};
|
||
}
|
||
|
||
const rawTextureDirectory = path.join(sourceDirectory, "raw/textures");
|
||
const preparedTextureDirectory = path.join(sourceDirectory, "prepared/textures");
|
||
const rawTextureFiles = await exists(rawTextureDirectory) ? await readdir(rawTextureDirectory) : [];
|
||
const preparedTextureFiles = await exists(preparedTextureDirectory) ? await readdir(preparedTextureDirectory) : [];
|
||
const sourceDdsByKey = new Map(rawTextureFiles.filter((file) => file.toLowerCase().endsWith(".dds"))
|
||
.map((file) => [imageKey(file), path.join(rawTextureDirectory, file)]));
|
||
const sourcePngByKey = new Map(preparedTextureFiles.filter((file) => file.toLowerCase().endsWith(".png"))
|
||
.map((file) => [imageKey(file), path.join(preparedTextureDirectory, file)]));
|
||
let document;
|
||
try {
|
||
document = await io.read(visualFile);
|
||
} catch (error) {
|
||
return {
|
||
id,
|
||
status: "unreadable-shipped-visual",
|
||
issues: [error.message],
|
||
sourceTextureCount: sourceDdsByKey.size,
|
||
textureCount: 0,
|
||
surfaceMetrics: {},
|
||
hotspotGroups: [],
|
||
textures: [],
|
||
visualFile: projectPath(visualFile),
|
||
};
|
||
}
|
||
const glbBytes = await readFile(visualFile);
|
||
const glbJson = readGlbJson(glbBytes, projectPath(visualFile));
|
||
const root = document.getRoot();
|
||
const rootTextures = root.listTextures();
|
||
const sourceTextureFiles = [...sourceDdsByKey.values()];
|
||
const imageDescriptors = new Map();
|
||
const textureRows = [];
|
||
const sourcePngByTexture = new Map();
|
||
let canonicalCropTextures = null;
|
||
const pixelTempDirectory = doPixelCompare
|
||
? await mkdtemp(path.join(os.tmpdir(), "rom-environment-quality-" + id + "-")) : null;
|
||
try {
|
||
for (const [index, texture] of rootTextures.entries()) {
|
||
const descriptor = await outputImageDescriptor(texture, index);
|
||
imageDescriptors.set(texture, descriptor);
|
||
let sourceDdsResolution = await resolveSourceTexturePath(descriptor.basename, sourceDdsByKey, sharedSourceTextures.ddsByKey);
|
||
let sourcePngResolution = await resolveSourceTexturePath(descriptor.basename, sourcePngByKey, sharedSourceTextures.pngByKey);
|
||
let sourcePixelCrop = null;
|
||
const cropMetadata = texture.getExtras()?.romSourcePixelCrop;
|
||
if (cropMetadata) {
|
||
try {
|
||
const sourceKey = imageKey(cropMetadata.sourceImageName);
|
||
const atlasDds = await resolveSourceTexturePath(sourceKey, sourceDdsByKey, sharedSourceTextures.ddsByKey);
|
||
const atlasPng = await resolveSourceTexturePath(sourceKey, sourcePngByKey, sharedSourceTextures.pngByKey);
|
||
if (!atlasDds.path || !atlasPng.path) throw new Error("Exact native crop atlas cannot be resolved.");
|
||
if (!canonicalCropTextures) {
|
||
const canonical = await io.read(path.join(sourceDirectory, "converted", `${id}-visual.glb`));
|
||
canonicalCropTextures = new Map(canonical.getRoot().listTextures().map(item => [item.getName(), item]));
|
||
}
|
||
const canonicalCrop = canonicalCropTextures.get(texture.getName());
|
||
if (!canonicalCrop?.getImage()) throw new Error("Canonical source crop image cannot be resolved.");
|
||
sourcePixelCrop = await verifySourcePixelCrop(cropMetadata, await readFile(atlasPng.path), canonicalCrop.getImage(), descriptor);
|
||
const nativeDds = parseDds(await readFile(atlasDds.path));
|
||
if (nativeDds.width !== cropMetadata.sourceWidth || nativeDds.height !== cropMetadata.sourceHeight) {
|
||
throw new Error("Verified PNG crop atlas dimensions do not match native DDS dimensions.");
|
||
}
|
||
sourceDdsResolution = { ...atlasDds, matchMethod: "verified-exact-source-pixel-crop" };
|
||
// Full-atlas PNGs are not valid codec references or image aliases for
|
||
// a cropped island. Pixel decoding compares against the proven crop.
|
||
sourcePngResolution = { path: null, matchMethod: "verified-exact-source-pixel-crop", ambiguous: [] };
|
||
if (doPixelCompare) {
|
||
const cropPath = path.join(pixelTempDirectory, `source-crop-${index}.png`);
|
||
await writeFile(cropPath, canonicalCrop.getImage());
|
||
sourcePngResolution.path = cropPath;
|
||
}
|
||
} catch (error) {
|
||
sourcePixelCrop = null;
|
||
issues.push(`${descriptor.name}: source pixel crop verification failed: ${error.message}`);
|
||
}
|
||
}
|
||
const sourceDdsFile = sourceDdsResolution.path;
|
||
const sourcePngFile = sourcePngResolution.path;
|
||
sourcePngByTexture.set(texture, sourcePngFile);
|
||
const nativeSourceDds = sourceDdsFile ? parseDds(await readFile(sourceDdsFile)) : null;
|
||
const sourceDds = sourcePixelCrop ? { ...nativeSourceDds, width: sourcePixelCrop.rect.width,
|
||
height: sourcePixelCrop.rect.height, levelCount: Math.floor(Math.log2(Math.max(sourcePixelCrop.rect.width, sourcePixelCrop.rect.height))) + 1 } : nativeSourceDds;
|
||
const outputResolution = descriptor.width && descriptor.height ? descriptor.width * descriptor.height : null;
|
||
const sourceResolution = sourceDds?.width && sourceDds?.height ? sourceDds.width * sourceDds.height : null;
|
||
const pixelComparison = doPixelCompare && ["ETC1S/BasisLZ", "UASTC/Zstandard"].includes(descriptor.encoding) && descriptor.width && descriptor.height
|
||
? await compareTexturePixels(descriptor.bytes, sourcePngFile, descriptor.width, descriptor.height,
|
||
pixelTempDirectory ?? os.tmpdir(), index, doPixelCompare ? ktxExecutable : null)
|
||
: { status: "not-audited" };
|
||
textureRows.push({
|
||
name: descriptor.name,
|
||
mimeType: descriptor.mimeType,
|
||
sha256: descriptor.sha256,
|
||
shipped: {
|
||
width: descriptor.width ?? null,
|
||
height: descriptor.height ?? null,
|
||
levelCount: descriptor.levelCount ?? null,
|
||
encoding: descriptor.encoding ?? null,
|
||
supercompressionScheme: descriptor.supercompressionScheme ?? null,
|
||
writerParameters: descriptor.writerParameters ?? null,
|
||
byteLength: descriptor.byteLength,
|
||
},
|
||
source: sourceDdsFile ? {
|
||
file: projectPath(sourceDdsFile),
|
||
matchMethod: sourceDdsResolution.matchMethod,
|
||
width: sourceDds?.width ?? null,
|
||
height: sourceDds?.height ?? null,
|
||
levelCount: sourceDds?.levelCount ?? null,
|
||
byteLength: sourceDds?.byteLength ?? null,
|
||
...(sourcePixelCrop ? { nativeAtlasWidth: nativeSourceDds.width, nativeAtlasHeight: nativeSourceDds.height } : {}),
|
||
} : null,
|
||
sourcePng: sourcePngFile && !sourcePixelCrop ? projectPath(sourcePngFile) : null,
|
||
sourcePixelCrop,
|
||
sourceResolutionMatchMethod: sourceDdsResolution.matchMethod,
|
||
ambiguousSourceCandidates: sourceDdsResolution.ambiguous,
|
||
sourceResolutionRetention: sourceResolution && outputResolution ? round(outputResolution / sourceResolution, 4) : null,
|
||
downsized: Boolean(sourceDds && descriptor.width && descriptor.height
|
||
&& (descriptor.width < sourceDds.width || descriptor.height < sourceDds.height)),
|
||
capHitAt2048: Boolean(sourceDds && Math.max(sourceDds.width, sourceDds.height) > 2048
|
||
&& Math.max(descriptor.width ?? 0, descriptor.height ?? 0) === 2048),
|
||
mipLevelsDropped: Boolean(sourceDds && descriptor.levelCount < sourceDds.levelCount),
|
||
pixelComparison,
|
||
});
|
||
}
|
||
|
||
const materialRows = root.listMaterials().map(createMaterialStats);
|
||
const textureUsage = summarizeTextureUsage(root.listMaterials());
|
||
const surfaceTotals = Object.fromEntries(SURFACES.map((surface) => [surface, newMetricAccumulator()]));
|
||
const groupAccumulators = new Map();
|
||
let triangleAreaUnderThresholdM2 = 0;
|
||
let triangleCountUnderThreshold = 0;
|
||
let eligibleTriangleCount = 0;
|
||
let gpuInstancedNodeCount = 0;
|
||
let gpuInstanceCount = 0;
|
||
const scene = root.getDefaultScene() ?? root.listScenes()[0];
|
||
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 instancedMesh = node.getExtension("EXT_mesh_gpu_instancing");
|
||
const instanceTransforms = nodeInstanceTransforms(node);
|
||
if (instancedMesh) {
|
||
gpuInstancedNodeCount += 1;
|
||
gpuInstanceCount += instanceTransforms.length;
|
||
}
|
||
for (const instanceTransform of instanceTransforms) {
|
||
for (const primitive of mesh.listPrimitives()) {
|
||
if (primitive.getMode() !== 4) continue;
|
||
const positions = primitive.getAttribute("POSITION");
|
||
if (!positions) continue;
|
||
const material = primitive.getMaterial();
|
||
const baseTexture = material?.getBaseColorTexture() ?? null;
|
||
const baseInfo = material?.getBaseColorTextureInfo() ?? null;
|
||
const transform = textureTransformFor(baseInfo);
|
||
const alphaMode = material?.getAlphaMode() ?? "OPAQUE";
|
||
const baseColorFactor = material?.getBaseColorFactor() ?? [1, 1, 1, 1];
|
||
const texCoord = transform?.getTexCoord() ?? baseInfo?.getTexCoord() ?? 0;
|
||
const uvAccessor = primitive.getAttribute("TEXCOORD_" + texCoord);
|
||
const textureDescriptor = baseTexture ? imageDescriptors.get(baseTexture) : null;
|
||
const width = textureDescriptor?.width ?? 0;
|
||
const height = textureDescriptor?.height ?? 0;
|
||
const sourceImageIdentity = await resolveSourceImageAliasKey(sourcePngByTexture.get(baseTexture), alphaMode,
|
||
baseColorFactor, sourceImageIdentityCache);
|
||
const mappingIsUsable = Boolean(baseTexture && textureDescriptor && width > 0 && height > 0 && uvAccessor);
|
||
const wrapS = baseInfo?.getWrapS() ?? 10497;
|
||
const wrapT = baseInfo?.getWrapT() ?? 10497;
|
||
const indices = primitive.getIndices();
|
||
const indexCount = indices?.getCount() ?? positions.getCount();
|
||
const step = indexCount % 3 === 0 ? 3 : 0;
|
||
if (!step) {
|
||
issues.push("" + projectPath(visualFile) + ": non-triangle index count in " + (node.getName() || "unnamed node") + ".");
|
||
continue;
|
||
}
|
||
const positionsCache = new Map();
|
||
const uvsCache = new Map();
|
||
const getPosition = (vertexIndex) => {
|
||
if (!positionsCache.has(vertexIndex)) {
|
||
positionsCache.set(vertexIndex, transformInstancedPoint(worldMatrix, positions.getElement(vertexIndex, [0, 0, 0]), instanceTransform));
|
||
}
|
||
return positionsCache.get(vertexIndex);
|
||
};
|
||
const getUv = (vertexIndex) => {
|
||
if (!uvsCache.has(vertexIndex)) {
|
||
const local = uvAccessor?.getElement(vertexIndex, [0, 0]) ?? null;
|
||
uvsCache.set(vertexIndex, local ? applyUvTransform(local, transform) : null);
|
||
}
|
||
return uvsCache.get(vertexIndex);
|
||
};
|
||
const primitiveMaterialName = material?.getName() || "(unmaterialed)";
|
||
const primitiveTextureName = baseTexture?.getName() || "(no base-color texture)";
|
||
for (let index = 0; index < indexCount; index += 3) {
|
||
const vertexIndices = [0, 1, 2].map((offset) => indices?.getScalar(index + offset) ?? index + offset);
|
||
const points = vertexIndices.map(getPosition);
|
||
const normal = cross(subtract(points[1], points[0]), subtract(points[2], points[0]));
|
||
const twiceArea = length(normal);
|
||
if (twiceArea <= 1e-12) continue;
|
||
const area = twiceArea / 2;
|
||
const surface = classifySurface(normal[1] / twiceArea);
|
||
const total = surfaceTotals[surface];
|
||
total.worldAreaM2 += area;
|
||
eligibleTriangleCount += 1;
|
||
if (area < MIN_SURFACE_TRIANGLE_M2) {
|
||
triangleAreaUnderThresholdM2 += area;
|
||
triangleCountUnderThreshold += 1;
|
||
continue;
|
||
}
|
||
const cell = points.reduce((center, point) => center.map((value, axis) => value + point[axis] / 3), [0, 0, 0]);
|
||
const cellKey = cell.map((value) => Math.floor(value / SPATIAL_CELL_METERS)).join(",");
|
||
const groupKey = [surface, primitiveMaterialName, primitiveTextureName, cellKey].join("\u0000");
|
||
const group = groupAccumulators.get(groupKey) ?? Object.assign(newMetricAccumulator(), {
|
||
surface,
|
||
material: primitiveMaterialName,
|
||
texture: primitiveTextureName,
|
||
sourceImageAliasKey: sourceImageIdentity,
|
||
alphaMode,
|
||
baseColorFactor: baseColorFactor.map((value) => round(value, 6)),
|
||
imageWidth: width || null,
|
||
imageHeight: height || null,
|
||
wrapS: WRAP_NAMES.get(wrapS) ?? String(wrapS),
|
||
wrapT: WRAP_NAMES.get(wrapT) ?? String(wrapT),
|
||
texCoord,
|
||
cellMeters: SPATIAL_CELL_METERS,
|
||
cell: cell.map((value) => Math.floor(value / SPATIAL_CELL_METERS)),
|
||
lowRankWorldAreaM2: 0,
|
||
});
|
||
groupAccumulators.set(groupKey, group);
|
||
group.worldAreaM2 += area;
|
||
group.nodeNames.add(node.getName() || "(unnamed node)");
|
||
group.positionSum = group.positionSum.map((value, axis) => value + cell[axis] * area);
|
||
group.weightedCentroidAreaM2 = (group.weightedCentroidAreaM2 ?? 0) + area;
|
||
for (const point of points) mergeMinMax(group.worldBounds, point);
|
||
if (!mappingIsUsable) {
|
||
if (!baseTexture) {
|
||
addNonMappedArea(group, area, "unmappedAreaM2");
|
||
addNonMappedArea(group, area, "unmappedBaseColorAreaM2");
|
||
addNonMappedArea(total, area, "unmappedBaseColorAreaM2");
|
||
} else {
|
||
addNonMappedArea(group, area, "uvMissingAreaM2");
|
||
addNonMappedArea(group, area, "unusableBaseColorMappingAreaM2");
|
||
addNonMappedArea(total, area, "unusableBaseColorMappingAreaM2");
|
||
}
|
||
total.unmappedAreaM2 += area;
|
||
continue;
|
||
}
|
||
const uvs = vertexIndices.map(getUv);
|
||
if (uvs.some((uv) => !uv)) {
|
||
addNonMappedArea(group, area, "uvMissingAreaM2");
|
||
addNonMappedArea(group, area, "unusableBaseColorMappingAreaM2");
|
||
addNonMappedArea(total, area, "unusableBaseColorMappingAreaM2");
|
||
total.uvMissingAreaM2 += area;
|
||
continue;
|
||
}
|
||
const mapping = mappingMetrics(points, uvs, width, height);
|
||
if (!mapping || mapping.uvDoubleArea <= 1e-12) {
|
||
group.uvDegenerateAreaM2 += area;
|
||
group.uvDegenerateTriangles += 1;
|
||
group.lowRankWorldAreaM2 += area;
|
||
group.lowRankTriangles += 1;
|
||
group.uvBounds.min[0] = Math.min(group.uvBounds.min[0], ...uvs.map((uv) => uv[0]));
|
||
group.uvBounds.min[1] = Math.min(group.uvBounds.min[1], ...uvs.map((uv) => uv[1]));
|
||
group.uvBounds.max[0] = Math.max(group.uvBounds.max[0], ...uvs.map((uv) => uv[0]));
|
||
group.uvBounds.max[1] = Math.max(group.uvBounds.max[1], ...uvs.map((uv) => uv[1]));
|
||
total.uvDegenerateAreaM2 += area;
|
||
total.uvDegenerateTriangles += 1;
|
||
total.lowRankWorldAreaM2 = (total.lowRankWorldAreaM2 ?? 0) + area;
|
||
total.lowRankTriangles += 1;
|
||
continue;
|
||
}
|
||
includeTriangleMetrics(group, {
|
||
area,
|
||
density: mapping.densityPxPerMeter,
|
||
anisotropy: mapping.anisotropy,
|
||
smallestStretch: mapping.smallestStretchPxPerMeter,
|
||
uvs,
|
||
points,
|
||
wrapS,
|
||
wrapT,
|
||
});
|
||
includeTriangleMetrics(total, {
|
||
area,
|
||
density: mapping.densityPxPerMeter,
|
||
anisotropy: mapping.anisotropy,
|
||
smallestStretch: mapping.smallestStretchPxPerMeter,
|
||
uvs,
|
||
points,
|
||
wrapS,
|
||
wrapT,
|
||
});
|
||
if (mapping.smallestStretchPxPerMeter <= 0.01) {
|
||
group.lowRankWorldAreaM2 += area;
|
||
total.lowRankWorldAreaM2 = (total.lowRankWorldAreaM2 ?? 0) + area;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const summarizedGroups = [...groupAccumulators.values()]
|
||
.map((group) => ({
|
||
surface: group.surface,
|
||
material: group.material,
|
||
texture: group.texture,
|
||
sourceImageAliasKey: group.sourceImageAliasKey,
|
||
alphaMode: group.alphaMode,
|
||
baseColorFactor: group.baseColorFactor,
|
||
imageWidth: group.imageWidth,
|
||
imageHeight: group.imageHeight,
|
||
wrapS: group.wrapS,
|
||
wrapT: group.wrapT,
|
||
texCoord: group.texCoord,
|
||
cell: group.cell,
|
||
...summarizeAccumulator(group),
|
||
lowRankWorldAreaM2: round(group.lowRankWorldAreaM2),
|
||
}))
|
||
.filter((group) => ["floor", "wall"].includes(group.surface))
|
||
.sort((left, right) => left.worldAreaM2 - right.worldAreaM2);
|
||
const broadFloorWall = summarizedGroups.filter((group) => group.worldAreaM2 >= HOTSPOT_MIN_AREA_M2);
|
||
const hotspotGroups = [
|
||
...broadFloorWall.filter((group) => group.uvDegenerateAreaM2 > 0 || group.lowRankWorldAreaM2 > 0)
|
||
.sort((left, right) => (right.uvDegenerateAreaM2 + right.lowRankWorldAreaM2) - (left.uvDegenerateAreaM2 + left.lowRankWorldAreaM2))
|
||
.slice(0, 8),
|
||
...broadFloorWall.filter((group) => group.texturedAreaM2 > 0 && group.texelDensityPxPerMeter !== null)
|
||
.sort((left, right) => left.texelDensityPxPerMeter - right.texelDensityPxPerMeter)
|
||
.slice(0, 8),
|
||
...broadFloorWall.filter((group) => group.texturedAreaM2 > 0 && group.anisotropyP90 !== null)
|
||
.sort((left, right) => right.anisotropyP90 - left.anisotropyP90)
|
||
.slice(0, 8),
|
||
].filter((group, index, all) => all.findIndex((candidate) => candidate.surface === group.surface
|
||
&& candidate.material === group.material && candidate.texture === group.texture
|
||
&& candidate.cell.join(",") === group.cell.join(",")) === index);
|
||
|
||
const convertedSourceGeometry = await auditConvertedSourceGeometry({ id, io, sharedSourceTextures, sourceImageIdentityCache });
|
||
const convertedSourceByLocation = new Map();
|
||
const convertedSourceByImageLocation = new Map();
|
||
for (const sourceGroup of convertedSourceGeometry.groups ?? []) {
|
||
const key = [sourceGroup.surface, imageKey(sourceGroup.texture), sourceGroup.cell.join(",")].join("\u0000");
|
||
convertedSourceByLocation.set(key, combineConvertedSourceGroups(convertedSourceByLocation.get(key), sourceGroup));
|
||
if (sourceGroup.sourceImageAliasKey) {
|
||
const aliasKey = [sourceGroup.surface, sourceGroup.sourceImageAliasKey, sourceGroup.cell.join(",")].join("\u0000");
|
||
convertedSourceByImageLocation.set(aliasKey,
|
||
combineConvertedSourceGroups(convertedSourceByImageLocation.get(aliasKey), sourceGroup));
|
||
}
|
||
}
|
||
const hotspotGroupsWithSource = hotspotGroups.map((group) => {
|
||
const exactSource = convertedSourceByLocation.get([group.surface, imageKey(group.texture), group.cell.join(",")].join("\u0000"));
|
||
const aliasSource = group.sourceImageAliasKey
|
||
? convertedSourceByImageLocation.get([group.surface, group.sourceImageAliasKey, group.cell.join(",")].join("\u0000"))
|
||
: null;
|
||
const hasTextureAlias = Boolean(aliasSource?.textureNames.some((name) => imageKey(name) !== imageKey(group.texture)));
|
||
const useImageAlias = Boolean(aliasSource && hasTextureAlias);
|
||
const source = useImageAlias ? aliasSource : exactSource ?? aliasSource;
|
||
const hasExactTextureName = Boolean(source?.textureNames.some((name) => imageKey(name) === imageKey(group.texture)));
|
||
const sourcePixelChannels = group.sourceImageAliasKey?.startsWith("rgba:") ? "rgba" : "rgb";
|
||
const textureMatchMethod = useImageAlias
|
||
? hasExactTextureName ? `exact-plus-source-${sourcePixelChannels}-alias` : `source-${sourcePixelChannels}-alias-pixel-equivalence`
|
||
: exactSource ? "exact-texture-basename" : aliasSource ? `source-${sourcePixelChannels}-pixel-identity` : null;
|
||
const nearbySourceGroups = source ? [] : (convertedSourceGeometry.groups ?? [])
|
||
.filter((candidate) => candidate.surface === group.surface && imageKey(candidate.texture) === imageKey(group.texture))
|
||
.map((candidate) => {
|
||
const gap = candidate.worldBounds.min.map((value, axis) => Math.max(
|
||
0,
|
||
group.worldBounds.min[axis] - candidate.worldBounds.max[axis],
|
||
candidate.worldBounds.min[axis] - group.worldBounds.max[axis],
|
||
));
|
||
const cellOffset = candidate.cell.map((value, axis) => value - group.cell[axis]);
|
||
return {
|
||
surface: candidate.surface,
|
||
cell: candidate.cell,
|
||
cellOffset,
|
||
boundsGapMeters: round(length(gap)),
|
||
areaM2: round(candidate.areaM2),
|
||
uvDegenerateAreaM2: round(candidate.uvDegenerateAreaM2),
|
||
rankCollapsedAreaM2: round(candidate.rankCollapsedAreaM2),
|
||
worldBounds: candidate.worldBounds,
|
||
};
|
||
})
|
||
.filter((candidate) => candidate.boundsGapMeters <= SPATIAL_CELL_METERS)
|
||
.sort((left, right) => left.boundsGapMeters - right.boundsGapMeters
|
||
|| Math.hypot(...left.cellOffset) - Math.hypot(...right.cellOffset))
|
||
.slice(0, 4);
|
||
return {
|
||
...group,
|
||
convertedSourceComparison: source ? {
|
||
status: useImageAlias ? "matched-surface-source-image-cell" : "matched-surface-texture-cell",
|
||
textureMatchMethod,
|
||
sourcePixelIdentityChannels: useImageAlias ? sourcePixelChannels : null,
|
||
sourceTextureNames: source.textureNames,
|
||
sourceImageAliasKey: aliasSource ? group.sourceImageAliasKey : null,
|
||
attributionScope: aliasSource
|
||
? "Coarse aggregate across opaque-material images with matching dimensions, normalized RGB pixels, and base-color factor; this is not triangle-level correspondence."
|
||
: "Matched by texture basename, surface class, and spatial cell; this is not triangle-level correspondence.",
|
||
sourceFile: convertedSourceGeometry.file,
|
||
sourceAreaM2: source.areaM2,
|
||
sourceTexturedAreaM2: source.texturedAreaM2,
|
||
sourceUvDegenerateAreaM2: source.uvDegenerateAreaM2,
|
||
sourceRankCollapsedAreaM2: source.rankCollapsedAreaM2,
|
||
sourceUvMissingAreaM2: source.uvMissingAreaM2,
|
||
postShipUvDegenerateDeltaM2: round(group.uvDegenerateAreaM2 - source.uvDegenerateAreaM2),
|
||
postShipRankCollapsedDeltaM2: round(group.lowRankWorldAreaM2 - source.rankCollapsedAreaM2),
|
||
sourceWorldBounds: source.worldBounds,
|
||
} : {
|
||
status: "no-matching-converted-source-surface-texture-cell",
|
||
sourceFile: convertedSourceGeometry.file,
|
||
limitation: "Converted primary source geometry did not produce this same surface/texture/spatial-cell key; shared WDB assemblies or source material remapping may account for the mismatch.",
|
||
nearbySourceGroups,
|
||
},
|
||
};
|
||
});
|
||
|
||
const texturesByName = new Map(textureRows.map((texture) => [imageKey(texture.name), texture]));
|
||
const usedTextureRows = [...textureUsage.entries()].map(([key, usage]) => ({
|
||
...textureRows.find((texture) => imageKey(texture.name) === key),
|
||
usedBy: { slots: usage.slots, materialCount: usage.materialCount, wrapModes: [...usage.wrapModes].sort() },
|
||
}));
|
||
const outputImages = textureRows.map(({ bytes, ...texture }) => texture);
|
||
const sourceTextureCount = sourceDdsByKey.size;
|
||
const matchedSourceTextures = textureRows.filter((texture) => texture.source).length;
|
||
const downsizedTextures = textureRows.filter((texture) => texture.downsized).length;
|
||
const capHits = textureRows.filter((texture) => texture.capHitAt2048).length;
|
||
const sourceMipsDropped = textureRows.filter((texture) => texture.mipLevelsDropped).length;
|
||
const comparedPixels = textureRows.filter((texture) => texture.pixelComparison.status === "compared");
|
||
const codecPsnrs = comparedPixels.map((texture) => texture.pixelComparison.rgbPsnrDb).filter(Number.isFinite).sort((a, b) => a - b);
|
||
const pixelComparison = {
|
||
comparedTextures: comparedPixels.length,
|
||
medianRgbPsnrDb: codecPsnrs.length ? round(codecPsnrs[Math.floor(codecPsnrs.length / 2)], 2) : null,
|
||
p10RgbPsnrDb: codecPsnrs.length ? round(codecPsnrs[Math.floor((codecPsnrs.length - 1) * 0.1)], 2) : null,
|
||
minRgbPsnrDb: codecPsnrs.length ? round(codecPsnrs[0], 2) : null,
|
||
meanRgbMae: comparedPixels.length
|
||
? round(sum(comparedPixels.map((texture) => texture.pixelComparison.rgbMae)) / comparedPixels.length, 3) : null,
|
||
note: "Codec metric compares the prepared source PNG resized to the shipped KTX2 level-0 dimensions against the decoded shipped KTX2. It isolates block-encoding loss from resolution reduction; RGB only. Null PSNR with rgbIdentical=true means exact RGB equality.",
|
||
};
|
||
const punctual = glbJson.extensions?.KHR_lights_punctual?.lights ?? [];
|
||
const punctualNodeReferences = (glbJson.nodes ?? []).filter((node) => node.extensions?.KHR_lights_punctual?.light !== undefined).length;
|
||
const emissiveMaterials = root.listMaterials().filter((material) => material.getEmissiveTexture()
|
||
|| material.getEmissiveFactor().some((value) => value > 0)).length;
|
||
const surfaceMetrics = Object.fromEntries(SURFACES.map((surface) => [surface, summarizeAccumulator(surfaceTotals[surface])]));
|
||
const unmappedFloorWallArea = sum(["floor", "wall"].map((surface) => surfaceMetrics[surface].unmappedBaseColorAreaM2));
|
||
const qualityFindings = textureRows.length === 0 && unmappedFloorWallArea >= HOTSPOT_MIN_AREA_M2
|
||
? [{
|
||
kind: "unresolved-untextured-fidelity-gap",
|
||
areaM2: round(unmappedFloorWallArea),
|
||
surfaces: ["floor", "wall"],
|
||
detail: "The shipped environment has no embedded texture images and broad floor/wall area has no base-color texture. The source texture fidelity cannot be verified from the current asset; this is an unresolved fidelity gap, not a pass.",
|
||
}]
|
||
: [];
|
||
const summary = {
|
||
id,
|
||
title: metadata?.title ?? id,
|
||
status: issues.length ? "review-required" : qualityFindings.length ? "fidelity-gap" : "audited",
|
||
visualFile: projectPath(visualFile),
|
||
sourceTextureCount,
|
||
textureCount: textureRows.length,
|
||
ktx2TextureCount: textureRows.filter((texture) => texture.mimeType === "image/ktx2").length,
|
||
otherImageCount: textureRows.filter((texture) => texture.mimeType !== "image/ktx2").length,
|
||
matchedSourceTextures,
|
||
verifiedSourcePixelCrops: textureRows.filter(texture => texture.sourcePixelCrop).length,
|
||
sourceTextureMatchRate: textureRows.length ? round(matchedSourceTextures / textureRows.length, 3) : null,
|
||
downsizedTextures,
|
||
capHitsAt2048: capHits,
|
||
mipLevelsDropped: sourceMipsDropped,
|
||
missingSourceTextureMatches: textureRows.filter((texture) => !texture.source).map((texture) => texture.name),
|
||
sourceTextureFilesWithoutShippedMatch: [...sourceDdsByKey.keys()].filter((key) => !texturesByName.has(key)),
|
||
visualNodeCount: root.listNodes().length,
|
||
meshCount: root.listMeshes().length,
|
||
materialCount: root.listMaterials().length,
|
||
triangleCount: Number(metadata?.assets?.find((asset) => asset.role === "visual")?.triangleCount ?? 0)
|
||
|| Object.values(await readJson(path.join(shippedDirectory, "conversion-report.json")).catch(() => ({})))
|
||
.find((value) => value && Number.isFinite(value.triangles))?.triangles
|
||
|| null,
|
||
gpuInstancedNodeCount,
|
||
gpuInstanceCount,
|
||
surfaceGroupCount: summarizedGroups.length,
|
||
geometryTrianglesAudited: eligibleTriangleCount,
|
||
thinTriangleFilter: {
|
||
minimumTriangleAreaM2: MIN_SURFACE_TRIANGLE_M2,
|
||
ignoredTriangles: triangleCountUnderThreshold,
|
||
ignoredWorldAreaM2: round(triangleAreaUnderThresholdM2),
|
||
},
|
||
surfaceMetrics,
|
||
qualityFindings,
|
||
pixelComparison,
|
||
lightingMetadata: {
|
||
embeddedPunctualLights: punctual.length,
|
||
nodesWithPunctualLight: punctualNodeReferences,
|
||
emissiveMaterials,
|
||
khrLightsPunctualExtension: (glbJson.extensionsUsed ?? []).includes("KHR_lights_punctual"),
|
||
},
|
||
convertedSourceGeometry: {
|
||
status: convertedSourceGeometry.status,
|
||
file: convertedSourceGeometry.file,
|
||
triangleCount: convertedSourceGeometry.triangleCount,
|
||
floor: convertedSourceGeometry.surfaceMetrics?.floor ?? null,
|
||
wall: convertedSourceGeometry.surfaceMetrics?.wall ?? null,
|
||
matchedHotspots: hotspotGroupsWithSource.filter((group) => group.convertedSourceComparison.status === "matched-surface-texture-cell").length,
|
||
unmatchedHotspots: hotspotGroupsWithSource.filter((group) => group.convertedSourceComparison.status !== "matched-surface-texture-cell").length,
|
||
},
|
||
issues,
|
||
};
|
||
const textureSummary = textureRows.map(({ bytes, ...row }) => row);
|
||
return {
|
||
...summary,
|
||
materialStats: materialRows,
|
||
textureUsage: usedTextureRows,
|
||
sourceTextures: sourceTextureFiles.map((file) => projectPath(file)),
|
||
textures: textureSummary,
|
||
surfaceGroups: summarizedGroups,
|
||
hotspotGroups: hotspotGroupsWithSource,
|
||
};
|
||
} finally {
|
||
if (pixelTempDirectory) await rm(pixelTempDirectory, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function median(values) {
|
||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||
if (!sorted.length) return null;
|
||
const center = Math.floor(sorted.length / 2);
|
||
return round(sorted.length % 2 ? sorted[center] : (sorted[center - 1] + sorted[center]) / 2, 2);
|
||
}
|
||
|
||
function compareSurfaceMetric(before, after, field) {
|
||
const beforeValue = Number(before?.[field] ?? 0);
|
||
const afterValue = Number(after?.[field] ?? 0);
|
||
return {
|
||
before: round(beforeValue),
|
||
after: round(afterValue),
|
||
reduction: round(beforeValue - afterValue),
|
||
reductionPercent: beforeValue > 0 ? round(((beforeValue - afterValue) / beforeValue) * 100, 1) : null,
|
||
};
|
||
}
|
||
|
||
function reportSurfaceSum(dungeons, surface, field) {
|
||
return sum(dungeons.map((dungeon) => Number(dungeon.surfaceMetrics?.[surface]?.[field] ?? 0)));
|
||
}
|
||
|
||
function fmt(value, digits = 2) {
|
||
return Number.isFinite(value) ? value.toFixed(digits) : "—";
|
||
}
|
||
|
||
function markdownCell(value) {
|
||
return String(value ?? "—").replaceAll("|", "\\|").replaceAll("\n", " ");
|
||
}
|
||
|
||
export function makeMarkdown(report) {
|
||
const fmtVector = (vector) => Array.isArray(vector) ? "[" + vector.map((value) => fmt(value, 1)).join(", ") + "]" : "—";
|
||
const summaryRows = report.dungeons.map((dungeon) => [
|
||
`| ${dungeon.id} | ${dungeon.status} | ${dungeon.textureCount} | ${dungeon.matchedSourceTextures}/${dungeon.textureCount} | ${dungeon.downsizedTextures} | ${dungeon.capHitsAt2048} | ${fmt(dungeon.pixelComparison?.medianRgbPsnrDb)} | ${fmt(dungeon.surfaceMetrics?.floor?.texelDensityPxPerMeter, 1)} | ${fmt(dungeon.surfaceMetrics?.wall?.texelDensityPxPerMeter, 1)} | ${dungeon.lightingMetadata?.embeddedPunctualLights ?? "—"} |`,
|
||
]);
|
||
const hotspotRows = [];
|
||
for (const dungeon of report.dungeons) {
|
||
for (const group of dungeon.hotspotGroups ?? []) {
|
||
const source = group.convertedSourceComparison;
|
||
hotspotRows.push(`| ${dungeon.id} | ${group.surface} | ${group.material} | ${group.texture} (${group.imageWidth ?? "?"}×${group.imageHeight ?? "?"}) | ${group.cell.join(", ")} | ${fmtVector(group.worldBounds?.min)} → ${fmtVector(group.worldBounds?.max)} | ${fmt(group.worldAreaM2, 1)} | ${fmt(group.texelDensityPxPerMeter, 1)} | ${fmt(group.texelDensityPxPerMeterP10, 1)} | ${fmt(group.anisotropyP90)} | ${fmt(group.uvDegenerateAreaM2, 1)} | ${fmt(group.lowRankWorldAreaM2, 1)} | ${source?.status === "matched-surface-texture-cell" ? "matched" : "unmatched"} | ${fmt(source?.sourceRankCollapsedAreaM2, 1)} | ${fmt(source?.postShipRankCollapsedDeltaM2, 1)} | ${group.wrapS}/${group.wrapT} |`);
|
||
}
|
||
}
|
||
const downsizedExamples = report.findings.downsizedExamples.map((texture) =>
|
||
`| ${texture.dungeonId} | ${texture.name} | ${texture.source.width}×${texture.source.height} | ${texture.shipped.width}×${texture.shipped.height} | ${fmt(texture.sourceResolutionRetention * 100, 1)}% | ${texture.pixelComparison.rgbPsnrDb ?? "—"} |`,
|
||
);
|
||
const pixelRows = report.dungeons.filter((dungeon) => dungeon.pixelComparison?.comparedTextures > 0).map((dungeon) =>
|
||
`| ${dungeon.id} | ${dungeon.pixelComparison.comparedTextures} | ${fmt(dungeon.pixelComparison.medianRgbPsnrDb)} | ${fmt(dungeon.pixelComparison.p10RgbPsnrDb)} | ${fmt(dungeon.pixelComparison.minRgbPsnrDb)} | ${fmt(dungeon.pixelComparison.meanRgbMae, 3)} |`,
|
||
);
|
||
const comparison = report.findings.preReoptimizationComparison;
|
||
const sourceResiduals = report.findings.convertedSourceResiduals;
|
||
const originDungeon = report.dungeons.find((dungeon) => dungeon.id === "the-origin");
|
||
const originFloorRankDelta = originDungeon
|
||
? originDungeon.surfaceMetrics.floor.lowRankWorldAreaM2 - (originDungeon.convertedSourceGeometry.floor?.lowRankWorldAreaM2 ?? 0) : null;
|
||
const originWallRankDelta = originDungeon
|
||
? originDungeon.surfaceMetrics.wall.lowRankWorldAreaM2 - (originDungeon.convertedSourceGeometry.wall?.lowRankWorldAreaM2 ?? 0) : null;
|
||
const percent = (value) => Number.isFinite(value) ? fmt(value, 1) + "%" : "—";
|
||
const signed = (value) => Number.isFinite(value) ? (value > 0 ? "+" : "") + fmt(value, 3) : "—";
|
||
const beforeAfterRows = (comparison?.dungeons ?? []).map((dungeon) =>
|
||
`| ${dungeon.id} | ${fmt(dungeon.floorRankCollapsedAreaM2.before, 1)} → ${fmt(dungeon.floorRankCollapsedAreaM2.after, 1)} (${percent(dungeon.floorRankCollapsedAreaM2.reductionPercent)}) | ${fmt(dungeon.wallRankCollapsedAreaM2.before, 1)} → ${fmt(dungeon.wallRankCollapsedAreaM2.after, 1)} (${percent(dungeon.wallRankCollapsedAreaM2.reductionPercent)}) | ${fmt(dungeon.floorUvDegenerateAreaM2.before, 1)} → ${fmt(dungeon.floorUvDegenerateAreaM2.after, 1)} | ${fmt(dungeon.wallUvDegenerateAreaM2.before, 1)} → ${fmt(dungeon.wallUvDegenerateAreaM2.after, 1)} |`,
|
||
);
|
||
return [
|
||
"# RoM environment texture and UV quality audit",
|
||
"",
|
||
`Generated: ${report.generatedAt}`,
|
||
"",
|
||
...(report.scope.assetRootRevision ? [
|
||
`Asset snapshot: Git revision \`${report.scope.assetRootRevision}\`; per-file manifest: \`${report.scope.assetRootManifest ?? "not supplied"}\`.`,
|
||
"",
|
||
] : []),
|
||
`Coverage: **${report.summary.auditedDungeons}/${report.summary.registeredDungeons} registered RoM environments**; ${report.summary.shippedVisualGlbs} shipped visual GLBs; ${report.summary.shippedTextures} embedded textures; ${report.summary.matchedSourceTextures - (report.summary.verifiedSourcePixelCrops ?? 0)} complete native DDS matches and ${report.summary.verifiedSourcePixelCrops ?? 0} verified source-pixel crops.`,
|
||
"",
|
||
"The registry scope is the generated RoM definitions plus the explicit Forsaken Abbey definition. The audit reads world visual GLBs from the asset root recorded in the report scope (the shipped root by default) and preserved ROS exporter source DDS/PNG caches. It excludes actor models and untextured collision/navigation GLBs.",
|
||
"",
|
||
"UV density is measured from shipped geometry in dungeon meters after node transforms. Texture UVs are transformed through `KHR_texture_transform` if present. Face classes use geometric normals: floor `normal.y ≥ 0.7`, wall `|normal.y| ≤ 0.3`; ceilings, slopes and other faces are reported separately. Triangles below 0.02 m² are excluded from hotspot calculations to keep thin trim and decorative fragments from dominating. Broad hotspot candidates aggregate at least 10 m² inside 20 m spatial cells.",
|
||
"",
|
||
"Per-triangle texel density is `sqrt(texture-space area / world-space area)` in source pixels per meter. Anisotropy is the ratio of the largest to smallest singular value of the local UV-to-world mapping. Zero UV area is counted separately; a smallest singular value at or below 0.01 px/m is counted as rank-collapsed. GPU instance translation, rotation, and scale are expanded for these calculations. The compact JSON retains aggregate metrics and selected floor/wall hotspots rather than every spatial group.",
|
||
"",
|
||
"## Per-dungeon coverage",
|
||
"",
|
||
"| Dungeon | Audit | Images | DDS linked | Downsized | 2048 cap | Median codec PSNR (dB) | Floor density (px/m) | Wall density (px/m) | Embedded lights |",
|
||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||
...summaryRows,
|
||
"",
|
||
"`Downsized` compares the shipped KTX2 level 0 dimensions with the matching source DDS. `2048 cap` identifies source textures whose longer side exceeds 2048 and shipped longer side is exactly 2048. Floor and wall density are area-weighted geometric means over mapped faces at or above the triangle cutoff.",
|
||
"",
|
||
...(comparison ? [
|
||
"## UV-collapse comparison against the pre-reoptimization shipment",
|
||
"",
|
||
`Compares the corrected baseline snapshot (${comparison.registeredDungeonsCompared} comparable of ${comparison.baselineDungeons} baseline dungeons) with the current shipped artifacts using the same expanded geometry/UV metric and area thresholds. ${comparison.excludedDungeons.length ? `Excluded because the frozen baseline did not expand GPU instances: ${comparison.excludedDungeons.map((dungeon) => dungeon.id).join(", ")}. ` : "All registered baseline dungeons are comparable. "}Positive reduction means fewer mapped face square meters have a collapsed direction in the current shipment; any remaining degenerate area still needs visual/source review and is not counted as automatically acceptable. The comparable rank-collapse change is ${fmt(comparison.summary.floorRankCollapsedAreaM2.before, 1)} → ${fmt(comparison.summary.floorRankCollapsedAreaM2.after, 1)} m² on floors and ${fmt(comparison.summary.wallRankCollapsedAreaM2.before, 1)} → ${fmt(comparison.summary.wallRankCollapsedAreaM2.after, 1)} m² on walls.`,
|
||
"",
|
||
"| Dungeon | Rank-collapsed floor before → after (reduction %) | Rank-collapsed wall before → after (reduction %) | Zero-UV floor before → after m² | Zero-UV wall before → after m² |",
|
||
"| --- | ---: | ---: | ---: | ---: |",
|
||
...beforeAfterRows,
|
||
"",
|
||
] : []),
|
||
...(sourceResiduals ? [
|
||
"## Remaining residuals compared with converted source",
|
||
"",
|
||
`The static converted-source comparison covers ${sourceResiduals.staticSourceAuditedDungeons} dungeons. Low-rank UV area (converted source → shipped; shipped minus source) is **${fmt(sourceResiduals.rankCollapsedAreaM2.floor.convertedSourceM2, 3)} → ${fmt(sourceResiduals.rankCollapsedAreaM2.floor.shippedM2, 3)} m² (${signed(sourceResiduals.rankCollapsedAreaM2.floor.shippedMinusConvertedSourceM2)}) on floors** and **${fmt(sourceResiduals.rankCollapsedAreaM2.wall.convertedSourceM2, 3)} → ${fmt(sourceResiduals.rankCollapsedAreaM2.wall.shippedM2, 3)} m² (${signed(sourceResiduals.rankCollapsedAreaM2.wall.shippedMinusConvertedSourceM2)}) on walls**. The large remaining low-rank areas therefore closely track source mapping; this is source attribution, not a visual-quality pass.`,
|
||
`Exact zero-area UV coverage differs by ${signed(sourceResiduals.exactlyZeroUvAreaM2.floor.shippedMinusConvertedSourceM2)} m² on floors and ${signed(sourceResiduals.exactlyZeroUvAreaM2.wall.shippedMinusConvertedSourceM2)} m² on walls. Small source UV differences quantizing to exact zero are one possible explanation; per-triangle attribution and visible impact are not proven by these totals.`,
|
||
`Among ${sourceResiduals.selectedHotspotGroups} selected broad hotspot groups, ${sourceResiduals.exactSurfaceTextureCellMatches} match by texture basename and exact 20 m cell, ${sourceResiduals.sourceImagePixelEquivalentCellMatches} include source-pixel alias support for compatible material alpha modes, and ${sourceResiduals.unmatchedHotspotCount} remain unmatched. Exact-name and alias counts can overlap. Pixel-equivalent matches combine coarse surface/cell area and texture aliases; they do not prove triangle-level correspondence. ${sourceResiduals.theOriginCellAmbiguity.unmatchedHotspotCount} The Origin hotspots remain unmatched.`,
|
||
`${sourceResiduals.theOriginCellAmbiguity.interpretation} Whole-dungeon rank deltas are ${signed(originFloorRankDelta)} m² on floors and ${signed(originWallRankDelta)} m² on walls.`,
|
||
"Technical audit coverage passed for every registered artifact, but source-level collapsed mappings and unresolved attribution still require visual/source review. The report does not certify every floor or wall as visually green.",
|
||
"",
|
||
] : []),
|
||
"",
|
||
"## Broad floor and wall mapping hotspots",
|
||
"",
|
||
"Rows are the union of the largest rank-collapse/degenerate-UV areas, lowest density groups, and highest p90 anisotropy groups per dungeon, restricted to spatial groups at least 10 m². `cell` is the 20 m world-grid coordinate `[x,y,z]`; bounds are exact current shipped GLB coordinates in meters. `uvDegenerate` reports geometric surface area with zero-area UV triangles; `rank collapsed` reports area with a minimum singular value ≤0.01 px/m. Converted source comparison matches by surface, texture basename, and spatial cell; a positive `post−source rank m²` is a possible newly introduced collapse, subject to the source GLB coverage limit below.",
|
||
"",
|
||
"| Dungeon | Face | Material | Texture | Cell | Shipped bounds min → max | Area m² | Mean px/m | P10 px/m | P90 anisotropy | UV-degenerate m² | Rank-collapsed m² | Source match | Source rank m² | Post−source rank m² | Wrap S/T |",
|
||
"| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | --- |",
|
||
...(hotspotRows.length ? hotspotRows : ["| — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |"]),
|
||
"",
|
||
"## Source-to-shipped dimensions",
|
||
"",
|
||
`Across ${report.summary.shippedTextures} embedded images, ${report.summary.downsizedTextures} are smaller than the matching source DDS and ${report.summary.capHitsAt2048} match the previous 2048-side cap condition. The current audit reports only shipped files; it does not rebuild or replace those assets.`,
|
||
"Source-pixel crops require a hashed native PNG/DDS atlas, an in-bounds integer pixel rectangle, a canonical PNG with byte-identical cropped RGBA, retained shipping island dimensions, and full island mip levels. Their effective source dimensions are the proven island dimensions; native atlas dimensions remain recorded separately. This proves a crop rather than exempting a downsampled image. Compressed pixel fidelity still requires decoding or rendered review.",
|
||
...(report.findings.preReoptimizationTextureDimensionComparison ? [
|
||
`The pre-reoptimization snapshot is also re-linked against the now-indexed shared DDS cache: ${report.findings.preReoptimizationTextureDimensionComparison.matchedSourceTextures}/${report.findings.preReoptimizationTextureDimensionComparison.imageCount} source matches, ${report.findings.preReoptimizationTextureDimensionComparison.downsizedTextures} downsized images, ${report.findings.preReoptimizationTextureDimensionComparison.capHitsAt2048} old 2048-cap hits. This corrects the frozen snapshot's original local-cache-only 823/1070 match coverage without changing its preserved UV measurements.`,
|
||
"",
|
||
] : []),
|
||
"",
|
||
"| Dungeon | Texture | Source DDS | Shipped KTX2 | Texels retained | Codec-only PSNR (dB) |",
|
||
"| --- | --- | ---: | ---: | ---: | ---: |",
|
||
...(downsizedExamples.length ? downsizedExamples : ["| — | No downsized matches recorded | — | — | — | — |"]),
|
||
"",
|
||
"## KTX2 codec quality comparison",
|
||
"",
|
||
(report.scope.pixelComparisonEnabled ? "Pixel decoding was enabled." : "Pixel decoding was skipped for this run.") + " For source PNG/KTX2 pairs, `ktx extract --transcode rgba8` decodes shipped ETC1S or UASTC level 0. RGB PSNR/MAE compares that decode with the source PNG resized to the shipped dimensions using Lanczos3, so it isolates codec loss from a resolution change. It does not score alpha, mip levels, display-space lighting, or a screenshot. Null PSNR with `rgbIdentical=true` means exact RGB equality. PSNR is a numerical image metric and does not substitute for in-game review.",
|
||
"",
|
||
"| Dungeon | Compared | Median PSNR dB | P10 PSNR dB | Minimum PSNR dB | Mean RGB MAE |",
|
||
"| --- | ---: | ---: | ---: | ---: | ---: |",
|
||
...(pixelRows.length ? pixelRows : ["| — | 0 | — | — | — | — |"]),
|
||
"",
|
||
"## Light metadata observed in the environment GLBs",
|
||
"",
|
||
"`KHR_lights_punctual` and emissive material metadata are counted from each shipped environment GLB. The runtime presentation lights live in the dungeon definitions, and their illumination still requires a rendered review; those are covered in the separate RoM lighting pass.",
|
||
"",
|
||
`- GLBs with embedded punctual lights: ${report.summary.dungeonsWithEmbeddedLights}/${report.summary.auditedDungeons}.` +
|
||
` The environments contain ${report.summary.embeddedPunctualLights} light records and ${report.summary.emissiveMaterials} emissive materials in total.`,
|
||
`- Surface area with degenerate base-color UV triangles (≥${MIN_SURFACE_TRIANGLE_M2} m² triangle filter): ${fmt(report.summary.degenerateFloorAreaM2, 1)} m² on floors and ${fmt(report.summary.degenerateWallAreaM2, 1)} m² on walls.`,
|
||
`- Surface area with a rank-collapsed UV direction (≤0.01 px/m): ${fmt(report.summary.rankCollapsedFloorAreaM2, 1)} m² on floors and ${fmt(report.summary.rankCollapsedWallAreaM2, 1)} m² on walls.`,
|
||
"",
|
||
"## Coverage gaps and interpretation limits",
|
||
"",
|
||
...report.limitations.map((limitation) => "- " + limitation),
|
||
...(report.issues.length ? report.issues.map((issue) => "- Audit issue: " + issue) : []),
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const doPixelCompare = !args.includes("--skip-pixel-decode");
|
||
const [generatedSource, registrySource, forsakenSource, recipes] = await Promise.all([
|
||
readFile(generatedDefinitionsPath, "utf8"),
|
||
readFile(dungeonRegistryPath, "utf8"),
|
||
readFile(forsakenDefinitionPath, "utf8"),
|
||
readDungeonRecipes(),
|
||
]);
|
||
const registry = getRegisteredRunewakerIds(generatedSource, registrySource, forsakenSource);
|
||
const recipeIds = recipes.map(({ value }) => value.slug).filter(Boolean).sort();
|
||
const unregisteredRecipes = recipeIds.filter((id) => !registry.ids.includes(id));
|
||
const missingRecipes = registry.ids.filter((id) => !recipeIds.includes(id));
|
||
const mismatchIssues = [
|
||
...unregisteredRecipes.map((id) => "Environment recipe exists but is not in the source RoM registry: " + id),
|
||
...missingRecipes.map((id) => "Registered RoM dungeon has no environment recipe: " + id),
|
||
];
|
||
const ktxCandidate = [
|
||
process.env.KTX_BIN_DIR ? path.join(process.env.KTX_BIN_DIR, process.platform === "win32" ? "ktx.exe" : "ktx") : null,
|
||
path.join(projectRoot, ".tools/ktx/bin", process.platform === "win32" ? "ktx.exe" : "ktx"),
|
||
].filter(Boolean)[0];
|
||
const resolvedKtx = ktxCandidate && await exists(ktxCandidate) ? ktxCandidate : null;
|
||
const sharedSourceTextures = await buildSharedSourceTextureIndex();
|
||
const sourceImageIdentityCache = new Map();
|
||
const io = new NodeIO()
|
||
.registerExtensions(ALL_EXTENSIONS)
|
||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||
await MeshoptDecoder.ready;
|
||
const dungeons = [];
|
||
for (const id of registry.ids) {
|
||
const result = await auditDungeon({ id, io, ktxExecutable: resolvedKtx, doPixelCompare, sharedSourceTextures, sourceImageIdentityCache });
|
||
dungeons.push(result);
|
||
console.log("[runewaker-quality] " + id + ": " + result.status
|
||
+ ", " + result.textureCount + " images, " + result.downsizedTextures + " downsized,"
|
||
+ " floor/wall groups=" + (result.hotspotGroups?.length ?? 0));
|
||
}
|
||
const audited = dungeons.filter((dungeon) => ["audited", "review-required", "fidelity-gap"].includes(dungeon.status));
|
||
const allTextures = dungeons.flatMap((dungeon) => (dungeon.textures ?? []).map((texture) => ({ dungeonId: dungeon.id, ...texture })));
|
||
const preReoptimizationReport = await exists(preReoptimizationReportPath) ? await readJson(preReoptimizationReportPath) : null;
|
||
const preReoptimizationTextureDimensions = preReoptimizationReport
|
||
? await compareReportTextureDimensions(preReoptimizationReport, sharedSourceTextures.ddsByKey)
|
||
: null;
|
||
const preReoptimizationById = new Map((preReoptimizationReport?.dungeons ?? []).map((dungeon) => [dungeon.id, dungeon]));
|
||
const beforeAfterByDungeon = preReoptimizationReport ? dungeons.map((after) => {
|
||
const before = preReoptimizationById.get(after.id);
|
||
const baselineExpandedInstances = Boolean(preReoptimizationReport?.scope?.gpuInstanceMetricsExpanded);
|
||
const comparable = Boolean(before) && (baselineExpandedInstances || Number(before.gpuInstanceCount ?? 0) === 0);
|
||
const comparisonMetric = (surface, field) => comparable
|
||
? compareSurfaceMetric(before.surfaceMetrics?.[surface], after.surfaceMetrics?.[surface], field)
|
||
: {
|
||
before: null,
|
||
after: Number(after.surfaceMetrics?.[surface]?.[field] ?? 0),
|
||
reduction: null,
|
||
reductionPercent: null,
|
||
status: before ? "baseline-instances-not-expanded" : "missing-baseline",
|
||
};
|
||
return {
|
||
id: after.id,
|
||
statusBefore: before?.status ?? "missing-baseline",
|
||
statusAfter: after.status,
|
||
comparisonStatus: comparable ? "comparable" : before ? "baseline-instances-not-expanded" : "missing-baseline",
|
||
baselineInstanceCount: Number(before?.gpuInstanceCount ?? 0),
|
||
currentInstanceCount: Number(after.gpuInstanceCount ?? 0),
|
||
floorUvDegenerateAreaM2: comparisonMetric("floor", "uvDegenerateAreaM2"),
|
||
floorRankCollapsedAreaM2: comparisonMetric("floor", "lowRankWorldAreaM2"),
|
||
wallUvDegenerateAreaM2: comparisonMetric("wall", "uvDegenerateAreaM2"),
|
||
wallRankCollapsedAreaM2: comparisonMetric("wall", "lowRankWorldAreaM2"),
|
||
};
|
||
}) : [];
|
||
const comparableBeforeAfter = beforeAfterByDungeon.filter((row) => row.comparisonStatus === "comparable");
|
||
const comparableIds = new Set(comparableBeforeAfter.map((row) => row.id));
|
||
const comparableBeforeDungeons = (preReoptimizationReport?.dungeons ?? []).filter((dungeon) => comparableIds.has(dungeon.id));
|
||
const comparableAfterDungeons = dungeons.filter((dungeon) => comparableIds.has(dungeon.id));
|
||
const compareComparableMetric = (surface, field) => compareSurfaceMetric(
|
||
{ [field]: sum(comparableBeforeDungeons.map((dungeon) => Number(dungeon.surfaceMetrics?.[surface]?.[field] ?? 0))) },
|
||
{ [field]: sum(comparableAfterDungeons.map((dungeon) => Number(dungeon.surfaceMetrics?.[surface]?.[field] ?? 0))) },
|
||
field,
|
||
);
|
||
const downsizedExamples = allTextures.filter((texture) => texture.downsized && texture.source && texture.shipped.width && texture.shipped.height)
|
||
.sort((left, right) => (left.source.width * left.source.height - left.shipped.width * left.shipped.height)
|
||
- (right.source.width * right.source.height - right.shipped.width * right.shipped.height));
|
||
const sumSurface = (surface, field) => sum(audited.map((dungeon) => Number(dungeon.surfaceMetrics?.[surface]?.[field] ?? 0)));
|
||
const sourceComparedDungeons = audited.filter((dungeon) => dungeon.convertedSourceGeometry?.status === "audited");
|
||
const sourceMetricComparison = (surface, field) => {
|
||
const convertedSourceM2 = sum(sourceComparedDungeons.map((dungeon) => Number(dungeon.convertedSourceGeometry?.[surface]?.[field] ?? 0)));
|
||
const shippedM2 = sum(sourceComparedDungeons.map((dungeon) => Number(dungeon.surfaceMetrics?.[surface]?.[field] ?? 0)));
|
||
return {
|
||
convertedSourceM2: round(convertedSourceM2),
|
||
shippedM2: round(shippedM2),
|
||
shippedMinusConvertedSourceM2: round(shippedM2 - convertedSourceM2),
|
||
};
|
||
};
|
||
const allSelectedHotspots = dungeons.flatMap((dungeon) => (dungeon.hotspotGroups ?? []).map((group) => ({ dungeonId: dungeon.id, ...group })));
|
||
const matchedSourceStatus = (group) => ["matched-surface-texture-cell", "matched-surface-source-image-cell"]
|
||
.includes(group.convertedSourceComparison?.status);
|
||
const unmatchedConvertedSourceHotspots = allSelectedHotspots.filter((group) => !matchedSourceStatus(group));
|
||
const originUnmatchedHotspots = unmatchedConvertedSourceHotspots.filter((group) => group.dungeonId === "the-origin");
|
||
const originCellExample = allSelectedHotspots.find((group) => group.dungeonId === "the-origin" && group.surface === "floor"
|
||
&& group.texture === "98274094_waterfall02" && group.cell.join(",") === "1,-1,8");
|
||
const convertedSourceResiduals = {
|
||
staticSourceAuditedDungeons: sourceComparedDungeons.length,
|
||
selectedHotspotGroups: allSelectedHotspots.length,
|
||
exactSurfaceTextureCellMatches: allSelectedHotspots.filter((group) => group.convertedSourceComparison?.sourceTextureNames
|
||
?.some((name) => imageKey(name) === imageKey(group.texture))).length,
|
||
sourceImagePixelEquivalentCellMatches: allSelectedHotspots.filter((group) => group.convertedSourceComparison?.textureMatchMethod
|
||
?.includes("-alias")).length,
|
||
unmatchedHotspotCount: unmatchedConvertedSourceHotspots.length,
|
||
rankCollapsedAreaM2: {
|
||
floor: sourceMetricComparison("floor", "lowRankWorldAreaM2"),
|
||
wall: sourceMetricComparison("wall", "lowRankWorldAreaM2"),
|
||
},
|
||
exactlyZeroUvAreaM2: {
|
||
floor: sourceMetricComparison("floor", "uvDegenerateAreaM2"),
|
||
wall: sourceMetricComparison("wall", "uvDegenerateAreaM2"),
|
||
},
|
||
unmatchedHotspots: unmatchedConvertedSourceHotspots.map((group) => ({
|
||
dungeonId: group.dungeonId,
|
||
surface: group.surface,
|
||
material: group.material,
|
||
texture: group.texture,
|
||
cell: group.cell,
|
||
worldBounds: group.worldBounds,
|
||
uvDegenerateAreaM2: group.uvDegenerateAreaM2,
|
||
rankCollapsedAreaM2: group.lowRankWorldAreaM2,
|
||
nearbySourceGroups: group.convertedSourceComparison?.nearbySourceGroups ?? [],
|
||
})),
|
||
theOriginCellAmbiguity: {
|
||
unmatchedHotspotCount: originUnmatchedHotspots.length,
|
||
textureNames: [...new Set(originUnmatchedHotspots.map((group) => group.texture))],
|
||
sample: originCellExample ? {
|
||
texture: originCellExample.texture,
|
||
shippedSurface: originCellExample.surface,
|
||
shippedCell: originCellExample.cell,
|
||
shippedAreaM2: originCellExample.worldAreaM2,
|
||
shippedWorldBounds: originCellExample.worldBounds,
|
||
shippedRankCollapsedAreaM2: originCellExample.lowRankWorldAreaM2,
|
||
sourceComparison: {
|
||
status: originCellExample.convertedSourceComparison?.status ?? "unmatched",
|
||
textureMatchMethod: originCellExample.convertedSourceComparison?.textureMatchMethod ?? null,
|
||
sourceTextureNames: originCellExample.convertedSourceComparison?.sourceTextureNames ?? [],
|
||
sourceAreaM2: originCellExample.convertedSourceComparison?.sourceAreaM2 ?? null,
|
||
sourceRankCollapsedAreaM2: originCellExample.convertedSourceComparison?.sourceRankCollapsedAreaM2 ?? null,
|
||
postShipRankCollapsedDeltaM2: originCellExample.convertedSourceComparison?.postShipRankCollapsedDeltaM2 ?? null,
|
||
sourceWorldBounds: originCellExample.convertedSourceComparison?.sourceWorldBounds ?? null,
|
||
},
|
||
} : null,
|
||
interpretation: originCellExample
|
||
? `Source-image identity resolves the previously unmatched Origin waterfall keys: opaque materials use RGB hashes, and the gate_waterfall/waterfall02 source images are also RGBA-identical. At cell [1,-1,8], the alias group contains ${originCellExample.convertedSourceComparison?.sourceTextureNames?.join(" and ") ?? "the matching source images"}; its source area is ${fmt(originCellExample.convertedSourceComparison?.sourceAreaM2, 3)} m² versus ${fmt(originCellExample.worldAreaM2, 3)} m² shipped, with rank-collapsed area ${fmt(originCellExample.convertedSourceComparison?.sourceRankCollapsedAreaM2, 3)} versus ${fmt(originCellExample.lowRankWorldAreaM2, 3)} m². The source and shipped bounds are nearly coincident. WDB lineage places the relevant precipice ROS assemblies statically; the alias comparison is a coarse surface/cell aggregate and not triangle-level attribution. This resolves the basename/key mismatch and shows the large-area discrepancy came from incomplete image-name grouping, while the small remaining rank/area deltas still require source or rendered review.`
|
||
: "Source-image aliases can resolve basename-only surface/cell misses when source pixels and material alpha semantics permit it. The selected The Origin waterfall sample was not present in the hotspot set for this run.",
|
||
},
|
||
interpretation: "Shipped low-rank area is close to converted-source totals overall, so most measured residual collapse is inherited from the static source mapping. Source-image aliases are joined only as coarse surface/cell aggregates when their source pixels and material alpha semantics permit it; this is not per-triangle attribution. Remaining source-level low-rank surfaces, exact-zero UV changes, unmatched hotspots, and untextured assets still require visual/source review.",
|
||
};
|
||
const report = {
|
||
schemaVersion: 2,
|
||
generatedAt: new Date().toISOString(),
|
||
generatedBy: "scripts/runewaker-pipeline/audit-environment-quality.mjs",
|
||
deterministicForInputs: true,
|
||
scope: {
|
||
registeredDefinitionSource: "src/game/dungeonRegistry.ts -> RUNEWAKER_DUNGEON_DEFINITIONS plus Forsaken Abbey",
|
||
registeredIds: registry.ids,
|
||
generatedIds: registry.generatedIds,
|
||
explicitIds: registry.explicitIds,
|
||
recipeIds,
|
||
shippedVisualAssetRoot: projectPath(shippedAssetRoot),
|
||
assetRootRevision: optionValue("--asset-revision"),
|
||
assetRootManifest: optionValue("--asset-manifest"),
|
||
exportedSourceTextureRoots: ["runewaker-export-work/<slug>/raw/textures/", "runewaker-export-work/resource-cache/<resource-key>/raw/textures/"],
|
||
pixelComparisonEnabled: doPixelCompare,
|
||
gpuInstanceMetricsExpanded: true,
|
||
exhaustiveSpatialSurfaceGroupsPersisted: false,
|
||
spatialSurfaceGroupDetails: "Every spatial group is measured in memory; JSON persists per-dungeon aggregate surface metrics and selected broad floor/wall hotspot groups.",
|
||
ktxTool: resolvedKtx ? projectPath(resolvedKtx) : null,
|
||
preReoptimizationReport: preReoptimizationReport ? projectPath(preReoptimizationReportPath) : null,
|
||
},
|
||
summary: {
|
||
registeredDungeons: registry.ids.length,
|
||
auditedDungeons: audited.length,
|
||
shippedVisualGlbs: audited.filter((dungeon) => dungeon.visualFile).length,
|
||
missingShippedVisualGlbs: dungeons.filter((dungeon) => dungeon.status === "missing-shipped-visual").length,
|
||
unreadableShippedVisualGlbs: dungeons.filter((dungeon) => dungeon.status === "unreadable-shipped-visual").length,
|
||
shippedTextures: allTextures.length,
|
||
matchedSourceTextures: allTextures.filter((texture) => texture.source).length,
|
||
verifiedSourcePixelCrops: allTextures.filter(texture => texture.sourcePixelCrop).length,
|
||
downsizedTextures: allTextures.filter((texture) => texture.downsized).length,
|
||
capHitsAt2048: allTextures.filter((texture) => texture.capHitAt2048).length,
|
||
mipLevelsDropped: allTextures.filter((texture) => texture.mipLevelsDropped).length,
|
||
codecComparedTextures: allTextures.filter((texture) => texture.pixelComparison.status === "compared").length,
|
||
dungeonsWithEmbeddedLights: audited.filter((dungeon) => dungeon.lightingMetadata?.embeddedPunctualLights > 0).length,
|
||
embeddedPunctualLights: sum(audited.map((dungeon) => dungeon.lightingMetadata?.embeddedPunctualLights ?? 0)),
|
||
emissiveMaterials: sum(audited.map((dungeon) => dungeon.lightingMetadata?.emissiveMaterials ?? 0)),
|
||
degenerateFloorAreaM2: sumSurface("floor", "uvDegenerateAreaM2"),
|
||
degenerateWallAreaM2: sumSurface("wall", "uvDegenerateAreaM2"),
|
||
rankCollapsedFloorAreaM2: sumSurface("floor", "lowRankWorldAreaM2"),
|
||
rankCollapsedWallAreaM2: sumSurface("wall", "lowRankWorldAreaM2"),
|
||
},
|
||
findings: {
|
||
previous2048CapHitCount: allTextures.filter((texture) => texture.capHitAt2048).length,
|
||
downsizedExamples: downsizedExamples.slice(0, 100),
|
||
sourceMatchesMissing: allTextures.filter((texture) => !texture.source).map((texture) => ({ dungeonId: texture.dungeonId, name: texture.name })),
|
||
ambiguousSourceMatches: allTextures.filter((texture) => texture.ambiguousSourceCandidates?.length)
|
||
.map((texture) => ({ dungeonId: texture.dungeonId, name: texture.name, candidates: texture.ambiguousSourceCandidates })),
|
||
convertedSourceResiduals,
|
||
broadFloorWallHotspots: dungeons.flatMap((dungeon) => (dungeon.hotspotGroups ?? []).map((group) => ({ dungeonId: dungeon.id, ...group }))),
|
||
preReoptimizationTextureDimensionComparison: preReoptimizationTextureDimensions,
|
||
preReoptimizationComparison: preReoptimizationReport ? {
|
||
generatedAt: preReoptimizationReport.generatedAt,
|
||
baselineDungeons: beforeAfterByDungeon.filter((row) => row.statusBefore !== "missing-baseline").length,
|
||
registeredDungeonsCompared: comparableBeforeAfter.length,
|
||
excludedDungeons: beforeAfterByDungeon.filter((row) => row.comparisonStatus !== "comparable").map((row) => ({
|
||
id: row.id,
|
||
status: row.comparisonStatus,
|
||
baselineInstanceCount: row.baselineInstanceCount,
|
||
currentInstanceCount: row.currentInstanceCount,
|
||
})),
|
||
summary: {
|
||
floorUvDegenerateAreaM2: compareComparableMetric("floor", "uvDegenerateAreaM2"),
|
||
wallUvDegenerateAreaM2: compareComparableMetric("wall", "uvDegenerateAreaM2"),
|
||
floorRankCollapsedAreaM2: compareComparableMetric("floor", "lowRankWorldAreaM2"),
|
||
wallRankCollapsedAreaM2: compareComparableMetric("wall", "lowRankWorldAreaM2"),
|
||
},
|
||
dungeons: beforeAfterByDungeon,
|
||
} : null,
|
||
},
|
||
issues: mismatchIssues.concat(dungeons.flatMap((dungeon) => (dungeon.issues ?? []).map((issue) => dungeon.id + ": " + issue))),
|
||
limitations: [
|
||
"The audit measures GLBs from the asset root specified by --asset-root (defaults to the shipped visual root); it does not rebuild assets, so producer-pipeline changes do not alter an existing asset root until packages are regenerated.",
|
||
"Source dimensions are linked through the GLB image basename to preserved dungeon-local and shared resource-cache DDS files. Shared-cache duplicate basenames are accepted only when the DDS byte hashes are identical; conflicting candidates remain unmatched and are listed.",
|
||
"Codec PSNR and MAE compare decoded ETC1S/UASTC RGB against resized source PNG pixels. They exclude alpha error, mip rendering, lighting, filtering, camera distance, and human visual perception. Null PSNR with rgbIdentical=true means exact RGB equality.",
|
||
doPixelCompare
|
||
? "Pixel decoding was enabled. Texture-level status records missing PNG inputs, unavailable tooling, or decoding failures; those textures have no codec PSNR/MAE result."
|
||
: "This run skipped pixel decoding; codec PSNR/MAE fields are intentionally unavailable. Dimension comparison, mip-level counts, material references, and base-color UV geometry were still audited.",
|
||
"UV density uses base-color texture coordinates for visual surfaces. Other texture channels and runtime material replacements are inventoried, but do not contribute to the floor/wall density ranking.",
|
||
"Recipe-driven visual UV adaptations are reviewed runtime changes. Barren Caves retains its original UV0 in the canonical unoptimized document and samples a repeated UV1; the optimizer may prune or renumber unused UV channels. The converted-source comparison measures this annotated conversion, rather than proving that the rendered UVs equal the original ROS mapping.",
|
||
"Native WDB terrain density diagnostics measure the default texture carrier. Runtime splat blending and optional steep-face side projections sample multiple source-scaled tile textures; a single-carrier metric does not describe their rendered density.",
|
||
"The audit expands EXT_mesh_gpu_instancing per-instance translation, rotation, and scale before measuring world areas, spatial cells, and UV density. A before/after comparison excludes a baseline with GPU instances unless that baseline records expanded instance metrics.",
|
||
"Surface classes are geometric-normal heuristics in the GLB frame. Floor includes upward surfaces; ceiling, slopes, and other faces are separately measured. This does not identify a semantic game level or classify sub-horizontal platform parts as authored floors.",
|
||
"Audit hotspots use triangle area ≥0.02 m² and spatial cells at 20 m. Aggregate groups under 10 m² are excluded from the broad hotspot report, though their measurements remain in surface totals.",
|
||
"Converted-source comparison reads runewaker-export-work/<slug>/converted/<slug>-visual.glb after static WDB assembly. Exact image basenames join by surface and 20 m cell. When source PNG pixels and base-color factors match, image aliases join as a coarse surface/cell aggregate: RGB identity only when OPAQUE materials have a fully opaque source alpha channel; non-opaque alpha is retained as RGBA identity when alpha mode or native use is unresolved, and MASK/BLEND materials always retain RGBA. This is not triangle-level correspondence. Separate dynamic WDB assets and resources marked unsupported are outside the static visual comparison; unmatched keys or expanded bounds need checking against the placement manifest before attribution.",
|
||
"Embedded light metadata does not describe the runtime renderer's ambient, hemisphere, directional, or point-light interactions. Rendered captures are needed for lighting validation.",
|
||
],
|
||
dungeons,
|
||
};
|
||
const compactDungeons = report.dungeons.map(({ surfaceGroups, ...dungeon }) => dungeon);
|
||
const { broadFloorWallHotspots, ...compactFindings } = report.findings;
|
||
const persistedReport = { ...report, findings: compactFindings, dungeons: compactDungeons };
|
||
await Promise.all([
|
||
writeFile(reportJsonPath, JSON.stringify(persistedReport, null, 2) + "\n", "utf8"),
|
||
writeFile(reportMarkdownPath, makeMarkdown(report), "utf8"),
|
||
]);
|
||
console.log(JSON.stringify({
|
||
registered: report.summary.registeredDungeons,
|
||
audited: report.summary.auditedDungeons,
|
||
shippedTextures: report.summary.shippedTextures,
|
||
matchedSourceTextures: report.summary.matchedSourceTextures,
|
||
verifiedSourcePixelCrops: report.summary.verifiedSourcePixelCrops,
|
||
downsizedTextures: report.summary.downsizedTextures,
|
||
capHitsAt2048: report.summary.capHitsAt2048,
|
||
pixelComparedTextures: report.summary.codecComparedTextures,
|
||
degenerateFloorAreaM2: round(report.summary.degenerateFloorAreaM2),
|
||
degenerateWallAreaM2: round(report.summary.degenerateWallAreaM2),
|
||
rankCollapsedFloorAreaM2: round(report.summary.rankCollapsedFloorAreaM2),
|
||
rankCollapsedWallAreaM2: round(report.summary.rankCollapsedWallAreaM2),
|
||
issues: report.issues.length,
|
||
json: projectPath(reportJsonPath),
|
||
markdown: projectPath(reportMarkdownPath),
|
||
}, null, 2));
|
||
if (report.issues.length) process.exitCode = 1;
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
await main();
|
||
}
|