forked from phenom/healer-man
531 lines
28 KiB
JavaScript
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;
|