import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFile, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs"; import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs"; import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs"; import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs"; import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs"; import { projectRoot, readJson } from "./lib/recipe.mjs"; const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes"); const workRoot = path.join(projectRoot, "runewaker-export-work"); const assetRoot = path.join(projectRoot, "src", "assets", "game", "dungeons"); const gltfTransformCli = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js"); const ktx2Compressor = path.join(projectRoot, "scripts", "runewaker-pipeline", "compress-environment-textures.mjs"); const args = process.argv.slice(2); const selectedSlug = args.includes("--slug") ? args[args.indexOf("--slug") + 1] : null; const applyStaged = args.includes("--apply-staged"); function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function run(command, commandArgs, label) { return new Promise((resolve, reject) => { const child = spawn(command, commandArgs, { cwd: projectRoot, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", (chunk) => process.stdout.write(chunk)); child.stderr.on("data", (chunk) => process.stderr.write(chunk)); child.once("error", reject); child.once("exit", (code) => { if (code === 0) resolve(); else reject(new Error(`${label} exited with ${code}.`)); }); }); } async function collectDungeons() { const recipeFiles = (await readdir(recipeDirectory, { withFileTypes: true })) .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith("-population.json")) .map((entry) => path.join(recipeDirectory, entry.name)); const recipes = []; for (const recipeFile of recipeFiles) { const recipe = await readJson(recipeFile); if (recipe.slug && recipe.source?.model && recipe.source?.wdb) recipes.push(recipe); } recipes.sort((left, right) => left.slug.localeCompare(right.slug)); const expectedRecipes = selectedSlug ? recipes.filter((recipe) => recipe.slug === selectedSlug) : recipes; if (selectedSlug && !expectedRecipes.length) { throw new Error(`No converted and shipped environment found for ${selectedSlug}.`); } const dungeons = []; for (const recipe of expectedRecipes) { const slug = recipe.slug; const convertedDirectory = path.join(workRoot, slug, "converted"); const source = path.join(convertedDirectory, `${slug}-visual.glb`); const metadataFile = path.join(assetRoot, slug, "import-metadata.json"); try { await stat(source); await stat(metadataFile); } catch (error) { throw new Error(`Expected RoM environment ${slug} is incomplete: ${error.message}`); } dungeons.push({ slug, recipe, convertedDirectory, source, metadataFile }); } if (!dungeons.length) throw new Error("No RoM environment recipes were found."); return dungeons; } async function stageDungeon(dungeon, index, count) { const { slug, convertedDirectory, source } = dungeon; const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`); const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json"); let sourceReport; if (dungeon.recipe.visualUvAdaptationPolicies?.length || dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.derivedSupportStylingPolicy) { sourceReport = await readJson(path.join(workRoot, slug, "prepared", "export-report.json")); sourceReport.sourceResourceSha256 ??= (await readJson(dungeon.metadataFile)).source?.modelSha256; } const visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(source, { dungeonId: slug, policyIds: dungeon.recipe.visualUvAdaptationPolicies ?? [], surfaceQualityPolicyIds: dungeon.recipe.visualSurfaceQualityPolicies ?? [], sourceReport, }); const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === dungeon.recipe.derivedSupportStylingPolicy); const derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile(source, { dungeonId: slug, policyId: dungeon.recipe.derivedSupportStylingPolicy, sourceReport, sourceDdsFile: supportPolicy ? path.join(workRoot, slug, "raw", "textures", supportPolicy.stagedTexture) : null, }); const nativeMaterialSemantics = await applyNativeMaterialSemantics(source); const sourceSize = (await stat(source)).size; await rm(stagedVisual, { force: true }); console.log(`\n[${index}/${count}] Optimizing ${slug} from converted geometry (simplification disabled).`); await run( process.execPath, [gltfTransformCli, ...environmentOptimizationArguments(source, stagedVisual, { preserveNativeWaterLayers: Boolean(dungeon.recipe.source?.nativeWater), preserveMaterialVariantCarriers: Boolean(dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.visualUvAdaptationPolicies?.length), })], `${slug} glTF optimization`, ); await run( process.execPath, [ktx2Compressor, stagedVisual, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.reoptimized.json")], `${slug} KTX2 texture compression`, ); const optimizedBytes = await readFile(stagedVisual); const report = { schemaVersion: 1, dungeonId: slug, status: "review-required", source: { file: path.basename(source), size: sourceSize }, optimized: { file: `${slug}-visual.optimized.glb`, size: optimizedBytes.length, checksum: sha256(optimizedBytes), }, compression: "meshopt+ktx2", commands: [ "node scripts/runewaker-pipeline/lib/native-material-semantics.mjs (before optimization)", "gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false", "node scripts/runewaker-pipeline/compress-environment-textures.mjs --concurrency 1 --jobs 2", ], textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.reoptimized.json"), "utf8")), nativeMaterialSemantics, visualUvAdaptations, derivedSupportStyling, }; await writeFile(stagedOptimizationReport, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(`[${index}/${count}] Staged ${slug}: ${optimizedBytes.length} bytes, SHA-256 ${report.optimized.checksum}.`); return { ...dungeon, stagedVisual, stagedOptimizationReport }; } async function validateStagedDungeon(dungeon) { const { slug, convertedDirectory } = dungeon; const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`); const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json"); const stagedBytes = await readFile(stagedVisual); const stagedReport = JSON.parse(await readFile(stagedOptimizationReport, "utf8")); if (stagedReport.dungeonId !== slug || stagedReport.optimized?.size !== stagedBytes.length || stagedReport.optimized?.checksum !== sha256(stagedBytes)) { throw new Error(`${slug} staged visual and optimization report do not match.`); } return { ...dungeon, stagedVisual, stagedOptimizationReport, stagedReport, stagedBytes }; } async function preparePackage(dungeon) { const { slug, convertedDirectory, stagedVisual, stagedOptimizationReport, metadataFile, stagedBytes, } = dungeon; const shippingDirectory = path.dirname(metadataFile); const shippingVisual = path.join(shippingDirectory, `${slug}-visual.glb`); const shippingReport = path.join(shippingDirectory, "optimization-report.json"); const metadata = JSON.parse(await readFile(metadataFile, "utf8")); const visualEntry = metadata.assets?.find((entry) => entry.role === "visual" && entry.fileName === path.basename(shippingVisual)); if (!visualEntry) throw new Error(`${slug} import metadata has no matching visual asset record.`); const stagedOptimized = path.join(convertedDirectory, `${slug}-visual.optimized.pending.glb`); const stagedShipping = path.join(shippingDirectory, `.${slug}-visual.pending.glb`); const stagedShippingReport = path.join(shippingDirectory, ".optimization-report.pending.json"); const stagedMetadata = `${metadataFile}.pending`; await rm(stagedOptimized, { force: true }); await rm(stagedShipping, { force: true }); await rm(stagedShippingReport, { force: true }); await rm(stagedMetadata, { force: true }); visualEntry.size = stagedBytes.length; visualEntry.sha256 = sha256(stagedBytes); metadata.generatedAt = new Date().toISOString(); await copyFile(stagedVisual, stagedOptimized); await copyFile(stagedVisual, stagedShipping); await copyFile(stagedOptimizationReport, stagedShippingReport); await writeFile(stagedMetadata, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); return { ...dungeon, stagedOptimized, stagedShipping, stagedShippingReport, stagedMetadata, shippingVisual, shippingReport, metadataFile, }; } async function applyPackage(dungeon) { const { slug, convertedDirectory, stagedVisual, stagedOptimizationReport, stagedOptimized, stagedShipping, stagedShippingReport, stagedMetadata, shippingVisual, shippingReport, metadataFile, } = dungeon; await rename(stagedOptimized, path.join(convertedDirectory, `${slug}-visual.optimized.glb`)); await rename(stagedShipping, shippingVisual); await rename(stagedShippingReport, shippingReport); await rename(stagedMetadata, metadataFile); await rm(stagedVisual, { force: true }); await rm(stagedOptimizationReport, { force: true }); console.log(`[package] ${slug}: visual checksum/size and optimization report refreshed.`); } const dungeons = await collectDungeons(); console.log(`[runewaker-reoptimize] ${dungeons.length} environment visual(s) selected.`); let staged; if (applyStaged) { staged = []; for (const dungeon of dungeons) staged.push(await validateStagedDungeon(dungeon)); const preparedPackages = []; for (const dungeon of staged) preparedPackages.push(await preparePackage(dungeon)); for (const dungeon of preparedPackages) await applyPackage(dungeon); console.log(`[runewaker-reoptimize] Applied ${staged.length} staged visuals without re-encoding textures.`); } else { staged = []; for (const [index, dungeon] of dungeons.entries()) { staged.push(await stageDungeon(dungeon, index + 1, dungeons.length)); } console.log(`[runewaker-reoptimize] Staged ${staged.length} corrected visuals. After the audit baseline is frozen, re-run with --apply-staged to replace shipped visuals.`); }