268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import sharp from "sharp";
|
|
import {
|
|
alphaMaterialMatches,
|
|
effectiveAlphaMaterialRole,
|
|
preparedAlphaTextureFilename,
|
|
} from "./lib/native-alpha-audit-helpers.mjs";
|
|
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work");
|
|
const shippingRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
|
|
const resourceCacheRoot = path.join(workRoot, "resource-cache");
|
|
const requestedSlugs = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
|
|
const fix = process.argv.includes("--fix");
|
|
const alphaClassifications = new Map();
|
|
let resourceTextureIndex;
|
|
|
|
async function exists(file) {
|
|
try {
|
|
await stat(file);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function parseGlb(buffer, file) {
|
|
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
|
throw new Error(`${file} is not a GLB file.`);
|
|
}
|
|
const chunks = [];
|
|
let json;
|
|
let offset = 12;
|
|
while (offset + 8 <= buffer.length) {
|
|
const chunkLength = buffer.readUInt32LE(offset);
|
|
const chunkType = buffer.readUInt32LE(offset + 4);
|
|
const chunkStart = offset + 8;
|
|
const chunkEnd = chunkStart + chunkLength;
|
|
if (chunkEnd > buffer.length) break;
|
|
const data = buffer.subarray(chunkStart, chunkEnd);
|
|
chunks.push({ type: chunkType, data });
|
|
if (chunkType === 0x4e4f534a) {
|
|
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
|
|
}
|
|
offset = chunkEnd;
|
|
}
|
|
if (!json) throw new Error(`${file} does not contain a JSON chunk.`);
|
|
return { json, chunks };
|
|
}
|
|
|
|
function serializeGlb(source, json, chunks) {
|
|
const encodedJson = Buffer.from(JSON.stringify(json));
|
|
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
|
|
encodedJson.copy(paddedJson);
|
|
const updatedChunks = chunks.map((chunk) => (
|
|
chunk.type === 0x4e4f534a ? { ...chunk, data: paddedJson } : chunk
|
|
));
|
|
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
|
|
const output = Buffer.alloc(totalLength);
|
|
source.copy(output, 0, 0, 12);
|
|
output.writeUInt32LE(totalLength, 8);
|
|
let offset = 12;
|
|
for (const chunk of updatedChunks) {
|
|
output.writeUInt32LE(chunk.data.length, offset);
|
|
output.writeUInt32LE(chunk.type, offset + 4);
|
|
chunk.data.copy(output, offset + 8);
|
|
offset += 8 + chunk.data.length;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function classifyAlpha(file) {
|
|
if (alphaClassifications.has(file)) return alphaClassifications.get(file);
|
|
const metadata = await sharp(file).metadata();
|
|
if (!metadata.hasAlpha) {
|
|
alphaClassifications.set(file, "opaque");
|
|
return "opaque";
|
|
}
|
|
|
|
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
let hasTransparentPixel = false;
|
|
for (let offset = 3; offset < data.length; offset += info.channels) {
|
|
const alpha = data[offset];
|
|
if (alpha === 255) continue;
|
|
hasTransparentPixel = true;
|
|
if (alpha > 0) {
|
|
alphaClassifications.set(file, "blend");
|
|
return "blend";
|
|
}
|
|
}
|
|
const role = hasTransparentPixel ? "cutout" : "opaque";
|
|
alphaClassifications.set(file, role);
|
|
return role;
|
|
}
|
|
|
|
function sourceImageIndex(texture) {
|
|
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
|
|
}
|
|
|
|
async function indexResourceTextures() {
|
|
if (resourceTextureIndex) return resourceTextureIndex;
|
|
resourceTextureIndex = new Map();
|
|
if (!(await exists(resourceCacheRoot))) return resourceTextureIndex;
|
|
const cacheDirectories = (await readdir(resourceCacheRoot, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory())
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
for (const cacheDirectory of cacheDirectories) {
|
|
const textureDirectory = path.join(resourceCacheRoot, cacheDirectory.name, "prepared", "textures");
|
|
if (!(await exists(textureDirectory))) continue;
|
|
for (const texture of (await readdir(textureDirectory)).filter((file) => file.endsWith(".png")).sort()) {
|
|
if (!resourceTextureIndex.has(texture)) {
|
|
resourceTextureIndex.set(texture, path.join(textureDirectory, texture));
|
|
}
|
|
}
|
|
}
|
|
return resourceTextureIndex;
|
|
}
|
|
|
|
async function resolveSourceTexture(localDirectory, imageName) {
|
|
const fileName = preparedAlphaTextureFilename(imageName);
|
|
const localFile = path.join(localDirectory, fileName);
|
|
if (await exists(localFile)) return localFile;
|
|
return (await indexResourceTextures()).get(fileName);
|
|
}
|
|
|
|
function sha256(buffer) {
|
|
return createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
async function refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer) {
|
|
visualArtifact.size = visualBuffer.length;
|
|
visualArtifact.sha256 = sha256(visualBuffer);
|
|
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
|
|
const optimizationFile = path.join(path.dirname(metadataFile), "optimization-report.json");
|
|
if (!(await exists(optimizationFile))) return;
|
|
const optimization = JSON.parse(await readFile(optimizationFile, "utf8"));
|
|
if (optimization.optimized) {
|
|
optimization.optimized.size = visualBuffer.length;
|
|
if (Object.hasOwn(optimization.optimized, "checksum")) {
|
|
optimization.optimized.checksum = sha256(visualBuffer);
|
|
}
|
|
}
|
|
await writeFile(optimizationFile, `${JSON.stringify(optimization, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function auditDungeon(recipe) {
|
|
const slug = recipe.slug;
|
|
const metadataFile = path.join(shippingRoot, slug, "import-metadata.json");
|
|
const preparedTextureDirectory = path.join(workRoot, slug, "prepared", "textures");
|
|
if (!(await exists(metadataFile))) {
|
|
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, metadataFile)}`] };
|
|
}
|
|
if (!(await exists(preparedTextureDirectory))) {
|
|
return {
|
|
slug,
|
|
status: "incomplete",
|
|
problems: [`Missing ${path.relative(projectRoot, preparedTextureDirectory)}`],
|
|
};
|
|
}
|
|
|
|
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
|
|
const visualArtifact = (metadata.assets ?? metadata.artifacts ?? [])
|
|
.find((artifact) => artifact.role === "visual");
|
|
const visualFile = path.join(shippingRoot, slug, visualArtifact?.fileName ?? `${slug}-visual.glb`);
|
|
if (!(await exists(visualFile))) {
|
|
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, visualFile)}`] };
|
|
}
|
|
|
|
const sourceGlb = await readFile(visualFile);
|
|
const { json: gltf, chunks } = parseGlb(sourceGlb, visualFile);
|
|
const materialResults = [];
|
|
const missingTextures = [];
|
|
for (const [materialIndex, material] of (gltf.materials ?? []).entries()) {
|
|
const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
|
const texture = gltf.textures?.[textureIndex];
|
|
const image = gltf.images?.[sourceImageIndex(texture)];
|
|
if (!image?.name) continue;
|
|
|
|
const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name);
|
|
if (!sourceFile) {
|
|
missingTextures.push(preparedAlphaTextureFilename(image.name));
|
|
continue;
|
|
}
|
|
const expectedRole = await classifyAlpha(sourceFile);
|
|
const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE";
|
|
const actualAlphaMode = material.alphaMode ?? "OPAQUE";
|
|
const actualRole = effectiveAlphaMaterialRole(material.extras?.semanticRole, actualAlphaMode);
|
|
materialResults.push({
|
|
materialIndex,
|
|
material: material.name ?? `material-${materialIndex}`,
|
|
texture: image.name,
|
|
expectedRole,
|
|
expectedAlphaMode,
|
|
actualRole,
|
|
actualAlphaMode,
|
|
matches: alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole),
|
|
});
|
|
if (fix && !alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole)) {
|
|
if (expectedAlphaMode === "OPAQUE") delete material.alphaMode;
|
|
else material.alphaMode = expectedAlphaMode;
|
|
if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5;
|
|
else delete material.alphaCutoff;
|
|
material.extras = { ...(material.extras ?? {}), semanticRole: expectedRole };
|
|
}
|
|
}
|
|
|
|
const mismatches = materialResults.filter((result) => !result.matches);
|
|
const alphaMaterials = materialResults.filter((result) => result.expectedRole !== "opaque");
|
|
const problems = [
|
|
...mismatches.map((result) =>
|
|
`${result.texture}: expected ${result.expectedAlphaMode}/${result.expectedRole}, got ${result.actualAlphaMode}/${result.actualRole}`,
|
|
),
|
|
...[...new Set(missingTextures)].map((file) => `Missing prepared source texture ${file}`),
|
|
];
|
|
if (fix && mismatches.length > 0 && missingTextures.length === 0) {
|
|
const visualBuffer = serializeGlb(sourceGlb, gltf, chunks);
|
|
await writeFile(visualFile, visualBuffer);
|
|
if (visualArtifact) {
|
|
await refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer);
|
|
}
|
|
}
|
|
return {
|
|
slug,
|
|
status: problems.length === 0 ? "green" : fix && missingTextures.length === 0 ? "repaired" : "mismatch",
|
|
materials: materialResults.length,
|
|
alphaMaterials: alphaMaterials.map(({ material, texture, expectedAlphaMode }) => ({
|
|
material,
|
|
texture,
|
|
alphaMode: expectedAlphaMode,
|
|
})),
|
|
mismatches,
|
|
problems,
|
|
};
|
|
}
|
|
|
|
const recipeFiles = (await readdir(recipeDirectory))
|
|
.filter((file) => file.endsWith(".json") && !file.endsWith("-population.json"))
|
|
.sort();
|
|
const recipes = [];
|
|
for (const recipeFile of recipeFiles) {
|
|
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeFile), "utf8"));
|
|
if (!recipe.slug || recipe.slug === "runewaker-player-avatars") continue;
|
|
if (requestedSlugs.size > 0 && !requestedSlugs.has(recipe.slug)) continue;
|
|
recipes.push(recipe);
|
|
}
|
|
|
|
const results = [];
|
|
for (const recipe of recipes) {
|
|
results.push(await auditDungeon(recipe));
|
|
}
|
|
|
|
const failures = results.filter((result) => !["green", "repaired"].includes(result.status));
|
|
const report = {
|
|
schemaVersion: 1,
|
|
dungeonCount: results.length,
|
|
status: failures.length === 0 ? fix ? "repaired" : "green" : "mismatch",
|
|
failureCount: failures.length,
|
|
alphaDungeonCount: results.filter((result) => result.alphaMaterials?.length > 0).length,
|
|
results,
|
|
};
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (failures.length > 0) process.exitCode = 1;
|