Release Healer Man 0.1.30
This commit is contained in:
1 parent
544c1bee91
commit
7d7fd567f0
845 files changed
+2040
-87
No files matched your search
@@ -0,0 +1,81 @@
|
||||
# Original WoW spell visuals
|
||||
|
||||
The runtime packages every WoW 3.3.5 visual model referenced by the current
|
||||
ability manifest, across all ten WoW classes. There is no class filter in
|
||||
`package-wow-original.mjs`. Ascension and RuneWaker entries retain their existing
|
||||
source mappings and sound behavior.
|
||||
|
||||
Current coverage: 378 directly referenced models plus five particle model
|
||||
dependencies; 4,205 of 4,205 source phase assets; 260 mesh models, 1,367 particle
|
||||
emitters, 193 ribbon emitters, and 428 unique source textures. The generated
|
||||
manifest includes per-class coverage. A spell rank without a source visual
|
||||
phase (for example, a passive or totem-management action) is not counted as a
|
||||
missing model. This does not invent visuals for spells with no source mapping.
|
||||
|
||||
## Generate and verify
|
||||
|
||||
```sh
|
||||
npm run effects:wow
|
||||
npm run effects:audit
|
||||
npm run effects:test
|
||||
```
|
||||
|
||||
`effects:generate` rebuilds the DBC/audio mappings before packaging all WoW
|
||||
visuals. `effects:priest` remains a compatibility alias for `effects:wow` so it
|
||||
cannot accidentally revert the main manifest to Priest-only coverage. The old
|
||||
Priest decoder and fixtures remain for compatibility tests.
|
||||
|
||||
Source assets resolve from the extracted sibling `LadiksMPQEditor` base-client
|
||||
directories, with the sibling `wow335a/Data` archive stack recovering missing
|
||||
files. Locale archives supply shared interface textures used by spell models.
|
||||
Recovered files go into `.tools/wow-effects-source`; source installations are
|
||||
read-only. BLPConverter8 converts the original BLPs to PNG. Model and texture
|
||||
paths are checked before resolution. An incomplete run reports failures and
|
||||
does not replace the active manifest.
|
||||
|
||||
Graph filenames hash the complete runtime payload, including source model,
|
||||
skin, textures and nested model dependencies. Changes therefore get new URLs
|
||||
instead of reusing an incompatible cached graph. The normal content publisher
|
||||
assigns the entire `assets/game/combat-effects` tree to `core:combat-media`.
|
||||
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.
|
||||
|
||||
## Rendering
|
||||
|
||||
The `wow-m2-model` format composes source particles, animated/skinned geometry,
|
||||
material alpha and colors, UV transforms, and bone-driven ribbon trails. The
|
||||
renderer resolves full and upright billboards, texture layers, source-space
|
||||
pivots, global animation sequences, and Stand/Hold/Decay timelines. Original
|
||||
geometry particles use instance pools; secondary particle emitters are bounded
|
||||
to prevent recursive effects multiplying the scene budget.
|
||||
|
||||
The live combat path uses the same cast, projectile, impact and aura events as
|
||||
before. In original mode, an instant release also renders any source-side
|
||||
`cast-start` layers without publishing another gameplay or audio event. Instant
|
||||
terminal events prefer their exact phase, then use the source `impact` or
|
||||
`aura-start` counterpart when that is the only mapping. The source phase stays
|
||||
on the render event so attachment and aura lifetime rules remain correct. This
|
||||
covers all 1,106 castable instant rank records that have a packaged source
|
||||
visual, including all 287 latest-rank instant abilities with source visuals.
|
||||
The source catalog also contains three passive instant records with visuals;
|
||||
passives do not publish combat events. Abilities without a source mapping, or
|
||||
with no usable asset load, retain generated fallback. Particle budgets,
|
||||
placement anchors, and travel timing remain the game's presentation rules;
|
||||
this is not a claim of pixel-identical client simulation or exhaustive
|
||||
spell-by-spell gameplay QA.
|
||||
|
||||
Decoder reference for compressed bone quaternions, float UV quaternions, and
|
||||
source record layouts: [wowlib M2 records](https://skarndev.github.io/wowlib/python/m2/records/).
|
||||
|
||||
## Local visual inspection
|
||||
|
||||
Run `npm run dev` and open `/scripts/combat-effects/preview.html`. The
|
||||
development-only preview lists every directly referenced model and uses the
|
||||
same mesh, ribbon and particle renderer as live combat. Drag to orbit, scroll
|
||||
to zoom, replay an effect, or freeze its opening frame. The preview is not
|
||||
included in the production app entry point.
|
||||
|
||||
Runtime tests parse all shipped graphs, sample bone transforms, and verify
|
||||
geometry bounds, dependency textures and billboard behavior. Browser evidence
|
||||
and observed limitations are recorded in `PLAYTEST_NOTES.md`.
|
||||
@@ -298,12 +298,14 @@ test("generated Priest mappings keep SpellVisualKit sounds out of visual phases"
|
||||
assert.deepEqual(models(17, "aura-start"), ["Spells/HolyDivineShield_State_Base.mdx"]);
|
||||
|
||||
const smite = entry(585);
|
||||
assert.equal(smite.phases.impact[0].effectGraphUrl,
|
||||
"/assets/game/combat-effects/original/wow335/priest/graphs/holysmite-low-chest-5e638a90a1be.json");
|
||||
assert.equal(smite.phases.impact[0].effectKind, "wow-m2-model");
|
||||
assert.match(smite.phases.impact[0].effectGraphUrl,
|
||||
/\/original\/wow335\/all\/graphs\/holysmite-low-chest-[a-f0-9]+\.json$/);
|
||||
assert.ok((smite.sounds ?? []).some((sound) => sound.sourcePath === "Sound/Spells/HolyBolt.wav"));
|
||||
const pain = entry(589);
|
||||
assert.equal(pain.phases.impact[0].effectGraphUrl,
|
||||
"/assets/game/combat-effects/original/wow335/priest/graphs/shadowwordpain-chest-8c57a0056fda.json");
|
||||
assert.equal(pain.phases.impact[0].effectKind, "wow-m2-model");
|
||||
assert.match(pain.phases.impact[0].effectGraphUrl,
|
||||
/\/original\/wow335\/all\/graphs\/shadowwordpain-chest-[a-f0-9]+\.json$/);
|
||||
assert.ok((pain.sounds ?? []).some((sound) => sound.sourcePath === "Sound/Spells/ShadowWordPainTarget.wav"));
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function particleRuntimeReady(particle, textures) {
|
||||
);
|
||||
}
|
||||
|
||||
function convertBlpToPng(sourcePath, outputPath, converterPath) {
|
||||
export function convertBlpToPng(sourcePath, outputPath, converterPath) {
|
||||
if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) return;
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
const temporaryPath = `${outputPath}.tmp-${process.pid}`;
|
||||
@@ -124,7 +124,7 @@ export function priestSpellIdsFromCatalog(catalog) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function runtimeParticle(particle) {
|
||||
export function runtimeParticle(particle) {
|
||||
const animatedTrackNames = [
|
||||
"emissionSpeed",
|
||||
"speedVariation",
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { decodeWotlkM2Effects } from "./generate-manifest.mjs";
|
||||
import { decodeWotlkM2Animation, decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs";
|
||||
import { convertBlpToPng, runtimeParticle } from "./package-priest-original.mjs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const prefix = "/assets/game/combat-effects/original/wow335/all";
|
||||
const outputRoot = path.join(projectRoot, "public", prefix);
|
||||
const digest = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
export function normalizeWowEffectPath(source, extension) {
|
||||
const normalized = source.replaceAll("\\", "/");
|
||||
if (!normalized || normalized.startsWith("/") || normalized.includes(":")
|
||||
|| normalized.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||
throw new Error(`Invalid WoW source path: ${source}`);
|
||||
}
|
||||
return (extension ? normalized.replace(/\.[^/.]+$/, extension) : normalized).toLowerCase();
|
||||
}
|
||||
|
||||
// Local extracted client data first; base-client archives recover sparse textures.
|
||||
export function createWowEffectSource(options = {}) {
|
||||
const roots = options.roots ?? ["common-2.mpq", "patch-3", "patch-2", "patch", "expansion", "lichking", "common"]
|
||||
.map((name) => path.resolve(projectRoot, "../LadiksMPQEditor", name));
|
||||
const cache = new Map();
|
||||
let archives;
|
||||
return {
|
||||
read(source) {
|
||||
const normalized = normalizeWowEffectPath(source);
|
||||
if (cache.has(normalized)) return cache.get(normalized);
|
||||
const local = roots.map((root) => path.join(root, normalized)).find((file) => fs.existsSync(file));
|
||||
if (local) {
|
||||
const result = { path: local, buffer: fs.readFileSync(local) };
|
||||
cache.set(normalized, result);
|
||||
return result;
|
||||
}
|
||||
if (!archives) {
|
||||
const { MpqArchive } = require("stormlib-js");
|
||||
archives = (options.archives ?? ["enUS/patch-enUS-3.MPQ", "enUS/patch-enUS-2.MPQ", "enUS/patch-enUS.MPQ",
|
||||
"enUS/lichking-locale-enUS.MPQ", "enUS/expansion-locale-enUS.MPQ", "enUS/locale-enUS.MPQ",
|
||||
"patch-3.MPQ", "patch-2.MPQ", "patch.MPQ", "lichking.MPQ", "expansion.MPQ", "common-2.MPQ", "common.MPQ"]
|
||||
.map((name) => path.resolve(projectRoot, "../wow335a/Data", name)))
|
||||
.filter((file) => fs.existsSync(file)).map((file) => MpqArchive.open(file));
|
||||
}
|
||||
for (const archive of archives) {
|
||||
let buffer;
|
||||
try { buffer = archive.extractFile(normalized.replaceAll("/", "\\")); } catch { continue; }
|
||||
if (!buffer?.length) continue;
|
||||
const file = path.join(projectRoot, ".tools/wow-effects-source", normalized);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, buffer);
|
||||
const result = { path: file, buffer };
|
||||
cache.set(normalized, result);
|
||||
return result;
|
||||
}
|
||||
cache.set(normalized, null);
|
||||
return null;
|
||||
},
|
||||
close() { for (const archive of archives ?? []) archive.close(); },
|
||||
};
|
||||
}
|
||||
|
||||
function ribbonTrack(track) {
|
||||
return {
|
||||
interpolation: track.interpolation,
|
||||
globalSequence: track.globalSequence,
|
||||
sequences: track.timestamps.sequences.map((sequence, index) => ({
|
||||
timestamps: sequence.values,
|
||||
values: track.values.sequences[index].values,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function packageWowOriginalEffects(manifest, options = {}) {
|
||||
const source = options.source ?? createWowEffectSource(options);
|
||||
const destination = options.outputRoot ?? outputRoot;
|
||||
const publicPrefix = options.publicPrefix ?? prefix;
|
||||
const converter = options.converter ?? path.resolve(projectRoot,
|
||||
"../wow-pvt-server-backups-2/azerothcore-wotlk/tools/WoTLK_MultiTool/BLPConverter8.exe");
|
||||
const catalog = options.catalog ?? JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/wow335AbilityData.generated.json")));
|
||||
const models = new Map();
|
||||
const graphs = new Map();
|
||||
const building = new Set();
|
||||
const textures = new Map();
|
||||
const failures = [];
|
||||
const report = { scope: "wow335-all-classes", models: 0, packagedModels: 0, meshModels: 0,
|
||||
particleEmitters: 0, ribbons: 0, textures: 0, spellEntries: 0, phaseAssets: 0, failures, classes: {} };
|
||||
const packageTexture = (texture) => {
|
||||
if (!texture.filename) return { ...texture };
|
||||
const name = normalizeWowEffectPath(texture.filename);
|
||||
if (textures.has(name)) return { ...texture, ...textures.get(name) };
|
||||
const original = source.read(name);
|
||||
if (!original) throw new Error(`Missing texture: ${name}`);
|
||||
const hash = digest(original.buffer);
|
||||
const filename = `${hash.slice(0, 20)}.png`;
|
||||
const output = path.join(destination, "textures", filename);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
(options.convertTexture ?? convertBlpToPng)(original.path, output, converter);
|
||||
const packaged = { sourcePath: name, sourceSha256: hash, url: `${publicPrefix}/textures/${filename}` };
|
||||
textures.set(name, packaged);
|
||||
return { ...texture, ...packaged };
|
||||
};
|
||||
const packageModel = (name) => {
|
||||
if (building.has(name)) throw new Error(`Cyclic effect dependency: ${name}`);
|
||||
building.add(name);
|
||||
const original = source.read(name);
|
||||
if (!original) throw new Error(`Missing model: ${name}`);
|
||||
const effects = decodeWotlkM2Effects(original.buffer, name);
|
||||
const animation = decodeWotlkM2Animation(original.buffer, name);
|
||||
const skinName = name.replace(/\.m2$/, "00.skin");
|
||||
const skin = original.buffer.readUInt32LE(60) ? source.read(skinName) : null;
|
||||
if (original.buffer.readUInt32LE(60) && !skin) throw new Error(`Missing skin: ${skinName}`);
|
||||
const mesh = skin ? decodeWotlkM2Mesh(original.buffer, skin.buffer, name) : null;
|
||||
const packagedTextures = effects.textures.map(packageTexture);
|
||||
const readyParticleIndices = effects.particles.flatMap((particle, index) => (
|
||||
packagedTextures[particle.textureIndex]?.url
|
||||
&& particle.samples.emissionRate.some((sample) => (sample.value?.value ?? sample.value) > 0)
|
||||
&& particle.samples.lifespan.some((sample) => (sample.value?.value ?? sample.value) > 0) ? [index] : []
|
||||
));
|
||||
const particleGraph = readyParticleIndices.length ? {
|
||||
schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", sourceModel: name,
|
||||
normalizedSourceModel: name, sourceSha256: digest(original.buffer),
|
||||
durationMs: effects.durationMs, animations: effects.animations, textures: packagedTextures,
|
||||
particles: effects.particles.map(runtimeParticle),
|
||||
runtime: { renderer: "wow-m2-particles-v1", readyParticleIndices,
|
||||
unsupportedRibbonCount: 0, unsupportedGeometryVertexCount: 0 },
|
||||
} : null;
|
||||
const ribbons = effects.ribbons.map((ribbon) => ({
|
||||
id: ribbon.id, parentBone: ribbon.parentBone, position: ribbon.position,
|
||||
textureIndices: ribbon.textureIndices.values, materialIndices: ribbon.materialIndices.values,
|
||||
edgesPerSecond: ribbon.edgesPerSecond, edgeLifetime: ribbon.edgeLifetime,
|
||||
gravity: ribbon.gravity, textureRows: ribbon.textureRows, textureColumns: ribbon.textureColumns,
|
||||
tracks: Object.fromEntries(Object.entries(ribbon.tracks).map(([key, track]) => [key, ribbonTrack(track)])),
|
||||
}));
|
||||
const graph = {
|
||||
schemaVersion: 1, kind: "wow-m2-model", source: "wow335", sourceModel: name,
|
||||
sourceSha256: digest(original.buffer), sourceSkinSha256: skin ? digest(skin.buffer) : null,
|
||||
animations: effects.animations, ...animation, textures: packagedTextures, particleGraph, ribbons,
|
||||
children: {},
|
||||
mesh: mesh ? {
|
||||
positions: mesh.vertices.flatMap((vertex) => vertex.position),
|
||||
normals: mesh.vertices.flatMap((vertex) => vertex.normal),
|
||||
uvs: mesh.vertices.flatMap((vertex) => vertex.uv),
|
||||
uvs2: mesh.vertices.flatMap((vertex) => vertex.uv2),
|
||||
boneIndices: mesh.vertices.flatMap((vertex) => vertex.boneIndices),
|
||||
boneWeights: mesh.vertices.flatMap((vertex) => vertex.normalizedBoneWeights),
|
||||
indices: mesh.indices, colors: mesh.colors, textureWeights: mesh.textureWeights,
|
||||
layers: mesh.skin.textureUnits.map((unit) => ({
|
||||
...unit.drawRange, colorIndex: unit.colorIndex, materialIndex: unit.materialIndex,
|
||||
textureIndices: unit.textureIndices, textureCoordinateIndices: unit.textureCoordinateIndices,
|
||||
textureWeightIndex: unit.textureWeightIndices[0], textureTransformIndices: unit.textureTransformIndices,
|
||||
})),
|
||||
} : null,
|
||||
};
|
||||
const dependencies = new Set(effects.particles.flatMap((particle) => [particle.geometryModel, particle.recursionModel])
|
||||
.filter(Boolean).map((model) => normalizeWowEffectPath(model, ".m2")));
|
||||
for (const dependency of dependencies) {
|
||||
if (!models.has(dependency)) {
|
||||
report.models++;
|
||||
models.set(dependency, packageModel(dependency));
|
||||
}
|
||||
graph.children[dependency] = graphs.get(dependency);
|
||||
}
|
||||
if (!particleGraph && !mesh?.indices.length && !ribbons.length) throw new Error(`Model has no renderable components: ${name}`);
|
||||
const json = `${JSON.stringify(graph)}\n`;
|
||||
const filename = `${path.posix.basename(name, ".m2").replaceAll("_", "-")}-${digest(json).slice(0, 16)}.json`;
|
||||
fs.mkdirSync(path.join(destination, "graphs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(destination, "graphs", filename), json);
|
||||
report.packagedModels++;
|
||||
report.meshModels += mesh ? 1 : 0;
|
||||
report.particleEmitters += readyParticleIndices.length;
|
||||
report.ribbons += ribbons.length;
|
||||
graphs.set(name, graph);
|
||||
building.delete(name);
|
||||
return { effectKind: "wow-m2-model", effectGraphUrl: `${publicPrefix}/graphs/${filename}`,
|
||||
originalSourceSha256: graph.sourceSha256 };
|
||||
};
|
||||
try {
|
||||
const entries = manifest.entries.map((entry) => {
|
||||
if (entry.source !== "wow335") return entry;
|
||||
report.spellEntries++;
|
||||
return { ...entry, phases: Object.fromEntries(Object.entries(entry.phases ?? {}).map(([phase, assets]) => [phase,
|
||||
assets.map((asset) => {
|
||||
if (!asset.model) return asset;
|
||||
const name = normalizeWowEffectPath(asset.model, ".m2");
|
||||
if (!models.has(name)) {
|
||||
report.models++;
|
||||
try { models.set(name, packageModel(name)); }
|
||||
catch (error) { failures.push({ model: name, reason: error.message }); models.set(name, null); }
|
||||
}
|
||||
const packaged = models.get(name);
|
||||
if (packaged) report.phaseAssets++;
|
||||
const { effectGraphUrl, effectKind, originalSourceSha256, ...rest } = asset;
|
||||
return { ...rest, ...(packaged ?? {}) };
|
||||
}),
|
||||
])) };
|
||||
});
|
||||
const byId = new Map(entries.filter((entry) => entry.source === "wow335").map((entry) => [entry.spellId, entry]));
|
||||
for (const [classId, chains] of Object.entries(catalog.classes)) {
|
||||
const ids = [...new Set(chains.flatMap((chain) => chain.ranks.map((rank) => rank.spellId)))];
|
||||
const assets = ids.flatMap((id) => Object.values(byId.get(id)?.phases ?? {}).flat());
|
||||
report.classes[classId] = { spellRanks: ids.length,
|
||||
withOriginalVisuals: ids.filter((id) => Object.values(byId.get(id)?.phases ?? {}).flat().some((asset) => asset.effectGraphUrl)).length,
|
||||
referencedPhaseAssets: assets.length, packagedPhaseAssets: assets.filter((asset) => asset.effectGraphUrl).length };
|
||||
}
|
||||
report.textures = new Set([...textures.values()].map((texture) => texture.url)).size;
|
||||
return { manifest: { ...manifest, coverage: { ...manifest.coverage, originalVisuals: report }, entries }, report };
|
||||
} finally { if (!options.source) source.close(); }
|
||||
}
|
||||
|
||||
export function auditWowOriginalManifest(manifest, publicRoot = path.join(projectRoot, "public")) {
|
||||
const missing = new Set();
|
||||
const urls = new Set();
|
||||
for (const entry of manifest.entries) {
|
||||
for (const sound of entry.sounds ?? []) {
|
||||
if (sound.sourcePath && !sound.url) missing.add(`${entry.key}:${sound.sourcePath}`);
|
||||
if (sound.url && !fs.existsSync(path.join(publicRoot, sound.url))) missing.add(sound.url);
|
||||
}
|
||||
}
|
||||
for (const entry of manifest.entries.filter((entry) => entry.source === "wow335")) {
|
||||
for (const asset of Object.values(entry.phases ?? {}).flat()) {
|
||||
if (asset.model && !asset.effectGraphUrl) missing.add(`${entry.key}:${asset.model}`);
|
||||
if (asset.effectGraphUrl) urls.add(asset.effectGraphUrl);
|
||||
}
|
||||
}
|
||||
for (const url of urls) {
|
||||
const file = path.join(publicRoot, url);
|
||||
if (!fs.existsSync(file)) { missing.add(url); continue; }
|
||||
const graph = JSON.parse(fs.readFileSync(file));
|
||||
const checkTextures = (model) => {
|
||||
for (const texture of model.textures ?? []) {
|
||||
if (texture.url && !fs.existsSync(path.join(publicRoot, texture.url))) missing.add(texture.url);
|
||||
}
|
||||
for (const child of Object.values(model.children ?? {})) checkTextures(child);
|
||||
};
|
||||
checkTextures(graph);
|
||||
}
|
||||
return { graphs: urls.size, missing: [...missing].sort() };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const manifestPath = path.join(projectRoot, "public/assets/game/combat-effects/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath));
|
||||
if (process.argv.includes("--audit")) {
|
||||
const audit = auditWowOriginalManifest(manifest);
|
||||
console.log(JSON.stringify(audit));
|
||||
if (audit.missing.length) process.exitCode = 1;
|
||||
} else {
|
||||
const result = packageWowOriginalEffects(manifest);
|
||||
console.log(JSON.stringify(result.report, null, 2));
|
||||
if (result.report.failures.length) process.exitCode = 1;
|
||||
else fs.writeFileSync(manifestPath, `${JSON.stringify(result.manifest)}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { auditWowOriginalManifest, normalizeWowEffectPath, packageWowOriginalEffects } from "./package-wow-original.mjs";
|
||||
import { decodeWotlkM2Animation, decodeWotlkM2Mesh } from "./wotlk-m2-mesh.mjs";
|
||||
|
||||
test("normalizes client model paths without allowing traversal", () => {
|
||||
assert.equal(normalizeWowEffectPath("Spells\\Fireball.mdx", ".m2"), "spells/fireball.m2");
|
||||
for (const name of ["../secret.m2", "spells/../secret.m2", "/spells/test.m2", "C:/secret.m2"]) {
|
||||
assert.throws(() => normalizeWowEffectPath(name), /Invalid WoW source path/);
|
||||
}
|
||||
});
|
||||
|
||||
test("all ten WoW classes package every referenced visual phase", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL("../../public/assets/game/combat-effects/manifest.json", import.meta.url)));
|
||||
const coverage = manifest.coverage.originalVisuals;
|
||||
assert.equal(coverage.scope, "wow335-all-classes");
|
||||
assert.equal(Object.keys(coverage.classes).length, 10);
|
||||
assert.equal(coverage.packagedModels, coverage.models);
|
||||
assert.deepEqual(coverage.failures, []);
|
||||
for (const [classId, entry] of Object.entries(coverage.classes)) {
|
||||
assert.ok(entry.withOriginalVisuals > 0, classId);
|
||||
assert.equal(entry.packagedPhaseAssets, entry.referencedPhaseAssets, classId);
|
||||
}
|
||||
assert.deepEqual(auditWowOriginalManifest(manifest).missing, []);
|
||||
});
|
||||
|
||||
test("reports unavailable source models without falsely marking them ready", () => {
|
||||
const original = { entries: [
|
||||
{ key: "wow335:133", source: "wow335", spellId: 133, phases: { impact: [{ model: "Spells/Missing.mdx", effectGraphUrl: "/old.json" }] } },
|
||||
{ key: "runewaker:1", source: "runewaker", spellId: 1 },
|
||||
] };
|
||||
const result = packageWowOriginalEffects(original, {
|
||||
source: { read: () => null }, catalog: { classes: { mage: [{ ranks: [{ spellId: 133 }] }] } },
|
||||
});
|
||||
assert.equal(result.report.failures.length, 1);
|
||||
assert.equal(result.manifest.entries[0].phases.impact[0].effectGraphUrl, undefined);
|
||||
assert.deepEqual(result.manifest.entries[1], original.entries[1]);
|
||||
assert.equal(original.entries[0].phases.impact[0].effectGraphUrl, "/old.json");
|
||||
});
|
||||
|
||||
const sourceRoot = path.resolve("../LadiksMPQEditor/common-2.mpq/spells");
|
||||
test("decodes Death Knight dual textures with a single layer transparency track", {
|
||||
skip: !fs.existsSync(path.join(sourceRoot, "deathknight_bloodstrike.m2")),
|
||||
}, () => {
|
||||
const m2 = fs.readFileSync(path.join(sourceRoot, "deathknight_bloodstrike.m2"));
|
||||
const skin = fs.readFileSync(path.join(sourceRoot, "deathknight_bloodstrike00.skin"));
|
||||
const mesh = decodeWotlkM2Mesh(m2, skin);
|
||||
assert.equal(mesh.skin.textureUnits[0].textureIndices.length, 2);
|
||||
assert.equal(mesh.skin.textureUnits[0].textureWeightIndices.length, 1);
|
||||
const animation = decodeWotlkM2Animation(m2);
|
||||
assert.ok(animation.bones.length > 0);
|
||||
assert.ok(animation.bones.flatMap((b) => b.rotation.sequences.flatMap((s) => s.values))
|
||||
.every((q) => q.every(Number.isFinite)));
|
||||
});
|
||||
@@ -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>Original WoW effect 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/OriginalEffectsPreview.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,6 +7,17 @@ const SKIN_SUBMESH_SIZE = 48;
|
||||
const SKIN_TEXTURE_UNIT_SIZE = 24;
|
||||
|
||||
const VALUE_TYPES = {
|
||||
floatQuaternion: {
|
||||
byteLength: 16,
|
||||
read: (buffer, offset) => [0, 4, 8, 12].map((component) => buffer.readFloatLE(offset + component)),
|
||||
},
|
||||
quaternion: {
|
||||
byteLength: 8,
|
||||
read: (buffer, offset) => [0, 2, 4, 6].map((component) => {
|
||||
const value = buffer.readInt16LE(offset + component);
|
||||
return (value < 0 ? value + 32768 : value - 32767) / 32767;
|
||||
}),
|
||||
},
|
||||
uint8x4: {
|
||||
byteLength: 4,
|
||||
read: (buffer, offset) => Array.from(buffer.subarray(offset, offset + 4)),
|
||||
@@ -211,6 +222,39 @@ function decodeTextureWeights(buffer, name) {
|
||||
));
|
||||
}
|
||||
|
||||
/** Source-space bone and UV animation, shared by meshes, particles and ribbons. */
|
||||
export function decodeWotlkM2Animation(buffer, name = "M2") {
|
||||
const bones = readArrayHeader(buffer, 44, `${name} bones`);
|
||||
if (bones.count) assertRange(buffer, bones.offset, bones.count * 88, `${name} bones`);
|
||||
const transforms = readArrayHeader(buffer, 96, `${name} texture transforms`);
|
||||
if (transforms.count) assertRange(buffer, transforms.offset, transforms.count * 60, `${name} texture transforms`);
|
||||
return {
|
||||
globalSequences: decodeArray(buffer, 20, VALUE_TYPES.uint32, `${name} global sequences`).values,
|
||||
bones: Array.from({ length: bones.count }, (_, index) => {
|
||||
const offset = bones.offset + index * 88;
|
||||
const parent = buffer.readInt16LE(offset + 8);
|
||||
if (parent >= index || parent < -1) throw new Error(`${name} bone ${index} has an invalid parent.`);
|
||||
return {
|
||||
parent,
|
||||
flags: buffer.readUInt32LE(offset + 4),
|
||||
pivot: vector3(buffer, offset + 76),
|
||||
translation: decodeTrack(buffer, offset + 16, VALUE_TYPES.vector3, `${name} bone ${index} translation`),
|
||||
rotation: decodeTrack(buffer, offset + 36, VALUE_TYPES.quaternion, `${name} bone ${index} rotation`),
|
||||
scale: decodeTrack(buffer, offset + 56, VALUE_TYPES.vector3, `${name} bone ${index} scale`),
|
||||
};
|
||||
}),
|
||||
textureTransforms: Array.from({ length: transforms.count }, (_, index) => {
|
||||
const offset = transforms.offset + index * 60;
|
||||
return {
|
||||
translation: decodeTrack(buffer, offset, VALUE_TYPES.vector3, `${name} UV ${index} translation`),
|
||||
rotation: decodeTrack(buffer, offset + 20, VALUE_TYPES.floatQuaternion, `${name} UV ${index} rotation`),
|
||||
scale: decodeTrack(buffer, offset + 40, VALUE_TYPES.vector3, `${name} UV ${index} scale`),
|
||||
};
|
||||
}),
|
||||
materials: decodeMaterials(buffer, name),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSkinSubmeshes(buffer, count, offset, triangleCount, vertexLookupCount, name) {
|
||||
const byteLength = count * SKIN_SUBMESH_SIZE;
|
||||
if (!Number.isSafeInteger(byteLength)) throw new Error(`${name} submeshes are too large to decode.`);
|
||||
@@ -303,7 +347,7 @@ function decodeSkinTextureUnits(buffer, count, offset, context, name) {
|
||||
textureWeightIndices: lookupSlice(
|
||||
context.lookups.textureWeights,
|
||||
textureWeightComboIndex,
|
||||
textureCount,
|
||||
1,
|
||||
`${name} texture unit ${index} texture weights`,
|
||||
),
|
||||
textureTransformIndices: lookupSlice(
|
||||
|
||||
Reference in new issue
Block a user