import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { verifiedRuneWakerParticleModels } from "./rom-particle-readiness.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const resourceRoot = path.resolve(process.env.RUNEWAKER_RESOURCE_ROOT ?? path.join(root, "../Runewaker/Resource")); const exporter = path.resolve(process.env.RUNEWAKER_EXPORTER ?? path.join(root, "scripts/runewaker-pipeline/native/bin/runewaker_model_exporter.exe")); const installedTexconv = path.join( process.env.LOCALAPPDATA ?? "", "Microsoft/WinGet/Packages/Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe/texconv.exe", ); const stagedTexconv = path.join(root, "runewaker-export-work/texconv.exe"); const texconv = path.resolve(process.env.TEXCONV_BIN ?? ( fs.existsSync(stagedTexconv) ? stagedTexconv : installedTexconv )); const shaderSourceFile = path.resolve(process.env.RUNEWAKER_SHADER_SOURCE ?? path.join( root, "../Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp", )); const outputRoot = path.join(root, "public/assets/game/combat-effects/original/runewaker"); const graphsDir = path.join(outputRoot, "graphs"); const texturesDir = path.join(outputRoot, "textures"); const shadersDir = path.join(outputRoot, "shaders"); const limitArg = process.argv.find((arg) => arg.startsWith("--limit=")); const limit = limitArg ? Number(limitArg.slice(8)) : Number.POSITIVE_INFINITY; if (!(limit > 0)) throw new Error("--limit must be a positive number"); for (const dependency of [resourceRoot, exporter, texconv, shaderSourceFile]) { if (!fs.existsSync(dependency)) throw new Error(`Required RuneWaker conversion dependency missing: ${dependency}`); } const manifest = JSON.parse(fs.readFileSync(path.join(root, "public/assets/game/combat-effects/manifest.json"), "utf8")); const catalog = JSON.parse(fs.readFileSync(path.join(root, "src/game/romPlayerCatalog.generated.json"), "utf8")); const cohorts = [ ["rom-priest", null], ["rom-priest", "rom-knight"], ["rom-knight", null], ["rom-knight", "rom-scout"], ["rom-scout", null], ["rom-scout", "rom-rogue"], ["rom-rogue", null], ["rom-rogue", "rom-warrior"], ["rom-mage", null], ["rom-mage", "rom-warlock"], ]; const spellIds = new Set(catalog.skills.filter((skill) => !skill.passive && cohorts.some( ([primary, secondary]) => skill.classId === primary && (skill.secondaryClassId ?? null) === secondary, )).flatMap((skill) => [skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])); const models = [...new Set(manifest.entries.filter((entry) => ( entry.source === "runewaker" && spellIds.has(entry.spellId) )).flatMap((entry) => Object.values(entry.phases ?? {}).flat().map((asset) => asset.model).filter(Boolean)))].sort(); const selectedModels = models.slice(0, limit); fs.mkdirSync(graphsDir, { recursive: true }); fs.mkdirSync(texturesDir, { recursive: true }); fs.mkdirSync(shadersDir, { recursive: true }); function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } function extractShader(source, variable, expectedMain) { const marker = `char *${variable} = "\\`; const start = source.indexOf(marker); if (start < 0) throw new Error(`Rune shader source has no ${variable}`); const bodyStart = start + marker.length; const end = source.indexOf('\n\t";', bodyStart); if (end < 0) throw new Error(`Rune shader source has no end for ${variable}`); const raw = source.slice(bodyStart, end).replace(/^\r?\n/, ""); const decoded = raw.replace(/\\n\\\r?\n/g, "\n").replace(/[ \t]+$/gm, "").replace(/\\n\\$/, "").trim(); if (!decoded.includes(expectedMain)) throw new Error(`Rune shader ${variable} main signature missing`); return `${decoded}\n`; } const shaderSourceBytes = fs.readFileSync(shaderSourceFile); const shaderSource = shaderSourceBytes.toString("utf8").replace(/\r\n/g, "\n"); const shaderSourceSha256 = sha256(shaderSourceBytes); const shaderAssets = { vertex: extractShader(shaderSource, "vsSource", "VS_OUTPUT main"), pixel: extractShader(shaderSource, "psSource", "PS_OUTPUT main"), }; for (const [stage, source] of Object.entries(shaderAssets)) { fs.writeFileSync(path.join(shadersDir, `particle-spriteex.${stage}.hlsl`), source); } function sourceFile(resourcePath) { if (!/^[\w./\\ -]+$/.test(resourcePath)) throw new Error(`Unsafe resource path: ${resourcePath}`); const resolved = path.resolve(resourceRoot, resourcePath.replaceAll("\\", path.sep)); if (!resolved.toLowerCase().startsWith(`${resourceRoot.toLowerCase()}${path.sep}`)) { throw new Error(`Resource path escapes root: ${resourcePath}`); } return resolved; } const textureCache = new Map(); const HOLY_CAST_MODEL = "model/fx/skill/holy/act_casting_c_hand.ros"; const HOLY_CAST_SOUND = "sound/fx/skill/holy/act_casting_c_hand.wav"; function visualOnlyHolyCastTrigger(model, motion, controller) { return model === HOLY_CAST_MODEL && motion.name === "appear" && controller.type === "unsupported" && controller.sourceType === "CRuController_EventTrigger" && controller.eventID === 43 // ruENTITYEVENT_AUDIO_3D_DIRECT && controller.eventParameter0 === 1 // Loop during cast. && controller.eventParameter1 === 0 && controller.eventString?.replaceAll("\\", "/").toLowerCase() === HOLY_CAST_SOUND && controller.startTime === 0 && Number.isFinite(controller.eventDuration) && controller.eventDuration > 0; } function packageTexture(resourcePath, scratch) { const normalized = resourcePath.replaceAll("\\", "/").toLowerCase(); if (textureCache.has(normalized)) return textureCache.get(normalized); const input = sourceFile(resourcePath); if (!fs.existsSync(input)) throw new Error(`Missing source texture: ${resourcePath}`); const sourceSha256 = sha256(fs.readFileSync(input)); const fileName = `${sourceSha256.slice(0, 24)}.png`; const destination = path.join(texturesDir, fileName); if (!fs.existsSync(destination)) { const convertedDir = path.join(scratch, "converted"); fs.mkdirSync(convertedDir, { recursive: true }); execFileSync(texconv, ["-ft", "PNG", "-o", convertedDir, "-y", "--permissive", input], { cwd: root, stdio: "pipe", maxBuffer: 8 * 1024 * 1024, }); const converted = path.join(convertedDir, `${path.parse(input).name}.png`); if (!fs.existsSync(converted)) throw new Error(`texconv produced no PNG: ${resourcePath}`); fs.copyFileSync(converted, destination); } const asset = { sourcePath: resourcePath.replaceAll("\\", "/"), sourceSha256, url: `/assets/game/combat-effects/original/runewaker/textures/${fileName}`, }; textureCache.set(normalized, asset); return asset; } const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "healer-rom-fx-")); const results = []; try { for (const model of selectedModels) { const result = { model, status: "failed", graphUrl: null, rendererReady: false, spriteControllers: 0, textures: 0, unsupported: [], errors: [] }; results.push(result); try { const binary = fs.readFileSync(sourceFile(model)); const sourceSha256 = sha256(binary); const reportFile = path.join(scratch, "fx-report.json"); execFileSync(exporter, ["--fx-report", resourceRoot, model, reportFile], { cwd: root, stdio: "pipe", maxBuffer: 8 * 1024 * 1024, }); const decoded = JSON.parse(fs.readFileSync(reportFile, "utf8")); if (decoded.schemaVersion !== 2 || !Array.isArray(decoded.motions)) { throw new Error(`Unexpected native particle report schema for ${model}`); } const textures = new Map(); for (const motion of decoded.motions) for (const controller of motion.controllers) { if (controller.type === "unsupported") result.unsupported.push(controller.sourceType); if (controller.type !== "sprite") continue; result.spriteControllers++; if (!controller.texture) { result.errors.push(`Sprite controller ${controller.anchor} has no texture`); continue; } try { const texture = packageTexture(controller.texture, scratch); controller.textureAssetUrl = texture.url; textures.set(texture.sourcePath.toLowerCase(), texture); } catch (error) { result.errors.push(String(error.message ?? error)); } } if (result.spriteControllers === 0) result.errors.push("No sprite controllers decoded"); result.unsupported.push(...decoded.unsupported); result.textures = textures.size; result.status = result.errors.length || result.unsupported.length ? "partial" : "decoded"; const unsupportedControllers = decoded.motions.flatMap((motion) => motion.controllers .filter((controller) => controller.type === "unsupported") .map((controller) => ({ motion, controller }))); // The holy casting graph's sole non-particle controller is a 3D WAV // callback. Its sprite layers are complete; audio is packaged separately. const audioOnlyTrigger = unsupportedControllers.length === 1 && unsupportedControllers.every(({ motion, controller }) => ( visualOnlyHolyCastTrigger(model, motion, controller) )) && result.unsupported.every((type) => type === "CRuController_EventTrigger"); result.rendererReady = verifiedRuneWakerParticleModels.has(model) && result.errors.length === 0 && (result.unsupported.length === 0 || audioOnlyTrigger); const graph = { schemaVersion: 1, kind: "runewaker-ros-particle-archive", source: "runewaker", sourceModel: model, sourceSha256, coordinateTransform: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", rendererReady: result.rendererReady, shader: { name: "ruSHADER_PARTICLE_SPRITEEX", sourceImplementation: "Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SMUNI/RuShader_SMUNI_Particle.cpp", sourceSha256: shaderSourceSha256, vertexSourceUrl: "/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.vertex.hlsl", pixelSourceUrl: "/assets/game/combat-effects/original/runewaker/shaders/particle-spriteex.pixel.hlsl", target: "Direct3D 9 source; WebGL sprite port for verified models", twoSided: true, fog: false, premultipliedOutput: true, blendModes: { 0: "source-alpha / one-minus-source-alpha", 1: "source-alpha / one", 2: "zero / one-minus-source-alpha", 3: "one / one-minus-source-alpha", 4: "one / one", 5: "one / zero", }, }, textures: [...textures.values()], motions: decoded.motions, sprites: decoded.sprites, unsupported: [...new Set(result.unsupported)], missing: result.errors, }; const graphJson = JSON.stringify(graph); const graphName = `${sha256(Buffer.from(graphJson)).slice(0, 24)}.json`; fs.writeFileSync(path.join(graphsDir, graphName), `${graphJson}\n`); result.graphUrl = `/assets/game/combat-effects/original/runewaker/graphs/${graphName}`; } catch (error) { result.errors.push(String(error.message ?? error)); } } } finally { if (!scratch.startsWith(`${os.tmpdir()}${path.sep}`)) throw new Error(`Unexpected scratch path: ${scratch}`); fs.rmSync(scratch, { recursive: true, force: true }); } const index = { schemaVersion: 1, kind: "runewaker-ros-particle-archive-index", scope: "primary-five-base-and-accepted-elite-pairs", requestedModels: models.length, processedModels: selectedModels.length, decodedModels: results.filter((result) => result.status === "decoded").length, partialModels: results.filter((result) => result.status === "partial").length, failedModels: results.filter((result) => result.status === "failed").length, results, }; fs.writeFileSync(path.join(outputRoot, "manifest.json"), `${JSON.stringify(index, null, 2)}\n`); if (selectedModels.length === models.length && index.failedModels === 0) { const activeGraphs = new Set(results.map((result) => path.basename(result.graphUrl))); for (const file of fs.readdirSync(graphsDir)) { if (/^[a-f0-9]{24}\.json$/.test(file) && !activeGraphs.has(file)) fs.unlinkSync(path.join(graphsDir, file)); } const activeTextures = new Set(results.flatMap((result) => { const graph = JSON.parse(fs.readFileSync(path.join(root, "public", result.graphUrl.slice(1)), "utf8")); return graph.textures.map((texture) => path.basename(texture.url)); })); for (const file of fs.readdirSync(texturesDir)) { if (/^[a-f0-9]{24}\.png$/.test(file) && !activeTextures.has(file)) fs.unlinkSync(path.join(texturesDir, file)); } } console.log(JSON.stringify({ requestedModels: index.requestedModels, processedModels: index.processedModels, decodedModels: index.decodedModels, partialModels: index.partialModels, failedModels: index.failedModels, textures: fs.readdirSync(texturesDir).length })); if (index.failedModels) process.exitCode = 1;