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"; const ICE_CAST_MODEL = "model/fx/skill/ice/act_icecone01_c_hand.ros"; const ICE_RELEASE_MODEL = "model/fx/skill/ice/act_icecone01_f.ros"; const RIPTIDE_IMPACT_MODEL = "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"; const URGENT_IMPACT_MODEL = "model/fx/skill/holy/act_benedictory_heal_t_down.ros"; const URGENT_RUNE_MODEL = "model/fx/skill/holy/_polygon/rune_cylinder.ros"; const URGENT_RUNE_TEXTURE = "texture/fx/skill/rune.dds"; const URGENT_DECAL_TEXTURE = "texture/fx/skill/kan_rune_circle.dds"; function normalizedEventString(controller) { return controller.eventString?.replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase(); } // RuneWaker's event manager routes these callbacks outside the particle // renderer. IDs 25 (effect trigger) and 33 (projectile flight) affect visuals // and intentionally remain blocked until their behavior is implemented. function visualOnlySourceTrigger(controller) { if (controller.type !== "unsupported" || controller.sourceType !== "CRuController_EventTrigger" || typeof controller.anchor !== "string" || !Number.isFinite(controller.startTime) || controller.startTime < 0 || !Number.isFinite(controller.eventDuration) || controller.eventDuration < 0 || !Number.isInteger(controller.eventParameter0) || !Number.isInteger(controller.eventParameter1)) return false; const eventString = normalizedEventString(controller); if ([1, 50, 61].includes(controller.eventID)) { return controller.eventParameter0 === 0 && controller.eventParameter1 === 0 && eventString === ""; } if ([41, 43].includes(controller.eventID)) { return [0, 1].includes(controller.eventParameter0) && /^sound\/fx\/[\w./ -]+\.(?:mp3|wav|ogg)$/.test(eventString) && !eventString.includes(".."); } return controller.eventID === 44 && controller.eventParameter0 === 1 && controller.eventParameter1 === 0 && eventString === "missile"; } 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 && normalizedEventString(controller) === HOLY_CAST_SOUND && controller.startTime === 0 && Number.isFinite(controller.eventDuration) && controller.eventDuration > 0; } function visualOnlyRisingTideTrigger(model, motion, controller) { if (controller.type !== "unsupported" || controller.sourceType !== "CRuController_EventTrigger" || controller.startTime !== 0 || controller.eventParameter1 !== 0) return false; const eventString = normalizedEventString(controller); if (model === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) { return controller.eventID === 43 && controller.eventParameter0 === 1 && eventString === "sound/fx/skill/ice/act_cone01_c_hand.mp3" && Number.isFinite(controller.eventDuration) && controller.eventDuration > 0; } if (model === ICE_RELEASE_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) { return controller.eventID === 44 && controller.eventParameter0 === 1 && eventString === "missile" && controller.eventDuration === 1 || motion.name === "appear" && controller.eventID === 43 && controller.eventParameter0 === 0 && controller.eventDuration === 0 && eventString === "sound/fx/skill/ice/icenew/act_icecone01_f.ros.mp3"; } if (model === RIPTIDE_IMPACT_MODEL && motion.name === "explode") { // The source HIT callback is handled by combat; it contributes no visual geometry. return controller.eventID === 1 && controller.eventParameter0 === 0 && controller.eventDuration === 0 && eventString === "" || controller.eventID === 43 && controller.eventParameter0 === 0 && controller.eventDuration === 0 && eventString === "sound/fx/skill/ice/act_riptide_t_freeze_hit.mp3"; } return false; } function verifiedGlobalAlpha(model, motion, controller) { if (controller.type !== "global-alpha" || controller.sourceType !== "CRuController_GlobalAlpha" || controller.startTime !== 0 || !Number.isFinite(controller.duration) || !Array.isArray(controller.alphaTrack) || !controller.alphaTrack.every((key) => Number.isFinite(key.time) && Number.isFinite(key.value) && key.value >= 0 && key.value <= 1)) return false; if (model === ICE_CAST_MODEL && ["idle", "appear", "disappear"].includes(motion.name)) { return motion.name === "appear" ? controller.duration > 0 && controller.alphaTrack.length === 2 && controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 0 && controller.alphaTrack[1].value === 1 : controller.duration === 0 && controller.alphaTrack.length === 1 && controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 1; } if (model === URGENT_IMPACT_MODEL && motion.name === "explode") { return controller.anchor === "frame/decal" && controller.duration > 0 && controller.alphaTrack.length === 4 && controller.alphaTrack[0].value === 0 && controller.alphaTrack[1].value === 1 && controller.alphaTrack[2].value === 1 && controller.alphaTrack[3].value === 0; } if (model === RIPTIDE_IMPACT_MODEL && motion.name === "explode") { return controller.duration === 0 && controller.alphaTrack.length === 1 && controller.alphaTrack[0].time === 0 && controller.alphaTrack[0].value === 1; } const alphas = motion.controllers.filter((candidate) => candidate.type === "global-alpha"); const sprites = motion.controllers.filter((candidate) => candidate.type === "sprite"); return alphas.length === 1 && sprites.length > 0 && sprites.every((sprite) => controller.anchor === "" || sprite.anchor.startsWith(`${controller.anchor}/`)); } const expectedSpecialControllerCounts = new Map([ [HOLY_CAST_MODEL, [1, 0]], [ICE_CAST_MODEL, [3, 3]], [ICE_RELEASE_MODEL, [4, 0]], [RIPTIDE_IMPACT_MODEL, [2, 1]], ]); // The source projects onto nearby world triangles. The browser draws this // same animated texture on a target-ground plane until terrain projection is ported. const urgentOmittedVisuals = ["CRuEntity_Decal:terrain-projection"]; function verifiedUrgentPartial(model, decoded, result, unsupportedControllers, globalAlphaControllers, runeMesh) { if (model !== URGENT_IMPACT_MODEL || decoded.motions.length !== 1 || decoded.motions[0].name !== "explode" || result.spriteControllers !== 2 || globalAlphaControllers.length !== 1 || unsupportedControllers.length !== 1 || decoded.unsupported.length !== 1 || decoded.unsupported[0] !== "CRuEntity_Particle_Visualizer_Entity" || runeMesh?.sourceModel !== URGENT_RUNE_MODEL || runeMesh.sourceTexture !== URGENT_RUNE_TEXTURE || runeMesh.positions.length !== 72 * 9 || runeMesh.uvs.length !== 72 * 6) return false; const controllers = decoded.motions[0].controllers; const sprites = controllers.filter((controller) => controller.type === "sprite"); const emitters = controllers.filter((controller) => controller.type === "emitter"); const entity = controllers.find((controller) => controller.type === "entity-visualizer"); const decal = controllers.find((controller) => controller.type === "decal"); const color = controllers.find((controller) => controller.type === "global-color"); const expectedSprites = new Map([ ["frame/holy_line", "texture/fx/skill/nor_phy_smashsharp.dds"], ["frame/star", "texture/fx/skill/nor_speedupstar.dds"], ]); if (sprites.length !== 2 || emitters.length !== 3 || !sprites.every((sprite) => expectedSprites.get(sprite.anchor) === sprite.texture?.replaceAll("\\", "/").toLowerCase()) || !["frame/holy_line", "frame/star", "frame/rune"].every((anchor) => emitters.some((emitter) => emitter.anchor === anchor))) return false; const trigger = unsupportedControllers[0].controller; if (trigger.sourceType !== "CRuController_EventTrigger" || trigger.anchor !== "frame" || trigger.eventID !== 43 || trigger.eventParameter0 !== 0 || trigger.eventParameter1 !== 0 || trigger.startTime !== 0 || trigger.eventDuration !== 0 || normalizedEventString(trigger) !== "sound/fx/skill/holy/act_benedictory_heal_t_down.mp3") return false; if (entity?.anchor !== "frame/rune" || entity.sourceType !== "CRuController_Particle_Visualizer_Entity" || entity.entityName?.replaceAll("\\", "/").toLowerCase() !== URGENT_RUNE_MODEL || entity.orientToTravel !== false || entity.tracks?.length !== 4 || !entity.tracks.every((track) => Array.isArray(track) && track.length > 0) || decal?.anchor !== "frame/decal" || decal.sourceType !== "CRuController_Decal" || decal.decalTexture?.replaceAll("\\", "/").toLowerCase() !== URGENT_DECAL_TEXTURE || !decal.textureAssetUrl || decal.billboardType !== 0 || decal.tracks?.length !== 3 || decal.tracks[1]?.length !== 2 || decal.tracks[2]?.length !== 3 || color?.anchor !== "frame/decal" || color.sourceType !== "CRuController_GlobalColor" || color.tracks?.length !== 1 || color.tracks[0]?.length !== 2) return false; const alpha = globalAlphaControllers[0].controller; const unsupportedTypes = new Set(result.unsupported); return alpha.anchor === "frame/decal" && alpha.sourceType === "CRuController_GlobalAlpha" && alpha.alphaTrack?.length === 4 && alpha.startTime === 0 && unsupportedTypes.size === 1 && unsupportedTypes.has("CRuController_EventTrigger"); } 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; } function packageUrgentRuneMesh(scratch) { const directory = path.join(scratch, "urgent-rune-mesh"); fs.mkdirSync(directory, { recursive: true }); execFileSync(exporter, [resourceRoot, URGENT_RUNE_MODEL, directory], { cwd: root, stdio: "pipe", maxBuffer: 8 * 1024 * 1024, }); const report = JSON.parse(fs.readFileSync(path.join(directory, "export-report.json"), "utf8")); const material = report.materials?.[0]; if (report.stats?.meshes !== 1 || report.stats?.triangles !== 72 || report.stats?.missingTextures !== 0 || material?.resolvedTexture?.replaceAll("\\", "/").toLowerCase() !== URGENT_RUNE_TEXTURE) { throw new Error("Urgent Heal rune cylinder source mesh changed"); } const vertices = []; const textureCoordinates = []; const positions = []; const uvs = []; for (const line of fs.readFileSync(path.join(directory, "scene.obj"), "utf8").split(/\r?\n/)) { if (line.startsWith("v ")) vertices.push(line.trim().split(/\s+/).slice(1).map(Number)); else if (line.startsWith("vt ")) textureCoordinates.push(line.trim().split(/\s+/).slice(1).map(Number)); else if (line.startsWith("f ")) { const corners = line.trim().split(/\s+/).slice(1); if (corners.length !== 3) throw new Error("Urgent rune mesh has a non-triangle face"); for (const corner of corners) { const [vertexIndex, uvIndex] = corner.split("/").map(Number); const vertex = vertices[vertexIndex - 1]; const uv = textureCoordinates[uvIndex - 1]; if (vertex?.length !== 3 || uv?.length !== 2 || ![...vertex, ...uv].every(Number.isFinite)) { throw new Error("Urgent rune mesh has invalid vertex data"); } positions.push(...vertex); uvs.push(...uv); } } } if (positions.length !== 72 * 9 || uvs.length !== 72 * 6) { throw new Error("Urgent rune mesh triangle count changed"); } const texture = packageTexture(material.resolvedTexture, scratch); return { sourceModel: URGENT_RUNE_MODEL, sourceSha256: sha256(fs.readFileSync(sourceFile(URGENT_RUNE_MODEL))), sourceTexture: URGENT_RUNE_TEXTURE, textureAssetUrl: texture.url, positions, uvs, texture, }; } 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, partialRendererReady: 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(); const runeMesh = model === URGENT_IMPACT_MODEL ? packageUrgentRuneMesh(scratch) : null; if (runeMesh) textures.set(runeMesh.texture.sourcePath.toLowerCase(), runeMesh.texture); for (const motion of decoded.motions) for (const controller of motion.controllers) { if (model !== URGENT_IMPACT_MODEL && ["entity-visualizer", "decal", "global-color"].includes(controller.type)) { controller.type = "unsupported"; delete controller.tracks; delete controller.textureAssetUrl; } if (model !== URGENT_IMPACT_MODEL && controller.type === "unsupported") { delete controller.entityName; delete controller.decalTexture; delete controller.orientToTravel; delete controller.billboardType; } if (controller.type === "global-alpha" && !verifiedGlobalAlpha(model, motion, controller)) { controller.type = "unsupported"; delete controller.startTime; delete controller.duration; delete controller.alphaTrack; } if (controller.type === "unsupported" || controller.type === "global-alpha" && !verifiedGlobalAlpha(model, motion, controller)) { result.unsupported.push(controller.sourceType); } if (model === URGENT_IMPACT_MODEL && controller.type === "decal") { const texture = packageTexture(controller.decalTexture, scratch); controller.textureAssetUrl = texture.url; textures.set(texture.sourcePath.toLowerCase(), texture); } 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"); for (const motion of decoded.motions) for (const controller of motion.controllers) { if (controller.type === "emitter" && (controller.particlePath !== "" || controller.emitterPath !== "" || controller.tracks?.length !== 27 || ![0, 1, 4, 5].includes(controller.tracks?.[1]?.[0]?.value))) { result.unsupported.push("RuneWaker browser emitter geometry mode"); } if (controller.type === "sprite" && (controller.shader !== "ruSHADER_PARTICLE_SPRITEEX" || ![0, 1, 2].includes(controller.blendMode) || ![1, 2].includes(controller.billboardType) || controller.tracks?.length !== 4)) { result.unsupported.push("RuneWaker browser sprite mode"); } } result.unsupported.push(...decoded.unsupported.filter((type) => !( model === URGENT_IMPACT_MODEL && type === "CRuEntity_Particle_Visualizer_Entity" && runeMesh ))); 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 globalAlphaControllers = decoded.motions.flatMap((motion) => motion.controllers .filter((controller) => controller.type === "global-alpha") .map((controller) => ({ motion, controller }))); const [expectedTriggers, expectedAlpha] = expectedSpecialControllerCounts.get(model) ?? [0, 0]; const visualOnlyControllers = (expectedTriggers === 0 || unsupportedControllers.length === expectedTriggers) && (expectedAlpha === 0 || globalAlphaControllers.length === expectedAlpha) && unsupportedControllers.every(({ motion, controller }) => ( visualOnlyHolyCastTrigger(model, motion, controller) || visualOnlyRisingTideTrigger(model, motion, controller) || visualOnlySourceTrigger(controller) )) && globalAlphaControllers.every(({ motion, controller }) => ( verifiedGlobalAlpha(model, motion, controller) )) && result.unsupported.every((type) => type === "CRuController_EventTrigger"); result.rendererReady = result.spriteControllers > 0 && result.errors.length === 0 && decoded.unsupported.length === 0 && !runeMesh && visualOnlyControllers; result.partialRendererReady = verifiedRuneWakerParticleModels.has(model) && result.errors.length === 0 && verifiedUrgentPartial(model, decoded, result, unsupportedControllers, globalAlphaControllers, runeMesh); 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, ...(result.partialRendererReady ? { partialRendererReady: true, visualFidelity: "partial-original", omittedVisuals: urgentOmittedVisuals, } : {}), 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()], ...(runeMesh ? { runeMesh: { sourceModel: runeMesh.sourceModel, sourceSha256: runeMesh.sourceSha256, sourceTexture: runeMesh.sourceTexture, textureAssetUrl: runeMesh.textureAssetUrl, positions: runeMesh.positions, uvs: runeMesh.uvs, } } : {}), 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;