Release Healer Man 0.1.30

This commit is contained in:
phenom committed 2026-09-11 18:49:33 -04:00
1 parent 544c1bee91
commit 7d7fd567f0
845 files changed
+2040 -87

No files matched your search

+81
View File
@@ -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)));
});
+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>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>
+45 -1
View File
@@ -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(