Update Healer Man server

This commit is contained in:
phenom committed 2026-09-24 16:42:43 -04:00
1 parent a64b2cb575
commit 71246e5b75
502 files changed
+4245 -19

No files matched your search

+46
View File
@@ -48,6 +48,52 @@ Publishing a new app/content release remains the normal deployment step.
Development gameplay uses Vite's local source media directly; release builds
retain the verified media-pack download requirement.
## RuneWaker spell media mapping
`npm run effects:rom` refreshes only RuneWaker manifest entries and packages
their sound cues without rebuilding WoW effects. It reads spell-to-image links
from `romPlayerCatalog.generated.json` and `magiccollectobject.db`, effect model
paths from `imageobject.db`, and exact same-name cues under the client's
parallel `sound/fx` tree. The sound association is a path match, not an
explicit sound ID from `imageobject.db`. Each cue is tagged with its source
cast phase so a target impact does not replay a caster sound.
`npm run effects:rom:particles` packages a decoded ROS particle archive for the
five primary classes and their accepted elite pairs. Build the native exporter
with `scripts/runewaker-pipeline/native/build-exporter.ps1` first. The exporter
reads ACT motion controllers, where the texture names and particle keys live;
the static entity tree alone contains default values. Set `RUNEWAKER_RESOURCE_ROOT`,
`RUNEWAKER_EXPORTER`, or `TEXCONV_BIN` to override the default source and tool
locations. The archive index at
`public/assets/game/combat-effects/original/runewaker/manifest.json` records
decoded and unsupported controller families per model. Its graphs contain the
source shader identity, blend mode, billboard settings, particle tracks, and
PNG versions of referenced DDS textures. The archive also extracts the SMUNI
vertex and pixel HLSL from the Rune engine source with a source hash. Texture
tile sizes are pixels, not atlas row and column counts.
`npm run effects:rom:audit` writes the first-five class and accepted-pair map
to `docs/ROM_SPELL_MEDIA_MAPPING.md`. It distinguishes source ROS references,
packaged audio, and browser-playable original visual graphs. The WebGL sprite
renderer plays Priest Urgent Heal, Heal, and Group Heal casts, Knight Charge
cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The exact
spell, phase, and model allowlist lives in `rom-particle-readiness.mjs`. Other
ROS phases remain in the archive until their controller families and motion
sequence are checked in the browser. Open `scripts/combat-effects/rom-preview.html`
with the Vite dev server to inspect the decoded sprite models. The emitter
integrates source rate keys over each rendered frame so short bursts survive
slower frames. Whirlwind Shield uses a 2.5 presentation scale after live
dungeon review.
The Priest's holy `act_casting_c_hand.ros` has three renderable sprite layers
and one `CRuController_EventTrigger` in its `appear` motion. The RuneWaker
source identifies event 43 as looping direct 3D audio for
`sound/fx/skill/holy/act_casting_c_hand.wav`, not another visual layer. The
packager marks this exact model renderer-ready after validating that event's
parameters. Its packaged WAV loops only during the matching Priest cast and
stops on release or cancellation. Other unsupported event triggers still
block a graph from the ready allowlist.
## Rendering
The `wow-m2-model` format composes source particles, animated/skinned geometry,
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const particleArchivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
const particleArchive = fs.existsSync(particleArchivePath)
? JSON.parse(fs.readFileSync(particleArchivePath, "utf8")) : null;
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8"));
const entries = new Map(manifest.entries.filter((entry) => entry.source === "runewaker").map((entry) => [entry.spellId, entry]));
const cohorts = [
["Priest", "rom-priest", "rom-knight"],
["Knight", "rom-knight", "rom-scout"],
["Scout", "rom-scout", "rom-rogue"],
["Rogue", "rom-rogue", "rom-warrior"],
["Mage", "rom-mage", "rom-warlock"],
];
const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"];
function idsForSkill(skill) {
return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])]
.filter((id) => Number.isInteger(id) && id > 0);
}
function skillMedia(skill) {
const ids = idsForSkill(skill);
const mapped = ids.map((id) => entries.get(id)).filter(Boolean);
const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) =>
(entry.phases?.[phase] ?? []).map((asset) => `${phase}: ${path.posix.basename(asset.model)}`))))];
const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? [])
.filter((sound) => sound.url)
.map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))];
const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat())
.filter((asset) => asset.effectGraphUrl).length;
for (const entry of mapped) {
for (const sound of entry.sounds ?? []) {
if (!sound.url || !fs.existsSync(path.join(projectRoot, "public", sound.url))) {
throw new Error(`Missing packaged RuneWaker sound ${entry.key}: ${sound.sourcePath}`);
}
}
}
return { skill, ids, models, sounds, playableVisuals };
}
function renderTable(rows) {
const result = [
"| Skill | Source spell IDs | Effect phase and source model | Sound phase and source cue |",
"| --- | --- | --- | --- |",
];
for (const row of rows) {
const clean = (value) => value.replaceAll("|", "\\|");
result.push(`| ${clean(row.skill.name)} | ${row.ids.join(", ")} | ${row.models.map(clean).join("<br>") || "—"} | ${row.sounds.map(clean).join("<br>") || "—"} |`);
}
return result.join("\n");
}
const sections = [];
const summary = [];
for (const [label, classId, secondaryClassId] of cohorts) {
for (const [kind, secondary] of [["Base", null], ["Accepted elite pair", secondaryClassId]]) {
const rows = catalog.skills.filter((skill) => (
skill.classId === classId && (skill.secondaryClassId ?? null) === secondary && !skill.passive
)).map(skillMedia);
const visual = rows.filter((row) => row.models.length).length;
const audio = rows.filter((row) => row.sounds.length).length;
const ready = rows.filter((row) => row.playableVisuals > 0).length;
summary.push({ label, kind, skills: rows.length, sourceVisuals: visual, packagedSounds: audio, playableVisuals: ready });
sections.push(`## ${label} — ${kind.toLowerCase()}\n\n${renderTable(rows)}`);
}
}
const lines = [
"# RuneWaker primary-five spell media mapping",
"",
"Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.",
"",
`The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. The sound column lists client cues that are packaged and played on their mapped phase. A dash means the source record or matching sound file did not provide that media.`,
"",
"| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |",
"| --- | --- | ---: | ---: | ---: | ---: |",
...summary.map((row) => `| ${row.label} | ${row.kind} | ${row.skills} | ${row.sourceVisuals} | ${row.packagedSounds} | ${row.playableVisuals} |`),
"",
...sections.flatMap((section) => [section, ""]),
"## Decoded particle archive",
"",
particleArchive
? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready, including the Priest holy cast whose remaining EventTrigger is an audio callback. It exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.`
: "The RuneWaker particle archive has not been packaged.",
"",
"## Next conversion step",
"",
"The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting, Knight Charge cast and Whirlwind Shield impact, and Mage Electric Bolt projectile. The Priest holy model's EventTrigger starts a looping 3D WAV and is handled by phase-matched combat audio while its three sprite layers render. Next, verify more spell phases against the source motion and controller semantics before attaching their `effectGraphUrl`. The Direct3D source and packaged ROS data remain the reference for those ports.",
];
const markdown = `${lines.join("\n").trimEnd()}\n`;
if (process.argv.includes("--write")) {
fs.writeFileSync(path.join(projectRoot, "docs/ROM_SPELL_MEDIA_MAPPING.md"), markdown);
}
console.log(JSON.stringify(summary));
+110 -7
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { verifiedRuneWakerParticlePhases } from "./rom-particle-readiness.mjs";
const require = createRequire(import.meta.url);
const { MpqArchive } = require("stormlib-js");
@@ -520,6 +521,19 @@ export function parseRunewakerImageRecords(buffer, requestedImageIds) {
return result;
}
export function runewakerSoundPathsForModel(modelPath, resourceRoot) {
if (!/^model\/fx\/.+\.(?:ros|ras)$/i.test(modelPath) || modelPath.includes("..")) return [];
const soundPath = modelPath.replace(/^model\//i, "sound/");
const stem = soundPath.replace(/\.(?:ros|ras)$/i, "");
for (const candidate of [stem, soundPath]) {
for (const extension of ["mp3", "wav", "ogg"]) {
const sourcePath = `${candidate}.${extension}`;
if (fs.existsSync(path.join(resourceRoot, ...sourcePath.split("/")))) return [sourcePath];
}
}
return [];
}
function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
const count = buffer.readInt32LE(132);
const recordSize = 1032;
@@ -534,7 +548,39 @@ function runewakerImageIdsFromMagicCollect(buffer, spellIds) {
return result;
}
function attachVerifiedRuneWakerParticles(entries) {
const archivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json");
if (!fs.existsSync(archivePath)) return;
const archive = JSON.parse(fs.readFileSync(archivePath, "utf8"));
const decoded = new Map((archive.results ?? [])
.filter((result) => result.rendererReady && result.graphUrl)
.map((result) => [result.model.toLowerCase(), result]));
const bySpellId = new Map(entries.map((entry) => [entry.spellId, entry]));
for (const { spellId, phase, model, scale, loopCastSound } of verifiedRuneWakerParticlePhases) {
const entry = bySpellId.get(spellId);
const asset = entry?.phases?.[phase]?.find((candidate) => (
candidate.model.toLowerCase() === model
));
const result = decoded.get(model);
if (!asset || !result) continue;
const graphPath = path.join(projectRoot, "public", result.graphUrl.slice(1));
if (!fs.existsSync(graphPath)) continue;
const graph = JSON.parse(fs.readFileSync(graphPath, "utf8"));
if (graph.sourceModel !== model || !graph.rendererReady) continue;
asset.effectKind = "runewaker-particles";
asset.effectGraphUrl = result.graphUrl;
if (scale) asset.scale = scale;
if (loopCastSound) for (const sound of entry.sounds ?? []) {
if (sound.phase === "cast-start"
&& sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav") {
sound.loopDuringCast = true;
}
}
}
}
function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
const resourceRoot = path.dirname(dataRoot);
const magicCollect = fs.readFileSync(path.join(dataRoot, "magiccollectobject.db"));
const collectedImages = runewakerImageIdsFromMagicCollect(magicCollect, spellIds);
for (const [spellId, imageId] of collectedImages) if (!imageBySpellId.has(spellId)) imageBySpellId.set(spellId, imageId);
@@ -554,25 +600,35 @@ function buildRunewakerEntries(spellIds, imageBySpellId, dataRoot) {
}
if (!/\.(?:ros|ras)$/i.test(sourcePath)) continue;
const lower = sourcePath.toLowerCase();
const phase = /(?:^|_)t(?:_|\.)|explode|impact|hit/.test(lower)
const filename = path.posix.basename(lower);
const phase = /(?:^|_)t(?:_|\.)|explode|impact/.test(filename)
|| (/hit/.test(filename) && !/(?:^|_)f(?:_|\.)/.test(filename))
? "impact"
: /(?:^|_)f(?:_|\.)|fly|missile/.test(lower)
: /(?:^|_)f(?:_|\.)|fly|missile/.test(filename)
? "release"
: /buff|aura|extend/.test(lower)
: /buff|aura|extend/.test(filename)
? "aura-start"
: "cast-start";
(phases[phase] ??= []).push({ model: sourcePath });
for (const soundPath of runewakerSoundPathsForModel(sourcePath, resourceRoot)) {
sounds.push({ sourcePath: soundPath, phase, weight: 1, volume: 1 });
}
}
if (!Object.keys(phases).length && !sounds.length) continue;
for (const [phase, assets] of Object.entries(phases)) {
phases[phase] = [...new Map(assets.map((asset) => [asset.model.toLowerCase(), asset])).values()];
}
const uniqueSounds = [...new Map(sounds.map((sound) => [`${sound.phase ?? "any"}:${sound.sourcePath.toLowerCase()}`, sound])).values()];
if (!Object.keys(phases).length && !uniqueSounds.length) continue;
entries.push({
key: `runewaker:${spellId}`,
source: "runewaker",
spellId,
imageId,
phases,
...(sounds.length ? { sounds } : {}),
...(uniqueSounds.length ? { sounds: uniqueSounds } : {}),
});
}
attachVerifiedRuneWakerParticles(entries);
return entries;
}
@@ -622,7 +678,9 @@ function packageAudioAssets(entries, options) {
?? path.join(projectRoot, "..", "wow335a", "Data"));
const runewakerResourceRoot = path.resolve(options.runewakerResourceRoot
?? path.join(projectRoot, "..", "Runewaker", "Resource"));
const archives = openedWowAudioArchives(wowDataRoot);
const archives = entries.some((entry) => entry.source !== "runewaker")
? openedWowAudioArchives(wowDataRoot)
: [];
const sourceCache = new Map();
const missing = [];
const packagedFiles = new Set();
@@ -736,12 +794,55 @@ export function generateCombatEffectManifest(options = {}) {
};
}
export function updateRunewakerCombatEffectManifest(options = {}) {
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const previous = JSON.parse(fs.readFileSync(output, "utf8"));
const runewakerData = path.resolve(options.runewakerData ?? path.join(projectRoot, "..", "Runewaker", "Resource", "data"));
const { requested, runewakerImageBySpellId } = collectAbilitySpellIds();
const runewakerEntries = buildRunewakerEntries(
requested.filter((entry) => entry.source === "runewaker").map((entry) => entry.spellId),
runewakerImageBySpellId,
runewakerData,
);
const audio = options.packageAudio
? packageAudioAssets(runewakerEntries, { ...options, runewakerResourceRoot: path.dirname(runewakerData) })
: { missing: [] };
const entries = [...previous.entries.filter((entry) => entry.source !== "runewaker"), ...runewakerEntries]
.sort((left, right) => left.source.localeCompare(right.source) || left.spellId - right.spellId);
const mapped = new Set(entries.map((entry) => entry.key));
const oldRunewakerRequested = previous.entries.filter((entry) => entry.source === "runewaker").length
+ previous.coverage.fallbackKeys.filter((key) => key.startsWith("runewaker:")).length;
const runewakerRequested = requested.filter((entry) => entry.source === "runewaker");
const fallbackKeys = [
...previous.coverage.fallbackKeys.filter((key) => !key.startsWith("runewaker:")),
...runewakerRequested.map((entry) => `runewaker:${entry.spellId}`).filter((key) => !mapped.has(key)),
];
const soundPaths = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => `${entry.source}:${sound.sourcePath}`)));
const audioUrls = new Set(entries.flatMap((entry) => (entry.sounds ?? []).map((sound) => sound.url).filter(Boolean)));
return {
...previous,
sources: previous.sources.map((source) => source.kind === "runewaker"
? { kind: "runewaker", path: path.relative(projectRoot, runewakerData).replaceAll("\\", "/"), sha256: sha256(fs.readFileSync(path.join(runewakerData, "imageobject.db"))) }
: source),
coverage: {
...previous.coverage,
requested: previous.coverage.requested - oldRunewakerRequested + runewakerRequested.length,
authentic: entries.length,
fallback: fallbackKeys.length,
fallbackKeys,
audio: { referenced: soundPaths.size, packaged: audioUrls.size, missing: audio.missing },
},
entries,
};
}
function parseArguments(arguments_) {
const options = {};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--audit") options.audit = true;
else if (argument === "--package-audio") options.packageAudio = true;
else if (argument === "--runewaker-only") options.runewakerOnly = true;
else if (argument === "--output") options.output = arguments_[++index];
else if (argument === "--wow-archive") options.wowArchive = arguments_[++index];
else if (argument === "--ascension-root") options.ascensionRoot = arguments_[++index];
@@ -754,7 +855,9 @@ function parseArguments(arguments_) {
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArguments(process.argv.slice(2));
const output = path.resolve(options.output ?? path.join(projectRoot, "public", "assets", "game", "combat-effects", "manifest.json"));
const manifest = generateCombatEffectManifest(options);
const manifest = options.runewakerOnly
? updateRunewakerCombatEffectManifest(options)
: generateCombatEffectManifest(options);
if (options.audit && manifest.coverage.audio.missing.length) {
throw new Error(`Combat effects audit failed: ${manifest.coverage.audio.missing.length} referenced audio files could not be packaged.`);
}
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
@@ -8,6 +9,7 @@ import {
decodeWotlkSpellVisualKitRow,
parseDbc,
parseRunewakerImageRecords,
runewakerSoundPathsForModel,
} from "./generate-manifest.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -180,6 +182,26 @@ test("classifies paths from fixed RuneWaker ImageObject records", () => {
assert.deepEqual(records.get(573727), ["model/fx/skill/fire/act_fireball01_f_i.ros"]);
});
test("matches RuneWaker sound siblings without guessing unrelated cues", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rom-sound-fixture-"));
try {
const soundRoot = path.join(root, "sound", "fx", "skill", "fire");
fs.mkdirSync(soundRoot, { recursive: true });
fs.writeFileSync(path.join(soundRoot, "act_fireball_t_hit.ros.mp3"), "fixture");
assert.deepEqual(runewakerSoundPathsForModel(
"model/fx/skill/fire/act_fireball_t_hit.ros", root,
), ["sound/fx/skill/fire/act_fireball_t_hit.ros.mp3"]);
assert.deepEqual(runewakerSoundPathsForModel(
"model/fx/skill/fire/act_other_t_hit.ros", root,
), []);
assert.deepEqual(runewakerSoundPathsForModel(
"model/../../sound/fx/skill/fire/act_fireball_t_hit.ros", root,
), []);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("decodes WotLK MD20 particle tracks from their real record offsets", () => {
const decoded = decodeWotlkM2Effects(wotlkParticleFixture(), "fixture");
const particle = decoded.particles[0];
@@ -320,3 +342,45 @@ test("generated coverage packages every referenced source sound", () => {
assert.ok(fs.existsSync(path.join(projectRoot, "public", sound.url)));
}
});
test("primary-five RuneWaker spells retain source phases and packaged sound cues", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const entry = (id) => manifest.entries.find((candidate) => candidate.key === `runewaker:${id}`);
assert.ok(entry(490261).phases["cast-start"].some((asset) => asset.model === "model/fx/skill/holy/act_casting_c_hand.ros"));
assert.ok(entry(490261).sounds.some((sound) => sound.phase === "cast-start" && sound.url));
assert.ok(entry(491128).phases.release.some((asset) => asset.model.includes("act_gustwindarrow_f_hit_point_i.ros")));
assert.ok(entry(491128).phases.impact.some((asset) => asset.model.includes("act_gustwindarrow_t_hit_point_e.ros")));
assert.ok(entry(490495).sounds.some((sound) => sound.sourcePath === "sound/fx/skill/holy/act_godshield_c_down.mp3"));
});
test("only browser-verified RuneWaker particle phases use original graphs", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8"));
const expected = new Set([
"491147:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"490261:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"490281:cast-start:model/fx/skill/holy/act_casting_c_hand.ros",
"490150:cast-start:model/fx/skill/physical/act_charge02_c_down.ros",
"490175:impact:model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros",
"491168:release:model/fx/skill/wind/act_electric_ball_f_i.ros",
]);
const actual = new Set();
for (const entry of manifest.entries.filter((candidate) => candidate.source === "runewaker")) {
for (const [phase, assets] of Object.entries(entry.phases ?? {})) for (const asset of assets) {
if (!asset.effectGraphUrl) continue;
const key = `${entry.spellId}:${phase}:${asset.model}`;
actual.add(key);
assert.equal(asset.effectKind, "runewaker-particles");
if (entry.spellId === 490175) assert.equal(asset.scale, 2.5);
const graph = JSON.parse(fs.readFileSync(path.join(projectRoot, "public", asset.effectGraphUrl.slice(1)), "utf8"));
assert.equal(graph.rendererReady, true);
assert.equal(graph.sourceModel, asset.model);
}
}
assert.deepEqual(actual, expected);
for (const spellId of [491147, 490261, 490281]) {
const entry = manifest.entries.find((candidate) => candidate.key === `runewaker:${spellId}`);
assert.ok(entry.sounds.some((sound) => sound.phase === "cast-start"
&& sound.sourcePath === "sound/fx/skill/holy/act_casting_c_hand.wav"
&& sound.loopDuringCast === true));
}
});
@@ -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;
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { verifiedRuneWakerParticleModels } from "./rom-particle-readiness.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(root, "public");
const index = JSON.parse(fs.readFileSync(path.join(publicRoot,
"assets/game/combat-effects/original/runewaker/manifest.json"), "utf8"));
function assetFile(url) {
assert.match(url, /^\/assets\/game\/combat-effects\/original\/runewaker\/[\w/.-]+\.(?:json|png|hlsl)$/);
const file = path.resolve(publicRoot, url.slice(1));
assert.ok(file.startsWith(`${publicRoot}${path.sep}`));
return file;
}
test("primary-five RuneWaker particle archive resolves its decoded graphs and textures", () => {
assert.equal(index.requestedModels, 123);
assert.equal(index.processedModels, index.requestedModels);
assert.equal(index.failedModels, 0);
assert.equal(index.results.length, index.requestedModels);
let spriteControllers = 0;
let textureReferences = 0;
for (const result of index.results) {
assert.ok(result.graphUrl, result.model);
const graph = JSON.parse(fs.readFileSync(assetFile(result.graphUrl), "utf8"));
assert.equal(graph.kind, "runewaker-ros-particle-archive");
assert.equal(graph.sourceModel, result.model);
assert.equal(graph.rendererReady, result.rendererReady);
assert.equal(result.rendererReady, verifiedRuneWakerParticleModels.has(result.model)
&& result.errors.length === 0
&& result.unsupported.every((type) => type === "CRuController_EventTrigger"));
assert.equal(graph.shader.name, "ruSHADER_PARTICLE_SPRITEEX");
for (const [stage, signature] of [["vertex", "VS_OUTPUT main"], ["pixel", "PS_OUTPUT main"]]) {
const shader = fs.readFileSync(assetFile(graph.shader[`${stage}SourceUrl`]), "utf8");
assert.ok(shader.includes(signature));
assert.ok(shader.endsWith("\n"));
assert.ok(!shader.trimEnd().endsWith("\\n\\"));
}
assert.equal(graph.textures.length, result.textures);
spriteControllers += result.spriteControllers;
for (const texture of graph.textures) {
const png = fs.readFileSync(assetFile(texture.url));
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
textureReferences++;
}
for (const motion of graph.motions) for (const controller of motion.controllers) {
if (controller.type === "sprite" && controller.textureAssetUrl) {
assert.ok(graph.textures.some((texture) => texture.url === controller.textureAssetUrl));
assert.equal(controller.shader, "ruSHADER_PARTICLE_SPRITEEX");
assert.ok(Array.isArray(controller.tileSizePixels));
}
if (controller.type === "emitter") assert.equal(controller.tracks.length, 27);
}
}
assert.equal(spriteControllers, 510);
assert.ok(textureReferences >= 103);
});
@@ -0,0 +1,12 @@
export const verifiedRuneWakerParticlePhases = [
{ spellId: 491147, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 490261, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 490281, phase: "cast-start", model: "model/fx/skill/holy/act_casting_c_hand.ros", loopCastSound: true },
{ spellId: 490150, phase: "cast-start", model: "model/fx/skill/physical/act_charge02_c_down.ros" },
{ spellId: 490175, phase: "impact", model: "model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros", scale: 2.5 },
{ spellId: 491168, phase: "release", model: "model/fx/skill/wind/act_electric_ball_f_i.ros" },
];
export const verifiedRuneWakerParticleModels = new Set(
verifiedRuneWakerParticlePhases.map((entry) => entry.model),
);
+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>RuneWaker particle preview</title></head>
<body style="margin:0;background:#0b121b;color:#e8eef8;font:14px system-ui">
<div id="root"></div><script type="module" src="/src/dev/RunewakerEffectsPreview.tsx"></script>
</body>
</html>