Files
2026-09-25 18:46:09 -04:00

531 lines
28 KiB
JavaScript

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;