Update Healer Man server
This commit is contained in:
1 parent
a64b2cb575
commit
71246e5b75
502 files changed
+4245
-19
No files matched your search
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
Reference in new issue
Block a user