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;