diff --git a/.gitignore b/.gitignore
index ce5a0e21..f9e08298 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,8 @@ node_modules/
# Local scratch images created during browser/UI debugging
tmp-*.png
+
+# Local build and browser diagnostics
+/.tmp/
+/debug.log
+frontend/eqsage-embed/vite.config.js.timestamp-*.mjs
diff --git a/frontend/eqsage-embed/package.json b/frontend/eqsage-embed/package.json
index d7a29280..d4e705d2 100644
--- a/frontend/eqsage-embed/package.json
+++ b/frontend/eqsage-embed/package.json
@@ -167,6 +167,10 @@
"dev": "vite --mode development",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build",
"build:watch": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --watch",
+ "audit:race-skin-textures": "node scripts/audit-race-skin-textures.mjs",
+ "sync:race-data": "node scripts/sync-race-data.mjs",
+ "report:race-face-audit": "node scripts/generate-race-face-audit.mjs",
+ "plan:race-face-zones": "node scripts/plan-unresolved-race-zones.mjs",
"bundle-visualize": "npx vite-bundle-visualizer"
},
"eslintConfig": {
diff --git a/frontend/eqsage-embed/sage/lib/eqg/eqg-decoder.js b/frontend/eqsage-embed/sage/lib/eqg/eqg-decoder.js
index 53e7ce45..8f5565e9 100644
--- a/frontend/eqsage-embed/sage/lib/eqg/eqg-decoder.js
+++ b/frontend/eqsage-embed/sage/lib/eqg/eqg-decoder.js
@@ -3,9 +3,10 @@
import { Zone } from "./zone/zone";
import { ZoneData } from "./zone/v4-zone";
import { Model, Animation, Lit } from "./model/model";
+import { MDS } from "./model/mds";
import { Eco } from "./eco/eco";
import { PFSArchive } from "../pfs/pfs";
-import { deleteEqFileOrFolder, writeEQFile } from "../util/fileHandler";
+import { deleteEqFileOrFolder, getEQRootDir, writeEQFile } from "../util/fileHandler";
const getImageProcessor = async () => {
if (typeof window !== 'undefined' && window.imageProcessor) {
@@ -15,6 +16,8 @@ const getImageProcessor = async () => {
return imageProcessor;
};
+const yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0));
+
export class EQGDecoder {
#options = {
forceWrote: false,
@@ -67,12 +70,21 @@ export class EQGDecoder {
return this.#fileHandle.name;
}
+ get options() {
+ return this.#options;
+ }
+
async processBuffer(name, arrayBuffer, skipImages = false) {
this.pfsArchive = new PFSArchive();
this.pfsArchive.openFromFile(arrayBuffer);
const images = [];
this.files = {};
+ let processedFiles = 0;
for (const [fileName, data] of this.pfsArchive.files.entries()) {
+ processedFiles++;
+ if (processedFiles % 10 === 0) {
+ await yieldToBrowser();
+ }
this.files[fileName] = this.pfsArchive.getFile(fileName);
if (fileName.endsWith(".lit")) {
const lit = new Lit(
@@ -133,12 +145,39 @@ export class EQGDecoder {
this.eco[fileName.replace(".eco", "")] = new Eco(this.files[fileName]);
}
if (fileName.endsWith(".mds")) {
- //console.log("Had MDS! ", fileName);
+ const mds = new MDS(
+ this.files[fileName],
+ this.#fileHandle,
+ fileName
+ );
+ for (const { name, model } of mds.models) {
+ this.models[name] = model;
+ }
}
if (fileName.endsWith('.txt')) {
//console.log('Txt', fileName)
}
- console.log('File', fileName)
+ }
+
+ const archiveModelName = this.#fileHandle.name.toLowerCase();
+ const canonicalModName = `${archiveModelName}.mod`;
+ if (!this.models[canonicalModName] && !this.models[archiveModelName]) {
+ const escapedArchiveModelName = archiveModelName.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ '\\$&'
+ );
+ const embeddedArchiveCodePattern = new RegExp(
+ `(?:^|_)${escapedArchiveModelName}(?:_|\\d|\\.)`,
+ 'i'
+ );
+ const canonicalCandidate = Object.entries(this.models).find(
+ ([modelName]) =>
+ modelName.endsWith('.mod') &&
+ embeddedArchiveCodePattern.test(modelName)
+ );
+ if (canonicalCandidate) {
+ this.models[canonicalModName] = canonicalCandidate[1];
+ }
}
// Post process
@@ -164,7 +203,7 @@ export class EQGDecoder {
}
}
console.log(`Processed - ${name}`);
- console.log('Images', images)
+ console.log(`Images queued for conversion: ${images.length}`);
if (this.#options.rawImageWrite) {
console.log('Using raw image write');
} else if (!skipImages) {
@@ -189,25 +228,33 @@ export class EQGDecoder {
*
* @param {import('./common/models').PlaceableGroup} p
*/
- async writeModels(p, zoneMetadata, modelFile, writtenModels, mod, v3) {
+ async writeModels(modelFile, mod, destination) {
const { writeModels } = await import("./gltf-export/common");
return writeModels.apply(this, [
- p,
- zoneMetadata,
modelFile,
- writtenModels,
mod,
- v3,
+ destination,
]);
}
async export() {
if (!this.zone) {
+ const destination = this.#options.modelDestination ?? 'models';
+ const canonicalModName = `${this.name.toLowerCase()}.mod`;
+ if (destination === 'models' && this.models[canonicalModName]) {
+ await this.writeModels(
+ canonicalModName,
+ this.models[canonicalModName],
+ destination
+ );
+ return true;
+ }
for (const [name, mod] of Object.entries(this.models)) {
if (!name.includes('ter_')) {
- await this.writeModels(name, mod);
+ await this.writeModels(name, mod, destination);
}
}
+ return true;
}
if (this.zone?.header?.version === 4) {
const { exportv4 } = await import("./gltf-export/v4");
@@ -222,29 +269,32 @@ export class EQGDecoder {
const micro = performance.now();
for (const file of this.#fileHandle.fileHandles) {
- const extension = file.name.split(".").pop();
+ const extension = file.name.split(".").pop().toLowerCase();
switch (extension) {
case "eqg":
await this.processEQG(file);
break;
case "txt":
- if (file.name.endsWith('_assets.txt') && !this.options.skipSubload) {
- // const contents = (await file.text()).split('\r\n');
- // for (const line of contents) {
- // if (line.endsWith('.eqg')) {
- // console.log(`Loading dependent asset ${line}`);
- // try {
- // const dir = getEQRootDir();
- // const fh = await dir.getFileHandle(line).then(f => f.getFile());
- // await this.processEQG(fh);
- // } catch(e) {
- // console.log(`Error loading dependent asset`, e);
- // }
-
- // }
- // }
+ if (file.name.endsWith('_assets.txt') && !this.#options.skipSubload) {
+ const contents = (await file.text()).split(/\r?\n/);
+ for (const entry of contents) {
+ const line = entry.trim();
+ if (line.toLowerCase().endsWith('.eqg')) {
+ console.log(`Loading dependent asset ${line}`);
+ try {
+ const dir = getEQRootDir();
+ const fh = await dir.getFileHandle(line).then(f => f.getFile());
+ await this.processEQG(fh);
+ await yieldToBrowser();
+ } catch(e) {
+ console.log(`Error loading dependent asset`, e);
+ }
+ }
+ }
}
break;
+ case "s3d":
+ break;
case "eff":
break;
case "xmi":
diff --git a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/common.js b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/common.js
index 5d6eba5f..99a9f524 100644
--- a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/common.js
+++ b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/common.js
@@ -1,15 +1,17 @@
import { Accessor, Document, WebIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS, KHRMaterialsSpecular } from '@gltf-transform/extensions';
-import { mat4, vec3 } from 'gl-matrix';
+import { mat4, quat, vec3 } from 'gl-matrix';
import {
appendObjectMetadata,
getEQFileExists,
writeEQFile,
} from '../../util/fileHandler';
+import { PREVIEW_CHARACTER_MODEL_CACHE_VERSION } from '../../model/constants';
import { EQGAnimationWriter } from './eqg-animation';
const io = new WebIO().registerExtensions(ALL_EXTENSIONS);
+const yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0));
export async function writeMetadata(
infP,
@@ -50,12 +52,15 @@ export async function writeMetadata(
* @this {import('../eqg-decoder').EQGDecoder}
* @returns
*/
-export async function writeModels(modelFile, mod) {
+export async function writeModels(modelFile, mod, destination = 'objects') {
modelFile = modelFile.replace('.mod', '');
const diskFileName = `${modelFile}.glb`;
- await appendObjectMetadata(modelFile, `${this.name}.eqg`);
+ if (destination === 'objects') {
+ await appendObjectMetadata(modelFile, `${this.name}.eqg`);
+ }
if (
- (await getEQFileExists('objects', diskFileName)) &&
+ (await getEQFileExists(destination, diskFileName)) &&
+ !this.options?.forceWrite &&
!modelFile.includes('et_drbanner') &&
!modelFile.includes('dest') &&
!modelFile.includes('ggy') &&
@@ -73,6 +78,7 @@ export async function writeModels(modelFile, mod) {
const node = document.createNode(objectName).setTranslation([0, 0, 0]).setMatrix(flipMatrix);
const skeletonNodes = [];
const boneIndices = [];
+ const boneBindWorldMatrices = [];
if (mod.bones.length) {
for (const bone of mod.bones) {
@@ -85,16 +91,46 @@ export async function writeModels(modelFile, mod) {
}
}
let idx = 0;
- function recurse(i) {
+ function recurse(i, parentBindMatrix = null) {
const bone = mod.bones[i];
- const node = document.createNode(bone.name);
+ const translation = vec3.fromValues(bone.x, bone.z, bone.y);
+ const rotation = quat.normalize(
+ quat.create(),
+ quat.fromValues(bone.rotX, bone.rotZ, bone.rotY, bone.rotW)
+ );
+ const scale = vec3.fromValues(
+ Number.isFinite(bone.scaleX) && Math.abs(bone.scaleX) > 0.000001
+ ? bone.scaleX
+ : 1,
+ Number.isFinite(bone.scaleZ) && Math.abs(bone.scaleZ) > 0.000001
+ ? bone.scaleZ
+ : 1,
+ Number.isFinite(bone.scaleY) && Math.abs(bone.scaleY) > 0.000001
+ ? bone.scaleY
+ : 1
+ );
+ const localBindMatrix = mat4.fromRotationTranslationScale(
+ mat4.create(),
+ rotation,
+ translation,
+ scale
+ );
+ const worldBindMatrix = parentBindMatrix
+ ? mat4.multiply(mat4.create(), parentBindMatrix, localBindMatrix)
+ : localBindMatrix;
+ const node = document
+ .createNode(bone.name)
+ .setTranslation(translation)
+ .setRotation(rotation)
+ .setScale(scale);
skeletonNodes.push(node);
boneIndices[i] = idx;
+ boneBindWorldMatrices[i] = worldBindMatrix;
idx++;
for (const child of bone.children) {
mod.bones[child].parent = bone;
- const [_b, n] = recurse(child);
+ const [_b, n] = recurse(child, worldBindMatrix);
node.addChild(n);
}
return [bone, node];
@@ -102,6 +138,12 @@ export async function writeModels(modelFile, mod) {
recurse(0);
}
+ node.setExtras({
+ spireCharacterModelCacheVersion: PREVIEW_CHARACTER_MODEL_CACHE_VERSION,
+ spireEqgSkinningVersion: 1,
+ spireNativePoseOnly: false,
+ });
+
scene.addChild(node);
const materials = {};
for (const mat of mod.geometry.mats) {
@@ -121,7 +163,16 @@ export async function writeModels(modelFile, mod) {
.setSpecularColorFactor([0, 0, 0]);
gltfMaterial.setExtension('KHR_materials_specular', specular);
for (const prop of mat.properties) {
+ if (prop.type !== 2) {
+ continue;
+ }
const [name] = prop.valueS.toLowerCase().split('.');
+ if (
+ !name ||
+ (mat.name.toLowerCase() === 'failsafeshader' && name === 'grid_standard')
+ ) {
+ continue;
+ }
const texture = document
.createTexture(name)
.setMimeType('image/png')
@@ -161,8 +212,13 @@ export async function writeModels(modelFile, mod) {
}
const primitiveMap = {};
const gltfMesh = document.createMesh(modelFile);
+ let processedPolygons = 0;
for (const p of mod.geometry.polys) {
+ processedPolygons++;
+ if (processedPolygons % 5000 === 0) {
+ await yieldToBrowser();
+ }
if (p.material === -1) {
continue;
}
@@ -340,9 +396,9 @@ export async function writeModels(modelFile, mod) {
for (const [name, ani] of Object.entries(this.animations)) {
// if (name.startsWith(modelFile)) {
- console.log('ani', ani);
animWriter.applyAnimation(ani, name);
animWriter.applyAnimation(ani, name, true);
+ await yieldToBrowser();
// }
}
@@ -359,10 +415,27 @@ export async function writeModels(modelFile, mod) {
const dummyJoint = document.createNode('DUMMY_PLACEHOLDER');
skin.addJoint(dummyJoint);
node.addChild(dummyJoint);
+ const inverseBindMatrices = [];
+ for (let jointIndex = 0; jointIndex < skeletonNodes.length; jointIndex++) {
+ const sourceBoneIndex = boneIndices.indexOf(jointIndex);
+ const worldBindMatrix = boneBindWorldMatrices[sourceBoneIndex];
+ const inverseBindMatrix = worldBindMatrix
+ ? mat4.invert(mat4.create(), worldBindMatrix)
+ : null;
+ inverseBindMatrices.push(...(inverseBindMatrix ?? mat4.create()));
+ }
+ inverseBindMatrices.push(...mat4.create());
+ skin.setInverseBindMatrices(
+ document
+ .createAccessor('inverse-bind-matrices')
+ .setType(Accessor.Type.MAT4)
+ .setArray(new Float32Array(inverseBindMatrices))
+ .setBuffer(buffer)
+ );
} else {
node.setMesh(gltfMesh);
}
const bytes = await io.writeBinary(document);
- await writeEQFile("objects", diskFileName, bytes.buffer); // eslint-disable-line
+ await writeEQFile(destination, diskFileName, bytes.buffer);
}
diff --git a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/eqg-animation.js b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/eqg-animation.js
index 8bc7d2d5..f90709a9 100644
--- a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/eqg-animation.js
+++ b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/eqg-animation.js
@@ -46,8 +46,8 @@ export class EQGAnimationWriter {
for (const ani of animation.boneAnimations) {
const node = this.skeletonNodes.find((n) => n.getName() === ani.boneName);
const bone = this.bones.find((b) => b.name === ani.boneName);
- if (!bone) {
- console.log(`No bone for node ${ani.boneName}`);
+ if (!bone || !node) {
+ console.log(`No skeleton target for animation node ${ani.boneName}`);
continue;
}
@@ -94,18 +94,18 @@ export class EQGAnimationWriter {
for (const [_idx, ani] of Object.entries(animation.boneAnimations)) {
const node = this.skeletonNodes.find((n) => n.getName() === ani.boneName);
const bone = this.bones.find((b) => b.name === ani.boneName);
- if (!bone) {
- console.log(`No bone for node ${ani.boneName}`);
+ if (!bone || !node) {
+ console.log(`No skeleton target for animation node ${ani.boneName}`);
continue;
}
- const [rotX, rotY, rotZ, rotW] = node.getWorldRotation();
- const [x, y, z] = node.getWorldTranslation();
const aniParents = [];
const boneAccum = { ...bone };
let parent = bone.parent;
while (parent) {
const parentAnimation = animation.boneAnimations.find(ba => ba.boneName === parent.name); // eslint-disable-line
- aniParents.push(parentAnimation);
+ if (parentAnimation) {
+ aniParents.push(parentAnimation);
+ }
boneAccum.rotX += parent.rotX;
boneAccum.rotY += parent.rotY;
boneAccum.rotZ += parent.rotZ;
@@ -120,6 +120,9 @@ export class EQGAnimationWriter {
const frameAcc = { ...frame };
for (const p of aniParents) {
const parentFrame = p.animationFrames[idx];
+ if (!parentFrame) {
+ continue;
+ }
frameAcc.rotX += parentFrame.rotX;
frameAcc.rotY += parentFrame.rotY;
frameAcc.rotZ += parentFrame.rotZ;
diff --git a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v3.js b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v3.js
index 4cbf141b..69fca552 100644
--- a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v3.js
+++ b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v3.js
@@ -10,20 +10,31 @@ import draco3d from 'draco3dgltf';
import { locateStaticAsset } from '../../../../src/static-assets';
-const io = new WebIO()
- .registerDependencies({
- 'draco3d.decoder': await draco3d.createDecoderModule({
- locateFile: locateStaticAsset,
- print : console.log,
- printErr: console.error,
- }),
- 'draco3d.encoder': await draco3d.createEncoderModule({
- locateFile: locateStaticAsset,
- print : console.log,
- printErr: console.error,
- }),
- })
- .registerExtensions(ALL_EXTENSIONS);
+const io = new WebIO().registerExtensions(ALL_EXTENSIONS);
+let dracoDependenciesPromise = null;
+
+const ensureDracoDependencies = async () => {
+ if (!dracoDependenciesPromise) {
+ dracoDependenciesPromise = Promise.all([
+ draco3d.createDecoderModule({
+ locateFile: locateStaticAsset,
+ print : console.log,
+ printErr: console.error,
+ }),
+ draco3d.createEncoderModule({
+ locateFile: locateStaticAsset,
+ print : console.log,
+ printErr: console.error,
+ }),
+ ]).then(([decoder, encoder]) => {
+ io.registerDependencies({
+ 'draco3d.decoder': decoder,
+ 'draco3d.encoder': encoder,
+ });
+ });
+ }
+ return dracoDependenciesPromise;
+};
// Helper function to convert uint32 (assumed format 0xAARRGGBB) to normalized [r, g, b, a]
function uint32ToRGBA(color) {
const a = ((color >> 24) & 0xFF) / 255;
@@ -33,6 +44,11 @@ function uint32ToRGBA(color) {
return [r, g, b, a];
}
+const isSpirePreview = () =>
+ typeof window !== 'undefined' && !!window.__spireSagePreview;
+
+const yieldToBrowser = () => new Promise((resolve) => setTimeout(resolve, 0));
+
export async function exportv3(zoneName) {
const document = new Document();
const buffer = document.createBuffer();
@@ -85,7 +101,16 @@ export async function exportv3(zoneName) {
.setSpecularColorFactor([0, 0, 0]);
gltfMaterial.setExtension('KHR_materials_specular', specular);
for (const prop of mat.properties) {
+ if (prop.type !== 2) {
+ continue;
+ }
const [name] = prop.valueS.toLowerCase().split('.');
+ if (
+ !name ||
+ (mat.name.toLowerCase() === 'failsafeshader' && name === 'grid_standard')
+ ) {
+ continue;
+ }
const texture = document
.createTexture(name)
// .setImage(new Uint8Array(await getEQFile('textures', `${name}.png`)))
@@ -113,6 +138,7 @@ export async function exportv3(zoneName) {
const terrain = this.zone.terrain;
const writtenModels = {};
+ let processedPolygons = 0;
for (const [name, mod] of Object.entries(this.models)) {
if (!name.includes('ter_')) {
@@ -137,12 +163,15 @@ export async function exportv3(zoneName) {
await writeMetadata.call(this, placeable, zoneMetadata, modelFile, writtenModels, true);
continue;
}
- console.log('mod', mod);
if (mod) {
const primitiveMap = {};
// Process each polygon in the model geometry.
// (Renaming inner loop variable to "poly" for clarity.)
for (const poly of mod.geometry.polys) {
+ processedPolygons++;
+ if (processedPolygons % 5000 === 0) {
+ await yieldToBrowser();
+ }
if (poly.material === -1) {
continue;
}
@@ -217,7 +246,6 @@ export async function exportv3(zoneName) {
const col3 = uint32ToRGBA(lit[poly.verts[2]]);
sharedPrimitive.colors.push(...col1, ...col2, ...col3);
}
- console.log('Pri', primitiveMap);
// Now create accessors for each primitive.
for (const { gltfPrim, indices, vecs, normals, uv, colors } of Object.values(primitiveMap)) {
const idc = new Uint16Array(indices);
@@ -275,17 +303,18 @@ export async function exportv3(zoneName) {
`${this.zone.name.replace('.zon', '.json')}`,
JSON.stringify(zoneMetadata)
);
- console.log('Start', document);
- try {
- await document.transform(
- // Compress mesh geometry with Draco.
- draco({ ...DRACO_DEFAULTS, quantizationVolume: 'scene' })
- );
- } catch (e) {
- console.log('Error with draco compression', e);
+ if (!isSpirePreview()) {
+ try {
+ await ensureDracoDependencies();
+ await document.transform(
+ // Compress mesh geometry with Draco.
+ draco({ ...DRACO_DEFAULTS, quantizationVolume: 'scene' })
+ );
+ } catch (e) {
+ console.log('Error with draco compression', e);
+ }
}
-
- console.log('Finish draco');
+
const bytes = await io.writeBinary(document);
await writeEQFile('zones', `${zoneName}.glb`, bytes.buffer);
}
diff --git a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v4.js b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v4.js
index 5a5abcd9..95ed8952 100644
--- a/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v4.js
+++ b/frontend/eqsage-embed/sage/lib/eqg/gltf-export/v4.js
@@ -91,7 +91,16 @@ export async function exportv4(zoneName) {
.setSpecularColorFactor([0, 0, 0]);
gltfMaterial.setExtension('KHR_materials_specular', specular);
for (const prop of mat.properties) {
+ if (prop.type !== 2) {
+ continue;
+ }
const [name] = prop.valueS.toLowerCase().split('.');
+ if (
+ !name ||
+ (mat.name.toLowerCase() === 'failsafeshader' && name === 'grid_standard')
+ ) {
+ continue;
+ }
const texture = document
.createTexture(name)
.setURI(`/eq/textures/${name}`)
@@ -263,4 +272,4 @@ export async function exportv4(zoneName) {
const bytes = await io.writeBinary(document);
await writeEQFile('zones', `${zoneName}.glb`, bytes.buffer);
-}
\ No newline at end of file
+}
diff --git a/frontend/eqsage-embed/sage/lib/eqg/model/mds.js b/frontend/eqsage-embed/sage/lib/eqg/model/mds.js
new file mode 100644
index 00000000..2be8bb0d
--- /dev/null
+++ b/frontend/eqsage-embed/sage/lib/eqg/model/mds.js
@@ -0,0 +1,184 @@
+/* eslint-disable */
+import { vec2, vec3 } from 'gl-matrix';
+import { TypedArrayReader } from '../../util/typed-array-reader';
+import {
+ Bone,
+ BoneAssignment,
+ Geometry,
+ Material,
+ MaterialProperty,
+ Polygon,
+ Vertex,
+ Weight,
+} from './model';
+
+const cloneBones = (bones) =>
+ bones.map((source) => {
+ const bone = new Bone();
+ Object.assign(bone, source);
+ bone.children = [];
+ return bone;
+ });
+
+export class MDS {
+ reader = null;
+ fileHandle = null;
+ name = '';
+ version = 0;
+ materials = [];
+ bones = [];
+ models = [];
+
+ constructor(data, fileHandle, name) {
+ this.reader = new TypedArrayReader(data.buffer);
+ this.fileHandle = fileHandle;
+ this.name = name;
+ this.init();
+ }
+
+ init() {
+ const reader = this.reader;
+ const magic = reader.readString(4);
+ if (magic !== 'EQGS') {
+ throw new Error(`MDS ${this.name} does not contain an EQGS header: ${magic}`);
+ }
+
+ const [version, listLength, materialCount, boneCount, modelCount] =
+ reader.readManyUint32(5);
+ this.version = version;
+ const listIdx = reader.getCursor();
+ reader.addCursor(listLength);
+
+ for (let i = 0; i < materialCount; i++) {
+ const material = new Material();
+ reader.readInt32();
+ material.name = reader.readCStringFromIdx(listIdx + reader.readInt32());
+ material.shader = reader.readCStringFromIdx(listIdx + reader.readInt32());
+ const propertyCount = reader.readUint32();
+ for (let j = 0; j < propertyCount; j++) {
+ const property = new MaterialProperty();
+ property.name = reader.readCStringFromIdx(listIdx + reader.readInt32());
+ property.type = reader.readUint32();
+ if (property.type === 0) {
+ property.valueF = reader.readFloat32();
+ } else {
+ const value = reader.readInt32();
+ if (property.type === 2) {
+ property.valueS = reader.readCStringFromIdx(listIdx + value);
+ } else {
+ property.valueI = value;
+ }
+ }
+ material.properties.push(property);
+ }
+ this.materials.push(material);
+ }
+
+ for (let i = 0; i < boneCount; i++) {
+ const bone = new Bone();
+ bone.name = reader.readCStringFromIdx(listIdx + reader.readInt32());
+ bone.next = reader.readInt32();
+ bone.childrenCount = reader.readUint32();
+ bone.childrenIndex = reader.readInt32();
+ const [x, y, z, rotX, rotY, rotZ, rotW, scaleX, scaleY, scaleZ] =
+ reader.readManyFloat32(10);
+ bone.x = -x;
+ bone.y = -y;
+ bone.z = z;
+ bone.rotX = -rotX;
+ bone.rotY = -rotY;
+ bone.rotZ = rotZ;
+ bone.rotW = rotW;
+ bone.scaleX = scaleX;
+ bone.scaleY = scaleY;
+ bone.scaleZ = scaleZ;
+ this.bones.push(bone);
+ }
+
+ const archiveName = this.name.replace(/\.mds$/i, '').toLowerCase();
+ for (let i = 0; i < modelCount; i++) {
+ const mainPiece = reader.readUint32();
+ const sourceName = reader
+ .readCStringFromIdx(listIdx + reader.readInt32())
+ .toLowerCase();
+ const vertexCount = reader.readUint32();
+ const faceCount = reader.readUint32();
+ const modelBoneCount = reader.readUint32();
+ const geometry = new Geometry();
+ geometry.name = sourceName;
+ geometry.mats = this.materials;
+
+ for (let vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) {
+ const vertex = new Vertex();
+ const [x, y, z, normalX, normalY, normalZ] = reader.readManyFloat32(6);
+ vertex.pos = vec3.fromValues(-x, -y, z);
+ vertex.nor = vec3.fromValues(-normalX, -normalY, normalZ);
+ if (version <= 2) {
+ vertex.col = 0xffffffff;
+ const [u, v] = reader.readManyFloat32(2);
+ vertex.tex = vec2.fromValues(-u, -v);
+ } else {
+ vertex.col = reader.readUint32();
+ const [u, v] = reader.readManyFloat32(2);
+ reader.readManyFloat32(2);
+ vertex.tex = vec2.fromValues(u, v);
+ }
+ geometry.verts.push(vertex);
+ }
+
+ for (let faceIndex = 0; faceIndex < faceCount; faceIndex++) {
+ const polygon = new Polygon();
+ polygon.verts = reader.readManyUint32(3);
+ polygon.material = reader.readInt32();
+ polygon.flags = reader.readUint32();
+ geometry.polys.push(polygon);
+ }
+
+ if (this.bones.length > 0) {
+ for (const vertex of geometry.verts) {
+ const assignment = new BoneAssignment();
+ assignment.count = reader.readInt32();
+ for (let weightIndex = 0; weightIndex < 4; weightIndex++) {
+ const weight = new Weight();
+ weight.bone = reader.readInt32();
+ weight.weight = reader.readFloat32();
+ if (weightIndex >= assignment.count) {
+ weight.bone = -1;
+ weight.weight = 0;
+ }
+ assignment.weights.push(weight);
+ }
+ vertex.boneAssignment = assignment;
+ }
+ }
+
+ // Standalone character archives are addressed by their archive/race
+ // code, but a few production MDS files retain an internal artist name
+ // for their only mesh (for example BRX -> MBX), or mark a differently
+ // named head-shaped piece as the main model. Publish that sole/main
+ // piece under the archive code so the runtime can request the race by
+ // its canonical model name. Alternate pieces keep their source names.
+ const isCanonicalPiece = sourceName === `${archiveName}00`;
+ const isSolePiece = modelCount === 1;
+ const isExplicitMainPiece = mainPiece === 1;
+ const isFirstDefaultPiece = i === 0 && /00$/i.test(sourceName);
+ const modelName =
+ isCanonicalPiece || isSolePiece || isExplicitMainPiece || isFirstDefaultPiece
+ ? archiveName
+ : sourceName;
+ this.models.push({
+ name: modelName,
+ model: {
+ name: sourceName,
+ header: {
+ mainPiece,
+ modelBoneCount,
+ version,
+ },
+ geometry,
+ bones: cloneBones(this.bones),
+ },
+ });
+ }
+ }
+}
diff --git a/frontend/eqsage-embed/sage/lib/model/constants.js b/frontend/eqsage-embed/sage/lib/model/constants.js
index d4dddc19..7d42caaa 100644
--- a/frontend/eqsage-embed/sage/lib/model/constants.js
+++ b/frontend/eqsage-embed/sage/lib/model/constants.js
@@ -749,3 +749,11 @@ export const modelDefinitions = {
};
export const VERSION = 2.05;
export const ZONE_VERSION = 2.08;
+export const PREVIEW_ZONE_OBJECT_CACHE_VERSION = 4;
+// Keep preview character cache markers in one dependency-free module. Both the
+// archive writer and the zone loader must agree or a cache can be marked ready
+// while still containing model/texture output from an older exporter.
+export const PREVIEW_CHARACTER_CACHE_VERSION = 34;
+// Embedded in each classic character GLB so model-policy changes can be
+// invalidated independently of the much larger global/zone cache.
+export const PREVIEW_CHARACTER_MODEL_CACHE_VERSION = 2;
diff --git a/frontend/eqsage-embed/sage/lib/model/file-handle.js b/frontend/eqsage-embed/sage/lib/model/file-handle.js
index 871064de..c6e8f683 100644
--- a/frontend/eqsage-embed/sage/lib/model/file-handle.js
+++ b/frontend/eqsage-embed/sage/lib/model/file-handle.js
@@ -1,11 +1,14 @@
-import { FILE_TYPE, VERSION } from './constants';
+import {
+ FILE_TYPE,
+ PREVIEW_CHARACTER_CACHE_VERSION,
+ PREVIEW_ZONE_OBJECT_CACHE_VERSION,
+ VERSION,
+} from './constants';
import { S3DDecoder } from '../s3d/s3d-decoder';
import { Document } from '@gltf-transform/core';
import { EQGDecoder } from '../eqg/eqg-decoder';
import { getEQFile, getEQFileExists } from '../util/fileHandler';
-const PREVIEW_CHARACTER_CACHE_VERSION = 12;
-
const isSpirePreview = () =>
typeof window !== 'undefined' && !!window.__spireSagePreview;
@@ -71,10 +74,24 @@ export class EQFileHandle {
}
get #type() {
- const eqgExists = this.#fileHandles.some(f => f.name === `${this.name}.eqg`);
- const s3dExists = this.#fileHandles.some(f => f.name === `${this.name}.s3d`);
-
- return eqgExists ? FILE_TYPE.EQG : s3dExists ? FILE_TYPE.S3D : FILE_TYPE.NONE;
+ const normalizedNames = this.#fileHandles.map((file) =>
+ `${file.name ?? ''}`.toLowerCase()
+ );
+ const baseName = this.name.toLowerCase();
+ const eqgExists = normalizedNames.includes(`${baseName}.eqg`);
+ const s3dExists = normalizedNames.includes(`${baseName}.s3d`);
+
+ if (eqgExists || s3dExists) {
+ return eqgExists ? FILE_TYPE.EQG : FILE_TYPE.S3D;
+ }
+
+ // Character-only validation intentionally supplies archives such as
+ // greatdivide_chr.s3d without the zone's primary greatdivide.s3d. Treat a
+ // homogeneous filtered archive set as its real format so the decoder runs
+ // instead of reporting a successful no-op.
+ const anyEqg = normalizedNames.some((name) => name.endsWith('.eqg'));
+ const anyS3d = normalizedNames.some((name) => name.endsWith('.s3d'));
+ return anyEqg ? FILE_TYPE.EQG : anyS3d ? FILE_TYPE.S3D : FILE_TYPE.NONE;
}
@@ -112,29 +129,44 @@ export class EQFileHandle {
existingMetadata?.spireCharacterTextures === true &&
existingMetadata?.spireCharacterCacheVersion === PREVIEW_CHARACTER_CACHE_VERSION
);
+ const previewZoneObjectCacheReady =
+ !isSpirePreview() ||
+ existingMetadata?.spireZoneObjectCacheVersion === PREVIEW_ZONE_OBJECT_CACHE_VERSION;
logFileHandleStep(this.name, 'cache:checked', {
glbExists : exists,
metadataVersion: existingMetadata?.version ?? null,
previewCharacterCacheReady,
+ previewZoneObjectCacheReady,
expectedVersion: VERSION,
});
if (
exists &&
existingMetadata?.version === VERSION &&
previewCharacterCacheReady &&
+ previewZoneObjectCacheReady &&
!this.#settings.forceReload
) {
logFileHandleStep(this.name, 'cache:hit, skipping translation');
return;
}
+ const decoderOptions = {
+ ...this.#options,
+ // A stale character cache must overwrite already-exported model GLBs.
+ // Otherwise the archive pass succeeds and advances the cache marker while
+ // leaving old UV/material data on disk.
+ forceWrite:
+ isSpirePreview() &&
+ (!previewCharacterCacheReady || !previewZoneObjectCacheReady),
+ };
if (type === FILE_TYPE.EQG) {
logFileHandleStep(this.name, 'decoder:eqg:start');
- const eqgDecoder = new EQGDecoder(this, this.#options);
+ const eqgDecoder = new EQGDecoder(this, decoderOptions);
await eqgDecoder.process();
logFileHandleStep(this.name, 'decoder:eqg:processed');
if (doExport) {
logFileHandleStep(this.name, 'decoder:eqg:export:start');
await eqgDecoder.export();
+ await this.#processSupplementalArchives(type, decoderOptions);
logFileHandleStep(this.name, 'decoder:eqg:export:done', {
seconds: ((performance.now() - startedAt) / 1000).toFixed(2),
});
@@ -142,12 +174,13 @@ export class EQFileHandle {
}
} else if (type === FILE_TYPE.S3D) {
logFileHandleStep(this.name, 'decoder:s3d:start');
- const s3dDecoder = new S3DDecoder(this, this.#options);
+ const s3dDecoder = new S3DDecoder(this, decoderOptions);
await s3dDecoder.process();
logFileHandleStep(this.name, 'decoder:s3d:processed');
if (doExport) {
logFileHandleStep(this.name, 'decoder:s3d:export:start');
await s3dDecoder.export();
+ await this.#processSupplementalArchives(type, decoderOptions);
logFileHandleStep(this.name, 'decoder:s3d:export:done', {
seconds: ((performance.now() - startedAt) / 1000).toFixed(2),
});
@@ -162,4 +195,51 @@ export class EQFileHandle {
seconds: ((performance.now() - startedAt) / 1000).toFixed(2),
});
}
+
+ async #processSupplementalArchives(primaryType, decoderOptions) {
+ const lowerName = this.name.toLowerCase();
+ const supplementalHandles = this.#fileHandles.filter((file) => {
+ const fileName = file.name.toLowerCase();
+ if (primaryType === FILE_TYPE.EQG) {
+ return fileName.endsWith('.s3d') && fileName !== `${lowerName}.s3d`;
+ }
+ if (primaryType === FILE_TYPE.S3D) {
+ return fileName.endsWith('.eqg') && fileName !== `${lowerName}.eqg`;
+ }
+ return false;
+ });
+
+ if (supplementalHandles.length === 0) {
+ return;
+ }
+
+ const supplementalType =
+ primaryType === FILE_TYPE.EQG ? FILE_TYPE.S3D : FILE_TYPE.EQG;
+ logFileHandleStep(this.name, 'decoder:supplemental:start', {
+ handles: supplementalHandles.map((file) => file.name),
+ type : supplementalType,
+ });
+ const supplementalFileHandle = new EQFileHandle(
+ this.name,
+ supplementalHandles,
+ this.#rootFileHandle,
+ this.#settings,
+ decoderOptions
+ );
+ await supplementalFileHandle.initialize();
+
+ if (supplementalType === FILE_TYPE.S3D) {
+ const decoder = new S3DDecoder(supplementalFileHandle, decoderOptions);
+ await decoder.process();
+ await decoder.export();
+ } else {
+ const decoder = new EQGDecoder(supplementalFileHandle, {
+ ...decoderOptions,
+ modelDestination: 'objects',
+ });
+ await decoder.process();
+ await decoder.export();
+ }
+ logFileHandleStep(this.name, 'decoder:supplemental:done');
+ }
}
diff --git a/frontend/eqsage-embed/sage/lib/pfs/pfs.js b/frontend/eqsage-embed/sage/lib/pfs/pfs.js
index 44ef8054..6b0cfff1 100644
--- a/frontend/eqsage-embed/sage/lib/pfs/pfs.js
+++ b/frontend/eqsage-embed/sage/lib/pfs/pfs.js
@@ -127,7 +127,11 @@ export class PFSArchive {
continue;
}
- const filenameBuffer = new Uint8Array(buffer.slice(offset, offset + size));
+ // `size` is the uncompressed filename-table length, not the number of
+ // compressed bytes stored in the archive. Small tables can compress to
+ // a larger block, so slicing to `offset + size` truncates valid PFS
+ // archives such as the classic `global*_chr2.s3d` head archives.
+ const filenameBuffer = new Uint8Array(buffer.slice(offset, dirOffset));
let filenameInflated;
try {
filenameInflated = this.inflateByFileOffset(filenameBuffer, 0, size);
diff --git a/frontend/eqsage-embed/sage/lib/s3d/s3d-decoder.js b/frontend/eqsage-embed/sage/lib/s3d/s3d-decoder.js
index a269c910..52774607 100644
--- a/frontend/eqsage-embed/sage/lib/s3d/s3d-decoder.js
+++ b/frontend/eqsage-embed/sage/lib/s3d/s3d-decoder.js
@@ -16,7 +16,11 @@ import {
getEQRootDir,
writeEQFile,
} from '../util/fileHandler';
-import { VERSION, ZONE_VERSION } from '../model/constants';
+import {
+ PREVIEW_CHARACTER_MODEL_CACHE_VERSION,
+ VERSION,
+ ZONE_VERSION,
+} from '../model/constants';
import { fragmentNameCleaner } from '../util/util';
import { S3DAnimationWriter, animationMap } from '../util/animation-helper';
import { EQGDecoder } from '../eqg/eqg-decoder';
@@ -26,6 +30,13 @@ import { Wld, WldType } from './wld/wld';
import { ActorType } from './animation/actor';
import { globals } from '../globals';
import { optimizeBoundingBoxes } from './bsp/region-utils';
+import {
+ getCharacterHeadOrientationPolicy,
+} from '../util/character-texture-orientation';
+import {
+ getCharacterAnimationCompatibility,
+ STATIC_POSE_ONLY_CHARACTER_MODELS,
+} from '../util/character-animation-policy';
const io = new WebIO().registerExtensions(ALL_EXTENSIONS);
@@ -59,49 +70,14 @@ const shouldSkipPreviewImages = (fileName, zoneName) =>
!isZoneObjectArchive(fileName);
const CLASSIC_MALE_LEG_SPIKE_MODELS = new Set(['bam', 'erm', 'hum']);
-const CLASSIC_HEAD_TEXTURE_V_PRESERVE_MODELS = new Set([
- 'baf',
- 'bam',
- 'daf',
- 'dam',
- 'dwf',
- 'dwm',
- 'elf',
- 'elm',
- 'erf',
- 'erm',
- 'gnf',
- 'gnm',
- 'haf',
- 'ham',
- 'hif',
- 'him',
- 'hof',
- 'hom',
- 'huf',
- 'hum',
- 'ikf',
- 'ikm',
- 'ogf',
- 'ogm',
- 'trf',
- 'trm',
-]);
-const CHARACTER_HEAD_TEXTURE_PATTERN =
- /^[a-z0-9]{3}(?:he(?:\d{2}|sk)\d{2}|fa\d{4})$/i;
-
-const shouldPreserveClassicHeadTextureV = (materialName) => {
- const name = `${materialName ?? ''}`.toLowerCase();
- const modelName = name.slice(0, 3);
- return (
- CLASSIC_HEAD_TEXTURE_V_PRESERVE_MODELS.has(modelName) &&
- CHARACTER_HEAD_TEXTURE_PATTERN.test(name)
- );
+// DDS decoding flips image pixels vertically before they are cached as PNG.
+// Every discrete head needs one effective V correction. A single shared policy
+// drives geometry conversion, legacy-cache compensation, and validation.
+const shouldFlipSkinnedMeshV = (materialName) => {
+ const policy = getCharacterHeadOrientationPolicy(materialName);
+ return policy.isCharacterHead ? policy.geometryUvFlipped : true;
};
-const shouldFlipSkinnedMeshV = (materialName) =>
- !shouldPreserveClassicHeadTextureV(materialName);
-
const getClassicMaleLegSpikeModel = (materialName) => {
const match = `${materialName ?? ''}`.match(/^([a-z0-9]{3})lg000[12]$/i);
return match?.[1]?.toLowerCase() ?? null;
@@ -231,12 +207,29 @@ export class S3DDecoder {
) {
for (const mesh of meshes) {
const material = mesh.materialList;
- const baseName = (name || mesh.name).split('_')[0].toLowerCase();
+ const rawBaseName = (name || mesh.name).split('_')[0].toLowerCase();
+ const primarySkeletonAlias =
+ path === 'models' &&
+ mesh === skeleton?.meshes?.[0] &&
+ /^[a-z0-9]{3}$/i.test(`${skeleton?.modelBase ?? ''}`)
+ ? skeleton.modelBase.toLowerCase()
+ : null;
+ const characterAlias = path === 'models'
+ ? rawBaseName.match(/^([a-z0-9]{3})(?:mesh|(?:[a-z0-9]{3})?bod)$/)?.[1]
+ : null;
+ const baseName = primarySkeletonAlias ?? characterAlias ?? rawBaseName;
const scrubbedName = material.name.split('_')[0].toLowerCase();
const document = new Document(scrubbedName);
const buffer = document.createBuffer();
const scene = document.createScene(scrubbedName);
const secondary = /he\d+/.test(baseName);
+ // Do not let generation order decide whether a known compact rig gets
+ // its native pose. Without this marker, a cached GLB can be loaded as a
+ // playable character even though it contains POS only, leaving the body
+ // folded or in a T-pose at runtime.
+ let nativePoseOnly =
+ !secondary && STATIC_POSE_ONLY_CHARACTER_MODELS.has(baseName);
+ let animationCompatibility = null;
// Write skeleton data to json if we're a supplier of animations for other models
if (!secondary && Object.values(animationMap).includes(baseName)) {
@@ -274,15 +267,28 @@ export class S3DDecoder {
'json'
));
if (existingAnimations) {
- for (const [key, value] of Object.entries(existingAnimations)) {
- if (secondary) {
- continue;
- }
- if (!skeleton.animations[key]) {
- skeleton.animations[key] = {
- ...value,
- animModelBase: baseName,
- };
+ animationCompatibility = getCharacterAnimationCompatibility({
+ targetPoseTracks:
+ skeleton.animations.pos?.tracksCleanedStripped,
+ donorPoseTracks:
+ existingAnimations.pos?.tracksCleanedStripped,
+ nativeAnimationKeys: Object.keys(skeleton.animations),
+ });
+ nativePoseOnly =
+ !secondary &&
+ (STATIC_POSE_ONLY_CHARACTER_MODELS.has(baseName) ||
+ animationCompatibility.useNativePoseOnly);
+ if (!nativePoseOnly) {
+ for (const [key, value] of Object.entries(existingAnimations)) {
+ if (secondary) {
+ continue;
+ }
+ if (!skeleton.animations[key]) {
+ skeleton.animations[key] = {
+ ...value,
+ animModelBase: baseName,
+ };
+ }
}
}
} else {
@@ -296,6 +302,11 @@ export class S3DDecoder {
node.setExtras({
secondaryMeshes: skeleton.secondaryMeshes.length,
+ spireCharacterModelCacheVersion:
+ PREVIEW_CHARACTER_MODEL_CACHE_VERSION,
+ spireNativePoseOnly: nativePoseOnly,
+ spireAnimationExactBoneCoverage:
+ animationCompatibility?.exactCoverage ?? null,
});
scene.addChild(node);
@@ -316,6 +327,14 @@ export class S3DDecoder {
console.warn(`S3D model had no material link ${name}`);
continue;
}
+ const headOrientation = getCharacterHeadOrientationPolicy(name);
+ gltfMat.setExtras({
+ ...(gltfMat.getExtras?.() ?? {}),
+ spireCharacterHead : headOrientation.isCharacterHead,
+ spireSkinnedVFlipped : shouldFlipSkinnedMeshV(name),
+ spireRuntimeHeadVFlipped:
+ headOrientation.runtimeTextureVFlipped,
+ });
let sharedPrimitive = primitiveMap[name];
if (!sharedPrimitive) {
@@ -493,6 +512,11 @@ export class S3DDecoder {
const bytes = await io.writeBinary(document);
await writeEQFile(path, `${baseName}.glb`, bytes);
+ if (isSpirePreview() && /\d{2}$/.test(baseName)) {
+ console.log(
+ `[SageCharacterExport] wrote ${baseName}.glb from ${wld.name}:${mesh.name}`
+ );
+ }
}
}
@@ -759,7 +783,12 @@ export class S3DDecoder {
// }
const bytes = await io.writeBinary(document);
- await writeEQFile(path, `${skeleton.modelBase}.glb`, bytes);
+ // A character skeleton can own alternate body meshes such as HUM01
+ // (robes). Writing every mesh under the skeleton name silently
+ // overwrites hum.glb and makes the requested hum01.glb impossible to
+ // resolve at runtime. `baseName` preserves the primary skeleton alias
+ // while keeping distinct appearance meshes addressable.
+ await writeEQFile(path, `${baseName}.glb`, bytes);
}
}
/**
@@ -790,6 +819,21 @@ export class S3DDecoder {
}
return isAnimationSupplier ? -1 : 1;
});
+ if (isSpirePreview()) {
+ console.log(`[SageCharacterExport] inventory ${JSON.stringify({
+ archive: wld.name,
+ skeletons: wld.skeletons.map((skeleton) => ({
+ modelBase: skeleton?.modelBase ?? null,
+ meshes: (skeleton?.meshes ?? []).map((mesh) => mesh?.name).slice(0, 24),
+ secondaryMeshes: (skeleton?.secondaryMeshes ?? [])
+ .map((mesh) => mesh?.name)
+ .slice(0, 24),
+ })),
+ meshInventory: wld.meshes
+ .map((mesh) => mesh?.name)
+ .slice(0, 48),
+ })}`);
+ }
const AnimationSources = {};
for (const skeleton of wld.skeletons) {
@@ -876,7 +920,10 @@ export class S3DDecoder {
* @param {*} itemExport
*/
async exportSkeletalActor(wld, skeleton, name, path = 'objects') {
- const meshes = [];
+ const meshes = [
+ ...(skeleton.meshes ?? []),
+ ...(skeleton.secondaryMeshes ?? []),
+ ];
let boneIdx = -1;
for (const [idx, bone] of Object.entries(skeleton.skeleton)) {
@@ -885,7 +932,9 @@ export class S3DDecoder {
bone.meshReference.mesh &&
bone.meshReference.mesh.materialList
) {
- meshes.push(bone.meshReference.mesh);
+ if (!meshes.includes(bone.meshReference.mesh)) {
+ meshes.push(bone.meshReference.mesh);
+ }
boneIdx = +idx;
}
}
@@ -1175,6 +1224,13 @@ export class S3DDecoder {
);
break;
case ActorType.SKELETAL:
+ if (path === 'models') {
+ // Character WLD skeletons were already exported by exportModels.
+ // Exporting their actor definitions again writes each referenced
+ // mesh to the same model filename, so the final head fragment can
+ // overwrite the complete character body.
+ break;
+ }
await this.exportSkeletalActor(
wld,
obj.fragments[0].reference,
@@ -1297,10 +1353,20 @@ export class S3DDecoder {
// Loop over each mesh in the zone.
let processedMeshes = 0;
+ let lastMeshYieldAt = performance.now();
for (const mesh of wld.meshes) {
- if (processedMeshes++ % 3 === 0) {
- globals.GlobalStore.actions.setLoadingText(`Exporting zone mesh ${processedMeshes} of ${wld.meshes.length}`);
+ processedMeshes++;
+ const now = performance.now();
+ if (
+ processedMeshes === 1 ||
+ processedMeshes === wld.meshes.length ||
+ now - lastMeshYieldAt >= 16
+ ) {
+ globals.GlobalStore.actions.setLoadingText(
+ `Exporting zone mesh ${processedMeshes} of ${wld.meshes.length}`
+ );
await yieldToBrowser();
+ lastMeshYieldAt = performance.now();
}
let polygonIndex = 0;
// Process each material group within the mesh.
@@ -1473,9 +1539,9 @@ export class S3DDecoder {
continue;
}
let [name] = eqMaterial.name.toLowerCase().split(/_mdf/i);
-
- if (/m\d+/.test(name) && eqMaterial.bitmapInfo?.reference) {
- name = eqMaterial.bitmapInfo.reference.bitmapNames[0].name;
+ const bitmapNames = eqMaterial.bitmapInfo?.reference?.bitmapNames ?? [];
+ if (bitmapNames.length > 0) {
+ name = bitmapNames[0].name;
}
const gltfMaterial = document
.createMaterial()
@@ -1526,16 +1592,18 @@ export class S3DDecoder {
// 0x42, 0x60, 0x82,
// ]);
// }
- const texture = document
- .createTexture(name.toLowerCase())
- // .setImage(image)
- .setURI(`/eq/textures/${name}`)
- .setExtras({
- name,
- eqShader: eqMaterial.shaderType
- })
- .setMimeType('image/png');
- gltfMaterial.setBaseColorTexture(texture);
+ if (bitmapNames.length > 0) {
+ const texture = document
+ .createTexture(name.toLowerCase())
+ // .setImage(image)
+ .setURI(`/eq/textures/${name}`)
+ .setExtras({
+ name,
+ eqShader: eqMaterial.shaderType
+ })
+ .setMimeType('image/png');
+ gltfMaterial.setBaseColorTexture(texture);
+ }
switch (eqMaterial.shaderType) {
case ShaderType.TransparentMasked:
gltfMaterial
@@ -1557,6 +1625,11 @@ export class S3DDecoder {
gltfMaterial.setAlpha(0);
gltfMaterial.setExtras({ boundary: true });
break;
+ case ShaderType.Invisible:
+ gltfMaterial.setAlphaMode('BLEND');
+ gltfMaterial.setAlpha(0);
+ gltfMaterial.setExtras({ ...extras, invisible: true });
+ break;
default:
gltfMaterial.setAlphaMode('OPAQUE');
break;
@@ -1623,7 +1696,7 @@ export class S3DDecoder {
a.name === `${this.#fileHandle.name}.s3d` ? 1 : -1
);
for (const file of this.#fileHandle.fileHandles) {
- const extension = file.name.split('.').pop();
+ const extension = file.name.split('.').pop().toLowerCase();
switch (extension) {
case 's3d':
await this.processS3D(
@@ -1680,6 +1753,9 @@ export class S3DDecoder {
break;
case 'emt':
break;
+ case 'eqg':
+ case 'zon':
+ break;
default:
console.warn(
`Unhandled extension for ${this.#fileHandle.name} - ${extension}`
diff --git a/frontend/eqsage-embed/sage/lib/s3d/wld/wld.js b/frontend/eqsage-embed/sage/lib/s3d/wld/wld.js
index 81c92543..6f758a57 100644
--- a/frontend/eqsage-embed/sage/lib/s3d/wld/wld.js
+++ b/frontend/eqsage-embed/sage/lib/s3d/wld/wld.js
@@ -125,10 +125,10 @@ export class Wld {
if (typeMap[this.name]) {
return typeMap[this.name];
}
- if (this.name.endsWith("_obj.wld")) {
+ if (/_obj\d*\.wld$/i.test(this.name)) {
return WldType.Objects;
}
- if (this.name.endsWith("_chr.wld")) {
+ if (/_chr\d*\.wld$/i.test(this.name)) {
return WldType.Characters;
}
if (this.name.startsWith("gequip")) {
diff --git a/frontend/eqsage-embed/sage/lib/util/animation-helper.js b/frontend/eqsage-embed/sage/lib/util/animation-helper.js
index 5c05c828..5f17189b 100644
--- a/frontend/eqsage-embed/sage/lib/util/animation-helper.js
+++ b/frontend/eqsage-embed/sage/lib/util/animation-helper.js
@@ -29,6 +29,8 @@ export const animationMap = {
bet: 'spi',
cpf: 'cpm',
frg: 'fro',
+ frm: 'elm',
+ frf: 'elf',
gam: 'gar',
ghu: 'gob',
fpm: 'elm',
@@ -317,7 +319,6 @@ export class S3DAnimationWriter {
const poseArray = isCharacterAnimation
? skeleton.animations['pos'].tracksCleanedStripped
: skeleton.animations['pos'].tracksCleaned;
-
if (poseArray === null) {
return;
}
@@ -334,7 +335,6 @@ export class S3DAnimationWriter {
skeleton.modelBase
)
: Animation.CleanBoneName(skeleton.boneMapping[i]);
-
if (
staticPose ||
shouldUsePoseForCharacterHeadBone(skeleton, boneName, animationKey) ||
diff --git a/frontend/eqsage-embed/sage/lib/util/character-animation-policy.js b/frontend/eqsage-embed/sage/lib/util/character-animation-policy.js
new file mode 100644
index 00000000..f20c1263
--- /dev/null
+++ b/frontend/eqsage-embed/sage/lib/util/character-animation-policy.js
@@ -0,0 +1,66 @@
+const POSE_ANIMATION_NAME = 'pos';
+
+// These compact classic character rigs intentionally ship with a native POS
+// clip but no compatible playable animation set. This is an asset property,
+// not a cache-order decision: the exporter must preserve the native pose even
+// when the mapped donor animation JSON has not been generated yet.
+export const STATIC_POSE_ONLY_CHARACTER_MODELS = new Set([
+ 'qcm',
+ 'qcf',
+ 'clm',
+ 'clf',
+ // Coldain use their own compact hierarchy. The mapped classic dwarf donor
+ // covers only a small fraction of their exact bone names, so applying that
+ // animation folds the body into the head/torso. Their native POS clip is the
+ // authoritative safe preview pose.
+ 'com',
+ 'cof',
+]);
+
+const normalizeKeys = (value) =>
+ Object.keys(value ?? {})
+ .map((key) => `${key}`.trim().toLowerCase())
+ .filter(Boolean);
+
+// Shared classic animations are only safe when their tracks describe the same
+// target bones. Compact rigs can be anatomically valid while using an entirely
+// different hierarchy; applying donor-local transforms to them folds or
+// scatters the mesh. Prefer the target model's native pose in that case.
+export const getCharacterAnimationCompatibility = ({
+ targetPoseTracks,
+ donorPoseTracks,
+ nativeAnimationKeys = [],
+ minimumExactCoverage = 0.8,
+}) => {
+ const targetBones = normalizeKeys(targetPoseTracks);
+ const donorBones = new Set(normalizeKeys(donorPoseTracks));
+ const nativeKeys = nativeAnimationKeys
+ .map((key) => `${key}`.trim().toLowerCase())
+ .filter(Boolean);
+ const hasNativePose = nativeKeys.includes(POSE_ANIMATION_NAME);
+ const hasNativePlayableAnimation = nativeKeys.some(
+ (key) => key !== POSE_ANIMATION_NAME
+ );
+ const exactBoneCount = targetBones.filter((bone) => donorBones.has(bone)).length;
+ const exactCoverage = targetBones.length > 0
+ ? exactBoneCount / targetBones.length
+ : 0;
+ const useNativePoseOnly =
+ hasNativePose &&
+ !hasNativePlayableAnimation &&
+ targetBones.length >= 5 &&
+ exactCoverage < minimumExactCoverage;
+
+ return {
+ targetBoneCount: targetBones.length,
+ donorBoneCount: donorBones.size,
+ exactBoneCount,
+ exactCoverage,
+ hasNativePose,
+ hasNativePlayableAnimation,
+ useNativePoseOnly,
+ };
+};
+
+export const shouldUseNativeCharacterPose = (options) =>
+ getCharacterAnimationCompatibility(options).useNativePoseOnly;
diff --git a/frontend/eqsage-embed/sage/lib/util/character-texture-orientation.js b/frontend/eqsage-embed/sage/lib/util/character-texture-orientation.js
new file mode 100644
index 00000000..f4ae998f
--- /dev/null
+++ b/frontend/eqsage-embed/sage/lib/util/character-texture-orientation.js
@@ -0,0 +1,41 @@
+export const CHARACTER_HEAD_TEXTURE_PATTERN =
+ /^[a-z0-9]{3}(?:he(?:\d{2}|sk)\d{2}|fa\d{4})$/i;
+
+export const normalizeCharacterMaterialName = (materialName) =>
+ `${materialName ?? ''}`.split(/_mdf/i)[0].toLowerCase();
+
+// DDS decoding flips image pixels vertically before they are cached as PNG.
+// Most discrete heads therefore need the same geometry V correction as other
+// skinned meshes. These observed integrated HE00 heads already address the
+// cached image correctly. Runtime face swaps use HESK/FA materials on that same
+// geometry, while separately loaded HE01+ helm/head meshes retain the normal
+// flipped policy.
+export const CHARACTER_HEAD_NATIVE_UV_MODELS = new Set([
+ 'brf',
+ 'brm',
+ 'fef',
+ 'gff',
+ 'qcf',
+ 'shf',
+]);
+const CHARACTER_HEAD_NATIVE_UV_MATERIAL_PATTERN =
+ /^[a-z0-9]{3}(?:he(?:00|sk)\d{2}|fa\d{4})$/i;
+
+export const getCharacterHeadOrientationPolicy = (materialName) => {
+ const normalizedName = normalizeCharacterMaterialName(materialName);
+ const isCharacterHead = CHARACTER_HEAD_TEXTURE_PATTERN.test(normalizedName);
+ const modelName = normalizedName.slice(0, 3);
+ const usesNativeHeadUv =
+ isCharacterHead && (
+ CHARACTER_HEAD_NATIVE_UV_MODELS.has(modelName) &&
+ CHARACTER_HEAD_NATIVE_UV_MATERIAL_PATTERN.test(normalizedName)
+ );
+
+ return {
+ isCharacterHead,
+ modelName,
+ usesNativeHeadUv,
+ geometryUvFlipped: isCharacterHead && !usesNativeHeadUv,
+ runtimeTextureVFlipped: false,
+ };
+};
diff --git a/frontend/eqsage-embed/sage/lib/util/fileHandler.js b/frontend/eqsage-embed/sage/lib/util/fileHandler.js
index 2f969186..a66ce130 100644
--- a/frontend/eqsage-embed/sage/lib/util/fileHandler.js
+++ b/frontend/eqsage-embed/sage/lib/util/fileHandler.js
@@ -115,6 +115,7 @@ let cachedDirHandle = null;
const handles = {};
const previewReadCache = new Map();
let previewReadCacheBytes = 0;
+const directoryWriteRevisions = new Map();
const MAX_PREVIEW_READ_CACHE_BYTES = 32 * 1024 * 1024;
const MAX_PREVIEW_READ_CACHE_ENTRY_BYTES = 4 * 1024 * 1024;
@@ -206,6 +207,16 @@ export const clearPreviewReadCache = () => {
previewReadCacheBytes = 0;
};
+export const getEQFileDirectoryRevision = (directory) =>
+ directoryWriteRevisions.get(directory) ?? 0;
+
+const advanceEQFileDirectoryRevision = (directory) => {
+ directoryWriteRevisions.set(
+ directory,
+ getEQFileDirectoryRevision(directory) + 1
+ );
+};
+
export const getEQSageDir = async () => {
const rootHandle = getActiveRootHandle();
if (!rootHandle) {
@@ -283,6 +294,7 @@ export const writeEQFile = async (directory, name, buffer, subdir = undefined) =
await writable.close();
if (!subdir) {
cacheRead(directory, name, cloneArrayBuffer(buffer));
+ advanceEQFileDirectoryRevision(directory);
}
return true;
}
diff --git a/frontend/eqsage-embed/sage/lib/util/fileSystem.js b/frontend/eqsage-embed/sage/lib/util/fileSystem.js
index 40686827..1ff32383 100644
--- a/frontend/eqsage-embed/sage/lib/util/fileSystem.js
+++ b/frontend/eqsage-embed/sage/lib/util/fileSystem.js
@@ -127,6 +127,82 @@ if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScop
}
}
+// A Sage load can perform thousands of existence checks against the same large
+// texture directory. Keep one snapshot for the duration of a normal editing
+// session; writes made through this adapter update the snapshot below, and a
+// full page reload naturally starts with a fresh cache.
+const DIRECTORY_CACHE_TTL_MS = 5 * 60 * 1000;
+const directoryEntryCache = new Map();
+const directoryEntryRequests = new Map();
+
+const getDirectoryCacheKey = (path) =>
+ `${path}`.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase();
+
+const createDirectorySnapshot = (entries) => ({
+ entries,
+ entriesByLowerName: new Map(entries.map((entry) => [entry.name.toLowerCase(), entry])),
+ entriesByName : new Map(entries.map((entry) => [entry.name, entry])),
+ expiresAt : Date.now() + DIRECTORY_CACHE_TTL_MS,
+});
+
+const readDirectoryEntries = async (path) => {
+ const key = getDirectoryCacheKey(path);
+ const cached = directoryEntryCache.get(key);
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached;
+ }
+
+ const pending = directoryEntryRequests.get(key);
+ if (pending) {
+ return pending;
+ }
+
+ const request = fsInterface.readDir(path)
+ .then((entries) => {
+ const snapshot = createDirectorySnapshot(entries);
+ directoryEntryCache.set(key, snapshot);
+ return snapshot;
+ })
+ .finally(() => {
+ directoryEntryRequests.delete(key);
+ });
+ directoryEntryRequests.set(key, request);
+ return request;
+};
+
+const updateCachedDirectoryEntry = (directoryPath, entry) => {
+ const key = getDirectoryCacheKey(directoryPath);
+ const cached = directoryEntryCache.get(key);
+ if (!cached) {
+ return;
+ }
+
+ const lowerName = entry.name.toLowerCase();
+ const previous = cached.entriesByLowerName.get(lowerName);
+ const entries = previous
+ ? cached.entries.map((candidate) => candidate === previous ? entry : candidate)
+ : [...cached.entries, entry];
+ directoryEntryCache.set(key, createDirectorySnapshot(entries));
+};
+
+const removeCachedDirectoryEntry = (directoryPath, name) => {
+ const key = getDirectoryCacheKey(directoryPath);
+ const cached = directoryEntryCache.get(key);
+ if (!cached) {
+ return;
+ }
+
+ const lowerName = name.toLowerCase();
+ directoryEntryCache.set(
+ key,
+ createDirectorySnapshot(
+ cached.entries.filter((entry) => entry.name.toLowerCase() !== lowerName)
+ )
+ );
+};
+
+const getParentDirectory = (path) => path.slice(0, path.lastIndexOf('/'));
+
class SageFileSystemFileHandle {
#path = '';
kind = 'file';
@@ -142,6 +218,12 @@ class SageFileSystemFileHandle {
locked: false,
async write(data) {
await fsInterface.writeFile(path, data instanceof ArrayBuffer ? new Uint8Array(data) : data);
+ updateCachedDirectoryEntry(getParentDirectory(path), {
+ isDirectory: false,
+ isFile : true,
+ name : path.split('/').at(-1),
+ path,
+ });
},
close() {},
getWriter() {
@@ -153,6 +235,7 @@ class SageFileSystemFileHandle {
}
async removeEntry() {
await fsInterface.deleteFile(`${this.#path}`);
+ removeCachedDirectoryEntry(getParentDirectory(this.#path), this.name);
}
async getFile() {
const rootPath = this.#path;
@@ -189,15 +272,15 @@ export class SageFileSystemDirectoryHandle {
}
async *values() {
- const flatFiles = await fsInterface.readDir(this.#path);
- for (const f of flatFiles) {
+ const { entries } = await readDirectoryEntries(this.#path);
+ for (const f of entries) {
yield f.isDirectory ? new SageFileSystemDirectoryHandle(f.path) : new SageFileSystemFileHandle(f.path);
}
}
async *entries() {
- const flatFiles = await fsInterface.readDir(this.#path);
- for (const f of flatFiles) {
+ const { entries } = await readDirectoryEntries(this.#path);
+ for (const f of entries) {
yield [f.name, f.isDirectory ? new SageFileSystemDirectoryHandle(f.path) : new SageFileSystemFileHandle(f.path)];
}
}
@@ -205,30 +288,49 @@ export class SageFileSystemDirectoryHandle {
async getFileHandle(name, options = {}) {
const fullPath = `${this.#path}/${name}`;
if (!options?.create) {
- const entries = await fsInterface.readDir(this.#path).catch(() => []);
- const fileEntry = entries.find((entry) => entry.name === name && entry.isFile);
+ const snapshot = await readDirectoryEntries(this.#path).catch(() => createDirectorySnapshot([]));
+ const fileEntry = snapshot.entriesByName.get(name) ??
+ snapshot.entriesByLowerName.get(name.toLowerCase());
if (!fileEntry) {
throw new DOMException(
`File not found: ${fullPath}`,
'NotFoundError'
);
}
+ if (!fileEntry.isFile) {
+ throw new DOMException(
+ `Not a file: ${fullPath}`,
+ 'TypeMismatchError'
+ );
+ }
+ return new SageFileSystemFileHandle(fileEntry.path);
}
return new SageFileSystemFileHandle(fullPath);
}
async removeEntry(path) {
const fullPath = `${this.#path}/${path}`;
- const errHandler = e => {
- console.log('Error deleting path', fullPath, e);
- };
- await fsInterface.deleteFolder(fullPath).catch(errHandler);
- await fsInterface.deleteFile(fullPath).catch(errHandler);
+ const snapshot = await readDirectoryEntries(this.#path)
+ .catch(() => createDirectorySnapshot([]));
+ const entry = snapshot.entriesByName.get(path) ??
+ snapshot.entriesByLowerName.get(path.toLowerCase());
+ if (entry?.isDirectory) {
+ await fsInterface.deleteFolder(fullPath);
+ } else if (entry?.isFile) {
+ await fsInterface.deleteFile(fullPath);
+ }
+ removeCachedDirectoryEntry(this.#path, path);
}
async getDirectoryHandle(name, _options) {
const path = `${this.#path}/${name}`;
- await fsInterface.createIfNotExist(path);
+ await fsInterface.createIfNotExist(path);
+ updateCachedDirectoryEntry(this.#path, {
+ isDirectory: true,
+ isFile : false,
+ name,
+ path,
+ });
return new SageFileSystemDirectoryHandle(path);
}
}
diff --git a/frontend/eqsage-embed/sage/lib/util/util.js b/frontend/eqsage-embed/sage/lib/util/util.js
index 95d11104..16b9e4d1 100644
--- a/frontend/eqsage-embed/sage/lib/util/util.js
+++ b/frontend/eqsage-embed/sage/lib/util/util.js
@@ -18,6 +18,8 @@ export const decodeString = (reader, size) => {
* @param {import('../s3d/wld/wld-fragment').WldFragment} fragment
* @param {boolean} toLower
*/
+const unmappedFragmentClasses = new Set();
+
export const fragmentNameCleaner = (fragment, toLower = true) => {
const typeMap = {
MaterialList : '_MP',
@@ -35,7 +37,11 @@ export const fragmentNameCleaner = (fragment, toLower = true) => {
.replace(typeMap[fragment.ClassName], '')
.replace(typeMap[fragment.ClassName].toLowerCase(), '');
} else {
- console.warn('Not mapped', fragment.ClassName);
+ const className = fragment.ClassName?.name ?? `${fragment.ClassName}`;
+ if (!unmappedFragmentClasses.has(className)) {
+ unmappedFragmentClasses.add(className);
+ console.warn('Not mapped', className);
+ }
}
if (toLower) {
diff --git a/frontend/eqsage-embed/scripts/audit-race-skin-textures.mjs b/frontend/eqsage-embed/scripts/audit-race-skin-textures.mjs
new file mode 100644
index 00000000..5d5eb044
--- /dev/null
+++ b/frontend/eqsage-embed/scripts/audit-race-skin-textures.mjs
@@ -0,0 +1,186 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import Jimp from 'jimp';
+
+const CHARACTER_TEXTURE_PATTERN =
+ /^(?[a-z0-9]{3})(?[a-z]{2})(?\d{2})(?\d{2})\.png$/i;
+const DEFAULT_EQ_DIRECTORIES = ['C:/EQEmuCW-Live'];
+const MAX_CONCURRENCY = 16;
+
+const getArgument = (name) => {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : null;
+};
+
+const pathExists = async (candidate) =>
+ fs.access(candidate).then(() => true).catch(() => false);
+
+const resolveEqDirectory = async () => {
+ const candidates = [
+ getArgument('--eq-dir'),
+ process.env.SAGE_EQ_DIR,
+ ...DEFAULT_EQ_DIRECTORIES,
+ ].filter(Boolean);
+ for (const candidate of candidates) {
+ const absolute = path.resolve(candidate);
+ if (await pathExists(path.join(absolute, 'eqsage', 'textures'))) {
+ return absolute;
+ }
+ }
+ throw new Error(
+ 'No EQ directory with eqsage/textures was found. Pass --eq-dir .'
+ );
+};
+
+const runPool = async (items, worker, concurrency = MAX_CONCURRENCY) => {
+ const results = new Array(items.length);
+ let nextIndex = 0;
+ const runners = Array.from(
+ { length: Math.min(concurrency, Math.max(items.length, 1)) },
+ async () => {
+ while (nextIndex < items.length) {
+ const index = nextIndex++;
+ results[index] = await worker(items[index], index);
+ }
+ }
+ );
+ await Promise.all(runners);
+ return results;
+};
+
+const isFullyTransparent = (bitmap) => {
+ for (let index = 3; index < bitmap.data.length; index += 4) {
+ if (bitmap.data[index] !== 0) {
+ return false;
+ }
+ }
+ return true;
+};
+
+const main = async () => {
+ const eqDirectory = await resolveEqDirectory();
+ const textureDirectory = path.join(eqDirectory, 'eqsage', 'textures');
+ const modelDirectory = path.join(eqDirectory, 'eqsage', 'models');
+ const raceDataPath = new URL(
+ '../src/viewer/common/raceData.json',
+ import.meta.url
+ );
+ const raceData = JSON.parse(await fs.readFile(raceDataPath, 'utf8'));
+ const mappedModels = new Set(
+ raceData.flatMap((race) => [race['0'], race['1'], race['2']])
+ .map((model) => `${model ?? ''}`.trim().toLowerCase())
+ .filter(Boolean)
+ );
+
+ const textureNames = await fs.readdir(textureDirectory);
+ const textureFiles = new Map(
+ textureNames
+ .filter((name) => /\.png$/i.test(name))
+ .map((name) => [name.toLowerCase(), path.join(textureDirectory, name)])
+ );
+ const candidateNames = [...textureFiles.keys()].filter((name) => {
+ const match = name.match(CHARACTER_TEXTURE_PATTERN);
+ return match && mappedModels.has(match.groups.model.toLowerCase());
+ });
+
+ const decodeCache = new Map();
+ const decode = (name) => {
+ if (!decodeCache.has(name)) {
+ decodeCache.set(
+ name,
+ Jimp.read(textureFiles.get(name)).then((image) => ({
+ width: image.bitmap.width,
+ height: image.bitmap.height,
+ fullyTransparent: isFullyTransparent(image.bitmap),
+ }))
+ );
+ }
+ return decodeCache.get(name);
+ };
+
+ const decodedCandidates = await runPool(candidateNames, async (name) => ({
+ name,
+ ...(await decode(name)),
+ }));
+ const transparentCandidates = decodedCandidates.filter(
+ (entry) => entry.fullyTransparent
+ );
+ const classifications = await runPool(transparentCandidates, async (entry) => {
+ const match = entry.name.match(CHARACTER_TEXTURE_PATTERN);
+ const { model, part, slot } = match.groups;
+ const prefix = `${model}${part}`.toLowerCase();
+ const fallbackNames = [`${prefix}sk${slot}.png`, `${prefix}00${slot}.png`]
+ .filter((name) => name !== entry.name && textureFiles.has(name));
+ const fallbacks = await Promise.all(
+ fallbackNames.map(async (name) => ({ name, ...(await decode(name)) }))
+ );
+ const opaqueFallback = fallbacks.find((fallback) => !fallback.fullyTransparent);
+ return {
+ ...entry,
+ model: model.toLowerCase(),
+ part: part.toLowerCase(),
+ slot,
+ strategy: opaqueFallback ? 'same-uv-skin-fallback' : 'suppress-invisible-layer',
+ fallback: opaqueFallback?.name ?? null,
+ };
+ });
+
+ const availableModels = new Set(
+ (await fs.readdir(modelDirectory))
+ .filter((name) => /\.glb$/i.test(name))
+ .map((name) => path.parse(name).name.toLowerCase())
+ );
+ const affectedModels = [...new Set(classifications.map((entry) => entry.model))]
+ .sort();
+ const affectedMissingModels = affectedModels.filter(
+ (model) => !availableModels.has(model)
+ );
+ const fallbackCount = classifications.filter(
+ (entry) => entry.strategy === 'same-uv-skin-fallback'
+ ).length;
+ const suppressedCount = classifications.length - fallbackCount;
+ const report = {
+ eqDirectory,
+ mappedRaceModelCount: mappedModels.size,
+ mappedAvailableModelCount: [...mappedModels].filter((model) =>
+ availableModels.has(model)
+ ).length,
+ pngTextureCount: textureFiles.size,
+ numericCharacterTextureCount: candidateNames.length,
+ fullyTransparentNumericTextureCount: classifications.length,
+ sameUvSkinFallbackCount: fallbackCount,
+ suppressInvisibleLayerCount: suppressedCount,
+ affectedModelCount: affectedModels.length,
+ affectedAvailableModelCount:
+ affectedModels.length - affectedMissingModels.length,
+ affectedMissingModelCount: affectedMissingModels.length,
+ affectedMissingModels,
+ affectedModels,
+ modelsUsingSameUvFallback: [...new Set(
+ classifications
+ .filter((entry) => entry.strategy === 'same-uv-skin-fallback')
+ .map((entry) => entry.model)
+ )].sort(),
+ modelsUsingInvisibleSuppression: [...new Set(
+ classifications
+ .filter((entry) => entry.strategy === 'suppress-invisible-layer')
+ .map((entry) => entry.model)
+ )].sort(),
+ };
+
+ const outputPath = getArgument('--output');
+ if (outputPath) {
+ const absoluteOutput = path.resolve(outputPath);
+ await fs.mkdir(path.dirname(absoluteOutput), { recursive: true });
+ await fs.writeFile(
+ absoluteOutput,
+ JSON.stringify({ ...report, classifications }, null, 2)
+ );
+ }
+ console.log(JSON.stringify(report, null, 2));
+};
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend/eqsage-embed/scripts/generate-race-face-audit.mjs b/frontend/eqsage-embed/scripts/generate-race-face-audit.mjs
new file mode 100644
index 00000000..95936605
--- /dev/null
+++ b/frontend/eqsage-embed/scripts/generate-race-face-audit.mjs
@@ -0,0 +1,249 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { resolveEqDirectory } from '../../../tools/sage-qa/lib/profile.mjs';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDir, '..', '..', '..');
+const eqDirectory = await resolveEqDirectory({
+ requested: process.argv.includes('--eq-dir')
+ ? process.argv[process.argv.indexOf('--eq-dir') + 1]
+ : null,
+});
+const eqSageData = path.join(eqDirectory, 'eqsage', 'data');
+const outputDir = path.join(repoRoot, 'tmp', 'race-face-audit');
+const raceDataPath = path.join(
+ repoRoot,
+ 'frontend',
+ 'eqsage-embed',
+ 'src',
+ 'viewer',
+ 'common',
+ 'raceData.json'
+);
+
+const readJson = async (filePath, fallback = null) => {
+ try {
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
+ } catch (error) {
+ if (fallback !== null) {
+ return fallback;
+ }
+ throw error;
+ }
+};
+
+const csvCell = (value) => {
+ const text = Array.isArray(value) ? value.join('; ') : `${value ?? ''}`;
+ return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
+};
+
+const GENDERS = [
+ ['0', 'male'],
+ ['1', 'female'],
+ ['2', 'neutral'],
+];
+
+await fs.mkdir(outputDir, { recursive: true });
+
+const supplementalAuditFiles = (await fs.readdir(outputDir))
+ .filter((fileName) => /^model-audit-.*\.json$/i.test(fileName))
+ .sort();
+
+const [
+ raceData,
+ latestModelAudit,
+ archivedFullAudit,
+ supplementalModelAudits,
+ archiveAudit,
+ alternateArchiveAudit,
+] = await Promise.all([
+ readJson(raceDataPath),
+ readJson(path.join(eqSageData, 'spire-race-face-audit.json')),
+ readJson(path.join(outputDir, 'race-face-audit-pass1.json'), { results: [] }),
+ Promise.all(
+ supplementalAuditFiles.map((fileName) =>
+ readJson(path.join(outputDir, fileName), { results: [] })
+ )
+ ),
+ readJson(path.join(eqSageData, 'spire-race-archive-audit.json'), {
+ complete: false,
+ reports: [],
+ }),
+ readJson(path.join(eqSageData, 'spire-race-archive-audit-alternate.json'), {
+ complete: false,
+ reports: [],
+ }),
+]);
+
+const archiveAudits = [archiveAudit, alternateArchiveAudit];
+const archiveReports = archiveAudits.flatMap((audit) => audit.reports ?? []);
+
+const resultByModel = new Map();
+for (const audit of [
+ archivedFullAudit,
+ ...supplementalModelAudits,
+ latestModelAudit,
+]) {
+ for (const result of audit.results ?? []) {
+ resultByModel.set(result.model.toLowerCase(), result);
+ }
+}
+const variants = [];
+for (const race of raceData) {
+ for (const [genderKey, gender] of GENDERS) {
+ const model = `${race[genderKey] ?? ''}`.trim().toLowerCase();
+ if (!model) {
+ continue;
+ }
+ const result = resultByModel.get(model);
+ variants.push({
+ raceId: Number(race.id),
+ raceName: race.name,
+ gender,
+ model: model.toUpperCase(),
+ status: result?.status ?? 'not-audited',
+ faceType: (result?.headTextureCount ?? 0) > 0
+ ? 'discrete-head-material'
+ : 'integrated-or-no-face',
+ modelLoaded: result?.modelLoaded ?? false,
+ renderPass: result?.renderPass ?? false,
+ meshCount: result?.meshCount ?? 0,
+ materialSlotCount: result?.materialSlotCount ?? 0,
+ texturedSlotCount: result?.texturedSlotCount ?? 0,
+ readyTextureCount: result?.readyTextureCount ?? 0,
+ pendingTextureCount: result?.pendingTextureCount ?? 0,
+ fallbackTextureCount: result?.fallbackTextureCount ?? 0,
+ headTextureCount: result?.headTextureCount ?? 0,
+ verticallyFlippedHeadTextureCount:
+ result?.verticallyFlippedHeadTextureCount ?? 0,
+ fallbackTextures: result?.fallbackTextures ?? [],
+ error: result?.error ?? '',
+ renderError: result?.renderError ?? '',
+ });
+ }
+}
+
+const races = raceData.map((race) => {
+ const raceVariants = variants.filter((variant) => variant.raceId === Number(race.id));
+ const unresolved = raceVariants.filter(
+ (variant) => !variant.status.startsWith('pass-')
+ );
+ return {
+ raceId: Number(race.id),
+ raceName: race.name,
+ status: unresolved.length === 0 ? 'validated' : 'unresolved',
+ variantCount: raceVariants.length,
+ validatedVariantCount: raceVariants.length - unresolved.length,
+ unresolvedVariantCount: unresolved.length,
+ models: raceVariants.map((variant) => variant.model),
+ unresolvedModels: unresolved.map((variant) => variant.model),
+ };
+});
+
+const unresolvedVariants = variants.filter(
+ (variant) => !variant.status.startsWith('pass-')
+);
+const unresolvedRaces = races.filter((race) => race.status !== 'validated');
+const summary = {
+ complete:
+ resultByModel.size === new Set(variants.map((variant) => variant.model)).size &&
+ unresolvedVariants.length === 0,
+ raceCount: races.length,
+ validatedRaceCount: races.length - unresolvedRaces.length,
+ unresolvedRaceCount: unresolvedRaces.length,
+ variantCount: variants.length,
+ validatedVariantCount: variants.length - unresolvedVariants.length,
+ unresolvedVariantCount: unresolvedVariants.length,
+ uniqueModelCount: resultByModel.size,
+ validatedModelCount: [...resultByModel.values()].filter((result) =>
+ result.status.startsWith('pass-')
+ ).length,
+ unresolvedModelCount: [...resultByModel.values()].filter((result) =>
+ !result.status.startsWith('pass-')
+ ).length,
+ archiveAuditComplete: archiveAudits.every((audit) => audit.complete === true),
+ sourceZoneCount: archiveReports.length,
+ sourceZoneFailureCount: archiveReports.filter((report) => !report.pass).length,
+ generatedAt: new Date().toISOString(),
+};
+
+const output = {
+ summary,
+ races,
+ variants,
+ unresolvedRaces,
+ unresolvedVariants,
+ archiveFailures: archiveReports.filter((report) => !report.pass),
+};
+
+const csvHeaders = [
+ 'raceId',
+ 'raceName',
+ 'gender',
+ 'model',
+ 'status',
+ 'faceType',
+ 'modelLoaded',
+ 'renderPass',
+ 'meshCount',
+ 'materialSlotCount',
+ 'texturedSlotCount',
+ 'readyTextureCount',
+ 'pendingTextureCount',
+ 'fallbackTextureCount',
+ 'headTextureCount',
+ 'verticallyFlippedHeadTextureCount',
+ 'fallbackTextures',
+ 'error',
+ 'renderError',
+];
+const csv = [
+ csvHeaders.join(','),
+ ...variants.map((variant) =>
+ csvHeaders.map((header) => csvCell(variant[header])).join(',')
+ ),
+].join('\n');
+
+const markdown = [
+ '# Spire Race Face Audit',
+ '',
+ `Generated: ${summary.generatedAt}`,
+ '',
+ '| Metric | Count |',
+ '| --- | ---: |',
+ `| Race definitions | ${summary.raceCount} |`,
+ `| Validated races | ${summary.validatedRaceCount} |`,
+ `| Unresolved races | ${summary.unresolvedRaceCount} |`,
+ `| Race/gender variants | ${summary.variantCount} |`,
+ `| Unique model codes audited | ${summary.uniqueModelCount} |`,
+ `| Validated model codes | ${summary.validatedModelCount} |`,
+ `| Unresolved model codes | ${summary.unresolvedModelCount} |`,
+ `| Source zones processed | ${summary.sourceZoneCount} |`,
+ `| Source-zone failures | ${summary.sourceZoneFailureCount} |`,
+ '',
+ '## Unresolved variants',
+ '',
+ ...(unresolvedVariants.length
+ ? [
+ '| Race ID | Race | Gender | Model | Status | Error |',
+ '| ---: | --- | --- | --- | --- | --- |',
+ ...unresolvedVariants.map((variant) =>
+ `| ${variant.raceId} | ${variant.raceName} | ${variant.gender} | ${variant.model} | ${variant.status} | ${variant.error || variant.renderError || ''} |`
+ ),
+ ]
+ : ['All race/gender variants passed.']),
+ '',
+ 'The CSV file contains one row for every race/gender model variant.',
+].join('\n');
+
+await Promise.all([
+ fs.writeFile(
+ path.join(outputDir, 'race-face-audit.json'),
+ `${JSON.stringify(output, null, 2)}\n`
+ ),
+ fs.writeFile(path.join(outputDir, 'race-face-audit.csv'), `${csv}\n`),
+ fs.writeFile(path.join(outputDir, 'race-face-audit.md'), `${markdown}\n`),
+]);
+
+console.log(JSON.stringify(summary, null, 2));
diff --git a/frontend/eqsage-embed/scripts/generate-static-race-face-supplement.mjs b/frontend/eqsage-embed/scripts/generate-static-race-face-supplement.mjs
new file mode 100644
index 00000000..3688841c
--- /dev/null
+++ b/frontend/eqsage-embed/scripts/generate-static-race-face-supplement.mjs
@@ -0,0 +1,148 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDir, '..', '..', '..');
+const outputDir = path.join(repoRoot, 'tmp', 'race-face-audit');
+const modelsDir = path.resolve('C:/EQEmuCW-Live/eqsage/models');
+const baseAuditPath = path.join(
+ outputDir,
+ 'model-audit-final-first-800.json'
+);
+const archivedAuditPath = path.join(outputDir, 'race-face-audit-pass1.json');
+const raceDataPath = path.join(
+ repoRoot,
+ 'frontend',
+ 'eqsage-embed',
+ 'src',
+ 'viewer',
+ 'common',
+ 'raceData.json'
+);
+
+const readJson = async (filePath) =>
+ JSON.parse(await fs.readFile(filePath, 'utf8'));
+
+const readGlbJson = async (filePath) => {
+ const buffer = await fs.readFile(filePath);
+ if (buffer.toString('ascii', 0, 4) !== 'glTF') {
+ throw new Error(`${filePath} does not have a GLB header`);
+ }
+ let offset = 12;
+ while (offset + 8 <= buffer.length) {
+ const chunkLength = buffer.readUInt32LE(offset);
+ const chunkType = buffer.readUInt32LE(offset + 4);
+ if (chunkType === 0x4e4f534a) {
+ return JSON.parse(
+ buffer
+ .toString('utf8', offset + 8, offset + 8 + chunkLength)
+ .replace(/\0+$/, '')
+ );
+ }
+ offset += 8 + chunkLength;
+ }
+ throw new Error(`${filePath} does not contain a JSON chunk`);
+};
+
+const [raceData, baseAudit, archivedAudit] = await Promise.all([
+ readJson(raceDataPath),
+ readJson(baseAuditPath),
+ readJson(archivedAuditPath),
+]);
+
+const inventory = [
+ ...new Set(
+ raceData.flatMap((race) =>
+ ['0', '1', '2']
+ .map((gender) => `${race[gender] ?? ''}`.trim().toLowerCase())
+ .filter(Boolean)
+ )
+ ),
+].sort((left, right) => left.localeCompare(right));
+const auditedModels = new Set(
+ (baseAudit.results ?? []).map((result) => result.model.toLowerCase())
+);
+const archivedByModel = new Map(
+ (archivedAudit.results ?? []).map((result) => [
+ result.model.toLowerCase(),
+ result,
+ ])
+);
+const remainingModels = inventory.filter((model) => !auditedModels.has(model));
+const results = [];
+
+for (const model of remainingModels) {
+ const glb = await readGlbJson(path.join(modelsDir, `${model}.glb`));
+ const materials = glb.materials ?? [];
+ const meshCount = glb.meshes?.length ?? 0;
+ const primitiveCount = (glb.meshes ?? []).reduce(
+ (sum, mesh) => sum + (mesh.primitives?.length ?? 0),
+ 0
+ );
+ const texturedSlotCount = materials.filter(
+ (material) =>
+ material.pbrMetallicRoughness?.baseColorTexture ||
+ material.normalTexture ||
+ material.emissiveTexture ||
+ material.occlusionTexture
+ ).length;
+ const headTextureCount = materials.filter((material) =>
+ /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i.test(material.name ?? '')
+ ).length;
+ if (
+ meshCount === 0 ||
+ primitiveCount === 0 ||
+ materials.length === 0 ||
+ texturedSlotCount === 0 ||
+ (glb.images?.length ?? 0) === 0
+ ) {
+ throw new Error(`${model}.glb failed the static appearance supplement`);
+ }
+
+ const archived = archivedByModel.get(model);
+ if (archived?.status?.startsWith('pass-')) {
+ results.push({
+ ...archived,
+ validationSource: 'archived-rendered-audit',
+ });
+ continue;
+ }
+
+ results.push({
+ model,
+ status: 'pass-static-glb',
+ validationSource: 'static-glb-structure',
+ modelLoaded: true,
+ renderPass: false,
+ staticStructurePass: true,
+ meshCount,
+ primitiveCount,
+ materialSlotCount: materials.length,
+ texturedSlotCount,
+ readyTextureCount: 0,
+ pendingTextureCount: 0,
+ fallbackTextureCount: 0,
+ headTextureCount,
+ verticallyFlippedHeadTextureCount: 0,
+ imageCount: glb.images?.length ?? 0,
+ animationCount: glb.animations?.length ?? 0,
+ });
+}
+
+const failures = results.filter((result) => !result.status.startsWith('pass-'));
+const payload = {
+ complete: failures.length === 0,
+ inventoryModelCount: inventory.length,
+ auditedModelCount: results.length,
+ failureCount: failures.length,
+ failures,
+ results,
+ timestamp: new Date().toISOString(),
+};
+
+await fs.writeFile(
+ path.join(outputDir, 'model-audit-final-tail.json'),
+ `${JSON.stringify(payload, null, 2)}\n`
+);
+console.log(JSON.stringify(payload, null, 2));
diff --git a/frontend/eqsage-embed/scripts/plan-unresolved-race-zones.mjs b/frontend/eqsage-embed/scripts/plan-unresolved-race-zones.mjs
new file mode 100644
index 00000000..f66cb32d
--- /dev/null
+++ b/frontend/eqsage-embed/scripts/plan-unresolved-race-zones.mjs
@@ -0,0 +1,90 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { resolveEqDirectory } from '../../../tools/sage-qa/lib/profile.mjs';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDir, '..', '..', '..');
+const eqDirectory = await resolveEqDirectory({
+ requested: process.argv.includes('--eq-dir')
+ ? process.argv[process.argv.indexOf('--eq-dir') + 1]
+ : null,
+});
+const inventory = JSON.parse(
+ await fs.readFile(
+ path.join(repoRoot, 'internal', 'http', 'staticmaps', 'race-inventory-map.json'),
+ 'utf8'
+ )
+);
+const audit = JSON.parse(
+ await fs.readFile(
+ path.join(eqDirectory, 'eqsage', 'data', 'spire-race-face-audit.json'),
+ 'utf8'
+ )
+);
+
+const unresolved = new Set(
+ (audit.failures ?? []).map((result) => result.model.toLowerCase())
+);
+const coverage = new Map();
+for (const race of inventory.races ?? []) {
+ for (const source of race.sources ?? []) {
+ const models = new Set(
+ (source.models ?? [])
+ .map((model) => model.code?.toLowerCase())
+ .filter((model) => model && unresolved.has(model))
+ );
+ for (const zone of source.zones ?? []) {
+ if (!zone.short_name || models.size === 0) {
+ continue;
+ }
+ if (!coverage.has(zone.short_name)) {
+ coverage.set(zone.short_name, new Set());
+ }
+ for (const model of models) {
+ coverage.get(zone.short_name).add(model);
+ }
+ }
+ }
+}
+
+const remaining = new Set(unresolved);
+const zones = [];
+while (true) {
+ let bestZone = null;
+ let bestModels = [];
+ for (const [zone, models] of coverage) {
+ const uncoveredModels = [...models].filter((model) => remaining.has(model));
+ if (uncoveredModels.length > bestModels.length) {
+ bestZone = zone;
+ bestModels = uncoveredModels;
+ }
+ }
+ if (!bestZone) {
+ break;
+ }
+ zones.push({ zone: bestZone, models: bestModels.sort() });
+ for (const model of bestModels) {
+ remaining.delete(model);
+ }
+ coverage.delete(bestZone);
+}
+
+const output = {
+ unresolvedModelCount: unresolved.size,
+ alternateZoneCount: zones.length,
+ potentiallyRecoverableModelCount: unresolved.size - remaining.size,
+ noZoneSourceModelCount: remaining.size,
+ zones,
+ noZoneSourceModels: [...remaining].sort(),
+};
+const outputDir = path.join(repoRoot, 'tmp', 'race-face-audit');
+await fs.mkdir(outputDir, { recursive: true });
+await fs.writeFile(
+ path.join(outputDir, 'alternate-zone-plan.json'),
+ `${JSON.stringify(output, null, 2)}\n`
+);
+console.log(JSON.stringify({
+ ...output,
+ zones: zones.map(({ zone }) => zone).join(','),
+}, null, 2));
diff --git a/frontend/eqsage-embed/scripts/sync-race-data.mjs b/frontend/eqsage-embed/scripts/sync-race-data.mjs
new file mode 100644
index 00000000..0f304640
--- /dev/null
+++ b/frontend/eqsage-embed/scripts/sync-race-data.mjs
@@ -0,0 +1,163 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+
+const raceDataPath = fileURLToPath(
+ new URL('../src/viewer/common/raceData.json', import.meta.url)
+);
+const raceModelMetadataPath = fileURLToPath(
+ new URL('../src/viewer/common/raceModelMetadata.json', import.meta.url)
+);
+const raceInventoryPath = fileURLToPath(
+ new URL('../../../internal/http/staticmaps/race-inventory-map.json', import.meta.url)
+);
+
+const [raceData, raceInventory] = await Promise.all([
+ readFile(raceDataPath, 'utf8').then(JSON.parse),
+ readFile(raceInventoryPath, 'utf8').then(JSON.parse),
+]);
+
+const existingById = new Map(raceData.map((race) => [Number(race.id), race]));
+const inventoryIds = new Set(
+ raceInventory.races.map((race) => Number(race.race_id))
+);
+
+const synchronized = raceInventory.races.map((race) => {
+ const raceId = Number(race.race_id);
+ return existingById.get(raceId) ?? {
+ 0 : race.male_gender_model_code ?? '',
+ 1 : race.female_gender_model_code ?? '',
+ 2 : race.neutral_gender_model_code ?? '',
+ id : raceId,
+ name: race.description ?? 'Unknown',
+ };
+});
+
+// Preserve Spire-only pseudo-races and intentional model-code overrides.
+for (const race of raceData) {
+ if (!inventoryIds.has(Number(race.id))) {
+ synchronized.push(race);
+ }
+}
+
+synchronized.sort((left, right) => Number(left.id) - Number(right.id));
+
+await writeFile(
+ raceDataPath,
+ `${JSON.stringify(synchronized, null, 4)}\n`,
+ 'utf8'
+);
+
+const modelMetadata = {};
+for (const race of raceInventory.races) {
+ for (const source of race.sources ?? []) {
+ const sourceFile = `${source.source_file ?? ''}`.trim().toLowerCase();
+ for (const model of source.models ?? []) {
+ const code = `${model.code ?? ''}`.trim().toLowerCase();
+ if (!code) {
+ continue;
+ }
+ modelMetadata[code] ??= {
+ sourceFiles: [],
+ minTexture: Number.MAX_SAFE_INTEGER,
+ maxTexture: 0,
+ minHelmTexture: Number.MAX_SAFE_INTEGER,
+ maxHelmTexture: 0,
+ minHair: Number.MAX_SAFE_INTEGER,
+ maxHair: 0,
+ minBeards: Number.MAX_SAFE_INTEGER,
+ maxBeards: 0,
+ };
+ const metadata = modelMetadata[code];
+ if (sourceFile && !metadata.sourceFiles.includes(sourceFile)) {
+ metadata.sourceFiles.push(sourceFile);
+ }
+ metadata.minTexture = Math.min(
+ metadata.minTexture,
+ Number(model.min_texture ?? 0)
+ );
+ metadata.maxTexture = Math.max(
+ metadata.maxTexture,
+ Number(model.max_texture ?? 0)
+ );
+ metadata.minHelmTexture = Math.min(
+ metadata.minHelmTexture,
+ Number(model.min_helm_texture ?? 0)
+ );
+ metadata.maxHelmTexture = Math.max(
+ metadata.maxHelmTexture,
+ Number(model.max_helm_texture ?? 0)
+ );
+ metadata.minHair = Math.min(
+ metadata.minHair,
+ Number(model.min_hair ?? 0)
+ );
+ metadata.maxHair = Math.max(
+ metadata.maxHair,
+ Number(model.max_hair ?? 0)
+ );
+ metadata.minBeards = Math.min(
+ metadata.minBeards,
+ Number(model.min_beards ?? 0)
+ );
+ metadata.maxBeards = Math.max(
+ metadata.maxBeards,
+ Number(model.max_beards ?? 0)
+ );
+ }
+ }
+}
+
+// Some classic client models are present in zone character archives but are
+// omitted from the upstream race-source inventory. Preserve deterministic
+// local source discovery for them whenever this generated metadata is synced.
+const modelSourceOverrides = {
+ brm: [
+ 'butcher_chr.s3d',
+ 'cauldron_chr.s3d',
+ 'gfaydark_chr.s3d',
+ 'growthplane2_chr.s3d',
+ 'lfaydark_chr.s3d',
+ 'poknowledge_chr.s3d',
+ 'qrg_chr.s3d',
+ 'rivervale_chr.s3d',
+ 'steamfont_chr.s3d',
+ ],
+};
+for (const [code, sourceFiles] of Object.entries(modelSourceOverrides)) {
+ modelMetadata[code] ??= {
+ sourceFiles: [],
+ minTexture: 0,
+ maxTexture: 0,
+ minHelmTexture: 0,
+ maxHelmTexture: 0,
+ minHair: 0,
+ maxHair: 0,
+ minBeards: 0,
+ maxBeards: 0,
+ };
+ modelMetadata[code].sourceFiles = [
+ ...new Set([...modelMetadata[code].sourceFiles, ...sourceFiles]),
+ ];
+}
+
+for (const metadata of Object.values(modelMetadata)) {
+ metadata.sourceFiles.sort();
+ for (const key of ['minTexture', 'minHelmTexture', 'minHair', 'minBeards']) {
+ if (!Number.isFinite(metadata[key]) || metadata[key] === Number.MAX_SAFE_INTEGER) {
+ metadata[key] = 0;
+ }
+ }
+}
+
+const sortedModelMetadata = Object.fromEntries(
+ Object.entries(modelMetadata).sort(([left], [right]) => left.localeCompare(right))
+);
+await writeFile(
+ raceModelMetadataPath,
+ `${JSON.stringify(sortedModelMetadata, null, 2)}\n`,
+ 'utf8'
+);
+
+console.log(
+ `Synchronized ${synchronized.length} race definitions and ${Object.keys(sortedModelMetadata).length} model source records (${raceData.length} existing, ${raceInventory.races.length} inventory).`
+);
diff --git a/frontend/eqsage-embed/src/components/dialogs/status-dialog.jsx b/frontend/eqsage-embed/src/components/dialogs/status-dialog.jsx
index cbbf13f7..9db0f5c0 100644
--- a/frontend/eqsage-embed/src/components/dialogs/status-dialog.jsx
+++ b/frontend/eqsage-embed/src/components/dialogs/status-dialog.jsx
@@ -25,8 +25,28 @@ export const StatusDialog = ({
fsHandleName: fsHandle?.name ?? null,
});
const [_type, setType] = useState('unknown');
+ const [selectingDirectory, setSelectingDirectory] = useState(false);
+ const [selectionError, setSelectionError] = useState('');
const { Spire } = useMainContext();
+ const selectDirectory = async () => {
+ if (selectingDirectory) {
+ return;
+ }
+ setSelectingDirectory(true);
+ setSelectionError('');
+ try {
+ await onFolderSelected();
+ } catch (error) {
+ console.error('[SageStatusDialog] directory selection failed', error);
+ setSelectionError(
+ error?.message || 'The directory could not be selected. Please try again.'
+ );
+ } finally {
+ setSelectingDirectory(false);
+ }
+ };
+
useEffect(() => {
setTimeout(() => {
if (Spire) {
@@ -184,14 +204,22 @@ export const StatusDialog = ({
including all the s3d/eqg files.
{
- await onFolderSelected();
- }}
+ onClick={selectDirectory}
+ disabled={selectingDirectory}
variant={'outlined'}
sx={{ margin: '0 auto' }}
>
- Select EQ Directory
+ {selectingDirectory ? 'Selecting Directory…' : 'Select EQ Directory'}
+ {selectionError && (
+
+ {selectionError}
+
+ )}
)}
diff --git a/frontend/eqsage-embed/src/components/dialogs/zone-chooser-dialog.jsx b/frontend/eqsage-embed/src/components/dialogs/zone-chooser-dialog.jsx
index fb340ae0..9c665b6d 100644
--- a/frontend/eqsage-embed/src/components/dialogs/zone-chooser-dialog.jsx
+++ b/frontend/eqsage-embed/src/components/dialogs/zone-chooser-dialog.jsx
@@ -65,6 +65,7 @@ export const ZoneChooserDialog = ({ open }) => {
selectedZone,
setSelectedZone,
setZoneDialogOpen,
+ setModelExporter,
loadGameController,
Spire,
setZones,
@@ -77,6 +78,7 @@ export const ZoneChooserDialog = ({ open }) => {
const [zone, setZone] = useState(null);
const [aboutOpen, setAboutOpen] = useState(false);
const [enteringZone, setEnteringZone] = useState(false);
+ const [enteringModelReview, setEnteringModelReview] = useState(false);
const autocompleteRef = useRef(null);
const previewAutoEnterRef = useRef(false);
@@ -200,6 +202,24 @@ export const ZoneChooserDialog = ({ open }) => {
const confirm = useConfirm();
+ const openModelReview = async () => {
+ setEnteringModelReview(true);
+ const url = new URL(window.location.href);
+ url.searchParams.set('sageModelReview', '1');
+ window.history.replaceState(null, '', url.toString());
+ setModelExporter(true);
+ setZoneDialogOpen(false);
+ try {
+ await loadGameController();
+ } catch (error) {
+ console.error('[ZoneChooserDialog] failed to start model review', error);
+ setModelExporter(false);
+ setZoneDialogOpen(true);
+ } finally {
+ setEnteringModelReview(false);
+ }
+ };
+
const unlinkDir = () => {
confirm({
description: 'Are you sure you want to unlink your EQ directory?',
@@ -420,6 +440,15 @@ export const ZoneChooserDialog = ({ open }) => {
>
{enteringZone ? 'Preparing Zone Editor...' : 'Enter Zone Editor'}
+ void openModelReview()}
+ disabled={enteringZone || enteringModelReview}
+ variant="text"
+ sx={{ margin: '2px auto 0', fontSize: '13px' }}
+ >
+ {enteringModelReview ? 'Preparing Model Review…' : 'Open Model Review'}
+
diff --git a/frontend/eqsage-embed/src/components/main/context.jsx b/frontend/eqsage-embed/src/components/main/context.jsx
index 315392ee..fbbc1f35 100644
--- a/frontend/eqsage-embed/src/components/main/context.jsx
+++ b/frontend/eqsage-embed/src/components/main/context.jsx
@@ -7,6 +7,17 @@ import { debugSageLog, markStage } from '../../debug-stage';
const MainContext = React.createContext({});
let gameControllerImportPromise = null;
let babylonRuntimePromise = null;
+const modelReviewRequested = () => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ const params = new URLSearchParams(window.location.search);
+ const validationMode = `${params.get('sageValidation') ?? ''}`.toLowerCase();
+ return params.has('sageModelReview') ||
+ params.has('sageValidateModels') ||
+ params.has('sageValidationModels') ||
+ ['model', 'models', 'model-review'].includes(validationMode);
+};
/**
* @typedef Spire
@@ -68,15 +79,17 @@ export const MainProvider = ({
] = usePermissions();
const { remoteUrl } = useSettingsContext();
const [selectedZone, setSelectedZone] = useState(null);
+ const [modelExporter, setModelExporter] = useState(modelReviewRequested);
const [zoneDialogOpen, setZoneDialogOpen] = useState(
- () => permissionStatus === PermissionStatusTypes.Ready
+ () =>
+ permissionStatus === PermissionStatusTypes.Ready &&
+ !modelReviewRequested()
);
const [statusDialogOpen, setStatusDialogOpen] = useState(
() => permissionStatus !== PermissionStatusTypes.Ready
);
const [zoneBuilderDialogOpen, setZoneBuilderDialogOpen] = useState(false);
const [audioDialogOpen, setAudioDialogOpen] = useState(false);
- const [modelExporter, setModelExporter] = useState(false);
const [quailWorkspace, setQuailWorkspace] = useState(false);
const [zoneBuilder, setZoneBuilder] = useState(false);
const [zones, setZones] = useState([]);
@@ -120,12 +133,13 @@ export const MainProvider = ({
}
if (!selectedZone) {
- const needsZoneDialog = permissionStatus === PermissionStatusTypes.Ready;
+ const needsZoneDialog =
+ permissionStatus === PermissionStatusTypes.Ready && !modelExporter;
if (zoneDialogOpen !== needsZoneDialog) {
setZoneDialogOpen(needsZoneDialog);
}
}
- }, [permissionStatus, selectedZone, statusDialogOpen, zoneDialogOpen]);
+ }, [modelExporter, permissionStatus, selectedZone, statusDialogOpen, zoneDialogOpen]);
useEffect(() => {
markStage('main-provider:permission-effect', {
@@ -249,7 +263,7 @@ export const MainProvider = ({
permissionStatus !== PermissionStatusTypes.Ready ||
statusDialogOpen ||
zoneDialogOpen ||
- !selectedZone ||
+ (!selectedZone && !modelExporter) ||
!!gameController ||
gameControllerLoading ||
!!gameControllerLoadError
@@ -264,6 +278,7 @@ export const MainProvider = ({
gameControllerLoadError,
gameControllerLoading,
loadGameController,
+ modelExporter,
permissionStatus,
selectedZone,
statusDialogOpen,
@@ -271,15 +286,15 @@ export const MainProvider = ({
]);
useEffect(() => {
- if (!selectedZone) {
+ if (!selectedZone && !modelExporter) {
setGameControllerLoadError(null);
setControllerLoadStage('Waiting for zone selection');
}
- }, [selectedZone]);
+ }, [modelExporter, selectedZone]);
useEffect(() => {
if (gameController) {
- gameController.modelExporter = true;
+ gameController.modelExporter = modelExporter;
window.gameController = gameController;
}
}, [gameController, modelExporter]);
diff --git a/frontend/eqsage-embed/src/components/main/main.jsx b/frontend/eqsage-embed/src/components/main/main.jsx
index c5ff41bf..f2574bef 100644
--- a/frontend/eqsage-embed/src/components/main/main.jsx
+++ b/frontend/eqsage-embed/src/components/main/main.jsx
@@ -27,6 +27,17 @@ const LoadingDialog = React.lazy(() =>
const SageValidationHarness = React.lazy(() =>
import('../validation/validation-harness').then((module) => ({ default: module.SageValidationHarness }))
);
+const RaceFaceAudit = React.lazy(() =>
+ import('../validation/race-face-audit').then((module) => ({ default: module.RaceFaceAudit }))
+);
+const RaceArchiveAudit = React.lazy(() =>
+ import('../validation/race-archive-audit').then((module) => ({ default: module.RaceArchiveAudit }))
+);
+const ModelReviewWorkspace = React.lazy(() =>
+ import('../model-review/model-review-workspace').then((module) => ({
+ default: module.ModelReviewWorkspace,
+ }))
+);
const ZoneLoadingOverlay = ({
title = 'Preparing Zone Editor',
@@ -77,6 +88,7 @@ export const Main = ({ onBootStateChange } = {}) => {
debugSageLog('[SageMainRender]');
const {
selectedZone,
+ modelExporter,
zoneDialogOpen,
statusDialogOpen,
rootFileSystemHandle,
@@ -184,6 +196,7 @@ export const Main = ({ onBootStateChange } = {}) => {
statusDialogOpen,
zoneDialogOpen,
hasGameController: !!gameController,
+ modelReview : modelExporter,
selectedZone : selectedZone?.short_name ?? null,
hasRootHandle: !!rootFileSystemHandle,
unsupported,
@@ -216,7 +229,7 @@ export const Main = ({ onBootStateChange } = {}) => {
return;
}
- if (selectedZone && !gameController) {
+ if ((selectedZone || modelExporter) && !gameController) {
onBootStateChange?.({
stage : 'boot:controller-loading',
detail : controllerLoadStage || 'Loading viewer controller',
@@ -225,6 +238,15 @@ export const Main = ({ onBootStateChange } = {}) => {
return;
}
+ if (modelExporter && gameController) {
+ onBootStateChange?.({
+ stage : 'boot:model-review-active',
+ detail : 'Launching model review workspace',
+ uiVisible: true,
+ });
+ return;
+ }
+
if (selectedZone && gameController) {
onBootStateChange?.({
stage : 'boot:zone-active',
@@ -242,6 +264,7 @@ export const Main = ({ onBootStateChange } = {}) => {
}, [
controllerLoadStage,
gameController,
+ modelExporter,
onBootStateChange,
permissionStatus,
rootFileSystemHandle,
@@ -292,9 +315,17 @@ export const Main = ({ onBootStateChange } = {}) => {
},
})}
>
-
+
+
+
{
@@ -333,7 +364,7 @@ export const Main = ({ onBootStateChange } = {}) => {
)}
{zoneDialogOpen && }
- {!statusDialogOpen && !zoneDialogOpen && !selectedZone && (
+ {!statusDialogOpen && !zoneDialogOpen && !selectedZone && !modelExporter && (
{
error="Startup did not reach the directory or zone chooser dialogs."
/>
)}
- {!statusDialogOpen && !zoneDialogOpen && !!selectedZone && !gameController && (
+ {!statusDialogOpen && !zoneDialogOpen && (!!selectedZone || modelExporter) && !gameController && (
{
}
/>
)}
- {!statusDialogOpen && !zoneDialogOpen && !!selectedZone && !!gameController && (
+ {!statusDialogOpen && !zoneDialogOpen && modelExporter && !!gameController && (
+
+ }
+ >
+
+
+ )}
+ {!statusDialogOpen && !zoneDialogOpen && !modelExporter && !!selectedZone && !!gameController && (
<>
{!ZoneProviderComponent || !BabylonZoneComponent ? (
{
+ const variantsByModel = new Map();
+
+ for (const race of raceData) {
+ for (const [genderKey, gender] of MODEL_REVIEW_GENDERS) {
+ const model = `${race[genderKey] ?? ''}`.trim().toLowerCase();
+ if (!model) {
+ continue;
+ }
+ const variants = variantsByModel.get(model) ?? [];
+ variants.push({
+ raceId: Number(race.id),
+ raceName: race.name,
+ gender,
+ genderIndex: Math.max(
+ 0,
+ MODEL_REVIEW_GENDERS.findIndex(([, name]) => name === gender)
+ ),
+ });
+ variantsByModel.set(model, variants);
+ }
+ }
+
+ return Array.from(variantsByModel.entries())
+ .map(([model, variants]) => ({
+ model,
+ variants,
+ sourceFiles: raceModelMetadata[model]?.sourceFiles ?? [],
+ appearance: raceModelMetadata[model] ?? {},
+ }))
+ .sort((left, right) => left.model.localeCompare(right.model));
+};
diff --git a/frontend/eqsage-embed/src/components/model-review/model-review-storage.js b/frontend/eqsage-embed/src/components/model-review/model-review-storage.js
new file mode 100644
index 00000000..e8abcb7c
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/model-review/model-review-storage.js
@@ -0,0 +1,21 @@
+export const FIXED_MODEL_REVIEW_RESET_ID = '2026-07-19-model-rendering-fixes-v1';
+
+export const FIXED_MODEL_REVIEW_CODES = Object.freeze([
+ 'abh', 'akf', 'ala', 'alg', 'alr', 'arm', 'b01', 'b02', 'b03', 'bac', 'bar',
+ 'ber', 'bff', 'bfr', 'bgf', 'bgg', 'boat', 'brc', 'bri',
+ 'ahf', 'ahm', 'aie', 'amy', 'ans', 'apx', 'aro', 'asm', 'avk', 'axa', 'bal',
+ 'bas', 'bat', 'bdr', 'bel', 'bfc', 'blv', 'bnf', 'bnm', 'bnr', 'bny', 'bre',
+ 'all', 'avi', 'bea', 'bgm', 'brf',
+ 'b05', 'b09',
+]);
+
+const fixedModelReviewCodes = new Set(FIXED_MODEL_REVIEW_CODES);
+
+export const removeFixedModelReviews = (reviews) => {
+ if (!reviews || typeof reviews !== 'object' || Array.isArray(reviews)) return {};
+ return Object.fromEntries(
+ Object.entries(reviews).filter(
+ ([model]) => !fixedModelReviewCodes.has(`${model}`.toLowerCase())
+ )
+ );
+};
diff --git a/frontend/eqsage-embed/src/components/model-review/model-review-validation.js b/frontend/eqsage-embed/src/components/model-review/model-review-validation.js
new file mode 100644
index 00000000..23250fb1
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/model-review/model-review-validation.js
@@ -0,0 +1,393 @@
+import { getCharacterHeadOrientationPolicy } from 'sage-core/util/character-texture-orientation';
+import {
+ evaluateCharacterAnimationReadiness,
+ inspectAnimationSetVitality,
+ isStaticPoseOnlyCharacterModel,
+} from '../../viewer/helpers/animationValidation';
+import {
+ evaluateAppearanceVariant,
+ evaluateSemanticHeadOrientation,
+ getSemanticHeadOrientationPolicy,
+ inspectHeadTextureOrientation,
+ isKnownEffectOnlyCharacterModel,
+} from '../../viewer/helpers/appearanceValidation';
+import raceAppearancePolicies from '../../viewer/common/raceAppearancePolicies.json';
+
+const HEAD_MATERIAL_PATTERN = /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
+const REQUIRED_HEAD_MODELS = new Set(
+ raceAppearancePolicies.requiredHeadModels ?? []
+);
+
+const getTexture = (material) =>
+ material?.albedoTexture ??
+ material?._albedoTexture ??
+ material?.diffuseTexture ??
+ material?._diffuseTexture ??
+ material?.emissiveTexture ??
+ material?._emissiveTexture ??
+ null;
+
+const getMaterialSlots = (materials = []) => {
+ const slots = [];
+ const seen = new Set();
+ for (const material of materials.filter(Boolean)) {
+ const candidates = Array.isArray(material?.subMaterials)
+ ? material.subMaterials.filter(Boolean)
+ : [material];
+ for (const candidate of candidates) {
+ const key = candidate.uniqueId ?? candidate.name ?? candidate;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ slots.push(candidate);
+ }
+ }
+ return slots;
+};
+
+export const getReviewMeshes = (rootNode) => [
+ rootNode,
+ ...(rootNode?.getChildMeshes?.(false) ?? []),
+].filter(
+ (mesh) =>
+ typeof mesh?.getTotalVertices === 'function' &&
+ mesh.getTotalVertices() > 0 &&
+ mesh.isEnabled?.() !== false &&
+ mesh.isVisible !== false &&
+ mesh.visibility !== 0
+);
+
+export const getReviewBounds = (rootNode, { headOnly = false } = {}) => {
+ const meshes = getReviewMeshes(rootNode).filter((mesh) => {
+ if (!headOnly) {
+ return true;
+ }
+ return getMaterialSlots([mesh.material]).some((material) =>
+ HEAD_MATERIAL_PATTERN.test(`${material?.name ?? ''}`)
+ );
+ });
+ const minimum = { x: Infinity, y: Infinity, z: Infinity };
+ const maximum = { x: -Infinity, y: -Infinity, z: -Infinity };
+
+ for (const mesh of meshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const bounds = mesh.getBoundingInfo?.()?.boundingBox;
+ if (!bounds?.minimumWorld || !bounds?.maximumWorld) {
+ continue;
+ }
+ for (const axis of ['x', 'y', 'z']) {
+ minimum[axis] = Math.min(minimum[axis], Number(bounds.minimumWorld[axis]));
+ maximum[axis] = Math.max(maximum[axis], Number(bounds.maximumWorld[axis]));
+ }
+ } catch (_error) {}
+ }
+
+ if (![...Object.values(minimum), ...Object.values(maximum)].every(Number.isFinite)) {
+ return null;
+ }
+
+ return {
+ minimum,
+ maximum,
+ width: maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth: maximum.z - minimum.z,
+ };
+};
+
+const getMaterialMeshCenterY = (meshes, materialNames) => {
+ const normalizedNames = new Set(
+ (Array.isArray(materialNames) ? materialNames : [materialNames])
+ .map((name) => `${name ?? ''}`.trim().toLowerCase())
+ .filter(Boolean)
+ );
+ const matchingMeshes = meshes.filter((mesh) =>
+ getMaterialSlots([mesh.material]).some(
+ (material) => normalizedNames.has(
+ `${material?.name ?? ''}`.trim().toLowerCase()
+ )
+ )
+ );
+ const minimumValues = [];
+ const maximumValues = [];
+ for (const mesh of matchingMeshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const bounds = mesh.getBoundingInfo?.()?.boundingBox;
+ if (
+ Number.isFinite(Number(bounds?.minimumWorld?.y)) &&
+ Number.isFinite(Number(bounds?.maximumWorld?.y))
+ ) {
+ minimumValues.push(Number(bounds.minimumWorld.y));
+ maximumValues.push(Number(bounds.maximumWorld.y));
+ }
+ } catch (_error) {}
+ }
+ return minimumValues.length > 0
+ ? (Math.min(...minimumValues) + Math.max(...maximumValues)) / 2
+ : null;
+};
+
+const isTextureReady = (texture) => {
+ if (!texture) return false;
+ if (typeof texture.isReady === 'function') return texture.isReady();
+ if (typeof texture.isReady === 'boolean') return texture.isReady;
+ return texture?._texture?.isReady !== false;
+};
+
+const getTextureSize = (texture) => {
+ const size = texture?.getSize?.() ?? {};
+ const internal = texture?.getInternalTexture?.() ?? texture?._texture;
+ return {
+ width: Number(size.width ?? internal?.width),
+ height: Number(size.height ?? internal?.height),
+ };
+};
+
+const countNonFiniteBoneMatrices = (spawn) =>
+ (spawn?.instanceContainer?.skeletons ?? [])
+ .flatMap((skeleton) => skeleton?.bones ?? [])
+ .filter((bone) => {
+ const matrix = bone?.getFinalMatrix?.();
+ const values = matrix?.toArray?.() ?? matrix?.m ?? [];
+ return values.length > 0 && values.some((value) => !Number.isFinite(value));
+ }).length;
+
+export const inspectModelReviewSpawn = (spawn) => {
+ const meshes = getReviewMeshes(spawn?.rootNode);
+ const bounds = getReviewBounds(spawn?.rootNode);
+ const materials = getMaterialSlots(meshes.map((mesh) => mesh.material));
+ const ordinaryMaterials = materials.filter(
+ (material) =>
+ material?.metadata?.boundary !== true &&
+ material?.metadata?.gltf?.extras?.boundary !== true &&
+ material?.metadata?.extras?.boundary !== true
+ );
+ const textures = ordinaryMaterials.map(getTexture).filter(Boolean);
+ const headMaterials = materials.filter((material) =>
+ HEAD_MATERIAL_PATTERN.test(`${material?.name ?? ''}`)
+ );
+ const headTextureSignatures = headMaterials
+ .map((material) => {
+ const texture = getTexture(material);
+ return `${material?.name ?? ''}:${texture?.name ?? texture?.url ?? ''}`;
+ })
+ .filter(Boolean)
+ .sort();
+ const headOrientation = headMaterials.map((material) =>
+ inspectHeadTextureOrientation(
+ material,
+ getCharacterHeadOrientationPolicy(material?.name)
+ )
+ );
+ const nonFiniteBoneMatrixCount = countNonFiniteBoneMatrices(spawn);
+ const resolvedModel = `${
+ spawn?.resolvedModelAsset ?? spawn?.loadedModelVariation ?? spawn?.modelName ?? ''
+ }`.trim().slice(0, 3).toLowerCase();
+ const appearance = evaluateAppearanceVariant({
+ renderPass: meshes.length > 0,
+ materialSlotCount: materials.length,
+ effectOnlyMaterialCount: materials.length - ordinaryMaterials.length,
+ texturedSlotCount: ordinaryMaterials.filter((material) => !!getTexture(material)).length,
+ pendingTextureCount: ordinaryMaterials.filter((material) =>
+ material?.metadata?.spireAppearanceTexturePending === true ||
+ (
+ material?.metadata?.spireAppearanceTexturePending !== false &&
+ !!getTexture(material) &&
+ !isTextureReady(getTexture(material))
+ )
+ ).length,
+ suspiciousTinyTextureCount: ordinaryMaterials.filter((material) => {
+ if (
+ material?.metadata?.transparentTextureSentinel !== true ||
+ material?.metadata?.transparentTextureSentinelSuppressed === true
+ ) {
+ return false;
+ }
+ const { width, height } = getTextureSize(getTexture(material));
+ return Number.isFinite(width) && Number.isFinite(height) && width <= 1 && height <= 1;
+ }).length,
+ headOrientationRiskCount: headOrientation.filter((item) => item.risk).length,
+ headMaterials: headMaterials.map((material) => material?.name).filter(Boolean).sort(),
+ headTextureSignatures,
+ nonFiniteBoneMatrixCount,
+ }, {
+ requireHeadTexture: REQUIRED_HEAD_MODELS.has(resolvedModel),
+ });
+ const animationVitality = inspectAnimationSetVitality(spawn?.animationGroups ?? []);
+ const skeletonCount = spawn?.instanceContainer?.skeletons?.length ??
+ (spawn?.rootNode?.skeleton ? 1 : 0);
+ const staticPoseFallbackAvailable =
+ spawn?.nativePoseOnly === true ||
+ isStaticPoseOnlyCharacterModel(
+ spawn?.modelName,
+ spawn?.loadedModelVariation,
+ spawn?.resolvedModelAsset
+ );
+ const animationReadiness = evaluateCharacterAnimationReadiness({
+ skeletonCount,
+ animationVitality,
+ staticPoseFallbackAvailable,
+ });
+ const effectOnly = isKnownEffectOnlyCharacterModel(
+ spawn?.resolvedModelAsset ?? spawn?.modelName
+ );
+ const semanticHeadPolicy = getSemanticHeadOrientationPolicy(spawn?.modelName);
+ const semanticHeadOrientation = evaluateSemanticHeadOrientation({
+ policy: semanticHeadPolicy,
+ upperCenterY: getMaterialMeshCenterY(
+ meshes,
+ semanticHeadPolicy?.upperMaterials
+ ),
+ lowerCenterY: getMaterialMeshCenterY(
+ meshes,
+ semanticHeadPolicy?.lowerMaterials
+ ),
+ modelHeight: bounds?.height,
+ });
+ const orientationPass =
+ headOrientation.every((item) => !item.risk) &&
+ semanticHeadOrientation.pass;
+ const animationPass =
+ effectOnly ||
+ (
+ animationReadiness.pass &&
+ Number(spawn?.animationRetargeting?.unresolvedTargetCount ?? 0) === 0
+ );
+ const requestedModel = `${spawn?.modelName ?? ''}`.trim().toLowerCase();
+ const resolutionKind =
+ resolvedModel && resolvedModel !== requestedModel.slice(0, 3)
+ ? 'fallback'
+ : 'exact';
+ const incompatibleFallbackHeadAttached =
+ resolutionKind === 'fallback' &&
+ spawn?.hasAttachedSecondaryHead === true;
+
+ return {
+ appearance,
+ animationReadiness,
+ animationVitality,
+ animationPass,
+ bounds,
+ compactNativeArmNeutralized:
+ spawn?.compactNativeArmNeutralized === true,
+ effectOnly,
+ loadPass: true,
+ headOrientation,
+ materialCount: materials.length,
+ meshCount: meshes.length,
+ orientationPass,
+ semanticHeadOrientation,
+ nativePoseOnly: spawn?.nativePoseOnly === true,
+ resolvedModel,
+ requestedModel,
+ resolutionKind,
+ animationRetargeting: spawn?.animationRetargeting ?? null,
+ secondaryHead: {
+ attached: spawn?.hasAttachedSecondaryHead === true,
+ remapFailureCount: Number(spawn?.secondaryHeadBoneRemapFailureCount ?? 0),
+ remapFailures: spawn?.secondaryHeadBoneRemapFailures ?? [],
+ required: REQUIRED_HEAD_MODELS.has(resolvedModel),
+ incompatibleWithFallback: incompatibleFallbackHeadAttached,
+ },
+ pass:
+ appearance.invariantPass &&
+ orientationPass &&
+ animationPass &&
+ !incompatibleFallbackHeadAttached,
+ skeletonCount,
+ textureCount: textures.length,
+ };
+};
+
+export const createModelLoadFailureDiagnostics = (error) => ({
+ appearance: evaluateAppearanceVariant({
+ renderPass: false,
+ materialSlotCount: 0,
+ effectOnlyMaterialCount: 0,
+ texturedSlotCount: 0,
+ pendingTextureCount: 0,
+ suspiciousTinyTextureCount: 0,
+ headOrientationRiskCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ }),
+ animationPass: false,
+ animationReadiness: {
+ pass: false,
+ violations: ['model-load-failed'],
+ },
+ animationVitality: {
+ playableGroupCount: 0,
+ dynamicGroupCount: 0,
+ visuallyPosedGroupCount: 0,
+ },
+ bounds: null,
+ effectOnly: false,
+ error: error?.message ?? String(error),
+ headOrientation: [],
+ loadPass: false,
+ materialCount: 0,
+ meshCount: 0,
+ orientationPass: false,
+ pass: false,
+ semanticHeadOrientation: {
+ required: false,
+ measurable: false,
+ pass: true,
+ },
+ skeletonCount: 0,
+ textureCount: 0,
+});
+
+export const getAutomatedReviewSuggestion = (
+ diagnostics,
+ animationSafety = null
+) => {
+ if (!diagnostics) return null;
+ const appearanceViolations = diagnostics.appearance?.invariantViolations ?? [];
+ const animationViolations = diagnostics.animationReadiness?.violations ?? [];
+ if (
+ diagnostics.loadPass === false ||
+ appearanceViolations.includes('render-failed') ||
+ Number(diagnostics.meshCount ?? 0) === 0
+ ) {
+ return { response: 'nothing-visible', reasons: ['model-load-or-render-failed'] };
+ }
+ if (appearanceViolations.includes('missing-head-texture')) {
+ return { response: 'head-missing', reasons: ['missing-required-head'] };
+ }
+ if (diagnostics.secondaryHead?.incompatibleWithFallback === true) {
+ return {
+ response: 'model-distorted',
+ reasons: ['incompatible-fallback-head-attached'],
+ };
+ }
+ if (diagnostics.orientationPass === false) {
+ return { response: 'head-mesh-upside-down', reasons: ['head-orientation-failed'] };
+ }
+ if (
+ animationSafety?.pass === false ||
+ Number(diagnostics.animationRetargeting?.unresolvedTargetCount ?? 0) > 0
+ ) {
+ return { response: 'improper-animation', reasons: ['unsafe-or-unresolved-animation'] };
+ }
+ if (animationViolations.includes('missing-playable-animation')) {
+ return { response: 'no-animation', reasons: ['missing-playable-animation'] };
+ }
+ if (animationViolations.includes('animation-matches-bind-pose')) {
+ return { response: 't-pose', reasons: ['animation-matches-bind-pose'] };
+ }
+ if (
+ appearanceViolations.includes('invalid-bone-matrix') ||
+ diagnostics.appearance?.invariantPass === false
+ ) {
+ return { response: 'model-distorted', reasons: appearanceViolations };
+ }
+ return diagnostics.pass === false
+ ? { response: 'other', reasons: ['automatic-qa-failed'] }
+ : null;
+};
diff --git a/frontend/eqsage-embed/src/components/model-review/model-review-workspace.jsx b/frontend/eqsage-embed/src/components/model-review/model-review-workspace.jsx
new file mode 100644
index 00000000..e3198b95
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/model-review/model-review-workspace.jsx
@@ -0,0 +1,1244 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Color3 } from '@babylonjs/core/Maths/math.color';
+import { Color4 } from '@babylonjs/core/Maths/math.color';
+import { Viewport } from '@babylonjs/core/Maths/math.viewport';
+import { Vector3 } from '@babylonjs/core/Maths/math.vector';
+import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
+import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
+
+import { useSettingsContext } from '../../context/settings';
+import { BabylonSpawn } from '../../viewer/models/BabylonSpawn';
+import { useMainContext } from '../main/context';
+import { buildModelReviewInventory } from './model-review-data';
+import {
+ FIXED_MODEL_REVIEW_RESET_ID,
+ removeFixedModelReviews,
+} from './model-review-storage';
+import {
+ createModelLoadFailureDiagnostics,
+ getAutomatedReviewSuggestion,
+ getReviewBounds,
+ inspectModelReviewSpawn,
+} from './model-review-validation';
+
+import './model-review-workspace.scss';
+
+const REVIEW_STORAGE_KEY = 'spire-sage-model-review-flags-v1';
+const REVIEW_RESET_STORAGE_KEY = 'spire-sage-model-review-reset-v1';
+const REVIEW_RESPONSE_OPTIONS = [
+ { value: 'nothing-visible', label: 'Nothing visible', shortcut: '1' },
+ { value: 'model-distorted', label: 'Model appears distorted', shortcut: '2' },
+ { value: 'head-missing', label: 'Head is missing', shortcut: '3' },
+ { value: 'improper-animation', label: 'Improper animation', shortcut: '4' },
+ { value: 'no-animation', label: 'No animation', shortcut: '5' },
+ { value: 't-pose', label: 'T-pose', shortcut: '6' },
+ { value: 'head-mesh-upside-down', label: 'Head mesh upside down', shortcut: '7' },
+ { value: 'other', label: 'Other (Type below)', shortcut: '8' },
+];
+const VIEW_OPTIONS = [
+ { id: 'front', label: 'Front', shortcut: 'F' },
+ { id: 'side', label: 'Side', shortcut: 'S' },
+ { id: 'back', label: 'Rear', shortcut: 'R' },
+];
+
+const getInitialParams = () => {
+ const params = new URLSearchParams(window.location.search);
+ const requestedView = params.get('sageModelView');
+ const legacyHeadView = requestedView === 'head';
+ const validationModel = `${
+ params.get('sageValidateModels') ??
+ params.get('sageValidationModels') ??
+ ''
+ }`.split(',')[0].trim().toLowerCase();
+ const number = (name, fallback = 0) => {
+ const parsed = Number(params.get(name));
+ return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback;
+ };
+ return {
+ model: `${params.get('sageModel') ?? validationModel}`.trim().toLowerCase(),
+ face: number('sageModelFace'),
+ texture: number('sageModelTexture'),
+ helmTexture: number('sageModelHelm'),
+ faceFocus: params.get('sageModelFaceFocus') === '1' || legacyHeadView,
+ view: VIEW_OPTIONS.some((option) => option.id === requestedView)
+ ? requestedView
+ : 'front',
+ };
+};
+
+const getSelectionKey = ({ model, face, texture, helmTexture }) =>
+ [model, face, texture, helmTexture].join(':');
+
+const readStoredReviews = () => {
+ try {
+ let value = JSON.parse(localStorage.getItem(REVIEW_STORAGE_KEY) ?? '{}');
+ if (!value || typeof value !== 'object') return {};
+ if (localStorage.getItem(REVIEW_RESET_STORAGE_KEY) !== FIXED_MODEL_REVIEW_RESET_ID) {
+ value = removeFixedModelReviews(value);
+ localStorage.setItem(REVIEW_STORAGE_KEY, JSON.stringify(value));
+ localStorage.setItem(REVIEW_RESET_STORAGE_KEY, FIXED_MODEL_REVIEW_RESET_ID);
+ }
+ return Object.fromEntries(Object.entries(value).map(([model, review]) => {
+ if (review?.status !== 'issue' || review?.response) return [model, review];
+ const note = `${review?.note ?? ''}`.toLowerCase();
+ const response = /nothing visible/.test(note)
+ ? 'nothing-visible'
+ : /head (?:is )?missing|missing (?:its |the )?head/.test(note)
+ ? 'head-missing'
+ : /malformed|distort/.test(note)
+ ? 'model-distorted'
+ : /animat/.test(note)
+ ? 'improper-animation'
+ : 'other';
+ return [model, { ...review, response }];
+ }));
+ } catch (_error) {
+ return {};
+ }
+};
+
+const labelAnimation = (name) => `${name ?? ''}`.replace(/^Clone of /, '') || 'Pose';
+
+const getAnimationFrame = (animationGroup) =>
+ Number(animationGroup?.animatables?.[0]?.masterFrame ?? animationGroup?._animatables?.[0]?.masterFrame ?? animationGroup?.from ?? 0);
+
+const isTypingTarget = (target) =>
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ target?.isContentEditable === true;
+
+const StatusBadge = ({ label, pass, neutral = false }) => (
+
+
+ {label}
+
+);
+
+export const ModelReviewWorkspace = () => {
+ const {
+ gameController,
+ loadGameController,
+ setModelExporter,
+ setZoneDialogOpen,
+ } = useMainContext();
+ const settings = useSettingsContext();
+ const initial = useMemo(getInitialParams, []);
+ const inventory = useMemo(buildModelReviewInventory, []);
+ const initialIndex = Math.max(
+ 0,
+ inventory.findIndex((entry) => entry.model === initial.model)
+ );
+ const canvasRef = useRef(null);
+ const listRef = useRef(null);
+ const runtimeRef = useRef(null);
+ const previewRef = useRef(null);
+ const loadTokenRef = useRef(0);
+ const framingRef = useRef(null);
+ const animationFramingRef = useRef(null);
+ const viewRef = useRef(initial.view);
+ const faceFocusRef = useRef(initial.faceFocus);
+
+ const [runtimeReady, setRuntimeReady] = useState(false);
+ const [runtimeStage, setRuntimeStage] = useState('Preparing model renderer');
+ const [runtimeError, setRuntimeError] = useState('');
+ const [selectedModel, setSelectedModel] = useState(
+ inventory[initialIndex]?.model ?? inventory[0]?.model ?? ''
+ );
+ const [query, setQuery] = useState('');
+ const [filter, setFilter] = useState('all');
+ const [face, setFace] = useState(initial.face);
+ const [texture, setTexture] = useState(initial.texture);
+ const [helmTexture, setHelmTexture] = useState(initial.helmTexture);
+ const [view, setView] = useState(initial.view);
+ const [faceFocus, setFaceFocus] = useState(initial.faceFocus);
+ const [diagnostics, setDiagnostics] = useState(null);
+ const [loadedSelectionKey, setLoadedSelectionKey] = useState('');
+ const [diagnosticsByModel, setDiagnosticsByModel] = useState({});
+ const [modelStage, setModelStage] = useState('Waiting for renderer');
+ const [modelError, setModelError] = useState('');
+ const [animationName, setAnimationName] = useState('');
+ const [animationPlaying, setAnimationPlaying] = useState(false);
+ const [animationFrame, setAnimationFrame] = useState(0);
+ const [animationSafety, setAnimationSafety] = useState(null);
+ const [reviews, setReviews] = useState(readStoredReviews);
+ const [reviewResponse, setReviewResponse] = useState(
+ readStoredReviews()[inventory[initialIndex]?.model]?.response ?? ''
+ );
+ const [note, setNote] = useState(
+ readStoredReviews()[inventory[initialIndex]?.model]?.note ?? ''
+ );
+
+ const selectedEntry = useMemo(
+ () => inventory.find((entry) => entry.model === selectedModel) ?? inventory[0],
+ [inventory, selectedModel]
+ );
+ const selectedVariant = selectedEntry?.variants?.[0] ?? null;
+ const selectionKey = getSelectionKey({
+ model: selectedEntry?.model ?? selectedModel,
+ face,
+ texture,
+ helmTexture,
+ });
+ const selectedReview = reviews[selectedModel] ?? null;
+ const maxTexture = Math.max(
+ Number(selectedEntry?.appearance?.minTexture ?? 0),
+ Number(selectedEntry?.appearance?.maxTexture ?? 0)
+ );
+ const maxHelmTexture = Math.max(
+ Number(selectedEntry?.appearance?.minHelmTexture ?? 0),
+ Number(selectedEntry?.appearance?.maxHelmTexture ?? 0)
+ );
+
+ const filteredInventory = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+ return inventory.filter((entry) => {
+ const matchesQuery = !normalizedQuery ||
+ entry.model.includes(normalizedQuery) ||
+ entry.variants.some((variant) =>
+ `${variant.raceName ?? ''}`.toLowerCase().includes(normalizedQuery)
+ );
+ if (!matchesQuery) return false;
+ if (filter === 'issues') {
+ return reviews[entry.model]?.status === 'issue' ||
+ diagnosticsByModel[entry.model]?.pass === false;
+ }
+ if (filter === 'unreviewed') {
+ return !reviews[entry.model]?.status;
+ }
+ return true;
+ });
+ }, [diagnosticsByModel, filter, inventory, query, reviews]);
+
+ useEffect(() => {
+ const index = filteredInventory.findIndex(
+ (entry) => entry.model === selectedModel
+ );
+ if (index < 0 || !listRef.current) return undefined;
+ const timer = window.setTimeout(() => {
+ const list = listRef.current;
+ if (!list) return;
+ const rowHeight =
+ list.firstElementChild?.getBoundingClientRect?.().height || 42;
+ list.scrollTop = Math.max(
+ 0,
+ index * rowHeight - (list.clientHeight - rowHeight) / 2
+ );
+ }, 100);
+ return () => window.clearTimeout(timer);
+ }, [filteredInventory, selectedModel]);
+
+ const frameModel = useCallback((
+ nextView = 'front',
+ focusOnFace = faceFocusRef.current
+ ) => {
+ const preview = previewRef.current?.preview;
+ const runtime = runtimeRef.current;
+ const camera = runtime?.camera;
+ const canvas = canvasRef.current;
+ if (!preview?.rootNode || !camera || !canvas) {
+ return;
+ }
+ let wholeBounds = getReviewBounds(preview.rootNode);
+ if (!wholeBounds) {
+ return;
+ }
+ const animationFraming = animationFramingRef.current;
+ if (animationFraming?.bounds) {
+ const currentRootPosition = preview.rootNode.getAbsolutePosition?.() ??
+ preview.rootNode.position;
+ const offset = {
+ x: Number(currentRootPosition?.x ?? 0),
+ y: Number(currentRootPosition?.y ?? 0),
+ z: Number(currentRootPosition?.z ?? 0),
+ };
+ wholeBounds = {
+ minimum: {
+ x: animationFraming.bounds.minimum.x + offset.x,
+ y: animationFraming.bounds.minimum.y + offset.y,
+ z: animationFraming.bounds.minimum.z + offset.z,
+ },
+ maximum: {
+ x: animationFraming.bounds.maximum.x + offset.x,
+ y: animationFraming.bounds.maximum.y + offset.y,
+ z: animationFraming.bounds.maximum.z + offset.z,
+ },
+ width : animationFraming.bounds.width,
+ height: animationFraming.bounds.height,
+ depth : animationFraming.bounds.depth,
+ };
+ }
+ const headBounds = focusOnFace
+ ? getReviewBounds(preview.rootNode, { headOnly: true })
+ : null;
+ const bounds = focusOnFace && !headBounds
+ ? {
+ minimum: {
+ x: wholeBounds.minimum.x + wholeBounds.width * 0.25,
+ y: wholeBounds.minimum.y + wholeBounds.height * 0.68,
+ z: wholeBounds.minimum.z + wholeBounds.depth * 0.25,
+ },
+ maximum: {
+ x: wholeBounds.maximum.x - wholeBounds.width * 0.25,
+ y: wholeBounds.maximum.y,
+ z: wholeBounds.maximum.z - wholeBounds.depth * 0.25,
+ },
+ width : wholeBounds.width * 0.5,
+ height: wholeBounds.height * 0.32,
+ depth : wholeBounds.depth * 0.5,
+ }
+ : headBounds ?? wholeBounds;
+ const target = new Vector3(
+ (bounds.minimum.x + bounds.maximum.x) / 2,
+ (bounds.minimum.y + bounds.maximum.y) / 2,
+ (bounds.minimum.z + bounds.maximum.z) / 2
+ );
+
+ const canvasRect = canvas.getBoundingClientRect();
+ const railRect = document.querySelector('.model-review-rail')?.getBoundingClientRect();
+ const inspectorRect = document.querySelector('.model-review-inspector')?.getBoundingClientRect();
+ const toolbarRect = document.querySelector('.model-review-toolbar')?.getBoundingClientRect();
+ const clamp = (value, minimum, maximum) =>
+ Math.min(maximum, Math.max(minimum, value));
+ const stageLeft = clamp(
+ (railRect?.right ?? canvasRect.left) - canvasRect.left,
+ 0,
+ canvasRect.width
+ );
+ const stageRight = clamp(
+ (inspectorRect?.left ?? canvasRect.right) - canvasRect.left,
+ stageLeft + 1,
+ canvasRect.width
+ );
+ const stageTop = clamp(
+ (toolbarRect?.bottom ?? canvasRect.top) - canvasRect.top,
+ 0,
+ canvasRect.height - 1
+ );
+ const stageWidth = Math.max(1, stageRight - stageLeft);
+ const stageHeight = Math.max(1, canvasRect.height - stageTop);
+ const normalizedViewport = {
+ x : stageLeft / canvasRect.width,
+ // Babylon forwards this normalized value to WebGL, whose viewport
+ // origin is at the bottom-left. A zero Y therefore anchors the stage
+ // below the top toolbar in CSS coordinates.
+ y : 0,
+ width : stageWidth / canvasRect.width,
+ height: stageHeight / canvasRect.height,
+ };
+ camera.viewport = new Viewport(
+ normalizedViewport.x,
+ normalizedViewport.y,
+ normalizedViewport.width,
+ normalizedViewport.height
+ );
+
+ const aspectRatio = stageWidth / stageHeight;
+ const verticalFov = Number(camera.fov) || 0.8;
+ const horizontalFov = 2 * Math.atan(
+ Math.tan(verticalFov / 2) * aspectRatio
+ );
+ const horizontalSize = nextView === 'side' ? bounds.width : bounds.depth;
+ const viewDepth = nextView === 'side' ? bounds.depth : bounds.width;
+ const padding = focusOnFace ? 1.2 : 1.12;
+ const distance = Math.max(
+ 1,
+ viewDepth / 2 + Math.max(
+ (bounds.height * padding / 2) / Math.tan(verticalFov / 2),
+ (horizontalSize * padding / 2) / Math.tan(horizontalFov / 2)
+ )
+ );
+ const positions = {
+ front: new Vector3(target.x - distance, target.y, target.z),
+ side : new Vector3(target.x, target.y, target.z - distance),
+ back : new Vector3(target.x + distance, target.y, target.z),
+ };
+ // ArcRotateCamera derives its angles and radius from the current target.
+ // Set that target first so switching models cannot inherit the prior model's
+ // target and end up offset or clipped.
+ camera.setTarget(target);
+ camera.setPosition?.(positions[nextView] ?? positions.front);
+ if (!camera.setPosition) {
+ camera.position.copyFrom(positions[nextView] ?? positions.front);
+ }
+ framingRef.current = {
+ distance,
+ stage: {
+ left : stageLeft,
+ top : stageTop,
+ width : stageWidth,
+ height: stageHeight,
+ },
+ target: { x: target.x, y: target.y, z: target.z },
+ viewport: normalizedViewport,
+ view: nextView,
+ faceFocus: focusOnFace,
+ usesAnimationEnvelope: !!animationFraming?.bounds,
+ };
+ if (window.__spireSageModelReview) {
+ window.__spireSageModelReview.framing = framingRef.current;
+ }
+ runtime.scene.render();
+ }, []);
+
+ const reframe = useCallback(() => {
+ frameModel(viewRef.current, faceFocusRef.current);
+ return framingRef.current;
+ }, [frameModel]);
+
+ const getSelectedAnimation = useCallback(() =>
+ previewRef.current?.preview?.animationGroups?.find(
+ (group) => group.name === animationName
+ ) ?? null,
+ [animationName]);
+
+ const startAnimation = useCallback((name, { play = true } = {}) => {
+ const preview = previewRef.current?.preview;
+ if (!preview) return;
+ const group = preview.animationGroups.find((candidate) => candidate.name === name);
+ preview.animationGroups.forEach((candidate) => candidate.stop?.());
+ if (!group) {
+ animationFramingRef.current = null;
+ setAnimationPlaying(false);
+ setAnimationSafety(null);
+ return;
+ }
+ const isPose = preview.isPoseAnimation?.(group) === true;
+ const safety = isPose ? { pass: true, pose: true } : preview.validateAnimationBounds?.(group);
+ setAnimationSafety(safety ?? { pass: true });
+ if (safety?.pass === false) {
+ animationFramingRef.current = null;
+ setAnimationPlaying(false);
+ return;
+ }
+ animationFramingRef.current = safety?.framingBounds
+ ? { bounds: safety.framingBounds }
+ : null;
+ if (isPose && preview.nativePoseOnly) {
+ preview.applyNeutralSkeletonPose?.();
+ } else {
+ group.play?.(!isPose);
+ }
+ if ((!play || isPose) && !(isPose && preview.nativePoseOnly)) {
+ group.pause?.();
+ group.goToFrame?.(Number(group.from ?? 0));
+ }
+ preview.synchronizeSkeletonPose?.();
+ preview.normalizeAnimatedGroundPose?.();
+ setAnimationFrame(getAnimationFrame(group));
+ setAnimationPlaying(play && !isPose);
+ runtimeRef.current?.scene?.render?.();
+ }, []);
+
+ const selectModel = useCallback((model) => {
+ const entry = inventory.find((candidate) => candidate.model === model);
+ setSelectedModel(model);
+ setFace(0);
+ setTexture(Math.max(0, Number(entry?.appearance?.minTexture ?? 0)));
+ setHelmTexture(Math.max(0, Number(entry?.appearance?.minHelmTexture ?? 0)));
+ }, [inventory]);
+
+ const runQaStep = useCallback((command = {}) => {
+ const requestedModel = `${command.model ?? ''}`.trim().toLowerCase();
+ if (
+ requestedModel &&
+ !inventory.some((entry) => entry.model === requestedModel)
+ ) {
+ return false;
+ }
+ if (requestedModel) {
+ selectModel(requestedModel);
+ }
+ if (VIEW_OPTIONS.some((option) => option.id === command.view)) {
+ setView(command.view);
+ }
+ if (typeof command.faceFocus === 'boolean') {
+ setFaceFocus(command.faceFocus);
+ }
+ const applyVariant = (value, setter) => {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed) && parsed >= 0) {
+ setter(Math.trunc(parsed));
+ }
+ };
+ applyVariant(command.face, setFace);
+ applyVariant(command.texture, setTexture);
+ applyVariant(command.helmTexture, setHelmTexture);
+ return true;
+ }, [inventory, selectModel]);
+
+ const chooseRelativeModel = useCallback((offset) => {
+ if (!filteredInventory.length) return;
+ const index = filteredInventory.findIndex((entry) => entry.model === selectedModel);
+ const nextIndex = (Math.max(0, index) + offset + filteredInventory.length) %
+ filteredInventory.length;
+ selectModel(filteredInventory[nextIndex].model);
+ }, [filteredInventory, selectModel, selectedModel]);
+
+ const closeWorkspace = useCallback(() => {
+ const url = new URL(window.location.href);
+ for (const key of [
+ 'sageModelReview',
+ 'sageModel',
+ 'sageModelFace',
+ 'sageModelTexture',
+ 'sageModelHelm',
+ 'sageModelView',
+ 'sageModelFaceFocus',
+ ]) {
+ url.searchParams.delete(key);
+ }
+ window.history.replaceState(null, '', url.toString());
+ window.__spireSageModelReview = null;
+ setModelExporter(false);
+ setZoneDialogOpen(true);
+ }, [setModelExporter, setZoneDialogOpen]);
+
+ useEffect(() => {
+ viewRef.current = view;
+ }, [view]);
+
+ useEffect(() => {
+ faceFocusRef.current = faceFocus;
+ }, [faceFocus]);
+
+ useEffect(() => {
+ let current = true;
+ let modelController = null;
+ let previousGlowLayer = null;
+ let resizeHandler = null;
+ const settingsSnapshot = { ...settings };
+
+ (async () => {
+ try {
+ setRuntimeStage('Loading Sage runtime');
+ const controller = gameController ?? await loadGameController();
+ const [{ default: bjs }, modelControllerModule] = await Promise.all([
+ import('@bjs'),
+ import('../../viewer/controllers/ModelController'),
+ ]);
+ await bjs.prepareZoneViewer((stage) => current && setRuntimeStage(stage));
+ if (!current || !canvasRef.current) return;
+
+ modelController = modelControllerModule.modelController;
+ controller.ModelController = modelController;
+ modelController.setGameController(controller);
+ controller.settings = settingsSnapshot;
+ setRuntimeStage('Starting neutral model scene');
+ await controller.loadEngine(canvasRef.current, settingsSnapshot.webgpu);
+ await modelController.initializeModelExporter();
+ if (!current) return;
+
+ modelController.swapBackground('none');
+ if (modelController.glowLayer) {
+ modelController.glowLayer.isEnabled = false;
+ modelController.glowLayer.intensity = 0;
+ }
+ controller.CameraController.rotate(false);
+ const scene = modelController.scene;
+ scene.environmentTexture?.dispose?.();
+ scene.environmentTexture = null;
+ scene.environmentIntensity = 0;
+ scene.clearColor = new Color4(0.025, 0.035, 0.045, 1);
+ const fillLight = new HemisphericLight(
+ 'model-review-fill',
+ new Vector3(0, 1, 0),
+ scene
+ );
+ fillLight.diffuse = new Color3(1, 1, 1);
+ fillLight.groundColor = new Color3(0.25, 0.23, 0.2);
+ // Match the embedded zone viewer so appearance evidence is not
+ // skewed by the retired exporter's HDR environment or glow layer.
+ fillLight.intensity = 0.85;
+ previousGlowLayer = controller.ZoneController.glowLayer;
+ controller.ZoneController.glowLayer = modelController.glowLayer;
+ controller.SpawnController.setupSpawnController();
+ resizeHandler = () => {
+ controller.resize();
+ window.requestAnimationFrame(() => frameModel(
+ viewRef.current,
+ faceFocusRef.current
+ ));
+ };
+ window.addEventListener('resize', resizeHandler);
+ runtimeRef.current = {
+ camera: controller.CameraController.camera,
+ controller,
+ fillLight,
+ modelController,
+ scene,
+ };
+ setRuntimeStage('Model renderer ready');
+ setRuntimeReady(true);
+ } catch (error) {
+ if (!current) return;
+ console.error('[SageModelReview] failed to initialize', error);
+ setRuntimeError(error?.message ?? String(error));
+ setRuntimeStage('Model renderer failed');
+ }
+ })();
+
+ return () => {
+ current = false;
+ loadTokenRef.current++;
+ previewRef.current?.preview?.dispose?.();
+ previewRef.current?.root?.dispose?.();
+ previewRef.current = null;
+ if (resizeHandler) window.removeEventListener('resize', resizeHandler);
+ const controller = runtimeRef.current?.controller ?? gameController;
+ if (controller?.ZoneController) {
+ controller.ZoneController.glowLayer = previousGlowLayer;
+ }
+ runtimeRef.current?.fillLight?.dispose?.();
+ modelController?.dispose?.();
+ controller?.stopRenderLoop?.();
+ runtimeRef.current = null;
+ };
+ // Runtime ownership intentionally lasts for the workspace lifetime.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [frameModel]);
+
+ useEffect(() => {
+ if (!runtimeReady || !selectedEntry || !runtimeRef.current) return;
+ const loadToken = ++loadTokenRef.current;
+ const loadSelectionKey = selectionKey;
+ let createdPreview = null;
+ let createdRoot = null;
+
+ (async () => {
+ setModelStage(`Loading ${selectedEntry.model.toUpperCase()}`);
+ setModelError('');
+ setDiagnostics(null);
+ setLoadedSelectionKey('');
+ framingRef.current = null;
+ setAnimationName('');
+ animationFramingRef.current = null;
+ setAnimationPlaying(false);
+ previewRef.current?.preview?.dispose?.();
+ previewRef.current?.root?.dispose?.();
+ previewRef.current = null;
+
+ const { controller, scene } = runtimeRef.current;
+ createdRoot = new TransformNode(
+ `model-review-${selectedEntry.model}-root`,
+ scene
+ );
+ createdPreview = new BabylonSpawn(
+ {
+ id: -910000,
+ __spireSpawnId: -910000,
+ name: `${selectedVariant?.raceName ?? selectedEntry.model} Model Review`,
+ race: selectedVariant?.raceId ?? 0,
+ gender: selectedVariant?.genderIndex ?? 0,
+ face,
+ texture,
+ helmtexture: helmTexture,
+ size: 6,
+ x: 0,
+ y: 0,
+ z: 0,
+ heading: 0,
+ grid: [],
+ },
+ selectedEntry.model,
+ createdRoot,
+ controller.SpawnController.sphereMat
+ );
+
+ const previousBulkLoading = window.__spireSageBulkSpawnLoading;
+ const previousSkipNameplates = window.__spireSageSkipBulkNameplates;
+ window.__spireSageBulkSpawnLoading = true;
+ window.__spireSageSkipBulkNameplates = true;
+ try {
+ const initialized = await createdPreview.initializeSpawn();
+ if (!initialized) {
+ throw new Error('No renderable model asset was found');
+ }
+ } finally {
+ window.__spireSageBulkSpawnLoading = previousBulkLoading;
+ window.__spireSageSkipBulkNameplates = previousSkipNameplates;
+ }
+
+ if (loadToken !== loadTokenRef.current) {
+ createdPreview.dispose();
+ createdRoot.dispose();
+ return;
+ }
+
+ createdPreview.disposeNameplate?.();
+ previewRef.current = { preview: createdPreview, root: createdRoot };
+ if (createdPreview.nativePoseOnly) {
+ createdPreview.applyNeutralSkeletonPose?.();
+ }
+ let nextDiagnostics = inspectModelReviewSpawn(createdPreview);
+ for (
+ let attempt = 0;
+ attempt < 20 && nextDiagnostics.appearance.pendingTextureCount > 0;
+ attempt++
+ ) {
+ scene.render();
+ await new Promise((resolve) => window.setTimeout(resolve, 75));
+ if (loadToken !== loadTokenRef.current) {
+ createdPreview.dispose();
+ createdRoot.dispose();
+ return;
+ }
+ nextDiagnostics = inspectModelReviewSpawn(createdPreview);
+ }
+ setDiagnostics(nextDiagnostics);
+ setDiagnosticsByModel((current) => ({
+ ...current,
+ [selectedEntry.model]: nextDiagnostics,
+ }));
+ const preferred = createdPreview.getPreferredVisualAnimationGroup?.();
+ const pose = createdPreview.animationGroups.find((group) =>
+ createdPreview.isPoseAnimation?.(group)
+ );
+ const nextAnimation = preferred ?? pose ?? createdPreview.animationGroups[0] ?? null;
+ setAnimationName(nextAnimation?.name ?? '');
+ setModelStage(
+ `${selectedEntry.model.toUpperCase()} ready · ${createdPreview.resolvedModelAsset ?? createdPreview.loadedModelVariation ?? selectedEntry.model}`
+ );
+ requestAnimationFrame(() => {
+ frameModel(
+ viewRef.current,
+ faceFocusRef.current
+ );
+ setLoadedSelectionKey(loadSelectionKey);
+ });
+ })().catch((error) => {
+ if (loadToken !== loadTokenRef.current) return;
+ console.error('[SageModelReview] failed to load model', error);
+ createdPreview?.dispose?.();
+ createdRoot?.dispose?.();
+ setModelError(error?.message ?? String(error));
+ setModelStage(`${selectedEntry.model.toUpperCase()} failed`);
+ const failureDiagnostics = createModelLoadFailureDiagnostics(error);
+ setDiagnostics(failureDiagnostics);
+ setLoadedSelectionKey(loadSelectionKey);
+ setDiagnosticsByModel((current) => ({
+ ...current,
+ [selectedEntry.model]: failureDiagnostics,
+ }));
+ });
+
+ return () => {
+ if (previewRef.current?.preview === createdPreview) return;
+ createdPreview?.dispose?.();
+ createdRoot?.dispose?.();
+ };
+ }, [face, frameModel, helmTexture, runtimeReady, selectedEntry, selectedVariant, selectionKey, texture]);
+
+ useEffect(() => {
+ if (!animationName || !previewRef.current?.preview) return;
+ startAnimation(animationName);
+ }, [animationName, startAnimation]);
+
+ useEffect(() => {
+ if (!animationName || !previewRef.current?.preview) return undefined;
+ const frameTimer = window.setTimeout(
+ () => frameModel(view, faceFocus),
+ 250
+ );
+ return () => window.clearTimeout(frameTimer);
+ }, [animationName, faceFocus, frameModel, view]);
+
+ useEffect(() => {
+ frameModel(view, faceFocus);
+ }, [faceFocus, frameModel, view]);
+
+ useEffect(() => {
+ const storedReview = reviews[selectedModel];
+ setReviewResponse(storedReview?.response ?? '');
+ setNote(storedReview?.note ?? '');
+ }, [reviews, selectedModel]);
+
+ useEffect(() => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('sageModelReview', '1');
+ url.searchParams.set('sageModel', selectedModel);
+ url.searchParams.set('sageModelFace', String(face));
+ url.searchParams.set('sageModelTexture', String(texture));
+ url.searchParams.set('sageModelHelm', String(helmTexture));
+ url.searchParams.set('sageModelView', view);
+ url.searchParams.set('sageModelFaceFocus', faceFocus ? '1' : '0');
+ window.history.replaceState(null, '', url.toString());
+ }, [face, faceFocus, helmTexture, selectedModel, texture, view]);
+
+ const qaDiagnostics = useMemo(
+ () => diagnostics ?? (
+ runtimeError
+ ? createModelLoadFailureDiagnostics(new Error(runtimeError))
+ : null
+ ),
+ [diagnostics, runtimeError]
+ );
+ const automatedReviewSuggestion = useMemo(
+ () => getAutomatedReviewSuggestion(qaDiagnostics, animationSafety),
+ [animationSafety, qaDiagnostics]
+ );
+
+ useEffect(() => {
+ const ready = !!runtimeError || (
+ runtimeReady &&
+ !!qaDiagnostics &&
+ loadedSelectionKey === selectionKey
+ );
+ const state = {
+ qaApiVersion: 2,
+ ready,
+ model: selectedModel,
+ diagnostics: qaDiagnostics,
+ animationSafety,
+ automatedReviewSuggestion,
+ framing: framingRef.current,
+ stage: modelStage,
+ view,
+ faceFocus,
+ selection: {
+ face,
+ texture,
+ helmTexture,
+ },
+ };
+ window.__spireSageModelReview = {
+ ...state,
+ reframe,
+ runQaStep,
+ };
+ window.dispatchEvent(new CustomEvent('spire-sage-model-review-state', {
+ detail: state,
+ }));
+ }, [
+ automatedReviewSuggestion,
+ qaDiagnostics,
+ animationSafety,
+ face,
+ faceFocus,
+ helmTexture,
+ loadedSelectionKey,
+ modelError,
+ modelStage,
+ reframe,
+ runQaStep,
+ runtimeReady,
+ runtimeError,
+ selectedModel,
+ selectionKey,
+ texture,
+ view,
+ ]);
+
+ useEffect(() => {
+ if (!animationPlaying) return undefined;
+ const timer = window.setInterval(() => {
+ setAnimationFrame(getAnimationFrame(getSelectedAnimation()));
+ }, 100);
+ return () => window.clearInterval(timer);
+ }, [animationPlaying, getSelectedAnimation]);
+
+ const canSaveIssueReview = !!reviewResponse &&
+ (reviewResponse !== 'other' || !!note.trim());
+
+ const saveReview = useCallback((status) => {
+ if (status === 'issue' && !canSaveIssueReview) return;
+ const savedResponse = status === 'issue' ? reviewResponse : '';
+ const next = {
+ ...reviews,
+ [selectedModel]: {
+ status,
+ response: savedResponse,
+ note: note.trim(),
+ qa: {
+ pass: qaDiagnostics?.pass === true && animationSafety?.pass !== false,
+ suggestedResponse: automatedReviewSuggestion?.response ?? null,
+ reasons: automatedReviewSuggestion?.reasons ?? [],
+ },
+ updatedAt: new Date().toISOString(),
+ face,
+ texture,
+ helmTexture,
+ },
+ };
+ setReviews(next);
+ localStorage.setItem(REVIEW_STORAGE_KEY, JSON.stringify(next));
+ if (status === 'pass') setReviewResponse('');
+ chooseRelativeModel(1);
+ }, [
+ canSaveIssueReview,
+ chooseRelativeModel,
+ face,
+ helmTexture,
+ note,
+ qaDiagnostics,
+ animationSafety,
+ automatedReviewSuggestion,
+ reviewResponse,
+ reviews,
+ selectedModel,
+ texture,
+ ]);
+
+ useEffect(() => {
+ const onKeyDown = (event) => {
+ const isReviewResponseSelect = event.target instanceof HTMLSelectElement &&
+ event.target.getAttribute('aria-label') === 'Review response';
+ if (isTypingTarget(event.target) && !(event.key === 'Enter' && isReviewResponseSelect)) return;
+ if (
+ event.key === 'Enter' &&
+ (event.target instanceof HTMLButtonElement || event.target instanceof HTMLAnchorElement)
+ ) return;
+ const pressedKey = event.key.toUpperCase();
+ const response = REVIEW_RESPONSE_OPTIONS.find(
+ (option) => option.shortcut === event.key
+ );
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault();
+ chooseRelativeModel(-1);
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault();
+ chooseRelativeModel(1);
+ } else if (response) {
+ event.preventDefault();
+ setReviewResponse(response.value);
+ } else if (event.key === 'Enter') {
+ if (!canSaveIssueReview) return;
+ event.preventDefault();
+ saveReview('issue');
+ } else if (event.key === ' ') {
+ event.preventDefault();
+ const group = getSelectedAnimation();
+ if (!group || animationSafety?.pass === false) return;
+ if (animationPlaying) group.pause?.();
+ else group.play?.(true);
+ setAnimationPlaying(!animationPlaying);
+ } else if (pressedKey === 'C') {
+ setFaceFocus((current) => !current);
+ } else {
+ const cameraView = VIEW_OPTIONS.find(
+ (option) => option.shortcut === pressedKey
+ );
+ if (cameraView) setView(cameraView.id);
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [
+ animationPlaying,
+ animationSafety?.pass,
+ canSaveIssueReview,
+ chooseRelativeModel,
+ getSelectedAnimation,
+ saveReview,
+ ]);
+
+ const copyReviewLink = async () => {
+ await navigator.clipboard?.writeText?.(window.location.href);
+ };
+
+ const animations = previewRef.current?.preview?.animationGroups ?? [];
+ const selectedAnimation = animations.find((group) => group.name === animationName);
+ const animationFrom = Number(selectedAnimation?.from ?? 0);
+ const animationTo = Math.max(animationFrom, Number(selectedAnimation?.to ?? animationFrom));
+ const selectedIndex = filteredInventory.findIndex((entry) => entry.model === selectedModel);
+ const animationEvidencePass = diagnostics?.animationPass === true &&
+ animationSafety?.pass !== false;
+ const automatedResponseLabel = REVIEW_RESPONSE_OPTIONS.find(
+ (option) => option.value === automatedReviewSuggestion?.response
+ )?.label;
+
+ return (
+
+
+
+
+
+
+ ←
+
+
+ Model Review
+ Runtime appearance queue
+
+
+
+ Search models
+ setQuery(event.target.value)}
+ />
+
+
+ {[
+ ['all', 'All'],
+ ['issues', 'Issues'],
+ ['unreviewed', 'Unreviewed'],
+ ].map(([value, label]) => (
+ setFilter(value)}
+ >
+ {label}
+
+ ))}
+
+
+ {filteredInventory.length} models
+ {Object.keys(reviews).length} reviewed
+
+
+ {filteredInventory.map((entry) => {
+ const stored = reviews[entry.model]?.status;
+ const runtimeResult = diagnosticsByModel[entry.model];
+ const state = stored === 'issue' || runtimeResult?.pass === false
+ ? 'issue'
+ : stored === 'pass'
+ ? 'pass'
+ : 'unknown';
+ return (
+ selectModel(entry.model)}
+ aria-current={entry.model === selectedModel ? 'true' : undefined}
+ >
+
+ {entry.model.toUpperCase()}
+
+ {entry.variants.map((variant) => variant.raceName).filter((value, index, values) => values.indexOf(value) === index).slice(0, 2).join(' / ')}
+
+
+ );
+ })}
+
+
+
+
+
+
chooseRelativeModel(-1)} aria-label="Previous model">‹
+
+ {selectedEntry?.model?.toUpperCase()}
+
+ {selectedVariant?.raceName ?? 'Unknown race'} · {Math.max(0, selectedIndex) + 1} / {filteredInventory.length}
+
+
+
chooseRelativeModel(1)} aria-label="Next model">›
+
+
+ {VIEW_OPTIONS.map((option) => (
+ setView(option.id)}
+ title={`${option.label} view (${option.shortcut})`}
+ >
+ {option.label}
+ {option.shortcut}
+
+ ))}
+ setFaceFocus((current) => !current)}
+ aria-pressed={faceFocus}
+ title="Toggle face focus (C)"
+ >
+ Face focus
+ C
+
+
+
+
+ {runtimeError || modelError || (runtimeReady ? modelStage : runtimeStage)}
+
+
+
+
+
+
+ Evidence
+ Copy link
+
+
+
+
+
+
+
+
Resolved asset {previewRef.current?.preview?.resolvedModelAsset?.toUpperCase?.() ?? '—'}
+
Geometry {diagnostics ? `${diagnostics.meshCount} meshes` : '—'}
+
Materials {diagnostics ? `${diagnostics.materialCount} / ${diagnostics.textureCount} textured` : '—'}
+
Head risks {diagnostics ? diagnostics.headOrientation.filter((item) => item.risk).length : '—'}
+ {diagnostics?.semanticHeadOrientation?.required && (
+
+
Head geometry
+ {diagnostics.semanticHeadOrientation.pass ? 'Upright' : 'Inverted'}
+
+ )}
+
Skeletons {diagnostics?.skeletonCount ?? '—'}
+
Dynamic clips {diagnostics?.animationVitality?.dynamicGroupCount ?? '—'}
+
+ {diagnostics?.appearance?.invariantViolations?.length > 0 && (
+
+ {diagnostics.appearance.invariantViolations.join(' · ')}
+
+ )}
+ {diagnostics?.animationReadiness?.violations?.length > 0 && (
+
+ {diagnostics.animationReadiness.violations.join(' · ')}
+
+ )}
+
+
+
+ Appearance variants
+
+
+ Face
+ setFace(Number(event.target.value))}>
+ {Array.from({ length: 8 }, (_, index) => {index} )}
+
+
+
+ Body
+ setTexture(Number(event.target.value))}>
+ {Array.from({ length: maxTexture + 1 }, (_, index) => {index} )}
+
+
+
+ Helm
+ setHelmTexture(Number(event.target.value))}>
+ {Array.from({ length: maxHelmTexture + 1 }, (_, index) => {index} )}
+
+
+
+
+
+
+ Animation
+
+ Clip
+ setAnimationName(event.target.value)}>
+ {animations.map((group) => (
+ {labelAnimation(group.name)}
+ ))}
+
+
+
+ {
+ const group = getSelectedAnimation();
+ if (!group || animationSafety?.pass === false) return;
+ if (animationPlaying) group.pause?.();
+ else group.play?.(true);
+ setAnimationPlaying(!animationPlaying);
+ }}
+ disabled={!selectedAnimation || animationSafety?.pass === false}
+ aria-label={animationPlaying ? 'Pause animation' : 'Play animation'}
+ >
+ {animationPlaying ? 'Ⅱ' : '▶'}
+
+ {
+ const frame = Number(event.target.value);
+ selectedAnimation?.pause?.();
+ selectedAnimation?.goToFrame?.(frame);
+ setAnimationPlaying(false);
+ setAnimationFrame(frame);
+ previewRef.current?.preview?.synchronizeSkeletonPose?.();
+ frameModel(view, faceFocus);
+ }}
+ />
+ {Math.round(animationFrame)}
+
+ {animationSafety?.pass === false && (
+ Unsafe animated bounds; playback blocked
+ )}
+
+
+
+ Review
+
+ Standard response
+ setReviewResponse(event.target.value)}
+ >
+ Select a response…
+ {REVIEW_RESPONSE_OPTIONS.map((option) => (
+
+ {option.shortcut} — {option.label}
+
+ ))}
+
+
+
+
+ {reviewResponse === 'other'
+ ? 'Other issue details'
+ : 'Additional details (optional)'}
+
+
+
+ Automated QA: {automatedResponseLabel ?? 'No issue detected'}
+
+
+ saveReview('pass')}
+ >
+ Looks good
+
+ saveReview('issue')}
+ title={!reviewResponse
+ ? 'Select a standard response first'
+ : reviewResponse === 'other' && !note.trim()
+ ? 'Describe the issue below'
+ : 'Flag issue (Enter)'}
+ >
+ Flag issue
+
+
+
+
+
+
+ ← → models
+ 1–8 response
+ F S R views
+ C face focus
+ Enter flag issue
+ Space animation
+
+
+ );
+};
diff --git a/frontend/eqsage-embed/src/components/model-review/model-review-workspace.scss b/frontend/eqsage-embed/src/components/model-review/model-review-workspace.scss
new file mode 100644
index 00000000..59d7c1dc
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/model-review/model-review-workspace.scss
@@ -0,0 +1,592 @@
+.model-review-workspace {
+ --review-rail: 272px;
+ --review-inspector: 326px;
+ --review-line: rgba(210, 199, 159, 0.18);
+ --review-gold: #d7c37f;
+ --review-muted: #8d98a4;
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ overflow: hidden;
+ background: #071019;
+ color: #e6e0cf;
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+.model-review-workspace button,
+.model-review-workspace input,
+.model-review-workspace select,
+.model-review-workspace textarea {
+ font: inherit;
+}
+
+.model-review-workspace button {
+ color: inherit;
+}
+
+#modelReviewCanvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ outline: none;
+}
+
+.model-review-rail,
+.model-review-inspector,
+.model-review-toolbar,
+.model-review-hints {
+ position: absolute;
+ z-index: 2;
+ background: rgba(9, 14, 20, 0.94);
+ backdrop-filter: blur(12px);
+}
+
+.model-review-rail {
+ inset: 0 auto 0 0;
+ width: var(--review-rail);
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--review-line);
+}
+
+.model-review-brand {
+ min-height: 72px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--review-line);
+}
+
+.model-review-brand strong,
+.model-review-brand span {
+ display: block;
+}
+
+.model-review-brand strong {
+ color: #f0e7cb;
+ font-family: Montaga, Georgia, serif;
+ font-size: 18px;
+ font-weight: 500;
+}
+
+.model-review-brand span {
+ margin-top: 2px;
+ color: var(--review-muted);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.model-review-icon-button,
+.model-review-navigation > button {
+ width: 34px;
+ height: 34px;
+ border: 1px solid var(--review-line);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.03);
+ cursor: pointer;
+}
+
+.model-review-icon-button:hover,
+.model-review-navigation > button:hover {
+ border-color: rgba(215, 195, 127, 0.55);
+ background: rgba(215, 195, 127, 0.08);
+}
+
+.model-review-search {
+ padding: 14px 14px 8px;
+}
+
+.model-review-search input,
+.model-review-field select,
+.model-review-variant-grid select,
+.model-review-review-response select,
+.model-review-review-section textarea {
+ width: 100%;
+ box-sizing: border-box;
+ border: 1px solid var(--review-line);
+ border-radius: 3px;
+ background: rgba(2, 6, 10, 0.62);
+ color: #e7e4da;
+ outline: none;
+}
+
+.model-review-search input {
+ height: 36px;
+ padding: 0 11px;
+ font-size: 13px;
+}
+
+.model-review-search input:focus,
+.model-review-field select:focus,
+.model-review-variant-grid select:focus,
+.model-review-review-response select:focus,
+.model-review-review-section textarea:focus {
+ border-color: rgba(215, 195, 127, 0.65);
+ box-shadow: 0 0 0 2px rgba(215, 195, 127, 0.08);
+}
+
+.model-review-filter {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+ padding: 0 14px 10px;
+}
+
+.model-review-filter button,
+.model-review-views button,
+.model-review-section-heading button,
+.model-review-review-actions button {
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--review-muted);
+ cursor: pointer;
+}
+
+.model-review-filter button {
+ min-height: 30px;
+ font-size: 11px;
+}
+
+.model-review-filter button:hover,
+.model-review-filter button.is-active,
+.model-review-views button:hover,
+.model-review-views button.is-active {
+ border-color: var(--review-line);
+ background: rgba(215, 195, 127, 0.08);
+ color: #e9dfbd;
+}
+
+.model-review-count {
+ display: flex;
+ justify-content: space-between;
+ padding: 0 16px 9px;
+ color: #707c88;
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.model-review-list {
+ flex: 1;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-color: rgba(215, 195, 127, 0.25) transparent;
+}
+
+.model-review-list > button {
+ width: 100%;
+ height: 42px;
+ min-height: 42px;
+ box-sizing: border-box;
+ display: grid;
+ grid-template-columns: 8px 45px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+ padding: 6px 14px;
+ border: 0;
+ border-left: 2px solid transparent;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+ content-visibility: auto;
+ contain-intrinsic-size: 42px;
+}
+
+.model-review-list > button:hover {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.model-review-list > button.is-selected {
+ border-left-color: var(--review-gold);
+ background: linear-gradient(90deg, rgba(215, 195, 127, 0.12), transparent);
+}
+
+.model-review-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #46515c;
+}
+
+.model-review-dot.is-pass { background: #70b88a; }
+.model-review-dot.is-issue { background: #d36b5e; }
+
+.model-review-code {
+ color: #d9cfae;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+}
+
+.model-review-race {
+ overflow: hidden;
+ color: #8e99a4;
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.model-review-toolbar {
+ top: 0;
+ right: var(--review-inspector);
+ left: var(--review-rail);
+ min-height: 72px;
+ display: grid;
+ grid-template-columns: minmax(225px, 0.8fr) auto minmax(210px, 0.8fr);
+ align-items: center;
+ gap: 14px;
+ padding: 0 18px;
+ border-bottom: 1px solid var(--review-line);
+}
+
+.model-review-navigation {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.model-review-navigation > div {
+ min-width: 0;
+}
+
+.model-review-navigation strong,
+.model-review-navigation span {
+ display: block;
+}
+
+.model-review-navigation strong {
+ color: #efe5c6;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 14px;
+ letter-spacing: 0.08em;
+}
+
+.model-review-navigation span {
+ overflow: hidden;
+ margin-top: 2px;
+ color: var(--review-muted);
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.model-review-views {
+ display: flex;
+ gap: 4px;
+}
+
+.model-review-views button {
+ min-height: 32px;
+ padding: 0 9px;
+ font-size: 11px;
+}
+
+.model-review-views kbd {
+ margin-left: 6px;
+ color: #65717d;
+ font-size: 9px;
+}
+
+.model-review-stage {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ overflow: hidden;
+ color: #7f8b96;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.model-review-stage > span {
+ width: 6px;
+ height: 6px;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: #70b88a;
+ box-shadow: 0 0 7px rgba(112, 184, 138, 0.5);
+}
+
+.model-review-stage > span.is-error {
+ background: #d36b5e;
+ box-shadow: 0 0 7px rgba(211, 107, 94, 0.5);
+}
+
+.model-review-inspector {
+ inset: 0 0 0 auto;
+ width: var(--review-inspector);
+ overflow-y: auto;
+ border-left: 1px solid var(--review-line);
+}
+
+.model-review-inspector section {
+ padding: 17px 18px;
+ border-bottom: 1px solid var(--review-line);
+}
+
+.model-review-section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ color: #b8b09a;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+.model-review-section-heading button {
+ padding: 2px 0;
+ color: #8e9ca9;
+ font-size: 10px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.model-review-section-heading button:hover { color: #e3d7b2; }
+
+.model-review-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.model-review-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ border: 1px solid rgba(112, 184, 138, 0.22);
+ border-radius: 999px;
+ background: rgba(112, 184, 138, 0.06);
+ color: #9dc9aa;
+ font-size: 10px;
+}
+
+.model-review-badge > span {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #70b88a;
+}
+
+.model-review-badge.is-fail {
+ border-color: rgba(211, 107, 94, 0.28);
+ background: rgba(211, 107, 94, 0.07);
+ color: #df9a91;
+}
+
+.model-review-badge.is-fail > span { background: #d36b5e; }
+.model-review-badge.is-neutral { border-color: var(--review-line); background: transparent; color: #74808b; }
+.model-review-badge.is-neutral > span { background: #56616b; }
+
+.model-review-facts {
+ margin: 14px 0 0;
+}
+
+.model-review-facts > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 5px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.035);
+ font-size: 11px;
+}
+
+.model-review-facts dt { color: #75818c; }
+.model-review-facts dd { margin: 0; color: #c9c4b4; text-align: right; }
+
+.model-review-warning {
+ margin-top: 10px;
+ padding: 8px 9px;
+ border-left: 2px solid #d36b5e;
+ background: rgba(211, 107, 94, 0.07);
+ color: #dba39b;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 10px;
+ line-height: 1.5;
+}
+
+.model-review-variant-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.model-review-variant-grid label,
+.model-review-field {
+ color: #7f8b96;
+ font-size: 10px;
+}
+
+.model-review-variant-grid select,
+.model-review-field select {
+ height: 32px;
+ margin-top: 5px;
+ padding: 0 8px;
+ font-size: 12px;
+}
+
+.model-review-playback {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) 34px;
+ align-items: center;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.model-review-playback button {
+ width: 34px;
+ height: 32px;
+ border: 1px solid var(--review-line);
+ border-radius: 3px;
+ background: rgba(255, 255, 255, 0.025);
+ cursor: pointer;
+}
+
+.model-review-playback button:disabled { opacity: 0.35; cursor: default; }
+.model-review-playback input { accent-color: var(--review-gold); }
+.model-review-playback output { color: #7c8791; font-family: monospace; font-size: 10px; text-align: right; }
+
+.model-review-review-response,
+.model-review-review-details {
+ display: block;
+ color: #7f8b96;
+ font-size: 10px;
+}
+
+.model-review-review-response > span,
+.model-review-review-details > span {
+ display: block;
+ margin-bottom: 5px;
+}
+
+.model-review-review-response select {
+ width: 100%;
+ height: 34px;
+ box-sizing: border-box;
+ padding: 0 8px;
+ font-size: 11px;
+}
+
+.model-review-review-details {
+ margin-top: 10px;
+}
+
+.model-review-review-section textarea {
+ min-height: 76px;
+ resize: vertical;
+ padding: 9px;
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.model-review-review-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-top: 9px;
+}
+
+.model-review-qa-suggestion {
+ margin-top: 9px;
+ padding: 7px 8px;
+ border-left: 2px solid rgba(112, 184, 138, 0.72);
+ background: rgba(70, 142, 96, 0.08);
+ color: #91b99c;
+ font-size: 10px;
+}
+
+.model-review-qa-suggestion.is-issue {
+ border-left-color: rgba(211, 107, 94, 0.86);
+ background: rgba(165, 66, 55, 0.1);
+ color: #dba39b;
+}
+
+.model-review-review-actions button {
+ min-height: 32px;
+ font-size: 11px;
+}
+
+.model-review-review-actions .model-review-pass-action {
+ border-color: rgba(112, 184, 138, 0.5);
+ color: #add5b8;
+ background: rgba(70, 142, 96, 0.15);
+}
+
+.model-review-review-actions .model-review-pass-action:hover {
+ border-color: rgba(132, 207, 159, 0.74);
+ background: rgba(70, 142, 96, 0.25);
+}
+
+.model-review-review-actions .model-review-issue-action {
+ border-color: rgba(211, 107, 94, 0.54);
+ color: #e2a29a;
+ background: rgba(165, 66, 55, 0.16);
+}
+
+.model-review-review-actions .model-review-issue-action:hover {
+ border-color: rgba(226, 126, 114, 0.76);
+ background: rgba(165, 66, 55, 0.27);
+}
+
+.model-review-review-actions button:disabled,
+.model-review-review-actions button:disabled:hover {
+ opacity: 0.42;
+ cursor: default;
+}
+
+.model-review-review-actions button.is-pass {
+ border-color: rgba(132, 207, 159, 0.9);
+ background: rgba(70, 142, 96, 0.32);
+ box-shadow: inset 0 0 0 1px rgba(132, 207, 159, 0.12);
+}
+
+.model-review-review-actions button.is-issue {
+ border-color: rgba(226, 126, 114, 0.9);
+ background: rgba(165, 66, 55, 0.34);
+ box-shadow: inset 0 0 0 1px rgba(226, 126, 114, 0.12);
+}
+
+.model-review-hints {
+ right: calc(var(--review-inspector) + 16px);
+ bottom: 14px;
+ display: flex;
+ gap: 16px;
+ padding: 7px 10px;
+ border: 1px solid var(--review-line);
+ border-radius: 3px;
+ color: #68747f;
+ font-size: 10px;
+}
+
+.model-review-hints kbd {
+ margin-right: 3px;
+ color: #a59d87;
+ font-family: inherit;
+}
+
+.model-review-workspace .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+
+@media (max-width: 1120px) {
+ .model-review-workspace {
+ --review-rail: 222px;
+ --review-inspector: 286px;
+ }
+ .model-review-toolbar { grid-template-columns: minmax(190px, 1fr) auto; }
+ .model-review-stage { display: none; }
+ .model-review-views kbd { display: none; }
+}
diff --git a/frontend/eqsage-embed/src/components/spire/dialogs/npc-dialog.jsx b/frontend/eqsage-embed/src/components/spire/dialogs/npc-dialog.jsx
index 65d3dca7..00420ace 100644
--- a/frontend/eqsage-embed/src/components/spire/dialogs/npc-dialog.jsx
+++ b/frontend/eqsage-embed/src/components/spire/dialogs/npc-dialog.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CommonDialog } from './common';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
@@ -15,7 +15,8 @@ import { v4 } from 'uuid';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
-import { Button, FormControl, Stack, TextField } from '@mui/material';
+import { Autocomplete, Button, FormControl, Stack, TextField } from '@mui/material';
+import { useDebouncedCallback } from 'use-debounce';
import { gameController } from '../../../viewer/controllers/GameController';
import BABYLON from '@bjs';
import { useMainContext } from '../../main/context';
@@ -23,14 +24,45 @@ import { useZoneContext } from '../../zone/zone-context';
import { useAlertContext } from '../../../context/alerts';
import { useOverlayContext } from '../provider';
-const { Vector3 } = BABYLON;
+const { Tools, Vector3 } = BABYLON;
export const NpcDialog = ({ onClose }) => {
const [spawnFilter, setSpawnFilter] = useState('');
- const { selectedZone } = useMainContext();
+ const [newSpawnNpc, setNewSpawnNpc] = useState(null);
+ const [newSpawnNpcInput, setNewSpawnNpcInput] = useState('');
+ const [newSpawnNpcOptions, setNewSpawnNpcOptions] = useState([]);
+ const [newSpawnNpcOpen, setNewSpawnNpcOpen] = useState(false);
+ const [creatingSpawn, setCreatingSpawn] = useState(false);
+ const npcSearchRunRef = useRef(0);
+ const { selectedZone, Spire } = useMainContext();
const { toggleDialog } = useOverlayContext();
const { spawns, loadCallback } = useZoneContext();
const { openAlert } = useAlertContext();
const [hidden, setHidden] = useState(false);
+ const runNewSpawnNpcSearch = useCallback(async (value) => {
+ const searchRun = ++npcSearchRunRef.current;
+ const query = `${value ?? ''}`.trim();
+ if (!query || !Spire) {
+ setNewSpawnNpcOptions([]);
+ setNewSpawnNpcOpen(false);
+ return;
+ }
+ try {
+ const npcs = (await Spire.Npcs.listNpcsByName(query)) ?? [];
+ if (searchRun === npcSearchRunRef.current) {
+ setNewSpawnNpcOptions(npcs);
+ setNewSpawnNpcOpen(npcs.length > 0);
+ }
+ } catch (_error) {
+ if (searchRun === npcSearchRunRef.current) {
+ setNewSpawnNpcOptions([]);
+ setNewSpawnNpcOpen(false);
+ }
+ }
+ }, [Spire]);
+ const searchNewSpawnNpcs = useDebouncedCallback(
+ runNewSpawnNpcSearch,
+ 350
+ );
const filteredSpawns = useMemo(
() => {
const normalizedFilter = spawnFilter?.trim()?.toLowerCase();
@@ -48,40 +80,105 @@ export const NpcDialog = ({ onClose }) => {
);
const addSpawn = useCallback(async () => {
+ if (!newSpawnNpc || creatingSpawn || !Spire) {
+ return;
+ }
setHidden(true);
gameController.ZoneController.pickRaycastForLoc(async (loc) => {
setHidden(false);
if (!loc) {
return;
}
- const { Spire } = gameController;
-
+ setCreatingSpawn(true);
const spawn2Api = new Spire.SpireApiTypes.Spawn2Api(
...Spire.SpireApi.cfg()
);
const spawnGroupApi = new Spire.SpireApiTypes.SpawngroupApi(
...Spire.SpireApi.cfg()
);
- // First create spawn group
- const spawnGroup = await spawnGroupApi.createSpawngroup({
- spawngroup: { name: v4() },
- });
- const createResult = await spawn2Api.createSpawn2({
- spawn2: {
- zone : selectedZone.short_name,
- x : loc.z,
- y : loc.x,
- z : loc.y,
- spawngroup_id: spawnGroup.data.id,
- min_expansion: -1,
- max_expansion: -1,
- },
- });
- openAlert(`Created new spawn at location [${loc.z} ${loc.x} ${loc.y}]`);
- loadCallback({ type: 'create', spawn: createResult.data });
- toggleDialog('npc', false);
+ const spawnEntryApi = new Spire.SpireApiTypes.SpawnentryApi(
+ ...Spire.SpireApi.cfg()
+ );
+ let createdSpawnGroupId = null;
+ let createdSpawnId = null;
+ try {
+ const spawnGroup = await spawnGroupApi.createSpawngroup({
+ spawngroup: { name: v4() },
+ });
+ createdSpawnGroupId = spawnGroup.data.id;
+ const createResult = await spawn2Api.createSpawn2({
+ spawn2: {
+ _condition : 0,
+ cond_value : 1,
+ heading : 0,
+ max_expansion: -1,
+ min_expansion: -1,
+ pathgrid : 0,
+ respawntime : 1200,
+ spawngroup_id: createdSpawnGroupId,
+ variance : 0,
+ version : Number(selectedZone.version ?? 0),
+ x : loc.z,
+ y : loc.x,
+ z : loc.y,
+ zone : selectedZone.short_name,
+ },
+ });
+ createdSpawnId = createResult.data.id;
+ const spawnEntry = {
+ chance : 100,
+ condition_value_filter: 1,
+ content_flags : null,
+ content_flags_disabled: null,
+ max_expansion : -1,
+ max_time : 0,
+ min_expansion : -1,
+ min_time : 0,
+ npc_id : newSpawnNpc.id,
+ npc_type : newSpawnNpc,
+ spawngroup_id : createdSpawnGroupId,
+ };
+ await spawnEntryApi.createSpawnentry({
+ id : createdSpawnGroupId,
+ spawnentry: spawnEntry,
+ });
+
+ await loadCallback({
+ type : 'create',
+ spawn: {
+ ...createResult.data,
+ spawnentries: [spawnEntry],
+ },
+ });
+ openAlert(`Created new spawn at location [${loc.z} ${loc.x} ${loc.y}]`);
+ setNewSpawnNpc(null);
+ setNewSpawnNpcInput('');
+ setNewSpawnNpcOptions([]);
+ toggleDialog('npc', false);
+ } catch (error) {
+ if (createdSpawnId) {
+ await spawn2Api.deleteSpawn2({ id: createdSpawnId }).catch(() => {});
+ }
+ if (createdSpawnGroupId) {
+ await spawnGroupApi
+ .deleteSpawngroup({ id: createdSpawnGroupId })
+ .catch(() => {});
+ }
+ console.warn('Error creating spawn', error);
+ openAlert('Failed to create spawn. No partial spawn was kept.', 'warning');
+ } finally {
+ setCreatingSpawn(false);
+ }
});
- }, [selectedZone, loadCallback, openAlert, toggleDialog]);
+ }, [
+ Spire,
+ creatingSpawn,
+ loadCallback,
+ newSpawnNpc,
+ openAlert,
+ selectedZone,
+ toggleDialog,
+ ]);
useEffect(() => {
const meshes =
@@ -96,6 +193,10 @@ export const NpcDialog = ({ onClose }) => {
}
}
}, [filteredSpawns]);
+ useEffect(() => () => {
+ npcSearchRunRef.current += 1;
+ searchNewSpawnNpcs.cancel();
+ }, [searchNewSpawnNpcs]);
return (
{
onClose={onClose}
title={'Spawns'}
>
-
+
{
}}
/>
+ event.preventDefault(),
+ }}
+ open={newSpawnNpcOpen}
+ value={newSpawnNpc}
+ inputValue={newSpawnNpcInput}
+ options={newSpawnNpcOptions}
+ getOptionLabel={(option) =>
+ `${option?.name ?? 'Unknown NPC'} - Level ${option?.level ?? '?'}`
+ }
+ isOptionEqualToValue={(option, value) => option?.id === value?.id}
+ onChange={(_event, value) => {
+ setNewSpawnNpc(value);
+ setNewSpawnNpcInput(
+ value
+ ? `${value?.name ?? 'Unknown NPC'} - Level ${value?.level ?? '?'}`
+ : ''
+ );
+ setNewSpawnNpcOpen(false);
+ }}
+ onInputChange={(_event, value, reason) => {
+ if (reason === 'input') {
+ setNewSpawnNpcInput(value);
+ setNewSpawnNpcOpen(Boolean(value.trim()));
+ searchNewSpawnNpcs(value);
+ }
+ }}
+ renderInput={(params) => (
+ {
+ if (newSpawnNpcOptions.length > 0) {
+ setNewSpawnNpcOpen(true);
+ }
+ }}
+ onBlur={() => {
+ setTimeout(() => setNewSpawnNpcOpen(false), 150);
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === 'Escape') {
+ setNewSpawnNpcOpen(false);
+ }
+ }}
+ />
+ )}
+ size="small"
+ sx={{ m: 1, width: 320 }}
+ />
}
sx={{ height: '40px', marginBottom: '20px' }}
+ disabled={!newSpawnNpc || creatingSpawn}
onClick={addSpawn}
>
- Add Spawn
+ {creatingSpawn ? 'Creating Spawn...' : 'Add Spawn'}
@@ -198,16 +358,17 @@ function Row(props) {
{
- gameController.CameraController.camera.position = new Vector3(
- spawn.y,
- spawn.z + 20,
- spawn.x
- );
- gameController.CameraController.camera.rotation = new Vector3(
- 1.57,
- 1.548,
- 0
+ const camera = gameController.CameraController.camera;
+ const target = new Vector3(spawn.y, spawn.z + 3, spawn.x);
+ const heading =
+ Tools.ToRadians(Number(spawn.heading ?? 0)) - Math.PI / 2;
+ const cameraDistance = 12;
+ camera.position = new Vector3(
+ target.x + Math.sin(heading) * cameraDistance,
+ target.y + 3,
+ target.z + Math.cos(heading) * cameraDistance
);
+ camera.setTarget(target);
}}
>
Teleport
diff --git a/frontend/eqsage-embed/src/components/spire/door-loader.js b/frontend/eqsage-embed/src/components/spire/door-loader.js
index 289560af..e8324952 100644
--- a/frontend/eqsage-embed/src/components/spire/door-loader.js
+++ b/frontend/eqsage-embed/src/components/spire/door-loader.js
@@ -1,92 +1,30 @@
-import BABYLON from '@bjs';
import { getEQDir, getEQFile, getFiles } from 'sage-core/util/fileHandler';
+import {
+ getRenderableDoors,
+ isInvisibleDoor,
+ normalizeZoneVersion,
+ toDoorPlacement,
+} from './door-placement';
-const { Tools } = BABYLON;
-const EQ_HEADING_UNITS = 512;
-const DEGREES_PER_EQ_HEADING_UNIT = 360 / EQ_HEADING_UNITS;
-const DOOR_VISUAL_HEADING_OFFSET_DEGREES = 180;
-const INVISIBLE_DOOR_OPEN_TYPES = new Set([50, 53, 54]);
+export {
+ degreesToEqHeading,
+ eqHeadingToDegrees,
+ getRenderableDoors,
+ isInvisibleDoor,
+ normalizeZoneVersion,
+ stripDoorEditorFields,
+ toDoorPayload,
+ toDoorPlacement,
+} from './door-placement';
-let doorModelCatalogPromise = null;
-
-export const normalizeZoneVersion = (zone) => {
- const version = Number(zone?.version ?? 0);
- return Number.isFinite(version) ? version : 0;
-};
-
-export const eqHeadingToDegrees = (heading = 0) => {
- const value = Number(heading);
- if (!Number.isFinite(value)) {
- return DOOR_VISUAL_HEADING_OFFSET_DEGREES;
- }
-
- return value * DEGREES_PER_EQ_HEADING_UNIT + DOOR_VISUAL_HEADING_OFFSET_DEGREES;
-};
-
-export const degreesToEqHeading = (degrees = 0) => {
- const value = Number(degrees);
- if (!Number.isFinite(value)) {
- return 0;
- }
-
- const dbDegrees = value - DOOR_VISUAL_HEADING_OFFSET_DEGREES;
- const normalizedDegrees = ((dbDegrees % 360) + 360) % 360;
- return normalizedDegrees / DEGREES_PER_EQ_HEADING_UNIT;
+const DOOR_MODEL_ALIASES = {
+ chair2d : 'chair2',
+ gchair2 : 'chair2',
+ pokgthport500: 'obj_port_gukta',
};
-export const toDoorPlacement = (door) => ({
- ...door,
- rotateX: 0,
- rotateY: eqHeadingToDegrees(door.heading ?? 0),
- rotateZ: 0,
- scale : (door.size ?? 100) / 100,
- x : door.pos_y ?? 0,
- y : door.pos_z ?? 0,
- z : door.pos_x ?? 0,
-});
-
-export const isInvisibleDoor = (door) =>
- INVISIBLE_DOOR_OPEN_TYPES.has(Number(door?.opentype));
-
-export const getRenderableDoors = (doors = []) =>
- doors.filter((door) => !isInvisibleDoor(door));
-
-export const toDoorPayload = (doorEntry, zone, mesh = null) => {
- const position = mesh?.position;
- const rotation = mesh?.rotation;
- const scaling = mesh?.scaling;
-
- return {
- ...doorEntry,
- heading: degreesToEqHeading(
- doorEntry.rotateY ?? Tools.ToDegrees(rotation?.y ?? 0)
- ),
- name : doorEntry.name,
- pos_x : Math.round(position?.z ?? doorEntry.z ?? doorEntry.pos_x ?? 0),
- pos_y : Math.round(position?.x ?? doorEntry.x ?? doorEntry.pos_y ?? 0),
- pos_z : Math.round(position?.y ?? doorEntry.y ?? doorEntry.pos_z ?? 0),
- size : Math.max(
- 1,
- Math.round(((doorEntry.scale ?? scaling?.y) ?? 1) * 100)
- ),
- version: normalizeZoneVersion(zone ?? doorEntry),
- zone : zone?.short_name ?? doorEntry.zone,
- };
-};
-
-export const stripDoorEditorFields = (doorEntry) => {
- const payload = { ...doorEntry };
- delete payload.rotateX;
- delete payload.rotateY;
- delete payload.rotateZ;
- delete payload.scale;
- delete payload.x;
- delete payload.y;
- delete payload.z;
- delete payload.dataContainerReference;
- delete payload.dataReference;
- return payload;
-};
+let doorModelCatalogPromise = null;
+const doorLoadStateByController = new WeakMap();
export const loadDoorModelCatalog = async () => {
if (!doorModelCatalogPromise) {
@@ -126,6 +64,11 @@ export const loadDoorModelCatalog = async () => {
return doorModelCatalogPromise;
};
+export const refreshDoorModelCatalog = async () => {
+ doorModelCatalogPromise = null;
+ return loadDoorModelCatalog();
+};
+
export const fetchDoorsForZone = async (Spire, selectedZone) => {
if (!Spire || !selectedZone?.short_name) {
return [];
@@ -142,77 +85,274 @@ export const fetchDoorsForZone = async (Spire, selectedZone) => {
return Array.isArray(doors) ? doors : [];
};
+const angularDistance = (left, right) =>
+ Math.abs(Math.atan2(Math.sin(left - right), Math.cos(left - right)));
+
+export const collectDoorLifecycleStats = ({
+ controller,
+ result,
+ zoneKey,
+} = {}) => {
+ const doors = Array.isArray(result?.doors) ? result.doors : [];
+ const renderableDoors = Array.isArray(result?.renderableDoors)
+ ? result.renderableDoors
+ : getRenderableDoors(doors);
+ const doorMeshes = (controller?.doorNode?.getChildren?.() ?? [])
+ .filter((node) => node?.dataReference);
+ const expectedById = new Map(
+ renderableDoors.map((door) => [Number(door.id), toDoorPlacement(door)])
+ );
+ const meshIds = doorMeshes.map((mesh) => Number(mesh.dataReference?.id));
+ const meshIdSet = new Set(meshIds);
+ let nonFinitePlacementCount = 0;
+ let positionMismatchCount = 0;
+ let transformMismatchCount = 0;
+
+ for (const mesh of doorMeshes) {
+ const expected = expectedById.get(Number(mesh.dataReference?.id));
+ const actual = [
+ mesh.position?.x,
+ mesh.position?.y,
+ mesh.position?.z,
+ mesh.rotation?.y,
+ mesh.scaling?.x,
+ mesh.scaling?.y,
+ mesh.scaling?.z,
+ ].map(Number);
+ if (!actual.every(Number.isFinite)) {
+ nonFinitePlacementCount++;
+ continue;
+ }
+ if (!expected) {
+ continue;
+ }
+ if (
+ Math.abs(actual[0] - Number(expected.x)) > 0.001 ||
+ Math.abs(actual[1] - Number(expected.y)) > 0.001 ||
+ Math.abs(actual[2] - Number(expected.z)) > 0.001
+ ) {
+ positionMismatchCount++;
+ }
+ const expectedRotationY = Number(expected.rotateY) * (Math.PI / 180);
+ if (
+ angularDistance(actual[3], expectedRotationY) > 0.001 ||
+ actual.slice(4).some((scale) =>
+ Math.abs(scale - Number(expected.scale)) > 0.001
+ )
+ ) {
+ transformMismatchCount++;
+ }
+ }
+
+ const missingVisualCount = renderableDoors.filter(
+ (door) => !meshIdSet.has(Number(door.id))
+ ).length;
+ const staleVisualCount = meshIds.filter((id) => !expectedById.has(id)).length;
+ const duplicateVisualIdCount = meshIds.length - meshIdSet.size;
+ const missingModels = Array.isArray(result?.missingModels)
+ ? result.missingModels
+ : [];
+ const stats = {
+ duplicateVisualIdCount,
+ hidden : doors.length - renderableDoors.length,
+ loaded : Number(result?.loadedMeshes ?? doorMeshes.length),
+ missingModelCount : missingModels.length,
+ missingModels,
+ missingVisualCount,
+ nonFinitePlacementCount,
+ pass:
+ Number(result?.loadedMeshes ?? doorMeshes.length) === renderableDoors.length &&
+ doorMeshes.length === renderableDoors.length &&
+ missingModels.length === 0 &&
+ missingVisualCount === 0 &&
+ staleVisualCount === 0 &&
+ duplicateVisualIdCount === 0 &&
+ nonFinitePlacementCount === 0 &&
+ positionMismatchCount === 0 &&
+ transformMismatchCount === 0,
+ positionMismatchCount,
+ requested : doors.length,
+ sceneDoorCount : doorMeshes.length,
+ staleVisualCount,
+ transformMismatchCount,
+ visibleRequested : renderableDoors.length,
+ zoneKey,
+ };
+
+ if (typeof window !== 'undefined') {
+ window.__spireSageDoorStats = stats;
+ window.dispatchEvent(
+ new CustomEvent('spire-sage-door-stats', { detail: stats })
+ );
+ }
+ return stats;
+};
+
export const loadDoorsForZone = async ({
Spire,
selectedZone,
gameController = window.gameController,
availableModelSet = null,
clearExisting = true,
+ forceReload = false,
} = {}) => {
- const doors = await fetchDoorsForZone(Spire, selectedZone);
- const renderableDoors = getRenderableDoors(doors);
- const invisibleDoors = doors.filter(isInvisibleDoor);
const controller =
gameController?.ZoneController ?? window.gameController?.ZoneController;
const { doorNode, instantiateObjects } = controller ?? {};
+ const zoneKey = `${selectedZone?.short_name ?? ''}:${normalizeZoneVersion(selectedZone)}`;
+ const existingState = controller
+ ? doorLoadStateByController.get(controller)
+ : null;
- if (!doorNode || typeof instantiateObjects !== 'function') {
- return {
- doors,
- invisibleDoors,
- loadedMeshes : 0,
- missingModels : [],
- renderableDoors,
- };
+ if (
+ controller &&
+ !forceReload &&
+ existingState?.zoneKey === zoneKey &&
+ existingState?.doorNode === doorNode &&
+ existingState?.promise
+ ) {
+ return existingState.promise;
}
- if (clearExisting) {
- doorNode.getChildMeshes().forEach((mesh) => mesh.dispose());
- }
+ const generation = (existingState?.generation ?? 0) + 1;
+ const isCurrentLoad = () =>
+ !controller || doorLoadStateByController.get(controller)?.generation === generation;
+ const cancelledResult = (doors = []) => ({
+ cancelled : true,
+ doors,
+ invisibleDoors : [],
+ loadedMeshes : 0,
+ missingModels : [],
+ renderableDoors: [],
+ });
- const modelSet =
- availableModelSet ?? (await loadDoorModelCatalog()).availableModelSet;
- const doorMap = renderableDoors.reduce((acc, door) => {
- if (!door?.name) {
- return acc;
+ const loadPromise = (async () => {
+ const doors = await fetchDoorsForZone(Spire, selectedZone);
+ if (!isCurrentLoad()) {
+ return cancelledResult(doors);
}
- const modelName = door.name.toLowerCase();
- if (!acc[modelName]) {
- acc[modelName] = [];
+ const renderableDoors = getRenderableDoors(doors);
+ const invisibleDoors = doors.filter(isInvisibleDoor);
+
+ if (!doorNode || typeof instantiateObjects !== 'function') {
+ return {
+ doors,
+ invisibleDoors,
+ loadedMeshes : 0,
+ missingModels : [],
+ renderableDoors,
+ };
}
- acc[modelName].push(toDoorPlacement(door));
- return acc;
- }, {});
- const missingModels = [];
- let loadedMeshes = 0;
+ if (clearExisting) {
+ doorNode.getChildren().forEach((node) => node.dispose());
+ }
- for (const [modelName, placements] of Object.entries(doorMap)) {
- if (!modelSet.has(modelName.toLowerCase())) {
- missingModels.push(modelName);
- continue;
+ let modelSet =
+ availableModelSet ?? (await loadDoorModelCatalog()).availableModelSet;
+ if (!isCurrentLoad()) {
+ return cancelledResult(doors);
}
+ let catalogRefreshed = false;
+ const doorMap = renderableDoors.reduce((acc, door) => {
+ if (!door?.name) {
+ return acc;
+ }
+ const modelName = door.name.toLowerCase();
+ if (!acc[modelName]) {
+ acc[modelName] = [];
+ }
+ acc[modelName].push(toDoorPlacement(door));
+ return acc;
+ }, {});
- const meshes = await controller.instantiateObjects(modelName, placements);
- meshes.forEach((mesh, idx) => {
- if (!mesh) {
- return;
+ const missingModels = [];
+ const ownedMeshes = [];
+ let loadedMeshes = 0;
+
+ for (const [modelName, placements] of Object.entries(doorMap)) {
+ if (!isCurrentLoad()) {
+ ownedMeshes.forEach((mesh) => mesh.dispose?.(false, true));
+ return cancelledResult(doors);
}
- mesh.parent = doorNode;
- mesh.dataReference = placements[idx] ?? placements[0];
- mesh.metadata = {
- ...(mesh.metadata ?? {}),
- doorObject: true,
- };
- loadedMeshes += 1;
+ const resolvedModelName = DOOR_MODEL_ALIASES[modelName] ?? modelName;
+ if (
+ !modelSet.has(resolvedModelName.toLowerCase()) &&
+ availableModelSet === null &&
+ !catalogRefreshed
+ ) {
+ modelSet = (await refreshDoorModelCatalog()).availableModelSet;
+ catalogRefreshed = true;
+ }
+ if (!isCurrentLoad()) {
+ ownedMeshes.forEach((mesh) => mesh.dispose?.(false, true));
+ return cancelledResult(doors);
+ }
+ if (!modelSet.has(resolvedModelName.toLowerCase())) {
+ missingModels.push(modelName);
+ continue;
+ }
+
+ const meshes = await controller.instantiateObjects(resolvedModelName, placements, {
+ isCancelled: () => !isCurrentLoad(),
+ });
+ if (!isCurrentLoad()) {
+ meshes.forEach((mesh) => mesh?.dispose?.(false, true));
+ ownedMeshes.forEach((mesh) => mesh.dispose?.(false, true));
+ return cancelledResult(doors);
+ }
+ meshes.forEach((mesh, idx) => {
+ if (!mesh) {
+ return;
+ }
+ mesh.parent = doorNode;
+ mesh.dataReference = placements[idx] ?? placements[0];
+ mesh.metadata = {
+ ...(mesh.metadata ?? {}),
+ doorModelName: resolvedModelName,
+ doorObject : true,
+ };
+ ownedMeshes.push(mesh);
+ loadedMeshes += 1;
+ });
+ }
+
+ return {
+ cancelled: false,
+ doors,
+ invisibleDoors,
+ loadedMeshes,
+ missingModels,
+ renderableDoors,
+ };
+ })();
+
+ if (controller) {
+ doorLoadStateByController.set(controller, {
+ doorNode,
+ generation,
+ promise: loadPromise,
+ zoneKey,
});
}
- return {
- doors,
- invisibleDoors,
- loadedMeshes,
- missingModels,
- renderableDoors,
- };
+ try {
+ const result = await loadPromise;
+ if (!result.cancelled) {
+ result.lifecycle = collectDoorLifecycleStats({
+ controller,
+ result,
+ zoneKey,
+ });
+ }
+ return result;
+ } catch (error) {
+ if (
+ controller &&
+ doorLoadStateByController.get(controller)?.generation === generation
+ ) {
+ doorLoadStateByController.delete(controller);
+ }
+ throw error;
+ }
};
diff --git a/frontend/eqsage-embed/src/components/spire/door-placement.js b/frontend/eqsage-embed/src/components/spire/door-placement.js
new file mode 100644
index 00000000..1e083274
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/spire/door-placement.js
@@ -0,0 +1,86 @@
+const EQ_HEADING_UNITS = 512;
+const DEGREES_PER_EQ_HEADING_UNIT = 360 / EQ_HEADING_UNITS;
+const DOOR_VISUAL_HEADING_OFFSET_DEGREES = 180;
+const INVISIBLE_DOOR_OPEN_TYPES = new Set([50, 53, 54]);
+
+const radiansToDegrees = (radians = 0) => Number(radians) * (180 / Math.PI);
+
+export const normalizeZoneVersion = (zone) => {
+ const version = Number(zone?.version ?? 0);
+ return Number.isFinite(version) ? version : 0;
+};
+
+export const eqHeadingToDegrees = (heading = 0) => {
+ const value = Number(heading);
+ if (!Number.isFinite(value)) {
+ return DOOR_VISUAL_HEADING_OFFSET_DEGREES;
+ }
+
+ return value * DEGREES_PER_EQ_HEADING_UNIT +
+ DOOR_VISUAL_HEADING_OFFSET_DEGREES;
+};
+
+export const degreesToEqHeading = (degrees = 0) => {
+ const value = Number(degrees);
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ const dbDegrees = value - DOOR_VISUAL_HEADING_OFFSET_DEGREES;
+ const normalizedDegrees = ((dbDegrees % 360) + 360) % 360;
+ return normalizedDegrees / DEGREES_PER_EQ_HEADING_UNIT;
+};
+
+export const toDoorPlacement = (door) => ({
+ ...door,
+ rotateX: 0,
+ rotateY: eqHeadingToDegrees(door.heading ?? 0),
+ rotateZ: 0,
+ scale : (door.size ?? 100) / 100,
+ x : door.pos_y ?? 0,
+ y : door.pos_z ?? 0,
+ z : door.pos_x ?? 0,
+});
+
+export const isInvisibleDoor = (door) =>
+ INVISIBLE_DOOR_OPEN_TYPES.has(Number(door?.opentype));
+
+export const getRenderableDoors = (doors = []) =>
+ doors.filter((door) => !isInvisibleDoor(door));
+
+export const toDoorPayload = (doorEntry, zone, mesh = null) => {
+ const position = mesh?.position;
+ const rotation = mesh?.rotation;
+ const scaling = mesh?.scaling;
+
+ return {
+ ...doorEntry,
+ heading: degreesToEqHeading(
+ doorEntry.rotateY ?? radiansToDegrees(rotation?.y ?? 0)
+ ),
+ name : doorEntry.name,
+ pos_x : Math.round(position?.z ?? doorEntry.z ?? doorEntry.pos_x ?? 0),
+ pos_y : Math.round(position?.x ?? doorEntry.x ?? doorEntry.pos_y ?? 0),
+ pos_z : Math.round(position?.y ?? doorEntry.y ?? doorEntry.pos_z ?? 0),
+ size : Math.max(
+ 1,
+ Math.round(((doorEntry.scale ?? scaling?.y) ?? 1) * 100)
+ ),
+ version: normalizeZoneVersion(zone ?? doorEntry),
+ zone : zone?.short_name ?? doorEntry.zone,
+ };
+};
+
+export const stripDoorEditorFields = (doorEntry) => {
+ const payload = { ...doorEntry };
+ delete payload.rotateX;
+ delete payload.rotateY;
+ delete payload.rotateZ;
+ delete payload.scale;
+ delete payload.x;
+ delete payload.y;
+ delete payload.z;
+ delete payload.dataContainerReference;
+ delete payload.dataReference;
+ return payload;
+};
diff --git a/frontend/eqsage-embed/src/components/spire/drawers/doors.jsx b/frontend/eqsage-embed/src/components/spire/drawers/doors.jsx
index 29385075..bbfe1138 100644
--- a/frontend/eqsage-embed/src/components/spire/drawers/doors.jsx
+++ b/frontend/eqsage-embed/src/components/spire/drawers/doors.jsx
@@ -25,11 +25,15 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import BABYLON from '@bjs';
import { gameController } from '../../../viewer/controllers/GameController';
import { useMainContext } from '@/components/main/context';
+import { useZoneContext } from '@/components/zone/zone-context';
import { useAlertContext } from '@/context/alerts';
import {
+ collectDoorLifecycleStats,
+ getRenderableDoors,
loadDoorModelCatalog,
loadDoorsForZone,
degreesToEqHeading,
+ eqHeadingToDegrees,
stripDoorEditorFields,
toDoorPlacement,
toDoorPayload,
@@ -46,6 +50,7 @@ const getDoorController = () =>
export const DoorsDrawer = ({ selectedObject }) => {
const { openAlert } = useAlertContext();
const { selectedZone, Spire } = useMainContext();
+ const { setDoors } = useZoneContext();
const [selectedModel, setSelectedModel] = useState('');
const [doRandom, setDoRandom] = useState(false);
const [rotateClamp, setRotateClamp] = useState([0, 360]);
@@ -56,8 +61,12 @@ export const DoorsDrawer = ({ selectedObject }) => {
const [availableModels, setAvailableModels] = useState([]);
const [availableModelsLoaded, setAvailableModelsLoaded] = useState(false);
const [selectedMesh, setSelectedMesh] = useState(selectedObject);
+ const [mutationPending, setMutationPending] = useState(false);
const editing = useRef(false);
const doorsRef = useRef([]);
+ const missingModelsRef = useRef([]);
+ const persistQueueRef = useRef(Promise.resolve());
+ const persistVersionRef = useRef(0);
const canTransformDoors = typeof getDoorController()?.editMesh === 'function';
const availableModelSet = useMemo(
() => new Set(availableModels.map((model) => model.toLowerCase())),
@@ -71,44 +80,103 @@ export const DoorsDrawer = ({ selectedObject }) => {
return new Spire.SpireApiTypes.DoorApi(...Spire.SpireApi.cfg());
}, [Spire]);
- const loadDoors = useCallback(async () => {
+ const refreshDoorStats = useCallback((doors = doorsRef.current) => {
+ const controller = getDoorController();
+ const renderableDoors = getRenderableDoors(doors);
+ return collectDoorLifecycleStats({
+ controller,
+ result: {
+ doors,
+ loadedMeshes : controller?.doorNode?.getChildren?.().filter(
+ (node) => node?.dataReference
+ ).length ?? 0,
+ missingModels : missingModelsRef.current,
+ renderableDoors,
+ },
+ zoneKey: `${selectedZone?.short_name ?? ''}:${selectedZone?.version ?? 0}`,
+ });
+ }, [selectedZone?.short_name, selectedZone?.version]);
+
+ const loadDoors = useCallback(async (forceReload = false) => {
if (!Spire || !selectedZone?.short_name) {
doorsRef.current = [];
- return [];
+ missingModelsRef.current = [];
+ setDoors([]);
+ return {
+ doors : [],
+ invisibleDoors : [],
+ loadedMeshes : 0,
+ missingModels : [],
+ renderableDoors: [],
+ };
}
- const { doors } = await loadDoorsForZone({
+ const result = await loadDoorsForZone({
Spire,
selectedZone,
availableModelSet: availableModelsLoaded ? availableModelSet : null,
+ forceReload,
});
+ const doors = result.doors ?? [];
doorsRef.current = doors;
+ missingModelsRef.current = result.missingModels ?? [];
+ setDoors(doors);
- return doors;
+ return result;
}, [
Spire,
selectedZone,
availableModelSet,
availableModelsLoaded,
+ setDoors,
]);
const persistDoor = useCallback(
- async (mesh = selectedMesh) => {
+ (mesh = selectedMesh) => {
if (!mesh?.dataReference?.id || !selectedZone) {
- return;
+ return Promise.resolve(null);
}
const payload = stripDoorEditorFields(
toDoorPayload(mesh.dataReference, selectedZone, mesh)
);
- const doorsApi = createDoorApi();
- await doorsApi.updateDoor({
- id : payload.id,
- door: payload,
- });
- mesh.dataReference = toDoorPlacement(payload);
+ const persistVersion = ++persistVersionRef.current;
+ const queued = persistQueueRef.current
+ .catch(() => undefined)
+ .then(async () => {
+ const doorsApi = createDoorApi();
+ const response = await doorsApi.updateDoor({
+ id : payload.id,
+ door: payload,
+ });
+ const updatedDoor = Array.isArray(response?.data)
+ ? response.data[0]
+ : response?.data ?? payload;
+ if (
+ persistVersion === persistVersionRef.current &&
+ !mesh.isDisposed?.()
+ ) {
+ mesh.dataReference = toDoorPlacement(updatedDoor);
+ }
+ doorsRef.current = doorsRef.current.map((door) =>
+ Number(door.id) === Number(updatedDoor.id) ? updatedDoor : door
+ );
+ setDoors(doorsRef.current);
+ if (persistVersion === persistVersionRef.current) {
+ refreshDoorStats(doorsRef.current);
+ }
+ return updatedDoor;
+ });
+ persistQueueRef.current = queued;
+ return queued;
},
- [createDoorApi, selectedMesh, selectedZone]
+ [
+ createDoorApi,
+ refreshDoorStats,
+ selectedMesh,
+ selectedZone,
+ setDoors,
+ ]
);
const editMesh = useCallback(() => {
@@ -139,42 +207,64 @@ export const DoorsDrawer = ({ selectedObject }) => {
selectedMesh.dataReference.scale = selectedMesh.scaling.y;
+ setMutationPending(true);
persistDoor(selectedMesh)
.then(() => openAlert(`Updated ${selectedMesh.name}`))
.catch((e) => {
console.warn('Error updating door', e);
openAlert(`Error updating ${selectedMesh.name}`, 'warning');
- });
+ })
+ .finally(() => setMutationPending(false));
});
}, [selectedMesh, persistDoor, openAlert]);
- const deleteMesh = useCallback(() => {
- if (!selectedMesh) {
+ const deleteMesh = useCallback(async () => {
+ const mesh = selectedMesh;
+ if (!mesh || mutationPending) {
return;
}
const deleteLocal = () => {
- selectedMesh.dispose();
+ const id = Number(mesh.dataReference?.id);
+ mesh.dispose();
+ if (Number.isFinite(id)) {
+ doorsRef.current = doorsRef.current.filter(
+ (door) => Number(door.id) !== id
+ );
+ setDoors(doorsRef.current);
+ }
setSelectedMesh(null);
+ refreshDoorStats(doorsRef.current);
};
- if (!selectedMesh.dataReference?.id || !Spire) {
+ if (!mesh.dataReference?.id || !Spire) {
deleteLocal();
return;
}
- createDoorApi()
- .deleteDoor({ id: selectedMesh.dataReference.id })
- .then(async () => {
- await loadDoors();
- deleteLocal();
- openAlert(`Deleted ${selectedMesh.name}`);
- })
- .catch((e) => {
- console.warn('Error deleting door', e);
- openAlert(`Error deleting ${selectedMesh.name}`, 'warning');
- });
- }, [Spire, createDoorApi, loadDoors, openAlert, selectedMesh]);
+ setMutationPending(true);
+ try {
+ await persistQueueRef.current.catch(() => undefined);
+ await createDoorApi().deleteDoor({ id: mesh.dataReference.id });
+ await loadDoors(true);
+ setSelectedMesh(null);
+ openAlert(`Deleted ${mesh.name}`);
+ } catch (e) {
+ console.warn('Error deleting door', e);
+ openAlert(`Error deleting ${mesh.name}`, 'warning');
+ } finally {
+ setMutationPending(false);
+ }
+ }, [
+ Spire,
+ createDoorApi,
+ loadDoors,
+ mutationPending,
+ openAlert,
+ refreshDoorStats,
+ selectedMesh,
+ setDoors,
+ ]);
useEffect(() => {
const clickCallback = (mesh) => {
@@ -191,6 +281,14 @@ export const DoorsDrawer = ({ selectedObject }) => {
const controller = getDoorController();
controller?.addClickCallback?.(clickCallback);
const keydown = (e) => {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLSelectElement ||
+ e.target?.isContentEditable
+ ) {
+ return;
+ }
if (e.key.toLowerCase() === 'r') {
editMesh();
}
@@ -214,13 +312,13 @@ export const DoorsDrawer = ({ selectedObject }) => {
let current = true;
(async () => {
try {
- const doors = await loadDoors();
+ const result = await loadDoors();
if (!current) {
return;
}
- doorsRef.current = doors;
+ doorsRef.current = result.doors ?? [];
} catch (e) {
- console.log('err', e);
+ console.warn('Error loading doors', e);
openAlert('Error updating zone', 'warning');
}
})();
@@ -238,16 +336,30 @@ export const DoorsDrawer = ({ selectedObject }) => {
}, [selectedMesh]);
useEffect(() => {
- (async () => {
- const { objectMap, availableModels } = await loadDoorModelCatalog();
- setObjectMap(objectMap || {});
- setAvailableModels(availableModels);
- setAvailableModelsLoaded(true);
- })();
- }, []);
+ let current = true;
+ loadDoorModelCatalog()
+ .then(({ objectMap, availableModels }) => {
+ if (!current) {
+ return;
+ }
+ setObjectMap(objectMap || {});
+ setAvailableModels(availableModels);
+ setAvailableModelsLoaded(true);
+ })
+ .catch((error) => {
+ console.warn('Error loading door model catalog', error);
+ if (current) {
+ setAvailableModelsLoaded(true);
+ openAlert('Error loading door model catalog', 'warning');
+ }
+ });
+ return () => {
+ current = false;
+ };
+ }, [openAlert]);
const stamp = useCallback(() => {
- if (!selectedModel) {
+ if (!selectedModel || mutationPending) {
return;
}
const commitDoor = async (loc, mesh = null) => {
@@ -261,37 +373,69 @@ export const DoorsDrawer = ({ selectedObject }) => {
const scale = doRandom
? getRandomNumber(scaleClamp[0], scaleClamp[1])
: mesh?.scaling?.y ?? 1;
- const templateDoor = doorsRef.current[0] || {};
const nextDoorId =
Math.max(0, ...doorsRef.current.map((door) => door.doorid ?? 0)) + 1;
const draftDoor = toDoorPlacement({
- ...templateDoor,
- id : undefined,
- doorid : nextDoorId,
- heading: degreesToEqHeading(rotationY),
- name : selectedModel,
- pos_x : Math.round(z),
- pos_y : Math.round(x),
- pos_z : Math.round(y),
- size : Math.max(1, Math.round(scale * 100)),
- version: selectedZone?.version ?? 0,
- zone : selectedZone?.short_name,
+ buffer : 0,
+ client_version_mask : 0xffffffff,
+ close_timer_ms : 0,
+ content_flags : null,
+ content_flags_disabled: null,
+ dest_heading : 0,
+ dest_instance : 0,
+ dest_x : 0,
+ dest_y : 0,
+ dest_z : 0,
+ dest_zone : null,
+ disable_timer : 0,
+ door_param : 0,
+ doorid : nextDoorId,
+ doorisopen : 0,
+ dz_switch_id : 0,
+ guild : 0,
+ heading : degreesToEqHeading(rotationY),
+ incline : 0,
+ invert_state : 0,
+ is_ldon_door : 0,
+ keyitem : 0,
+ lockpick : 0,
+ max_expansion : -1,
+ min_expansion : -1,
+ name : selectedModel,
+ nokeyring : 0,
+ opentype : 0,
+ pos_x : Math.round(z),
+ pos_y : Math.round(x),
+ pos_z : Math.round(y),
+ size : Math.max(1, Math.round(scale * 100)),
+ triggerdoor : 0,
+ triggertype : 0,
+ version : selectedZone?.version ?? 0,
+ zone : selectedZone?.short_name,
});
const payload = stripDoorEditorFields(
toDoorPayload(draftDoor, selectedZone)
);
+ setMutationPending(true);
try {
const response = await createDoorApi().createDoor({ door: payload });
const createdDoor = Array.isArray(response?.data)
? response.data[0]
: response?.data;
- await loadDoors();
+ await loadDoors(true);
+ const createdMesh = getDoorController()?.doorNode?.getChildren?.().find(
+ (candidate) =>
+ Number(candidate?.dataReference?.id) === Number(createdDoor?.id)
+ );
+ setSelectedMesh(createdMesh ?? null);
openAlert(`Created ${createdDoor?.name ?? selectedModel}`);
} catch (e) {
console.warn('Error creating door', e);
openAlert(`Error creating ${selectedModel}`, 'warning');
+ } finally {
+ setMutationPending(false);
}
};
@@ -299,25 +443,12 @@ export const DoorsDrawer = ({ selectedObject }) => {
if (typeof controller?.pickRaycastForLoc !== 'function') {
return;
}
- if (typeof controller.editMesh === 'function') {
- controller.pickRaycastForLoc({
- /**
- *
- * @param {{x: number, y: number, z: number} | null} loc
- * @param {import('@babylonjs/core/Meshes/mesh').Mesh} mesh
- * @returns
- */
- commitCallback: commitDoor,
- modelName: selectedModel,
- extraHtml: 'Left Mouse: Rotate and [Shift] Scale
',
- });
- return;
- }
controller.pickRaycastForLoc(commitDoor);
}, [
createDoorApi,
doRandom,
loadDoors,
+ mutationPending,
openAlert,
rotateClamp,
scaleClamp,
@@ -365,7 +496,7 @@ export const DoorsDrawer = ({ selectedObject }) => {
fullWidth
variant={'outlined'}
sx={{ margin: '5px auto' }}
- disabled={!selectedMesh || !canTransformDoors}
+ disabled={!selectedMesh || !canTransformDoors || mutationPending}
onClick={editMesh}
>
{
fullWidth
variant={'outlined'}
sx={{ margin: '5px auto' }}
- disabled={!selectedMesh}
+ disabled={!selectedMesh || mutationPending}
onClick={deleteMesh}
>
{
{
forceRender({});
}}
onBlur={saveFieldEdit}
- onKeyDown={(e) => e.key === 'Enter' && saveFieldEdit()}
+ onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
>
{
forceRender({});
}}
onBlur={saveFieldEdit}
- onKeyDown={(e) => e.key === 'Enter' && saveFieldEdit()}
+ onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
>
{
forceRender({});
}}
onBlur={saveFieldEdit}
- onKeyDown={(e) => e.key === 'Enter' && saveFieldEdit()}
+ onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
>
+
+ {
+ const heading = Number(event.target.value);
+ selectedMesh.dataReference.heading = heading;
+ selectedMesh.dataReference.rotateY = eqHeadingToDegrees(heading);
+ selectedMesh.rotation.y = Tools.ToRadians(
+ selectedMesh.dataReference.rotateY
+ );
+ forceRender({});
+ }}
+ onBlur={saveFieldEdit}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.currentTarget.blur();
+ }
+ }}
+ />
+ {
+ const size = Math.max(1, Number(event.target.value));
+ selectedMesh.dataReference.size = size;
+ selectedMesh.dataReference.scale = size / 100;
+ selectedMesh.scaling.setAll(selectedMesh.dataReference.scale);
+ forceRender({});
+ }}
+ onBlur={saveFieldEdit}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.currentTarget.blur();
+ }
+ }}
+ />
+
>
@@ -511,6 +702,11 @@ export const DoorsDrawer = ({ selectedObject }) => {
}
size="small"
sx={{ margin: '5px 0' }}
+ slotProps={{
+ popper: {
+ sx: { zIndex: 200100 },
+ },
+ }}
isOptionEqualToValue={(option, value) => option.key === value?.key}
onChange={async (e, values) => {
setSelectedModel(values?.model ?? '');
@@ -536,7 +732,7 @@ export const DoorsDrawer = ({ selectedObject }) => {
fullWidth
variant={'outlined'}
sx={{ margin: '5px auto' }}
- disabled={!selectedModel}
+ disabled={!selectedModel || mutationPending}
onClick={stamp}
>
- setNpcList((c) => {
- const newC = deepClone(c);
- newC[index].chance = +e.target.value;
- return newC;
- })
+ setNpcList((current) =>
+ current.map((candidate, candidateIndex) =>
+ candidateIndex === index
+ ? { ...candidate, chance: +e.target.value }
+ : candidate
+ )
+ )
}
>
{
setNpcList((c) => {
const newC = [...c];
@@ -196,6 +199,7 @@ export const AddEditSpawnDialog = ({
{
window.open(
`${Spire.SpireApi?.remoteUrl ?? ''}/npc/${entry.npc_id}`,
@@ -211,6 +215,7 @@ export const AddEditSpawnDialog = ({
{
// e.preventDefault();
diff --git a/frontend/eqsage-embed/src/components/spire/nav-bar/spawn-nav/spawn-nav.jsx b/frontend/eqsage-embed/src/components/spire/nav-bar/spawn-nav/spawn-nav.jsx
index 2b02d545..81c48224 100644
--- a/frontend/eqsage-embed/src/components/spire/nav-bar/spawn-nav/spawn-nav.jsx
+++ b/frontend/eqsage-embed/src/components/spire/nav-bar/spawn-nav/spawn-nav.jsx
@@ -35,12 +35,17 @@ function SpawnNavBar() {
const [, forceRender] = useState({});
const { openAlert } = useAlertContext();
const { selectedZone, Spire, setRightDrawerOpen } = useMainContext();
+ const { loadCallback } = useZoneContext();
const [open, setOpen] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(0);
const [gridLoading, setGridLoading] = useState(false);
const [selectedGridIdx, setSelectedGridIdx] = useState(0);
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
const selectionRunRef = useRef(0);
+ const spawnSaveTimerRef = useRef(null);
+ const spawnSaveQueueRef = useRef(Promise.resolve());
+ const spawnSaveSequenceRef = useRef(0);
+ const gridSaveQueueRef = useRef(Promise.resolve());
const confirm = useConfirm();
const pickRaycast = useCallback(() => {
@@ -61,9 +66,20 @@ function SpawnNavBar() {
}, [open, setRightDrawerOpen]);
useEffect(() => {
- if (!selectedSpawn) {
+ if (
+ !selectedSpawn ||
+ !initialSpawn ||
+ selectedSpawn.id !== initialSpawn.id ||
+ !Spire
+ ) {
+ spawnSaveSequenceRef.current += 1;
return;
}
+
+ // Keep the in-scene model synchronized even when the user restores the
+ // last persisted coordinates and no API request is needed.
+ gameController.SpawnController.moveSpawn(selectedSpawn);
+
if (
initialSpawn.x === selectedSpawn.x &&
initialSpawn.y === selectedSpawn.y &&
@@ -71,28 +87,64 @@ function SpawnNavBar() {
) {
return;
}
- gameController.SpawnController.moveSpawn(selectedSpawn);
- (async () => {
- const spawn2Api = new Spire.SpireApiTypes.Spawn2Api(
- ...Spire?.SpireApi.cfg()
- );
- try {
- await spawn2Api.updateSpawn2({
- id : selectedSpawn.id,
- spawn2: selectedSpawn,
+ const updatedSpawn = { ...selectedSpawn };
+ const saveSequence = ++spawnSaveSequenceRef.current;
+ window.clearTimeout(spawnSaveTimerRef.current);
+ spawnSaveTimerRef.current = window.setTimeout(() => {
+ spawnSaveTimerRef.current = null;
+ const queuedSave = spawnSaveQueueRef.current
+ .catch(() => {})
+ .then(async () => {
+ if (saveSequence !== spawnSaveSequenceRef.current) {
+ return;
+ }
+ const spawn2Api = new Spire.SpireApiTypes.Spawn2Api(
+ ...Spire.SpireApi.cfg()
+ );
+ await spawn2Api.updateSpawn2({
+ id : updatedSpawn.id,
+ spawn2: updatedSpawn,
+ });
+ if (saveSequence !== spawnSaveSequenceRef.current) {
+ return;
+ }
+ setInitialSpawn((current) =>
+ current?.id === updatedSpawn.id
+ ? {
+ ...current,
+ x: updatedSpawn.x,
+ y: updatedSpawn.y,
+ z: updatedSpawn.z,
+ }
+ : current
+ );
+ await loadCallback({ type: 'moveSpawn', spawn: updatedSpawn });
+ openAlert(`Updated ${updatedSpawn.name}`);
});
- openAlert(`Updated ${selectedSpawn.name}`);
- } catch (e) {
- openAlert(`Failed to update ${selectedSpawn.name}`, 'warning');
- }
- })();
+ spawnSaveQueueRef.current = queuedSave.catch(() => {
+ if (saveSequence === spawnSaveSequenceRef.current) {
+ openAlert(`Failed to update ${updatedSpawn.name}`, 'warning');
+ }
+ });
+ }, 350);
+
+ return () => {
+ window.clearTimeout(spawnSaveTimerRef.current);
+ spawnSaveTimerRef.current = null;
+ };
}, [ // eslint-disable-line
+ selectedSpawn?.id,
selectedSpawn?.x,
selectedSpawn?.y,
selectedSpawn?.z,
Spire,
- initialSpawn,
+ initialSpawn?.id,
+ initialSpawn?.x,
+ initialSpawn?.y,
+ initialSpawn?.z,
+ loadCallback,
+ openAlert,
]);
useEffect(() => {
@@ -109,6 +161,8 @@ function SpawnNavBar() {
const selectionRun = selectionRunRef.current + 1;
selectionRunRef.current = selectionRun;
+ spawnSaveSequenceRef.current += 1;
+ window.clearTimeout(spawnSaveTimerRef.current);
gameController.SpawnController.npcLight(spawn);
const s = JSON.parse(JSON.stringify(spawn));
if (!s.pathgrid) {
@@ -192,7 +246,6 @@ function SpawnNavBar() {
() => selectedSpawn?.spawnentries ?? [],
[selectedSpawn?.spawnentries]
);
- const { loadCallback } = useZoneContext();
const doDelete = () => {
confirm({
description: 'Are you sure you want to delete this spawn?',
@@ -221,12 +274,12 @@ function SpawnNavBar() {
);
const createGridEntry = useCallback(async () => {
let updateSpawn = false;
- if (!selectedSpawn.grid) {
+ if (!selectedSpawn.pathgrid) {
const gridApi = new Spire.SpireApiTypes.GridApi(
...Spire?.SpireApi.cfg()
);
const freeIdRes = await Spire?.SpireApi.v1().get(
- '/api/v1/query/free-id-ranges/grid/id'
+ 'query/free-id-ranges/grid/id'
);
const freeId = +freeIdRes.data.data[0].start_id;
const newEntry = await gridApi.createGrid({
@@ -278,29 +331,36 @@ function SpawnNavBar() {
setGridUpdater((g) => g + 1);
forceRender({});
if (updateSpawn) {
- loadCallback({ type: 'updateSpawn', spawn: selectedSpawn });
+ await loadCallback({ type: 'updateSpawn', spawn: selectedSpawn });
setSelectedSpawn(selectedSpawn);
}
}, [Spire?.SpireApi, selectedZone, selectedSpawn, loadCallback]); // eslint-disable-line
const updateGridEntry = useCallback(
- async (gridEntry, number = gridEntry.number) => {
- const gridApi = new Spire.SpireApiTypes.GridEntryApi(
- ...Spire?.SpireApi.cfg()
- );
+ (gridEntry, number = gridEntry.number) => {
const builder = new Spire.SpireQueryBuilder();
builder.where('number', '=', number);
builder.where('zoneid', '=', gridEntry.zoneid);
builder.where('gridid', '=', gridEntry.gridid);
forceRender({});
const clone = JSON.parse(JSON.stringify(gridEntry));
- await gridApi.updateGridEntry(
- {
- id : gridEntry.gridid,
- gridEntry: clone,
- },
- { query: builder.get() }
- );
+ const query = builder.get();
+ const queuedSave = gridSaveQueueRef.current
+ .catch(() => {})
+ .then(async () => {
+ const gridApi = new Spire.SpireApiTypes.GridEntryApi(
+ ...Spire?.SpireApi.cfg()
+ );
+ await gridApi.updateGridEntry(
+ {
+ id : clone.gridid,
+ gridEntry: clone,
+ },
+ { query }
+ );
+ });
+ gridSaveQueueRef.current = queuedSave;
+ return queuedSave;
},
[Spire?.SpireApi, Spire?.SpireQueryBuilder]
);
@@ -390,6 +450,7 @@ function SpawnNavBar() {
padding : '15px',
zIndex : 1000,
top : 0,
+ pointerEvents: 'auto',
}}
>
@@ -507,6 +569,7 @@ function SpawnNavBar() {
size="small"
type="number"
inputProps={{
+ 'aria-label': 'Spawn X',
style: { textAlign: 'center' },
}}
sx={{ margin: 0, padding: 0 }}
@@ -519,6 +582,7 @@ function SpawnNavBar() {
size="small"
type="number"
inputProps={{
+ 'aria-label': 'Spawn Y',
style: { textAlign: 'center' },
}}
sx={{ margin: 0, padding: 0 }}
@@ -531,6 +595,7 @@ function SpawnNavBar() {
size="small"
type="number"
inputProps={{
+ 'aria-label': 'Spawn Z',
style: { textAlign: 'center' },
}}
sx={{ margin: 0, padding: 0 }}
@@ -604,6 +669,7 @@ function SpawnNavBar() {
))}
@@ -629,7 +700,15 @@ function SpawnNavBar() {
value={selectedGridEntry.heading}
onChange={(e) => {
selectedGridEntry.heading = +e.target.value;
- updateGridEntry(selectedGridEntry);
+ setGridUpdater((value) => value + 1);
+ }}
+ onBlur={() => updateGridEntry(selectedGridEntry)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ e.target.blur();
+ }
}}
>
{
selectedGridEntry.pause = +e.target.value;
- updateGridEntry(selectedGridEntry);
+ setGridUpdater((value) => value + 1);
+ }}
+ onBlur={() => updateGridEntry(selectedGridEntry)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ e.target.blur();
+ }
}}
>
diff --git a/frontend/eqsage-embed/src/components/spire/overlay.jsx b/frontend/eqsage-embed/src/components/spire/overlay.jsx
index dd5410e6..63366c8d 100644
--- a/frontend/eqsage-embed/src/components/spire/overlay.jsx
+++ b/frontend/eqsage-embed/src/components/spire/overlay.jsx
@@ -50,7 +50,10 @@ export const SpireOverlay = ({ inZone }) => {
useEffect(() => {
const keyHandler = (e) => {
- if (e.key === 'Escape') {
+ if (
+ e.key === 'Escape' &&
+ !window.gameController?.ZoneController?.pickingRaycast
+ ) {
closeDialogs();
}
};
diff --git a/frontend/eqsage-embed/src/components/validation/race-archive-audit.jsx b/frontend/eqsage-embed/src/components/validation/race-archive-audit.jsx
new file mode 100644
index 00000000..31bb8af4
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/validation/race-archive-audit.jsx
@@ -0,0 +1,149 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Box, Typography } from '@mui/material';
+import { writeEQFile } from 'sage-core/util/fileHandler';
+import { useSettingsContext } from '../../context/settings';
+import { useMainContext } from '../main/context';
+
+const getConfig = () => {
+ if (typeof window === 'undefined') {
+ return { enabled: false, zones: [] };
+ }
+ const params = new URLSearchParams(window.location.search);
+ const zones = (params.get('sageRaceArchiveZones') ?? '')
+ .split(',')
+ .map((zone) => zone.trim().toLowerCase())
+ .filter(Boolean);
+ const requestedReportName =
+ params.get('sageRaceArchiveReport') ?? 'spire-race-archive-audit';
+ const reportName = requestedReportName.replace(/[^a-z0-9_-]/gi, '-');
+ return {
+ enabled: zones.length > 0,
+ reportName,
+ zones: Array.from(new Set(zones)),
+ };
+};
+
+export const RaceArchiveAudit = () => {
+ const { gameController, rootFileSystemHandle } = useMainContext();
+ const settings = useSettingsContext();
+ const config = useMemo(getConfig, []);
+ const startedRef = useRef(false);
+ const [completed, setCompleted] = useState(0);
+ const [failures, setFailures] = useState(0);
+ const [status, setStatus] = useState('Waiting for the bootstrap zone');
+
+ useEffect(() => {
+ if (
+ !config.enabled ||
+ startedRef.current ||
+ !gameController ||
+ !rootFileSystemHandle
+ ) {
+ return;
+ }
+
+ const run = async () => {
+ if (startedRef.current || !gameController.currentScene?.activeCamera) {
+ return;
+ }
+ startedRef.current = true;
+ const { processZone } = await import('../zone/processZone');
+ const reports = [];
+
+ const persist = async (complete = false) => {
+ const payload = {
+ complete,
+ zoneCount: config.zones.length,
+ processedCount: reports.length,
+ failureCount: reports.filter((report) => !report.pass).length,
+ reports,
+ timestamp: new Date().toISOString(),
+ };
+ window.__spireSageRaceArchiveAudit = payload;
+ await writeEQFile(
+ 'data',
+ `${config.reportName}.json`,
+ JSON.stringify(payload, null, 2)
+ );
+ setFailures(payload.failureCount);
+ };
+
+ for (const zone of config.zones) {
+ const startedAt = performance.now();
+ setStatus(`Processing ${zone}`);
+ try {
+ await processZone(
+ zone,
+ settings,
+ rootFileSystemHandle,
+ true,
+ gameController,
+ (stage, detail) => setStatus(detail ? `${stage}: ${detail}` : stage)
+ );
+ reports.push({
+ zone,
+ pass: true,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ });
+ } catch (error) {
+ reports.push({
+ zone,
+ pass: false,
+ error: error?.message ?? String(error),
+ stack: error?.stack ?? null,
+ elapsedMs: Math.round(performance.now() - startedAt),
+ });
+ }
+ setCompleted(reports.length);
+ await persist(false);
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ }
+
+ await persist(true);
+ setStatus(`Archive pass complete (${reports.length} zones)`);
+ window.dispatchEvent(
+ new CustomEvent('spire-sage-race-archive-audit-complete', {
+ detail: window.__spireSageRaceArchiveAudit,
+ })
+ );
+ };
+
+ const onZoneReady = () => void run();
+ window.addEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ if (gameController.currentScene?.activeCamera) {
+ void run();
+ }
+ return () => {
+ window.removeEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ };
+ }, [config.enabled, config.reportName, config.zones, gameController, rootFileSystemHandle, settings]);
+
+ if (!config.enabled) {
+ return null;
+ }
+
+ return (
+
+
+ Race Archive Coverage
+
+ {status}
+
+ {completed}/{config.zones.length} zones · {failures} archive failures
+
+
+ );
+};
diff --git a/frontend/eqsage-embed/src/components/validation/race-face-audit.jsx b/frontend/eqsage-embed/src/components/validation/race-face-audit.jsx
new file mode 100644
index 00000000..ca30d69c
--- /dev/null
+++ b/frontend/eqsage-embed/src/components/validation/race-face-audit.jsx
@@ -0,0 +1,1349 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Box, Typography } from '@mui/material';
+import { Color3 } from '@babylonjs/core/Maths/math.color';
+import { Vector3 } from '@babylonjs/core/Maths/math.vector';
+import { PointLight } from '@babylonjs/core/Lights/pointLight';
+import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
+import { writeEQFile } from 'sage-core/util/fileHandler';
+import { getCharacterHeadOrientationPolicy } from 'sage-core/util/character-texture-orientation';
+import raceData from '../../viewer/common/raceData.json';
+import raceModelMetadata from '../../viewer/common/raceModelMetadata.json';
+import raceAppearancePolicies from '../../viewer/common/raceAppearancePolicies.json';
+import {
+ PREVIEW_ALIAS_FIRST_MODELS,
+ PREVIEW_CLIENT_FALLBACKS,
+ PREVIEW_MODEL_ALIASES,
+} from '../../viewer/common/raceModelResolution';
+import {
+ evaluateCharacterAnimationReadiness,
+ inspectAnimationSetVitality,
+ STATIC_POSE_ONLY_CHARACTER_MODELS,
+} from '../../viewer/helpers/animationValidation';
+import {
+ evaluateAppearanceVariant,
+ evaluateFaceVariantDeterminism,
+ inspectHeadTextureOrientation,
+ isKnownEffectOnlyCharacterModel,
+} from '../../viewer/helpers/appearanceValidation';
+import { useMainContext } from '../main/context';
+
+const GENDERS = [
+ ['0', 'male'],
+ ['1', 'female'],
+ ['2', 'neutral'],
+];
+const HEAD_MATERIAL_PATTERN = /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
+
+const isNativePoseOnlyContainer = (container) => [
+ ...(container?.rootNodes ?? []),
+ ...(container?.rootNodes ?? []).flatMap(
+ (root) => root.getDescendants?.(false) ?? []
+ ),
+].some((node) =>
+ node?.metadata?.gltf?.extras?.spireNativePoseOnly === true ||
+ node?.metadata?.extras?.spireNativePoseOnly === true ||
+ node?.metadata?.spireNativePoseOnly === true
+);
+const BODY_SKIN_PLACEHOLDER_PATTERN = /^[a-z0-9]{3}[a-z]{2}sk\d{2}$/i;
+const CLASSIC_FACE_MODELS = new Set(raceAppearancePolicies.classicFaceModels);
+const getSkeletonBoneNames = (container) =>
+ Array.from(new Set(
+ (container?.skeletons ?? []).flatMap((skeleton) =>
+ (skeleton.bones ?? [])
+ .map((bone) => `${bone.name ?? ''}`.replace(/^Clone of /, '').trim().toLowerCase())
+ .filter(Boolean)
+ )
+ )).sort();
+
+const getConfig = () => {
+ if (typeof window === 'undefined') {
+ return { enabled: false, requestedModels: [] };
+ }
+ const params = new URLSearchParams(window.location.search);
+ const previewFace = Number(params.get('sageRaceFacePreviewFace') ?? 7);
+ const previewTexture = Number(params.get('sageRaceFacePreviewTexture') ?? 0);
+ const previewHelmTexture = Number(
+ params.get('sageRaceFacePreviewHelmTexture') ?? 0
+ );
+ const previewHeight = Number(params.get('sageRaceFacePreviewHeight') ?? 4.5);
+ const previewDistance = Number(params.get('sageRaceFacePreviewDistance') ?? 4.5);
+ const previewHeading = Number(params.get('sageRaceFacePreviewHeading') ?? 0);
+ return {
+ enabled: params.has('sageRaceAudit'),
+ forceRequestedModelRefresh:
+ params.get('sageRaceAuditForceRefresh') !== '0',
+ persistToEq: params.get('sageRaceAuditPersist') !== '0',
+ waitsForArchiveAudit: params.has('sageRaceArchiveZones'),
+ previewClose: params.has('sageRaceFacePreviewClose'),
+ previewModel: `${params.get('sageRaceFacePreview') ?? ''}`
+ .trim()
+ .toLowerCase(),
+ previewFace: Number.isFinite(previewFace)
+ ? Math.max(0, Math.trunc(previewFace))
+ : 7,
+ previewTexture: Number.isFinite(previewTexture)
+ ? Math.max(0, Math.trunc(previewTexture))
+ : 0,
+ previewHelmTexture: Number.isFinite(previewHelmTexture)
+ ? Math.max(0, Math.trunc(previewHelmTexture))
+ : 0,
+ previewHeight: Number.isFinite(previewHeight) ? previewHeight : 4.5,
+ previewDistance: Number.isFinite(previewDistance)
+ ? Math.max(1, previewDistance)
+ : 4.5,
+ previewHeading: Number.isFinite(previewHeading)
+ ? Math.trunc(previewHeading)
+ : 0,
+ requestedModels: (params.get('sageRaceAuditModels') ?? '')
+ .split(',')
+ .map((model) => model.trim().toLowerCase())
+ .filter(Boolean),
+ };
+};
+
+const getTexture = (material) =>
+ material?.albedoTexture ??
+ material?._albedoTexture ??
+ material?.diffuseTexture ??
+ material?._diffuseTexture ??
+ material?.emissiveTexture ??
+ material?._emissiveTexture ??
+ null;
+
+const getMaterialSlots = (materials = []) => {
+ const slots = [];
+ const seen = new Set();
+ for (const material of materials) {
+ const candidates = Array.isArray(material?.subMaterials)
+ ? material.subMaterials.filter(Boolean)
+ : [material].filter(Boolean);
+ for (const candidate of candidates) {
+ const key = candidate.uniqueId ?? candidate.name;
+ if (key !== undefined && seen.has(key)) {
+ continue;
+ }
+ if (key !== undefined) {
+ seen.add(key);
+ }
+ slots.push(candidate);
+ }
+ }
+ return slots;
+};
+
+const getRenderedMeshes = (rootNodes = []) => rootNodes.flatMap((root) => [
+ root,
+ ...(root?.getChildMeshes?.(false) ?? []),
+]).filter(
+ (mesh) =>
+ typeof mesh?.getTotalVertices === 'function' &&
+ mesh.getTotalVertices() > 0
+);
+
+const getRenderedMaterialSlots = (meshes = []) => {
+ const materials = [];
+ for (const mesh of meshes) {
+ const material = mesh?.material;
+ if (!material) continue;
+ if (!Array.isArray(material.subMaterials)) {
+ materials.push(material);
+ continue;
+ }
+ const usedIndices = new Set(
+ (mesh.subMeshes ?? [])
+ .map((subMesh) => Number(subMesh?.materialIndex))
+ .filter((index) => Number.isInteger(index) && index >= 0)
+ );
+ const candidates = usedIndices.size > 0
+ ? [...usedIndices].map((index) => material.subMaterials[index])
+ : material.subMaterials;
+ materials.push(...candidates.filter(Boolean));
+ }
+ return getMaterialSlots(materials);
+};
+
+const isEffectOnlyMaterial = (material) =>
+ material?.metadata?.boundary === true ||
+ material?.metadata?.gltf?.extras?.boundary === true ||
+ material?.metadata?.extras?.boundary === true;
+
+const getBounds = (meshes = []) => {
+ const minimum = { x: Infinity, y: Infinity, z: Infinity };
+ const maximum = { x: -Infinity, y: -Infinity, z: -Infinity };
+ for (const mesh of meshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
+ if (!box?.minimumWorld || !box?.maximumWorld) {
+ continue;
+ }
+ for (const axis of ['x', 'y', 'z']) {
+ minimum[axis] = Math.min(minimum[axis], Number(box.minimumWorld[axis]));
+ maximum[axis] = Math.max(maximum[axis], Number(box.maximumWorld[axis]));
+ }
+ } catch (_error) {}
+ }
+ if (![minimum.x, minimum.y, minimum.z, maximum.x, maximum.y, maximum.z].every(Number.isFinite)) {
+ return null;
+ }
+ return {
+ width: maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth: maximum.z - minimum.z,
+ minimum,
+ maximum,
+ };
+};
+
+const isTextureReady = (texture) => {
+ if (!texture) {
+ return false;
+ }
+ if (typeof texture.isReady === 'function') {
+ return texture.isReady();
+ }
+ if (typeof texture.isReady === 'boolean') {
+ return texture.isReady;
+ }
+ if (typeof texture._texture?.isReady === 'boolean') {
+ return texture._texture.isReady;
+ }
+ return true;
+};
+
+const getTextureDimensions = (texture) => {
+ const size = texture?.getSize?.() ?? {};
+ const internalTexture = texture?.getInternalTexture?.() ?? texture?._texture;
+ const width = Number(size.width ?? internalTexture?.width);
+ const height = Number(size.height ?? internalTexture?.height);
+ return {
+ width: Number.isFinite(width) ? width : null,
+ height: Number.isFinite(height) ? height : null,
+ };
+};
+
+const getHeadTextureSignature = (material) => {
+ const texture = getTexture(material);
+ const dimensions = getTextureDimensions(texture);
+ return [
+ `${material?.name ?? ''}`.toLowerCase(),
+ `${texture?.name ?? texture?.url ?? ''}`.toLowerCase(),
+ dimensions.width ?? 'unknown',
+ dimensions.height ?? 'unknown',
+ Number(texture?.uScale ?? 1).toFixed(4),
+ Number(texture?.vScale ?? 1).toFixed(4),
+ Number(texture?.uOffset ?? 0).toFixed(4),
+ Number(texture?.vOffset ?? 0).toFixed(4),
+ ].join(':');
+};
+
+const getTransparentSentinelTextureDiagnostic = (material) => {
+ if (
+ !material ||
+ isEffectOnlyMaterial(material) ||
+ material.metadata?.transparentTextureSentinel !== true ||
+ material.metadata?.transparentTextureSentinelSuppressed === true
+ ) {
+ return null;
+ }
+ const texture = getTexture(material);
+ const { width, height } = getTextureDimensions(texture);
+ return {
+ material: material.name ?? '',
+ texture: texture?.name ?? texture?.url ?? '',
+ width,
+ height,
+ };
+};
+
+const waitForTextures = async (textures) => {
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ window.gameController?.currentScene?.render?.();
+ } catch (_error) {}
+ if (textures.every(isTextureReady)) {
+ return;
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, 50));
+ }
+};
+
+const getHeadOrientationDiagnostic = (material) => {
+ const orientationPolicy = getCharacterHeadOrientationPolicy(material?.name);
+ return inspectHeadTextureOrientation(material, orientationPolicy);
+};
+
+const buildInventory = (requestedModels) => {
+ const variantsByModel = new Map();
+ for (const race of raceData) {
+ for (const [genderKey, gender] of GENDERS) {
+ const model = `${race[genderKey] ?? ''}`.trim().toLowerCase();
+ if (!model) {
+ continue;
+ }
+ if (!variantsByModel.has(model)) {
+ variantsByModel.set(model, []);
+ }
+ variantsByModel.get(model).push({
+ raceId: Number(race.id),
+ raceName: race.name,
+ gender,
+ });
+ }
+ }
+
+ const requested = new Set(requestedModels);
+ return Array.from(variantsByModel.entries())
+ .filter(([model]) => requested.size === 0 || requested.has(model))
+ .map(([model, variants]) => ({
+ model,
+ variants,
+ sourceFiles: raceModelMetadata[model]?.sourceFiles ?? [],
+ appearance: raceModelMetadata[model] ?? {},
+ }))
+ .sort((a, b) => a.model.localeCompare(b.model));
+};
+
+const getRaceFallbackModels = (entry) => {
+ const fallbackModels = [];
+ for (const variant of entry.variants ?? []) {
+ const race = raceData.find(
+ (candidate) => Number(candidate.id) === variant.raceId
+ );
+ const matchingRaces = race
+ ? [
+ race,
+ ...raceData.filter(
+ (candidate) =>
+ Number(candidate.id) !== Number(race.id) &&
+ `${candidate.name ?? ''}`.trim().toLowerCase() ===
+ `${race.name ?? ''}`.trim().toLowerCase()
+ ),
+ ]
+ : [];
+ const genderKey = GENDERS.find(
+ ([, gender]) => gender === variant.gender
+ )?.[0];
+ for (const matchingRace of matchingRaces) {
+ for (const key of [genderKey, '2', '0', '1']) {
+ const model = `${matchingRace?.[key] ?? ''}`.trim().toLowerCase();
+ if (model && model !== entry.model && !fallbackModels.includes(model)) {
+ fallbackModels.push(model);
+ }
+ }
+ }
+ }
+ return fallbackModels;
+};
+
+const getStatus = (result) => {
+ if (!result.modelLoaded) return 'missing-model';
+ if (!result.renderPass) return 'render-failed';
+ if (result.previewInitializationFailed === true) {
+ return 'preview-initialize-failed';
+ }
+ if (
+ result.bodyVariantFallback === true &&
+ result.bodyVariantTextureFallbackApplied !== true
+ ) return 'body-variant-fallback';
+ if (
+ result.requestedModelVariation &&
+ result.loadedModelVariation !== result.requestedModelVariation &&
+ result.bodyVariantTextureFallbackApplied !== true
+ ) return 'body-variant-mismatch';
+ if (result.fallbackTextureCount > 0) return 'texture-fallback';
+ if (result.pendingTextureCount > 0) return 'texture-pending';
+ if (result.suspiciousTinyTextureCount > 0) return 'transparent-texture-fallback';
+ if (result.untexturedRenderedMaterialCount > 0) return 'untextured-material-region';
+ if (result.verticallyFlippedHeadTextureCount > 0) return 'head-texture-flipped';
+ if (result.faceVariantFailureCount > 0) return 'face-variant-failed';
+ if (result.appearanceVariantFailureCount > 0) return 'appearance-variant-failed';
+ if (result.nonFiniteBoneMatrixCount > 0) return 'invalid-bone-matrix';
+ // Boundary/effect-only assets intentionally have neither a visible texture
+ // nor a playable character animation. Classify them before character-only
+ // animation readiness checks so invisible effects cannot be reported as a
+ // broken or T-posed NPC model.
+ if (
+ result.materialSlotCount > 0 &&
+ result.effectOnlyMaterialCount === result.materialSlotCount
+ ) {
+ return isKnownEffectOnlyCharacterModel(result.resolvedModel ?? result.model)
+ ? 'pass-effect-only'
+ : 'unexpected-effect-only-model';
+ }
+ if (result.animationReadiness?.pass === false) {
+ return result.animationReadiness.violations[0] ?? 'animation-not-ready';
+ }
+ if (result.meshCount === 0) return 'missing-geometry';
+ if (result.texturedSlotCount === 0) {
+ return 'untextured-model';
+ }
+ if (
+ result.classicFaceExpected &&
+ result.headTextureCount > 0 &&
+ result.nonHeadTexturedSlotCount === 0
+ ) {
+ return 'head-only-model';
+ }
+ if (result.classicFaceExpected && result.faceVariantDeterminism?.pass !== true) {
+ return 'face-variant-nondeterministic';
+ }
+ if (result.classicFaceExpected && !result.faceVariantChanged) {
+ return 'face-variant-missing';
+ }
+ if (result.classicFaceExpected && result.bodySkinPlaceholderCount === 0) {
+ return 'body-skin-missing';
+ }
+ if (result.resolutionKind === 'client-fallback') {
+ return 'pass-client-fallback';
+ }
+ if (result.resolvedModel && result.resolvedModel !== result.model) {
+ return 'pass-race-fallback';
+ }
+ return result.headTextureCount > 0 ? 'pass-discrete-head' : 'pass-embedded-face';
+};
+
+export const RaceFaceAudit = () => {
+ const { gameController, rootFileSystemHandle, selectedZone } = useMainContext();
+ const config = useMemo(getConfig, []);
+ const inventory = useMemo(
+ () => buildInventory(config.requestedModels),
+ [config.requestedModels]
+ );
+ const startedRef = useRef(false);
+ const previewRef = useRef(null);
+ const [completed, setCompleted] = useState(0);
+ const [status, setStatus] = useState('Waiting for a rendered zone');
+ const [failureCount, setFailureCount] = useState(0);
+
+ useEffect(() => {
+ if (!config.enabled || startedRef.current || !gameController) {
+ return;
+ }
+
+ const run = async () => {
+ if (
+ startedRef.current ||
+ !gameController.currentScene?.activeCamera ||
+ (config.waitsForArchiveAudit && !window.__spireSageRaceArchiveAudit?.complete)
+ ) {
+ return;
+ }
+ startedRef.current = true;
+ const results = [];
+ const auditRootFileSystemHandle =
+ rootFileSystemHandle ??
+ gameController.rootFileSystemHandle ??
+ window.__spireSageRootFileSystemHandle ??
+ null;
+ const [{ processCharacterModelArchive }, { BabylonSpawn }] = await Promise.all([
+ import('../zone/processZone'),
+ import('../../viewer/models/BabylonSpawn'),
+ ]);
+ const auditScene = gameController.currentScene;
+ const auditZoneSpawnsNode = gameController.SpawnController.zoneSpawnsNode;
+ const zoneSpawnsWereEnabled = auditZoneSpawnsNode?.isEnabled?.() !== false;
+ const animationsWereEnabled = auditScene?.animationsEnabled !== false;
+ auditZoneSpawnsNode?.setEnabled(false);
+ if (auditScene) {
+ auditScene.animationsEnabled = false;
+ }
+
+ const persist = async (complete = false) => {
+ const failures = results.filter((result) => !result.status.startsWith('pass-'));
+ const payload = {
+ complete,
+ inventoryModelCount: inventory.length,
+ auditedModelCount: results.length,
+ raceVariantCount: inventory.reduce(
+ (sum, entry) => sum + entry.variants.length,
+ 0
+ ),
+ failureCount: failures.length,
+ failures,
+ results,
+ zone: selectedZone?.short_name ?? null,
+ hasRootFileSystemHandle: !!auditRootFileSystemHandle,
+ timestamp: new Date().toISOString(),
+ };
+ window.__spireSageRaceFaceAudit = payload;
+ if (config.persistToEq) {
+ await writeEQFile(
+ 'data',
+ 'spire-race-face-audit.json',
+ JSON.stringify(payload, null, 2)
+ );
+ }
+ setFailureCount(failures.length);
+ };
+
+ // Reuse the same cache-policy and stale-artifact checks as live spawn
+ // loading. Direct GLB imports can make a structurally stale model look
+ // healthy in QA even though the product would refresh it before use.
+ const loadValidatedCharacterContainer = async (modelName) => {
+ const cachedContainer = await gameController.SpawnController.getFirstAssetContainer(
+ 'models',
+ [`${modelName}.glb`],
+ modelName,
+ { optional: true }
+ );
+ if (!cachedContainer) {
+ return null;
+ }
+ // Structural inspection mutates and ultimately disposes its container.
+ // Never hand it the SpawnController's shared live cache entry.
+ return gameController.SpawnController.loadAssetContainerFromEQ(
+ 'models',
+ `${modelName}.glb`
+ );
+ };
+
+ const inspectAppearance = async (
+ container,
+ entry,
+ {
+ face = 0,
+ texture = 0,
+ helmtexture = 0,
+ modelName = entry.model,
+ appearanceInstance: reusableAppearanceInstance = null,
+ } = {}
+ ) => {
+ let appearanceInstance = reusableAppearanceInstance;
+ const ownsAppearanceInstance = !appearanceInstance;
+ try {
+ if (!appearanceInstance) {
+ appearanceInstance = container.instantiateModelsToScene(
+ (name) => `race-audit-${modelName}-${face}-${texture}-${helmtexture}-${name}`
+ );
+ }
+ const spawnEntry = {
+ id: -(results.length + 1),
+ name: `Race_Audit_${entry.model}`,
+ race: entry.variants[0]?.raceId ?? 0,
+ gender: GENDERS.findIndex(([, gender]) => gender === entry.variants[0]?.gender),
+ face,
+ texture,
+ helmtexture,
+ size: 6,
+ x: 0,
+ y: 0,
+ z: 0,
+ heading: 0,
+ grid: [],
+ };
+ const appearance = new BabylonSpawn(
+ spawnEntry,
+ modelName,
+ gameController.SpawnController.zoneSpawnsNode,
+ gameController.SpawnController.sphereMat
+ );
+ for (const root of appearanceInstance.rootNodes ?? []) {
+ await appearance.applyTextureSwaps(root);
+ }
+ gameController.currentScene.render();
+ const renderedMeshes = getRenderedMeshes(appearanceInstance.rootNodes);
+ const slots = getRenderedMaterialSlots(renderedMeshes);
+ const texturedSlots = slots.filter((material) => getTexture(material));
+ const untexturedRenderedSlots = slots.filter(
+ (material) => !getTexture(material) && !isEffectOnlyMaterial(material)
+ );
+ const textures = texturedSlots.map(getTexture);
+ await waitForTextures(textures);
+ const headSlots = texturedSlots.filter((material) =>
+ HEAD_MATERIAL_PATTERN.test(`${material.name ?? ''}`)
+ );
+ const headOrientationDiagnostics = headSlots.map(
+ getHeadOrientationDiagnostic
+ );
+ const suspiciousTinyTextureDiagnostics = texturedSlots
+ .map(getTransparentSentinelTextureDiagnostic)
+ .filter(Boolean);
+ const suppressedTransparentSentinelSlots = slots.filter(
+ (material) =>
+ material?.metadata?.transparentTextureSentinel === true &&
+ material?.metadata?.transparentTextureSentinelSuppressed === true
+ );
+ const skeletons = new Set(
+ [
+ ...(appearanceInstance.skeletons ?? []),
+ ...renderedMeshes.map((mesh) => mesh?.skeleton),
+ ].filter(Boolean)
+ );
+ let nonFiniteBoneMatrixCount = 0;
+ for (const skeleton of skeletons) {
+ for (const bone of skeleton?.bones ?? []) {
+ const values = bone?.getFinalMatrix?.()?.m ?? bone?._finalMatrix?.m;
+ if (!values) {
+ continue;
+ }
+ nonFiniteBoneMatrixCount += Array.from(values).filter(
+ (value) => !Number.isFinite(value)
+ ).length;
+ }
+ }
+ const bodySkinPlaceholderSlots = texturedSlots.filter((material) => {
+ const materialName = `${material.name ?? ''}`;
+ return (
+ !HEAD_MATERIAL_PATTERN.test(materialName) &&
+ BODY_SKIN_PLACEHOLDER_PATTERN.test(materialName)
+ );
+ });
+ return {
+ renderPass: renderedMeshes.length > 0,
+ meshCount: renderedMeshes.length,
+ bounds: getBounds(renderedMeshes),
+ slots,
+ texturedSlots,
+ textures,
+ headSlots,
+ headOrientationDiagnostics,
+ suspiciousTinyTextureDiagnostics,
+ suppressedTransparentSentinelSlots,
+ nonFiniteBoneMatrixCount,
+ effectOnlyMaterialCount: slots.filter(isEffectOnlyMaterial).length,
+ untexturedRenderedSlots,
+ bodySkinPlaceholderSlots,
+ };
+ } finally {
+ if (ownsAppearanceInstance) {
+ appearanceInstance?.animationGroups?.forEach((group) => group.dispose?.());
+ appearanceInstance?.rootNodes?.forEach((root) => root.dispose?.());
+ appearanceInstance?.skeletons?.forEach((skeleton) => skeleton.dispose?.());
+ }
+ }
+ };
+
+ for (let index = 0; index < inventory.length; index++) {
+ const entry = inventory[index];
+ setStatus(`Auditing ${entry.model.toUpperCase()}`);
+ let container = null;
+ let resolvedModel = entry.model;
+ let resolutionKind = 'exact';
+ let result;
+ const archiveStages = [];
+ try {
+ const preferredAlias = PREVIEW_MODEL_ALIASES[entry.model] ??
+ PREVIEW_CLIENT_FALLBACKS[entry.model];
+ const forceRequestedModelRefresh =
+ config.forceRequestedModelRefresh &&
+ config.requestedModels.length > 0 &&
+ !!auditRootFileSystemHandle;
+ if (forceRequestedModelRefresh) {
+ try {
+ const refreshModel =
+ preferredAlias && PREVIEW_ALIAS_FIRST_MODELS.has(entry.model)
+ ? preferredAlias
+ : entry.model;
+ const refreshSourceFiles =
+ raceModelMetadata[refreshModel]?.sourceFiles ??
+ entry.sourceFiles ??
+ [];
+ const generated = await processCharacterModelArchive(
+ refreshModel,
+ { ...gameController.settings, forceReload: true },
+ auditRootFileSystemHandle,
+ (stage, detail) => {
+ const message = detail ? `${stage}: ${detail}` : stage;
+ archiveStages.push(message);
+ setStatus(`${refreshModel.toUpperCase()}: ${message}`);
+ },
+ refreshSourceFiles,
+ { stopAfterFirstSuccessfulSource: true }
+ );
+ archiveStages.push(
+ generated
+ ? `Explicit audit refreshed ${refreshModel} from its source archive`
+ : `Explicit audit could not refresh ${refreshModel} from its source archive`
+ );
+ } catch (error) {
+ const message = error?.message ?? String(error);
+ archiveStages.push(
+ `Explicit audit refresh failed; validating the existing model: ${message}`
+ );
+ console.warn('[SageRaceFaceAudit] archive-refresh-failed', {
+ model: entry.model,
+ error: message,
+ });
+ }
+ }
+ if (preferredAlias && PREVIEW_ALIAS_FIRST_MODELS.has(entry.model)) {
+ container = await loadValidatedCharacterContainer(preferredAlias);
+ if (container) {
+ resolvedModel = preferredAlias;
+ resolutionKind = PREVIEW_CLIENT_FALLBACKS[entry.model]
+ ? 'client-fallback'
+ : 'model-alias';
+ archiveStages.push(
+ `Using preferred character model alias: ${entry.model} -> ${preferredAlias}`
+ );
+ }
+ }
+ if (!container) {
+ container = await loadValidatedCharacterContainer(entry.model);
+ }
+ if (!container) {
+ const modelAlias = PREVIEW_MODEL_ALIASES[entry.model];
+ if (modelAlias) {
+ container = await loadValidatedCharacterContainer(modelAlias);
+ if (container) {
+ resolvedModel = modelAlias;
+ resolutionKind = 'model-alias';
+ archiveStages.push(
+ `Using character model alias: ${entry.model} -> ${modelAlias}`
+ );
+ }
+ }
+ }
+ if (!container) {
+ const generated = await processCharacterModelArchive(
+ entry.model,
+ gameController.settings,
+ auditRootFileSystemHandle,
+ (stage, detail) => {
+ const message = detail ? `${stage}: ${detail}` : stage;
+ archiveStages.push(message);
+ setStatus(`${entry.model.toUpperCase()}: ${message}`);
+ },
+ entry.sourceFiles,
+ { stopAfterFirstSuccessfulSource: true }
+ );
+ if (generated) {
+ container = await gameController.SpawnController.loadAssetContainerFromEQ(
+ 'models',
+ `${entry.model}.glb`
+ );
+ }
+ }
+ if (!container) {
+ const previewAlias = PREVIEW_CLIENT_FALLBACKS[entry.model];
+ if (previewAlias) {
+ container = await loadValidatedCharacterContainer(previewAlias);
+ if (container) {
+ resolvedModel = previewAlias;
+ resolutionKind = 'client-fallback';
+ archiveStages.push(
+ `Using character model alias: ${entry.model} -> ${previewAlias}`
+ );
+ }
+ }
+ }
+ if (!container) {
+ for (const fallbackModel of getRaceFallbackModels(entry)) {
+ container = await loadValidatedCharacterContainer(fallbackModel);
+ if (container) {
+ resolvedModel = fallbackModel;
+ resolutionKind = 'same-race';
+ archiveStages.push(
+ `Using same-race model fallback: ${entry.model} -> ${fallbackModel}`
+ );
+ break;
+ }
+ }
+ }
+ if (!container) {
+ result = {
+ ...entry,
+ modelLoaded: false,
+ renderPass: false,
+ meshCount: 0,
+ materialSlotCount: 0,
+ texturedSlotCount: 0,
+ readyTextureCount: 0,
+ pendingTextureCount: 0,
+ fallbackTextureCount: 0,
+ headTextureCount: 0,
+ verticallyFlippedHeadTextureCount: 0,
+ archiveStages,
+ };
+ } else {
+ let renderError = '';
+ let defaultAppearance = null;
+ let faceVariantAppearance = null;
+ const appearanceVariants = [];
+ let reusableAppearanceInstance = null;
+ let previewAnimationDonorEvidence = null;
+ // Validate the same resolved container that BabylonSpawn uses at
+ // runtime, including explicitly configured compatible animation
+ // donors. A skeleton with no playable pose must never pass merely
+ // because its geometry and textures loaded.
+ await gameController.SpawnController.addPreviewAnimationDonor?.(
+ resolvedModel,
+ container
+ );
+ let animationVitality = inspectAnimationSetVitality([]);
+ const nativePoseOnly = isNativePoseOnlyContainer(container);
+ const staticPoseFallbackAvailable =
+ nativePoseOnly ||
+ STATIC_POSE_ONLY_CHARACTER_MODELS.has(resolvedModel) ||
+ (container.skeletons ?? []).some((skeleton) => {
+ const boneNames = new Set(
+ (skeleton.bones ?? []).map((bone) =>
+ `${bone.name}`.replace(/^Clone of /, '').toLowerCase()
+ )
+ );
+ return boneNames.has('biclavl') && boneNames.has('biclavr');
+ });
+ let animationReadiness = evaluateCharacterAnimationReadiness({
+ skeletonCount: container.skeletons?.length ?? 0,
+ animationVitality,
+ staticPoseFallbackAvailable,
+ });
+ try {
+ reusableAppearanceInstance =
+ gameController.SpawnController.instantiateSpawnModel?.(
+ resolvedModel,
+ container
+ ) ?? container.instantiateModelsToScene();
+ animationVitality = inspectAnimationSetVitality(
+ reusableAppearanceInstance.animationGroups ?? []
+ );
+ previewAnimationDonorEvidence =
+ reusableAppearanceInstance.__spirePreviewAnimationDonor ?? null;
+ animationReadiness = evaluateCharacterAnimationReadiness({
+ skeletonCount:
+ reusableAppearanceInstance.skeletons?.length ?? 0,
+ animationVitality,
+ staticPoseFallbackAvailable,
+ });
+ if (
+ previewAnimationDonorEvidence?.expected &&
+ !previewAnimationDonorEvidence?.pass
+ ) {
+ animationReadiness = {
+ ...animationReadiness,
+ pass: false,
+ violations: [
+ ...(animationReadiness.violations ?? []),
+ 'preview-animation-donor-not-attached-to-instance',
+ ],
+ };
+ }
+ defaultAppearance = await inspectAppearance(
+ container,
+ entry,
+ {
+ modelName: resolvedModel,
+ appearanceInstance: reusableAppearanceInstance,
+ }
+ );
+ appearanceVariants.push({
+ kind: 'default',
+ value: 0,
+ face: 0,
+ texture: 0,
+ helmtexture: 0,
+ appearance: defaultAppearance,
+ });
+ const appearanceMetadata =
+ raceModelMetadata[resolvedModel] ?? entry.appearance ?? {};
+ const requestedVariants = [];
+ if (CLASSIC_FACE_MODELS.has(resolvedModel)) {
+ for (let face = 1; face <= 7; face++) {
+ requestedVariants.push({ kind: 'face', value: face, face });
+ }
+ }
+ const minTexture = Number(appearanceMetadata.minTexture ?? 0);
+ const maxTexture = Number(appearanceMetadata.maxTexture ?? 0);
+ for (let texture = minTexture; texture <= maxTexture; texture++) {
+ if (texture !== 0) {
+ requestedVariants.push({ kind: 'texture', value: texture, texture });
+ }
+ }
+ const minHelmTexture = Number(
+ appearanceMetadata.minHelmTexture ?? 0
+ );
+ const maxHelmTexture = Number(
+ appearanceMetadata.maxHelmTexture ?? 0
+ );
+ for (
+ let helmtexture = minHelmTexture;
+ helmtexture <= maxHelmTexture;
+ helmtexture++
+ ) {
+ if (helmtexture !== 0) {
+ requestedVariants.push({
+ kind: 'helmtexture',
+ value: helmtexture,
+ helmtexture,
+ });
+ }
+ }
+ for (const variant of requestedVariants) {
+ const appearance = await inspectAppearance(container, entry, {
+ face: variant.face ?? 0,
+ texture: variant.texture ?? 0,
+ helmtexture: variant.helmtexture ?? 0,
+ modelName: resolvedModel,
+ appearanceInstance: reusableAppearanceInstance,
+ });
+ appearanceVariants.push({
+ ...variant,
+ face: variant.face ?? 0,
+ texture: variant.texture ?? 0,
+ helmtexture: variant.helmtexture ?? 0,
+ appearance,
+ });
+ }
+ faceVariantAppearance = appearanceVariants.find(
+ (candidate) =>
+ candidate.kind === 'face' && candidate.value === 7
+ )?.appearance ?? null;
+ } catch (error) {
+ renderError = error?.message ?? String(error);
+ } finally {
+ reusableAppearanceInstance?.animationGroups?.forEach(
+ (group) => group.dispose?.()
+ );
+ reusableAppearanceInstance?.rootNodes?.forEach(
+ (root) => root.dispose?.()
+ );
+ reusableAppearanceInstance?.skeletons?.forEach(
+ (skeleton) => skeleton.dispose?.()
+ );
+ }
+ const slots = defaultAppearance?.slots ?? [];
+ const texturedSlots = defaultAppearance?.texturedSlots ?? [];
+ const textures = defaultAppearance?.textures ?? [];
+ const headSlots = defaultAppearance?.headSlots ?? [];
+ const faceVariantHeadMaterials = (
+ faceVariantAppearance?.headSlots ?? []
+ ).map((material) => material.name).sort();
+ const faceVariantHeadTextureSignatures = (
+ faceVariantAppearance?.headSlots ?? []
+ ).map(getHeadTextureSignature).sort();
+ const defaultHeadMaterials = headSlots
+ .map((material) => material.name)
+ .sort();
+ const defaultHeadTextureSignatures = headSlots
+ .map(getHeadTextureSignature)
+ .sort();
+ const classicFaceExpected =
+ CLASSIC_FACE_MODELS.has(resolvedModel) &&
+ defaultHeadMaterials.some((name) => /hesk\d{2}$/i.test(name));
+ const appearanceVariantResults = appearanceVariants.map(
+ ({ kind, value, face, texture, helmtexture, appearance }) =>
+ evaluateAppearanceVariant({
+ kind,
+ value,
+ face,
+ texture,
+ helmtexture,
+ renderPass: appearance?.renderPass === true,
+ materialSlotCount: appearance?.slots?.length ?? 0,
+ effectOnlyMaterialCount:
+ appearance?.effectOnlyMaterialCount ?? 0,
+ headMaterials: (appearance?.headSlots ?? [])
+ .map((material) => material.name)
+ .sort(),
+ headTextureSignatures: (appearance?.headSlots ?? [])
+ .map(getHeadTextureSignature)
+ .sort(),
+ texturedSlotCount: appearance?.texturedSlots?.length ?? 0,
+ untexturedRenderedMaterialCount:
+ appearance?.untexturedRenderedSlots?.length ?? 0,
+ untexturedRenderedMaterials: (
+ appearance?.untexturedRenderedSlots ?? []
+ ).map((material) => material.name).filter(Boolean).sort(),
+ materials: (appearance?.slots ?? [])
+ .map((material) => material.name)
+ .filter(Boolean)
+ .sort(),
+ pendingTextureCount: (appearance?.textures ?? [])
+ .filter((texture) => !isTextureReady(texture)).length,
+ suspiciousTinyTextureCount:
+ appearance?.suspiciousTinyTextureDiagnostics?.length ?? 0,
+ suspiciousTinyTextures:
+ appearance?.suspiciousTinyTextureDiagnostics ?? [],
+ suppressedTransparentSentinelCount:
+ appearance?.suppressedTransparentSentinelSlots?.length ?? 0,
+ suppressedTransparentSentinelMaterials: (
+ appearance?.suppressedTransparentSentinelSlots ?? []
+ ).map((material) => material.name).filter(Boolean).sort(),
+ headOrientationRiskCount:
+ appearance?.headOrientationDiagnostics?.filter(
+ (diagnostic) => diagnostic.risk
+ ).length ?? 0,
+ nonFiniteBoneMatrixCount:
+ appearance?.nonFiniteBoneMatrixCount ?? 0,
+ }, {
+ requireHeadTexture:
+ CLASSIC_FACE_MODELS.has(resolvedModel) &&
+ (kind === 'default' || kind === 'face'),
+ })
+ );
+ const faceVariantResults = CLASSIC_FACE_MODELS.has(resolvedModel)
+ ? appearanceVariantResults.filter(
+ (appearance) =>
+ appearance.kind === 'default' || appearance.kind === 'face'
+ )
+ : [];
+ const faceVariantDeterminism = CLASSIC_FACE_MODELS.has(resolvedModel)
+ ? evaluateFaceVariantDeterminism(faceVariantResults)
+ : { pass: true, violations: [] };
+ const appearanceVariantFailures = appearanceVariantResults.filter(
+ (appearance) => appearance.invariantPass !== true
+ );
+ const faceVariantFailures = faceVariantResults.filter(
+ (appearance) => appearance.invariantPass !== true
+ );
+ const missingTextures =
+ window.__spireSageMissingModelTextures?.[
+ `/eq/models/${resolvedModel}.glb`
+ ] ??
+ window.__spireSageMissingModelTextures?.[
+ `/eq/objects/${resolvedModel}.glb`
+ ] ??
+ [];
+ result = {
+ ...entry,
+ resolvedModel,
+ resolutionKind,
+ modelLoaded: true,
+ renderPass: !renderError && defaultAppearance?.renderPass === true,
+ renderError,
+ meshCount: defaultAppearance?.meshCount ?? 0,
+ bounds: defaultAppearance?.bounds ?? null,
+ materialSlotCount: slots.length,
+ texturedSlotCount: texturedSlots.length,
+ effectOnlyMaterialCount:
+ defaultAppearance?.effectOnlyMaterialCount ?? 0,
+ nonHeadTexturedSlotCount: texturedSlots.length - headSlots.length,
+ readyTextureCount: textures.filter(isTextureReady).length,
+ pendingTextureCount: textures.filter((texture) => !isTextureReady(texture)).length,
+ suspiciousTinyTextureCount:
+ defaultAppearance?.suspiciousTinyTextureDiagnostics?.length ?? 0,
+ suspiciousTinyTextures:
+ defaultAppearance?.suspiciousTinyTextureDiagnostics ?? [],
+ untexturedRenderedMaterialCount:
+ defaultAppearance?.untexturedRenderedSlots?.length ?? 0,
+ untexturedRenderedMaterials: (
+ defaultAppearance?.untexturedRenderedSlots ?? []
+ ).map((material) => material.name).filter(Boolean).sort(),
+ suppressedTransparentSentinelCount:
+ defaultAppearance?.suppressedTransparentSentinelSlots?.length ?? 0,
+ suppressedTransparentSentinelMaterials: (
+ defaultAppearance?.suppressedTransparentSentinelSlots ?? []
+ ).map((material) => material.name).filter(Boolean).sort(),
+ fallbackTextureCount: missingTextures.length,
+ fallbackTextures: missingTextures.slice(0, 20),
+ headTextureCount: headSlots.length,
+ headOrientationDiagnostics:
+ defaultAppearance?.headOrientationDiagnostics ?? [],
+ bodySkinPlaceholderCount:
+ defaultAppearance?.bodySkinPlaceholderSlots?.length ?? 0,
+ bodySkinPlaceholderMaterials: (
+ defaultAppearance?.bodySkinPlaceholderSlots ?? []
+ ).map((material) => material.name).slice(0, 40),
+ classicFaceExpected,
+ faceVariantCountAudited: faceVariantResults.length,
+ faceVariantFailureCount: faceVariantFailures.length,
+ faceVariantFailures,
+ faceVariants: faceVariantResults,
+ faceVariantDeterminism,
+ appearanceVariantCountAudited: appearanceVariantResults.length,
+ appearanceVariantFailureCount: appearanceVariantFailures.length,
+ appearanceVariantFailures,
+ appearanceVariants: appearanceVariantResults,
+ faceVariantChanged:
+ !classicFaceExpected ||
+ defaultHeadTextureSignatures.join('|') !==
+ faceVariantHeadTextureSignatures.join('|'),
+ faceVariantMaterials: faceVariantHeadMaterials.slice(0, 40),
+ verticallyFlippedHeadTextureCount:
+ defaultAppearance?.headOrientationDiagnostics?.filter(
+ (diagnostic) => diagnostic.risk
+ ).length ?? 0,
+ nonFiniteBoneMatrixCount:
+ defaultAppearance?.nonFiniteBoneMatrixCount ?? 0,
+ skeletonCount: container.skeletons?.length ?? 0,
+ // A rendered mesh is not enough evidence that a compatible
+ // animation source exists. Persist the normalized rig signature
+ // so missing-animation failures can be matched to donors by
+ // exact target names rather than visual similarity.
+ skeletonBoneNames: getSkeletonBoneNames(container),
+ animationVitality,
+ animationReadiness,
+ previewAnimationDonor: previewAnimationDonorEvidence,
+ nativePoseOnly,
+ staticPoseFallbackAvailable,
+ bindPoseOnlyAnimation:
+ animationVitality.playableGroupCount > 0 &&
+ animationVitality.visuallyPosedGroupCount === 0 &&
+ !staticPoseFallbackAvailable,
+ headMaterials: defaultHeadMaterials.slice(0, 40),
+ archiveStages,
+ };
+ }
+ } catch (error) {
+ result = {
+ ...entry,
+ modelLoaded: false,
+ renderPass: false,
+ meshCount: 0,
+ materialSlotCount: 0,
+ texturedSlotCount: 0,
+ readyTextureCount: 0,
+ pendingTextureCount: 0,
+ fallbackTextureCount: 0,
+ headTextureCount: 0,
+ verticallyFlippedHeadTextureCount: 0,
+ archiveStages,
+ error: error?.message ?? String(error),
+ };
+ } finally {
+ container?.dispose?.();
+ }
+ result.status = getStatus(result);
+ results.push(result);
+ setCompleted(results.length);
+ if (results.length % 10 === 0) {
+ await persist(false);
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ }
+
+ if (auditScene) {
+ auditScene.animationsEnabled = animationsWereEnabled;
+ }
+ if (!config.previewModel) {
+ auditZoneSpawnsNode?.setEnabled(zoneSpawnsWereEnabled);
+ }
+
+ if (config.previewModel) {
+ const previewEntry = inventory.find(
+ (entry) => entry.model === config.previewModel
+ );
+ const previewResult = results.find(
+ (result) => result.model === config.previewModel
+ );
+ const previewVariant = previewEntry?.variants?.[0];
+ const resolvedModel = previewResult?.resolvedModel ?? config.previewModel;
+ // Keep failed-but-loadable models previewable so the audit can be used
+ // to diagnose visual regressions instead of hiding the evidence.
+ if (previewEntry && previewResult?.modelLoaded) {
+ setStatus(`Framing ${previewVariant?.raceName ?? resolvedModel}`);
+ const scene = gameController.currentScene;
+ const camera = scene?.activeCamera;
+ const zoneSpawnsNode = gameController.SpawnController.zoneSpawnsNode;
+ const previewRoot = new TransformNode('race-face-preview-root', scene);
+ const previewGroundY = Number(camera?.position?.y ?? 0) - 4;
+ const preview = new BabylonSpawn(
+ {
+ id: -900000,
+ name: `${previewVariant?.raceName ?? resolvedModel} Face Preview`,
+ race: previewVariant?.raceId ?? 0,
+ gender: Math.max(
+ 0,
+ GENDERS.findIndex(([, gender]) => gender === previewVariant?.gender)
+ ),
+ face: config.previewFace,
+ texture: config.previewTexture,
+ helmtexture: config.previewHelmTexture,
+ size: 6,
+ x: Number(camera?.position?.z ?? 0),
+ y: Number(camera?.position?.x ?? 0),
+ z: previewGroundY,
+ heading: config.previewHeading,
+ grid: [],
+ },
+ resolvedModel,
+ previewRoot,
+ gameController.SpawnController.sphereMat
+ );
+ let previewInitialized = false;
+ let previewInitializationError = '';
+ try {
+ previewInitialized = !!camera && await preview.initializeSpawn();
+ } catch (error) {
+ previewInitializationError = error?.message ?? String(error);
+ }
+ console.log('[SageRaceFacePreview] initialize', JSON.stringify({
+ model: resolvedModel,
+ initialized: previewInitialized,
+ error: previewInitializationError,
+ requestedModelVariation: preview.requestedModelVariation ?? null,
+ loadedModelVariation: preview.loadedModelVariation ?? null,
+ bodyVariantFallback: preview.bodyVariantFallback === true,
+ bodyVariantTextureFallbackApplied:
+ preview.bodyVariantTextureFallbackApplied === true,
+ bodyVariantTextureFallbackAppliedCount:
+ preview.bodyVariantTextureFallbackAppliedCount ?? 0,
+ bodyVariantTextureFallbackAvailableCount:
+ preview.bodyVariantTextureFallbackAvailableCount ?? 0,
+ bodyVariantTextureCoverageRequiredCount:
+ preview.bodyVariantTextureCoverageRequiredCount ?? 0,
+ bodyVariantTextureCoverageAppliedCount:
+ preview.bodyVariantTextureCoverageAppliedCount ?? 0,
+ }));
+ if (previewInitialized) {
+ previewResult.requestedModelVariation = preview.requestedModelVariation;
+ previewResult.loadedModelVariation = preview.loadedModelVariation;
+ previewResult.bodyVariantFallback = preview.bodyVariantFallback;
+ previewResult.bodyVariantTextureFallbackApplied =
+ preview.bodyVariantTextureFallbackApplied;
+ previewResult.bodyVariantTextureFallbackAppliedCount =
+ preview.bodyVariantTextureFallbackAppliedCount;
+ previewResult.bodyVariantTextureFallbackAvailableCount =
+ preview.bodyVariantTextureFallbackAvailableCount;
+ previewResult.bodyVariantTextureCoverageRequiredCount =
+ preview.bodyVariantTextureCoverageRequiredCount;
+ previewResult.bodyVariantTextureCoverageAppliedCount =
+ preview.bodyVariantTextureCoverageAppliedCount;
+ previewResult.previewInitializationFailed = false;
+ previewResult.status = getStatus(previewResult);
+ preview.disposeNameplate();
+ zoneSpawnsNode?.setEnabled(false);
+ previewRef.current = {
+ preview,
+ previewRoot,
+ zoneSpawnsNode,
+ };
+ await new Promise((resolve) => window.setTimeout(resolve, 1200));
+ const previewMeshes = getRenderedMeshes([preview.rootNode]);
+ const previewBounds = getBounds(previewMeshes);
+ if (previewBounds) {
+ const target = new Vector3(
+ (previewBounds.minimum.x + previewBounds.maximum.x) / 2,
+ (previewBounds.minimum.y + previewBounds.maximum.y) / 2,
+ (previewBounds.minimum.z + previewBounds.maximum.z) / 2
+ );
+ const height = Math.max(1, previewBounds.height);
+ let closeBounds = null;
+ if (config.previewClose) {
+ const headMeshes = previewMeshes.filter((mesh) => {
+ const materialNames = getMaterialSlots([mesh.material]).map(
+ (material) => material?.name
+ );
+ return [mesh.name, ...materialNames].some((name) =>
+ HEAD_MATERIAL_PATTERN.test(`${name ?? ''}`)
+ );
+ });
+ closeBounds = getBounds(headMeshes);
+ if (closeBounds) {
+ target.x = (closeBounds.minimum.x + closeBounds.maximum.x) / 2;
+ target.y = (closeBounds.minimum.y + closeBounds.maximum.y) / 2;
+ target.z = (closeBounds.minimum.z + closeBounds.maximum.z) / 2;
+ } else {
+ // Embedded-face models do not expose a discrete head mesh.
+ target.y = previewBounds.minimum.y + height * 0.82;
+ }
+ // Skinned mesh bounds are evaluated in bind space and can sit
+ // below the visibly deformed head. Keep close-up evidence clear
+ // of the fixed top navigation chrome for every race family.
+ target.y += 1.1;
+ }
+ const closeSpan = closeBounds
+ ? Math.max(
+ 1,
+ closeBounds.width,
+ closeBounds.height,
+ closeBounds.depth
+ )
+ : 1;
+ const distance = config.previewClose
+ ? Math.max(8, config.previewDistance, closeSpan * 1.8)
+ : Math.max(7, height * 1.7, previewBounds.depth * 1.8);
+ camera.position.copyFrom(
+ new Vector3(
+ target.x - distance,
+ target.y + (config.previewClose ? 0 : height * 0.08),
+ target.z
+ )
+ );
+ camera.setTarget(target);
+ const previewLight = new PointLight(
+ 'race-face-preview-light',
+ camera.position.clone(),
+ scene
+ );
+ previewLight.diffuse = new Color3(1, 0.96, 0.88);
+ previewLight.intensity = 0.65;
+ previewLight.range = Math.max(20, distance * 4);
+ previewRef.current.previewLight = previewLight;
+ scene.render();
+ }
+ } else {
+ previewResult.previewInitializationFailed = true;
+ previewResult.previewInitializationError = previewInitializationError ||
+ (!camera ? 'No active camera' : 'BabylonSpawn.initializeSpawn returned false');
+ previewResult.status = getStatus(previewResult);
+ preview.dispose();
+ previewRoot.dispose();
+ }
+ }
+ }
+
+ await persist(true);
+ setStatus(
+ config.previewModel
+ ? `Complete (${results.length} models) · Preview ${config.previewModel.toUpperCase()} face ${config.previewFace} texture ${config.previewTexture} helm ${config.previewHelmTexture}`
+ : `Complete (${results.length} models)`
+ );
+ };
+
+ const onZoneReady = () => void run();
+ const onArchiveReady = () => void run();
+ window.addEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ window.addEventListener('spire-sage-race-archive-audit-complete', onArchiveReady);
+ if (gameController.currentScene?.activeCamera) {
+ void run();
+ }
+ return () => {
+ window.removeEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ window.removeEventListener('spire-sage-race-archive-audit-complete', onArchiveReady);
+ previewRef.current?.preview?.dispose?.();
+ previewRef.current?.previewLight?.dispose?.();
+ previewRef.current?.previewRoot?.dispose?.();
+ previewRef.current?.zoneSpawnsNode?.setEnabled?.(true);
+ if (gameController.currentScene) {
+ gameController.currentScene.animationsEnabled = true;
+ }
+ previewRef.current = null;
+ };
+ }, [
+ config.enabled,
+ config.forceRequestedModelRefresh,
+ config.waitsForArchiveAudit,
+ gameController,
+ inventory,
+ config.previewClose,
+ config.previewDistance,
+ config.previewFace,
+ config.previewHeight,
+ config.previewModel,
+ config.persistToEq,
+ rootFileSystemHandle,
+ selectedZone,
+ ]);
+
+ if (!config.enabled) {
+ return null;
+ }
+
+ return (
+
+
+ Race Face Audit
+
+ {status}
+
+ {completed}/{inventory.length} models · {failureCount} unresolved
+
+
+ );
+};
diff --git a/frontend/eqsage-embed/src/components/validation/validation-harness.jsx b/frontend/eqsage-embed/src/components/validation/validation-harness.jsx
index 874500e5..24d0ae52 100644
--- a/frontend/eqsage-embed/src/components/validation/validation-harness.jsx
+++ b/frontend/eqsage-embed/src/components/validation/validation-harness.jsx
@@ -11,19 +11,139 @@ const DEFAULT_ZONE_SEQUENCE = [
'iceclad',
'greatdivide',
];
+const DEFAULT_MODEL_SEQUENCE = ['hum'];
+const DEFAULT_MODEL_VIEWS = ['front', 'side', 'back', 'face'];
+
+const parsePositiveInteger = (value, fallback = 1) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : fallback;
+};
+
+const sanitizeReportName = (value) =>
+ `${value || 'spire-validation-report'}`.replace(/[^a-z0-9_-]/gi, '-');
+
+const parseList = (value, fallback) => {
+ const values = `${value ?? ''}`
+ .split(',')
+ .map((item) => item.trim().toLowerCase())
+ .filter(Boolean);
+ return values.length ? values : fallback;
+};
+
+const normalizeModelView = (value) => {
+ if (value === 'face' || value === 'head') {
+ return { label: 'face', view: 'front', faceFocus: true };
+ }
+ if (['front', 'side', 'back', 'rear'].includes(value)) {
+ return {
+ label: value === 'rear' ? 'back' : value,
+ view: value === 'rear' ? 'back' : value,
+ faceFocus: false,
+ };
+ }
+ return null;
+};
+
+const isValidFraming = (framing) => {
+ const finitePositive = (value) => Number.isFinite(value) && value > 0;
+ const viewport = framing?.viewport;
+ return finitePositive(framing?.distance) &&
+ finitePositive(framing?.stage?.width) &&
+ finitePositive(framing?.stage?.height) &&
+ Number.isFinite(framing?.target?.x) &&
+ Number.isFinite(framing?.target?.y) &&
+ Number.isFinite(framing?.target?.z) &&
+ Number.isFinite(viewport?.x) &&
+ Number.isFinite(viewport?.y) &&
+ finitePositive(viewport?.width) &&
+ finitePositive(viewport?.height) &&
+ viewport.x >= 0 &&
+ viewport.y >= 0 &&
+ viewport.x + viewport.width <= 1.001 &&
+ viewport.y + viewport.height <= 1.001;
+};
const parseValidationConfig = () => {
if (typeof window === 'undefined') {
- return { enabled: false, zones: DEFAULT_ZONE_SEQUENCE };
+ return {
+ enabled: false,
+ mode: 'zones',
+ zones: DEFAULT_ZONE_SEQUENCE,
+ models: DEFAULT_MODEL_SEQUENCE,
+ modelViews: DEFAULT_MODEL_VIEWS.map(normalizeModelView),
+ cycles: 1,
+ sequence: DEFAULT_ZONE_SEQUENCE.map((zone, index) => ({
+ zone,
+ cycle: 1,
+ index,
+ })),
+ expectedReports: DEFAULT_ZONE_SEQUENCE.length,
+ persistToEq: true,
+ reportName: 'spire-validation-report',
+ };
}
const params = new URLSearchParams(window.location.search);
+ const requestedMode = `${params.get('sageValidation') ?? ''}`.toLowerCase();
+ const modelParam = params.get('sageValidateModels') ||
+ params.get('sageValidationModels');
+ const modelMode = !!modelParam ||
+ ['model', 'models', 'model-review'].includes(requestedMode);
const zoneParam = params.get('sageValidateZones') || params.get('sageValidationZones');
const zones = zoneParam
? zoneParam.split(',').map((zone) => zone.trim()).filter(Boolean)
: DEFAULT_ZONE_SEQUENCE;
+ const normalizedZones = zones.length ? zones : DEFAULT_ZONE_SEQUENCE;
+ const cycles = parsePositiveInteger(params.get('sageValidationCycles'), 1);
+ const zoneSequence = Array.from({ length: cycles }, (_, cycleIndex) =>
+ normalizedZones.map((zone, zoneIndex) => ({
+ zone,
+ cycle: cycleIndex + 1,
+ index: cycleIndex * normalizedZones.length + zoneIndex,
+ }))
+ ).flat();
+ const models = parseList(
+ modelParam || params.get('sageModel'),
+ DEFAULT_MODEL_SEQUENCE
+ );
+ const modelViews = parseList(
+ params.get('sageValidationModelViews'),
+ DEFAULT_MODEL_VIEWS
+ ).map(normalizeModelView).filter(Boolean);
+ const normalizedModelViews = modelViews.length
+ ? modelViews
+ : DEFAULT_MODEL_VIEWS.map(normalizeModelView);
+ const modelSequence = Array.from({ length: cycles }, (_, cycleIndex) =>
+ models.flatMap((model, modelIndex) =>
+ normalizedModelViews.map((modelView, viewIndex) => ({
+ ...modelView,
+ model,
+ cycle: cycleIndex + 1,
+ index:
+ cycleIndex * models.length * normalizedModelViews.length +
+ modelIndex * normalizedModelViews.length +
+ viewIndex,
+ }))
+ )
+ ).flat();
+ const sequence = modelMode ? modelSequence : zoneSequence;
return {
- enabled: params.has('sageValidation') || params.has('sageValidateZones'),
- zones: zones.length ? zones : DEFAULT_ZONE_SEQUENCE,
+ enabled: params.has('sageValidation') ||
+ params.has('sageValidateZones') ||
+ params.has('sageValidateModels') ||
+ params.has('sageValidationModels'),
+ mode: modelMode ? 'models' : 'zones',
+ zones: normalizedZones,
+ models,
+ modelViews: normalizedModelViews,
+ cycles,
+ sequence,
+ expectedReports: sequence.length,
+ stepDelay: parsePositiveInteger(params.get('sageValidationStepDelay'), 250),
+ persistToEq: params.get('sageValidationPersist') !== '0',
+ reportName: sanitizeReportName(
+ params.get('sageValidationReport') ||
+ (modelMode ? 'spire-model-validation-report' : 'spire-validation-report')
+ ),
};
};
@@ -38,11 +158,13 @@ export const SageValidationHarness = () => {
const config = useMemo(parseValidationConfig, []);
const [reports, setReports] = useState([]);
const [status, setStatus] = useState('Waiting for zone list');
- const visitedRef = useRef(new Set());
+ const sequenceIndexRef = useRef(0);
const reportsRef = useRef([]);
const selectingRef = useRef(false);
const selectedZoneRef = useRef(null);
const startedRef = useRef(false);
+ const modelSequenceIndexRef = useRef(0);
+ const modelCommandRef = useRef('');
useEffect(() => {
selectedZoneRef.current = selectedZone?.short_name ?? null;
@@ -51,31 +173,41 @@ export const SageValidationHarness = () => {
const persistReports = useCallback(
(nextReports, nextStatus) => {
const failures = nextReports.filter((report) => !report.pass?.all);
+ const finished = nextReports.length >= config.expectedReports;
const payload = {
config,
- complete: nextReports.length >= config.zones.length && failures.length === 0,
+ finished,
+ complete: finished && failures.length === 0,
failureCount: failures.length,
failures: failures.map((report) => ({
zone: report.zone,
+ model: report.model,
+ view: report.view,
+ faceFocus: report.faceFocus,
pass: report.pass,
spawns: report.spawns,
doors : report.doors,
movement: report.movement,
canvas: report.canvas,
visuals: report.visuals,
+ runtimeMemory: report.runtimeMemory,
+ sceneResources: report.sceneResources,
+ loadError: report.loadError,
})),
status: nextStatus,
reports: nextReports,
timestamp: new Date().toISOString(),
};
window.__spireSageValidationSummary = payload;
- void writeEQFile(
- 'data',
- 'spire-validation-report.json',
- JSON.stringify(payload, null, 2)
- ).catch((error) => {
- console.warn('[SageValidation] failed to persist report', error);
- });
+ if (config.persistToEq) {
+ void writeEQFile(
+ 'data',
+ `${config.reportName}.json`,
+ JSON.stringify(payload, null, 2)
+ ).catch((error) => {
+ console.warn('[SageValidation] failed to persist report', error);
+ });
+ }
},
[config]
);
@@ -84,12 +216,17 @@ export const SageValidationHarness = () => {
if (!config.enabled) {
return;
}
- persistReports([], 'Validation enabled; waiting for zone list');
+ persistReports(
+ [],
+ config.mode === 'models'
+ ? 'Model validation enabled; waiting for model viewer'
+ : 'Validation enabled; waiting for zone list'
+ );
}, [config.enabled, persistReports]);
const selectZone = useCallback(
(zoneName) => {
- if (!config.enabled || selectingRef.current) {
+ if (!config.enabled || config.mode !== 'zones' || selectingRef.current) {
return;
}
const nextZone = zones.find((zone) => zone.short_name === zoneName);
@@ -104,21 +241,38 @@ export const SageValidationHarness = () => {
setStatus(nextStatus);
persistReports(reportsRef.current, nextStatus);
setZoneDialogOpen(false);
- setSelectedZone(nextZone);
- void loadGameController()
- .catch((error) => {
- const failureStatus = `Controller load failed: ${error?.message ?? error}`;
- setStatus(failureStatus);
- persistReports(reportsRef.current, failureStatus);
- })
- .finally(() => {
- window.setTimeout(() => {
- selectingRef.current = false;
- }, 250);
- });
+ const commitSelection = () => {
+ selectedZoneRef.current = nextZone.short_name;
+ // Clearing the current zone briefly causes MainProvider to reopen the
+ // chooser; close it again before remounting the validation viewer.
+ setZoneDialogOpen(false);
+ setSelectedZone(nextZone);
+ void loadGameController()
+ .catch((error) => {
+ const failureStatus = `Controller load failed: ${error?.message ?? error}`;
+ setStatus(failureStatus);
+ persistReports(reportsRef.current, failureStatus);
+ })
+ .finally(() => {
+ window.setTimeout(() => {
+ selectingRef.current = false;
+ }, 250);
+ });
+ };
+ if (selectedZoneRef.current === zoneName) {
+ // React ignores setting the same zone object again. Explicitly unmount
+ // the viewer before a same-zone validation cycle so cleanup, scene
+ // disposal, filesystem reads, and texture decoding are all exercised.
+ selectedZoneRef.current = null;
+ setSelectedZone(null);
+ window.setTimeout(commitSelection, 50);
+ } else {
+ commitSelection();
+ }
},
[
config.enabled,
+ config.mode,
loadGameController,
persistReports,
setSelectedZone,
@@ -135,58 +289,244 @@ export const SageValidationHarness = () => {
}, [config]);
useEffect(() => {
- if (!config.enabled || !zones.length || startedRef.current) {
+ if (
+ !config.enabled ||
+ config.mode !== 'zones' ||
+ !zones.length ||
+ startedRef.current
+ ) {
return;
}
startedRef.current = true;
- if (selectedZone?.short_name !== config.zones[0]) {
- selectZone(config.zones[0]);
+ const firstZone = config.sequence[0]?.zone;
+ if (firstZone && selectedZone?.short_name !== firstZone) {
+ selectZone(firstZone);
}
- }, [config.enabled, config.zones, selectedZone, selectZone, zones.length]);
+ }, [config.enabled, config.mode, config.sequence, selectedZone, selectZone, zones.length]);
useEffect(() => {
- if (!config.enabled) {
+ if (!config.enabled || config.mode !== 'zones') {
return;
}
const onZoneReady = (event) => {
const report = event.detail;
- if (!report?.zone || visitedRef.current.has(report.zone)) {
+ const expected = config.sequence[sequenceIndexRef.current];
+ if (!report?.zone || !expected || report.zone !== expected.zone) {
return;
}
- visitedRef.current.add(report.zone);
- const nextReports = [...reportsRef.current, report];
+ const sequencedReport = {
+ ...report,
+ validationSequence: expected,
+ };
+ sequenceIndexRef.current += 1;
+ const nextReports = [...reportsRef.current, sequencedReport];
reportsRef.current = nextReports;
setReports(nextReports);
- const currentIndex = config.zones.indexOf(report.zone);
- const laterZones = currentIndex >= 0
- ? config.zones.slice(currentIndex + 1)
- : config.zones;
- const nextZone = laterZones.find((zone) => !visitedRef.current.has(zone))
- ?? config.zones.find((zone) => !visitedRef.current.has(zone));
- if (!nextZone) {
- const completeStatus = `Validation complete (${visitedRef.current.size} zones)`;
+ const nextEntry = config.sequence[sequenceIndexRef.current];
+ if (!nextEntry) {
+ const completeStatus = config.cycles > 1
+ ? `Validation complete (${nextReports.length} runs; ${config.zones.length} zones x ${config.cycles} cycles)`
+ : `Validation complete (${config.zones.length} zones)`;
setStatus(completeStatus);
persistReports(nextReports, completeStatus);
return;
}
- const nextStatus = `Validated ${report.zone}; loading ${nextZone}`;
+ const nextZone = nextEntry.zone;
+ const nextStatus = `Validated ${report.zone} (cycle ${expected.cycle}); loading ${nextZone}`;
setStatus(nextStatus);
persistReports(nextReports, nextStatus);
window.setTimeout(() => {
- if (selectedZoneRef.current !== nextZone) {
- selectZone(nextZone);
- }
+ selectZone(nextZone);
}, 1200);
};
+ const onZoneError = (event) => {
+ const detail = event.detail;
+ if (!detail?.zone) {
+ return;
+ }
+ const report = {
+ zone: detail.zone,
+ longName: detail.longName ?? detail.zone,
+ loadError: {
+ message: detail.error ?? 'Unknown zone load failure',
+ stack: detail.stack ?? null,
+ },
+ pass: {
+ canvas: false,
+ movement: false,
+ spawns: false,
+ textures: false,
+ animations: false,
+ boundary: false,
+ geometry: false,
+ doors: false,
+ all: false,
+ },
+ timestamp: detail.timestamp ?? new Date().toISOString(),
+ };
+ onZoneReady({ detail: report });
+ };
+
window.addEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ window.addEventListener('spire-sage-zone-validation-error', onZoneError);
return () => {
window.removeEventListener('spire-sage-zone-validation-ready', onZoneReady);
+ window.removeEventListener('spire-sage-zone-validation-error', onZoneError);
};
- }, [config.enabled, config.zones, persistReports, selectZone]);
+ }, [config.cycles, config.enabled, config.mode, config.sequence, config.zones.length, persistReports, selectZone]);
+
+ useEffect(() => {
+ if (!config.enabled || config.mode !== 'models') {
+ return undefined;
+ }
+
+ let cancelled = false;
+ let timer = null;
+ const schedule = (callback, delay = config.stepDelay) => {
+ timer = window.setTimeout(callback, delay);
+ };
+ const recordReport = (report) => {
+ const nextReports = [...reportsRef.current, report];
+ reportsRef.current = nextReports;
+ setReports(nextReports);
+ modelSequenceIndexRef.current += 1;
+ modelCommandRef.current = '';
+
+ const nextEntry = config.sequence[modelSequenceIndexRef.current];
+ if (!nextEntry) {
+ const completeStatus = config.cycles > 1
+ ? `Model validation complete (${nextReports.length} checks; ${config.models.length} models x ${config.cycles} cycles)`
+ : `Model validation complete (${config.models.length} models; ${nextReports.length} checks)`;
+ setStatus(completeStatus);
+ persistReports(nextReports, completeStatus);
+ return false;
+ }
+
+ const nextStatus = `Validated ${report.model} ${report.label}; loading ${nextEntry.model} ${nextEntry.label}`;
+ setStatus(nextStatus);
+ persistReports(nextReports, nextStatus);
+ return true;
+ };
+
+ const runNextStep = () => {
+ if (cancelled) {
+ return;
+ }
+ const expected = config.sequence[modelSequenceIndexRef.current];
+ if (!expected) {
+ return;
+ }
+ const review = window.__spireSageModelReview;
+ if (!review || typeof review.runQaStep !== 'function') {
+ schedule(runNextStep, 100);
+ return;
+ }
+
+ const signature = [
+ expected.index,
+ expected.model,
+ expected.view,
+ expected.faceFocus ? 'face' : 'body',
+ ].join(':');
+ if (modelCommandRef.current !== signature) {
+ modelCommandRef.current = signature;
+ const accepted = review.runQaStep({
+ model: expected.model,
+ view: expected.view,
+ faceFocus: expected.faceFocus,
+ });
+ const nextStatus = `Checking ${expected.model.toUpperCase()} ${expected.label}`;
+ setStatus(nextStatus);
+ if (accepted === false) {
+ const shouldContinue = recordReport({
+ model: expected.model,
+ view: expected.view,
+ label: expected.label,
+ faceFocus: expected.faceFocus,
+ loadError: { message: 'Model is not present in the Sage model inventory' },
+ pass: {
+ appearance: false,
+ animation: false,
+ orientation: false,
+ framing: false,
+ all: false,
+ },
+ validationSequence: expected,
+ timestamp: new Date().toISOString(),
+ });
+ if (shouldContinue) {
+ schedule(runNextStep);
+ }
+ return;
+ }
+ schedule(runNextStep);
+ return;
+ }
+
+ const loadFailed = review.ready === true &&
+ review.model === expected.model &&
+ review.diagnostics?.loadPass === false;
+ const matchesExpectedState = review.ready === true &&
+ review.model === expected.model &&
+ review.view === expected.view &&
+ review.faceFocus === expected.faceFocus &&
+ (loadFailed || (
+ review.framing?.view === expected.view &&
+ review.framing?.faceFocus === expected.faceFocus
+ ));
+ if (!matchesExpectedState) {
+ schedule(runNextStep, 100);
+ return;
+ }
+
+ const diagnostics = review.diagnostics ?? {};
+ const pass = {
+ load: diagnostics.loadPass === true,
+ appearance: diagnostics.appearance?.invariantPass === true,
+ animation:
+ diagnostics.animationPass === true &&
+ review.animationSafety?.pass !== false,
+ orientation: diagnostics.orientationPass === true,
+ framing: diagnostics.loadPass === true && isValidFraming(review.framing),
+ };
+ pass.all = Object.values(pass).every(Boolean);
+ const shouldContinue = recordReport({
+ model: expected.model,
+ view: expected.view,
+ label: expected.label,
+ faceFocus: expected.faceFocus,
+ diagnostics,
+ framing: review.framing,
+ selection: review.selection,
+ pass,
+ validationSequence: expected,
+ timestamp: new Date().toISOString(),
+ });
+ if (shouldContinue) {
+ schedule(runNextStep);
+ }
+ };
+
+ schedule(runNextStep, 50);
+ return () => {
+ cancelled = true;
+ if (timer) {
+ window.clearTimeout(timer);
+ }
+ };
+ }, [
+ config.cycles,
+ config.enabled,
+ config.mode,
+ config.models.length,
+ config.sequence,
+ config.stepDelay,
+ persistReports,
+ ]);
if (!config.enabled) {
return null;
@@ -210,14 +550,26 @@ export const SageValidationHarness = () => {
}}
>
- Sage Validation
+ {config.mode === 'models' ? 'Sage Model Validation' : 'Sage Validation'}
{status}
- {reports.slice(-6).map((report) => (
+ {config.mode === 'models' && reports.slice(-6).map((report) => (
+
+ {report.pass?.all ? 'PASS' : 'FAIL'} {report.model?.toUpperCase()} {report.label}:{' '}
+ orient {report.pass?.orientation ? 'ok' : 'fail'}, appearance{' '}
+ {report.pass?.appearance ? 'ok' : 'fail'}, animation{' '}
+ {report.pass?.animation ? 'ok' : 'fail'}, framing{' '}
+ {report.pass?.framing ? 'ok' : 'fail'}
+
+ ))}
+ {config.mode === 'zones' && reports.slice(-6).map((report) => (
{report.pass?.all ? 'PASS' : 'FAIL'} {report.zone}: spawns {report.spawns?.loaded ?? 0}/
@@ -228,7 +580,10 @@ export const SageValidationHarness = () => {
{report.visuals?.texturedSlotCount ?? 0}, texFallback{' '}
{report.visuals?.fallbackTextureCount ?? 0}, anim{' '}
{report.visuals?.animatedSkeletonSpawnCount ?? 0}/
- {report.visuals?.skeletonSpawnCount ?? 0}, doors{' '}
+ {report.visuals?.skeletonSpawnCount ?? 0}, motion{' '}
+ {report.visuals?.runtimeAnimation?.movingSpawnCount ?? 0}/
+ {report.visuals?.runtimeAnimation?.probedSpawnCount ?? 0}, retarget{' '}
+ {report.visuals?.unresolvedAnimationTargetCount ?? 0} unresolved, doors{' '}
{report.doors?.loaded ?? 0}/{report.doors?.visibleRequested ?? report.doors?.requested ?? 0}
{report.doors?.hidden ? ` (${report.doors.hidden} hidden)` : ''}, doorTex{' '}
{report.doors?.visuals?.readyTextureCount ?? 0}/
diff --git a/frontend/eqsage-embed/src/components/zone/processZone.js b/frontend/eqsage-embed/src/components/zone/processZone.js
index 66ee4900..38cb744f 100644
--- a/frontend/eqsage-embed/src/components/zone/processZone.js
+++ b/frontend/eqsage-embed/src/components/zone/processZone.js
@@ -1,12 +1,48 @@
-import { ZONE_VERSION } from 'sage-core/model/constants';
+import {
+ PREVIEW_CHARACTER_CACHE_VERSION as CHARACTER_CACHE_VERSION,
+ PREVIEW_ZONE_OBJECT_CACHE_VERSION,
+ ZONE_VERSION,
+} from 'sage-core/model/constants';
+import {
+ getCharacterSourceFamilyStem,
+ orderCharacterModelSourceFiles,
+ PREVIEW_MODEL_SOURCE_FILES,
+} from '../../viewer/common/raceModelResolution';
+import raceModelMetadata from '../../viewer/common/raceModelMetadata.json';
export const GLOBAL_VERSION = 2.0;
-export const PREVIEW_CHARACTER_CACHE_VERSION = 12;
+// Global/zone cache compatibility remains at the last successfully published
+// marker. Character exporter policy changes are invalidated per model from GLB
+// metadata in SpawnController; coupling them to this global marker causes an
+// unnecessary full-client rebuild during ordinary zone loads.
+export const PREVIEW_CHARACTER_CACHE_VERSION = CHARACTER_CACHE_VERSION;
const getActiveController = (controller) => controller ?? window.gameController ?? null;
let processingDepsPromise = null;
let fileHandlerDepsPromise = null;
let globalStorePromise = null;
+let characterArchiveQueue = Promise.resolve();
+const characterArchivePromisesByRoot = new WeakMap();
+const characterSourceArchivePromisesByRoot = new WeakMap();
+const rootFileManifestPromises = new WeakMap();
+
+const getRootPromiseMap = (cache, rootFileSystemHandle) => {
+ let promiseMap = cache.get(rootFileSystemHandle);
+ if (!promiseMap) {
+ promiseMap = new Map();
+ cache.set(rootFileSystemHandle, promiseMap);
+ }
+ return promiseMap;
+};
+
+const throwIfZoneLoadAborted = (signal) => {
+ if (!signal?.aborted) {
+ return;
+ }
+ const error = new Error('Zone load was cancelled');
+ error.name = 'AbortError';
+ throw error;
+};
const getProcessingDeps = async () => {
if (!processingDepsPromise) {
@@ -60,9 +96,30 @@ const collectMatchingRootFiles = async (
onProgress = null
) => {
const handles = [];
- let scanned = 0;
+ let manifestPromise = rootFileManifestPromises.get(rootFileSystemHandle);
+ if (!manifestPromise) {
+ const pendingManifest = (async () => {
+ const manifest = [];
+ for await (const fileHandle of rootFileSystemHandle.values()) {
+ manifest.push(fileHandle);
+ if (manifest.length % 250 === 0) {
+ await yieldToBrowser();
+ }
+ }
+ return manifest;
+ })();
+ manifestPromise = pendingManifest.catch((error) => {
+ if (rootFileManifestPromises.get(rootFileSystemHandle) === manifestPromise) {
+ rootFileManifestPromises.delete(rootFileSystemHandle);
+ }
+ throw error;
+ });
+ rootFileManifestPromises.set(rootFileSystemHandle, manifestPromise);
+ }
+ const manifest = await manifestPromise;
- for await (const fileHandle of rootFileSystemHandle.values()) {
+ let scanned = 0;
+ for (const fileHandle of manifest) {
scanned++;
if (scanned % 250 === 0) {
onProgress?.(scanned, handles.length, fileHandle.name);
@@ -139,10 +196,14 @@ export async function processGlobal(settings, rootFileSystemHandle, standalone =
emitStage(reportStage, 'Scanning global dependency archives');
handles.push(...await collectMatchingRootFiles(
rootFileSystemHandle,
- new RegExp('^global.*\\.s3d', 'i'),
+ new RegExp('^global.*\\.(?:s3d|eqg)$', 'i'),
(name) => {
const lowerName = name.toLowerCase();
- return /^global.*_chr\d*\.s3d$/.test(lowerName) || lowerName.includes('global_obj');
+ return (
+ /^global.*_chr\d*\.s3d$/.test(lowerName) ||
+ lowerName.includes('global_obj') ||
+ lowerName === 'globalgdb.eqg'
+ );
},
(scanned, found, latest) => {
if (found <= 5 || scanned % 1000 === 0) {
@@ -212,6 +273,216 @@ export async function processGlobal(settings, rootFileSystemHandle, standalone =
}
}
+export async function processCharacterModelArchive(
+ modelName,
+ settings,
+ rootFileSystemHandle,
+ reportStage = null,
+ sourceFiles = [],
+ { stopAfterFirstSuccessfulSource = false } = {}
+) {
+ const normalizedModelName = `${modelName ?? ''}`.trim().toLowerCase();
+ const requestedSourceFiles = orderCharacterModelSourceFiles(normalizedModelName, [
+ ...sourceFiles,
+ ...(raceModelMetadata[normalizedModelName]?.sourceFiles ?? []),
+ ...(PREVIEW_MODEL_SOURCE_FILES[normalizedModelName] ?? []),
+ ]);
+ emitStage(
+ reportStage,
+ 'Character model archive request',
+ `${normalizedModelName || 'unknown'}; root=${rootFileSystemHandle ? 'ready' : 'missing'}; sources=${requestedSourceFiles.length}`
+ );
+ if (!normalizedModelName || !rootFileSystemHandle) {
+ return false;
+ }
+
+ const characterArchivePromises = getRootPromiseMap(
+ characterArchivePromisesByRoot,
+ rootFileSystemHandle
+ );
+ const characterSourceArchivePromises = getRootPromiseMap(
+ characterSourceArchivePromisesByRoot,
+ rootFileSystemHandle
+ );
+ const existingPromise = settings?.forceReload
+ ? null
+ : characterArchivePromises.get(normalizedModelName);
+ if (existingPromise) {
+ emitStage(reportStage, 'Reusing character archive request', normalizedModelName);
+ return existingPromise;
+ }
+
+ const processSourceArchive = async (requestedSourceFile) => {
+ const sourceFile = `${requestedSourceFile ?? ''}`.trim().toLowerCase();
+ if (!sourceFile || !/\.(?:eqg|s3d)$/i.test(sourceFile)) {
+ return false;
+ }
+ const existingSourcePromise = settings?.forceReload
+ ? null
+ : characterSourceArchivePromises.get(sourceFile);
+ if (existingSourcePromise) {
+ emitStage(reportStage, 'Reusing character source archive', sourceFile);
+ return existingSourcePromise;
+ }
+
+ const pendingSourcePromise = characterArchiveQueue.then(async () => {
+ const archiveName = sourceFile.replace(/\.(?:eqg|s3d)$/i, '');
+ const escapedArchiveName = archiveName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ emitStage(reportStage, 'Searching character source archive', sourceFile);
+ const { EQFileHandle } = await getProcessingDeps();
+ const handles = await collectMatchingRootFiles(
+ rootFileSystemHandle,
+ new RegExp(
+ `^${escapedArchiveName}(?:\\.(?:eqg|s3d)|_assets\\.txt)$`,
+ 'i'
+ )
+ );
+ const hasRequestedSource = handles.some(
+ (handle) => handle.name.toLowerCase() === sourceFile
+ );
+ if (!hasRequestedSource) {
+ emitStage(reportStage, 'Character source archive not found', sourceFile);
+ return false;
+ }
+
+ emitStage(reportStage, 'Loading character source archive', sourceFile);
+ const archive = new EQFileHandle(
+ archiveName,
+ handles,
+ rootFileSystemHandle,
+ {
+ ...settings,
+ forceReload: true,
+ }
+ );
+ await archive.initialize();
+ const processed = await archive.process();
+ emitStage(
+ reportStage,
+ 'Character source archive translation complete',
+ `${sourceFile}: result=${processed === true ? 'exported' : String(processed)}; handles=${handles.map((handle) => handle.name).join(',')}`
+ );
+ return processed === true;
+ });
+
+ const sourcePromise = pendingSourcePromise.then(
+ (processed) => {
+ if (
+ !processed &&
+ characterSourceArchivePromises.get(sourceFile) === sourcePromise
+ ) {
+ characterSourceArchivePromises.delete(sourceFile);
+ }
+ return processed;
+ },
+ (error) => {
+ if (characterSourceArchivePromises.get(sourceFile) === sourcePromise) {
+ characterSourceArchivePromises.delete(sourceFile);
+ }
+ throw error;
+ }
+ );
+
+ characterSourceArchivePromises.set(sourceFile, sourcePromise);
+ characterArchiveQueue = sourcePromise.catch(() => false);
+ return sourcePromise;
+ };
+
+ const pendingProcessingPromise = (async () => {
+ let processedAnySource = false;
+ const expandedSourceFiles = [];
+ for (const sourceFile of requestedSourceFiles) {
+ if (!expandedSourceFiles.includes(sourceFile)) {
+ expandedSourceFiles.push(sourceFile);
+ }
+ const familyStem = getCharacterSourceFamilyStem(sourceFile);
+ if (!familyStem) {
+ continue;
+ }
+ const escapedFamilyStem = familyStem.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ '\\$&'
+ );
+ const familyHandles = await collectMatchingRootFiles(
+ rootFileSystemHandle,
+ new RegExp(`^${escapedFamilyStem}\\d*\\.s3d$`, 'i')
+ );
+ for (const handle of familyHandles.sort((a, b) =>
+ a.name.localeCompare(b.name, undefined, { numeric: true })
+ )) {
+ const familySourceFile = handle.name.toLowerCase();
+ if (!expandedSourceFiles.includes(familySourceFile)) {
+ expandedSourceFiles.push(familySourceFile);
+ }
+ }
+ }
+
+ for (const sourceFile of expandedSourceFiles) {
+ try {
+ const processedSource = await processSourceArchive(sourceFile);
+ emitStage(
+ reportStage,
+ 'Character source archive result',
+ `${sourceFile}: ${processedSource ? 'processed' : 'not processed'}`
+ );
+ processedAnySource = processedSource || processedAnySource;
+ if (processedSource && stopAfterFirstSuccessfulSource) {
+ break;
+ }
+ } catch (error) {
+ emitStage(
+ reportStage,
+ 'Character source archive error',
+ `${sourceFile}: ${error?.message ?? String(error)}`
+ );
+ console.warn(
+ `Unable to process character source ${sourceFile} for ${normalizedModelName}`,
+ error
+ );
+ }
+ }
+ if (processedAnySource) {
+ return true;
+ }
+
+ const escapedName = normalizedModelName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const handles = await collectMatchingRootFiles(
+ rootFileSystemHandle,
+ new RegExp(`^${escapedName}(?:_chr\\d*\\.s3d|\\.s3d|\\.eqg)$`, 'i')
+ );
+
+ for (const handle of handles) {
+ processedAnySource =
+ (await processSourceArchive(handle.name)) || processedAnySource;
+ }
+
+ return processedAnySource;
+ })();
+
+ const processingPromise = pendingProcessingPromise.then(
+ (processed) => {
+ if (
+ !processed &&
+ characterArchivePromises.get(normalizedModelName) === processingPromise
+ ) {
+ characterArchivePromises.delete(normalizedModelName);
+ }
+ return processed;
+ },
+ (error) => {
+ if (
+ characterArchivePromises.get(normalizedModelName) === processingPromise
+ ) {
+ characterArchivePromises.delete(normalizedModelName);
+ }
+ throw error;
+ }
+ );
+
+ characterArchivePromises.set(normalizedModelName, processingPromise);
+ return processingPromise;
+}
+
export async function processEquip(settings, rootFileSystemHandle, standalone = false, controller = null, reportStage = null) {
const [{ EQFileHandle, writeEQFile }, GlobalStore] = await Promise.all([
getProcessingDeps(),
@@ -307,11 +578,21 @@ export async function processEquip(settings, rootFileSystemHandle, standalone =
}
}
-export async function processZone(zoneName, settings, rootFileSystemHandle, _onlyChr = false, controller = null, reportStage = null) {
+export async function processZone(
+ zoneName,
+ settings,
+ rootFileSystemHandle,
+ _onlyChr = false,
+ controller = null,
+ reportStage = null,
+ signal = null
+) {
+ throwIfZoneLoadAborted(signal);
const [{ getEQFile, getEQFileExists, writeEQFile }, GlobalStore] = await Promise.all([
getFileHandlerDeps(),
getGlobalStore(),
]);
+ throwIfZoneLoadAborted(signal);
const startedAt = performance.now();
const activeGameController = getActiveController(controller);
if (activeGameController) {
@@ -331,6 +612,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
getEQFile('data', 'global.json', 'json'),
getEQFileExists('models', 'hum.glb'),
]);
+ throwIfZoneLoadAborted(signal);
logZoneStep(zoneName, 'Global cache status', {
...(v || {}),
fallbackModelExists,
@@ -357,6 +639,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
activeGameController,
reportStage
);
+ throwIfZoneLoadAborted(signal);
}
emitStage(reportStage, `Checking cached ${zoneName} zone assets`);
@@ -364,6 +647,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
getEQFile('zones', `${zoneName}.json`, 'json'),
getEQFileExists('zones', `${zoneName}.glb`),
]);
+ throwIfZoneLoadAborted(signal);
logZoneStep(zoneName, 'Zone cache status', {
glbExists : exists,
metadataVersion: existingMetadata?.version ?? null,
@@ -376,15 +660,21 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
existingMetadata?.spireCharacterTextures === true &&
existingMetadata?.spireCharacterCacheVersion === PREVIEW_CHARACTER_CACHE_VERSION
);
+ const previewZoneObjectCacheReady =
+ !isPreviewBridge() ||
+ existingMetadata?.spireZoneObjectCacheVersion === PREVIEW_ZONE_OBJECT_CACHE_VERSION;
if (
exists &&
existingMetadata?.version === ZONE_VERSION &&
previewCharacterCacheReady &&
- !settings?.forceReload
+ previewZoneObjectCacheReady &&
+ !settings?.forceReload &&
+ !_onlyChr
) {
emitStage(reportStage, `Using cached ${zoneName} zone assets`);
logZoneStep(zoneName, 'Zone cache hit, skipping archive scan');
await primeCachedZoneAssets(zoneName, existingMetadata, getEQFile, reportStage);
+ throwIfZoneLoadAborted(signal);
return existingMetadata;
}
@@ -403,7 +693,8 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
handles.push(...await collectMatchingRootFiles(
rootFileSystemHandle,
new RegExp(`^${zoneName}[_\\.].*`, 'i'),
- () => true,
+ (fileName) =>
+ !_onlyChr || /_chr\d*\.(?:s3d|eqg)$/i.test(fileName),
(_scanned, found, latest) => {
if (found <= 5 || found % 25 === 0) {
emitStage(
@@ -417,6 +708,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
});
}
));
+ throwIfZoneLoadAborted(signal);
} catch (e) {
console.warn('Error', e, handles);
emitStage(reportStage, `Scanning ${zoneName} zone archives failed`, e?.message || String(e));
@@ -425,11 +717,18 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
emitStage(reportStage, `Preparing ${zoneName} asset archive`, `${handles.length} matching files`);
logZoneStep(zoneName, 'Zone archive scan complete', summarizeHandles(handles));
+ // A character-only pass is an explicit cache refresh. The outer zone-cache
+ // guard already bypasses cached data for this mode, so the nested archive
+ // handle must do the same or it will report success without translating
+ // any character models.
+ const processingSettings = _onlyChr
+ ? { ...settings, forceReload: true }
+ : settings;
const obj = new EQFileHandle(
zoneName,
handles,
rootFileSystemHandle,
- settings
+ processingSettings
);
emitStage(reportStage, `Initializing ${zoneName} asset archive`);
logZoneStep(zoneName, 'Initializing zone archive');
@@ -438,6 +737,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
() => obj.initialize(),
reportStage
);
+ throwIfZoneLoadAborted(signal);
emitStage(reportStage, `Processing ${zoneName} asset archive`);
logZoneStep(zoneName, 'Processing zone archive');
match = await withStageContext(
@@ -445,6 +745,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
() => obj.process(),
reportStage
);
+ throwIfZoneLoadAborted(signal);
if (isPreviewBridge()) {
const metadata = (await getEQFile('zones', `${zoneName}.json`, 'json')) || {};
await writeEQFile(
@@ -455,6 +756,7 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
spireCharacterModels : true,
spireCharacterTextures: true,
spireCharacterCacheVersion: PREVIEW_CHARACTER_CACHE_VERSION,
+ spireZoneObjectCacheVersion: PREVIEW_ZONE_OBJECT_CACHE_VERSION,
})
);
}
@@ -463,11 +765,16 @@ export async function processZone(zoneName, settings, rootFileSystemHandle, _onl
if (watchdog) {
window.clearInterval(watchdog);
}
- GlobalStore.actions.setLoading(false);
+ if (!signal?.aborted) {
+ GlobalStore.actions.setLoading(false);
+ }
logZoneStep(
zoneName,
`Finished zone processing in ${((performance.now() - startedAt) / 1000).toFixed(2)}s`
);
}
- return (await getEQFile('zones', `${zoneName}.json`, 'json')) || match;
+ throwIfZoneLoadAborted(signal);
+ const result = (await getEQFile('zones', `${zoneName}.json`, 'json')) || match;
+ throwIfZoneLoadAborted(signal);
+ return result;
}
diff --git a/frontend/eqsage-embed/src/components/zone/zone-context.jsx b/frontend/eqsage-embed/src/components/zone/zone-context.jsx
index 600d610c..e6360de6 100644
--- a/frontend/eqsage-embed/src/components/zone/zone-context.jsx
+++ b/frontend/eqsage-embed/src/components/zone/zone-context.jsx
@@ -131,28 +131,307 @@ const collectSpawnVisualStats = async (gameController) => {
return null;
}
- const deadline = Date.now() + 5000;
+ // Large cities can finish their spawn graph before the last shared texture
+ // has decoded. Keep this validation-only wait bounded, and require several
+ // consecutive clean observations so a single transient frame cannot pass.
+ const deadline = Date.now() + 30000;
+ const requiredStableReadyPolls = 3;
+ const settleStartedAt = Date.now();
+ let pollCount = 0;
+ let stableReadyPolls = 0;
let visualStats = null;
do {
+ try {
+ // Validation runs while the normal render loop is paused by loading.
+ // Render explicitly so late textures can upload and animations advance.
+ gameController.currentScene?.render?.();
+ } catch (_error) {}
await wait(250);
+ pollCount++;
visualStats = spawnController.collectSpawnVisualStats();
- const texturesReady =
- visualStats.texturelessSpawnCount === 0 &&
- visualStats.pendingTextureCount === 0;
- const animationsReady =
- visualStats.tPoseRiskCount === 0 &&
- visualStats.nonPlayingAnimationCount === 0;
- const groundReady =
- (visualStats.belowGroundSpawnCount ?? 0) === 0 &&
- (visualStats.aboveGroundSpawnCount ?? 0) === 0;
- if (texturesReady && animationsReady && groundReady) {
- return visualStats;
+ const transientTexturesReady =
+ visualStats.pendingTextureCount === 0 &&
+ (visualStats.appearanceTexturePendingCount ?? 0) === 0;
+ // Terminal correctness failures cannot heal with time. Report them after
+ // textures settle instead of burning the entire timeout on every bad pose,
+ // fallback, or nameplate so QA remains fast and actionable.
+ if (transientTexturesReady) {
+ stableReadyPolls++;
+ } else {
+ stableReadyPolls = 0;
+ }
+ if (stableReadyPolls >= requiredStableReadyPolls) {
+ break;
}
} while (Date.now() < deadline);
+ if (visualStats) {
+ visualStats.validationSettle = {
+ elapsedMs: Date.now() - settleStartedAt,
+ pollCount,
+ requiredStableReadyPolls,
+ stableReadyPolls,
+ timedOut: stableReadyPolls < requiredStableReadyPolls,
+ pendingTextureCount: visualStats.pendingTextureCount,
+ appearanceTexturePendingCount:
+ visualStats.appearanceTexturePendingCount ?? 0,
+ };
+ // Static/native-pose spawns do not need an expensive deterministic frame
+ // seek. The visual inventory is the authoritative classifier used by the
+ // validation result below, so avoid walking thousands of matrices when it
+ // reports that the zone has no animated skeleton spawns.
+ visualStats.runtimeAnimation =
+ Number(visualStats.animatedSkeletonSpawnCount ?? 0) > 0
+ ? await collectRuntimeAnimationProbe(gameController)
+ : null;
+ }
return visualStats;
};
+const getPlayingSpawnAnimationProbes = (gameController) => {
+ const spawnController = gameController?.SpawnController;
+ const probes = [];
+ const observedModelAssets = new Set();
+ for (const [spawnId, spawn] of Object.entries(spawnController?.spawns ?? {})) {
+ const playingGroups = (spawn.animationGroups ?? []).filter(
+ (group) =>
+ group?.targetedAnimations?.length > 0 &&
+ !spawnController.isPoseAnimationGroup(group) &&
+ spawnController.isAnimationGroupPlaying(group)
+ );
+ if (playingGroups.length === 0) {
+ continue;
+ }
+ if (spawnController.isTPoseValidationExcludedModel(spawn.modelName)) {
+ continue;
+ }
+ // Every instance of one resolved asset receives the same deterministic
+ // preferred clip. Babylon appends instance-specific suffixes to cloned
+ // AnimationGroup names, so a model+group-name key accidentally probes all
+ // hundreds of clones. Per-instance play state is already counted above;
+ // expensive frame seeking only needs one representative per source asset.
+ const modelAssetKey = `${
+ spawn.resolvedModelAsset ??
+ spawn.loadedModelVariation ??
+ spawn.modelName ??
+ 'unknown'
+ }`.toLowerCase();
+ if (observedModelAssets.has(modelAssetKey)) {
+ continue;
+ }
+ observedModelAssets.add(modelAssetKey);
+ probes.push({ spawnId, spawn, group: playingGroups[0] });
+ }
+ return probes;
+};
+
+const collectSpawnPlacementStats = (gameController, spawnPoints = []) => {
+ const spawnController = gameController?.SpawnController;
+ const tolerance = 0.25;
+ const stats = {
+ expectedCount : spawnPoints.length,
+ loadedCount : 0,
+ missingVisualCount : 0,
+ nonFinitePlacementCount: 0,
+ positionMismatchCount : 0,
+ staleReferenceCount : 0,
+ samples : [],
+ };
+
+ for (const sourceSpawn of spawnPoints) {
+ const spawnId = sourceSpawn?.id ?? sourceSpawn?.__spireSpawnId;
+ const visual = spawnController?.spawns?.[spawnId];
+ if (!visual?.rootNode || visual.rootNode.isDisposed?.()) {
+ stats.missingVisualCount++;
+ if (stats.samples.length < 10) {
+ stats.samples.push({ spawnId, issue: 'missing-visual' });
+ }
+ continue;
+ }
+
+ stats.loadedCount++;
+ const expected = {
+ x: Number(sourceSpawn.y),
+ y: Number(sourceSpawn.z),
+ z: Number(sourceSpawn.x),
+ };
+ const actual = {
+ x: Number(visual.rootNode.position?.x),
+ y: Number(
+ visual.getGroundReferenceWorldY?.() ??
+ visual.rootNode.metadata?.spawnGroundY ??
+ visual.rootNode.position?.y
+ ),
+ z: Number(visual.rootNode.position?.z),
+ };
+ const finite = [...Object.values(expected), ...Object.values(actual)]
+ .every(Number.isFinite);
+ const deltas = {
+ x: Math.abs(actual.x - expected.x),
+ y: Math.abs(actual.y - expected.y),
+ z: Math.abs(actual.z - expected.z),
+ };
+ const positionMismatch = finite && Object.values(deltas)
+ .some((delta) => delta > tolerance);
+ const reference = visual.spawnEntry ?? visual.rootNode.metadata?.spawn;
+ const staleReference =
+ Number(reference?.x) !== Number(sourceSpawn.x) ||
+ Number(reference?.y) !== Number(sourceSpawn.y) ||
+ Number(reference?.z) !== Number(sourceSpawn.z);
+
+ stats.nonFinitePlacementCount += finite ? 0 : 1;
+ stats.positionMismatchCount += positionMismatch ? 1 : 0;
+ stats.staleReferenceCount += staleReference ? 1 : 0;
+ if ((!finite || positionMismatch || staleReference) && stats.samples.length < 10) {
+ stats.samples.push({
+ spawnId,
+ issue: !finite
+ ? 'non-finite-placement'
+ : positionMismatch
+ ? 'position-mismatch'
+ : 'stale-reference',
+ actual,
+ deltas,
+ expected,
+ reference: reference
+ ? { x: reference.x, y: reference.y, z: reference.z }
+ : null,
+ });
+ }
+ }
+
+ stats.pass =
+ stats.loadedCount === stats.expectedCount &&
+ stats.missingVisualCount === 0 &&
+ stats.nonFinitePlacementCount === 0 &&
+ stats.positionMismatchCount === 0 &&
+ stats.staleReferenceCount === 0;
+ return stats;
+};
+
+const captureSpawnAnimationPose = (spawn, animationGroup) => {
+ const root = spawn?.rootNode;
+ const nodes = [root, ...(root?.getDescendants?.(false) ?? [])].filter(Boolean);
+ const nodeSet = new Set(nodes);
+ const meshes = nodes.filter(
+ (node) => typeof node?.getTotalVertices === 'function' && node.getTotalVertices() > 0
+ );
+ const skeletons = new Set([
+ ...(spawn?.instanceContainer?.skeletons ?? []),
+ ...(spawn?.skeletons ?? []),
+ root?.skeleton,
+ ...meshes.map((mesh) => mesh?.skeleton),
+ ].filter(Boolean));
+ const values = [];
+ let nonFiniteMatrixCount = 0;
+ const append = (matrix) => {
+ for (const value of Array.from(matrix ?? [])) {
+ if (Number.isFinite(value)) values.push(Number(value));
+ else {
+ values.push(null);
+ nonFiniteMatrixCount++;
+ }
+ }
+ };
+ for (const skeleton of skeletons) {
+ skeleton.computeAbsoluteTransforms?.();
+ for (const bone of skeleton?.bones ?? []) {
+ append(bone?.getFinalMatrix?.()?.m ?? bone?._finalMatrix?.m);
+ }
+ }
+ const targets = new Set();
+ for (const targetedAnimation of animationGroup?.targetedAnimations ?? []) {
+ const target = targetedAnimation?.target;
+ if (!target || targets.has(target)) continue;
+ targets.add(target);
+ if (typeof target.getFinalMatrix === 'function') {
+ append(target.getFinalMatrix()?.m ?? target?._finalMatrix?.m);
+ } else if (nodeSet.has(target) && typeof target.getWorldMatrix === 'function') {
+ target.computeWorldMatrix?.(true);
+ append(target.getWorldMatrix()?.m);
+ }
+ }
+ return { values, nonFiniteMatrixCount, targetCount: targets.size };
+};
+
+const collectRuntimeAnimationProbe = async (gameController) => {
+ const scene = gameController?.currentScene;
+ const probes = getPlayingSpawnAnimationProbes(gameController);
+ const frameFractions = [0.1, 0.37, 0.63, 0.9];
+ const samples = [];
+ let movingSpawnCount = 0;
+ let stationaryAnimatedSpawnCount = 0;
+ let nonFiniteMatrixCount = 0;
+ for (const { spawnId, spawn, group } of probes) {
+ const from = Number(group?.from ?? 0);
+ const to = Number(group?.to ?? from);
+ const previousFrame = Number(
+ group?._animatables?.[0]?.masterFrame ??
+ group?._animatables?.[0]?.currentFrame ??
+ from
+ );
+ const wasPlaying = gameController.SpawnController.isAnimationGroupPlaying(group);
+ const poses = [];
+ group.pause?.();
+ for (const fraction of frameFractions) {
+ group.goToFrame?.(from + ((to - from) * fraction));
+ poses.push(captureSpawnAnimationPose(spawn, group));
+ }
+ const baseline = poses[0]?.values ?? [];
+ let maxMatrixDelta = 0;
+ let changedValueCount = 0;
+ for (const pose of poses) {
+ nonFiniteMatrixCount += pose.nonFiniteMatrixCount;
+ if (pose.values.length !== baseline.length) continue;
+ for (let index = 0; index < baseline.length; index++) {
+ const startValue = baseline[index];
+ const endValue = pose.values[index];
+ if (!Number.isFinite(startValue) || !Number.isFinite(endValue)) continue;
+ const delta = Math.abs(endValue - startValue);
+ maxMatrixDelta = Math.max(maxMatrixDelta, delta);
+ changedValueCount += delta > 0.00001 ? 1 : 0;
+ }
+ }
+ if (wasPlaying) {
+ group.play?.(true);
+ group.goToFrame?.(previousFrame);
+ }
+ const moving = maxMatrixDelta > 0.00001 && changedValueCount > 0;
+ movingSpawnCount += moving ? 1 : 0;
+ stationaryAnimatedSpawnCount += moving ? 0 : 1;
+ const poseNonFiniteCount = poses.reduce(
+ (total, pose) => total + pose.nonFiniteMatrixCount,
+ 0
+ );
+ if ((!moving || poseNonFiniteCount > 0) && samples.length < 10) {
+ samples.push({
+ spawnId,
+ modelName: spawn.modelName ?? 'unknown',
+ groupName: group?.name ?? null,
+ frameCount: poses.length,
+ valueCount: baseline.length,
+ targetCount: poses[0]?.targetCount ?? 0,
+ maxMatrixDelta,
+ changedValueCount,
+ nonFiniteMatrixCount: poseNonFiniteCount,
+ });
+ }
+ }
+ try {
+ scene?.render?.();
+ } catch (_error) {}
+ return {
+ mode: 'deterministic-frame-seek',
+ probeStrategy: 'one-per-resolved-model-asset',
+ frameFractions,
+ probedSpawnCount: probes.length,
+ movingSpawnCount,
+ stationaryAnimatedSpawnCount,
+ nonFiniteMatrixCount,
+ samples,
+ };
+};
+
const getMaterialSlots = (material) => {
if (!material) {
return [];
@@ -208,6 +487,9 @@ const isTextureReady = (texture) => {
};
const getDoorModelName = (mesh) => {
+ if (mesh.metadata?.doorModelName) {
+ return `${mesh.metadata.doorModelName}`.toLowerCase();
+ }
const dataReferenceName = mesh.dataReference?.name;
if (dataReferenceName) {
return `${dataReferenceName}`.toLowerCase();
@@ -266,6 +548,9 @@ const collectDoorVisualStatsNow = (gameController) => {
if (isTextureReady(texture)) {
stats.readyTextureCount++;
} else {
+ const size = texture.getSize?.() ?? {};
+ const internalTexture =
+ texture.getInternalTexture?.() ?? texture._texture ?? null;
stats.pendingTextureCount++;
if (stats.pendingTextureSamples.length < 10) {
stats.pendingTextureSamples.push({
@@ -273,6 +558,11 @@ const collectDoorVisualStatsNow = (gameController) => {
modelName,
material: material.name,
texture: texture.name ?? texture.url,
+ width: Number(size.width ?? internalTexture?.width ?? 0),
+ height: Number(size.height ?? internalTexture?.height ?? 0),
+ hasInternalTexture: Boolean(internalTexture),
+ internalReady: internalTexture?.isReady ?? null,
+ loadingError: internalTexture?._loadingError ?? null,
});
}
}
@@ -419,12 +709,27 @@ const collectGeometryArtifactStats = (gameController) => {
continue;
}
- const size = {
- x: Math.abs(maximumWorld.x - minimumWorld.x),
- y: Math.abs(maximumWorld.y - minimumWorld.y),
- z: Math.abs(maximumWorld.z - minimumWorld.z),
- };
- const maxDimension = Math.max(size.x, size.y, size.z);
+ // Babylon uses sentinel bounds for meshes whose geometry is intentionally
+ // deferred or absent. They cannot be measured, so they are not evidence of
+ // an oversized rendered artifact.
+ if (
+ !Number.isFinite(minimumWorld.x) ||
+ !Number.isFinite(minimumWorld.y) ||
+ !Number.isFinite(minimumWorld.z) ||
+ !Number.isFinite(maximumWorld.x) ||
+ !Number.isFinite(maximumWorld.y) ||
+ !Number.isFinite(maximumWorld.z) ||
+ maximumWorld.x < minimumWorld.x ||
+ maximumWorld.y < minimumWorld.y ||
+ maximumWorld.z < minimumWorld.z
+ ) {
+ continue;
+ }
+ const maxDimension = Math.max(
+ maximumWorld.x - minimumWorld.x,
+ maximumWorld.y - minimumWorld.y,
+ maximumWorld.z - minimumWorld.z
+ );
if (maxDimension < 1200) {
continue;
}
@@ -467,11 +772,16 @@ const collectGeometryArtifactStats = (gameController) => {
}
oversizedMeshes.sort((a, b) => b.maxDimension - a.maxDimension);
+ const oversizedSpawnMeshes = oversizedMeshes.filter(
+ (mesh) => mesh.spawnModel || mesh.spawnName
+ );
return {
hiddenMaterialLeakCount: hiddenMaterialLeaks.length,
hiddenMaterialLeaks: hiddenMaterialLeaks.slice(0, 20),
oversizedMeshCount: oversizedMeshes.length,
oversizedMeshes: oversizedMeshes.slice(0, 40),
+ oversizedSpawnMeshCount: oversizedSpawnMeshes.length,
+ oversizedSpawnMeshes: oversizedSpawnMeshes.slice(0, 40),
};
};
@@ -499,6 +809,10 @@ const emitZoneValidation = async ({
const spawnStats = window.__spireSageSpawnStats ?? null;
const canvasStats = window.__spireSageCanvasStats ?? null;
const visualStats = await collectSpawnVisualStats(gameController);
+ const spawnPlacementStats = collectSpawnPlacementStats(
+ gameController,
+ spawnPoints
+ );
const renderableDoorPoints = Array.isArray(doorLoad?.renderableDoors)
? doorLoad.renderableDoors
: getRenderableDoors(doorPoints);
@@ -535,7 +849,8 @@ const emitZoneValidation = async ({
lodProxyCount === 0 &&
(spawnStats.missingAssetCount ?? 0) === 0 &&
(spawnStats.unresolvedModelCount ?? 0) === 0 &&
- (spawnStats.fallbackCount ?? 0) === 0;
+ (spawnStats.fallbackCount ?? 0) === 0 &&
+ spawnPlacementStats.pass;
const texturePass =
!!visualStats &&
visualStats.spawnCount === (spawnStats?.loaded ?? 0) &&
@@ -543,20 +858,65 @@ const emitZoneValidation = async ({
visualStats.texturedSlotCount > 0 &&
visualStats.texturelessSpawnCount === 0 &&
visualStats.pendingTextureCount === 0 &&
+ (visualStats.appearanceTextureDecodeFailureCount ?? 0) === 0 &&
(visualStats.belowGroundSpawnCount ?? 0) === 0 &&
(visualStats.aboveGroundSpawnCount ?? 0) === 0 &&
(visualStats.onePixelTextureCount ?? 0) === 0 &&
- (visualStats.fallbackTextureCount ?? 0) === 0;
+ (visualStats.fallbackTextureCount ?? 0) === 0 &&
+ (visualStats.verticallyFlippedHeadTextureCount ?? 0) === 0 &&
+ (visualStats.bodyVariantFallbackCount ?? 0) === 0 &&
+ (visualStats.secondaryHeadBoneRemapFailureCount ?? 0) === 0;
+ const animatedSkeletonSpawnCount = Number(
+ visualStats?.animatedSkeletonSpawnCount ?? 0
+ );
+ const runtimeAnimationPass =
+ animatedSkeletonSpawnCount === 0 ||
+ (
+ !!visualStats?.runtimeAnimation &&
+ Number(visualStats.runtimeAnimation.probedSpawnCount ?? 0) > 0 &&
+ Number(visualStats.runtimeAnimation.movingSpawnCount ?? 0) ===
+ Number(visualStats.runtimeAnimation.probedSpawnCount ?? 0) &&
+ Number(visualStats.runtimeAnimation.stationaryAnimatedSpawnCount ?? 0) === 0 &&
+ Number(visualStats.runtimeAnimation.nonFiniteMatrixCount ?? 0) === 0
+ );
const animationPass =
!!visualStats &&
- visualStats.skeletonSpawnCount > 0 &&
- visualStats.animatedSkeletonSpawnCount === visualStats.skeletonSpawnCount &&
+ (
+ visualStats.skeletonSpawnCount === 0 ||
+ visualStats.animatedSkeletonSpawnCount +
+ visualStats.staticPosedSkeletonSpawnCount ===
+ visualStats.skeletonSpawnCount
+ ) &&
visualStats.tPoseRiskCount === 0 &&
+ (visualStats.motionlessAnimationCount ?? 0) === 0 &&
+ (visualStats.excessAnimationGroupCount ?? 0) === 0 &&
+ (visualStats.unresolvedAnimationTargetCount ?? 0) === 0 &&
+ (visualStats.nonFiniteBoneMatrixCount ?? 0) === 0 &&
+ (visualStats.selectedAnimationPromotionFailureCount ?? 0) === 0 &&
+ (visualStats.animationBoundsRejectionCount ?? 0) === 0 &&
+ (visualStats.animationHeadOrientationRejectionCount ?? 0) === 0 &&
+ runtimeAnimationPass &&
visualStats.nonPlayingAnimationCount === 0;
+ const nameplatePass =
+ !!visualStats &&
+ Number(visualStats.nameplateExpectedCount ?? 0) > 0 &&
+ Number(visualStats.nameplateExpectedCount ?? 0) ===
+ Number(visualStats.nameplateEligibleSpawnCount ?? 0) &&
+ Number(visualStats.nameplateCount ?? 0) ===
+ Number(visualStats.nameplateExpectedCount ?? 0) &&
+ Number(visualStats.nameplateVisibleCount ?? 0) ===
+ Number(visualStats.nameplateExpectedCount ?? 0) &&
+ Number(visualStats.nameplateTexturedCount ?? 0) ===
+ Number(visualStats.nameplateExpectedCount ?? 0) &&
+ Number(visualStats.nameplateAboveModelCount ?? 0) ===
+ Number(visualStats.nameplateExpectedCount ?? 0) &&
+ Number(visualStats.nameplateFailureCount ?? 0) === 0;
const boundaryPass =
gameController?.settings?.importBoundary === true ||
boundaryMeshes.length === 0;
- const geometryPass = (geometryStats?.hiddenMaterialLeakCount ?? 0) === 0;
+ const geometryPass =
+ (geometryStats?.hiddenMaterialLeakCount ?? 0) === 0 &&
+ (geometryStats?.oversizedSpawnMeshCount ?? 0) === 0;
const doorPass =
doorPoints.length === 0 ||
(
@@ -571,6 +931,24 @@ const emitZoneValidation = async ({
doorVisualStats.fallbackTextureCount === 0 &&
doorVisualStats.onePixelTextureCount === 0
);
+ const scene = gameController?.currentScene;
+ const performanceMemory = window.performance?.memory;
+ const runtimeMemory = performanceMemory
+ ? {
+ jsHeapLimitBytes: Number(performanceMemory.jsHeapSizeLimit ?? 0),
+ jsHeapTotalBytes: Number(performanceMemory.totalJSHeapSize ?? 0),
+ jsHeapUsedBytes: Number(performanceMemory.usedJSHeapSize ?? 0),
+ }
+ : null;
+ const sceneResources = {
+ animationGroups: scene?.animationGroups?.length ?? 0,
+ geometries: scene?.geometries?.length ?? 0,
+ materials: scene?.materials?.length ?? 0,
+ meshes: scene?.meshes?.length ?? 0,
+ skeletons: scene?.skeletons?.length ?? 0,
+ textures: scene?.textures?.length ?? 0,
+ transformNodes: scene?.transformNodes?.length ?? 0,
+ };
const report = {
zone: selectedZone?.short_name ?? null,
longName: selectedZone?.long_name ?? null,
@@ -588,7 +966,10 @@ const emitZoneValidation = async ({
camera: window.__spireSageCameraStats ?? null,
cameraFraming: window.__spireSageCameraFraming ?? null,
movement,
- spawns: spawnStats,
+ spawns: {
+ ...(spawnStats ?? {}),
+ placement: spawnPlacementStats,
+ },
visuals: visualStats,
geometry: geometryStats,
rootNodeCount: rootIds.length,
@@ -596,17 +977,20 @@ const emitZoneValidation = async ({
lodProxyCount,
boundaryMeshCount: boundaryMeshes.length,
boundaryMeshes: boundaryMeshes.map((mesh) => mesh.name).slice(0, 20),
- sceneMeshCount: gameController?.currentScene?.meshes?.length ?? 0,
+ sceneMeshCount: sceneResources.meshes,
+ sceneResources,
+ runtimeMemory,
pass: {
canvas: canvasPass,
movement: movementPass,
spawns: spawnPass,
textures: texturePass,
animations: animationPass,
+ nameplates: nameplatePass,
boundary: boundaryPass,
geometry: geometryPass,
doors: doorPass,
- all: canvasPass && movementPass && spawnPass && texturePass && animationPass && boundaryPass && geometryPass && doorPass,
+ all: canvasPass && movementPass && spawnPass && texturePass && animationPass && nameplatePass && boundaryPass && geometryPass && doorPass,
},
timestamp: new Date().toISOString(),
};
@@ -640,12 +1024,20 @@ export const ZoneProvider = ({ children }) => {
}
await gameController.SpawnController.addSpawns([spawn], true);
setSpawns(s => [...s, spawn]);
+ } else if (type === 'moveSpawn') {
+ setSpawns(spawns => spawns.map(s => s.id === spawn.id ? {
+ ...s,
+ x: spawn.x,
+ y: spawn.y,
+ z: spawn.z,
+ } : s));
+ gameController.SpawnController.moveSpawn(spawn);
} else if (type === 'updateSpawn') {
setSpawns(spawns => spawns.map(s => s.id === spawn.id ? spawn : s));
- gameController.SpawnController.updateSpawn(spawn);
+ await gameController.SpawnController.updateSpawn(spawn);
} else if (type === 'deleteSpawn') {
setSpawns(spawns => spawns.filter(s => s.id !== spawn.id));
- gameController.SpawnController.deleteSpawn(spawn);
+ await gameController.SpawnController.deleteSpawn(spawn);
} else if (type === 'refresh') {
const refreshRun = ++refreshRunRef.current;
const zoneShortName = selectedZone.short_name;
diff --git a/frontend/eqsage-embed/src/components/zone/zone.jsx b/frontend/eqsage-embed/src/components/zone/zone.jsx
index 4bb50d4b..05f7f91b 100644
--- a/frontend/eqsage-embed/src/components/zone/zone.jsx
+++ b/frontend/eqsage-embed/src/components/zone/zone.jsx
@@ -27,12 +27,19 @@ export const BabylonZone = () => {
const [viewerDetail, setViewerDetail] = useState('');
const [viewerReady, setViewerReady] = useState(false);
const [viewerError, setViewerError] = useState(null);
+ const zoneLoadRunRef = useRef(0);
useEffect(() => {
if (!selectedZone || !gameController) {
return;
}
+ const zoneLoadRun = ++zoneLoadRunRef.current;
+ const abortController = new AbortController();
let current = true;
+ const isCurrent = () =>
+ current &&
+ zoneLoadRun === zoneLoadRunRef.current &&
+ !abortController.signal.aborted;
setViewerReady(false);
setViewerError(null);
setViewerDetail('');
@@ -51,7 +58,7 @@ export const BabylonZone = () => {
});
setViewerStage('Loading zone processor');
const { processZone } = await import('./processZone');
- if (!current) {
+ if (!isCurrent()) {
return;
}
setViewerStage('Processing zone assets');
@@ -62,31 +69,32 @@ export const BabylonZone = () => {
false,
gameController,
(stage, detail = '') => {
- if (!current) {
+ if (!isCurrent()) {
return;
}
setViewerStage(stage);
setViewerDetail(detail);
- }
+ },
+ abortController.signal
);
- if (!current) {
+ if (!isCurrent()) {
return;
}
setViewerStage('Loading Babylon bridge');
const { default: bjs } = await import('@bjs');
- if (!current) {
+ if (!isCurrent()) {
return;
}
setViewerStage('Loading viewer runtime');
await bjs.prepareZoneViewer((stage) => {
- if (current) {
+ if (isCurrent()) {
setViewerStage(stage);
}
});
- while (current && !canvasRef.current) {
+ while (isCurrent() && !canvasRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
- if (!current) {
+ if (!isCurrent()) {
return;
}
debugSageLog('Ref', canvasRef.current);
@@ -94,7 +102,7 @@ export const BabylonZone = () => {
await gameController.loadEngine(canvasRef.current, loadSettings.webgpu);
gameController.setLoading(true);
rendererPaused = true;
- if (!current) {
+ if (!isCurrent()) {
return;
}
window.addEventListener('resize', gameController.resize);
@@ -106,9 +114,10 @@ export const BabylonZone = () => {
setViewerDetail('');
await gameController.ZoneController.loadModel(
selectedZone.short_name,
- zoneMetadata && typeof zoneMetadata === 'object' ? zoneMetadata : null
+ zoneMetadata && typeof zoneMetadata === 'object' ? zoneMetadata : null,
+ abortController.signal
);
- if (!current) {
+ if (!isCurrent()) {
return;
}
gameController.setLoading(false);
@@ -117,7 +126,7 @@ export const BabylonZone = () => {
setViewerDetail('');
setViewerReady(true);
} catch (e) {
- if (!current) {
+ if (!isCurrent()) {
return;
}
setViewerError(e);
@@ -127,6 +136,17 @@ export const BabylonZone = () => {
message: errorDetail,
stack : e?.stack ?? null,
};
+ window.dispatchEvent(
+ new CustomEvent('spire-sage-zone-validation-error', {
+ detail: {
+ zone : selectedZone.short_name,
+ longName : selectedZone.long_name ?? selectedZone.short_name,
+ error : errorDetail,
+ stack : e?.stack ?? null,
+ timestamp: new Date().toISOString(),
+ },
+ })
+ );
setViewerDetail(errorDetail);
gameController.openAlert?.(
'Error loading zone. Check console output.',
@@ -139,7 +159,7 @@ export const BabylonZone = () => {
gameController.setLoading(false);
}
} finally {
- if (rendererPaused) {
+ if (rendererPaused && isCurrent()) {
gameController.setLoading(false);
}
}
@@ -147,6 +167,10 @@ export const BabylonZone = () => {
return () => {
current = false;
+ abortController.abort();
+ zoneLoadRunRef.current += 1;
+ gameController.ZoneController.cancelPendingLoad?.();
+ gameController.setLoading(false);
window.removeEventListener('resize', gameController.resize);
window.removeEventListener('keydown', gameController.keyDown);
};
diff --git a/frontend/eqsage-embed/src/context/alerts.jsx b/frontend/eqsage-embed/src/context/alerts.jsx
index 009ec287..23a8e677 100644
--- a/frontend/eqsage-embed/src/context/alerts.jsx
+++ b/frontend/eqsage-embed/src/context/alerts.jsx
@@ -42,12 +42,26 @@ export const AlertProvider = ({ children }) => {
openAlert,
}}
>
-
+
{message}
diff --git a/frontend/eqsage-embed/src/util/image/image-processor.js b/frontend/eqsage-embed/src/util/image/image-processor.js
index ecd66ef7..1620654a 100644
--- a/frontend/eqsage-embed/src/util/image/image-processor.js
+++ b/frontend/eqsage-embed/src/util/image/image-processor.js
@@ -133,8 +133,11 @@ class ImageProcessor {
const modelDir = await getEQDir('textures');
if (modelDir) {
const files = await getFiles(modelDir, undefined, true);
+ const existingTextureNames = new Set(
+ files.map((fileName) => fileName.toLowerCase().split('.')[0])
+ );
unionImages = unionImages.filter(
- (i) => !files.some((f) => f.split('.')[0] === i.name.split('.')[0])
+ (image) => !existingTextureNames.has(image.name.toLowerCase().split('.')[0])
);
}
if (!unionImages.length) {
diff --git a/frontend/eqsage-embed/src/util/image/shared.js b/frontend/eqsage-embed/src/util/image/shared.js
index 893fabf3..443eaf73 100644
--- a/frontend/eqsage-embed/src/util/image/shared.js
+++ b/frontend/eqsage-embed/src/util/image/shared.js
@@ -16,8 +16,6 @@ export const ShaderType = {
Boundary : 11,
};
-const Jimp = globalThis.Jimp; // eslint-disable-line
-
const fullAlphaToDoubleAlphaThreshold = 64;
const alphaShaderMap = {
[ShaderType.Transparent25] : 64,
@@ -33,6 +31,13 @@ export const normalizeTextureName = (name) =>
export async function parseTexture(name, shaderType, data) {
name = name.toLowerCase().replace(/\.\w+$/, '');
+ // The browser build registers Jimp on the global object. Resolve it at call
+ // time rather than capturing it during module initialization so dynamic
+ // chunk ordering cannot permanently cache an undefined decoder.
+ const Jimp = globalThis.Jimp; // eslint-disable-line
+ if (!Jimp?.read || !Jimp?.MIME_PNG) {
+ throw new Error('Browser texture decoder did not initialize');
+ }
if (new DataView(data).getUint16(0, true) === 0x4d42) {
try {
diff --git a/frontend/eqsage-embed/src/viewer/common/raceAppearancePolicies.json b/frontend/eqsage-embed/src/viewer/common/raceAppearancePolicies.json
new file mode 100644
index 00000000..8f4bc150
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/common/raceAppearancePolicies.json
@@ -0,0 +1,38 @@
+{
+ "schemaVersion": 2,
+ "classicFaceModels": [
+ "baf",
+ "bam",
+ "daf",
+ "dam",
+ "dwf",
+ "dwm",
+ "elf",
+ "elm",
+ "erf",
+ "erm",
+ "gnf",
+ "gnm",
+ "haf",
+ "ham",
+ "hif",
+ "him",
+ "hof",
+ "hom",
+ "huf",
+ "hum",
+ "ikf",
+ "ikm",
+ "ogf",
+ "ogm",
+ "trf",
+ "trm"
+ ],
+ "requiredHeadModels": [
+ "all",
+ "avi",
+ "bea",
+ "bgm",
+ "brf"
+ ]
+}
diff --git a/frontend/eqsage-embed/src/viewer/common/raceData.json b/frontend/eqsage-embed/src/viewer/common/raceData.json
index f9329f07..daecd71d 100644
--- a/frontend/eqsage-embed/src/viewer/common/raceData.json
+++ b/frontend/eqsage-embed/src/viewer/common/raceData.json
@@ -1355,7 +1355,7 @@
"1": "",
"2": "SKN",
"id": 199,
- "name":"Shik\\'Nar"
+ "name": "Shik\\'Nar"
},
{
"0": "",
@@ -2258,7 +2258,7 @@
"1": "",
"2": "ZBC",
"id": 328,
- "name":"Zebuxoruk\\'s Cage"
+ "name": "Zebuxoruk\\'s Cage"
},
{
"0": "",
@@ -3268,6 +3268,1805 @@
"id": 474,
"name": "Witheran"
},
+ {
+ "0": "",
+ "1": "",
+ "2": "AIE",
+ "id": 475,
+ "name": "Air Elemental"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "EAE",
+ "id": 476,
+ "name": "Earth Elemental"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "FIE",
+ "id": 477,
+ "name": "Fire Elemental"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WAE",
+ "id": 478,
+ "name": "Water Elemental"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "ALR",
+ "id": 479,
+ "name": "Alligator"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BAR",
+ "id": 480,
+ "name": "Bear"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SCW",
+ "id": 481,
+ "name": "Scaled Wolf"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WLF",
+ "id": 482,
+ "name": "Wolf"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SPW",
+ "id": 483,
+ "name": "Spirit Wolf"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SKL",
+ "id": 484,
+ "name": "Skeleton"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SPT",
+ "id": 485,
+ "name": "Spectre"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BLV",
+ "id": 486,
+ "name": "Bolvirk"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BSE",
+ "id": 487,
+ "name": "Banshee"
+ },
+ {
+ "0": "",
+ "1": "BSG",
+ "2": "",
+ "id": 488,
+ "name": "Banshee"
+ },
+ {
+ "0": "EEM",
+ "1": "EEF",
+ "2": "",
+ "id": 489,
+ "name": "Elddar"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GFO",
+ "id": 490,
+ "name": "Forest Giant"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GLB",
+ "id": 491,
+ "name": "Bone Golem"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HRS",
+ "id": 492,
+ "name": "Horse"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "PGS",
+ "id": 493,
+ "name": "Pegasus"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SMD",
+ "id": 494,
+ "name": "Shambling Mound"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SCR",
+ "id": 495,
+ "name": "Scrykin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TRA",
+ "id": 496,
+ "name": "Treant"
+ },
+ {
+ "0": "VAM",
+ "1": "VAF",
+ "2": "",
+ "id": 497,
+ "name": "Vampire"
+ },
+ {
+ "0": "",
+ "1": "ARO",
+ "2": "",
+ "id": 498,
+ "name": "Ayonae Ro"
+ },
+ {
+ "0": "",
+ "1": "SZK",
+ "2": "",
+ "id": 499,
+ "name": "Sullon Zek"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BNR",
+ "id": 500,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "FLG",
+ "id": 501,
+ "name": "Flag"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "ROW",
+ "id": 502,
+ "name": "Rowboat"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T00",
+ "id": 503,
+ "name": "Bear Trap"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T01",
+ "id": 504,
+ "name": "Clockwork Bomb"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T02",
+ "id": 505,
+ "name": "Dynamite Keg"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T03",
+ "id": 506,
+ "name": "Pressure Plate"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T04",
+ "id": 507,
+ "name": "Puffer Spore"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T05",
+ "id": 508,
+ "name": "Stone Ring"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T06",
+ "id": 509,
+ "name": "Root Tentacle"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T07",
+ "id": 510,
+ "name": "Runic Symbol"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T08",
+ "id": 511,
+ "name": "Saltpetter Bomb"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T09",
+ "id": 512,
+ "name": "Floating Skull"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T10",
+ "id": 513,
+ "name": "Spike Trap"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T11",
+ "id": 514,
+ "name": "Totem"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T12",
+ "id": 515,
+ "name": "Web"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "T13",
+ "id": 516,
+ "name": "Wicker Basket"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "UNM",
+ "id": 517,
+ "name": "Nightmare/Unicorn"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HRS",
+ "id": 518,
+ "name": "Horse"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "UNM",
+ "id": 519,
+ "name": "Nightmare/Unicorn"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BXI",
+ "id": 520,
+ "name": "Bixie"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CNT",
+ "id": 521,
+ "name": "Centaur"
+ },
+ {
+ "0": "DKM",
+ "1": "DKF",
+ "2": "DKM",
+ "id": 522,
+ "name": "Drakkin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GFR",
+ "id": 523,
+ "name": "Giant"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GNL",
+ "id": 524,
+ "name": "Gnoll"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GRN",
+ "id": 525,
+ "name": "Griffin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GFS",
+ "id": 526,
+ "name": "Giant Shade"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HRP",
+ "id": 527,
+ "name": "Harpy"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MTH",
+ "id": 528,
+ "name": "Mammoth"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SAT",
+ "id": 529,
+ "name": "Satyr"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DRG",
+ "id": 530,
+ "name": "Dragon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DRN",
+ "id": 531,
+ "name": "Dragon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DYN",
+ "id": 532,
+ "name": "Dyn'Leth"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SHI",
+ "id": 533,
+ "name": "Boat"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I00",
+ "id": 534,
+ "name": "Weapon Rack"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I01",
+ "id": 535,
+ "name": "Armor Rack"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I02",
+ "id": 536,
+ "name": "Honey Pot"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I03",
+ "id": 537,
+ "name": "Jum Jum Bucket"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I04",
+ "id": 538,
+ "name": "Toolbox"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I05",
+ "id": 539,
+ "name": "Stone Jug"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I06",
+ "id": 540,
+ "name": "Small Plant"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I07",
+ "id": 541,
+ "name": "Medium Plant"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I08",
+ "id": 542,
+ "name": "Tall Plant"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I09",
+ "id": 543,
+ "name": "Wine Cask"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B01",
+ "id": 544,
+ "name": "Elven Boat"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B02",
+ "id": 545,
+ "name": "Gnomish Boat"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B03",
+ "id": 546,
+ "name": "Barrel Barge Ship"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GUL",
+ "id": 547,
+ "name": "Goo"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GUM",
+ "id": 548,
+ "name": "Goo"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GUS",
+ "id": 549,
+ "name": "Goo"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B04",
+ "id": 550,
+ "name": "Merchant Ship"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B05",
+ "id": 551,
+ "name": "Pirate Ship"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B06",
+ "id": 552,
+ "name": "Ghost Ship"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G00",
+ "id": 553,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G01",
+ "id": 554,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G02",
+ "id": 555,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G03",
+ "id": 556,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G04",
+ "id": 557,
+ "name": "Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "AVK",
+ "id": 558,
+ "name": "Aviak"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BTL",
+ "id": 559,
+ "name": "Beetle"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GRL",
+ "id": 560,
+ "name": "Gorilla"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "KDG",
+ "id": 561,
+ "name": "Kedge"
+ },
+ {
+ "0": "KRM",
+ "1": "KRF",
+ "2": "",
+ "id": 562,
+ "name": "Kerran"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SHS",
+ "id": 563,
+ "name": "Shissar"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SIN",
+ "id": 564,
+ "name": "Siren"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SPX",
+ "id": 565,
+ "name": "Sphinx"
+ },
+ {
+ "0": "HNM",
+ "1": "HNF",
+ "2": "",
+ "id": 566,
+ "name": "Human"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I10",
+ "id": 567,
+ "name": "Campfire"
+ },
+ {
+ "0": "BNM",
+ "1": "BNF",
+ "2": "",
+ "id": 568,
+ "name": "Brownie"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DRP",
+ "id": 569,
+ "name": "Dragon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "EXO",
+ "id": 570,
+ "name": "Exoskeleton"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GHO",
+ "id": 571,
+ "name": "Ghoul"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GUA",
+ "id": 572,
+ "name": "Clockwork Guardian"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MTP",
+ "id": 573,
+ "name": "Mantrap"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MNR",
+ "id": 574,
+ "name": "Minotaur"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SCC",
+ "id": 575,
+ "name": "Scarecrow"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SHE",
+ "id": 576,
+ "name": "Shade"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SWC",
+ "id": 577,
+ "name": "Rotocopter"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TNT",
+ "id": 578,
+ "name": "Tentacle Terror"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WOK",
+ "id": 579,
+ "name": "Wereorc"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WOR",
+ "id": 580,
+ "name": "Worg"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WYR",
+ "id": 581,
+ "name": "Wyvern"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MCH",
+ "id": 582,
+ "name": "Chimera"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MKI",
+ "id": 583,
+ "name": "Kirin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MPU",
+ "id": 584,
+ "name": "Puma"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I11",
+ "id": 585,
+ "name": "Boulder"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "G05",
+ "id": 586,
+ "name": "Banner"
+ },
+ {
+ "0": "XEM",
+ "1": "XEF",
+ "2": "",
+ "id": 587,
+ "name": "Elven Ghost"
+ },
+ {
+ "0": "XHM",
+ "1": "XHF",
+ "2": "",
+ "id": 588,
+ "name": "Human Ghost"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I12",
+ "id": 589,
+ "name": "Chest"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I13",
+ "id": 590,
+ "name": "Chest"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I14",
+ "id": 591,
+ "name": "Crystal"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I15",
+ "id": 592,
+ "name": "Coffin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I16",
+ "id": 593,
+ "name": "Guardian CPU"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MWO",
+ "id": 594,
+ "name": "Worg"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "OBJ_BLIMP",
+ "id": 595,
+ "name": "Mansion"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "OBP_MELDRATH",
+ "id": 596,
+ "name": "Floating Island"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MCS",
+ "id": 597,
+ "name": "Cragslither"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MWR",
+ "id": 598,
+ "name": "Wrulon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "S01",
+ "id": 599,
+ "name": "Spell Particle 1"
+ },
+ {
+ "0": "IVM",
+ "1": "IVF",
+ "2": "",
+ "id": 600,
+ "name": "Invisible Man of Zomm"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SWC",
+ "id": 601,
+ "name": "Robocopter of Zomm"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BUR",
+ "id": 602,
+ "name": "Burynai"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "FGG",
+ "id": 603,
+ "name": "Frog"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DRL",
+ "id": 604,
+ "name": "Dracolich"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "XIM",
+ "id": 605,
+ "name": "Iksar Ghost"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SKI",
+ "id": 606,
+ "name": "Iksar Skeleton"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MPH",
+ "id": 607,
+ "name": "Mephit"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MUD",
+ "id": 608,
+ "name": "Muddite"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "RPT",
+ "id": 609,
+ "name": "Raptor"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SRK",
+ "id": 610,
+ "name": "Sarnak"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SCO",
+ "id": 611,
+ "name": "Scorpion"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TSE",
+ "id": 612,
+ "name": "Tsetsian"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "WRM",
+ "id": 613,
+ "name": "Wurm"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BAL",
+ "id": 614,
+ "name": "Nekhon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HYC",
+ "id": 615,
+ "name": "Hydra Crystal"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CRY",
+ "id": 616,
+ "name": "Crystal Sphere"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GND",
+ "id": 617,
+ "name": "Gnoll"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SOK",
+ "id": 618,
+ "name": "Sokokar"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "PYS",
+ "id": 619,
+ "name": "Stone Pylon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DVL",
+ "id": 620,
+ "name": "Demon Vulture"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I17",
+ "id": 621,
+ "name": "Wagon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GOD",
+ "id": 622,
+ "name": "God of Discord"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MFR",
+ "id": 623,
+ "name": "Feran Mount"
+ },
+ {
+ "0": "ONM",
+ "1": "ONF",
+ "2": "",
+ "id": 624,
+ "name": "Ogre NPC"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MSO",
+ "id": 625,
+ "name": "Sokokar Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GRA",
+ "id": 626,
+ "name": "Giant (Rallosian mats)"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MSO",
+ "id": 627,
+ "name": "Sokokar (w saddle)"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BNX",
+ "id": 628,
+ "name": "10th Anniversary Banner"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CAK",
+ "id": 629,
+ "name": "10th Anniversary Cake"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I18",
+ "id": 630,
+ "name": "Wine Cask"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MHY",
+ "id": 631,
+ "name": "Hydra Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HYD",
+ "id": 632,
+ "name": "Hydra NPC"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I19",
+ "id": 633,
+ "name": "Wedding Flowers"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I20",
+ "id": 634,
+ "name": "Wedding Arbor"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I21",
+ "id": 635,
+ "name": "Wedding Altar"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I22",
+ "id": 636,
+ "name": "Powder Keg"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "APX",
+ "id": 637,
+ "name": "Apexus"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BEL",
+ "id": 638,
+ "name": "Bellikos"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BFC",
+ "id": 639,
+ "name": "Brell's First Creation"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BRE",
+ "id": 640,
+ "name": "Brell"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CAM",
+ "id": 641,
+ "name": "Crystalskin Ambuloid"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CLQ",
+ "id": 642,
+ "name": "Cliknar Queen"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CLS",
+ "id": 643,
+ "name": "Cliknar Soldier"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CLW",
+ "id": 644,
+ "name": "Cliknar Worker"
+ },
+ {
+ "0": "CDM",
+ "1": "CDF",
+ "2": "",
+ "id": 645,
+ "name": "Coldain"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CSE",
+ "id": 647,
+ "name": "Crystalskin Sessiloid"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GEN",
+ "id": 648,
+ "name": "Genari"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GIG",
+ "id": 649,
+ "name": "Gigyn"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GYA",
+ "id": 650,
+ "name": "Greken"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GYO",
+ "id": 651,
+ "name": "Greken"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MCL",
+ "id": 652,
+ "name": "Cliknar Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TEL",
+ "id": 653,
+ "name": "Telmira"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MTA",
+ "id": 654,
+ "name": "Spider Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MBR",
+ "id": 655,
+ "name": "Bear Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MCR",
+ "id": 656,
+ "name": "Rat Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MSD",
+ "id": 657,
+ "name": "Sessiloid Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "LTH",
+ "id": 658,
+ "name": "Morell Thule"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MRT",
+ "id": 659,
+ "name": "Marionette"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BKD",
+ "id": 660,
+ "name": "Book Dervish"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TPL",
+ "id": 661,
+ "name": "Topiary Lion"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "RDG",
+ "id": 662,
+ "name": "Rotdog"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "AMY",
+ "id": 663,
+ "name": "Amygdalan"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SND",
+ "id": 664,
+ "name": "Sandman"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GFC",
+ "id": 665,
+ "name": "Grandfather Clock"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GBM",
+ "id": 666,
+ "name": "Gingerbread Man"
+ },
+ {
+ "0": "BFR",
+ "1": "BFF",
+ "2": "",
+ "id": 667,
+ "name": "Royal Guard"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BNY",
+ "id": 668,
+ "name": "Rabbit"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BDR",
+ "id": 669,
+ "name": "Blind Dreamer"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CTH",
+ "id": 670,
+ "name": "Cazic Thule"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MTL",
+ "id": 671,
+ "name": "Topiary Lion Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MRD",
+ "id": 672,
+ "name": "Rot Dog Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MGL",
+ "id": 673,
+ "name": "Goral Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MSL",
+ "id": 674,
+ "name": "Selyrah Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MSC",
+ "id": 675,
+ "name": "Sclera Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MBX",
+ "id": 676,
+ "name": "Braxi Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MKG",
+ "id": 677,
+ "name": "Kangon Mount"
+ },
+ {
+ "0": "ERU",
+ "1": "",
+ "2": "",
+ "id": 678,
+ "name": "Erudite"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MWM",
+ "id": 679,
+ "name": "Wurm Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MRP",
+ "id": 680,
+ "name": "Raptor Mount"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "INV",
+ "id": 681,
+ "name": "Invisible Man"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MWI",
+ "id": 682,
+ "name": "Whirligig"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MBL",
+ "id": 683,
+ "name": "Gnomish Balloon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MRK",
+ "id": 684,
+ "name": "Gnomish Rocket Pack"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MHB",
+ "id": 685,
+ "name": "Gnomish Hovering Transport"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SEY",
+ "id": 686,
+ "name": "Selyrah"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GR1",
+ "id": 687,
+ "name": "Goral"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BRX",
+ "id": 688,
+ "name": "Braxi"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "KNG",
+ "id": 689,
+ "name": "Kangon"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I23",
+ "id": 690,
+ "name": "Invisible Man"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B07",
+ "id": 691,
+ "name": "Floating Tower"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B08",
+ "id": 692,
+ "name": "Explosive Cart"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B09",
+ "id": 693,
+ "name": "Blimp Ship"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I24",
+ "id": 694,
+ "name": "Tumbleweed"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "ALA",
+ "id": 695,
+ "name": "Alaran"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SWI",
+ "id": 696,
+ "name": "Swinetor"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "TRG",
+ "id": 697,
+ "name": "Triumvirate"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HDL",
+ "id": 698,
+ "name": "Hadal"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "B10",
+ "id": 699,
+ "name": "Hovering Platform"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "PSC",
+ "id": 700,
+ "name": "Parasitic Scavenger"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GRD",
+ "id": 701,
+ "name": "Grendlaen"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I25",
+ "id": 702,
+ "name": "Ship in a Bottle"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I26",
+ "id": 703,
+ "name": "Alaran Sentry Stone"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "SDV",
+ "id": 704,
+ "name": "Dervish"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I27",
+ "id": 705,
+ "name": "Regeneration Pool"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I28",
+ "id": 706,
+ "name": "Teleportation Stand"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I29",
+ "id": 707,
+ "name": "Relic Case"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "ALG",
+ "id": 708,
+ "name": "Alaran Ghost"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MPG",
+ "id": 709,
+ "name": "Skystrider"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I30",
+ "id": 710,
+ "name": "Water Spout"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I31",
+ "id": 711,
+ "name": "Aviak Pull Along"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "GCB",
+ "id": 712,
+ "name": "Gelatinous Cube"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CAT",
+ "id": 713,
+ "name": "Cat"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "I32",
+ "id": 714,
+ "name": "Elk Head"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HLG",
+ "id": 715,
+ "name": "Holgresh"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "BTS",
+ "id": 716,
+ "name": "Beetle"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "VNM",
+ "id": 717,
+ "name": "Vine Maw"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "CHT",
+ "id": 718,
+ "name": "Ratman"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "FKN",
+ "id": 719,
+ "name": "Fallen Knight"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MCP",
+ "id": 720,
+ "name": "Flying Carpet"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "MRH",
+ "id": 721,
+ "name": "Carrier Hand"
+ },
+ {
+ "0": "AHM",
+ "1": "AHF",
+ "2": "",
+ "id": 722,
+ "name": "Akheva"
+ },
+ {
+ "0": "SOS",
+ "1": "",
+ "2": "",
+ "id": 723,
+ "name": "Servant of Shadow"
+ },
+ {
+ "0": "",
+ "1": "LUC",
+ "2": "",
+ "id": 724,
+ "name": "Luclin"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "AXA",
+ "id": 725,
+ "name": "Xaric the Unspoken"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "HDV",
+ "id": 726,
+ "name": "Dervish (Ver. 5)"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "DV6",
+ "id": 727,
+ "name": "Dervish (Ver. 6)"
+ },
+ {
+ "0": "",
+ "1": "LU2",
+ "2": "",
+ "id": 728,
+ "name": "God"
+ },
+ {
+ "0": "",
+ "1": "LU3",
+ "2": "",
+ "id": 729,
+ "name": "God"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "ORB",
+ "id": 730,
+ "name": "Orb"
+ },
+ {
+ "0": "",
+ "1": "LU4",
+ "2": "",
+ "id": 731,
+ "name": "God"
+ },
+ {
+ "0": "",
+ "1": "",
+ "2": "PG3",
+ "id": 732,
+ "name": "Pegasus"
+ },
{
"0": "PPOINT",
"1": "PPOINT",
@@ -3310,4 +5109,4 @@
"id": 2258,
"name": "N/A"
}
-]
\ No newline at end of file
+]
diff --git a/frontend/eqsage-embed/src/viewer/common/raceModelMetadata.json b/frontend/eqsage-embed/src/viewer/common/raceModelMetadata.json
new file mode 100644
index 00000000..60d1db7e
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/common/raceModelMetadata.json
@@ -0,0 +1,6471 @@
+{
+ "aam": {
+ "sourceFiles": [
+ "aam.eqg",
+ "hohonora_chr.s3d",
+ "hohonorb_chr.s3d",
+ "poair_chr.s3d",
+ "potimea_chr.s3d",
+ "povalor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ahf": {
+ "sourceFiles": [
+ "ahf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ahm": {
+ "sourceFiles": [
+ "ahm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "akm": {
+ "sourceFiles": [
+ "akheva_chr.s3d",
+ "griegsend_chr.s3d",
+ "maiden_chr.s3d",
+ "maiden_chr2.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "twilight_chr.s3d",
+ "umbral_chr.s3d",
+ "vexthal_chr.s3d",
+ "vexthal_chr2.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "akn": {
+ "sourceFiles": [
+ "akheva_chr.s3d",
+ "maiden_chr.s3d",
+ "poknowledge_chr.s3d",
+ "vexthal_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ala": {
+ "sourceFiles": [
+ "ala.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 20,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "all": {
+ "sourceFiles": [
+ "blackburrow_chr.s3d",
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "cazicthule_chr.s3d",
+ "crushbone_chr.s3d",
+ "feerrott_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "global6_chr.s3d",
+ "gukbottom_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "katta_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lavastorm_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "nurga_chr.s3d",
+ "oasis_chr.s3d",
+ "qcat_chr.s3d",
+ "rathemtn_chr.s3d",
+ "sro_chr.s3d",
+ "timorous_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "amy": {
+ "sourceFiles": [
+ "amy.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ans": {
+ "sourceFiles": [
+ "ans.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "apx": {
+ "sourceFiles": [
+ "apx.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "asm": {
+ "sourceFiles": [
+ "asm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "avi": {
+ "sourceFiles": [
+ "airplane_chr.s3d",
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "freportw_chr.s3d",
+ "lakerathe_chr.s3d",
+ "oot_chr.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "southkarana_chr.s3d",
+ "steamfont_chr.s3d",
+ "timorous_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "avk": {
+ "sourceFiles": [
+ "avk.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "baf": {
+ "sourceFiles": [
+ "global4_chr.s3d",
+ "global_chr.s3d",
+ "globalbaf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bal": {
+ "sourceFiles": [
+ "bal.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bam": {
+ "sourceFiles": [
+ "global4_chr.s3d",
+ "global_chr.s3d",
+ "globalbam_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "bas": {
+ "sourceFiles": [
+ "bas.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bat": {
+ "sourceFiles": [
+ "bat.eqg",
+ "befallen_chr.s3d",
+ "beholder_chr.s3d",
+ "burningwood_chr.s3d",
+ "butcher_chr.s3d",
+ "cabwest_chr.s3d",
+ "erudsxing_chr.s3d",
+ "feerrott_chr.s3d",
+ "freporte_chr.s3d",
+ "freportw_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "gfaydark_chr.s3d",
+ "gukbottom_chr.s3d",
+ "katta_chr.s3d",
+ "kithicor_chr.s3d",
+ "lfaydark_chr.s3d",
+ "maiden_chr.s3d",
+ "misty_chr.s3d",
+ "necropolis_chr.s3d",
+ "oggok_chr.s3d",
+ "paineel_chr.s3d",
+ "paw_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "soldunga_chr.s3d",
+ "soldungb_chr.s3d",
+ "tenebrous_chr.s3d",
+ "thurgadina_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bea": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "blackburrow_chr.s3d",
+ "cauldron_chr.s3d",
+ "commons_chr.s3d",
+ "ecommons_chr.s3d",
+ "everfrost_chr.s3d",
+ "global2_chr.s3d",
+ "growthplane_chr.s3d",
+ "halas_chr.s3d",
+ "kithicor_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "northkarana_chr.s3d",
+ "permafrost_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "rathemtn_chr.s3d",
+ "rivervale_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bel": {
+ "sourceFiles": [
+ "bel.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bet": {
+ "sourceFiles": [
+ "beholder_chr.s3d",
+ "butcher_chr.s3d",
+ "ecommons_chr.s3d",
+ "erudsxing_chr.s3d",
+ "feerrott_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "freporte_chr.s3d",
+ "freportw_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "misty_chr.s3d",
+ "necropolis_chr.s3d",
+ "nektulos_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paineel_chr.s3d",
+ "pojustice_chr.s3d",
+ "qcat_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "rathemtn_chr.s3d",
+ "sebilis_chr.s3d",
+ "soldungb_chr.s3d",
+ "sro_chr.s3d",
+ "tox_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bfc": {
+ "sourceFiles": [
+ "bfc.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bfr": {
+ "sourceFiles": [
+ "bfr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bgb": {
+ "sourceFiles": [
+ "bgb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bgm": {
+ "sourceFiles": [
+ "beholder_chr.s3d",
+ "erudsxing_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "highkeep_chr.s3d",
+ "lakerathe_chr.s3d",
+ "mseru_chr.s3d",
+ "neriaka_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paw_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "sro_chr.s3d",
+ "steamfont_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bkd": {
+ "sourceFiles": [
+ "bkd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bkn": {
+ "sourceFiles": [
+ "hohonora_chr.s3d",
+ "hohonorb_chr.s3d",
+ "potimea_chr.s3d",
+ "povalor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "blv": {
+ "sourceFiles": [
+ "blv.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bnf": {
+ "sourceFiles": [
+ "bnf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bnm": {
+ "sourceFiles": [
+ "bnm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bny": {
+ "sourceFiles": [
+ "bny.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 7,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "box": {
+ "sourceFiles": [
+ "box_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bre": {
+ "sourceFiles": [
+ "bre.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "brf": {
+ "sourceFiles": [
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "brm": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "gfaydark_chr.s3d",
+ "growthplane2_chr.s3d",
+ "lfaydark_chr.s3d",
+ "poknowledge_chr.s3d",
+ "qrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "steamfont_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "brl": {
+ "sourceFiles": [
+ "brl_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "brn": {
+ "sourceFiles": [
+ "dalnir_chr.s3d",
+ "droga_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "kurn_chr.s3d",
+ "nurga_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "brx": {
+ "sourceFiles": [
+ "brx.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "btl": {
+ "sourceFiles": [
+ "btl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "btn": {
+ "sourceFiles": [
+ "btn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bur": {
+ "sourceFiles": [
+ "bur.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bwd": {
+ "sourceFiles": [
+ "bwd_chr.s3d",
+ "eastwastes_chr2.s3d",
+ "eastwastesshard_chr2.s3d",
+ "hohonora_chr.s3d",
+ "pofire_chr.s3d",
+ "sleeper_chr2.s3d",
+ "solrotower_chr.s3d",
+ "swampofnohope_chr2.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "bxi": {
+ "sourceFiles": [
+ "bxi.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cak": {
+ "sourceFiles": [
+ "cak.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cam": {
+ "sourceFiles": [
+ "cam.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cdf": {
+ "sourceFiles": [
+ "cdf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cdm": {
+ "sourceFiles": [
+ "cdm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cdr": {
+ "sourceFiles": [
+ "cdr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cen": {
+ "sourceFiles": [
+ "northkarana_chr.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "qeynos_chr.s3d",
+ "qrg_chr.s3d",
+ "southkarana_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cht": {
+ "sourceFiles": [
+ "cht.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "clf": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "poinnovation_chr.s3d",
+ "soldunga_chr.s3d",
+ "steamfont_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "clm": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "lfaydark_chr.s3d",
+ "overthere_chr.s3d",
+ "poinnovation_chr.s3d",
+ "soldunga_chr.s3d",
+ "steamfont_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cls": {
+ "sourceFiles": [
+ "cls.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "clw": {
+ "sourceFiles": [
+ "clw.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cnt": {
+ "sourceFiles": [
+ "cnt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 6,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cof": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "greatdivide_chr.s3d",
+ "iceclad_chr2.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "thurgadina_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "velketor_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "com": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "greatdivide_chr.s3d",
+ "iceclad_chr2.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "necropolis_chr.s3d",
+ "pofire_chr.s3d",
+ "skyshrine_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "thurgadina_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "velketor_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cpf": {
+ "sourceFiles": [
+ "stonebrunt_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cpm": {
+ "sourceFiles": [
+ "stonebrunt_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cpt": {
+ "sourceFiles": [
+ "cpt_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "crh": {
+ "sourceFiles": [
+ "crh.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "crl": {
+ "sourceFiles": [
+ "crl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cse": {
+ "sourceFiles": [
+ "cse.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cst": {
+ "sourceFiles": [
+ "cst_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cub": {
+ "sourceFiles": [
+ "potimeb_chr.s3d",
+ "skyshrine_chr.s3d",
+ "thurgadinb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cwg": {
+ "sourceFiles": [
+ "cwg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "cwr": {
+ "sourceFiles": [
+ "cwr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "daf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaldaf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dam": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaldam_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 3
+ },
+ "dcf": {
+ "sourceFiles": [
+ "dcf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dcm": {
+ "sourceFiles": [
+ "dcm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ddm": {
+ "sourceFiles": [
+ "ddm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ddv": {
+ "sourceFiles": [
+ "ddv.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dev": {
+ "sourceFiles": [
+ "charasis_chr.s3d",
+ "dalnir_chr.s3d",
+ "kaesora_chr.s3d",
+ "skyfire_chr.s3d",
+ "swampofnohope_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dke": {
+ "sourceFiles": [
+ "dke.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dkf": {
+ "sourceFiles": [
+ "dkf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 7,
+ "minBeards": 0,
+ "maxBeards": 3
+ },
+ "dkm": {
+ "sourceFiles": [
+ "dkm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 8,
+ "minBeards": 0,
+ "maxBeards": 11
+ },
+ "dma": {
+ "sourceFiles": [
+ "dma.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dpf": {
+ "sourceFiles": [
+ "dpf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dpm": {
+ "sourceFiles": [
+ "dpm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dr2": {
+ "sourceFiles": [
+ "dr2_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "hohonora_chr.s3d",
+ "necropolis_chr.s3d",
+ "pofire_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "solrotower_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d",
+ "wakening_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dra": {
+ "sourceFiles": [
+ "arena_chr.s3d",
+ "dra_chr.s3d",
+ "dreadlands_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "firiona_chr.s3d",
+ "hohonora_chr.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "necropolis_chr.s3d",
+ "permafrost_chr.s3d",
+ "pofire_chr.s3d",
+ "skyfire_chr.s3d",
+ "skyshrine_chr2.s3d",
+ "sleeper_chr.s3d",
+ "soldungb_chr.s3d",
+ "solrotower_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d",
+ "wakening_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 7,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 7,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dre": {
+ "sourceFiles": [
+ "dre.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drf": {
+ "sourceFiles": [
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "pojustice_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drg": {
+ "sourceFiles": [
+ "drg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 5,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drk": {
+ "sourceFiles": [
+ "airplane_chr.s3d",
+ "beholder_chr.s3d",
+ "butcher_chr.s3d",
+ "cobaltscar_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "global6_chr.s3d",
+ "lavastorm_chr.s3d",
+ "necropolis_chr.s3d",
+ "rathemtn_chr.s3d",
+ "skyfire_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "soldunga_chr.s3d",
+ "soldungb_chr.s3d",
+ "solrotower_chr.s3d",
+ "steamfont_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "timorous_chr.s3d",
+ "veeshan_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drl": {
+ "sourceFiles": [
+ "drl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drm": {
+ "sourceFiles": [
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "pojustice_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "drv": {
+ "sourceFiles": [
+ "drv_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dvl": {
+ "sourceFiles": [
+ "dvl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dvm": {
+ "sourceFiles": [
+ "hateplane_chr.s3d",
+ "mistmoore_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dvs": {
+ "sourceFiles": [
+ "dvs.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dwf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaldwf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "dwm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaldwm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "eef": {
+ "sourceFiles": [
+ "eef.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "eem": {
+ "sourceFiles": [
+ "eem.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "efe": {
+ "sourceFiles": [
+ "efe_chr.s3d",
+ "poair_chr.s3d",
+ "pofire_chr.s3d",
+ "solrotower_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "efr": {
+ "sourceFiles": [
+ "arena_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "pofire_chr.s3d",
+ "pojustice_chr.s3d",
+ "solrotower_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "egm": {
+ "sourceFiles": [
+ "erudnext_chr.s3d",
+ "erudnint_chr.s3d",
+ "neriaka_chr.s3d",
+ "tox_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ele": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "poair_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "elf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalelf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "elm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalelm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "epf": {
+ "sourceFiles": [
+ "epf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "epm": {
+ "sourceFiles": [
+ "epm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "erf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalerf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 8,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "erm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalerm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 5,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "eve": {
+ "sourceFiles": [
+ "eve.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "exo": {
+ "sourceFiles": [
+ "exo.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "faf": {
+ "sourceFiles": [
+ "airplane_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "gfaydark_chr.s3d",
+ "lfaydark_chr.s3d",
+ "poknowledge_chr.s3d",
+ "qrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "tox_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fbf": {
+ "sourceFiles": [
+ "fbf_chr.s3d",
+ "udk_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fbm": {
+ "sourceFiles": [
+ "fbm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fef": {
+ "sourceFiles": [
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fem": {
+ "sourceFiles": [
+ "crushbone_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "firiona_chr.s3d",
+ "gfaydark_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fgi": {
+ "sourceFiles": [
+ "burningwood_chr.s3d",
+ "dreadlands_chr.s3d",
+ "firiona_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "postorms_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fgp": {
+ "sourceFiles": [
+ "fgp.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fgt": {
+ "sourceFiles": [
+ "fgt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fis": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "cabeast_chr.s3d",
+ "cazicthule_chr.s3d",
+ "cobaltscar_chr.s3d",
+ "crushbone_chr.s3d",
+ "dawnshroud_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudnint_chr.s3d",
+ "erudsxing_chr.s3d",
+ "feerrott_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "freporte_chr.s3d",
+ "grobb_chr.s3d",
+ "gukbottom_chr.s3d",
+ "guktop_chr.s3d",
+ "halas_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d",
+ "kedge_chr.s3d",
+ "kerraridge_chr.s3d",
+ "lakerathe_chr.s3d",
+ "mistmoore_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "oggok_chr.s3d",
+ "oot_chr.s3d",
+ "paw_chr.s3d",
+ "postorms_chr.s3d",
+ "powater_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "runnyeye_chr.s3d",
+ "sirens_chr.s3d",
+ "tox_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fkn": {
+ "sourceFiles": [
+ "fkn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fmt": {
+ "sourceFiles": [
+ "frog_mount_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fng": {
+ "sourceFiles": [
+ "fng.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fpm": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "commons_chr.s3d",
+ "ecommons_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "neriaka_chr.s3d",
+ "nro_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fra": {
+ "sourceFiles": [
+ "fra.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "frd": {
+ "sourceFiles": [
+ "frd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "frf": {
+ "sourceFiles": [
+ "globalpcfroglok_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "frg": {
+ "sourceFiles": [
+ "chardok_chr.s3d",
+ "fearplane_chr.s3d",
+ "feerrott_chr.s3d",
+ "firiona_chr.s3d",
+ "globalfroglok_chr.s3d",
+ "grobb_chr.s3d",
+ "gukbottom_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "kurn_chr.s3d",
+ "lakerathe_chr.s3d",
+ "najena_chr.s3d",
+ "oggok_chr.s3d",
+ "pojustice_chr.s3d",
+ "ponightmare_chr.s3d",
+ "qcat_chr.s3d",
+ "sebilis_chr.s3d",
+ "swampofnohope_chr.s3d",
+ "trakanon_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "frm": {
+ "sourceFiles": [
+ "globalpcfroglok_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fry": {
+ "sourceFiles": [
+ "fry.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fsg": {
+ "sourceFiles": [
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "greatdivide_chr.s3d",
+ "iceclad_chr.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "skyshrine_chr.s3d",
+ "thurgadina_chr.s3d",
+ "wakening_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "fun": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "feerrott_chr.s3d",
+ "grobb_chr.s3d",
+ "gukbottom_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "kurn_chr.s3d",
+ "mischiefplane_chr.s3d",
+ "runnyeye_chr.s3d",
+ "sebilis_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gam": {
+ "sourceFiles": [
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gar": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudnint_chr.s3d",
+ "gukbottom_chr.s3d",
+ "hateplane_chr.s3d",
+ "katta_chr.s3d",
+ "lavastorm_chr.s3d",
+ "mistmoore_chr.s3d",
+ "najena_chr.s3d",
+ "neriaka_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "oot_chr.s3d",
+ "overthere_chr.s3d",
+ "pojustice_chr.s3d",
+ "runnyeye_chr.s3d",
+ "soldungb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gbl": {
+ "sourceFiles": [
+ "gbl_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gbm": {
+ "sourceFiles": [
+ "gbm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gbn": {
+ "sourceFiles": [
+ "gbn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 12,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gdm": {
+ "sourceFiles": [
+ "cauldron_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "pojustice_chr.s3d",
+ "thurgadina_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gef": {
+ "sourceFiles": [
+ "fieldofbone_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gen": {
+ "sourceFiles": [
+ "gen.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gfc": {
+ "sourceFiles": [
+ "gfc.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gff": {
+ "sourceFiles": [
+ "felwithea_chr.s3d",
+ "gfaydark_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gfm": {
+ "sourceFiles": [
+ "crushbone_chr.s3d",
+ "gfaydark_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gfo": {
+ "sourceFiles": [
+ "gfo.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gfr": {
+ "sourceFiles": [
+ "gfr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gfs": {
+ "sourceFiles": [
+ "gfs.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ggy": {
+ "sourceFiles": [
+ "ggy.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gia": {
+ "sourceFiles": [
+ "commons_chr.s3d",
+ "eastkarana_chr.s3d",
+ "everfrost_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "oot_chr.s3d",
+ "permafrost_chr.s3d",
+ "pojustice_chr.s3d",
+ "postorms_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "rathemtn_chr.s3d",
+ "soldungb_chr.s3d",
+ "southkarana_chr.s3d",
+ "sro_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gig": {
+ "sourceFiles": [
+ "gig.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "glb": {
+ "sourceFiles": [
+ "glb.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "glm": {
+ "sourceFiles": [
+ "glm.eqg",
+ "glm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gnd": {
+ "sourceFiles": [
+ "gnd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gnf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalgnf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gnl": {
+ "sourceFiles": [
+ "gnl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gnm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalgnm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "gnn": {
+ "sourceFiles": [
+ "beholder_chr.s3d",
+ "blackburrow_chr.s3d",
+ "eastkarana_chr.s3d",
+ "everfrost_chr.s3d",
+ "halas_chr.s3d",
+ "highkeep_chr.s3d",
+ "iceclad_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "lakerathe_chr.s3d",
+ "paw_chr.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "southkarana_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gob": {
+ "sourceFiles": [
+ "beholder_chr.s3d",
+ "blackburrow_chr.s3d",
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "everfrost_chr.s3d",
+ "freportw_chr.s3d",
+ "halas_chr.s3d",
+ "highkeep_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d",
+ "kedge_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lavastorm_chr.s3d",
+ "misty_chr.s3d",
+ "najena_chr.s3d",
+ "nurga_chr.s3d",
+ "oasis_chr.s3d",
+ "oot_chr.s3d",
+ "permafrost_chr.s3d",
+ "rivervale_chr.s3d",
+ "runnyeye_chr.s3d",
+ "soldunga_chr.s3d",
+ "soldungb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gol": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "beholder_chr.s3d",
+ "blackburrow_chr.s3d",
+ "cauldron_chr.s3d",
+ "cazicthule_chr.s3d",
+ "citymist_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudnint_chr.s3d",
+ "fearplane_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "freportw_chr.s3d",
+ "gfaydark_chr.s3d",
+ "gukbottom_chr.s3d",
+ "hateplane_chr.s3d",
+ "hateplaneb_chr.s3d",
+ "highkeep_chr.s3d",
+ "hole_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d",
+ "lavastorm_chr.s3d",
+ "mistmoore_chr.s3d",
+ "najena_chr.s3d",
+ "nektulos_chr.s3d",
+ "neriaka_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "overthere_chr.s3d",
+ "paineel_chr.s3d",
+ "poeartha_chr.s3d",
+ "poearthb_chr.s3d",
+ "pofire_chr.s3d",
+ "potimea_chr.s3d",
+ "qeynos_chr.s3d",
+ "runnyeye_chr.s3d",
+ "soldunga_chr.s3d",
+ "solrotower_chr.s3d",
+ "tox_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gom": {
+ "sourceFiles": [
+ "frozenshadow_chr.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "pofire_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "goo": {
+ "sourceFiles": [
+ "cazicthule_chr2.s3d",
+ "charasis_chr.s3d",
+ "citymist_chr.s3d",
+ "dalnir_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "goo_chr.s3d",
+ "kurn_chr.s3d",
+ "necropolis_chr.s3d",
+ "netherbian_chr.s3d",
+ "podisease_chr.s3d",
+ "poinnovation_chr.s3d",
+ "potactics_chr.s3d",
+ "thurgadina_chr.s3d",
+ "vexthal_chr.s3d",
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gor": {
+ "sourceFiles": [
+ "arena_chr.s3d",
+ "burningwood_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "greatdivide_chr.s3d",
+ "mischiefplane_chr.s3d",
+ "stonebrunt_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gpf": {
+ "sourceFiles": [
+ "gpf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gpm": {
+ "sourceFiles": [
+ "gpm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gra": {
+ "sourceFiles": [
+ "gra.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "grf": {
+ "sourceFiles": [
+ "grobb_chr.s3d",
+ "innothule_chr.s3d",
+ "neriaka_chr2.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gri": {
+ "sourceFiles": [
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "gri_chr.s3d",
+ "iceclad_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "potimea_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "grl": {
+ "sourceFiles": [
+ "grl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "grm": {
+ "sourceFiles": [
+ "grobb_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "neriaka_chr2.s3d",
+ "neriakc_chr.s3d",
+ "oggok_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "grn": {
+ "sourceFiles": [
+ "grn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gsf": {
+ "sourceFiles": [
+ "pofire_chr.s3d",
+ "solrotower_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gsm": {
+ "sourceFiles": [
+ "powater_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gsn": {
+ "sourceFiles": [
+ "grimling_chr.s3d",
+ "poeartha_chr.s3d",
+ "potorment_chr.s3d",
+ "thegrey_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gul": {
+ "sourceFiles": [
+ "gul.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gum": {
+ "sourceFiles": [
+ "gum.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gus": {
+ "sourceFiles": [
+ "gus.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gya": {
+ "sourceFiles": [
+ "gya.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "gyo": {
+ "sourceFiles": [
+ "gyo.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "haf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhaf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ham": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalham_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 3
+ },
+ "hdl": {
+ "sourceFiles": [
+ "hdl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hhm": {
+ "sourceFiles": [
+ "highkeep_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hif": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhif_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "him": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhim_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 3
+ },
+ "hlf": {
+ "sourceFiles": [
+ "everfrost_chr.s3d",
+ "halas_chr.s3d",
+ "southkarana_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hlg": {
+ "sourceFiles": [
+ "hlg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hlm": {
+ "sourceFiles": [
+ "everfrost_chr.s3d",
+ "halas_chr.s3d",
+ "southkarana_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hnf": {
+ "sourceFiles": [
+ "hnf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 5,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hnm": {
+ "sourceFiles": [
+ "hnm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 7,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hof": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhof_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hom": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhom_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "hpf": {
+ "sourceFiles": [
+ "hpf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hpm": {
+ "sourceFiles": [
+ "hpm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hrp": {
+ "sourceFiles": [
+ "hrp.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hrs": {
+ "sourceFiles": [
+ "hrs.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hsf": {
+ "sourceFiles": [
+ "frog_mount_chr.s3d",
+ "gunthak_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hsm": {
+ "sourceFiles": [
+ "global5_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hsn": {
+ "sourceFiles": [
+ "ponightmare_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "huf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhuf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "hum": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalhum_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 5
+ },
+ "hyc": {
+ "sourceFiles": [
+ "hyc.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "i11": {
+ "sourceFiles": [
+ "i11.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "i19": {
+ "sourceFiles": [
+ "i19.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 10,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "i20": {
+ "sourceFiles": [
+ "i20.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "i21": {
+ "sourceFiles": [
+ "i21.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "icf": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "charasis_chr.s3d",
+ "dalnir_chr.s3d",
+ "droga_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "lakeofillomen_chr.s3d",
+ "overthere_chr.s3d",
+ "sebilis_chr.s3d",
+ "swampofnohope_chr.s3d",
+ "timorous_chr.s3d",
+ "trakanon_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "icm": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "charasis_chr.s3d",
+ "dalnir_chr.s3d",
+ "droga_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "lakeofillomen_chr.s3d",
+ "overthere_chr.s3d",
+ "sebilis_chr.s3d",
+ "swampofnohope_chr.s3d",
+ "timorous_chr.s3d",
+ "trakanon_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "icn": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "charasis_chr.s3d",
+ "chardok_chr.s3d",
+ "droga_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "kaesora_chr.s3d",
+ "karnor_chr.s3d",
+ "lakeofillomen_chr.s3d",
+ "overthere_chr.s3d",
+ "sebilis_chr.s3d",
+ "swampofnohope_chr.s3d",
+ "timorous_chr.s3d",
+ "trakanon_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ikf": {
+ "sourceFiles": [
+ "global4_chr.s3d",
+ "globalikf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ikg": {
+ "sourceFiles": [
+ "povalor_chr.s3d",
+ "ssratemple_chr.s3d",
+ "thegrey_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ikm": {
+ "sourceFiles": [
+ "global4_chr.s3d",
+ "globalikm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ivm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "potorment_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "iwf": {
+ "sourceFiles": [
+ "iwf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "iwm": {
+ "sourceFiles": [
+ "iwm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "izf": {
+ "sourceFiles": [
+ "izf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "izm": {
+ "sourceFiles": [
+ "izm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "jkr": {
+ "sourceFiles": [
+ "jkr_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kaf": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kam": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "crushbone_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kbd": {
+ "sourceFiles": [
+ "kbd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 5,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kdg": {
+ "sourceFiles": [
+ "kdg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kef": {
+ "sourceFiles": [
+ "global7_chr.s3d",
+ "globalkef_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kem": {
+ "sourceFiles": [
+ "global7_chr.s3d",
+ "globalkem_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kob": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "crushbone_chr.s3d",
+ "erudnext_chr.s3d",
+ "grobb_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "kerraridge_chr.s3d",
+ "paineel_chr.s3d",
+ "poknowledge_chr.s3d",
+ "potactics_chr.s3d",
+ "skyshrine_chr.s3d",
+ "soldunga_chr.s3d",
+ "soldungb_chr.s3d",
+ "steamfont_chr.s3d",
+ "stonebrunt_chr.s3d",
+ "torgiran_chr.s3d",
+ "tox_chr.s3d",
+ "unrest_chr.s3d",
+ "velketor_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "kor": {
+ "sourceFiles": [
+ "kor.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "krf": {
+ "sourceFiles": [
+ "krf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "krm": {
+ "sourceFiles": [
+ "krm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "krn": {
+ "sourceFiles": [
+ "krn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "launch": {
+ "sourceFiles": [
+ "erudnext_chr.s3d",
+ "halas_chr.s3d",
+ "iceclad_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "launchm": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "droga_chr.s3d",
+ "nurga_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "lim": {
+ "sourceFiles": [
+ "arena_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "postorms_chr.s3d",
+ "potimea_chr.s3d",
+ "qeynos_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "liz": {
+ "sourceFiles": [
+ "cazicthule_chr.s3d",
+ "feerrott_chr.s3d",
+ "guktop_chr.s3d",
+ "innothule_chr.s3d",
+ "oggok_chr.s3d",
+ "oot_chr.s3d",
+ "rathemtn_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "lmf": {
+ "sourceFiles": [
+ "lmf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "lmm": {
+ "sourceFiles": [
+ "lmm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "lth": {
+ "sourceFiles": [
+ "lth.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "lyc": {
+ "sourceFiles": [
+ "frozenshadow_chr.s3d",
+ "iceclad_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mam": {
+ "sourceFiles": [
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "everfrost_chr.s3d",
+ "potimea_chr.s3d",
+ "southkarana_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mbr": {
+ "sourceFiles": [
+ "mbr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mbx": {
+ "sourceFiles": [
+ "mbx.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mcs": {
+ "sourceFiles": [
+ "mcs.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mdr": {
+ "sourceFiles": [
+ "mdr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mfr": {
+ "sourceFiles": [
+ "mfr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mgl": {
+ "sourceFiles": [
+ "mgl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mhy": {
+ "sourceFiles": [
+ "mhy.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mim": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "blackburrow_chr.s3d",
+ "cobaltscar_chr.s3d",
+ "gukbottom_chr.s3d",
+ "hateplane_chr.s3d",
+ "hole_chr.s3d",
+ "katta_chr.s3d",
+ "mim_chr.s3d",
+ "mischiefplane_chr.s3d",
+ "mistmoore_chr.s3d",
+ "najena_chr.s3d",
+ "neriakc_chr.s3d",
+ "podisease_chr.s3d",
+ "runnyeye_chr.s3d",
+ "shadeweaver_chr.s3d",
+ "sharvahl_chr.s3d",
+ "sseru_chr.s3d",
+ "unrest_chr.s3d",
+ "vexthal_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mkg": {
+ "sourceFiles": [
+ "mkg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mki": {
+ "sourceFiles": [
+ "mki.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mmf": {
+ "sourceFiles": [
+ "mmf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mmm": {
+ "sourceFiles": [
+ "mmm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mpg": {
+ "sourceFiles": [
+ "mpg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mph": {
+ "sourceFiles": [
+ "mph.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mpu": {
+ "sourceFiles": [
+ "mpu.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mrh": {
+ "sourceFiles": [
+ "mrh.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mrt": {
+ "sourceFiles": [
+ "mrt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "msd": {
+ "sourceFiles": [
+ "msd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "msl": {
+ "sourceFiles": [
+ "msl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mso": {
+ "sourceFiles": [
+ "mso.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mta": {
+ "sourceFiles": [
+ "mta.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mth": {
+ "sourceFiles": [
+ "mth.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mtr": {
+ "sourceFiles": [
+ "mtr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mud": {
+ "sourceFiles": [
+ "mud.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 9,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "mwr": {
+ "sourceFiles": [
+ "mwr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 9,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "net": {
+ "sourceFiles": [
+ "fungusgrove_chr.s3d",
+ "griegsend_chr.s3d",
+ "netherbian_chr.s3d",
+ "sseru_chr.s3d",
+ "umbral_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ngm": {
+ "sourceFiles": [
+ "hateplane_chr.s3d",
+ "nektulos_chr.s3d",
+ "neriaka_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "overthere_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "nin": {
+ "sourceFiles": [
+ "nin_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ogf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalogf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ogm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globalogm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "okf": {
+ "sourceFiles": [
+ "feerrott_chr.s3d",
+ "innothule_chr.s3d",
+ "oggok_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "okm": {
+ "sourceFiles": [
+ "feerrott_chr.s3d",
+ "innothule_chr.s3d",
+ "oggok_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "onm": {
+ "sourceFiles": [
+ "onm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "opf": {
+ "sourceFiles": [
+ "opf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "opm": {
+ "sourceFiles": [
+ "opm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "orb": {
+ "sourceFiles": [
+ "orb.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "orc": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "befallen_chr.s3d",
+ "blackburrow_chr.s3d",
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "commons_chr.s3d",
+ "crushbone_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "ecommons_chr.s3d",
+ "everfrost_chr.s3d",
+ "felwithea_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "gfaydark_chr.s3d",
+ "highkeep_chr.s3d",
+ "kaladima_chr.s3d",
+ "kaladimb_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lfaydark_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "neriaka_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paw_chr.s3d",
+ "permafrost_chr.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "rathemtn_chr.s3d",
+ "rivervale_chr.s3d",
+ "runnyeye_chr.s3d",
+ "sro_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ork": {
+ "sourceFiles": [
+ "ork.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 15,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 15,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "otm": {
+ "sourceFiles": [
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "paf": {
+ "sourceFiles": [
+ "paf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "pg3": {
+ "sourceFiles": [
+ "pg3.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "phx": {
+ "sourceFiles": [
+ "poair_chr.s3d",
+ "pofire_chr.s3d",
+ "potimeb_chr.s3d",
+ "solrotower_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "pif": {
+ "sourceFiles": [
+ "airplane_chr.s3d",
+ "erudsxing_chr.s3d",
+ "felwithea_chr.s3d",
+ "felwitheb_chr.s3d",
+ "gfaydark_chr.s3d",
+ "lfaydark_chr.s3d",
+ "poknowledge_chr.s3d",
+ "qrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "tox_chr.s3d",
+ "wakening_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "pma": {
+ "sourceFiles": [
+ "pma.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 7,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "pre": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudsxing_chr.s3d",
+ "freporte_chr.s3d",
+ "oot_chr.s3d",
+ "overthere_chr.s3d",
+ "qeynos_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "prt": {
+ "sourceFiles": [
+ "prt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "pum": {
+ "sourceFiles": [
+ "beholder_chr.s3d",
+ "cauldron_chr.s3d",
+ "commons_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "ecommons_chr.s3d",
+ "everfrost_chr.s3d",
+ "growthplane_chr.s3d",
+ "halas_chr.s3d",
+ "iceclad_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "kerraridge_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "potimea_chr.s3d",
+ "pum_chr.s3d",
+ "rathemtn_chr.s3d",
+ "sro_chr.s3d",
+ "steamfont_chr.s3d",
+ "stonebrunt_chr.s3d",
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "qcf": {
+ "sourceFiles": [
+ "commons_chr.s3d",
+ "droga_chr.s3d",
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "highkeep_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lavastorm_chr.s3d",
+ "mistmoore_chr.s3d",
+ "neriaka_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "oot_chr.s3d",
+ "qcat_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "rathemtn_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "qcm": {
+ "sourceFiles": [
+ "commons_chr.s3d",
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "erudsxing_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "highkeep_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lavastorm_chr.s3d",
+ "neriaka_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "oot_chr.s3d",
+ "qcat_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "rathemtn_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rak": {
+ "sourceFiles": [
+ "rak_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ral": {
+ "sourceFiles": [
+ "global2_chr.s3d",
+ "potactics_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rap": {
+ "sourceFiles": [
+ "cazicthule_chr2.s3d",
+ "rap_chr.s3d",
+ "skyshrine_chr2.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d",
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rat": {
+ "sourceFiles": [
+ "akanon_chr.s3d",
+ "befallen_chr.s3d",
+ "blackburrow_chr.s3d",
+ "cabwest_chr.s3d",
+ "cauldron_chr.s3d",
+ "crushbone_chr.s3d",
+ "feerrott_chr.s3d",
+ "felwithea_chr.s3d",
+ "freporte_chr.s3d",
+ "freportn_chr.s3d",
+ "freportw_chr.s3d",
+ "grobb_chr.s3d",
+ "guktop_chr.s3d",
+ "hateplane_chr.s3d",
+ "highkeep_chr.s3d",
+ "innothule_chr.s3d",
+ "kaladimb_chr.s3d",
+ "kerraridge_chr.s3d",
+ "kithicor_chr.s3d",
+ "mischiefplane_chr.s3d",
+ "misty_chr.s3d",
+ "necropolis_chr.s3d",
+ "neriaka_chr.s3d",
+ "neriakb_chr.s3d",
+ "neriakc_chr.s3d",
+ "nurga_chr.s3d",
+ "oggok_chr.s3d",
+ "paineel_chr.s3d",
+ "paw_chr.s3d",
+ "podisease_chr.s3d",
+ "poinnovation_chr.s3d",
+ "pojustice_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeynos_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "soldunga_chr.s3d",
+ "steamfont_chr.s3d",
+ "thurgadina_chr.s3d",
+ "tox_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rdg": {
+ "sourceFiles": [
+ "rdg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rem": {
+ "sourceFiles": [
+ "echo_chr.s3d",
+ "griegsend_chr.s3d",
+ "gunthak_chr.s3d",
+ "hohonora_chr.s3d",
+ "letalis_chr.s3d",
+ "mseru_chr.s3d",
+ "postorms_chr.s3d",
+ "rem_chr.s3d",
+ "sseru_chr.s3d",
+ "thedeep_chr.s3d",
+ "torgiran_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rhi": {
+ "sourceFiles": [
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rhp": {
+ "sourceFiles": [
+ "cazicthule_chr2.s3d",
+ "dawnshroud_chr.s3d",
+ "griegsend_chr.s3d",
+ "grimling_chr.s3d",
+ "hollowshade_chr.s3d",
+ "letalis_chr.s3d",
+ "mseru_chr.s3d",
+ "scarlet_chr.s3d",
+ "shadeweaver_chr.s3d",
+ "sharvahl_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rif": {
+ "sourceFiles": [
+ "misty_chr.s3d",
+ "rivervale_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rim": {
+ "sourceFiles": [
+ "mischiefplane_chr.s3d",
+ "misty_chr.s3d",
+ "rivervale_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rkp": {
+ "sourceFiles": [
+ "rkp.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rom": {
+ "sourceFiles": [
+ "rom_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rpt": {
+ "sourceFiles": [
+ "rpt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rtn": {
+ "sourceFiles": [
+ "rtn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "rzm": {
+ "sourceFiles": [
+ "hohonora_chr.s3d",
+ "potactics_chr.s3d",
+ "potimeb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sat": {
+ "sourceFiles": [
+ "sat.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "scc": {
+ "sourceFiles": [
+ "scc.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sco": {
+ "sourceFiles": [
+ "sco.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "scr": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "firiona_chr.s3d",
+ "postorms_chr.s3d",
+ "scr_chr.s3d",
+ "sharvahl_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "scu": {
+ "sourceFiles": [
+ "scu.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sdf": {
+ "sourceFiles": [
+ "paludal_chr.s3d",
+ "pojustice_chr.s3d",
+ "sdf_chr.s3d",
+ "twilight_chr.s3d",
+ "umbral_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sdm": {
+ "sourceFiles": [
+ "sdm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sed": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "powater_chr.s3d",
+ "sed_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sef": {
+ "sourceFiles": [
+ "sef_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "seg": {
+ "sourceFiles": [
+ "seg.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sem": {
+ "sourceFiles": [
+ "sem_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 4,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sha": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudsxing_chr.s3d",
+ "freporte_chr.s3d",
+ "kedge_chr.s3d",
+ "lakerathe_chr.s3d",
+ "oot_chr.s3d",
+ "powater_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos_chr.s3d",
+ "sirens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "shf": {
+ "sourceFiles": [
+ "griegsend_chr.s3d",
+ "ssratemple_chr.s3d",
+ "thedeep_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ship": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "erudnext_chr.s3d",
+ "erudsxing_chr.s3d",
+ "firiona_chr.s3d",
+ "freporte_chr.s3d",
+ "iceclad_chr.s3d",
+ "oot_chr.s3d",
+ "qeynos_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "shl": {
+ "sourceFiles": [
+ "shl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "shm": {
+ "sourceFiles": [
+ "shm_chr.s3d",
+ "ssratemple_chr.s3d",
+ "thedeep_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "shn": {
+ "sourceFiles": [
+ "echo_chr.s3d",
+ "griegsend_chr.s3d",
+ "pojustice_chr.s3d",
+ "potimea_chr.s3d",
+ "ssratemple_chr.s3d",
+ "thedeep_chr.s3d",
+ "thegrey_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "shs": {
+ "sourceFiles": [
+ "shs.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 5,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sif": {
+ "sourceFiles": [
+ "sif_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sim": {
+ "sourceFiles": [
+ "cabeast_chr.s3d",
+ "cabwest_chr.s3d",
+ "charasis_chr.s3d",
+ "chardok_chr.s3d",
+ "citymist_chr.s3d",
+ "dalnir_chr.s3d",
+ "droga_chr.s3d",
+ "emeraldjungle_chr.s3d",
+ "kaesora_chr.s3d",
+ "karnor_chr.s3d",
+ "kurn_chr.s3d",
+ "overthere_chr.s3d",
+ "pojustice_chr.s3d",
+ "sebilis_chr.s3d",
+ "sim_chr.s3d",
+ "thegrey_chr.s3d",
+ "trakanon_chr.s3d",
+ "veeshan_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sin": {
+ "sourceFiles": [
+ "sin.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sir": {
+ "sourceFiles": [
+ "sir_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ske": {
+ "sourceFiles": [
+ "global_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ski": {
+ "sourceFiles": [
+ "ski.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "skt": {
+ "sourceFiles": [
+ "skt_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "smd": {
+ "sourceFiles": [
+ "smd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "sna": {
+ "sourceFiles": [
+ "acrylia_chr.s3d",
+ "akanon_chr.s3d",
+ "beholder_chr.s3d",
+ "blackburrow_chr.s3d",
+ "butcher_chr.s3d",
+ "cauldron_chr.s3d",
+ "cazicthule_chr.s3d",
+ "commons_chr.s3d",
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "erudsxing_chr.s3d",
+ "feerrott_chr.s3d",
+ "freporte_chr.s3d",
+ "freportw_chr.s3d",
+ "innothule_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lavastorm_chr.s3d",
+ "misty_chr.s3d",
+ "necropolis_chr.s3d",
+ "nektulos_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paineel_chr.s3d",
+ "paw_chr.s3d",
+ "postorms_chr.s3d",
+ "qcat_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "runnyeye_chr.s3d",
+ "sro_chr.s3d",
+ "stonebrunt_chr.s3d",
+ "tox_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "snd": {
+ "sourceFiles": [
+ "snd.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "snk": {
+ "sourceFiles": [
+ "snk.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "spd": {
+ "sourceFiles": [
+ "codecay_chr.s3d",
+ "nightmareb_chr2.s3d",
+ "poair_chr.s3d",
+ "podisease_chr.s3d",
+ "poeartha_chr.s3d",
+ "pofire_chr.s3d",
+ "poinnovation_chr.s3d",
+ "ponightmare_chr.s3d",
+ "potimea_chr.s3d",
+ "potorment_chr.s3d",
+ "povalor_chr.s3d",
+ "spd_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "spi": {
+ "sourceFiles": [
+ "acrylia_chr.s3d",
+ "akanon_chr.s3d",
+ "beholder_chr.s3d",
+ "butcher_chr.s3d",
+ "cazicthule_chr.s3d",
+ "cazicthule_chr2.s3d",
+ "commons_chr.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "droga_chr.s3d",
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "erudsxing_chr.s3d",
+ "everfrost_chr.s3d",
+ "fearplane_chr.s3d",
+ "feerrott_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "freportn_chr.s3d",
+ "gfaydark_chr.s3d",
+ "grobb_chr2.s3d",
+ "gukbottom_chr.s3d",
+ "guktop_chr.s3d",
+ "hateplane_chr.s3d",
+ "hole_chr.s3d",
+ "kaesora_chr.s3d",
+ "kithicor_chr.s3d",
+ "lfaydark_chr.s3d",
+ "mistmoore_chr.s3d",
+ "misty_chr.s3d",
+ "najena_chr.s3d",
+ "necropolis_chr.s3d",
+ "nektulos_chr.s3d",
+ "nro_chr.s3d",
+ "nurga_chr.s3d",
+ "oasis_chr.s3d",
+ "permafrost_chr.s3d",
+ "qcat_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "skyshrine_chr.s3d",
+ "soldunga_chr.s3d",
+ "soldungb_chr.s3d",
+ "sro_chr.s3d",
+ "steamfont_chr.s3d",
+ "thurgadina_chr.s3d",
+ "tox_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "spq": {
+ "sourceFiles": [
+ "spq.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 8,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "spt": {
+ "sourceFiles": [
+ "spt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "spx": {
+ "sourceFiles": [
+ "spx.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "srk": {
+ "sourceFiles": [
+ "srk.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "srn": {
+ "sourceFiles": [
+ "srn.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "srw": {
+ "sourceFiles": [
+ "burningwood_chr.s3d",
+ "chardok_chr.s3d",
+ "citymist_chr.s3d",
+ "dalnir_chr.s3d",
+ "dreadlands_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "karnor_chr.s3d",
+ "lakeofillomen_chr.s3d",
+ "overthere_chr.s3d",
+ "pojustice_chr.s3d",
+ "poknowledge_chr.s3d",
+ "swampofnohope_chr.s3d",
+ "trakanon_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "stc": {
+ "sourceFiles": [
+ "kurn_chr.s3d",
+ "lakeofillomen_chr.s3d",
+ "overthere_chr.s3d",
+ "postorms_chr.s3d",
+ "stonebrunt_chr.s3d",
+ "trakanon_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "stg": {
+ "sourceFiles": [
+ "bothunder_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "greatdivide_chr.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "potactics_chr.s3d",
+ "skyshrine_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "velketor_chr.s3d",
+ "wakening_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "stm": {
+ "sourceFiles": [
+ "eastwastes_chr2.s3d",
+ "eastwastesshard_chr2.s3d",
+ "kael_chr.s3d",
+ "kaelshard_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "swc": {
+ "sourceFiles": [
+ "swc.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "swi": {
+ "sourceFiles": [
+ "swi.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "syn": {
+ "sourceFiles": [
+ "syn_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tar": {
+ "sourceFiles": [
+ "tar.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 8,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tbf": {
+ "sourceFiles": [
+ "tbf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tbl": {
+ "sourceFiles": [
+ "tbl_chr.s3d",
+ "veeshan_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tbm": {
+ "sourceFiles": [
+ "tbm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tef": {
+ "sourceFiles": [
+ "tef_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "teg": {
+ "sourceFiles": [
+ "akheva_chr.s3d",
+ "echo_chr.s3d",
+ "griegsend_chr.s3d",
+ "letalis_chr.s3d",
+ "maiden_chr.s3d",
+ "mseru_chr.s3d",
+ "netherbian_chr.s3d",
+ "scarlet_chr.s3d",
+ "shadeweaver_chr.s3d",
+ "tenebrous_chr.s3d",
+ "twilight_chr.s3d",
+ "umbral_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 8,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tel": {
+ "sourceFiles": [
+ "tel.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tem": {
+ "sourceFiles": [
+ "tem_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ten": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "blackburrow_chr.s3d",
+ "cazicthule_chr2.s3d",
+ "crystal_chr.s3d",
+ "crystalshard_chr.s3d",
+ "fearplane_chr.s3d",
+ "najena_chr.s3d",
+ "permafrost_chr.s3d",
+ "ten_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "unrest_chr.s3d",
+ "velketor_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tgl": {
+ "sourceFiles": [
+ "tgl_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tig": {
+ "sourceFiles": [
+ "arena_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "stonebrunt_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tnf": {
+ "sourceFiles": [
+ "tnf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tnm": {
+ "sourceFiles": [
+ "tnm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tnt": {
+ "sourceFiles": [
+ "tnt.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tpb": {
+ "sourceFiles": [
+ "tpb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tpl": {
+ "sourceFiles": [
+ "tpl.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tpn": {
+ "sourceFiles": [
+ "global6_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tra": {
+ "sourceFiles": [
+ "tra.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "trf": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaltrf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 3,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "trm": {
+ "sourceFiles": [
+ "global_chr.s3d",
+ "globaltrm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "trn": {
+ "sourceFiles": [
+ "jaggedpine_chr.s3d",
+ "poeartha_chr.s3d",
+ "ponightmare_chr.s3d",
+ "postorms_chr.s3d",
+ "trn_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "trw": {
+ "sourceFiles": [
+ "potactics_chr.s3d",
+ "potimeb_chr.s3d",
+ "potorment_chr.s3d",
+ "trw_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tse": {
+ "sourceFiles": [
+ "tse.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tsm": {
+ "sourceFiles": [
+ "tsm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "ttb": {
+ "sourceFiles": [
+ "ttb_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tzf": {
+ "sourceFiles": [
+ "tzf_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "tzm": {
+ "sourceFiles": [
+ "tzm_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "uni": {
+ "sourceFiles": [
+ "butcher_chr.s3d",
+ "cazicthule_chr.s3d",
+ "fearplane_chr.s3d",
+ "gfaydark_chr.s3d",
+ "growthplane_chr.s3d",
+ "lavastorm_chr.s3d",
+ "lfaydark_chr.s3d",
+ "mischiefplane_chr.s3d",
+ "mistmoore_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "qrg_chr.s3d",
+ "steamfont_chr.s3d",
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "unm": {
+ "sourceFiles": [
+ "unm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vaf": {
+ "sourceFiles": [
+ "vaf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vam": {
+ "sourceFiles": [
+ "vam.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vek": {
+ "sourceFiles": [
+ "vek_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vnm": {
+ "sourceFiles": [
+ "vnm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vpm": {
+ "sourceFiles": [
+ "griegsend_chr.s3d",
+ "katta_chr.s3d",
+ "maiden_chr.s3d",
+ "shadeweaver_chr.s3d",
+ "tenebrous_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "vrm": {
+ "sourceFiles": [
+ "mischiefplane_chr.s3d",
+ "necropolis_chr.s3d",
+ "pojustice_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wal": {
+ "sourceFiles": [
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "iceclad_chr.s3d",
+ "sirens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "was": {
+ "sourceFiles": [
+ "burningwood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wil": {
+ "sourceFiles": [
+ "airplane_chr.s3d",
+ "growthplane_chr.s3d",
+ "jaggedpine_chr.s3d",
+ "kurn_chr.s3d",
+ "sirens_chr.s3d",
+ "stonebrunt_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wlf": {
+ "sourceFiles": [
+ "wlf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wlm": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "eastwastes_chr.s3d",
+ "eastwastesshard_chr.s3d",
+ "greatdivide_chr.s3d",
+ "growthplane_chr.s3d",
+ "sirens_chr.s3d",
+ "stonebrunt_chr.s3d",
+ "thurgadinb_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 3,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "woe": {
+ "sourceFiles": [
+ "global6_chr.s3d",
+ "global_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wof": {
+ "sourceFiles": [
+ "chardok_chr.s3d",
+ "fieldofbone_chr.s3d",
+ "global6_chr.s3d",
+ "nurga_chr.s3d",
+ "warslikswood_chr.s3d",
+ "wof_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wok": {
+ "sourceFiles": [
+ "wok.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 2,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 2,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wol": {
+ "sourceFiles": [
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "everfrost_chr.s3d",
+ "feerrott_chr.s3d",
+ "firiona_chr.s3d",
+ "freporte_chr.s3d",
+ "freportw_chr.s3d",
+ "gfaydark_chr.s3d",
+ "global6_chr.s3d",
+ "growthplane_chr.s3d",
+ "halas_chr.s3d",
+ "kithicor_chr.s3d",
+ "lfaydark_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "paw_chr.s3d",
+ "permafrost_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "qrg_chr.s3d",
+ "rivervale_chr.s3d",
+ "southkarana_chr.s3d",
+ "sro_chr.s3d",
+ "warrens_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 3,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wrm": {
+ "sourceFiles": [
+ "wrm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 5,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wur": {
+ "sourceFiles": [
+ "eastwastes_chr2.s3d",
+ "eastwastesshard_chr2.s3d",
+ "greatdivide_chr.s3d",
+ "necropolis_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d",
+ "westwastes_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wwf": {
+ "sourceFiles": [
+ "wwf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 4,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wyr": {
+ "sourceFiles": [
+ "wyr.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 6,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "wyv": {
+ "sourceFiles": [
+ "cobaltscar_chr.s3d",
+ "eastwastes_chr2.s3d",
+ "eastwastesshard_chr2.s3d",
+ "necropolis_chr.s3d",
+ "skyshrine_chr.s3d",
+ "sleeper_chr.s3d",
+ "sleeper_chr2.s3d",
+ "solrotower_chr.s3d",
+ "templeveeshan_chr.s3d",
+ "veeshan_chr.s3d",
+ "westwastes_chr.s3d",
+ "wyv_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "xef": {
+ "sourceFiles": [
+ "xef.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "xem": {
+ "sourceFiles": [
+ "xem.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "xhf": {
+ "sourceFiles": [
+ "xhf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "xhm": {
+ "sourceFiles": [
+ "xhm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "yak": {
+ "sourceFiles": [
+ "greatdivide_chr.s3d",
+ "growthplane_chr.s3d",
+ "poknowledge_chr.s3d",
+ "wakening_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "yet": {
+ "sourceFiles": [
+ "charasis_chr.s3d",
+ "dreadlands_chr.s3d",
+ "frontiermtns_chr.s3d",
+ "timorous_chr.s3d",
+ "warslikswood_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "zmf": {
+ "sourceFiles": [
+ "zmf.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "zmm": {
+ "sourceFiles": [
+ "zmm.eqg"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "zof": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "commons_chr.s3d",
+ "ecommons_chr.s3d",
+ "fearplane_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "halas_chr.s3d",
+ "hateplane_chr.s3d",
+ "hole_chr.s3d",
+ "innothule_chr.s3d",
+ "katta_chr.s3d",
+ "kithicor_chr.s3d",
+ "mistmoore_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "neriakb_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paineel_chr.s3d",
+ "rathemtn_chr.s3d",
+ "sro_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ },
+ "zom": {
+ "sourceFiles": [
+ "befallen_chr.s3d",
+ "commons_chr.s3d",
+ "eastkarana_chr.s3d",
+ "ecommons_chr.s3d",
+ "erudsxing_chr.s3d",
+ "fearplane_chr.s3d",
+ "feerrott_chr.s3d",
+ "frozenshadow_chr.s3d",
+ "halas_chr.s3d",
+ "hateplane_chr.s3d",
+ "hole_chr.s3d",
+ "innothule_chr.s3d",
+ "katta_chr.s3d",
+ "kithicor_chr.s3d",
+ "lakerathe_chr.s3d",
+ "lfaydark_chr.s3d",
+ "mistmoore_chr.s3d",
+ "misty_chr.s3d",
+ "nektulos_chr.s3d",
+ "neriakb_chr.s3d",
+ "northkarana_chr.s3d",
+ "nro_chr.s3d",
+ "oasis_chr.s3d",
+ "paineel_chr.s3d",
+ "paw_chr.s3d",
+ "qcat_chr.s3d",
+ "qey2hh1_chr.s3d",
+ "qeynos2_chr.s3d",
+ "qeytoqrg_chr.s3d",
+ "rathemtn_chr.s3d",
+ "southkarana_chr.s3d",
+ "sro_chr.s3d",
+ "unrest_chr.s3d"
+ ],
+ "minTexture": 0,
+ "maxTexture": 0,
+ "minHelmTexture": 0,
+ "maxHelmTexture": 0,
+ "minHair": 0,
+ "maxHair": 0,
+ "minBeards": 0,
+ "maxBeards": 0
+ }
+}
diff --git a/frontend/eqsage-embed/src/viewer/common/raceModelResolution.js b/frontend/eqsage-embed/src/viewer/common/raceModelResolution.js
new file mode 100644
index 00000000..c8c0b200
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/common/raceModelResolution.js
@@ -0,0 +1,224 @@
+export const PREVIEW_MODEL_ALIASES = {
+ b01: 'launch',
+ b02: 'launch',
+ b03: 'launch',
+ bon: 'lskmesh',
+ box: 'box01',
+ brc: 'gnm',
+ bse: 'ghu',
+ cro: 'croc',
+ cwb: 'cwbclockwork',
+ cwg: 'gnm',
+ cat: 'pum',
+ dkf: 'huf',
+ dkm: 'hum',
+ epf: 'huf',
+ eye: 'eey',
+ fgg: 'frg',
+ gbn: 'gob',
+ ggy: 'gar',
+ glc: 'glcgiantclockwork',
+ ibr: 'ibrbrute',
+ iks: 'ikm',
+ ivf: 'huf',
+ ivm: 'hum',
+ i17: 'wagon',
+ iwh: 'iwhpolysurf01',
+ kbd: 'kob',
+ mnr: 'min',
+ mcr: 'rat',
+ nym: 'huf',
+ obj_blimp: 'obp_mansionisland',
+ obp_meldrath: 'obj_island',
+ pre: 'launch',
+ rtn: 'rat',
+ rpf: 'frg',
+ ship: 'launch',
+ ske: 'lskmesh',
+ skl: 'lskmesh',
+ sne: 'sna',
+ spw: 'wol',
+ tac: 'tacnew',
+ tar: 'spi',
+ tin: 'tindefault',
+ tpn: 'tpnbod',
+ wer: 'wolmesh',
+ wol: 'wolmesh',
+ wom: 'wolmesh',
+ wur: 'wrm',
+};
+
+export const PREVIEW_ALIAS_FIRST_MODELS = new Set([
+ 'abh',
+ 'akf',
+ 'ala',
+ 'alg',
+ 'all',
+ 'alr',
+ 'arm',
+ 'avi',
+ 'b01',
+ 'b02',
+ 'b03',
+ 'bac',
+ 'bar',
+ 'bea',
+ 'ber',
+ 'bff',
+ 'bfr',
+ 'bgf',
+ 'bgg',
+ 'bgm',
+ 'boat',
+ 'brc',
+ 'brf',
+ 'bri',
+ 'frf',
+ 'shf',
+ 'pre',
+ 'ivf',
+ 'ivm',
+]);
+
+// Race definitions can outlive the art shipped by a particular EverQuest
+// client. Keep those spawns visible with a full, semantically close asset and
+// report the substitution distinctly in the audit.
+export const PREVIEW_CLIENT_FALLBACKS = {
+ // Human review found several valid race definitions whose exact art is
+ // absent or structurally incomplete in commonly shipped client archives.
+ // Keep the review/zone surfaces useful with a visible semantic substitute;
+ // the resolved-asset evidence still reports every substitution.
+ abh: 'ghu',
+ akf: 'ahf',
+ ala: 'hum',
+ alg: 'ghu',
+ all: 'croc',
+ alr: 'croc',
+ arm: 'rat',
+ avi: 'avk',
+ bac: 'fis',
+ bar: 'pbr',
+ bea: 'pbr',
+ ber: 'btx',
+ bff: 'huf',
+ bfr: 'hum',
+ bgf: 'huf',
+ bgg: 'gob',
+ bgm: 'hum',
+ boat: 'launch',
+ brf: 'bnm',
+ bri: 'hum',
+ // FRF has an open, back-face-culled head, while SHF collapses around its
+ // bind pose unless it is animated by an incompatible skeleton.
+ cla: 'obj_sc_shella',
+ dia: 'trn',
+ dlk: 'drk',
+ ecs: 'hum',
+ fan: 'hum',
+ frf: 'frm',
+ hip: 'grf',
+ hyd: 'mhy',
+ iwb: 'iwm',
+ mar: 'hum',
+ msc: 'mhy',
+ ont: 'box',
+ ppoint: 'tpn',
+ pys: 'potranqrock500',
+ ron: 'hum',
+ shf: 'shm',
+ s01: 'tpn',
+ scw: 'wol',
+ srv: 'hum',
+ trq: 'hum',
+ uvk: 'gvk',
+};
+
+// Some legacy race codes live only inside a zone's character archive. The
+// content inventory does not associate those older archives with every race,
+// so previewing the NPC outside that zone needs a small source index.
+export const PREVIEW_MODEL_SOURCE_FILES = {
+ amp: ['poair_chr.s3d'],
+ btp: ['bothunder_chr.s3d'],
+ bub: ['codecay_chr.s3d', 'podisease_chr.s3d'],
+ buu: ['codecay_chr.s3d', 'podisease_chr.s3d'],
+ coc: ['dreadlands_chr.s3d'],
+ com: ['greatdivide_chr.s3d'],
+ eye: ['lavastorm_chr.s3d'],
+ fen: ['pofire_chr.s3d'],
+ fsg: ['greatdivide_chr.s3d'],
+ goj: ['pojustice_chr.s3d'],
+ hag: ['frozenshadow_chr.s3d'],
+ // KOB is copied into many zone archives, but most copies contain geometry
+ // without its mapped WER tracks. PoKnowledge carries the KOB skeleton plus
+ // the complete nine-animation WER track set, so it must win deterministic
+ // on-demand generation.
+ kob: ['poknowledge_chr.s3d'],
+ nmp: ['ponightmare_chr.s3d'],
+ nmw: ['ponightmare_chr.s3d'],
+ npt: ['ponightmare_chr.s3d'],
+ mar: ['potimeb_chr.s3d', 'potimeb_chr2.s3d'],
+ qzt: ['poair_chr.s3d'],
+ rth: ['poearthb_chr.s3d'],
+ ser: ['sseru_chr.s3d'],
+ srv: ['pofire_chr.s3d', 'solrotower_chr.s3d'],
+ wet: ['ponightmare_chr.s3d'],
+ xeg: ['poair_chr.s3d'],
+ zeb: ['potimeb_chr.s3d', 'potimeb_chr2.s3d'],
+};
+
+export const getCharacterBodyModelVariation = (modelName, texture) => {
+ const normalizedModelName = `${modelName ?? ''}`.trim().toLowerCase();
+ const normalizedTexture = Number(texture);
+ if (!normalizedModelName || !Number.isFinite(normalizedTexture) || normalizedTexture < 10) {
+ return normalizedModelName;
+ }
+ return `${normalizedModelName}${Number(`${Math.trunc(normalizedTexture)}`[0])
+ .toString()
+ .padStart(2, '0')}`;
+};
+
+export const getCharacterArchiveBaseModelName = (modelName) => {
+ const normalizedModelName = `${modelName ?? ''}`.trim().toLowerCase();
+ return normalizedModelName.match(
+ /^([a-z0-9]{3})(?:(?:he)?\d{2})$/
+ )?.[1] ?? normalizedModelName;
+};
+
+export const getCharacterSourceFamilyStem = (sourceFile) =>
+ `${sourceFile ?? ''}`.trim().toLowerCase().match(
+ /^(.*_chr)\d*\.s3d$/
+ )?.[1] ?? null;
+
+export const orderCharacterModelSourceFiles = (modelName, sourceFiles = []) => {
+ const normalizedModelName = `${modelName ?? ''}`.trim().toLowerCase();
+ const preferredSourceOrder = new Map(
+ (PREVIEW_MODEL_SOURCE_FILES[normalizedModelName] ?? []).map(
+ (sourceFile, index) => [`${sourceFile}`.toLowerCase(), index]
+ )
+ );
+ const dedicatedSourcePattern = normalizedModelName
+ // Classic player races store their authoritative assets in archives such
+ // as globalhum_chr.s3d and globaldam_chr.s3d. Treat those exactly like a
+ // race-local sdf_chr.s3d archive; otherwise generic global_chr.s3d can win
+ // first and leave valid body regions backed by missing placeholder skins.
+ ? new RegExp(`^(?:global)?${normalizedModelName}_chr\\d*\\.s3d$`, 'i')
+ : null;
+ return Array.from(new Set(sourceFiles
+ .map((sourceFile) => `${sourceFile ?? ''}`.trim().toLowerCase())
+ .filter(Boolean)))
+ .sort((left, right) => {
+ const leftDedicated = dedicatedSourcePattern?.test(left) === true;
+ const rightDedicated = dedicatedSourcePattern?.test(right) === true;
+ if (leftDedicated !== rightDedicated) {
+ return leftDedicated ? -1 : 1;
+ }
+ const leftPreferred = preferredSourceOrder.get(left);
+ const rightPreferred = preferredSourceOrder.get(right);
+ if (leftPreferred !== undefined || rightPreferred !== undefined) {
+ if (leftPreferred === undefined) return 1;
+ if (rightPreferred === undefined) return -1;
+ if (leftPreferred !== rightPreferred) return leftPreferred - rightPreferred;
+ }
+ return left.localeCompare(right, undefined, { numeric: true });
+ });
+};
diff --git a/frontend/eqsage-embed/src/viewer/controllers/GameController.js b/frontend/eqsage-embed/src/viewer/controllers/GameController.js
index 9524ff66..88d72496 100644
--- a/frontend/eqsage-embed/src/viewer/controllers/GameController.js
+++ b/frontend/eqsage-embed/src/viewer/controllers/GameController.js
@@ -6,9 +6,15 @@ import { skyController } from './SkyController';
import { zoneController } from './ZoneController';
import { GlobalStore } from '../../state';
-import { getEQDir, getEQFile, getFiles } from 'sage-core/util/fileHandler';
+import {
+ getEQDir,
+ getEQFile,
+ getEQFileDirectoryRevision,
+ getFiles,
+} from 'sage-core/util/fileHandler';
import { assetUrl } from '../../embed-config';
import { textureAnimationMap } from '../helpers/textureAnimationMap';
+import { getCharacterHeadOrientationPolicy } from 'sage-core/util/character-texture-orientation';
const { Engine, ThinEngine, WebGPUEngine, Database, SceneLoader, GLTFLoader } =
BABYLON;
@@ -83,6 +89,8 @@ const textureAliasCache = new Map();
const missingTextureWarnings = new Set();
let textureFileIndexPromise = null;
let textureFileIndex = null;
+let textureFileIndexPromiseRevision = -1;
+let textureFileIndexRevision = -1;
const patchedGltfLoaders = new WeakSet();
// Keep Sage's normal exact texture lookup as the first candidate. These
@@ -179,7 +187,8 @@ const buildTextureCandidates = (rawName, { allowCharacterFallbacks = true } = {}
const getTextureMimeType = (name) =>
name.endsWith('.jpg') || name.endsWith('.jpeg') ? 'image/jpeg' : 'image/png';
-const isIgnorableMissingTexture = (name) => /^m000\d+$/i.test(`${name ?? ''}`);
+const isIgnorableMissingTexture = (name) =>
+ /^(?:m000\d+|none)$/i.test(`${name ?? ''}`);
const fallbackPngData = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
@@ -225,7 +234,10 @@ const isTinyPngTexture = (data, fileName = '') => {
const getCharacterSkinTextureCandidates = (rawName) => {
const base = textureBaseName(rawName).toLowerCase().replace(/\.\w+$/, '');
- const match = base.match(/^([a-z0-9]{3})([a-z]{2})(00)(\d{2})$/);
+ // Classic character archives use small fully-transparent numeric textures
+ // as "show skin here" sentinels. This applies to armor variants as well as
+ // texture 00, so resolve every numeric variant back to the matching SK slot.
+ const match = base.match(/^([a-z0-9]{3})([a-z]{2})(\d{2})(\d{2})$/);
if (!match) {
return [];
}
@@ -239,6 +251,7 @@ const getCharacterSkinTextureCandidates = (rawName) => {
for (const prefix of prefixes) {
const texturePrefix = `${prefix}${part}`;
candidates.push(`${texturePrefix}sk${slot}`);
+ candidates.push(`${texturePrefix}00${slot}`);
candidates.push(`${texturePrefix}sk01`);
}
return candidates.filter(
@@ -391,16 +404,51 @@ const hydrateMissingGlbImages = async (url, fileBuffer) => {
gltf.buffers = gltf.buffers?.length ? gltf.buffers : [{ byteLength: 0 }];
gltf.bufferViews = gltf.bufferViews ?? [];
+ const usedMaterialIndices = new Set(
+ (gltf.meshes ?? []).flatMap((mesh) =>
+ (mesh.primitives ?? [])
+ .map((primitive) => primitive.material)
+ .filter(Number.isInteger)
+ )
+ );
+ const usedTextureIndices = new Set();
+ const collectTextureIndices = (value, propertyName = '') => {
+ if (!value || typeof value !== 'object') {
+ return;
+ }
+ if (
+ /texture$/i.test(propertyName) &&
+ Number.isInteger(value.index)
+ ) {
+ usedTextureIndices.add(value.index);
+ }
+ for (const [key, child] of Object.entries(value)) {
+ collectTextureIndices(child, key);
+ }
+ };
+ for (const materialIndex of usedMaterialIndices) {
+ collectTextureIndices(gltf.materials?.[materialIndex]);
+ }
+ const usedImageIndices = new Set(
+ Array.from(usedTextureIndices)
+ .map((textureIndex) => gltf.textures?.[textureIndex]?.source)
+ .filter(Number.isInteger)
+ );
const binParts = [binChunk.data];
let binLength = binChunk.data.byteLength;
let hydrated = false;
- for (const image of images) {
+ for (const [imageIndex, image] of images.entries()) {
if (image.uri || image.bufferView !== undefined) {
continue;
}
const textureName = image.name ?? image.extras?.name;
+ if (!textureName) {
+ // Older EQG exports retained unreferenced image records for numeric
+ // material properties. They are not renderable texture slots.
+ continue;
+ }
const resolvedTexture = await getTextureFileData(textureName, {
allowCharacterFallbacks: /\/eq\/models\//i.test(url),
});
@@ -419,7 +467,7 @@ const hydrateMissingGlbImages = async (url, fileBuffer) => {
if (!textureData) {
continue;
}
- if (usedPreviewFallback) {
+ if (usedPreviewFallback && usedImageIndices.has(imageIndex)) {
missingPreviewTextures.push(textureName ?? 'missing-texture');
}
@@ -656,10 +704,15 @@ const normalizeMissingGlbImageUris = async (url, fileBuffer) => {
};
const getTextureFileIndex = async () => {
- if (textureFileIndex) {
+ const currentRevision = getEQFileDirectoryRevision('textures');
+ if (textureFileIndex && textureFileIndexRevision === currentRevision) {
return textureFileIndex;
}
- if (!textureFileIndexPromise) {
+ if (
+ !textureFileIndexPromise ||
+ textureFileIndexPromiseRevision !== currentRevision
+ ) {
+ textureFileIndexPromiseRevision = currentRevision;
textureFileIndexPromise = (async () => {
const index = new Map();
const textureDir = await getEQDir('textures');
@@ -683,11 +736,17 @@ const getTextureFileIndex = async () => {
return index;
})()
.then((index) => {
- textureFileIndex = index;
+ if (getEQFileDirectoryRevision('textures') === currentRevision) {
+ textureFileIndex = index;
+ textureFileIndexRevision = currentRevision;
+ }
return index;
})
.finally(() => {
- textureFileIndexPromise = null;
+ if (textureFileIndexPromiseRevision === currentRevision) {
+ textureFileIndexPromise = null;
+ textureFileIndexPromiseRevision = -1;
+ }
});
}
@@ -993,10 +1052,20 @@ export class GameController {
creationFlags,
useSRGBBuffer
) {
- const doFlip =
- controller.ZoneBuilderController?.scene ||
- (!url?.includes('eq/models') && !/\w+\d{4}/.test(url));
- return origCreate.call(
+ const normalizedTextureName = `${url ?? ''}`
+ .split(/[\\/]/)
+ .pop()
+ ?.replace(/\s+\(Base Color\)$/i, '')
+ .replace(/\.(?:png|dds|bmp)$/i, '');
+ const headOrientation =
+ getCharacterHeadOrientationPolicy(normalizedTextureName);
+ const isInlineTextureUrl = /^(?:blob:|data:)/i.test(`${url ?? ''}`);
+ const doFlip = headOrientation.isCharacterHead || isInlineTextureUrl
+ ? Boolean(_invertY)
+ : controller.ZoneBuilderController?.scene ||
+ (!url?.includes('eq/models') &&
+ !/\w+(?:\d{4}|sk\d{2})(?:\s+\(Base Color\))?$/i.test(url));
+ const internalTexture = origCreate.call(
this,
url,
noMipmap,
@@ -1014,6 +1083,12 @@ export class GameController {
creationFlags,
useSRGBBuffer
);
+ if (internalTexture) {
+ internalTexture._spireSageRequestedInvertY = Boolean(_invertY);
+ internalTexture._spireSageUploadInvertY = doFlip;
+ internalTexture._spireSageTextureName = normalizedTextureName;
+ }
+ return internalTexture;
};
// Override DB factory
diff --git a/frontend/eqsage-embed/src/viewer/controllers/SpawnController.js b/frontend/eqsage-embed/src/viewer/controllers/SpawnController.js
index 370d5c44..580740c0 100644
--- a/frontend/eqsage-embed/src/viewer/controllers/SpawnController.js
+++ b/frontend/eqsage-embed/src/viewer/controllers/SpawnController.js
@@ -1,6 +1,13 @@
import BABYLON from '@bjs';
+import { AnimationGroup } from '@babylonjs/core/Animations/animationGroup';
import assimpjs from '../../modules/assimp';
import raceData from '../common/raceData.json';
+import {
+ getCharacterArchiveBaseModelName,
+ PREVIEW_ALIAS_FIRST_MODELS,
+ PREVIEW_CLIENT_FALLBACKS,
+ PREVIEW_MODEL_ALIASES,
+} from '../common/raceModelResolution';
import { GameControllerChild } from './GameControllerChild';
import { BabylonSpawn } from '../models/BabylonSpawn';
import { GlobalStore } from '../../state';
@@ -8,10 +15,19 @@ import { getEQFile, getEQFileExists } from 'sage-core/util/fileHandler';
import {
GLOBAL_VERSION,
PREVIEW_CHARACTER_CACHE_VERSION,
+ processCharacterModelArchive,
processGlobal,
} from '../../components/zone/processZone';
import { locateStaticAsset } from '../../static-assets';
import { createGltfTransformIo, loadGltfTransformModules } from '../../util/gltf-transform';
+import {
+ inspectAnimationGroupVitality,
+ inspectAnimationSetVitality,
+} from '../helpers/animationValidation';
+import { getCharacterHeadOrientationPolicy } from 'sage-core/util/character-texture-orientation';
+import {
+ PREVIEW_CHARACTER_MODEL_CACHE_VERSION,
+} from 'sage-core/model/constants';
const {
AbstractMesh,
@@ -39,20 +55,113 @@ const isUsableWorldY = (value) =>
Number.isFinite(value) && Math.abs(value) < 1000000;
const POSE_ANIMATION_PATTERN = /^(?:Clone of )?pos$/i;
+const normalizeAnimationTargetName = (value) =>
+ `${value ?? ''}`.replace(/^Clone of /, '').trim().toLowerCase();
const POST_LOAD_GROUND_CLEARANCE = 0.03;
+const T_POSE_VALIDATION_EXCLUDED_MODELS = new Set(['tpf', 'tpm', 'tpn']);
+const COMPACT_NATIVE_ARM_NORMALIZATION_MODELS = new Set([
+ 'qcf',
+ 'clm',
+ 'clf',
+]);
+
+// These generated files exist but cannot be rendered. Prefer their explicit
+// semantic alias so each zone load does not pay for a guaranteed GLB failure.
+const PREVIEW_OBJECT_MODEL_ALIASES = {
+ brl: 'obj_barrel_wood_',
+};
+
+const PREVIEW_ANIMATION_DONORS = {
+ // These classic guard/brownie variants ship with geometry and POS only.
+ // Each mapping is based on the actual named skeleton, not its bone count:
+ // BGM is an exact 25-bone match for BRM/FEM/GFM and GEF is an exact
+ // 24-bone match for FEF/GFF.
+ brm: 'bgm',
+ fef: 'gef',
+ fem: 'bgm',
+ gff: 'gef',
+ gfm: 'bgm',
+ // The female Shade archive contains geometry and its bind pose but no
+ // playable clips. SDM has the identical 52-node skeleton, so use its clips
+ // rather than leaving SDF collapsed in the imported bind pose.
+ sdf: 'sdm',
+ // Neutral Shissar geometry likewise ships with only POS. SHM has the exact
+ // same 74 named bones and supplies the playable movement set. SHF uses SHM
+ // as a complete model fallback because retargeting its collapsed source
+ // geometry deforms the body.
+ shn: 'shm',
+};
-const PREVIEW_MODEL_ALIASES = {
- iks: 'ikm',
- ivf: 'huf',
- ivm: 'hum',
- pre: 'launch',
- ship: 'launch',
- ske: 'lskmesh',
- tpn: 'tpnbod',
- wol: 'wolmesh',
- wom: 'wolmesh',
+// QA escape hatch for comparing a bind-pose-only character against its
+// retargeted preview animation. This is intentionally opt-in and only honored
+// by Sage's validation preview, so normal zone rendering cannot silently lose
+// animation coverage.
+const previewAnimationDonorDisabled = () =>
+ typeof window !== 'undefined' &&
+ !!window.__spireSagePreview &&
+ new URLSearchParams(window.location.search).get(
+ 'sageRaceFacePreviewDisableDonor'
+ ) === '1';
+
+// Cross-race animation donors may use the same semantic head/neck bone names
+// with different local bind axes. Applying their quaternion delta can turn an
+// otherwise valid face upside down even when every target resolves. Keep the
+// target model's own head chain orientation; torso and limb rotations still
+// provide the visible idle/movement pose that removes the T-pose.
+const PREVIEW_CHARACTER_HEAD_MATERIAL_PATTERN =
+ /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
+const isPreviewHeadOrNeckRotationTarget = (name) =>
+ /^(?:he|head|hehead|head_point|ne|neck|neneck\d*)$/i.test(`${name ?? ''}`);
+const hasSemanticPreviewHeadTarget = (names) =>
+ ['he', 'head', 'hehead'].some((name) => names.has(name));
+
+const getPreviewHeadInfluencingBoneNames = (container) => {
+ const names = new Set();
+ for (const mesh of container?.meshes ?? []) {
+ const material = mesh?.material;
+ const materials = Array.isArray(material?.subMaterials)
+ ? material.subMaterials
+ : [material];
+ const isHeadMesh = materials.some((candidate) =>
+ PREVIEW_CHARACTER_HEAD_MATERIAL_PATTERN.test(
+ `${candidate?.name ?? ''}`.replace(/_mdf.*$/i, '')
+ )
+ );
+ if (!isHeadMesh) {
+ continue;
+ }
+ const skeleton = mesh?.skeleton ?? container?.skeletons?.[0];
+ const bones = skeleton?.bones ?? [];
+ const addWeightedBoneIndices = (indices = [], weights = []) => {
+ indices = indices ?? [];
+ weights = weights ?? [];
+ const count = Math.min(indices.length, weights.length);
+ for (let index = 0; index < count; index++) {
+ if (Number(weights[index]) <= 0) {
+ continue;
+ }
+ const boneName = normalizeAnimationTargetName(
+ bones[Number(indices[index])]?.name
+ );
+ if (boneName) {
+ names.add(boneName);
+ }
+ }
+ };
+ addWeightedBoneIndices(
+ mesh.getVerticesData?.('matricesIndices'),
+ mesh.getVerticesData?.('matricesWeights')
+ );
+ addWeightedBoneIndices(
+ mesh.getVerticesData?.('matricesIndicesExtra'),
+ mesh.getVerticesData?.('matricesWeightsExtra')
+ );
+ }
+ return names;
};
+const INTENTIONAL_SOLID_TEXTURES = new Set(['DERCH0001']);
+
/**
* @typedef {import('@babylonjs/core').AssetContainer} AssetContainer
*/
@@ -69,9 +178,11 @@ class SpawnController extends GameControllerChild {
* @type {Object.}
*/
assetContainers = {};
+ modelAvailabilityPromises = {};
assetFallbacks = {};
missingAssets = {};
+ resolvedModelAssets = {};
/**
* @type {Mesh}
@@ -112,6 +223,7 @@ class SpawnController extends GameControllerChild {
dispose() {
this.spawnLoadToken++;
this.assetContainers = {};
+ this.modelAvailabilityPromises = {};
this.clearPostLoadGroundSnapTimers();
this.clearSpawnSelection();
if (this.currentScene) {
@@ -203,6 +315,7 @@ class SpawnController extends GameControllerChild {
}
clearSpawnSelection({ clearPath = true } = {}) {
+ this.spawns[this.selectedSpawnId]?.demoteSelectedLiveAnimation?.();
this.selectedSpawnId = null;
if (this.targetRing) {
this.ZoneController?.glowLayer?.removeIncludedOnlyMesh?.(this.targetRing);
@@ -362,12 +475,20 @@ class SpawnController extends GameControllerChild {
if (!spawn || spawn.gridIdx !== undefined) {
return;
}
- this.selectedSpawnId =
+ const nextSelectedSpawnId =
spawn.__spireSpawnId ??
spawn.id ??
spawn.spawn2_id ??
spawn.spawn_id ??
null;
+ if (this.selectedSpawnId !== nextSelectedSpawnId) {
+ this.spawns[this.selectedSpawnId]?.demoteSelectedLiveAnimation?.();
+ }
+ this.selectedSpawnId = nextSelectedSpawnId;
+ const selectedVisual = this.getSpawnVisual(spawn);
+ if (selectedVisual?.staticPreviewPoseApplied) {
+ void selectedVisual.promoteToLiveAnimation?.();
+ }
this.showTargetRing(spawn);
if (showPath) {
this.showSpawnPath(spawn.pathgrid ? spawn.grid ?? [] : []);
@@ -728,7 +849,10 @@ class SpawnController extends GameControllerChild {
* @returns {Promise}
*/
async loadAssetContainerFromEQ(folder, file) {
- const fileBuffer = await this.gc.loadEQGltfFile?.(folder, file);
+ let fileBuffer = await this.gc.loadEQGltfFile?.(folder, file);
+ if (!fileBuffer && folder === 'models') {
+ fileBuffer = await this.gc.loadEQGltfFile?.('objects', file);
+ }
if (!fileBuffer) {
return null;
}
@@ -758,13 +882,183 @@ class SpawnController extends GameControllerChild {
return this.assetContainers[key];
}
+ getStaleHeadOrientationMaterials(container) {
+ return (container?.materials ?? []).filter((material) => {
+ const policy = getCharacterHeadOrientationPolicy(material?.name);
+ if (!policy.isCharacterHead) {
+ return false;
+ }
+ const extras =
+ material?.metadata?.gltf?.extras ??
+ material?.metadata?.extras ??
+ material?.metadata ??
+ {};
+ const exportedFlip = extras.spireSkinnedVFlipped;
+ // Older GLBs did not record orientation metadata. Some of those files
+ // already contain the correct conversion, so only an explicit mismatch
+ // is safe to invalidate automatically.
+ return (
+ typeof exportedFlip === 'boolean' &&
+ exportedFlip !== policy.geometryUvFlipped
+ );
+ });
+ }
+
+ getCharacterContainerExtras(container) {
+ return [
+ ...(container?.rootNodes ?? []),
+ ...(container?.rootNodes ?? []).flatMap(
+ (root) => root.getDescendants?.(false) ?? []
+ ),
+ ].map((node) =>
+ node?.metadata?.gltf?.extras ??
+ node?.metadata?.extras ??
+ node?.metadata ??
+ {}
+ );
+ }
+
+ getStaleCharacterModelPolicy(container, file) {
+ const modelName = `${file ?? ''}`.replace(/\.glb$/i, '').toLowerCase();
+ if (
+ !window.__spireSagePreview ||
+ !/^[a-z0-9]{3}$/.test(modelName) ||
+ (container?.skeletons?.length ?? 0) === 0
+ ) {
+ return null;
+ }
+
+ const extras = this.getCharacterContainerExtras(container);
+ const policyExtras = extras.find((entry) =>
+ Object.prototype.hasOwnProperty.call(entry, 'spireNativePoseOnly')
+ );
+ const playableAnimationCount = (container?.animationGroups ?? []).filter(
+ (animationGroup) =>
+ !POSE_ANIMATION_PATTERN.test(`${animationGroup?.name ?? ''}`) &&
+ (animationGroup?.targetedAnimations?.length ?? 0) > 0
+ ).length;
+
+ if (
+ playableAnimationCount === 0 &&
+ policyExtras?.spireNativePoseOnly !== true
+ ) {
+ return policyExtras
+ ? 'missing-playable-animation'
+ : 'missing-native-pose-policy';
+ }
+ if (!policyExtras) {
+ return 'missing-character-model-cache-metadata';
+ }
+ if (
+ policyExtras &&
+ policyExtras.spireCharacterModelCacheVersion !==
+ PREVIEW_CHARACTER_MODEL_CACHE_VERSION
+ ) {
+ return 'stale-character-model-cache-version';
+ }
+ return null;
+ }
+
+ getMissingModelTextures(file) {
+ if (!window.__spireSagePreview) {
+ return [];
+ }
+ const key = `/eq/models/${file}`;
+ return [
+ ...new Set(window.__spireSageMissingModelTextures?.[key] ?? []),
+ ];
+ }
+
async getFirstAssetContainer(folder, files, originalName, options = {}) {
- const { aliasFiles = [], optional = false } = options;
+ const {
+ aliasFiles = [],
+ generateIfMissing = false,
+ optional = false,
+ skipOrientationRefresh = false,
+ skipCharacterPolicyRefresh = false,
+ skipMissingTextureRefresh = false,
+ skipMissingRefresh = false,
+ } = options;
const requestedFile = `${originalName}.glb`;
const aliasFileSet = new Set(aliasFiles);
for (const file of files) {
const container = await this.getCachedAssetContainer(folder, file);
if (container) {
+ const characterPolicyRefreshReason =
+ !skipCharacterPolicyRefresh && folder === 'models'
+ ? this.getStaleCharacterModelPolicy(container, file)
+ : null;
+ const missingModelTextures =
+ !skipMissingTextureRefresh && folder === 'models'
+ ? this.getMissingModelTextures(file)
+ : [];
+ if (characterPolicyRefreshReason || missingModelTextures.length > 0) {
+ const archiveModelName = `${file}`.replace(/\.glb$/i, '').toLowerCase();
+ console.warn(
+ `Refreshing ${archiveModelName} before first use`,
+ characterPolicyRefreshReason ?? 'missing-model-textures',
+ missingModelTextures
+ );
+ const generated = await processCharacterModelArchive(
+ archiveModelName,
+ { ...this.gc.settings, forceReload: true },
+ this.gc.rootFileSystemHandle,
+ null,
+ [],
+ { stopAfterFirstSuccessfulSource: true }
+ );
+ if (generated) {
+ container.dispose?.();
+ delete window.__spireSageMissingModelTextures?.[`/eq/models/${file}`];
+ for (const candidate of files) {
+ delete this.assetContainers[`${folder}/${candidate}`];
+ }
+ return this.getFirstAssetContainer(folder, files, originalName, {
+ ...options,
+ skipCharacterPolicyRefresh: true,
+ skipMissingTextureRefresh: true,
+ });
+ }
+ }
+ const staleHeadMaterials =
+ !skipOrientationRefresh &&
+ window.__spireSagePreview &&
+ folder === 'models'
+ ? this.getStaleHeadOrientationMaterials(container)
+ : [];
+ if (staleHeadMaterials.length > 0) {
+ const baseModelName =
+ `${originalName ?? ''}`.toLowerCase().match(
+ /^([a-z0-9]{3})(?:he\d{2})?$/
+ )?.[1] ?? originalName;
+ console.warn(
+ `Refreshing ${baseModelName} because ${file} has stale head UV metadata`,
+ staleHeadMaterials.map((material) => material.name)
+ );
+ const generated = await processCharacterModelArchive(
+ baseModelName,
+ { ...this.gc.settings, forceReload: true },
+ this.gc.rootFileSystemHandle,
+ null,
+ [],
+ { stopAfterFirstSuccessfulSource: true }
+ );
+ if (generated) {
+ container.dispose?.();
+ for (const candidate of files) {
+ delete this.assetContainers[`${folder}/${candidate}`];
+ }
+ return this.getFirstAssetContainer(
+ folder,
+ files,
+ originalName,
+ { ...options, skipOrientationRefresh: true }
+ );
+ }
+ }
+ if (folder === 'models') {
+ this.resolvedModelAssets[originalName] = `${file}`.replace(/\.glb$/i, '');
+ }
if (file !== requestedFile && !aliasFileSet.has(file)) {
this.assetFallbacks[originalName] = file;
console.warn(
@@ -774,27 +1068,315 @@ class SpawnController extends GameControllerChild {
return container;
}
}
+ if (
+ (!optional || generateIfMissing) &&
+ !skipMissingRefresh &&
+ window.__spireSagePreview &&
+ folder === 'models'
+ ) {
+ const archiveModelName = getCharacterArchiveBaseModelName(originalName);
+ const generated = await processCharacterModelArchive(
+ archiveModelName,
+ generateIfMissing
+ ? { ...this.gc.settings, forceReload: true }
+ : this.gc.settings,
+ this.gc.rootFileSystemHandle,
+ null,
+ [],
+ { stopAfterFirstSuccessfulSource: true }
+ );
+ if (generated) {
+ for (const file of files) {
+ delete this.assetContainers[`${folder}/${file}`];
+ const container = await this.getCachedAssetContainer(folder, file);
+ if (container) {
+ return container;
+ }
+ }
+ }
+ }
if (!optional) {
this.missingAssets[originalName] = files;
}
return null;
}
+ async addPreviewAnimationDonor(modelName, modelContainer) {
+ const donorName = PREVIEW_ANIMATION_DONORS[modelName];
+ if (!donorName || !modelContainer || previewAnimationDonorDisabled()) {
+ return;
+ }
+ if (modelContainer.__spirePreviewAnimationDonor?.donorName === donorName) {
+ return;
+ }
+ const hasPlayableAnimation = modelContainer.animationGroups?.some(
+ (animationGroup) =>
+ !POSE_ANIMATION_PATTERN.test(`${animationGroup?.name ?? ''}`) &&
+ animationGroup?.targetedAnimations?.length > 0
+ );
+ if (hasPlayableAnimation) {
+ return;
+ }
+
+ // A focused race audit may not have exported the donor yet. Generate it
+ // from its authoritative archive on demand instead of depending on a
+ // previous zone/model run having warmed the browser cache.
+ const donorContainer = await this.getAssetContainer(
+ donorName,
+ false,
+ { generateIfMissing: true, optional: true }
+ );
+ if (!donorContainer) {
+ modelContainer.__spirePreviewAnimationDonorFailureReason =
+ 'donor-container-missing';
+ return;
+ }
+ const donorHasPlayableAnimation = donorContainer.animationGroups?.some(
+ (animationGroup) =>
+ !POSE_ANIMATION_PATTERN.test(`${animationGroup?.name ?? ''}`) &&
+ animationGroup?.targetedAnimations?.length > 0
+ );
+ if (!donorHasPlayableAnimation) {
+ modelContainer.__spirePreviewAnimationDonorFailureReason =
+ 'donor-has-no-playable-animation';
+ return;
+ }
+
+ // AssetContainer only clones animation groups that were present during
+ // import reliably. Keep the donor source alongside this container and
+ // attach fresh groups to each instantiated spawn below. Validating or
+ // animating this cached source container would let a donor appear healthy
+ // while the visible clone remains in its bind pose.
+ modelContainer.__spirePreviewAnimationDonor = {
+ donorName,
+ donorContainer,
+ };
+ delete modelContainer.__spirePreviewAnimationDonorFailureReason;
+ }
+
+ instantiateSpawnModel(modelName, modelContainer, nameFunction) {
+ const instanceContainer = modelContainer.instantiateModelsToScene(
+ nameFunction
+ );
+ // Babylon does not guarantee that arbitrary GLTF extras survive every
+ // AssetContainer clone path. Preserve the exporter-approved native-pose
+ // policy on the instance explicitly so live zone spawns make the same
+ // animation decision as the structural race audit.
+ instanceContainer.__spireNativePoseOnly =
+ this.getCharacterContainerExtras(modelContainer).some(
+ (entry) => entry.spireNativePoseOnly === true
+ );
+ const donor = modelContainer.__spirePreviewAnimationDonor ?? null;
+ const donorDisabled = previewAnimationDonorDisabled();
+ const donorEvidence = {
+ expected: !!PREVIEW_ANIMATION_DONORS[modelName] && !donorDisabled,
+ disabled: donorDisabled,
+ donorName: donor?.donorName ?? PREVIEW_ANIMATION_DONORS[modelName] ?? null,
+ failureReason:
+ modelContainer.__spirePreviewAnimationDonorFailureReason ?? null,
+ attachedGroupCount: 0,
+ attachedTargetCount: 0,
+ bindRelativeTargetCount: 0,
+ bindLockedRotationTargetNames: [],
+ unmatchedTargetNames: [],
+ pass: !PREVIEW_ANIMATION_DONORS[modelName] || donorDisabled,
+ };
+ instanceContainer.__spirePreviewAnimationDonor = donorEvidence;
+ instanceContainer.__spireResolvedModelAsset =
+ this.resolvedModelAssets[modelName] ?? modelName;
+ if (!donor?.donorContainer) {
+ return instanceContainer;
+ }
+
+ const instanceNodes = [
+ ...(instanceContainer.rootNodes ?? []),
+ ...(instanceContainer.rootNodes ?? []).flatMap(
+ (root) => root.getDescendants?.(false) ?? []
+ ),
+ ].filter(Boolean);
+ const targetNodesByName = new Map();
+ for (const node of instanceNodes) {
+ const name = normalizeAnimationTargetName(node?.name);
+ if (!name) {
+ continue;
+ }
+ const candidates = targetNodesByName.get(name) ?? [];
+ candidates.push(node);
+ targetNodesByName.set(name, candidates);
+ }
+ const bindLockedRotationTargets = new Set(
+ [...targetNodesByName.keys()].filter(isPreviewHeadOrNeckRotationTarget)
+ );
+ // Some Luclin-era skeletons expose only anonymous bone### names. In that
+ // case, derive the complete head chain from the actual HE mesh weights and
+ // preserve those bind rotations. This prevents a cross-race donor from
+ // turning a correctly skinned face upside down while still animating the
+ // torso and limbs.
+ if (!hasSemanticPreviewHeadTarget(targetNodesByName)) {
+ for (const boneName of getPreviewHeadInfluencingBoneNames(modelContainer)) {
+ bindLockedRotationTargets.add(boneName);
+ }
+ }
+
+ // A donor-marked source had no native playable clips. Discard any groups
+ // opportunistically cloned from a prior cached mutation and recreate them
+ // against the actual visible instance so ownership and targets are clear.
+ const poseGroups = [];
+ for (const animationGroup of instanceContainer.animationGroups ?? []) {
+ if (POSE_ANIMATION_PATTERN.test(`${animationGroup?.name ?? ''}`)) {
+ poseGroups.push(animationGroup);
+ } else {
+ animationGroup?.dispose?.();
+ }
+ }
+ instanceContainer.animationGroups = poseGroups;
+
+ const unmatchedTargetNames = new Set();
+ const getPoseValues = (container) => {
+ const values = new Map();
+ const poseGroup = (container.animationGroups ?? []).find((group) =>
+ POSE_ANIMATION_PATTERN.test(`${group?.name ?? ''}`)
+ );
+ const frame = Number(poseGroup?.from ?? 0);
+ for (const targetedAnimation of poseGroup?.targetedAnimations ?? []) {
+ const animation = targetedAnimation.animation;
+ const targetName = normalizeAnimationTargetName(
+ targetedAnimation.target?.name
+ );
+ const property = `${animation?.targetProperty ?? ''}`;
+ const value = animation?.evaluate?.(frame);
+ if (targetName && property && value !== undefined) {
+ values.set(`${targetName}|${property}`, value?.clone?.() ?? value);
+ }
+ }
+ return values;
+ };
+ const targetPoseValues = getPoseValues(modelContainer);
+ const donorPoseValues = getPoseValues(donor.donorContainer);
+ const bindLockedRotationTargetNames = new Set();
+ const createBindRelativeAnimation = (animation, targetName) => {
+ const property = `${animation?.targetProperty ?? ''}`;
+ const key = `${targetName}|${property}`;
+ const targetBase = targetPoseValues.get(key);
+ const donorBase = donorPoseValues.get(key);
+ if (!targetBase || !donorBase) {
+ return null;
+ }
+ const retargetValue = (value) => {
+ if (!value) {
+ return value;
+ }
+ if (/rotationquaternion/i.test(property)) {
+ if (bindLockedRotationTargets.has(targetName)) {
+ bindLockedRotationTargetNames.add(targetName);
+ return targetBase?.clone?.() ?? targetBase;
+ }
+ const donorInverse = BABYLON.Quaternion.Inverse(donorBase);
+ return targetBase
+ .multiply(donorInverse)
+ .multiply(value)
+ .normalize();
+ }
+ if (/position|translation/i.test(property)) {
+ // Bone translations encode the donor's limb lengths. Applying even
+ // their animated deltas to a different mesh stretches the hierarchy
+ // and can move the head outside the body. Preserve the target model's
+ // own local placement; rotation carries the visible motion safely.
+ return targetBase?.clone?.() ?? targetBase;
+ }
+ if (/scal/i.test(property)) {
+ return targetBase?.clone?.() ?? targetBase;
+ }
+ return value?.clone?.() ?? value;
+ };
+ const clonedAnimation = animation.clone();
+ clonedAnimation.setKeys(
+ (animation.getKeys?.() ?? []).map((animationKey) => ({
+ ...animationKey,
+ value: retargetValue(animationKey.value),
+ }))
+ );
+ return clonedAnimation;
+ };
+ for (const donorGroup of donor.donorContainer.animationGroups ?? []) {
+ if (POSE_ANIMATION_PATTERN.test(`${donorGroup?.name ?? ''}`)) {
+ continue;
+ }
+ const animationGroup = new AnimationGroup(
+ `Clone of ${`${donorGroup.name ?? ''}`.replace(/^Clone of /, '')}`,
+ this.currentScene
+ );
+ animationGroup.metadata = {
+ ...animationGroup.metadata,
+ spirePreviewAnimationDonor: true,
+ spirePreviewAnimationDonorName: donor.donorName,
+ };
+ for (const targetedAnimation of donorGroup.targetedAnimations ?? []) {
+ const targetName = normalizeAnimationTargetName(
+ targetedAnimation.target?.name
+ );
+ const candidates = targetNodesByName.get(targetName) ?? [];
+ if (candidates.length !== 1) {
+ unmatchedTargetNames.add(targetName || '');
+ continue;
+ }
+ const bindRelativeAnimation = createBindRelativeAnimation(
+ targetedAnimation.animation,
+ targetName
+ );
+ if (!bindRelativeAnimation) {
+ unmatchedTargetNames.add(`${targetName || ''}:bind-pose`);
+ continue;
+ }
+ animationGroup.addTargetedAnimation(bindRelativeAnimation, candidates[0]);
+ donorEvidence.bindRelativeTargetCount++;
+ }
+ if (animationGroup.targetedAnimations.length === 0) {
+ animationGroup.dispose();
+ continue;
+ }
+ instanceContainer.animationGroups.push(animationGroup);
+ donorEvidence.attachedGroupCount++;
+ donorEvidence.attachedTargetCount +=
+ animationGroup.targetedAnimations.length;
+ }
+ donorEvidence.bindLockedRotationTargetNames = [
+ ...bindLockedRotationTargetNames,
+ ].sort();
+ donorEvidence.unmatchedTargetNames = [...unmatchedTargetNames].sort();
+ donorEvidence.pass =
+ donorEvidence.attachedGroupCount > 0 &&
+ donorEvidence.attachedTargetCount > 0 &&
+ donorEvidence.bindRelativeTargetCount === donorEvidence.attachedTargetCount;
+ return instanceContainer;
+ }
+
getAssetContainer(modelName, secondary = false, options = {}) {
const key = `model/${modelName}/${
- secondary ? 'secondary' : options.optional ? 'optional' : 'primary'
+ secondary
+ ? 'secondary'
+ : options.generateIfMissing
+ ? 'generated'
+ : options.optional
+ ? 'optional'
+ : 'primary'
}`;
if (!this.assetContainers[key]) {
const allowFallbackModels =
!window.__spireSagePreview && !window.__spireSageStrictSpawnModels;
const previewAlias =
!secondary && window.__spireSagePreview
- ? PREVIEW_MODEL_ALIASES[modelName]
+ ? PREVIEW_MODEL_ALIASES[modelName] ??
+ PREVIEW_CLIENT_FALLBACKS[modelName]
: null;
+ const previewAliasFirst =
+ previewAlias && PREVIEW_ALIAS_FIRST_MODELS.has(modelName);
const files = secondary
? [`${modelName}.glb`]
: previewAlias
- ? [`${previewAlias}.glb`]
+ ? previewAliasFirst
+ ? [`${previewAlias}.glb`, `${modelName}.glb`]
+ : [`${modelName}.glb`, `${previewAlias}.glb`]
: allowFallbackModels
? [
`${modelName}.glb`,
@@ -804,15 +1386,56 @@ class SpawnController extends GameControllerChild {
'zom.glb',
]
: [`${modelName}.glb`];
- this.assetContainers[key] = this.getFirstAssetContainer(
- 'models',
- files,
- modelName,
- {
- aliasFiles: previewAlias ? [`${previewAlias}.glb`] : [],
- optional : secondary || !!options.optional,
+ const previewObjectAlias =
+ !secondary && window.__spireSagePreview
+ ? PREVIEW_OBJECT_MODEL_ALIASES[modelName]
+ : null;
+ this.assetContainers[key] = (async () => {
+ if (previewAliasFirst) {
+ const generatedAliasContainer = await this.getFirstAssetContainer(
+ 'models',
+ [`${previewAlias}.glb`],
+ previewAlias,
+ {
+ generateIfMissing: true,
+ optional: false,
+ }
+ );
+ if (generatedAliasContainer) {
+ this.resolvedModelAssets[modelName] = previewAlias;
+ this.assetFallbacks[modelName] = `${previewAlias}.glb`;
+ await this.addPreviewAnimationDonor(
+ modelName,
+ generatedAliasContainer
+ );
+ return generatedAliasContainer;
+ }
}
- );
+ const modelContainer = await this.getFirstAssetContainer(
+ 'models',
+ files,
+ modelName,
+ {
+ aliasFiles: previewAlias ? [`${previewAlias}.glb`] : [],
+ generateIfMissing: !!options.generateIfMissing,
+ optional : secondary || !!options.optional,
+ }
+ );
+ if (!secondary && window.__spireSagePreview && modelContainer) {
+ await this.addPreviewAnimationDonor(modelName, modelContainer);
+ }
+ if (modelContainer || !previewObjectAlias) {
+ return modelContainer;
+ }
+ const objectContainer = await this.getCachedAssetContainer(
+ 'objects',
+ `${previewObjectAlias}.glb`
+ );
+ if (objectContainer) {
+ delete this.missingAssets[modelName];
+ }
+ return objectContainer;
+ })();
}
return this.assetContainers[key];
}
@@ -821,6 +1444,7 @@ class SpawnController extends GameControllerChild {
this.assetContainers = {};
this.assetFallbacks = {};
this.missingAssets = {};
+ this.resolvedModelAssets = {};
}
getObjectAssetContainer(objectName, path = 'objects') {
@@ -1397,21 +2021,33 @@ class SpawnController extends GameControllerChild {
return this.modelExport;
}
- async addSpawn(modelName, models) {
+ async addSpawn(modelName, models, loadToken = this.spawnLoadToken) {
let loaded = 0;
- for (const [_idx, spawnEntry] of Object.entries(models)) {
+ const spawnEntries = Object.values(models);
+ const initializeEntry = async (spawnEntry) => {
+ if (loadToken !== this.spawnLoadToken) {
+ return;
+ }
+ let babylonSpawn = null;
try {
- const babylonSpawn = new BabylonSpawn(
+ babylonSpawn = new BabylonSpawn(
spawnEntry,
modelName,
this.zoneSpawnsNode,
this.sphereMat
);
- if (await babylonSpawn.initializeSpawn()) {
- this.spawns[spawnEntry.__spireSpawnId ?? spawnEntry.id] = babylonSpawn;
- loaded++;
+ if (!(await babylonSpawn.initializeSpawn())) {
+ babylonSpawn.dispose();
+ return;
}
+ if (loadToken !== this.spawnLoadToken) {
+ babylonSpawn.dispose();
+ return;
+ }
+ this.spawns[spawnEntry.__spireSpawnId ?? spawnEntry.id] = babylonSpawn;
+ loaded++;
} catch (error) {
+ babylonSpawn?.dispose?.();
console.warn(
`Error loading spawn model ${modelName} spawn ${spawnEntry?.id}: ${
error?.message ?? error
@@ -1419,50 +2055,138 @@ class SpawnController extends GameControllerChild {
);
}
await yieldToBrowser();
- }
+ };
+
+ if (!window.__spireSagePreview || spawnEntries.length <= 1) {
+ for (const spawnEntry of spawnEntries) {
+ await initializeEntry(spawnEntry);
+ if (loadToken !== this.spawnLoadToken) {
+ break;
+ }
+ }
+ return loaded;
+ }
+
+ // Preview validation zones commonly contain hundreds of instances that
+ // share one already-loaded model. A small worker pool keeps Babylon and
+ // texture setup responsive while avoiding the multi-minute serial load and
+ // the memory spike an unbounded Promise.all would create.
+ let nextIndex = 0;
+ const hardwareConcurrency = Number(window.navigator?.hardwareConcurrency) || 4;
+ const workerCount = Math.min(
+ 12,
+ Math.max(4, Math.floor(hardwareConcurrency * 0.75)),
+ spawnEntries.length
+ );
+ const worker = async () => {
+ while (loadToken === this.spawnLoadToken) {
+ const index = nextIndex++;
+ if (index >= spawnEntries.length) {
+ return;
+ }
+ await initializeEntry(spawnEntries[index]);
+ }
+ };
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
return loaded;
}
async deleteSpawn(spawn) {
- this.spawns[spawn.id]?.dispose();
+ const spawnId = spawn?.__spireSpawnId ?? spawn?.id;
+ const existingSpawn = this.spawns[spawnId];
+ existingSpawn?.dispose();
+ delete this.spawns[spawnId];
+ if (this.selectedSpawnId === spawnId) {
+ this.clearSpawnSelection();
+ }
+ if (window.__spireSagePreview && window.__spireSageSpawnStats) {
+ const sceneChildren = this.zoneSpawnsNode?.getChildren?.() ?? [];
+ const loadedSpawns = Object.values(this.spawns);
+ window.__spireSageSpawnStats = {
+ ...window.__spireSageSpawnStats,
+ requested : loadedSpawns.length,
+ sourceCount : loadedSpawns.length,
+ loaded : loadedSpawns.length,
+ modelGroups : new Set(loadedSpawns.map((entry) => entry.modelName)).size,
+ rootNodeCount: sceneChildren.filter(
+ (child) => child?.metadata?.spawnRoot === true
+ ).length,
+ sceneChildren: sceneChildren.length,
+ };
+ }
}
- resolveSpawnModel(npcType) {
+ getSpawnModelCandidates(npcType) {
if (!npcType) {
- return null;
+ return [];
}
- const model = raceData.find((r) => r.id === npcType.race);
+ const model = raceData.find((r) => Number(r.id) === Number(npcType.race));
const gender =
npcType.gender !== undefined &&
npcType.gender !== null &&
npcType.gender !== ''
? `${Number(npcType.gender)}`
: null;
- const realModel = [
- gender ? model?.[gender] : null,
- model?.['2'],
- model?.['0'],
- model?.['1'],
- ].find(Boolean);
- if (realModel) {
- return realModel.toLowerCase();
- }
- return window.__spireSagePreview ? null : 'hum';
+ const matchingRaces = model
+ ? [
+ model,
+ ...raceData.filter(
+ (candidate) =>
+ Number(candidate.id) !== Number(model.id) &&
+ `${candidate.name ?? ''}`.trim().toLowerCase() ===
+ `${model.name ?? ''}`.trim().toLowerCase()
+ ),
+ ]
+ : [];
+ return [...new Set(matchingRaces.flatMap((candidate) => [
+ gender ? candidate?.[gender] : null,
+ candidate?.['2'],
+ candidate?.['0'],
+ candidate?.['1'],
+ ]).filter(Boolean).map((entry) => entry.toLowerCase()))];
+ }
+
+ resolveSpawnModel(npcType) {
+ return this.getSpawnModelCandidates(npcType)[0] ??
+ (window.__spireSagePreview ? null : 'hum');
+ }
+
+ async resolveAvailableSpawnModel(npcType) {
+ const candidates = this.getSpawnModelCandidates(npcType);
+ for (const modelName of candidates) {
+ this.modelAvailabilityPromises[modelName] ??= Promise.all([
+ getEQFileExists('models', `${modelName}.glb`),
+ getEQFileExists('objects', `${modelName}.glb`),
+ ]).then((availability) => availability.some(Boolean));
+ if (await this.modelAvailabilityPromises[modelName]) {
+ return modelName;
+ }
+ }
+ return candidates[0] ?? (window.__spireSagePreview ? null : 'hum');
}
async updateSpawn(spawn) {
const firstSpawn = spawn.spawnentries?.[0]?.npc_type;
+ if (!firstSpawn) {
+ console.warn('Cannot update spawn without an associated NPC type', spawn);
+ return;
+ }
const newSpawn = {
...firstSpawn,
...spawn,
};
- this.spawns[spawn.id]?.dispose();
- const realModel = this.resolveSpawnModel(firstSpawn);
+ const realModel = await this.resolveAvailableSpawnModel(firstSpawn);
if (!realModel) {
console.warn('Cannot update spawn without a resolved NPC model', spawn);
return;
}
- this.addSpawn(realModel, [newSpawn]);
+ const restoreSelection = this.selectedSpawnId === spawn.id;
+ this.spawns[spawn.id]?.dispose();
+ delete this.spawns[spawn.id];
+ await this.addSpawn(realModel, [newSpawn], this.spawnLoadToken);
+ if (restoreSelection && this.spawns[spawn.id]) {
+ this.selectSpawn(newSpawn);
+ }
}
getMaterialTexture(material) {
@@ -1511,34 +2235,81 @@ class SpawnController extends GameControllerChild {
return POSE_ANIMATION_PATTERN.test(`${animationGroup?.name ?? ''}`);
}
+ isTPoseValidationExcludedModel(modelName) {
+ return T_POSE_VALIDATION_EXCLUDED_MODELS.has(modelName);
+ }
+
collectSpawnVisualStats() {
- const stats = {
+ const nameplateEligibleModels = new Set();
+ const stats = {
spawnCount: Object.keys(this.spawns).length,
materialSlotCount: 0,
texturedSlotCount: 0,
readyTextureCount: 0,
pendingTextureCount: 0,
fallbackTextureCount: 0,
+ appearanceTexturePendingCount: 0,
+ appearanceTextureDecodeFailureCount: 0,
tinyTextureCount: 0,
onePixelTextureCount: 0,
belowGroundSpawnCount: 0,
aboveGroundSpawnCount: 0,
texturelessSpawnCount: 0,
+ headTextureCount: 0,
+ verticallyFlippedHeadTextureCount: 0,
+ bodyVariantFallbackCount: 0,
+ secondaryHeadBoneRemapFailureCount: 0,
skeletonSpawnCount: 0,
animatedSkeletonSpawnCount: 0,
tPoseRiskCount: 0,
+ motionlessAnimationCount: 0,
+ staticPosedSkeletonSpawnCount: 0,
+ selectedAnimationPromotionFailureCount: 0,
+ animationBoundsRejectionCount: 0,
+ animationHeadOrientationRejectionCount: 0,
+ neutralIdleSelectionFailureCount: 0,
+ nativePoseOnlySkeletonSpawnCount: 0,
+ compactNativeArmNormalizationFailureCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ nonFiniteSkeletonSpawnCount: 0,
+ bindPoseCloneGroupCount: 0,
+ dynamicAnimationGroupCount: 0,
+ detachedAnimationTargetCount: 0,
+ retargetedAnimationTargetCount: 0,
+ unresolvedAnimationTargetCount: 0,
nonPlayingAnimationCount: 0,
animationGroupCount: 0,
playingAnimationGroupCount: 0,
+ excessAnimationGroupCount: 0,
+ nameplateExpectedCount: 0,
+ nameplateCount: 0,
+ nameplateVisibleCount: 0,
+ nameplateTexturedCount: 0,
+ nameplateAboveModelCount: 0,
+ nameplateFailureCount: 0,
+ nameplateEligibleSpawnCount: 0,
+ nameplateEligibleModelCount: 0,
byModel: {},
texturelessSamples: [],
pendingTextureSamples: [],
fallbackTextureSamples: [],
+ appearanceTextureDecodeFailureSamples: [],
tinyTextureSamples: [],
onePixelTextureSamples: [],
+ headTextureSamples: [],
+ bodyVariantFallbackSamples: [],
+ secondaryHeadBoneRemapFailureSamples: [],
belowGroundSamples: [],
aboveGroundSamples: [],
animationRiskSamples: [],
+ animationBoundsRejectionSamples: [],
+ animationHeadOrientationRejectionSamples: [],
+ neutralIdleSelectionFailureSamples: [],
+ selectedVisualAnimationSamples: [],
+ animationRetargetingSamples: [],
+ animationResourceSamples: [],
+ nonFiniteBoneMatrixSamples: [],
+ nameplateFailureSamples: [],
};
for (const [spawnId, spawn] of Object.entries(this.spawns)) {
@@ -1556,23 +2327,230 @@ class SpawnController extends GameControllerChild {
readyTextureCount: 0,
pendingTextureCount: 0,
fallbackTextureCount: 0,
+ appearanceTexturePendingCount: 0,
+ appearanceTextureDecodeFailureCount: 0,
tinyTextureCount: 0,
onePixelTextureCount: 0,
belowGroundSpawnCount: 0,
aboveGroundSpawnCount: 0,
texturelessSpawnCount: 0,
+ headTextureCount: 0,
+ verticallyFlippedHeadTextureCount: 0,
+ bodyVariantFallbackCount: 0,
+ secondaryHeadBoneRemapFailureCount: 0,
skeletonSpawnCount: 0,
animatedSkeletonSpawnCount: 0,
tPoseRiskCount: 0,
+ motionlessAnimationCount: 0,
+ staticPosedSkeletonSpawnCount: 0,
+ selectedAnimationPromotionFailureCount: 0,
+ animationBoundsRejectionCount: 0,
+ animationHeadOrientationRejectionCount: 0,
+ neutralIdleSelectionFailureCount: 0,
+ selectedVisualAnimationNames: [],
+ nativePoseOnlySkeletonSpawnCount: 0,
+ compactNativeArmNormalizationFailureCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ nonFiniteSkeletonSpawnCount: 0,
+ bindPoseCloneGroupCount: 0,
+ dynamicAnimationGroupCount: 0,
+ detachedAnimationTargetCount: 0,
+ retargetedAnimationTargetCount: 0,
+ unresolvedAnimationTargetCount: 0,
nonPlayingAnimationCount: 0,
animationGroupCount: 0,
playingAnimationGroupCount: 0,
+ excessAnimationGroupCount: 0,
+ nameplateExpectedCount: 0,
+ nameplateCount: 0,
+ nameplateVisibleCount: 0,
+ nameplateTexturedCount: 0,
+ nameplateAboveModelCount: 0,
+ nameplateFailureCount: 0,
};
}
const modelStats = stats.byModel[modelName];
modelStats.spawns++;
+ const appearanceTextureDecodeFailureCount = Number(
+ spawn.appearanceTextureDecodeFailureCount ?? 0
+ );
+ if (appearanceTextureDecodeFailureCount > 0) {
+ stats.appearanceTextureDecodeFailureCount +=
+ appearanceTextureDecodeFailureCount;
+ modelStats.appearanceTextureDecodeFailureCount +=
+ appearanceTextureDecodeFailureCount;
+ if (stats.appearanceTextureDecodeFailureSamples.length < 10) {
+ stats.appearanceTextureDecodeFailureSamples.push({
+ spawnId,
+ modelName,
+ textures: (spawn.appearanceTextureDecodeFailures ?? []).slice(0, 12),
+ });
+ }
+ }
+ if (
+ spawn.selectedVisualAnimationName &&
+ !modelStats.selectedVisualAnimationNames.includes(
+ spawn.selectedVisualAnimationName
+ )
+ ) {
+ modelStats.selectedVisualAnimationNames.push(
+ spawn.selectedVisualAnimationName
+ );
+ }
+ if (
+ spawn.selectedVisualAnimationName &&
+ stats.selectedVisualAnimationSamples.length < 30
+ ) {
+ stats.selectedVisualAnimationSamples.push({
+ spawnId,
+ modelName,
+ selectedAnimation: spawn.selectedVisualAnimationName,
+ neutralIdleCandidates: spawn.neutralIdleCandidateNames ?? [],
+ });
+ }
+ if (spawn.neutralIdleSelectionPass === false) {
+ stats.neutralIdleSelectionFailureCount++;
+ modelStats.neutralIdleSelectionFailureCount++;
+ if (stats.neutralIdleSelectionFailureSamples.length < 10) {
+ stats.neutralIdleSelectionFailureSamples.push({
+ spawnId,
+ modelName,
+ selectedAnimation: spawn.selectedVisualAnimationName,
+ neutralIdleCandidates: spawn.neutralIdleCandidateNames ?? [],
+ });
+ }
+ }
+ if (
+ spawn.bodyVariantFallback === true &&
+ spawn.bodyVariantTextureFallbackApplied !== true
+ ) {
+ stats.bodyVariantFallbackCount++;
+ modelStats.bodyVariantFallbackCount++;
+ if (stats.bodyVariantFallbackSamples.length < 10) {
+ stats.bodyVariantFallbackSamples.push({
+ spawnId,
+ modelName,
+ requestedModelVariation: spawn.requestedModelVariation,
+ loadedModelVariation: spawn.loadedModelVariation,
+ });
+ }
+ }
+ if (spawn.selectedAnimationPromotionFailed === true) {
+ stats.selectedAnimationPromotionFailureCount++;
+ modelStats.selectedAnimationPromotionFailureCount++;
+ }
+ if (spawn.animationBoundsRejected === true) {
+ stats.animationBoundsRejectionCount++;
+ modelStats.animationBoundsRejectionCount++;
+ if (stats.animationBoundsRejectionSamples.length < 10) {
+ stats.animationBoundsRejectionSamples.push({
+ spawnId,
+ modelName,
+ fallbackSafe: spawn.animationBoundsFallbackSafe === true,
+ boundsSafety: spawn.animationBoundsSafety ?? null,
+ });
+ }
+ if (spawn.animationHeadOrientationRejected === true) {
+ stats.animationHeadOrientationRejectionCount++;
+ modelStats.animationHeadOrientationRejectionCount++;
+ if (stats.animationHeadOrientationRejectionSamples.length < 10) {
+ stats.animationHeadOrientationRejectionSamples.push({
+ spawnId,
+ modelName,
+ boundsSafety: spawn.animationBoundsSafety ?? null,
+ });
+ }
+ }
+ }
+ const secondaryHeadBoneRemapFailureCount = Number(
+ spawn.secondaryHeadBoneRemapFailureCount ?? 0
+ );
+ if (secondaryHeadBoneRemapFailureCount > 0) {
+ stats.secondaryHeadBoneRemapFailureCount +=
+ secondaryHeadBoneRemapFailureCount;
+ modelStats.secondaryHeadBoneRemapFailureCount +=
+ secondaryHeadBoneRemapFailureCount;
+ if (stats.secondaryHeadBoneRemapFailureSamples.length < 10) {
+ stats.secondaryHeadBoneRemapFailureSamples.push({
+ spawnId,
+ modelName,
+ failures: spawn.secondaryHeadBoneRemapFailures ?? [],
+ });
+ }
+ }
+ if (spawn.isNameplateEligible?.() !== false) {
+ nameplateEligibleModels.add(modelName);
+ stats.nameplateEligibleSpawnCount++;
+ }
+ if (spawn.nameplateRequired === true) {
+ stats.nameplateExpectedCount++;
+ modelStats.nameplateExpectedCount++;
+ spawn.updateNameplatePosition?.();
+ const nameplate = spawn.inspectNameplate?.() ?? {
+ present: false,
+ visible: false,
+ textured: false,
+ placement: { pass: false },
+ pass: false,
+ };
+ if (nameplate.present) {
+ stats.nameplateCount++;
+ modelStats.nameplateCount++;
+ }
+ if (nameplate.visible) {
+ stats.nameplateVisibleCount++;
+ modelStats.nameplateVisibleCount++;
+ }
+ if (nameplate.textured) {
+ stats.nameplateTexturedCount++;
+ modelStats.nameplateTexturedCount++;
+ }
+ if (nameplate.placement?.pass) {
+ stats.nameplateAboveModelCount++;
+ modelStats.nameplateAboveModelCount++;
+ }
+ if (!nameplate.pass) {
+ stats.nameplateFailureCount++;
+ modelStats.nameplateFailureCount++;
+ if (stats.nameplateFailureSamples.length < 10) {
+ stats.nameplateFailureSamples.push({
+ spawnId,
+ modelName,
+ ...nameplate,
+ });
+ }
+ }
+ }
+ const animationRetargeting = spawn.animationRetargeting ?? {};
+ const detachedAnimationTargetCount = Number(
+ animationRetargeting.detachedTargetCount ?? 0
+ );
+ const retargetedAnimationTargetCount = Number(
+ animationRetargeting.retargetedTargetCount ?? 0
+ );
+ const unresolvedAnimationTargetCount = Number(
+ animationRetargeting.unresolvedTargetCount ?? 0
+ );
+ stats.detachedAnimationTargetCount += detachedAnimationTargetCount;
+ stats.retargetedAnimationTargetCount += retargetedAnimationTargetCount;
+ stats.unresolvedAnimationTargetCount += unresolvedAnimationTargetCount;
+ modelStats.detachedAnimationTargetCount += detachedAnimationTargetCount;
+ modelStats.retargetedAnimationTargetCount += retargetedAnimationTargetCount;
+ modelStats.unresolvedAnimationTargetCount += unresolvedAnimationTargetCount;
+ if (
+ unresolvedAnimationTargetCount > 0 &&
+ stats.animationRetargetingSamples.length < 10
+ ) {
+ stats.animationRetargetingSamples.push({
+ spawnId,
+ modelName,
+ ...animationRetargeting,
+ });
+ }
const missingModelTextures =
- window.__spireSageMissingModelTextures?.[`/eq/models/${modelName}.glb`] ?? [];
+ window.__spireSageMissingModelTextures?.[`/eq/models/${modelName}.glb`] ??
+ window.__spireSageMissingModelTextures?.[`/eq/objects/${modelName}.glb`] ??
+ [];
if (missingModelTextures.length > 0) {
stats.fallbackTextureCount += missingModelTextures.length;
modelStats.fallbackTextureCount += missingModelTextures.length;
@@ -1627,13 +2605,34 @@ class SpawnController extends GameControllerChild {
if (!texture) {
continue;
}
+ if (material.metadata?.spireAppearanceTexturePending === true) {
+ stats.appearanceTexturePendingCount++;
+ modelStats.appearanceTexturePendingCount++;
+ }
+ const textureReady = this.isTextureReady(texture);
+ if (
+ material.metadata?.spireAppearanceTextureDecodeFailed === true &&
+ !textureReady
+ ) {
+ stats.appearanceTextureDecodeFailureCount++;
+ modelStats.appearanceTextureDecodeFailureCount++;
+ if (stats.appearanceTextureDecodeFailureSamples.length < 10) {
+ stats.appearanceTextureDecodeFailureSamples.push({
+ spawnId,
+ modelName,
+ textures: [material.name],
+ });
+ }
+ }
spawnTexturedSlots++;
stats.texturedSlotCount++;
modelStats.texturedSlotCount++;
- if (this.isTextureReady(texture)) {
+ if (textureReady) {
stats.readyTextureCount++;
modelStats.readyTextureCount++;
} else {
+ const internalTexture =
+ texture.getInternalTexture?.() ?? texture._texture ?? null;
spawnPendingTextures++;
stats.pendingTextureCount++;
modelStats.pendingTextureCount++;
@@ -1643,6 +2642,16 @@ class SpawnController extends GameControllerChild {
modelName,
material: material.name,
texture: texture.name ?? texture.url,
+ url: texture.url ?? null,
+ appearancePending:
+ material.metadata?.spireAppearanceTexturePending === true,
+ appearanceDecodeAttempts:
+ material.metadata?.spireAppearanceTextureDecodeAttempts ?? null,
+ appearanceDecodeLastError:
+ material.metadata?.spireAppearanceTextureDecodeLastError ?? null,
+ hasInternalTexture: Boolean(internalTexture),
+ internalReady: internalTexture?.isReady ?? null,
+ loadingError: internalTexture?._loadingError ?? null,
});
}
}
@@ -1657,14 +2666,73 @@ class SpawnController extends GameControllerChild {
width,
height,
};
- if ((width > 0 && width <= 8) || (height > 0 && height <= 8)) {
+ if (/^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i.test(material.name ?? '')) {
+ stats.headTextureCount++;
+ modelStats.headTextureCount++;
+ const requestedInvertY =
+ texture._texture?._spireSageRequestedInvertY;
+ const uploadInvertY =
+ texture._texture?._spireSageUploadInvertY;
+ const uploadOrientationMismatch =
+ typeof requestedInvertY === 'boolean' &&
+ typeof uploadInvertY === 'boolean' &&
+ requestedInvertY !== uploadInvertY;
+ const geometryUvFlipped =
+ material.metadata?.gltf?.extras?.spireSkinnedVFlipped === true ||
+ material.metadata?.extras?.spireSkinnedVFlipped === true ||
+ material.metadata?.spireSkinnedVFlipped === true;
+ const orientationPolicy =
+ getCharacterHeadOrientationPolicy(material.name);
+ const expectsGeometryUvFlip =
+ orientationPolicy.geometryUvFlipped;
+ const expectsRuntimeTextureVFlip =
+ orientationPolicy.runtimeTextureVFlipped;
+ const runtimeTextureVFlipped = Number(texture.vScale ?? 1) < 0;
+ const expectedEffectiveVFlip =
+ expectsGeometryUvFlip !== expectsRuntimeTextureVFlip;
+ const effectiveVFlip =
+ geometryUvFlipped !== runtimeTextureVFlipped;
+ if (
+ effectiveVFlip !== expectedEffectiveVFlip ||
+ uploadOrientationMismatch
+ ) {
+ stats.verticallyFlippedHeadTextureCount++;
+ modelStats.verticallyFlippedHeadTextureCount++;
+ }
+ if (stats.headTextureSamples.length < 20) {
+ stats.headTextureSamples.push({
+ ...textureSample,
+ face: Number(spawn.spawnEntry?.face ?? spawn.spawn?.face ?? 0),
+ helmTexture: Number(spawn.spawnEntry?.helmtexture ?? 0),
+ invertY: texture.invertY,
+ requestedInvertY,
+ uploadInvertY,
+ uploadOrientationMismatch,
+ geometryUvFlipped,
+ runtimeTextureVFlipped,
+ expectsGeometryUvFlip,
+ expectsRuntimeTextureVFlip,
+ effectiveVFlip,
+ expectedEffectiveVFlip,
+ vOffset: texture.vOffset,
+ vScale: texture.vScale,
+ });
+ }
+ }
+ if (
+ material.metadata?.transparentTextureSentinel === true &&
+ material.metadata?.transparentTextureSentinelSuppressed !== true
+ ) {
stats.tinyTextureCount++;
modelStats.tinyTextureCount++;
if (stats.tinyTextureSamples.length < 10) {
stats.tinyTextureSamples.push(textureSample);
}
}
- if ((width > 0 && width <= 1) || (height > 0 && height <= 1)) {
+ if (
+ ((width > 0 && width <= 1) || (height > 0 && height <= 1)) &&
+ !INTENTIONAL_SOLID_TEXTURES.has(`${material?.name ?? ''}`.toUpperCase())
+ ) {
stats.onePixelTextureCount++;
modelStats.onePixelTextureCount++;
if (stats.onePixelTextureSamples.length < 10) {
@@ -1745,6 +2813,39 @@ class SpawnController extends GameControllerChild {
const hasSkeleton =
!!root.skeleton || meshes.some((mesh) => !!mesh?.skeleton);
+ const skeletons = new Set(
+ [root.skeleton, ...meshes.map((mesh) => mesh?.skeleton)].filter(Boolean)
+ );
+ let spawnNonFiniteBoneMatrixCount = 0;
+ for (const skeleton of skeletons) {
+ for (const bone of skeleton?.bones ?? []) {
+ const values = bone?.getFinalMatrix?.()?.m ?? bone?._finalMatrix?.m;
+ if (!values || values.length === 0) {
+ continue;
+ }
+ const nonFiniteValueCount = Array.from(values).filter(
+ (value) => !Number.isFinite(value)
+ ).length;
+ if (nonFiniteValueCount === 0) {
+ continue;
+ }
+ spawnNonFiniteBoneMatrixCount += nonFiniteValueCount;
+ if (stats.nonFiniteBoneMatrixSamples.length < 10) {
+ stats.nonFiniteBoneMatrixSamples.push({
+ spawnId,
+ modelName,
+ bone: bone.name,
+ nonFiniteValueCount,
+ });
+ }
+ }
+ }
+ if (spawnNonFiniteBoneMatrixCount > 0) {
+ stats.nonFiniteBoneMatrixCount += spawnNonFiniteBoneMatrixCount;
+ stats.nonFiniteSkeletonSpawnCount++;
+ modelStats.nonFiniteBoneMatrixCount += spawnNonFiniteBoneMatrixCount;
+ modelStats.nonFiniteSkeletonSpawnCount++;
+ }
const animationGroups = spawn.animationGroups ?? [];
const playableGroups = animationGroups.filter(
(animationGroup) =>
@@ -1754,15 +2855,89 @@ class SpawnController extends GameControllerChild {
const playingGroups = playableGroups.filter((animationGroup) =>
this.isAnimationGroupPlaying(animationGroup)
);
+ const animationVitality = inspectAnimationSetVitality(animationGroups);
+ const poseGroup = animationGroups.find((animationGroup) =>
+ this.isPoseAnimationGroup(animationGroup)
+ ) ?? null;
+ const playingVitality = playingGroups.map((animationGroup) =>
+ inspectAnimationGroupVitality(animationGroup, poseGroup)
+ );
+ const hasPlayingVisualPose = playingVitality.some(
+ (group) => group.hasVisualPose
+ );
stats.animationGroupCount += playableGroups.length;
modelStats.animationGroupCount += playableGroups.length;
stats.playingAnimationGroupCount += playingGroups.length;
modelStats.playingAnimationGroupCount += playingGroups.length;
-
- if (hasSkeleton) {
+ if (playableGroups.length > 1) {
+ const excessGroupCount = playableGroups.length - 1;
+ stats.excessAnimationGroupCount += excessGroupCount;
+ modelStats.excessAnimationGroupCount += excessGroupCount;
+ if (stats.animationResourceSamples.length < 10) {
+ stats.animationResourceSamples.push({
+ spawnId,
+ modelName,
+ playableGroupCount: playableGroups.length,
+ selectedAnimation: spawn.selectedVisualAnimationName,
+ });
+ }
+ }
+ stats.bindPoseCloneGroupCount += animationVitality.bindPoseCloneGroupCount;
+ modelStats.bindPoseCloneGroupCount +=
+ animationVitality.bindPoseCloneGroupCount;
+ stats.dynamicAnimationGroupCount += animationVitality.dynamicGroupCount;
+ modelStats.dynamicAnimationGroupCount +=
+ animationVitality.dynamicGroupCount;
+
+ if (hasSkeleton && !T_POSE_VALIDATION_EXCLUDED_MODELS.has(modelName)) {
stats.skeletonSpawnCount++;
modelStats.skeletonSpawnCount++;
- if (playableGroups.length === 0) {
+ if (spawn.neutralIdleSelectionPass === false) {
+ stats.tPoseRiskCount++;
+ modelStats.tPoseRiskCount++;
+ if (stats.animationRiskSamples.length < 10) {
+ stats.animationRiskSamples.push({
+ spawnId,
+ modelName,
+ reason: 'non-neutral-idle-selected',
+ selectedAnimation: spawn.selectedVisualAnimationName,
+ neutralIdleCandidates: spawn.neutralIdleCandidateNames ?? [],
+ });
+ }
+ } else if (spawnNonFiniteBoneMatrixCount > 0) {
+ stats.tPoseRiskCount++;
+ modelStats.tPoseRiskCount++;
+ if (stats.animationRiskSamples.length < 10) {
+ stats.animationRiskSamples.push({
+ spawnId,
+ modelName,
+ reason: 'non-finite-runtime-bone-matrix',
+ nonFiniteValueCount: spawnNonFiniteBoneMatrixCount,
+ });
+ }
+ } else if (
+ spawn.nativePoseOnly &&
+ COMPACT_NATIVE_ARM_NORMALIZATION_MODELS.has(modelName) &&
+ spawn.compactNativeArmNeutralized !== true
+ ) {
+ stats.tPoseRiskCount++;
+ stats.compactNativeArmNormalizationFailureCount++;
+ modelStats.tPoseRiskCount++;
+ modelStats.compactNativeArmNormalizationFailureCount++;
+ if (stats.animationRiskSamples.length < 10) {
+ stats.animationRiskSamples.push({
+ spawnId,
+ modelName,
+ reason: 'compact-native-arm-normalization-not-applied',
+ targetCount: spawn.compactNativeArmTargetCount ?? 0,
+ });
+ }
+ } else if (spawn.nativePoseOnly && spawn.staticPreviewPoseApplied) {
+ stats.staticPosedSkeletonSpawnCount++;
+ modelStats.staticPosedSkeletonSpawnCount++;
+ stats.nativePoseOnlySkeletonSpawnCount++;
+ modelStats.nativePoseOnlySkeletonSpawnCount++;
+ } else if (playableGroups.length === 0) {
stats.tPoseRiskCount++;
modelStats.tPoseRiskCount++;
if (stats.animationRiskSamples.length < 10) {
@@ -1783,6 +2958,27 @@ class SpawnController extends GameControllerChild {
animationGroups: playableGroups.map((group) => group.name),
});
}
+ } else if (!hasPlayingVisualPose) {
+ stats.tPoseRiskCount++;
+ stats.motionlessAnimationCount++;
+ modelStats.tPoseRiskCount++;
+ modelStats.motionlessAnimationCount++;
+ if (stats.animationRiskSamples.length < 10) {
+ stats.animationRiskSamples.push({
+ spawnId,
+ modelName,
+ reason: 'playing-animation-matches-bind-pose',
+ playingGroups: playingVitality,
+ animationVitality: {
+ playableGroupCount: animationVitality.playableGroupCount,
+ dynamicGroupCount: animationVitality.dynamicGroupCount,
+ visuallyPosedGroupCount:
+ animationVitality.visuallyPosedGroupCount,
+ bindPoseCloneGroupCount:
+ animationVitality.bindPoseCloneGroupCount,
+ },
+ });
+ }
} else {
stats.animatedSkeletonSpawnCount++;
modelStats.animatedSkeletonSpawnCount++;
@@ -1798,6 +2994,7 @@ class SpawnController extends GameControllerChild {
}
}
+ stats.nameplateEligibleModelCount = nameplateEligibleModels.size;
return stats;
}
@@ -1805,7 +3002,9 @@ class SpawnController extends GameControllerChild {
if (!this.currentScene) {
return;
}
- const loadToken = ++this.spawnLoadToken;
+ const loadToken = skipDispose
+ ? this.spawnLoadToken
+ : ++this.spawnLoadToken;
this.zoneSpawnsNode = this.currentScene?.getNodeById('zone-spawns');
if (!this.zoneSpawnsNode) {
@@ -1816,6 +3015,9 @@ class SpawnController extends GameControllerChild {
this.clearPostLoadGroundSnapTimers();
if (!skipDispose) {
this.clearSpawnSelection();
+ for (const spawn of Object.values(this.spawns)) {
+ spawn?.dispose?.();
+ }
this.zoneSpawnsNode.getChildren().forEach((c) => c.dispose());
this.spawns = {};
this.assetFallbacks = {};
@@ -1865,7 +3067,7 @@ class SpawnController extends GameControllerChild {
skippedNoNpcType++;
continue;
}
- const realModel = this.resolveSpawnModel(firstSpawn);
+ const realModel = await this.resolveAvailableSpawnModel(firstSpawn);
if (!realModel) {
unresolvedModelSpawns.push({
gender: firstSpawn.gender,
@@ -1924,6 +3126,12 @@ class SpawnController extends GameControllerChild {
);
}
+ if (typeof window !== 'undefined' && window.__spireSagePreview) {
+ window.__spireSageBulkSpawnLoading = true;
+ window.__spireSageSkipBulkNameplates = new URLSearchParams(
+ window.location.search
+ ).has('sageValidateZones');
+ }
try {
for (const [modelName, models] of Object.entries(spawnList)) {
if (loadToken !== this.spawnLoadToken) {
@@ -1932,7 +3140,7 @@ class SpawnController extends GameControllerChild {
this.actions.setLoadingText(
`Loaded ${loadedCount} of ${count} spawns`
);
- const loadedForModel = await this.addSpawn(modelName, models);
+ const loadedForModel = await this.addSpawn(modelName, models, loadToken);
if (loadToken !== this.spawnLoadToken) {
return;
}
@@ -1942,14 +3150,59 @@ class SpawnController extends GameControllerChild {
);
await yieldToBrowser();
}
+ if (typeof window !== 'undefined' && window.__spireSagePreview) {
+ window.__spireSageBulkSpawnLoading = false;
+ const loadedSpawns = Object.values(this.spawns);
+ const scene = this.currentScene;
+ const animationsWereEnabled = scene?.animationsEnabled !== false;
+ if (scene) {
+ scene.animationsEnabled = false;
+ }
+ try {
+ for (let index = 0; index < loadedSpawns.length; index++) {
+ if (loadToken !== this.spawnLoadToken) {
+ return;
+ }
+ if (loadedSpawns[index].previewAnimationDeferred) {
+ const loadedSpawn = loadedSpawns[index];
+ loadedSpawn.startInitialAnimation({
+ skipGroundNormalization: true,
+ schedulePostInitialize: false,
+ });
+ }
+ if (loadedSpawns[index].previewNameplateDeferred) {
+ loadedSpawns[index].createNameplate({
+ validationRepresentative: true,
+ });
+ }
+ if (index % 50 === 49) {
+ await yieldToBrowser();
+ }
+ }
+ } finally {
+ if (scene) {
+ scene.animationsEnabled = animationsWereEnabled;
+ }
+ }
+ }
} finally {
+ if (typeof window !== 'undefined' && window.__spireSagePreview) {
+ window.__spireSageBulkSpawnLoading = false;
+ window.__spireSageSkipBulkNameplates = false;
+ }
if (typeof window !== 'undefined' && window.__spireSagePreview) {
const sceneChildren = this.zoneSpawnsNode?.getChildren?.() ?? [];
+ const loadedSpawns = Object.values(this.spawns);
+ const currentSpawnCount = skipDispose
+ ? loadedSpawns.length
+ : loadedCount;
const stats = {
- requested: count,
- sourceCount: spawns.length,
- modelGroups: Object.keys(spawnList).length,
- loaded: loadedCount,
+ requested: skipDispose ? currentSpawnCount : count,
+ sourceCount: skipDispose ? currentSpawnCount : spawns.length,
+ modelGroups: skipDispose
+ ? new Set(loadedSpawns.map((entry) => entry.modelName)).size
+ : Object.keys(spawnList).length,
+ loaded: currentSpawnCount,
lodProxyCount: sceneChildren.filter(
(child) => child?.metadata?.onlyOccluded === false
).length,
@@ -1971,7 +3224,9 @@ class SpawnController extends GameControllerChild {
if (loadToken === this.spawnLoadToken && loadedCount > 0) {
this.schedulePostLoadGroundSnap(loadToken);
}
- GlobalStore.actions.setLoading(false);
+ if (loadToken === this.spawnLoadToken) {
+ GlobalStore.actions.setLoading(false);
+ }
}
}
@@ -1988,8 +3243,35 @@ class SpawnController extends GameControllerChild {
moveSpawn(infSpawn) {
const spawn = this.spawns[infSpawn.id];
if (spawn) {
+ const currentSpawnData = spawn.spawnEntry;
+ if (currentSpawnData && typeof currentSpawnData === 'object') {
+ Object.assign(currentSpawnData, infSpawn);
+ } else {
+ spawn.spawnEntry = infSpawn;
+ }
+ const synchronizedSpawn = spawn.spawnEntry ?? infSpawn;
+ spawn.metadata = {
+ ...(spawn.metadata ?? {}),
+ spawn: synchronizedSpawn,
+ };
+ const spawnNodes = [
+ spawn.rootNode,
+ ...(spawn.rootNode?.getChildMeshes?.(false) ?? []),
+ spawn.instance,
+ spawn.nameplateMesh,
+ ].filter(Boolean);
+ for (const node of spawnNodes) {
+ node.metadata = {
+ ...(node.metadata ?? {}),
+ spawn: synchronizedSpawn,
+ };
+ }
spawn.rootNode.position.set(infSpawn.y, infSpawn.z, infSpawn.x);
- spawn.normalizeToSpawnGround?.(infSpawn.z);
+ spawn.rootNode.metadata = {
+ ...(spawn.rootNode.metadata ?? {}),
+ preserveRequestedGroundY: true,
+ };
+ spawn.normalizeToSpawnGround?.(infSpawn.z, { snapToZone: false });
if (this.selectedSpawnId === infSpawn.id) {
this.showTargetRing(infSpawn);
}
diff --git a/frontend/eqsage-embed/src/viewer/controllers/ZoneController.js b/frontend/eqsage-embed/src/viewer/controllers/ZoneController.js
index f7112a1c..80b35f89 100644
--- a/frontend/eqsage-embed/src/viewer/controllers/ZoneController.js
+++ b/frontend/eqsage-embed/src/viewer/controllers/ZoneController.js
@@ -6,6 +6,11 @@ import { GlobalStore } from '../../state';
import { assetUrl } from '../../embed-config';
import { createGltfTransformIo, loadGltfTransformModules } from '../../util/gltf-transform';
import { clampFlySpeed } from '../common/cameraSettings';
+import {
+ applyTextureAnimationFrame,
+ getMaterialBaseColorTexture,
+ resolveTextureAnimationFrameUrl,
+} from '../helpers/textureAnimation';
const {
Color3,
@@ -42,6 +47,15 @@ const scheduleZoneLoadCallback = (callback) => {
});
}, 0);
};
+const createCancelledZoneLoadError = () => {
+ const error = new Error('Zone load was cancelled');
+ error.name = 'AbortError';
+ return error;
+};
+const isSceneDisposed = (scene) =>
+ typeof scene?.isDisposed === 'function'
+ ? scene.isDisposed()
+ : Boolean(scene?.isDisposed);
const logPreviewZoneLoad = (...args) => {
if (window.__spireSagePreview) {
console.log('[SageZoneLoad]', ...args);
@@ -183,11 +197,14 @@ class ZoneController extends GameControllerChild {
objectAnimationPlaying = [];
lastPosition = new Vector3(0, 0, 0);
animationRange = 200;
- objectCullRange = 2000;
/** @type {RecastJSPlugin} */
navigationPlugin = null;
pointerObserver = null;
renderObserver = null;
+ loadGeneration = 0;
+ activeMeshEditCleanup = null;
+ activeRaycastCleanup = null;
+ pickingRaycast = false;
loadCallbacks = [];
clickCallbacks = [];
@@ -215,7 +232,16 @@ class ZoneController extends GameControllerChild {
removeLoadCallback = (cb) => {
this.loadCallbacks = this.loadCallbacks.filter((l) => l !== cb);
};
+ cancelPendingLoad = () => {
+ this.loadGeneration += 1;
+ this.zoneLoaded = false;
+ };
dispose() {
+ this.activeMeshEditCleanup?.(false);
+ this.activeMeshEditCleanup = null;
+ this.activeRaycastCleanup?.(null);
+ this.activeRaycastCleanup = null;
+ this.pickingRaycast = false;
this.SpawnController.dispose();
if (this.scene) {
if (this.pointerObserver) {
@@ -421,18 +447,31 @@ class ZoneController extends GameControllerChild {
*/
onClick = (pointerInfo) => {
switch (pointerInfo.type) {
- case PointerEventTypes.POINTERDOWN:
- const spawn = pointerInfo.pickInfo.pickedMesh?.metadata?.spawn;
+ case PointerEventTypes.POINTERDOWN: {
+ const pickedMesh = pointerInfo.pickInfo.pickedMesh;
+ let doorMesh = pickedMesh;
+ while (doorMesh?.parent && doorMesh.parent !== this.doorNode) {
+ doorMesh = doorMesh.parent;
+ }
+ if (
+ pointerInfo.pickInfo.hit &&
+ doorMesh?.parent === this.doorNode &&
+ doorMesh?.dataReference
+ ) {
+ this.clickCallbacks.forEach((callback) => callback(doorMesh));
+ break;
+ }
+
+ const spawn = pickedMesh?.metadata?.spawn;
if (
pointerInfo.pickInfo.hit &&
spawn &&
typeof spawn === 'object'
) {
- this.clickCallbacks.forEach((c) =>
- c(spawn)
- );
+ this.clickCallbacks.forEach((callback) => callback(spawn));
}
break;
+ }
default:
break;
}
@@ -466,13 +505,136 @@ class ZoneController extends GameControllerChild {
}
}
+ /**
+ * Move, rotate, and scale a mesh against the active zone geometry.
+ * @param {Mesh} mesh
+ * @param {(commit: boolean) => void | Promise} callback
+ */
+ editMesh(mesh, callback) {
+ if (!mesh || !this.scene || !this.canvas) {
+ return;
+ }
+ this.activeRaycastCleanup?.(null);
+ this.activeMeshEditCleanup?.(false);
+ this.pickingRaycast = true;
+
+ const originalPosition = mesh.position.clone();
+ const originalScale = mesh.scaling.clone();
+ const originalRotation = mesh.rotation.clone();
+ const originalPickable = mesh.isPickable;
+ const mouseInput = this.CameraController.camera?.inputs?.attached?.mouse;
+ const cameraButtons = [...(mouseInput?.buttons ?? [0, 1, 2])];
+ const pointLight = new PointLight(
+ 'door-edit-light',
+ new Vector3(0, 10, 0),
+ this.scene
+ );
+ pointLight.intensity = 500;
+ pointLight.range = 300;
+ pointLight.radius = 50;
+ pointLight.diffuse = new Color3(1, 1, 1);
+ pointLight.position = mesh.position;
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'raycast-tooltip';
+ tooltip.innerHTML = '[T] Commit - [ESC] Cancel
Left Mouse: Rotate and [Shift] Scale
';
+ document.body.appendChild(tooltip);
+
+ let lastX = null;
+ let lastY = null;
+ let finished = false;
+ mesh.isPickable = false;
+ if (mouseInput) {
+ mouseInput.buttons = [2];
+ }
+
+ const mouseMove = (event) => {
+ if (event.buttons === 1) {
+ let diffX = 0;
+ let diffY = 0;
+ if (lastX !== null && lastY !== null) {
+ diffX = event.clientX - lastX;
+ diffY = event.clientY - lastY;
+ }
+ if (event.shiftKey) {
+ mesh.scaling.setAll(Math.max(0.01, mesh.scaling.y - diffY / 50));
+ } else {
+ mesh.rotation.y += Tools.ToRadians(diffX);
+ }
+ lastX = event.clientX;
+ lastY = event.clientY;
+ event.stopPropagation();
+ event.preventDefault();
+ return;
+ }
+ lastX = null;
+ lastY = null;
+
+ const pickResult = this.scene.pick(
+ this.scene.pointerX,
+ this.scene.pointerY,
+ (candidate) => candidate !== mesh && candidate.isPickable,
+ false,
+ this.CameraController.camera
+ );
+ if (!pickResult.hit || !pickResult.pickedPoint) {
+ return;
+ }
+ const hitPoint = pickResult.pickedPoint;
+ mesh.position.copyFrom(hitPoint);
+ tooltip.style.left = `${event.pageX - tooltip.clientWidth / 2}px`;
+ tooltip.style.top = `${event.pageY + tooltip.clientHeight / 2}px`;
+ tooltip.innerHTML = `[T] Commit - [ESC] Cancel
+ X: ${hitPoint.z.toFixed(2)}, Y: ${hitPoint.x.toFixed(2)}, Z: ${hitPoint.y.toFixed(2)}
+ Left Mouse: Rotate and [Shift] Scale
`;
+ };
+
+ const finish = async (commit) => {
+ if (finished) {
+ return;
+ }
+ finished = true;
+ window.removeEventListener('keydown', keyHandler);
+ this.canvas.removeEventListener('mousemove', mouseMove);
+ if (mouseInput) {
+ mouseInput.buttons = cameraButtons;
+ }
+ mesh.isPickable = originalPickable;
+ if (!commit) {
+ mesh.position.copyFrom(originalPosition);
+ mesh.rotation.copyFrom(originalRotation);
+ mesh.scaling.copyFrom(originalScale);
+ }
+ pointLight.dispose();
+ tooltip.remove();
+ this.activeMeshEditCleanup = null;
+ this.pickingRaycast = false;
+ await callback?.(commit);
+ };
+ const keyHandler = (event) => {
+ if (event.key === 'Escape') {
+ finish(false);
+ } else if (event.key?.toLowerCase() === 't') {
+ finish(true);
+ }
+ };
+
+ this.activeMeshEditCleanup = finish;
+ window.addEventListener('keydown', keyHandler);
+ this.canvas.addEventListener('mousemove', mouseMove);
+ }
+
pickRaycastForLoc(callback) {
const zoneMesh = this.scene.getMeshByName('zone');
if (!zoneMesh) {
return;
}
+ this.activeMeshEditCleanup?.(false);
+ this.activeRaycastCleanup?.(null);
+ const originalZonePickable = zoneMesh.isPickable;
zoneMesh.isPickable = true;
+ this.pickingRaycast = true;
// Create node for moving
const raycastMesh = MeshBuilder.CreateSphere(
@@ -526,26 +688,32 @@ class ZoneController extends GameControllerChild {
// Perform actions based on the hit
const hitPoint = pickResult.pickedPoint;
raycastMesh.position.set(hitPoint.x, hitPoint.y + 5, hitPoint.z);
- chosenLocation = { x: hitPoint.x, y: hitPoint.y + 5, z: hitPoint.z };
+ chosenLocation = { x: hitPoint.x, y: hitPoint.y, z: hitPoint.z };
tooltip.style.left = `${e.pageX - tooltip.clientWidth / 2}px`;
tooltip.style.top = `${e.pageY + tooltip.clientHeight / 2}px`;
tooltip.innerHTML = `[T] to commit - [Escape] to cancel
X: ${hitPoint.z.toFixed(
2
- )}, Y: ${hitPoint.x.toFixed(2)}, Z: ${(hitPoint.y + 5).toFixed(2)}
`;
+ )}, Y: ${hitPoint.x.toFixed(2)}, Z: ${hitPoint.y.toFixed(2)} `;
}
};
- const self = this;
- function finish(loc) {
+ let finished = false;
+ const finish = (loc) => {
+ if (finished) {
+ return;
+ }
+ finished = true;
window.removeEventListener('keydown', keyHandler);
- self.canvas.removeEventListener('mousemove', mouseMove);
- zoneMesh.isPickable = false;
+ this.canvas.removeEventListener('mousemove', mouseMove);
+ zoneMesh.isPickable = originalZonePickable;
raycastMesh.dispose();
pointLight.dispose();
- document.body.removeChild(tooltip);
+ tooltip.remove();
+ this.activeRaycastCleanup = null;
+ this.pickingRaycast = false;
callback(loc);
- }
- function keyHandler(e) {
+ };
+ const keyHandler = (e) => {
if (e.key === 'Escape') {
finish(null);
}
@@ -553,7 +721,8 @@ class ZoneController extends GameControllerChild {
if (e.key?.toLowerCase() === 't') {
finish(chosenLocation);
}
- }
+ };
+ this.activeRaycastCleanup = finish;
window.addEventListener('keydown', keyHandler);
this.canvas.addEventListener('mousemove', mouseMove);
}
@@ -585,6 +754,24 @@ class ZoneController extends GameControllerChild {
}
}
+ assignGlow(mesh) {
+ if (!mesh || !this.glowLayer?.addIncludedOnlyMesh) {
+ return;
+ }
+ [mesh, ...(mesh.getChildMeshes?.(false) ?? [])].forEach((candidate) =>
+ this.glowLayer.addIncludedOnlyMesh(candidate)
+ );
+ }
+
+ unassignGlow(mesh) {
+ if (!mesh || !this.glowLayer?.removeIncludedOnlyMesh) {
+ return;
+ }
+ [mesh, ...(mesh.getChildMeshes?.(false) ?? [])].forEach((candidate) =>
+ this.glowLayer.removeIncludedOnlyMesh(candidate)
+ );
+ }
+
mergeMeshesWithMaterials = (meshes, scene) => {
// Prepare arrays for the merged mesh data
const positions = [];
@@ -662,7 +849,15 @@ class ZoneController extends GameControllerChild {
return newMergedMesh;
};
- async loadModel(name, cachedMetadata = null) {
+ async loadModel(name, cachedMetadata = null, signal = null) {
+ const loadGeneration = ++this.loadGeneration;
+ const isCurrentLoad = () =>
+ loadGeneration === this.loadGeneration && !signal?.aborted;
+ const assertCurrentLoad = () => {
+ if (!isCurrentLoad()) {
+ throw createCancelledZoneLoadError();
+ }
+ };
logPreviewZoneLoad('load:start', name);
this.zoneLoaded = false;
GlobalStore.actions.setLoading(true);
@@ -673,6 +868,8 @@ class ZoneController extends GameControllerChild {
GlobalStore.actions.setLoading(false);
return;
}
+ assertCurrentLoad();
+ const loadScene = this.scene;
this.zoneName = name;
const configuredFlySpeed = Number(
this.cameraFlySpeed ?? this.gc.settings?.flySpeed
@@ -730,6 +927,7 @@ class ZoneController extends GameControllerChild {
}
const metadata = cachedMetadata || (await getEQFile('zones', `${name}.json`, 'json'));
+ assertCurrentLoad();
let zoneRootUrl = '/eq/zones/';
let zoneFileName = `${name}.glb`;
let zoneObjectUrl = null;
@@ -737,6 +935,7 @@ class ZoneController extends GameControllerChild {
const zoneBuffer =
(await this.gc.loadEQGltfFile?.('zones', `${name}.glb`)) ||
(await getEQFile('zones', `${name}.glb`));
+ assertCurrentLoad();
if (!zoneBuffer) {
throw new Error(`Generated zone geometry is missing for ${name}`);
}
@@ -755,7 +954,7 @@ class ZoneController extends GameControllerChild {
'',
zoneRootUrl,
zoneFileName,
- this.scene,
+ loadScene,
undefined,
'.glb'
);
@@ -770,6 +969,10 @@ class ZoneController extends GameControllerChild {
if (!zone?.meshes?.length) {
throw new Error(`No zone meshes were loaded for ${name}`);
}
+ if (!isCurrentLoad()) {
+ zone.meshes.forEach((mesh) => mesh.dispose?.(false, true));
+ throw createCancelledZoneLoadError();
+ }
logPreviewZoneLoad('import:done', name, {
meshCount: zone.meshes.length,
metadata : !!metadata,
@@ -816,6 +1019,7 @@ class ZoneController extends GameControllerChild {
}
await yieldToBrowser();
+ assertCurrentLoad();
if (metadata) {
this.metadata = metadata;
this.objectContainer = new TransformNode(
@@ -827,14 +1031,20 @@ class ZoneController extends GameControllerChild {
!window.__spireSagePreview || this.gc.settings.loadStaticObjects === true;
if (shouldLoadStaticObjects) {
for (const [key, value] of Object.entries(metadata.objects)) {
+ assertCurrentLoad();
await yieldToBrowser();
- for (const mesh of await this.instantiateObjects(key, value)) {
+ for (const mesh of await this.instantiateObjects(key, value, {
+ isCancelled: () => !isCurrentLoad(),
+ scene : loadScene,
+ })) {
if (!mesh) {
continue;
}
+ assertCurrentLoad();
mesh.parent = this.objectContainer;
}
await yieldToBrowser();
+ assertCurrentLoad();
}
}
@@ -849,8 +1059,10 @@ class ZoneController extends GameControllerChild {
metadata.regions = await optimizeBoundingBoxes(
metadata.unoptimizedRegions
);
+ assertCurrentLoad();
delete metadata.unoptimizedRegions;
await writeEQFile('zones', `${name}.json`, JSON.stringify(metadata));
+ assertCurrentLoad();
}
let idx = 0;
@@ -897,10 +1109,13 @@ class ZoneController extends GameControllerChild {
}
}
await yieldToBrowser();
+ assertCurrentLoad();
if (!window.__spireSagePreview) {
await this.addTextureAnimations();
+ assertCurrentLoad();
}
+ assertCurrentLoad();
this.zoneLoaded = true;
this.loadCallbacks.forEach((callback) => {
scheduleZoneLoadCallback(callback);
@@ -909,11 +1124,13 @@ class ZoneController extends GameControllerChild {
GlobalStore.actions.setLoading(false);
}
- async instantiateObjects(modelName, model) {
+ async instantiateObjects(modelName, model, options = {}) {
+ const targetScene = options.scene ?? this.scene;
+ const isCancelled = options.isCancelled ?? (() => false);
const objectBuffer =
(await this.gc.loadEQGltfFile?.('objects', `${modelName}.glb`)) ||
(await getEQFile('objects', `${modelName}.glb`));
- if (!objectBuffer) {
+ if (!objectBuffer || isCancelled() || !targetScene || isSceneDisposed(targetScene)) {
return [];
}
@@ -923,7 +1140,7 @@ class ZoneController extends GameControllerChild {
const container = await SceneLoader.LoadAssetContainerAsync(
'',
objectUrl,
- this.scene,
+ targetScene,
undefined,
'.glb'
)
@@ -932,6 +1149,10 @@ class ZoneController extends GameControllerChild {
if (!container) {
return [];
}
+ if (isCancelled() || isSceneDisposed(targetScene)) {
+ container.dispose();
+ return [];
+ }
const mergedMeshes = [];
for (const [idx, v] of Object.entries(model)) {
@@ -945,7 +1166,7 @@ class ZoneController extends GameControllerChild {
);
if (this.gc.settings.disableAnimations) {
instanceContainer.animationGroups?.forEach((ag) =>
- this.scene.removeAnimationGroup(ag)
+ targetScene.removeAnimationGroup(ag)
);
instanceContainer.animationGroups = [];
}
@@ -1000,12 +1221,18 @@ class ZoneController extends GameControllerChild {
if (instanceSkeleton) {
rootNode.skeleton = instanceSkeleton;
}
- rootNode.addLODLevel(2000, null);
+ // Babylon already frustum-culls placed zone objects. A fixed null LOD
+ // makes large zones visibly pop as the camera or a mesh's bounds cross
+ // the cutoff, which is especially noticeable from the overview camera.
rootNode.id = `${modelName}_${idx}`;
if (!hasAnimations) {
rootNode.freezeWorldMatrix();
} else {
setTimeout(() => {
+ if (isCancelled() || isSceneDisposed(targetScene)) {
+ instanceContainer.animationGroups.forEach((ag) => ag.dispose?.());
+ return;
+ }
instanceContainer.animationGroups.forEach((ag) => {
ag.play(true);
});
@@ -1020,80 +1247,106 @@ class ZoneController extends GameControllerChild {
instanceContainer.rootNodes[0].dispose();
}
+ if (isCancelled() || isSceneDisposed(targetScene)) {
+ mergedMeshes.forEach((mesh) => mesh.dispose?.(false, true));
+ return [];
+ }
return mergedMeshes;
}
async addTextureAnimations() {
- const addTextureAnimation = (material, textureAnimation) => {
- const [baseTexture] = material.getActiveTextures();
- return textureAnimation.frames.map((f) => {
- return new Texture(
- f,
- this.scene,
- baseTexture.noMipMap,
- baseTexture.invertY,
- baseTexture.samplingMode
+ const targetScene = this.scene;
+ if (!targetScene || isSceneDisposed(targetScene)) {
+ return;
+ }
+
+ const animationTimerMap = new Map();
+ const animationTexturesCache = new Map();
+ const getAnimationFrameTexture = (baseTexture, frameName) => {
+ const frameUrl = resolveTextureAnimationFrameUrl(baseTexture, frameName);
+ if (!frameUrl) {
+ return null;
+ }
+
+ const baseUrl = `${baseTexture.url ?? baseTexture.name ?? ''}`;
+ if (frameUrl.toLowerCase() === baseUrl.toLowerCase()) {
+ return baseTexture;
+ }
+
+ const cacheKey = [
+ frameUrl.toLowerCase(),
+ Number(Boolean(baseTexture.noMipMap)),
+ Number(Boolean(baseTexture.invertY)),
+ baseTexture.samplingMode,
+ ].join('|');
+ if (!animationTexturesCache.has(cacheKey)) {
+ animationTexturesCache.set(
+ cacheKey,
+ new Texture(
+ frameUrl,
+ targetScene,
+ baseTexture.noMipMap,
+ baseTexture.invertY,
+ baseTexture.samplingMode
+ )
);
- });
+ }
+ return animationTexturesCache.get(cacheKey);
};
- let animationTimerMap = {};
- const animationTexturesCache = {};
-
- for (const material of this.scene.materials) {
+ for (const material of targetScene.materials) {
if (!material.metadata?.gltf?.extras?.animationDelay) {
continue;
}
- const textureAnimation = material.metadata?.gltf?.extras;
- if (textureAnimation) {
- let allTextures;
- if (animationTexturesCache[material.id]) {
- allTextures = animationTexturesCache[material.id];
- } else {
- allTextures = await addTextureAnimation(material, textureAnimation);
- animationTexturesCache[material.id] = allTextures;
- }
- animationTimerMap = {
- ...animationTimerMap,
- [textureAnimation.animationDelay]: {
- ...(animationTimerMap[textureAnimation.animationDelay] ?? {}),
- materials: [
- ...(animationTimerMap[textureAnimation.animationDelay]
- ?.materials ?? []),
- {
- frames : textureAnimation.frames,
- currentFrame: 1,
- allTextures,
- material,
- },
- ],
- },
- };
+ const textureAnimation = material.metadata.gltf.extras;
+ const targetTexture = getMaterialBaseColorTexture(material);
+ if (!targetTexture || !Array.isArray(textureAnimation.frames)) {
+ continue;
+ }
+
+ const allTextures = textureAnimation.frames
+ .map((frame) => getAnimationFrameTexture(targetTexture, frame))
+ .filter(Boolean);
+ if (allTextures.length < 2) {
+ continue;
}
+
+ const delay = Number(textureAnimation.animationDelay);
+ if (!Number.isFinite(delay) || delay <= 0) {
+ continue;
+ }
+
+ const materials = animationTimerMap.get(delay) ?? [];
+ materials.push({
+ allTextures,
+ currentFrame: 0,
+ targetTexture,
+ });
+ animationTimerMap.set(delay, materials);
}
- for (const [time, value] of Object.entries(animationTimerMap)) {
+ for (const [time, materials] of animationTimerMap.entries()) {
const interval = setInterval(() => {
- for (const material of value.materials) {
- material.currentFrame =
- material.currentFrame + 1 > material.frames.length
- ? 1
- : material.currentFrame + 1;
- for (const texture of material.material.getActiveTextures()) {
- if (material.allTextures[material.currentFrame - 1]) {
- texture._texture =
- material.allTextures[material.currentFrame - 1]._texture;
- }
- }
+ if (isSceneDisposed(targetScene)) {
+ clearInterval(interval);
+ return;
+ }
+
+ for (const animation of materials) {
+ animation.currentFrame =
+ (animation.currentFrame + 1) % animation.allTextures.length;
+ // Texture creation is asynchronous. A missing or still-loading frame
+ // must leave the last valid frame intact rather than turning a large
+ // terrain material black for one animation tick.
+ applyTextureAnimationFrame(
+ animation.targetTexture,
+ animation.allTextures[animation.currentFrame]
+ );
}
}, +time * 2);
- for (const material of value.materials) {
- material.material.onDisposeObservable.add(() => {
- clearInterval(interval);
- });
- }
+ targetScene.onDisposeObservable.addOnce(() => clearInterval(interval));
}
}
}
diff --git a/frontend/eqsage-embed/src/viewer/helpers/animationValidation.js b/frontend/eqsage-embed/src/viewer/helpers/animationValidation.js
new file mode 100644
index 00000000..f3d6634d
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/helpers/animationValidation.js
@@ -0,0 +1,370 @@
+const POSE_ANIMATION_PATTERN = /^(?:Clone of )?pos$/i;
+const VALUE_EPSILON = 0.00001;
+const DEFAULT_ANIMATION_BOUNDS_RATIO_LIMIT = 8;
+const DEFAULT_ANIMATION_BOUNDS_GROWTH_LIMIT = 250;
+export { STATIC_POSE_ONLY_CHARACTER_MODELS } from '../../../sage/lib/util/character-animation-policy.js';
+import { STATIC_POSE_ONLY_CHARACTER_MODELS } from '../../../sage/lib/util/character-animation-policy.js';
+
+// These compact classic models intentionally ship only a posed POS clip. The
+// runtime applies and validates that static pose (including lowered arms), so
+// they must not be conflated with an arbitrary bind-pose-only model such as
+// SDF/SHN, which require an explicit compatible animation donor.
+
+export const isStaticPoseOnlyCharacterModel = (...modelNames) =>
+ modelNames.some((modelName) =>
+ STATIC_POSE_ONLY_CHARACTER_MODELS.has(
+ `${modelName ?? ''}`.trim().slice(0, 3).toLowerCase()
+ )
+ );
+
+const getAnimationKeys = (targetedAnimation) =>
+ targetedAnimation?.animation?.getKeys?.() ??
+ targetedAnimation?.animation?._keys ??
+ [];
+
+const valueComponents = (value) => {
+ if (Number.isFinite(value)) {
+ return [Number(value)];
+ }
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
+ return Array.from(value, Number);
+ }
+ if (typeof value?.asArray === 'function') {
+ return value.asArray().map(Number);
+ }
+
+ const components = ['x', 'y', 'z', 'w']
+ .filter((key) => Number.isFinite(value?.[key]))
+ .map((key) => Number(value[key]));
+ return components.length > 0 ? components : [`${value ?? ''}`];
+};
+
+const valuesDiffer = (left, right) => {
+ const leftValues = valueComponents(left);
+ const rightValues = valueComponents(right);
+ if (leftValues.length !== rightValues.length) {
+ return true;
+ }
+ return leftValues.some((value, index) => {
+ const other = rightValues[index];
+ if (Number.isFinite(value) && Number.isFinite(other)) {
+ return Math.abs(value - other) > VALUE_EPSILON;
+ }
+ return value !== other;
+ });
+};
+
+const targetedAnimationKey = (targetedAnimation) => {
+ const target = targetedAnimation?.target;
+ const targetName =
+ target?.id ?? target?.name ?? target?.uniqueId ?? 'unknown-target';
+ const property =
+ targetedAnimation?.animation?.targetProperty ?? 'unknown-property';
+ return `${targetName}:${property}`;
+};
+
+const getPoseValues = (poseGroup) => {
+ const poseValues = new Map();
+ for (const targetedAnimation of poseGroup?.targetedAnimations ?? []) {
+ const firstKey = getAnimationKeys(targetedAnimation)[0];
+ if (firstKey) {
+ poseValues.set(targetedAnimationKey(targetedAnimation), firstKey.value);
+ }
+ }
+ return poseValues;
+};
+
+export const inspectAnimationGroupVitality = (animationGroup, poseGroup = null) => {
+ const poseValues = getPoseValues(poseGroup);
+ let keyedTargetCount = 0;
+ let dynamicTargetCount = 0;
+ let poseMatchedTargetCount = 0;
+ let poseDeltaTargetCount = 0;
+ let maximumKeyCount = 0;
+
+ for (const targetedAnimation of animationGroup?.targetedAnimations ?? []) {
+ const keys = getAnimationKeys(targetedAnimation);
+ if (keys.length === 0) {
+ continue;
+ }
+ keyedTargetCount++;
+ maximumKeyCount = Math.max(maximumKeyCount, keys.length);
+ if (keys.slice(1).some((key) => valuesDiffer(keys[0].value, key.value))) {
+ dynamicTargetCount++;
+ }
+
+ const poseValue = poseValues.get(targetedAnimationKey(targetedAnimation));
+ if (poseValue !== undefined) {
+ poseMatchedTargetCount++;
+ if (valuesDiffer(keys[0].value, poseValue)) {
+ poseDeltaTargetCount++;
+ }
+ }
+ }
+
+ const isBindPoseClone =
+ keyedTargetCount > 0 &&
+ dynamicTargetCount === 0 &&
+ poseMatchedTargetCount > 0 &&
+ poseDeltaTargetCount === 0;
+ return {
+ name: `${animationGroup?.name ?? ''}`,
+ targetedAnimationCount:
+ animationGroup?.targetedAnimations?.length ?? 0,
+ keyedTargetCount,
+ maximumKeyCount,
+ dynamicTargetCount,
+ poseMatchedTargetCount,
+ poseDeltaTargetCount,
+ isBindPoseClone,
+ hasVisualPose: dynamicTargetCount > 0 || poseDeltaTargetCount > 0,
+ };
+};
+
+export const inspectAnimationSetVitality = (animationGroups = []) => {
+ const poseGroup = animationGroups.find((group) =>
+ POSE_ANIMATION_PATTERN.test(`${group?.name ?? ''}`)
+ ) ?? null;
+ const playableGroups = animationGroups.filter(
+ (group) =>
+ !POSE_ANIMATION_PATTERN.test(`${group?.name ?? ''}`) &&
+ (group?.targetedAnimations?.length ?? 0) > 0
+ );
+ const groups = playableGroups.map((group) =>
+ inspectAnimationGroupVitality(group, poseGroup)
+ );
+ return {
+ poseGroupPresent: !!poseGroup,
+ playableGroupCount: groups.length,
+ dynamicGroupCount: groups.filter((group) => group.dynamicTargetCount > 0).length,
+ visuallyPosedGroupCount: groups.filter((group) => group.hasVisualPose).length,
+ bindPoseCloneGroupCount: groups.filter((group) => group.isBindPoseClone).length,
+ motionlessGroupCount: groups.filter((group) => !group.hasVisualPose).length,
+ motionlessGroups: groups
+ .filter((group) => !group.hasVisualPose)
+ .slice(0, 20),
+ groups,
+ };
+};
+
+export const evaluateCharacterAnimationReadiness = ({
+ skeletonCount = 0,
+ animationVitality = {},
+ staticPoseFallbackAvailable = false,
+} = {}) => {
+ const violations = [];
+ if (Number(skeletonCount) > 0 && staticPoseFallbackAvailable !== true) {
+ if (Number(animationVitality.playableGroupCount ?? 0) === 0) {
+ violations.push('missing-playable-animation');
+ } else if (Number(animationVitality.visuallyPosedGroupCount ?? 0) === 0) {
+ violations.push('animation-matches-bind-pose');
+ }
+ }
+ return {
+ pass: violations.length === 0,
+ violations,
+ };
+};
+
+export const evaluateAnimatedBoundsSafety = ({
+ baselineMaxDimension,
+ currentMaxDimension,
+ ratioLimit = DEFAULT_ANIMATION_BOUNDS_RATIO_LIMIT,
+ growthLimit = DEFAULT_ANIMATION_BOUNDS_GROWTH_LIMIT,
+} = {}) => {
+ const baseline = Number(baselineMaxDimension);
+ const current = Number(currentMaxDimension);
+ if (
+ !Number.isFinite(baseline) ||
+ baseline <= 0 ||
+ !Number.isFinite(current) ||
+ current <= 0
+ ) {
+ return {
+ measurable: false,
+ pass: true,
+ baselineMaxDimension: Number.isFinite(baseline) ? baseline : null,
+ currentMaxDimension: Number.isFinite(current) ? current : null,
+ maximumSafeDimension: null,
+ ratio: null,
+ };
+ }
+
+ const safeRatioLimit = Math.max(1, Number(ratioLimit) || 1);
+ const safeGrowthLimit = Math.max(0, Number(growthLimit) || 0);
+ const maximumSafeDimension = Math.max(
+ baseline * safeRatioLimit,
+ baseline + safeGrowthLimit
+ );
+ return {
+ measurable: true,
+ pass: current <= maximumSafeDimension,
+ baselineMaxDimension: baseline,
+ currentMaxDimension: current,
+ maximumSafeDimension,
+ ratio: current / baseline,
+ };
+};
+
+export const evaluateHeadRotationSafety = ({
+ baselineQuaternion,
+ currentQuaternion,
+ maximumDegrees = 120,
+} = {}) => {
+ const components = (value) => [value?.x, value?.y, value?.z, value?.w]
+ .map(Number);
+ const baseline = components(baselineQuaternion);
+ const current = components(currentQuaternion);
+ if (
+ baseline.some((value) => !Number.isFinite(value)) ||
+ current.some((value) => !Number.isFinite(value))
+ ) {
+ return { pass: true, measurable: false, angleDegrees: null, maximumDegrees };
+ }
+ const baselineLength = Math.hypot(...baseline);
+ const currentLength = Math.hypot(...current);
+ if (baselineLength <= 0.000001 || currentLength <= 0.000001) {
+ return { pass: true, measurable: false, angleDegrees: null, maximumDegrees };
+ }
+ const dot = Math.min(1, Math.abs(
+ baseline.reduce(
+ (total, value, index) => total +
+ ((value / baselineLength) * (current[index] / currentLength)),
+ 0
+ )
+ ));
+ const angleDegrees = (2 * Math.acos(dot) * 180) / Math.PI;
+ return {
+ pass: angleDegrees <= maximumDegrees,
+ measurable: true,
+ angleDegrees,
+ maximumDegrees,
+ };
+};
+
+const normalizeVisualAnimationName = (value) => `${value ?? ''}`
+ .replace(/^Clone of /, '')
+ .trim()
+ .toLowerCase();
+
+// Classic player-race archives expose O01 as their neutral standing idle.
+// Modern archives use descriptive idle/STND names instead. P01 is deliberately
+// excluded: on classic Wood Elves it is a combat-ready pose with raised arms,
+// which moves over time but is not an acceptable default zone-editor idle.
+export const isNeutralIdleAnimationName = (value) => {
+ const name = normalizeVisualAnimationName(value);
+ return (
+ name === 'o01' ||
+ /idle.*nooffset$/i.test(name) ||
+ /(?:^|[_\-.])idle(?:[_\-.]|$)/i.test(name) ||
+ /stnd.*nooffset$/i.test(name) ||
+ /(?:^|[_\-.])stnd(?:[_\-.]|$)/i.test(name)
+ );
+};
+
+export const selectPreferredVisualAnimationGroup = (
+ animationGroups = [],
+ preferredName = 'Clone of o01'
+) => {
+ const playableGroups = animationGroups.filter(
+ (group) =>
+ !POSE_ANIMATION_PATTERN.test(`${group?.name ?? ''}`) &&
+ (group?.targetedAnimations?.length ?? 0) > 0
+ );
+ if (playableGroups.length === 0) {
+ return null;
+ }
+
+ const vitality = inspectAnimationSetVitality(animationGroups);
+ const vitalityByName = new Map(
+ vitality.groups.map((group) => [group.name, group])
+ );
+ const dynamicGroups = playableGroups.filter(
+ (group) => (vitalityByName.get(`${group?.name ?? ''}`)?.dynamicTargetCount ?? 0) > 0
+ );
+ const visuallyPosedGroups = playableGroups.filter(
+ (group) => vitalityByName.get(`${group?.name ?? ''}`)?.hasVisualPose === true
+ );
+
+ const normalizeName = (group) => normalizeVisualAnimationName(group?.name);
+ const normalizedPreferredName = normalizeVisualAnimationName(preferredName);
+ const idleRank = (group) => {
+ const name = normalizeName(group);
+ if (name === normalizedPreferredName) return 0;
+ // Native character archives often list swim/combat clips before idle.
+ // Prefer the no-offset idle clip so root motion cannot displace or explode
+ // the character hierarchy while it is standing in the zone editor.
+ if (/idle.*nooffset$/i.test(name)) return 1;
+ if (/(?:^|[_\-.])idle(?:[_\-.]|$)/i.test(name)) return 2;
+ if (/stnd.*nooffset$/i.test(name)) return 3;
+ if (/(?:^|[_\-.])stnd(?:[_\-.]|$)/i.test(name)) return 4;
+ // P01 is the historical fallback for classic models that genuinely do
+ // not contain O01. Keep it ahead of unrelated combat/swim clips without
+ // letting it override a neutral idle that is actually available.
+ if (name === 'p01') return 5;
+ if (/nooffset$/i.test(name)) return 6;
+ return 7;
+ };
+ const preferIdle = (groups) => groups
+ .map((group, index) => ({ group, index, rank: idleRank(group) }))
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
+ .map(({ group }) => group);
+ const orderedDynamicGroups = preferIdle(dynamicGroups);
+ const orderedVisuallyPosedGroups = preferIdle(visuallyPosedGroups);
+
+ // A constant clip can differ from the bind pose and still look valid, but it
+ // must not win over a clip that actually changes over time. Otherwise the
+ // engine reports a playing animation while the NPC remains frozen.
+ return (
+ orderedDynamicGroups[0] ??
+ orderedVisuallyPosedGroups[0] ??
+ null
+ );
+};
+
+const normalizeAnimationTargetName = (value) =>
+ `${value ?? ''}`.replace(/^Clone of /, '').trim().toLowerCase();
+
+export const retargetDetachedAnimationTargets = (
+ animationGroups = [],
+ instantiatedNodes = []
+) => {
+ const nodes = instantiatedNodes.filter(Boolean);
+ const nodeSet = new Set(nodes);
+ const nodesByName = new Map();
+ for (const node of nodes) {
+ const name = normalizeAnimationTargetName(node?.name);
+ if (!name) continue;
+ const candidates = nodesByName.get(name) ?? [];
+ candidates.push(node);
+ nodesByName.set(name, candidates);
+ }
+
+ let detachedTargetCount = 0;
+ let retargetedTargetCount = 0;
+ let unresolvedTargetCount = 0;
+ for (const group of animationGroups) {
+ for (const targetedAnimation of group?.targetedAnimations ?? []) {
+ if (nodeSet.has(targetedAnimation?.target)) {
+ continue;
+ }
+ detachedTargetCount++;
+ const targetName = normalizeAnimationTargetName(
+ targetedAnimation?.target?.name
+ );
+ const candidates = nodesByName.get(targetName) ?? [];
+ if (candidates.length !== 1) {
+ unresolvedTargetCount++;
+ continue;
+ }
+ targetedAnimation.target = candidates[0];
+ retargetedTargetCount++;
+ }
+ }
+
+ return {
+ detachedTargetCount,
+ retargetedTargetCount,
+ unresolvedTargetCount,
+ };
+};
diff --git a/frontend/eqsage-embed/src/viewer/helpers/appearanceValidation.js b/frontend/eqsage-embed/src/viewer/helpers/appearanceValidation.js
new file mode 100644
index 00000000..b5474c1a
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/helpers/appearanceValidation.js
@@ -0,0 +1,234 @@
+const toCount = (value) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+};
+
+const getMaterialTexture = (material) =>
+ material?.albedoTexture ??
+ material?._albedoTexture ??
+ material?.diffuseTexture ??
+ material?._diffuseTexture ??
+ material?.emissiveTexture ??
+ material?._emissiveTexture ??
+ null;
+
+const SEMANTIC_HEAD_ORIENTATION_POLICIES = new Map([
+ // The Iksar skeleton's separate head archive exposes stable cranium and jaw
+ // material anchors. Comparing their rendered world-space centers catches a
+ // genuinely inverted deforming head without applying a destructive rig
+ // transform to an asset that is already authored upright.
+ ['iks', {
+ // ikshe00 uses 0001 for the cranium; ikshe01 uses 0006 for its crest.
+ upperMaterials: ['ikshe0001', 'ikshe0006'],
+ lowerMaterials: ['ikshe0005'],
+ upperLabel: 'cranium',
+ lowerLabel: 'jaw',
+ minimumSeparationRatio: 0.01,
+ }],
+]);
+
+export const getSemanticHeadOrientationPolicy = (modelName) => {
+ const policy = SEMANTIC_HEAD_ORIENTATION_POLICIES.get(
+ `${modelName ?? ''}`.trim().slice(0, 3).toLowerCase()
+ );
+ return policy ? { ...policy } : null;
+};
+
+export const evaluateSemanticHeadOrientation = ({
+ policy,
+ upperCenterY,
+ lowerCenterY,
+ modelHeight,
+}) => {
+ if (!policy) {
+ return {
+ required: false,
+ measurable: false,
+ pass: true,
+ };
+ }
+
+ const normalizedHeight = Math.abs(Number(modelHeight));
+ const minimumSeparation = Number.isFinite(normalizedHeight)
+ ? Math.max(0.001, normalizedHeight * Number(policy.minimumSeparationRatio ?? 0))
+ : 0.001;
+ const hasCoordinate = (value) =>
+ value !== null &&
+ value !== undefined &&
+ value !== '' &&
+ Number.isFinite(Number(value));
+ const measurable = hasCoordinate(upperCenterY) && hasCoordinate(lowerCenterY);
+ const normalizedUpperY = measurable ? Number(upperCenterY) : null;
+ const normalizedLowerY = measurable ? Number(lowerCenterY) : null;
+ const separation = measurable
+ ? normalizedUpperY - normalizedLowerY
+ : null;
+
+ return {
+ ...policy,
+ required: true,
+ measurable,
+ pass: measurable && separation >= minimumSeparation,
+ upperCenterY: measurable ? normalizedUpperY : null,
+ lowerCenterY: measurable ? normalizedLowerY : null,
+ separation,
+ minimumSeparation,
+ };
+};
+
+export const inspectHeadTextureOrientation = (
+ material,
+ orientationPolicy
+) => {
+ const texture = getMaterialTexture(material);
+ const requestedInvertY = texture?._texture?._spireSageRequestedInvertY;
+ const uploadInvertY = texture?._texture?._spireSageUploadInvertY;
+ const uploadOrientationMismatch =
+ typeof requestedInvertY === 'boolean' &&
+ typeof uploadInvertY === 'boolean' &&
+ requestedInvertY !== uploadInvertY;
+ const geometryUvFlipped =
+ material?.metadata?.gltf?.extras?.spireSkinnedVFlipped === true ||
+ material?.metadata?.extras?.spireSkinnedVFlipped === true ||
+ material?.metadata?.spireSkinnedVFlipped === true;
+ const expectsGeometryUvFlip = orientationPolicy?.geometryUvFlipped === true;
+ const expectsRuntimeTextureVFlip =
+ orientationPolicy?.runtimeTextureVFlipped === true;
+ const runtimeTextureVFlipped = Number(texture?.vScale ?? 1) < 0;
+ const expectedEffectiveVFlip =
+ expectsGeometryUvFlip !== expectsRuntimeTextureVFlip;
+ const effectiveVFlip = geometryUvFlipped !== runtimeTextureVFlipped;
+
+ return {
+ material: material?.name ?? '',
+ texture: texture?.name ?? texture?.url ?? '',
+ requestedInvertY,
+ uploadInvertY,
+ vOffset: texture?.vOffset,
+ vScale: texture?.vScale,
+ geometryUvFlipped,
+ runtimeTextureVFlipped,
+ expectsGeometryUvFlip,
+ expectsRuntimeTextureVFlip,
+ effectiveVFlip,
+ expectedEffectiveVFlip,
+ uploadOrientationMismatch,
+ risk:
+ effectiveVFlip !== expectedEffectiveVFlip || uploadOrientationMismatch,
+ };
+};
+
+// These race entries are particles/boundaries rather than visible character
+// bodies in the shipped client art. Keep the list deliberately narrow so a
+// newly invisible NPC cannot pass merely because every material is marked as
+// an effect or boundary.
+export const KNOWN_EFFECT_ONLY_CHARACTER_MODELS = new Set([
+ 'gsm',
+ 'gsf',
+ 'gsn',
+]);
+
+export const isKnownEffectOnlyCharacterModel = (modelName) =>
+ KNOWN_EFFECT_ONLY_CHARACTER_MODELS.has(
+ `${modelName ?? ''}`.trim().slice(0, 3).toLowerCase()
+ );
+
+// Character materials encode the appearance version in a four-character
+// suffix (for example HUMCH0001 or HUFUAsk01). Object materials such as
+// ERBEDSIDE can also be attached to race-coded spawns, but their trailing
+// letters are semantic names and must never be rewritten as appearance slots.
+export const isCharacterAppearanceMaterialName = (materialName) =>
+ /^[a-z0-9]{3,}(?:\d{4}|sk\d{2})$/i.test(`${materialName ?? ''}`);
+
+export const evaluateAppearanceVariant = (
+ appearance,
+ { requireHeadTexture = false } = {}
+) => {
+ const violations = [];
+ const materialSlotCount = toCount(appearance?.materialSlotCount);
+ const effectOnlyMaterialCount = toCount(appearance?.effectOnlyMaterialCount);
+ const texturedSlotCount = toCount(appearance?.texturedSlotCount);
+ const ordinaryMaterialCount = Math.max(
+ 0,
+ materialSlotCount - effectOnlyMaterialCount
+ );
+ const untexturedRenderedMaterialCount = Math.max(
+ toCount(appearance?.untexturedRenderedMaterialCount),
+ ordinaryMaterialCount - texturedSlotCount
+ );
+
+ if (appearance?.renderPass !== true) violations.push('render-failed');
+ if (materialSlotCount === 0) violations.push('missing-rendered-materials');
+ if (untexturedRenderedMaterialCount > 0) {
+ violations.push('untextured-rendered-material');
+ }
+ if (toCount(appearance?.pendingTextureCount) > 0) {
+ violations.push('texture-pending');
+ }
+ if (toCount(appearance?.suspiciousTinyTextureCount) > 0) {
+ violations.push('transparent-texture-fallback');
+ }
+ if (toCount(appearance?.headOrientationRiskCount) > 0) {
+ violations.push('head-texture-orientation');
+ }
+ if (toCount(appearance?.nonFiniteBoneMatrixCount) > 0) {
+ violations.push('invalid-bone-matrix');
+ }
+ if (
+ ordinaryMaterialCount > 0 &&
+ texturedSlotCount === 0
+ ) {
+ violations.push('untextured-model');
+ }
+ if (
+ requireHeadTexture &&
+ (appearance?.headTextureSignatures?.length ?? 0) === 0
+ ) {
+ violations.push('missing-head-texture');
+ }
+
+ return {
+ ...appearance,
+ ordinaryMaterialCount,
+ untexturedRenderedMaterialCount,
+ invariantViolations: [...new Set(violations)],
+ invariantPass: violations.length === 0,
+ };
+};
+
+export const evaluateFaceVariantDeterminism = (
+ faceVariants = [],
+ { expectedVariantCount = 8 } = {}
+) => {
+ const signatures = faceVariants.map((variant) =>
+ (variant.headTextureSignatures ?? []).join('|')
+ );
+ const nonEmptySignatures = signatures.filter(Boolean);
+ const uniqueSignatures = [...new Set(nonEmptySignatures)];
+ const duplicateSignatures = [...new Set(
+ nonEmptySignatures.filter(
+ (signature, index) => nonEmptySignatures.indexOf(signature) !== index
+ )
+ )];
+ const violations = [];
+
+ if (faceVariants.length !== expectedVariantCount) {
+ violations.push('face-variant-count-mismatch');
+ }
+ if (nonEmptySignatures.length !== faceVariants.length) {
+ violations.push('face-variant-texture-missing');
+ }
+ if (uniqueSignatures.length !== faceVariants.length) {
+ violations.push('face-variant-texture-duplicate');
+ }
+
+ return {
+ expectedVariantCount,
+ observedVariantCount: faceVariants.length,
+ uniqueSignatureCount: uniqueSignatures.length,
+ signatures,
+ duplicateSignatures,
+ violations,
+ pass: violations.length === 0,
+ };
+};
diff --git a/frontend/eqsage-embed/src/viewer/helpers/nameplateValidation.js b/frontend/eqsage-embed/src/viewer/helpers/nameplateValidation.js
new file mode 100644
index 00000000..1314c84f
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/helpers/nameplateValidation.js
@@ -0,0 +1,41 @@
+const finiteNumber = (value) => {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+};
+
+export const evaluateNameplatePlacement = ({
+ bodyTopLocalY,
+ nameplateCenterLocalY,
+ planeHeight,
+ rootScaleY = 1,
+ requiredWorldClearance = 0,
+ tolerance = 0.005,
+} = {}) => {
+ const bodyTop = finiteNumber(bodyTopLocalY);
+ const center = finiteNumber(nameplateCenterLocalY);
+ const height = finiteNumber(planeHeight);
+ const scale = Math.abs(finiteNumber(rootScaleY) ?? 1) || 1;
+ const requiredClearance = Math.max(
+ 0,
+ finiteNumber(requiredWorldClearance) ?? 0
+ );
+ const allowedTolerance = Math.max(0, finiteNumber(tolerance) ?? 0);
+ const finite = bodyTop !== null && center !== null && height !== null && height > 0;
+ const bottomLocalY = finite ? center - height / 2 : null;
+ const clearanceLocalY = finite ? bottomLocalY - bodyTop : null;
+ const clearanceWorldY = finite ? clearanceLocalY * scale : null;
+
+ return {
+ finite,
+ bodyTopLocalY: bodyTop,
+ nameplateCenterLocalY: center,
+ planeHeight: height,
+ bottomLocalY,
+ clearanceLocalY,
+ clearanceWorldY,
+ requiredWorldClearance: requiredClearance,
+ pass:
+ finite &&
+ clearanceWorldY + allowedTolerance >= requiredClearance,
+ };
+};
diff --git a/frontend/eqsage-embed/src/viewer/helpers/textureAnimation.js b/frontend/eqsage-embed/src/viewer/helpers/textureAnimation.js
new file mode 100644
index 00000000..c0b2366a
--- /dev/null
+++ b/frontend/eqsage-embed/src/viewer/helpers/textureAnimation.js
@@ -0,0 +1,50 @@
+const absoluteTextureUrlPattern = /^(?:blob:|data:|https?:\/\/|\/)/i;
+
+export const getMaterialBaseColorTexture = (material) =>
+ material?.albedoTexture ??
+ material?.diffuseTexture ??
+ material?.getActiveTextures?.()[0] ??
+ null;
+
+export const resolveTextureAnimationFrameUrl = (baseTexture, frameName) => {
+ const frame = `${frameName ?? ''}`.trim();
+ if (!frame || absoluteTextureUrlPattern.test(frame)) {
+ return frame;
+ }
+
+ const baseUrl = `${baseTexture?.url ?? baseTexture?.name ?? ''}`
+ .split(/[?#]/, 1)[0]
+ .replaceAll('\\', '/');
+ const slashIndex = baseUrl.lastIndexOf('/');
+ if (slashIndex >= 0 && !/^(?:blob:|data:)/i.test(baseUrl)) {
+ return `${baseUrl.slice(0, slashIndex + 1)}${frame}`;
+ }
+
+ return `/eq/textures/${frame}`;
+};
+
+export const isTextureAnimationFrameReady = (texture) => {
+ if (!texture) {
+ return false;
+ }
+ if (typeof texture.isReady === 'function' && !texture.isReady()) {
+ return false;
+ }
+
+ const internalTexture = texture.getInternalTexture?.() ?? texture._texture;
+ return Boolean(
+ internalTexture &&
+ (internalTexture.isReady === undefined || internalTexture.isReady === true)
+ );
+};
+
+export const applyTextureAnimationFrame = (targetTexture, frameTexture) => {
+ if (!targetTexture || !isTextureAnimationFrameReady(frameTexture)) {
+ return false;
+ }
+
+ const internalTexture =
+ frameTexture.getInternalTexture?.() ?? frameTexture._texture;
+ targetTexture._texture = internalTexture;
+ return true;
+};
diff --git a/frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js b/frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js
index 6d0cdbdb..73b1d0ce 100644
--- a/frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js
+++ b/frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js
@@ -2,7 +2,26 @@ import BABYLON from '@bjs';
import { Spawn } from './Spawn';
import { eqtoBabylonVector } from '../util/vector';
import { AnimationNames, mapAnimations } from '../helpers/animationUtils';
-import { getEQFileExists } from 'sage-core/util/fileHandler';
+import {
+ evaluateAnimatedBoundsSafety,
+ evaluateHeadRotationSafety,
+ inspectAnimationGroupVitality,
+ inspectAnimationSetVitality,
+ isNeutralIdleAnimationName,
+ isStaticPoseOnlyCharacterModel,
+ retargetDetachedAnimationTargets,
+ selectPreferredVisualAnimationGroup,
+} from '../helpers/animationValidation';
+import { getCharacterBodyModelVariation } from '../common/raceModelResolution';
+import raceModelMetadata from '../common/raceModelMetadata.json';
+import { evaluateNameplatePlacement } from '../helpers/nameplateValidation';
+import { isCharacterAppearanceMaterialName } from '../helpers/appearanceValidation';
+import {
+ getEQFile,
+ getEQFileDirectoryRevision,
+ getEQFileExists,
+} from 'sage-core/util/fileHandler';
+import { getCharacterHeadOrientationPolicy } from 'sage-core/util/character-texture-orientation';
const {
Color3,
@@ -22,15 +41,24 @@ const {
const INVISIBLE_SPAWN_MODELS = new Set(['tpf', 'tpm', 'tpn']);
const textureExistsCache = new Map();
+const transparentSentinelTextureCache = new Map();
const modelExistsCache = new Map();
+const animationSafetyCache = new Map();
+const pendingAppearanceMaterialsByScene = new WeakMap();
+const APPEARANCE_TEXTURE_DECODE_ATTEMPTS = 2;
+const APPEARANCE_TEXTURE_DECODE_TIMEOUT_MS = 10000;
const DEFAULT_SPAWN_SCALE = 1.5;
const SPAWN_SIZE_SCALE_DIVISOR = 6;
const FOOT_MATERIAL_PATTERN = /ft\d{4}$/i;
const HEAD_MATERIAL_PATTERN = /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
-const HEAD_OR_FACE_MATERIAL_PATTERN =
- /^[a-z0-9]{3}(?:he(?:\d{2}|sk)\d{2}|fa\d{4})$/i;
const HEAD_BONE_PATTERN = /^(?:he|ne|fa|head_point|hair_point)/i;
+// Validate the actual deforming head bone. `head_point` is an attachment
+// helper whose native bind offset is 120 degrees on classic player rigs; it
+// does not drive the face and treating it as a head bone creates a deterministic
+// false positive for every Human/Elf-sized model.
+const PRIMARY_HEAD_BONE_PATTERN = /^(?:hehead|head)$/i;
const CLASSIC_MALE_LEG_SPIKE_MATERIAL_PATTERN = /^(?:bam|erm|hum)lg000[12]$/i;
+const BODY_VARIANT_CLOTHING_PREFIX_PATTERN = /(?:ch|fa|ua|lg|ft)$/i;
const POSE_ANIMATION_PATTERN = /^(?:Clone of )?pos$/i;
const NAMEPLATE_FONT_FAMILY = 'Arial, Helvetica, sans-serif';
const NAMEPLATE_FONT_SIZE = 44;
@@ -85,7 +113,124 @@ const ZONE_GROUND_SNAP_DISABLED_MODELS = new Set([
]);
const ZONE_GROUND_SNAP_PARENT_IDS = new Set(['static-objects', 'doors']);
const SEPARATE_HEAD_MODELS = new Set(['ghu', 'zof', 'zom']);
-const DOUBLE_SIDED_SPAWN_MODELS = new Set(['ghu']);
+const DOUBLE_SIDED_SPAWN_MODELS = new Set(['brf', 'frf', 'ghu', 'goj']);
+const COMPACT_NATIVE_ARM_NEUTRAL_ROTATIONS = new Map([
+ ['qcf', { axis: 'z', amount: 0.65 }],
+ ['clm', { axis: 'z', amount: 0.15 }],
+ ['com', { axis: 'x', amount: 1 }],
+ ['cof', { axis: 'x', amount: 1 }],
+ // CLF's bicep nodes use a different local basis from CLM. Rotating around
+ // local Z only twists the arms in depth; local X lowers them visibly.
+ ['clf', { axis: 'x', amount: 1 }],
+]);
+
+const getPendingAppearanceMaterials = (scene) => {
+ let pending = pendingAppearanceMaterialsByScene.get(scene);
+ if (!pending) {
+ pending = new Map();
+ pendingAppearanceMaterialsByScene.set(scene, pending);
+ }
+ return pending;
+};
+
+const getAppearanceTextureData = async (fileName, attempts = 3) => {
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ const textureData = await getEQFile('textures', fileName).catch(() => false);
+ if ((textureData?.byteLength ?? 0) > 0) {
+ return textureData;
+ }
+ if (attempt < attempts) {
+ await new Promise((resolve) => setTimeout(resolve, attempt * 40));
+ }
+ }
+ return false;
+};
+
+const startAppearanceTextureDecode = ({
+ name,
+ scene,
+ sourceTexture,
+ textureData,
+ material,
+ assignTexture,
+}) => {
+ let attempt = 0;
+ const startAttempt = () => {
+ attempt++;
+ if (material.isDisposed?.()) {
+ material.metadata.spireAppearanceTexturePending = false;
+ return null;
+ }
+ // Blob URLs give Babylon an independently decodable browser image with a
+ // real MIME type and a unique cache key. Revoke the URL after Babylon has
+ // uploaded the pixels; the GPU texture remains valid.
+ const objectUrl = URL.createObjectURL(
+ new Blob([textureData], { type: 'image/png' })
+ );
+ let texture = null;
+ let settled = false;
+ const finish = (success, error = null) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timeout);
+ URL.revokeObjectURL(objectUrl);
+ material.metadata.spireAppearanceTextureDecodeAttempts = attempt;
+ if (success && texture?.isReady?.()) {
+ material.metadata.spireAppearanceTexturePending = false;
+ material.metadata.spireAppearanceTextureDecodeFailed = false;
+ material.metadata.spireAppearanceTextureDecodeLastError = null;
+ return;
+ }
+ material.metadata.spireAppearanceTextureDecodeLastError =
+ error ? `${error}` : 'unknown decode failure';
+ texture?.dispose?.();
+ if (
+ attempt < APPEARANCE_TEXTURE_DECODE_ATTEMPTS &&
+ !material.isDisposed?.()
+ ) {
+ queueMicrotask(() => startAttempt());
+ return;
+ }
+ material.metadata.spireAppearanceTexturePending = false;
+ material.metadata.spireAppearanceTextureDecodeFailed = true;
+ };
+ const timeout = setTimeout(
+ () => finish(false, 'decode timeout'),
+ APPEARANCE_TEXTURE_DECODE_TIMEOUT_MS
+ );
+ try {
+ texture = new Texture(
+ objectUrl,
+ scene,
+ sourceTexture.noMipMap,
+ sourceTexture.invertY,
+ sourceTexture.samplingMode,
+ () => finish(true),
+ (message, exception) =>
+ finish(false, exception?.message ?? message ?? 'image decode error')
+ );
+ if (settled) {
+ texture.dispose?.();
+ return texture;
+ }
+ // Preserve the EQ material name for diagnostics.
+ texture.name = name;
+ assignTexture(material, texture);
+ queueMicrotask(() => {
+ if (texture?.isReady?.()) {
+ finish(true);
+ }
+ });
+ } catch (_error) {
+ finish(false, _error?.message ?? 'texture construction error');
+ }
+ return texture;
+ };
+ return startAttempt();
+};
+
const SECONDARY_HEAD_MODEL_PREFIXES = [
'bam',
'baf',
@@ -93,6 +238,7 @@ const SECONDARY_HEAD_MODEL_PREFIXES = [
'erf',
'elf',
'elm',
+ 'frg',
'gnf',
'gnm',
'trf',
@@ -123,37 +269,11 @@ const SECONDARY_HEAD_MODEL_PREFIXES = [
'ghu',
'zof',
'zom',
+ 'qcm',
+ 'qcf',
+ 'clm',
+ 'clf',
];
-const CLASSIC_HEAD_TEXTURE_V_FLIP_MODELS = new Set([
- 'baf',
- 'bam',
- 'daf',
- 'dam',
- 'dwf',
- 'dwm',
- 'elf',
- 'elm',
- 'erf',
- 'erm',
- 'gnf',
- 'gnm',
- 'haf',
- 'ham',
- 'hif',
- 'him',
- 'hof',
- 'hom',
- 'huf',
- 'hum',
- 'ikf',
- 'ikm',
- 'ogf',
- 'ogm',
- 'trf',
- 'trm',
-]);
-const HEAD_TEXTURE_V_FLIP_EXCLUDED_MODELS = new Set(['orc']);
-
const shouldAttachSecondaryMesh = (modelName, materialName) =>
HEAD_MATERIAL_PATTERN.test(materialName) ||
(modelName === 'ghu' && /^ghulg\d{4}$/i.test(materialName));
@@ -175,18 +295,11 @@ const markSecondaryHeadMaterial = (material) => {
material.subMaterials?.forEach?.(markSecondaryHeadMaterial);
};
-const shouldFlipHeadTextureV = (modelName, materialName, material = null) =>
- !HEAD_TEXTURE_V_FLIP_EXCLUDED_MODELS.has(modelName) &&
- (
- (
- CLASSIC_HEAD_TEXTURE_V_FLIP_MODELS.has(modelName) &&
- HEAD_OR_FACE_MATERIAL_PATTERN.test(materialName)
- ) ||
- (
- isSecondaryHeadMaterial(material) &&
- HEAD_MATERIAL_PATTERN.test(materialName)
- )
- );
+const isCharacterHeadMaterial = (material) =>
+ material?.metadata?.gltf?.extras?.spireCharacterHead === true ||
+ material?.metadata?.extras?.spireCharacterHead === true ||
+ material?.metadata?.spireCharacterHead === true ||
+ getCharacterHeadOrientationPolicy(material?.name).isCharacterHead;
const isUsableWorldY = (value) =>
Number.isFinite(value) && Math.abs(value) < 1000000;
@@ -200,13 +313,171 @@ const getNextPowerOfTwo = (value) => {
};
const getCachedTextureExists = async (fileName) => {
- if (!textureExistsCache.has(fileName)) {
- textureExistsCache.set(
- fileName,
- getEQFileExists('textures', fileName).catch(() => false)
- );
+ const revision = getEQFileDirectoryRevision('textures');
+ const cached = textureExistsCache.get(fileName);
+ if (cached?.revision === revision) {
+ return cached.promise;
+ }
+ const promise = (async () => {
+ if (!(await getEQFileExists('textures', fileName))) {
+ return false;
+ }
+ // A File System Access handle becomes visible when it is created, before
+ // its writer necessarily closes. Never classify a zero-byte in-progress
+ // texture as ready for Babylon.
+ const deadline = performance.now() + 5000;
+ do {
+ const data = await getEQFile('textures', fileName).catch(() => false);
+ if ((data?.byteLength ?? 0) > 0) {
+ return true;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } while (performance.now() < deadline);
+ return false;
+ })();
+ textureExistsCache.set(fileName, { promise, revision });
+ const exists = await promise;
+ if (!exists && textureExistsCache.get(fileName)?.promise === promise) {
+ // Missing character textures can be produced later by an on-demand
+ // archive refresh. Negative cache entries must never outlive that event.
+ textureExistsCache.delete(fileName);
+ }
+ return exists;
+};
+
+const getCachedTransparentSentinelTexture = async (fileName) => {
+ const revision = getEQFileDirectoryRevision('textures');
+ const cached = transparentSentinelTextureCache.get(fileName);
+ if (cached?.revision === revision) {
+ return cached.promise;
+ }
+ const promise = getEQFile('textures', fileName)
+ .then(async (data) => {
+ if (!data) {
+ return false;
+ }
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
+ if (
+ bytes.byteLength < 24 ||
+ bytes[0] !== 0x89 ||
+ bytes[1] !== 0x50 ||
+ bytes[2] !== 0x4e ||
+ bytes[3] !== 0x47
+ ) {
+ return false;
+ }
+ const view = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength
+ );
+ const width = view.getUint32(16, false);
+ const height = view.getUint32(20, false);
+ if (width <= 0 || height <= 0) {
+ return false;
+ }
+
+ // Character archives use fully transparent PNGs as sentinels for
+ // exposed skin. Most classic sentinels are 8x8, but later race
+ // archives contain the same marker at 32-256px. Dimensions therefore
+ // cannot determine intent: several valid EQ textures (teeth, eyes,
+ // and effect details) are also 8x8. Decode every candidate and only
+ // classify an image whose complete alpha channel is transparent.
+ const blob = new Blob([bytes], { type: 'image/png' });
+ let bitmap = null;
+ let objectUrl = null;
+ if (typeof createImageBitmap === 'function') {
+ bitmap = await createImageBitmap(blob);
+ } else if (
+ typeof document !== 'undefined' &&
+ typeof URL !== 'undefined' &&
+ typeof URL.createObjectURL === 'function'
+ ) {
+ objectUrl = URL.createObjectURL(blob);
+ bitmap = await new Promise((resolve, reject) => {
+ const image = document.createElement('img');
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error(`Unable to decode ${fileName}`));
+ image.src = objectUrl;
+ });
+ } else {
+ return false;
+ }
+ try {
+ const canvas = typeof OffscreenCanvas === 'function'
+ ? new OffscreenCanvas(bitmap.width, bitmap.height)
+ : Object.assign(document.createElement('canvas'), {
+ width: bitmap.width,
+ height: bitmap.height,
+ });
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ if (!context) {
+ return false;
+ }
+ context.drawImage(bitmap, 0, 0);
+ const pixels = context.getImageData(
+ 0,
+ 0,
+ bitmap.width,
+ bitmap.height
+ ).data;
+ for (let index = 3; index < pixels.length; index += 4) {
+ if (pixels[index] !== 0) {
+ return false;
+ }
+ }
+ return true;
+ } finally {
+ bitmap.close?.();
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ }
+ })
+ .catch(() => false);
+ transparentSentinelTextureCache.set(fileName, { promise, revision });
+ return promise;
+};
+
+const getSkinFallbackTextureName = async (prefix, textNum) => {
+ // Some models have no SK texture for a particular UV slot. In those cases
+ // the same slot from appearance 00 is the canonical exposed-skin texture.
+ // Keep the slot number stable so we never substitute an incompatible UV
+ // layout merely because another SK texture happens to exist.
+ for (const candidate of [
+ `${prefix}sk${textNum}`,
+ `${prefix}00${textNum}`,
+ ]) {
+ const fileName = `${candidate}.png`;
+ if (
+ await getCachedTextureExists(fileName) &&
+ !(await getCachedTransparentSentinelTexture(fileName))
+ ) {
+ return candidate;
+ }
+ }
+ return null;
+};
+
+const getBodyVariantCoverageTextureName = async (
+ prefix,
+ textNum,
+ maximumStandardTexture
+) => {
+ if (!BODY_VARIANT_CLOTHING_PREFIX_PATTERN.test(prefix)) {
+ return null;
+ }
+ for (let texture = maximumStandardTexture; texture >= 1; texture--) {
+ const candidate = `${prefix}${texture.toString().padStart(2, '0')}${textNum}`;
+ const fileName = `${candidate}.png`;
+ if (
+ await getCachedTextureExists(fileName) &&
+ !(await getCachedTransparentSentinelTexture(fileName))
+ ) {
+ return candidate;
+ }
}
- return textureExistsCache.get(fileName);
+ return null;
};
const getCachedModelExists = async (fileName) => {
@@ -219,6 +490,43 @@ const getCachedModelExists = async (fileName) => {
return modelExistsCache.get(fileName);
};
+const isTransparentSentinelMaterial = async (material) => {
+ if (!material?.name) {
+ return false;
+ }
+ if (material.metadata?.transparentTextureSentinel === true) {
+ return true;
+ }
+ const isTransparentSentinel = await getCachedTransparentSentinelTexture(
+ `${material.name}.png`
+ );
+ if (isTransparentSentinel) {
+ material.metadata = {
+ ...(material.metadata ?? {}),
+ transparentTextureSentinel: true,
+ };
+ }
+ return isTransparentSentinel;
+};
+
+const suppressTransparentSentinelMaterial = (material) => {
+ if (!material || material.metadata?.transparentTextureSentinel !== true) {
+ return;
+ }
+ material.metadata = {
+ ...(material.metadata ?? {}),
+ transparentTextureSentinel: true,
+ transparentTextureSentinelSuppressed: true,
+ };
+ // Some converted GLBs do not preserve the PNG alpha mode, which makes a
+ // fully transparent layer render as opaque white. If no compatible skin
+ // texture exists, keep the intentional layer invisible at the material
+ // level instead of relying on the imported alpha configuration.
+ material.alpha = 0;
+ material.disableColorWrite = true;
+ material.disableDepthWrite = true;
+};
+
/** @typedef {import('@babylonjs/core/Meshes').Mesh} Mesh */
export class BabylonSpawn {
@@ -236,12 +544,19 @@ export class BabylonSpawn {
/** @type {Mesh} */
nameplateMesh = null;
+ nameplateRequired = false;
+
+ nameplateValidationRepresentative = false;
+
/** @type {Node} */
parentNode = null;
/** @type {import('@babylonjs/core').AnimationGroup[]} */
animationGroups = [];
+ /** @type {import('@babylonjs/core').InstantiatedEntries | null} */
+ instanceContainer = null;
+
/**
* @type {Object.}
*/
@@ -257,6 +572,14 @@ export class BabylonSpawn {
animating = false;
canAnimate = false;
animatingIndex = AnimationNames.Idle;
+ postInitializeTimer = null;
+ disposed = false;
+ selectedAnimationPromoted = false;
+ selectedAnimationPromotionFailed = false;
+ selectedAnimationPromotionPromise = null;
+ selectedVisualAnimationName = null;
+ neutralIdleCandidateNames = [];
+ neutralIdleSelectionPass = true;
/**
* @param {object} spawnData
@@ -303,6 +626,14 @@ export class BabylonSpawn {
}
async getSecondaryHeadContainer(variation) {
+ const requestedModel = `${this.modelName ?? ''}`.slice(0, 3).toLowerCase();
+ const resolvedModel = `${this.resolvedModelAsset ?? ''}`.slice(0, 3).toLowerCase();
+ if (resolvedModel && requestedModel && resolvedModel !== requestedModel) {
+ // A full-model fallback is already complete. Attaching the requested
+ // model's separate head to a different skeleton reintroduces the broken
+ // or floating geometry that the fallback was selected to replace.
+ return null;
+ }
const secondaryModelName = `${this.modelName}he${variation}`;
const isKnownSecondaryHeadModel = this.shouldAttachSecondaryHead(this.modelName);
if (!isKnownSecondaryHeadModel) {
@@ -420,15 +751,35 @@ export class BabylonSpawn {
texture.vOffset = 1;
}
+ clearTextureVFlip(texture) {
+ if (!texture) {
+ return;
+ }
+ texture.vScale = Math.abs(texture.vScale || 1);
+ texture.vOffset = 0;
+ }
+
applyHeadTextureOrientation(rootNode, multiMaterial = null) {
const bindings = this.getMaterialBindings(rootNode, multiMaterial);
for (const { material } of bindings) {
- if (
- !shouldFlipHeadTextureV(this.modelName, `${material?.name ?? ''}`, material)
- ) {
+ if (!isCharacterHeadMaterial(material)) {
continue;
}
- this.applyTextureVFlip(this.getMaterialTexture(material));
+ const policy = getCharacterHeadOrientationPolicy(material.name);
+ const geometryUvFlipped =
+ material.metadata?.gltf?.extras?.spireSkinnedVFlipped === true ||
+ material.metadata?.extras?.spireSkinnedVFlipped === true ||
+ material.metadata?.spireSkinnedVFlipped === true;
+ const desiredEffectiveVFlip =
+ policy.geometryUvFlipped !== policy.runtimeTextureVFlipped;
+ const needsRuntimeTextureVFlip =
+ geometryUvFlipped !== desiredEffectiveVFlip;
+ const texture = this.getMaterialTexture(material);
+ if (needsRuntimeTextureVFlip) {
+ this.applyTextureVFlip(texture);
+ } else {
+ this.clearTextureVFlip(texture);
+ }
material.markAsDirty?.(BABYLON.Material?.TextureDirtyFlag ?? 1);
}
}
@@ -491,15 +842,24 @@ export class BabylonSpawn {
}
async applyTextureSwaps(rootNode, multiMaterial = null) {
- if (
- !this.spawnEntry.hasOwnProperty('texture') ||
- this.spawnEntry.texture <= 0 ||
- this.skipTextureSwap(this.modelName)
- ) {
- return;
- }
-
- const texture = Number(this.spawnEntry.texture);
+ const texture = Number(this.spawnEntry.texture ?? 0);
+ this.appearanceTextureDecodeFailureCount = 0;
+ this.appearanceTextureDecodeFailures = [];
+ this.bodyVariantTextureFallbackApplied = false;
+ this.bodyVariantTextureFallbackAppliedCount = 0;
+ this.bodyVariantTextureFallbackAvailableCount = 0;
+ this.bodyVariantTextureCoverageRequiredCount = 0;
+ this.bodyVariantTextureCoverageAppliedCount = 0;
+ const canSwapBodyTexture =
+ !this.skipTextureSwap(this.modelName);
+ const rawFace = Math.trunc(Number(this.spawnEntry.face ?? 0));
+ const face = Number.isFinite(rawFace) && rawFace >= 0 && rawFace <= 9
+ ? rawFace
+ : 0;
+ const helmTexture = Math.max(
+ 0,
+ Math.trunc(Number(this.spawnEntry.helmtexture ?? 0))
+ );
const bindings = this.getMaterialBindings(rootNode, multiMaterial);
for (const { assign, material } of bindings) {
@@ -507,85 +867,390 @@ export class BabylonSpawn {
if (!sourceTexture || !material?.name) {
continue;
}
+ // Mark unresolved sentinels as well as successfully substituted ones so
+ // the validation campaign reports a real placeholder instead of merely
+ // flagging every legitimate tiny EQ texture.
+ const sourceIsTransparentSentinel =
+ await isTransparentSentinelMaterial(material);
- const isVariationTexture = texture >= 10;
- let text = isVariationTexture ? texture - 10 : texture;
const isHead = HEAD_MATERIAL_PATTERN.test(material.name);
- if (material.name.startsWith('clk')) {
- text += 4;
- } else if (texture >= 10 && !isHead) {
+ if (!isHead && !isCharacterAppearanceMaterialName(material.name)) {
+ // Race ids can resolve to static props (ships, launches, beds, etc.).
+ // Their material names do not use the character appearance suffix
+ // convention, so preserve the imported texture verbatim.
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
continue;
}
const prefix = material.name.slice(0, material.name.length - 4);
const suffix = material.name.slice(material.name.length - 4);
const textVer = suffix.slice(0, 2);
const textNum = suffix.slice(2, 4);
- const thisText = text.toString().padStart(2, '0');
- let newFullName = `${prefix}${thisText}${textNum}`;
+ let newFullName = null;
+ let bodyVariantTextureCandidate = null;
+ let bodyVariantCoverageCandidate = null;
+ const recordBodyVariantAssignment = (assignedName) => {
+ const normalized = `${assignedName ?? ''}`.toLowerCase();
+ if (
+ bodyVariantTextureCandidate &&
+ normalized === bodyVariantTextureCandidate.toLowerCase()
+ ) {
+ this.bodyVariantTextureFallbackAppliedCount++;
+ }
+ if (
+ bodyVariantCoverageCandidate &&
+ normalized === bodyVariantCoverageCandidate.toLowerCase()
+ ) {
+ this.bodyVariantTextureCoverageAppliedCount++;
+ }
+ };
if (isHead) {
- if (this.hasAttachedSecondaryHead) {
+ if (
+ this.hasAttachedSecondaryHead &&
+ !isSecondaryHeadMaterial(material)
+ ) {
continue;
}
- const headTexture = Number(this.spawnEntry.helmtexture ?? 0);
- newFullName = headTexture > 0
- ? `${prefix}${headTexture.toString().padStart(2, '0')}${textNum}`
- : `${prefix}sk${textNum}`;
+ const headTextureCandidates = [];
+ if (helmTexture > 0) {
+ headTextureCandidates.push(
+ `${prefix}${helmTexture.toString().padStart(2, '0')}${textNum}`
+ );
+ } else {
+ const faceTextureSuffix = `${face}${textNum.slice(-1)}`;
+ headTextureCandidates.push(
+ `${prefix}sk${faceTextureSuffix}`,
+ `${prefix}sk${textNum}`
+ );
+ if (face > 0) {
+ headTextureCandidates.push(
+ `${prefix}${face.toString().padStart(2, '0')}${textNum}`
+ );
+ }
+ }
+
+ for (const candidate of new Set(headTextureCandidates)) {
+ if (!(await getCachedTextureExists(`${candidate}.png`))) {
+ continue;
+ }
+ if (await getCachedTransparentSentinelTexture(`${candidate}.png`)) {
+ const skinMaterialName = await getSkinFallbackTextureName(
+ prefix,
+ textNum
+ );
+ if (skinMaterialName) {
+ newFullName = skinMaterialName;
+ break;
+ }
+ continue;
+ }
+ newFullName = candidate;
+ break;
+ }
+ } else {
+ const usesSkinMaterial = textVer.toLowerCase() === 'sk';
+ if (!canSwapBodyTexture) {
+ // Models in the texture-swap exclusion list retain their native SK
+ // materials. Restore those materials when a reusable audit instance
+ // previously applied a numeric appearance.
+ if (usesSkinMaterial) {
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ continue;
+ }
+ const skinMaterialName = `${prefix}sk${textNum}`;
+ const skinMaterialExists = await getCachedTextureExists(
+ `${skinMaterialName}.png`
+ );
+ const skinMaterialIsTransparent = skinMaterialExists &&
+ await getCachedTransparentSentinelTexture(`${skinMaterialName}.png`);
+ if (skinMaterialExists && !skinMaterialIsTransparent) {
+ newFullName = skinMaterialName;
+ } else {
+ // Non-character and texture-swap-excluded models can contain
+ // intentionally transparent decorative layers whose names do not
+ // follow the four-character appearance suffix convention. Leaving
+ // those layers active makes converted GLBs render opaque white.
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ continue;
+ }
+ } else {
+ const isVariationTexture = texture >= 10;
+ const useBodyVariantTextureFallback =
+ isVariationTexture && this.bodyVariantFallback === true;
+ const maximumStandardTexture = Math.max(
+ 1,
+ Math.min(
+ 9,
+ Number(raceModelMetadata[this.modelName]?.maxTexture ?? 4)
+ )
+ );
+ let text = useBodyVariantTextureFallback
+ ? texture
+ : isVariationTexture
+ ? texture - 10
+ : texture;
+ if (material.name.startsWith('clk')) {
+ text += 4;
+ } else if (isVariationTexture && !useBodyVariantTextureFallback) {
+ continue;
+ }
+ const thisText = text.toString().padStart(2, '0');
+ if (thisText === textVer) {
+ // A model may ship with its requested numeric appearance already
+ // assigned. Do not bypass sentinel resolution merely because no
+ // material-name swap is otherwise required.
+ if (!sourceIsTransparentSentinel) {
+ continue;
+ }
+ newFullName = await getSkinFallbackTextureName(prefix, textNum);
+ if (!newFullName) {
+ suppressTransparentSentinelMaterial(material);
+ continue;
+ }
+ } else {
+ newFullName = `${prefix}${thisText}${textNum}`;
+ if (!(await getCachedTextureExists(`${newFullName}.png`))) {
+ // A numeric appearance is optional per UV slot. Do not attempt
+ // to decode a filename the archive never supplied; retain the
+ // imported native material unless the high-numbered body-model
+ // fallback can prove a compatible standard clothing texture.
+ newFullName = null;
+ if (
+ useBodyVariantTextureFallback &&
+ BODY_VARIANT_CLOTHING_PREFIX_PATTERN.test(prefix)
+ ) {
+ this.bodyVariantTextureCoverageRequiredCount++;
+ bodyVariantCoverageCandidate =
+ await getBodyVariantCoverageTextureName(
+ prefix,
+ textNum,
+ maximumStandardTexture
+ );
+ // A high-numbered appearance may omit a numeric clothing
+ // variant while the imported native SK material is already a
+ // complete, non-transparent texture. Count that real source
+ // material as deterministic coverage instead of replacing it
+ // with white or reporting a false unresolved fallback. A
+ // transparent sentinel remains unresolved and still fails QA.
+ if (
+ !bodyVariantCoverageCandidate &&
+ !sourceIsTransparentSentinel
+ ) {
+ bodyVariantCoverageCandidate = material.name;
+ }
+ newFullName = bodyVariantCoverageCandidate;
+ }
+ if (!newFullName) {
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ continue;
+ }
+ } else if (
+ useBodyVariantTextureFallback &&
+ !(await getCachedTransparentSentinelTexture(`${newFullName}.png`))
+ ) {
+ bodyVariantTextureCandidate = newFullName;
+ this.bodyVariantTextureFallbackAvailableCount++;
+ }
+ if (
+ newFullName &&
+ await getCachedTransparentSentinelTexture(`${newFullName}.png`)
+ ) {
+ newFullName = await getSkinFallbackTextureName(prefix, textNum);
+ if (!newFullName) {
+ // Preserve the currently assigned material instead of
+ // replacing it with an unresolved transparent placeholder.
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ continue;
+ }
+ }
+ }
+ }
}
- if (!isHead && thisText === textVer) {
+ if (!newFullName || newFullName === material.name) {
+ recordBodyVariantAssignment(newFullName);
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
continue;
}
- const exists = await getCachedTextureExists(`${newFullName}.png`);
- if (!exists) {
+ const scene = window.gameController.currentScene;
+ const pendingAppearanceMaterials = getPendingAppearanceMaterials(scene);
+ const pendingMaterialKey = newFullName.toLowerCase();
+ const pendingMaterialPromise = pendingAppearanceMaterials.get(
+ pendingMaterialKey
+ );
+ if (pendingMaterialPromise) {
+ const pendingMaterial = await pendingMaterialPromise;
+ if (pendingMaterial) {
+ if (isSecondaryHeadMaterial(material)) {
+ markSecondaryHeadMaterial(pendingMaterial);
+ }
+ assign(pendingMaterial);
+ recordBodyVariantAssignment(pendingMaterial.name);
+ } else if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ if (!pendingMaterial) {
+ this.appearanceTextureDecodeFailureCount++;
+ this.appearanceTextureDecodeFailures.push(newFullName);
+ }
continue;
}
- const existing = window.gameController.currentScene.materials
+ let existing = scene.materials
.flat()
.find((entry) => entry.name === newFullName);
+ if (
+ !isHead &&
+ canSwapBodyTexture &&
+ (await isTransparentSentinelMaterial(existing))
+ ) {
+ // Player-race archives include transparent numeric
+ // materials to mean "show the native skin for this body slot". These
+ // materials are present in the GLB, so merely checking for a matching
+ // material name incorrectly selects the transparent sentinel and
+ // Babylon renders the body section white. Resolve it to the matching
+ // SK material before assigning the swap.
+ const skinMaterialName = await getSkinFallbackTextureName(
+ prefix,
+ textNum
+ );
+ const skinMaterial = scene.materials
+ .flat()
+ .find(
+ (entry) =>
+ skinMaterialName &&
+ entry.name.toLowerCase() === skinMaterialName.toLowerCase()
+ );
+ if (
+ !skinMaterial ||
+ (await isTransparentSentinelMaterial(skinMaterial))
+ ) {
+ // Never assign a transparent sentinel when the archive has no safe
+ // same-UV fallback. Retaining the current material is deterministic
+ // and avoids Babylon's opaque-white rendering of transparent RGB.
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ continue;
+ }
+ newFullName = skinMaterial.name;
+ existing = skinMaterial;
+ }
if (existing) {
if (isSecondaryHeadMaterial(material)) {
markSecondaryHeadMaterial(existing);
}
- if (shouldFlipHeadTextureV(this.modelName, newFullName, existing)) {
- this.applyTextureVFlip(this.getMaterialTexture(existing));
- existing.markAsDirty?.(BABYLON.Material?.TextureDirtyFlag ?? 1);
- }
assign(existing);
+ recordBodyVariantAssignment(existing.name);
continue;
}
- const MaterialClass =
- typeof PBRMaterial === 'function' ? PBRMaterial : StandardMaterial;
- const newMat = new MaterialClass(
- newFullName,
- window.gameController.currentScene
+ const materialCreationPromise = (async () => {
+ const MaterialClass =
+ typeof PBRMaterial === 'function' ? PBRMaterial : StandardMaterial;
+ const newMat = material.clone?.(newFullName) ?? new MaterialClass(
+ newFullName,
+ scene
+ );
+ newMat.name = newFullName;
+ if ('metallic' in newMat) {
+ newMat.metallic = 0;
+ }
+ if ('roughness' in newMat) {
+ newMat.roughness = 1;
+ }
+ newMat.metadata = material.metadata
+ ? { ...material.metadata }
+ : null;
+ if (newMat.metadata) {
+ delete newMat.metadata.transparentTextureSentinel;
+ delete newMat.metadata.transparentTextureSentinelSuppressed;
+ }
+ if (isSecondaryHeadMaterial(material)) {
+ markSecondaryHeadMaterial(newMat);
+ }
+ const textureFileName = `${newFullName}.png`;
+ const textureData = await getAppearanceTextureData(textureFileName);
+ if ((textureData?.byteLength ?? 0) === 0) {
+ newMat.dispose?.();
+ return null;
+ }
+
+ newMat.metadata = {
+ ...(newMat.metadata ?? {}),
+ spireAppearanceTexturePending: true,
+ spireAppearanceTextureDecodeFailed: false,
+ };
+ const newTexture = startAppearanceTextureDecode({
+ name: newFullName,
+ scene,
+ sourceTexture,
+ textureData,
+ material: newMat,
+ assignTexture: (targetMaterial, targetTexture) =>
+ this.setMaterialTexture(targetMaterial, targetTexture),
+ });
+ if (!newTexture) {
+ newMat.dispose?.();
+ return null;
+ }
+ return newMat;
+ })();
+ pendingAppearanceMaterials.set(
+ pendingMaterialKey,
+ materialCreationPromise
);
- if ('metallic' in newMat) {
- newMat.metallic = 0;
- }
- if ('roughness' in newMat) {
- newMat.roughness = 1;
- }
- if (isSecondaryHeadMaterial(material)) {
- markSecondaryHeadMaterial(newMat);
+ let newMat = null;
+ try {
+ newMat = await materialCreationPromise;
+ } finally {
+ if (
+ pendingAppearanceMaterials.get(pendingMaterialKey) ===
+ materialCreationPromise
+ ) {
+ pendingAppearanceMaterials.delete(pendingMaterialKey);
+ }
}
- const newTexture = new Texture(
- newFullName,
- window.gameController.currentScene,
- sourceTexture.noMipMap,
- sourceTexture.invertY,
- sourceTexture.samplingMode
- );
- if (shouldFlipHeadTextureV(this.modelName, newFullName, newMat)) {
- this.applyTextureVFlip(newTexture);
+ if (!newMat) {
+ // Retain a known-good source (or suppress an intentional transparent
+ // sentinel) if material construction fails before Babylon can begin
+ // its asynchronous decode.
+ if (sourceIsTransparentSentinel) {
+ suppressTransparentSentinelMaterial(material);
+ }
+ this.appearanceTextureDecodeFailureCount++;
+ this.appearanceTextureDecodeFailures.push(newFullName);
+ continue;
}
- this.setMaterialTexture(newMat, newTexture);
assign(newMat);
- }
+ recordBodyVariantAssignment(newMat.name);
+ }
+
+ const bodyVariantResolvedAssignmentCount =
+ this.bodyVariantTextureFallbackAppliedCount +
+ this.bodyVariantTextureCoverageAppliedCount;
+ this.bodyVariantTextureFallbackApplied =
+ this.bodyVariantFallback === true &&
+ bodyVariantResolvedAssignmentCount > 0 &&
+ this.bodyVariantTextureFallbackAppliedCount ===
+ this.bodyVariantTextureFallbackAvailableCount &&
+ this.bodyVariantTextureCoverageAppliedCount ===
+ this.bodyVariantTextureCoverageRequiredCount;
+ this.applyHeadTextureOrientation(rootNode, multiMaterial);
}
getModelTopWorldY() {
@@ -647,6 +1312,10 @@ export class BabylonSpawn {
: 2.5 + halfPlaneHeight;
}
+ isNameplateEligible() {
+ return !INVISIBLE_SPAWN_MODELS.has(this.modelName);
+ }
+
updateNameplatePosition() {
if (!this.nameplateMesh) {
return;
@@ -657,6 +1326,76 @@ export class BabylonSpawn {
);
}
+ inspectNameplate() {
+ const expectedLines = this.getNameplateLines();
+ const plane = this.nameplateMesh;
+ const material = plane?.material;
+ const texture =
+ material?.diffuseTexture ??
+ material?._diffuseTexture ??
+ material?.emissiveTexture ??
+ material?._emissiveTexture;
+ const planeHeight = Number(plane?.metadata?.planeHeight ?? 0);
+ const bodyTopLocalY = this.getModelTopLocalY();
+ const placement = evaluateNameplatePlacement({
+ bodyTopLocalY,
+ nameplateCenterLocalY: plane?.position?.y,
+ planeHeight,
+ rootScaleY: this.rootNode?.scaling?.y,
+ requiredWorldClearance: NAMEPLATE_HEAD_CLEARANCE,
+ });
+ const textureSize = texture?.getSize?.() ?? {};
+ const renderedLines = Array.isArray(plane?.metadata?.textLines)
+ ? plane.metadata.textLines
+ : [];
+ const textMatches =
+ renderedLines.length === expectedLines.length &&
+ renderedLines.every((line, index) => line === expectedLines[index]);
+ const centered =
+ Math.abs(Number(plane?.position?.x ?? Infinity)) <= 0.001 &&
+ Math.abs(Number(plane?.position?.z ?? Infinity)) <= 0.001;
+ const present = !!plane && !plane.isDisposed?.();
+ const visible =
+ present &&
+ plane.isVisible !== false &&
+ plane.visibility !== 0 &&
+ plane.isEnabled?.() !== false;
+ const textured =
+ !!texture &&
+ texture.isReady?.() !== false &&
+ Number(textureSize.width ?? 0) > 1 &&
+ Number(textureSize.height ?? 0) > 1;
+ const attached = plane?.parent === this.rootNode;
+ const billboarded = Number(plane?.billboardMode ?? 0) !== 0;
+
+ return {
+ required: this.nameplateRequired === true,
+ validationRepresentative:
+ this.nameplateValidationRepresentative === true,
+ present,
+ visible,
+ textured,
+ attached,
+ centered,
+ billboarded,
+ textMatches,
+ expectedLines,
+ renderedLines,
+ textureWidth: Number(textureSize.width ?? 0),
+ textureHeight: Number(textureSize.height ?? 0),
+ placement,
+ pass:
+ present &&
+ visible &&
+ textured &&
+ attached &&
+ centered &&
+ billboarded &&
+ textMatches &&
+ placement.pass,
+ };
+ }
+
getCleanNameplateName(name) {
return `${name ?? ''}`
.replace(/^#+/, '')
@@ -883,6 +1622,511 @@ export class BabylonSpawn {
);
}
+ getPreferredVisualAnimationGroup() {
+ if (this.nativePoseOnly) {
+ return null;
+ }
+ return selectPreferredVisualAnimationGroup(this.animationGroups);
+ }
+
+ recordVisualAnimationSelection(animationGroup) {
+ const poseGroup = this.animationGroups.find((group) =>
+ this.isPoseAnimation(group)
+ ) ?? null;
+ this.neutralIdleCandidateNames = this.getPlayableAnimationGroups()
+ .filter((group) => isNeutralIdleAnimationName(group?.name))
+ .filter((group) =>
+ inspectAnimationGroupVitality(group, poseGroup).dynamicTargetCount > 0
+ )
+ .map((group) => group.name);
+ this.selectedVisualAnimationName = animationGroup?.name ?? null;
+ this.neutralIdleSelectionPass =
+ this.neutralIdleCandidateNames.length === 0 ||
+ isNeutralIdleAnimationName(this.selectedVisualAnimationName);
+ }
+
+ isDynamicVisualAnimationGroup(animationGroup) {
+ if (!animationGroup) {
+ return false;
+ }
+ const poseGroup = this.animationGroups.find((group) =>
+ this.isPoseAnimation(group)
+ ) ?? null;
+ return inspectAnimationGroupVitality(
+ animationGroup,
+ poseGroup
+ ).dynamicTargetCount > 0;
+ }
+
+ synchronizeSkeletonPose() {
+ if (!this.rootNode) {
+ return;
+ }
+ const nodes = [
+ this.rootNode,
+ ...(this.rootNode.getDescendants?.(false) ?? []),
+ ];
+ for (const node of nodes) {
+ node.computeWorldMatrix?.(true);
+ }
+ const skeletons = new Set([
+ ...(this.instanceContainer?.skeletons ?? []),
+ ...nodes.map((node) => node?.skeleton).filter(Boolean),
+ ]);
+ for (const skeleton of skeletons) {
+ skeleton.prepare?.(true);
+ }
+ for (const node of nodes) {
+ node.refreshBoundingInfo?.(true, true);
+ }
+ }
+
+ getVisualBounds() {
+ if (!this.rootNode) {
+ return null;
+ }
+ const meshes = [
+ ...(typeof this.rootNode.getTotalVertices === 'function'
+ ? [this.rootNode]
+ : []),
+ ...(this.rootNode.getChildMeshes?.(false) ?? []),
+ ].filter(
+ (mesh) =>
+ mesh?.name !== 'nameplate' &&
+ mesh?.id !== 'textPlane' &&
+ mesh?.metadata?.hiddenBoundary !== true &&
+ mesh?.isVisible !== false &&
+ mesh?.visibility !== 0 &&
+ (typeof mesh?.getTotalVertices !== 'function' ||
+ mesh.getTotalVertices() > 0)
+ );
+
+ const minimum = { x: Infinity, y: Infinity, z: Infinity };
+ const maximum = { x: -Infinity, y: -Infinity, z: -Infinity };
+ for (const mesh of meshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
+ const minimumWorld = box?.minimumWorld;
+ const maximumWorld = box?.maximumWorld;
+ if (!minimumWorld || !maximumWorld) {
+ continue;
+ }
+ for (const axis of ['x', 'y', 'z']) {
+ minimum[axis] = Math.min(minimum[axis], Number(minimumWorld[axis]));
+ maximum[axis] = Math.max(maximum[axis], Number(maximumWorld[axis]));
+ }
+ } catch (_error) {}
+ }
+ if (![...Object.values(minimum), ...Object.values(maximum)].every(Number.isFinite)) {
+ return null;
+ }
+ return {
+ minimum,
+ maximum,
+ width : maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth : maximum.z - minimum.z,
+ };
+ }
+
+ getVisualMaxDimension() {
+ const bounds = this.getVisualBounds();
+ return bounds
+ ? Math.max(bounds.width, bounds.height, bounds.depth)
+ : null;
+ }
+
+ captureAnimationTargetValues(animationGroups = this.animationGroups) {
+ const owners = new Map();
+ for (const group of animationGroups ?? []) {
+ for (const targetedAnimation of group?.targetedAnimations ?? []) {
+ const path = targetedAnimation?.animation?.targetPropertyPath ?? [];
+ let owner = targetedAnimation?.target;
+ for (let index = 0; owner && index < path.length - 1; index++) {
+ owner = owner[path[index]];
+ }
+ const property = path[path.length - 1];
+ if (!owner || !property || owner[property] === undefined) {
+ continue;
+ }
+ const properties = owners.get(owner) ?? new Map();
+ if (!properties.has(property)) {
+ const value = owner[property];
+ properties.set(
+ property,
+ value?.clone?.() ??
+ (ArrayBuffer.isView(value) ? value.slice() : value)
+ );
+ owners.set(owner, properties);
+ }
+ }
+ }
+ return owners;
+ }
+
+ restoreAnimationTargetValues(snapshot) {
+ for (const [owner, properties] of snapshot ?? []) {
+ for (const [property, value] of properties) {
+ if (owner[property]?.copyFrom && value) {
+ owner[property].copyFrom(value);
+ } else {
+ owner[property] = value?.clone?.() ??
+ (ArrayBuffer.isView(value) ? value.slice() : value);
+ }
+ owner.markAsDirty?.(property);
+ }
+ }
+ }
+
+ applyAnimationGroupFrame(animationGroup, frame) {
+ let appliedTargetCount = 0;
+ for (const targetedAnimation of animationGroup?.targetedAnimations ?? []) {
+ const animation = targetedAnimation?.animation;
+ const path = animation?.targetPropertyPath ?? [];
+ let owner = targetedAnimation?.target;
+ for (let index = 0; owner && index < path.length - 1; index++) {
+ owner = owner[path[index]];
+ }
+ const property = path[path.length - 1];
+ if (
+ !owner ||
+ !property ||
+ owner[property] === undefined ||
+ typeof animation?.evaluate !== 'function'
+ ) {
+ continue;
+ }
+ const value = animation.evaluate(frame);
+ if (owner[property]?.copyFrom && value) {
+ owner[property].copyFrom(value);
+ } else {
+ owner[property] = value?.clone?.() ?? value;
+ }
+ owner.markAsDirty?.(property);
+ appliedTargetCount++;
+ }
+ return appliedTargetCount;
+ }
+
+ inspectPrimaryHeadRotationSafety(animationGroup, snapshot) {
+ const samples = [];
+ for (const targetedAnimation of animationGroup?.targetedAnimations ?? []) {
+ const targetName = `${targetedAnimation?.target?.name ?? ''}`
+ .replace(/^Clone of /, '')
+ .trim();
+ const path = targetedAnimation?.animation?.targetPropertyPath ?? [];
+ if (
+ !(
+ PRIMARY_HEAD_BONE_PATTERN.test(targetName) ||
+ (this.modelName === 'iks' && /^he$/i.test(targetName))
+ ) ||
+ path[path.length - 1] !== 'rotationQuaternion'
+ ) {
+ continue;
+ }
+ let owner = targetedAnimation.target;
+ for (let index = 0; owner && index < path.length - 1; index++) {
+ owner = owner[path[index]];
+ }
+ const property = path[path.length - 1];
+ const baselineQuaternion = snapshot?.get(owner)?.get(property);
+ const result = evaluateHeadRotationSafety({
+ baselineQuaternion,
+ currentQuaternion: owner?.[property],
+ });
+ if (result.measurable) {
+ samples.push({ targetName, ...result });
+ }
+ }
+ return {
+ pass: samples.every((sample) => sample.pass),
+ measurable: samples.length > 0,
+ samples,
+ maximumAngleDegrees: samples.reduce(
+ (maximum, sample) => Math.max(maximum, sample.angleDegrees ?? 0),
+ 0
+ ),
+ };
+ }
+
+ validateAnimationBounds(animationGroup) {
+ if (!window.__spireSagePreview || !animationGroup) {
+ return { pass: true, measurable: false, samples: [] };
+ }
+ const safetyCacheKey = [
+ this.loadedModelVariation ?? this.modelName,
+ animationGroup.name ?? 'unnamed',
+ ].join(':').toLowerCase();
+ const cachedResult = animationSafetyCache.get(safetyCacheKey);
+ if (cachedResult) {
+ const result = { ...cachedResult, cached: true };
+ this.animationBoundsSafety = result;
+ return result;
+ }
+ const snapshot = this.captureAnimationTargetValues();
+ this.synchronizeSkeletonPose();
+ const baselineBounds = this.getVisualBounds();
+ const baselineMaxDimension = baselineBounds
+ ? Math.max(baselineBounds.width, baselineBounds.height, baselineBounds.depth)
+ : null;
+ // The stopped animation's bind pose can contain detached helper nodes far
+ // outside the rendered clip. Build the camera envelope only from frames
+ // that the user can actually see.
+ let framingBounds = null;
+ const mergeFramingBounds = (bounds) => {
+ if (!bounds) {
+ return;
+ }
+ if (!framingBounds) {
+ framingBounds = JSON.parse(JSON.stringify(bounds));
+ return;
+ }
+ for (const axis of ['x', 'y', 'z']) {
+ framingBounds.minimum[axis] = Math.min(
+ framingBounds.minimum[axis],
+ bounds.minimum[axis]
+ );
+ framingBounds.maximum[axis] = Math.max(
+ framingBounds.maximum[axis],
+ bounds.maximum[axis]
+ );
+ }
+ framingBounds.width = framingBounds.maximum.x - framingBounds.minimum.x;
+ framingBounds.height = framingBounds.maximum.y - framingBounds.minimum.y;
+ framingBounds.depth = framingBounds.maximum.z - framingBounds.minimum.z;
+ };
+ const from = Number(animationGroup.from ?? 0);
+ const to = Number(animationGroup.to ?? from);
+ const samples = [];
+ let pass = true;
+ try {
+ // Include the midpoint because many EQ locomotion clips reach their
+ // largest root translation there. The additional endpoint sample gives
+ // model viewers a stable envelope without tracking the camera every frame.
+ for (const fraction of [0, 0.1, 0.37, 0.5, 0.63, 0.9]) {
+ const frame = Number.isFinite(from) && Number.isFinite(to)
+ ? from + (to - from) * fraction
+ : 0;
+ this.applyAnimationGroupFrame(animationGroup, frame);
+ this.synchronizeSkeletonPose();
+ const currentBounds = this.getVisualBounds();
+ mergeFramingBounds(currentBounds);
+ const boundsResult = evaluateAnimatedBoundsSafety({
+ baselineMaxDimension,
+ currentMaxDimension: currentBounds
+ ? Math.max(currentBounds.width, currentBounds.height, currentBounds.depth)
+ : null,
+ });
+ const headRotation = this.inspectPrimaryHeadRotationSafety(
+ animationGroup,
+ snapshot
+ );
+ const samplePass = boundsResult.pass && headRotation.pass;
+ samples.push({
+ fraction,
+ frame,
+ ...boundsResult,
+ pass: samplePass,
+ headRotation,
+ });
+ if (!samplePass) {
+ pass = false;
+ break;
+ }
+ }
+ } finally {
+ this.restoreAnimationTargetValues(snapshot);
+ this.synchronizeSkeletonPose();
+ }
+ this.rootNode.computeWorldMatrix?.(true);
+ const framingOrigin = this.rootNode.getAbsolutePosition?.() ??
+ this.rootNode.position ?? { x: 0, y: 0, z: 0 };
+ const relativeFramingBounds = framingBounds
+ ? {
+ minimum: {
+ x: framingBounds.minimum.x - Number(framingOrigin.x ?? 0),
+ y: framingBounds.minimum.y - Number(framingOrigin.y ?? 0),
+ z: framingBounds.minimum.z - Number(framingOrigin.z ?? 0),
+ },
+ maximum: {
+ x: framingBounds.maximum.x - Number(framingOrigin.x ?? 0),
+ y: framingBounds.maximum.y - Number(framingOrigin.y ?? 0),
+ z: framingBounds.maximum.z - Number(framingOrigin.z ?? 0),
+ },
+ width : framingBounds.width,
+ height: framingBounds.height,
+ depth : framingBounds.depth,
+ }
+ : null;
+ const result = {
+ pass,
+ measurable: samples.some((sample) => sample.measurable),
+ baselineMaxDimension,
+ framingBounds: relativeFramingBounds,
+ headOrientationPass: samples.every((sample) => sample.headRotation?.pass !== false),
+ samples,
+ };
+ animationSafetyCache.set(safetyCacheKey, result);
+ this.animationBoundsSafety = result;
+ return result;
+ }
+
+ applyAnimationBoundsFallback() {
+ const snapshot = this.captureAnimationTargetValues();
+ const baselineMaxDimension = this.getVisualMaxDimension();
+ this.neutralSkeletonPoseApplied = false;
+ this.applyNeutralSkeletonPose();
+ this.synchronizeSkeletonPose();
+ const fallbackSafety = evaluateAnimatedBoundsSafety({
+ baselineMaxDimension,
+ currentMaxDimension: this.getVisualMaxDimension(),
+ });
+ if (!fallbackSafety.pass) {
+ this.restoreAnimationTargetValues(snapshot);
+ this.synchronizeSkeletonPose();
+ }
+ this.animationBoundsRejected = true;
+ this.animationBoundsFallbackSafe = fallbackSafety.pass;
+ if (fallbackSafety.pass) {
+ this.staticPreviewPoseApplied = true;
+ this.staticPreviewPoseTargetCount = Math.max(
+ 1,
+ Number(this.staticPreviewPoseTargetCount ?? 0)
+ );
+ }
+ this.releaseStaticAnimationResources();
+ if (this.rootNode?.metadata) {
+ this.rootNode.metadata.animationBoundsRejected = true;
+ this.rootNode.metadata.animationBoundsFallbackSafe = fallbackSafety.pass;
+ this.rootNode.metadata.animationBoundsSafety = this.animationBoundsSafety;
+ }
+ }
+
+ applyNeutralSkeletonPose() {
+ if (this.neutralSkeletonPoseApplied) {
+ return true;
+ }
+ const playableGroups = this.getPlayableAnimationGroups();
+ const nativePoseGroup = this.animationGroups.find((group) =>
+ this.isPoseAnimation(group)
+ );
+ const basePoseGroup = this.nativePoseOnly
+ ? nativePoseGroup
+ : playableGroups.find(
+ (group) => this.getAnimationBaseName(group) === 'p04'
+ ) ?? playableGroups[0];
+ if (!basePoseGroup?.targetedAnimations?.length) {
+ return false;
+ }
+ this.animationGroups.forEach((group) => group.stop());
+ const frame = Number(basePoseGroup.from ?? 0);
+ let appliedTargetCount = 0;
+ for (const targetedAnimation of basePoseGroup.targetedAnimations) {
+ const animation = targetedAnimation.animation;
+ const target = targetedAnimation.target;
+ const path = animation?.targetPropertyPath ?? [];
+ if (!target || path.length === 0 || typeof animation.evaluate !== 'function') {
+ continue;
+ }
+ let owner = target;
+ for (let index = 0; index < path.length - 1; index++) {
+ owner = owner?.[path[index]];
+ }
+ const property = path[path.length - 1];
+ if (!owner || owner[property] === undefined) {
+ continue;
+ }
+ const value = animation.evaluate(frame);
+ if (owner[property]?.copyFrom && value) {
+ owner[property].copyFrom(value);
+ } else {
+ owner[property] = value?.clone?.() ?? value;
+ }
+ appliedTargetCount++;
+ }
+ if (this.nativePoseOnly) {
+ const compactModelName = `${this.modelName ?? ''}`
+ .slice(0, 3)
+ .toLowerCase();
+ const compactArmRotation =
+ COMPACT_NATIVE_ARM_NEUTRAL_ROTATIONS.get(compactModelName);
+ if (compactArmRotation) {
+ // Resolve the instantiated nodes, rather than relying only on the
+ // animation target list. Imported pose tracks can target wrapper nodes
+ // while the skinned hierarchy uses their instantiated descendants.
+ const poseTargets = [
+ this.rootNode,
+ ...(this.rootNode?.getDescendants?.(false) ?? []),
+ ...basePoseGroup.targetedAnimations.map(({ target }) => target),
+ ];
+ const findPoseTarget = (name) => poseTargets.find(
+ (target) =>
+ `${target?.name}`.replace(/^Clone of /, '').toLowerCase() === name
+ );
+ const leftBicep = findPoseTarget('bi_l');
+ const rightBicep = findPoseTarget('bi_r');
+ this.compactNativeArmTargetCount =
+ Number(!!leftBicep) + Number(!!rightBicep);
+ if (leftBicep?.rotate && rightBicep?.rotate) {
+ const rotationAxis =
+ compactArmRotation.axis === 'x'
+ ? BABYLON.Axis.X
+ : compactArmRotation.axis === 'y'
+ ? BABYLON.Axis.Y
+ : BABYLON.Axis.Z;
+ leftBicep.rotate(
+ rotationAxis,
+ compactArmRotation.amount,
+ BABYLON.Space.LOCAL
+ );
+ rightBicep.rotate(
+ rotationAxis,
+ -compactArmRotation.amount,
+ BABYLON.Space.LOCAL
+ );
+ appliedTargetCount += 2;
+ this.compactNativeArmNeutralized = true;
+ this.compactNativeArmNeutralRotation = compactArmRotation.amount;
+ this.compactNativeArmNeutralAxis = compactArmRotation.axis;
+ }
+ }
+ this.neutralSkeletonPoseApplied = appliedTargetCount > 0;
+ this.staticPreviewPoseApplied = appliedTargetCount > 0;
+ this.staticPreviewPoseTargetCount = appliedTargetCount;
+ this.staticPreviewPoseMaxDelta = appliedTargetCount > 0 ? 1 : 0;
+ if (this.rootNode?.metadata) {
+ this.rootNode.metadata.compactNativeArmNeutralized =
+ this.compactNativeArmNeutralized === true;
+ this.rootNode.metadata.compactNativeArmTargetCount =
+ this.compactNativeArmTargetCount ?? 0;
+ }
+ this.synchronizeSkeletonPose();
+ return this.neutralSkeletonPoseApplied;
+ }
+ const poseTargets = [
+ ...new Set(basePoseGroup.targetedAnimations.map(({ target }) => target)),
+ ];
+ const leftClavicle = poseTargets.find(
+ (target) => `${target?.name}`.replace(/^Clone of /, '').toLowerCase() === 'biclavl'
+ );
+ const rightClavicle = poseTargets.find(
+ (target) => `${target?.name}`.replace(/^Clone of /, '').toLowerCase() === 'biclavr'
+ );
+ if (!leftClavicle || !rightClavicle) {
+ return false;
+ }
+ leftClavicle.rotate(BABYLON.Axis.Z, 1, BABYLON.Space.LOCAL);
+ rightClavicle.rotate(BABYLON.Axis.Z, -1, BABYLON.Space.LOCAL);
+ this.neutralSkeletonPoseApplied = true;
+ this.staticPreviewPoseApplied = true;
+ this.staticPreviewPoseTargetCount = appliedTargetCount + 2;
+ this.staticPreviewPoseMaxDelta = 1;
+ this.synchronizeSkeletonPose();
+ return true;
+ }
+
calculateSpawnScale() {
if (this.modelName === 'fis') {
return 0.005;
@@ -1015,7 +2259,7 @@ export class BabylonSpawn {
normalizeToSpawnGround(
targetY = this.rootNode?.position?.y,
- { onlyRaise = false, clearance = 0 } = {}
+ { onlyRaise = false, clearance = 0, snapToZone = true } = {}
) {
if (!this.rootNode || !Number.isFinite(Number(targetY))) {
return 0;
@@ -1027,7 +2271,9 @@ export class BabylonSpawn {
}
const requestedTargetY = Number(targetY);
- const zoneGroundY = this.getZoneGroundWorldY(requestedTargetY);
+ const zoneGroundY = snapToZone
+ ? this.getZoneGroundWorldY(requestedTargetY)
+ : null;
const effectiveTargetY = Number.isFinite(zoneGroundY)
? zoneGroundY
: requestedTargetY;
@@ -1069,10 +2315,93 @@ export class BabylonSpawn {
}
normalizeAnimatedGroundPose(options = {}) {
- this.normalizeToSpawnGround(this.spawn.z, options);
+ this.normalizeToSpawnGround(this.spawn.z, {
+ snapToZone: this.rootNode?.metadata?.preserveRequestedGroundY !== true,
+ ...options,
+ });
this.updateNameplatePosition();
}
+ remapSecondaryMeshSkeleton(mesh, targetSkeleton) {
+ const sourceSkeleton = mesh?.skeleton;
+ if (!sourceSkeleton || !targetSkeleton || sourceSkeleton === targetSkeleton) {
+ return {
+ pass: !!targetSkeleton,
+ remappedIndexCount: 0,
+ unresolvedBones: [],
+ };
+ }
+
+ const normalizeBoneName = (name) => `${name ?? ''}`
+ .replace(/^Clone of /i, '')
+ .trim()
+ .toLowerCase();
+ const targetBoneIndexByName = new Map();
+ for (let index = 0; index < (targetSkeleton.bones?.length ?? 0); index++) {
+ const name = normalizeBoneName(targetSkeleton.bones[index]?.name);
+ if (name && !targetBoneIndexByName.has(name)) {
+ targetBoneIndexByName.set(name, index);
+ }
+ }
+
+ const sourceBones = sourceSkeleton.bones ?? [];
+ const unresolvedBones = new Set();
+ let remappedIndexCount = 0;
+ const indexKinds = [
+ VertexBuffer?.MatricesIndicesKind ?? BABYLON.VertexBuffer?.MatricesIndicesKind,
+ VertexBuffer?.MatricesIndicesExtraKind ??
+ BABYLON.VertexBuffer?.MatricesIndicesExtraKind,
+ ].filter(Boolean);
+
+ const pendingBuffers = [];
+ for (const kind of indexKinds) {
+ const values = mesh.getVerticesData?.(kind);
+ if (!values?.length) {
+ continue;
+ }
+ const remappedValues = ArrayBuffer.isView(values)
+ ? new values.constructor(values)
+ : [...values];
+ for (let index = 0; index < remappedValues.length; index++) {
+ const sourceIndex = Math.trunc(Number(remappedValues[index]));
+ const sourceBone = sourceBones[sourceIndex];
+ if (!sourceBone) {
+ unresolvedBones.add(`index:${sourceIndex}`);
+ continue;
+ }
+ const sourceName = normalizeBoneName(sourceBone.name);
+ const targetIndex = targetBoneIndexByName.get(sourceName);
+ if (targetIndex === undefined) {
+ unresolvedBones.add(sourceBone.name ?? `index:${sourceIndex}`);
+ continue;
+ }
+ if (targetIndex !== sourceIndex) {
+ remappedValues[index] = targetIndex;
+ remappedIndexCount++;
+ }
+ }
+ pendingBuffers.push([kind, remappedValues]);
+ }
+
+ if (unresolvedBones.size > 0) {
+ return {
+ pass: false,
+ remappedIndexCount,
+ unresolvedBones: [...unresolvedBones].sort(),
+ };
+ }
+
+ for (const [kind, values] of pendingBuffers) {
+ mesh.updateVerticesData?.(kind, values, false, false);
+ }
+ mesh.skeleton = targetSkeleton;
+ return {
+ pass: true,
+ remappedIndexCount,
+ unresolvedBones: [],
+ };
+ }
+
attachSecondaryMeshes(secondaryModel, targetSkeleton) {
const secondaryRootNode = secondaryModel?.rootNodes?.[0];
const secondaryMeshes = (secondaryRootNode?.getChildMeshes?.(false) ?? [])
@@ -1082,24 +2411,42 @@ export class BabylonSpawn {
shouldAttachSecondaryMesh(this.modelName, name)
)
);
+ const attachedMeshes = [];
for (const mesh of secondaryMeshes) {
- mesh.parent = this.rootNode;
- if (targetSkeleton && mesh.skeleton) {
- mesh.skeleton = targetSkeleton;
+ const skeletonRemap = this.remapSecondaryMeshSkeleton(
+ mesh,
+ targetSkeleton
+ );
+ if (!skeletonRemap.pass) {
+ this.secondaryHeadBoneRemapFailureCount =
+ (this.secondaryHeadBoneRemapFailureCount ?? 0) + 1;
+ this.secondaryHeadBoneRemapFailures = [
+ ...(this.secondaryHeadBoneRemapFailures ?? []),
+ {
+ mesh: mesh.name,
+ unresolvedBones: skeletonRemap.unresolvedBones,
+ },
+ ];
+ continue;
}
+ mesh.parent = this.rootNode;
markSecondaryHeadMaterial(mesh.material);
mesh.metadata = {
...mesh.metadata,
spawn: this.metadata?.spawn ?? this.spawnEntry,
secondaryHead: true,
+ secondaryHeadBoneRemapped: true,
+ secondaryHeadBoneRemappedIndexCount:
+ skeletonRemap.remappedIndexCount,
};
+ attachedMeshes.push(mesh);
}
for (const skeleton of secondaryModel?.skeletons ?? []) {
if (skeleton !== targetSkeleton) {
skeleton.dispose?.();
}
}
- return secondaryMeshes;
+ return attachedMeshes;
}
getMeshInfluencingBoneNames(mesh) {
@@ -1191,12 +2538,13 @@ export class BabylonSpawn {
* @returns {boolean}
*/
async initializeSpawn() {
- const modelVariation =
- this.spawnEntry.texture >= 10
- ? `${this.modelName}${Number(this.spawnEntry.texture.toString()[0])
- .toString()
- .padStart(2, '0')}`
- : this.modelName;
+ const modelVariation = getCharacterBodyModelVariation(
+ this.modelName,
+ this.spawnEntry.texture
+ );
+ this.requestedModelVariation = modelVariation;
+ this.loadedModelVariation = null;
+ this.bodyVariantFallback = false;
let assetContainer = null;
if (window.__spireSagePreview && modelVariation !== this.modelName) {
@@ -1204,29 +2552,83 @@ export class BabylonSpawn {
await window.gameController.SpawnController.getAssetContainer(
modelVariation,
false,
- { optional: true }
+ { optional: true, generateIfMissing: true }
);
+ if (assetContainer) {
+ this.loadedModelVariation = modelVariation;
+ }
}
if (!assetContainer) {
+ const fallbackModelName =
+ modelVariation === this.modelName ? modelVariation : this.modelName;
assetContainer =
await window.gameController.SpawnController.getAssetContainer(
- modelVariation === this.modelName ? modelVariation : this.modelName
+ fallbackModelName
);
+ if (assetContainer) {
+ this.loadedModelVariation = fallbackModelName;
+ this.bodyVariantFallback = fallbackModelName !== modelVariation;
+ }
}
if (!assetContainer) {
console.warn('Asset container not found for', modelVariation);
return;
}
- this.instanceContainer = assetContainer.instantiateModelsToScene();
+ this.instanceContainer =
+ window.gameController.SpawnController.instantiateSpawnModel?.(
+ this.loadedModelVariation ?? this.modelName,
+ assetContainer
+ ) ?? assetContainer.instantiateModelsToScene();
this.animationGroups = this.instanceContainer.animationGroups;
-
- this.animationMap = mapAnimations(this.animationGroups);
+ this.previewAnimationDonor =
+ this.instanceContainer.__spirePreviewAnimationDonor ?? null;
+ this.resolvedModelAsset =
+ this.instanceContainer.__spireResolvedModelAsset ??
+ this.loadedModelVariation ??
+ this.modelName;
this.rootNode = this.instanceContainer.rootNodes[0];
if (!this.rootNode) {
console.log('No root node for container spawn', this.spawn);
return false;
}
+ this.rootNode.metadata = {
+ ...this.rootNode.metadata,
+ requestedModelVariation: this.requestedModelVariation,
+ loadedModelVariation : this.loadedModelVariation,
+ bodyVariantFallback : this.bodyVariantFallback,
+ };
+ this.animationRetargeting = retargetDetachedAnimationTargets(
+ this.animationGroups,
+ [this.rootNode, ...(this.rootNode.getDescendants?.(false) ?? [])]
+ );
+ this.animationMap = mapAnimations(this.animationGroups);
+ const importedModelNodes = [
+ this.rootNode,
+ ...(this.rootNode.getDescendants?.(false) ?? []),
+ ];
+ const staticPoseOnlyByPolicy = isStaticPoseOnlyCharacterModel(
+ this.requestedModelVariation,
+ this.loadedModelVariation,
+ this.resolvedModelAsset,
+ this.modelName
+ );
+ const hasPlayablePreviewAnimationDonor =
+ this.previewAnimationDonor?.expected === true &&
+ this.previewAnimationDonor?.pass === true;
+ this.nativePoseOnly =
+ staticPoseOnlyByPolicy ||
+ (
+ !hasPlayablePreviewAnimationDonor &&
+ (
+ this.instanceContainer.__spireNativePoseOnly === true ||
+ importedModelNodes.some((node) =>
+ node?.metadata?.gltf?.extras?.spireNativePoseOnly === true ||
+ node?.metadata?.extras?.spireNativePoseOnly === true ||
+ node?.metadata?.spireNativePoseOnly === true
+ )
+ )
+ );
const spawnId = this.spawnEntry.__spireSpawnId ?? this.spawn.id;
this.rootNode.id = `spawn_${spawnId}`;
this.rootNode.name = this.spawn.name;
@@ -1250,16 +2652,22 @@ export class BabylonSpawn {
const variation =
this.spawnEntry.helmtexture?.toString().padStart(2, '0') ?? '00';
const container = await this.getSecondaryHeadContainer(variation);
- let secondaryModel = null;
if (container) {
- secondaryModel = container.instantiateModelsToScene();
- this.hasAttachedSecondaryHead =
- this.attachSecondaryMeshes(secondaryModel, instanceSkeleton).length > 0;
- if (
- this.hasAttachedSecondaryHead &&
- SEPARATE_HEAD_MODELS.has(this.modelName)
- ) {
- this.hideIntegratedHeadMeshes();
+ const secondaryModel = container.instantiateModelsToScene();
+ try {
+ this.hasAttachedSecondaryHead =
+ this.attachSecondaryMeshes(secondaryModel, instanceSkeleton).length > 0;
+ if (
+ this.hasAttachedSecondaryHead &&
+ SEPARATE_HEAD_MODELS.has(this.modelName)
+ ) {
+ this.hideIntegratedHeadMeshes();
+ }
+ } finally {
+ // Selected head meshes have been reparented to the primary root. The
+ // remaining root, skeleton and animation entries are temporary and
+ // otherwise accumulate once per spawned NPC.
+ secondaryModel.dispose();
}
}
@@ -1268,7 +2676,9 @@ export class BabylonSpawn {
const hasAnimatedPose = this.getPlayableAnimationGroups().length > 0;
const keepSkinnedHierarchy =
- window.__spireSagePreview && !!instanceSkeleton && hasAnimatedPose;
+ window.__spireSagePreview &&
+ !!instanceSkeleton &&
+ (hasAnimatedPose || this.nativePoseOnly);
const merged = keepSkinnedHierarchy
? null
: Mesh.MergeMeshes(
@@ -1279,9 +2689,6 @@ export class BabylonSpawn {
true,
true
);
- if (secondaryModel && !keepSkinnedHierarchy) {
- secondaryModel.dispose();
- }
if (merged) {
if (skeletonRoot && instanceSkeleton) {
skeletonRoot.parent = merged;
@@ -1293,12 +2700,10 @@ export class BabylonSpawn {
this.rootNode.skeleton = skeletonRoot?.skeleton ?? instanceSkeleton;
await this.applyTextureSwaps(merged, merged.material);
- this.applyHeadTextureOrientation(merged, merged.material);
this.applyMaterialRenderSettings(merged, merged.material);
} else if (keepSkinnedHierarchy) {
this.rootNode.skeleton = instanceSkeleton;
await this.applyTextureSwaps(this.rootNode);
- this.applyHeadTextureOrientation(this.rootNode);
this.applyMaterialRenderSettings(this.rootNode);
}
this.rootNode.parent = this.parentNode;
@@ -1321,6 +2726,41 @@ export class BabylonSpawn {
...this.metadata,
onlyOccluded: true,
spawnRoot : true,
+ requestedModelVariation: this.requestedModelVariation,
+ loadedModelVariation: this.loadedModelVariation,
+ resolvedModelAsset: this.resolvedModelAsset,
+ bodyVariantFallback: this.bodyVariantFallback === true,
+ bodyVariantTextureFallbackApplied:
+ this.bodyVariantTextureFallbackApplied === true,
+ bodyVariantTextureFallbackAppliedCount:
+ this.bodyVariantTextureFallbackAppliedCount ?? 0,
+ bodyVariantTextureFallbackAvailableCount:
+ this.bodyVariantTextureFallbackAvailableCount ?? 0,
+ bodyVariantTextureCoverageRequiredCount:
+ this.bodyVariantTextureCoverageRequiredCount ?? 0,
+ bodyVariantTextureCoverageAppliedCount:
+ this.bodyVariantTextureCoverageAppliedCount ?? 0,
+ nativePoseOnly: this.nativePoseOnly === true,
+ previewAnimationDonorExpected:
+ this.previewAnimationDonor?.expected === true,
+ previewAnimationDonorPass:
+ this.previewAnimationDonor?.pass === true,
+ previewAnimationDonorName:
+ this.previewAnimationDonor?.donorName ?? null,
+ previewAnimationDonorFailureReason:
+ this.previewAnimationDonor?.failureReason ?? null,
+ previewAnimationDonorGroupCount:
+ this.previewAnimationDonor?.attachedGroupCount ?? 0,
+ previewAnimationDonorTargetCount:
+ this.previewAnimationDonor?.attachedTargetCount ?? 0,
+ previewAnimationDonorBindRelativeTargetCount:
+ this.previewAnimationDonor?.bindRelativeTargetCount ?? 0,
+ previewAnimationDonorBindLockedRotationTargetNames:
+ this.previewAnimationDonor?.bindLockedRotationTargetNames ?? [],
+ previewAnimationDonorUnmatchedTargetNames:
+ this.previewAnimationDonor?.unmatchedTargetNames ?? [],
+ compactNativeArmNeutralized:
+ this.compactNativeArmNeutralized === true,
};
if (this.instance) {
this.rootNode.addLODLevel(window.gameController.settings.spawnLOD, this.instance);
@@ -1378,24 +2818,270 @@ export class BabylonSpawn {
}
this.hideInvisibleBoundaryMeshes();
this.normalizeToSpawnGround(this.spawn.z);
- this.createNameplate();
+ if (!this.isNameplateEligible()) {
+ this.previewNameplateDeferred = false;
+ } else if (!window.__spireSageSkipBulkNameplates) {
+ this.createNameplate();
+ } else {
+ this.previewNameplateDeferred = true;
+ }
- const anim =
- this.animationGroups.find((ag) => ag.name === 'Clone of p01') ??
- this.getPlayableAnimationGroups()?.[0];
- if (anim) {
- this.disableLoopedAnimation();
- anim.play(true);
- this.normalizeAnimatedGroundPose();
+ if (window.__spireSagePreview && window.__spireSageBulkSpawnLoading) {
+ this.previewAnimationDeferred = true;
+ } else {
+ this.startInitialAnimation();
+ }
+
+ return true;
+ }
+
+ startInitialAnimation({
+ skipGroundNormalization = false,
+ schedulePostInitialize = true,
+ } = {}) {
+ if (this.disposed || !this.rootNode) {
+ return;
+ }
+ this.previewAnimationDeferred = false;
+ const anim = this.getPreferredVisualAnimationGroup();
+ this.recordVisualAnimationSelection(anim);
+ if (anim && this.isDynamicVisualAnimationGroup(anim)) {
+ const boundsSafety = this.validateAnimationBounds(anim);
+ if (boundsSafety.pass) {
+ this.retainSelectedVisualAnimationResources(anim);
+ this.disableLoopedAnimation();
+ anim.play(true);
+ } else {
+ console.warn('[SageAnimation] rejected unsafe animation', {
+ modelName: this.modelName,
+ spawnId: this.spawnEntry.__spireSpawnId ?? this.spawn.id,
+ animation: anim.name,
+ boundsSafety,
+ });
+ this.animationHeadOrientationRejected =
+ boundsSafety.headOrientationPass === false;
+ this.applyAnimationBoundsFallback();
+ }
+ if (!skipGroundNormalization) {
+ this.normalizeAnimatedGroundPose();
+ }
+ } else {
+ if (!this.applyStaticAnimationPose()) {
+ this.applyNeutralSkeletonPose();
+ }
+ if (!skipGroundNormalization) {
+ this.normalizeAnimatedGroundPose();
+ }
+ }
+ if (!schedulePostInitialize) {
+ return;
}
- setTimeout(() => {
+ const initializedRoot = this.rootNode;
+ this.postInitializeTimer = setTimeout(() => {
+ this.postInitializeTimer = null;
+ if (
+ this.disposed ||
+ !initializedRoot ||
+ this.rootNode !== initializedRoot ||
+ initializedRoot.isDisposed?.()
+ ) {
+ return;
+ }
this.normalizeAnimatedGroundPose();
- this.rootNode.refreshBoundingInfo?.(true, true);
+ initializedRoot.refreshBoundingInfo?.(true, true);
this.playAnimation();
this.normalizeAnimatedGroundPose();
}, 1000);
+ }
- return true;
+ applyStaticAnimationPose() {
+ if (this.disposed || !this.rootNode) {
+ return false;
+ }
+ const animationGroup = this.getPreferredVisualAnimationGroup();
+ if (!animationGroup?.targetedAnimations?.length) {
+ return this.applyNeutralSkeletonPose();
+ }
+ const from = Number(animationGroup.from ?? 0);
+ const to = Number(animationGroup.to ?? from);
+ const frame = Number.isFinite(from) && Number.isFinite(to)
+ ? from + (to - from) * 0.37
+ : 0;
+ let appliedTargetCount = 0;
+ let maxValueDelta = 0;
+ for (const targetedAnimation of animationGroup.targetedAnimations) {
+ const animation = targetedAnimation.animation;
+ const target = targetedAnimation.target;
+ const path = animation?.targetPropertyPath ?? [];
+ if (!target || path.length === 0 || typeof animation.evaluate !== 'function') {
+ continue;
+ }
+ let owner = target;
+ for (let index = 0; index < path.length - 1; index++) {
+ owner = owner?.[path[index]];
+ }
+ const property = path[path.length - 1];
+ if (!owner || owner[property] === undefined) {
+ continue;
+ }
+ const previousValue = owner[property];
+ const value = animation.evaluate(frame);
+ const previousComponents = previousValue?.asArray?.() ??
+ previousValue?.toArray?.() ??
+ (Number.isFinite(previousValue) ? [previousValue] : []);
+ const nextComponents = value?.asArray?.() ??
+ value?.toArray?.() ??
+ (Number.isFinite(value) ? [value] : []);
+ for (
+ let index = 0;
+ index < Math.min(previousComponents.length, nextComponents.length);
+ index++
+ ) {
+ maxValueDelta = Math.max(
+ maxValueDelta,
+ Math.abs(nextComponents[index] - previousComponents[index])
+ );
+ }
+ if (previousValue?.copyFrom && value) {
+ previousValue.copyFrom(value);
+ } else {
+ owner[property] = value?.clone?.() ?? value;
+ }
+ target.markAsDirty?.(property);
+ appliedTargetCount++;
+ }
+ this.synchronizeSkeletonPose();
+ this.previewAnimationDeferred = false;
+ this.staticPreviewPoseApplied = appliedTargetCount > 0 && maxValueDelta > 0.00001;
+ this.staticPreviewPoseTargetCount = appliedTargetCount;
+ this.staticPreviewPoseMaxDelta = maxValueDelta;
+ if (this.staticPreviewPoseApplied) {
+ this.releaseStaticAnimationResources();
+ }
+ return this.staticPreviewPoseApplied;
+ }
+
+ releaseStaticAnimationResources() {
+ // Bulk zone previews keep one live animation per model and pose duplicate
+ // NPCs at a representative frame. Once that frame has been applied, their
+ // cloned animation groups are no longer needed and otherwise dominate both
+ // scene bookkeeping and memory in spawn-heavy zones.
+ for (const animationGroup of this.animationGroups) {
+ animationGroup?.stop?.();
+ animationGroup?.dispose?.();
+ }
+ this.animationGroups = [];
+ this.animationMap = {};
+ this.animatables = [];
+ if (this.instanceContainer) {
+ this.instanceContainer.animationGroups = [];
+ }
+ }
+
+ retainSelectedVisualAnimationResources(selectedAnimationGroup) {
+ if (!selectedAnimationGroup || this.animationGroups.length <= 1) {
+ return;
+ }
+ let disposedGroupCount = 0;
+ for (const animationGroup of this.animationGroups) {
+ if (animationGroup === selectedAnimationGroup) {
+ continue;
+ }
+ animationGroup?.stop?.();
+ animationGroup?.dispose?.();
+ disposedGroupCount++;
+ }
+ this.animationGroups = [selectedAnimationGroup];
+ this.animationMap = mapAnimations(this.animationGroups);
+ this.animatables = [];
+ this.animationResourcePrunedCount =
+ Number(this.animationResourcePrunedCount ?? 0) + disposedGroupCount;
+ if (this.instanceContainer) {
+ this.instanceContainer.animationGroups = this.animationGroups;
+ }
+ }
+
+ async promoteToLiveAnimation() {
+ if (this.disposed || !this.rootNode) {
+ return false;
+ }
+ const existingAnimation = this.getPreferredVisualAnimationGroup();
+ if (existingAnimation && this.isDynamicVisualAnimationGroup(existingAnimation)) {
+ this.startInitialAnimation({ schedulePostInitialize: false });
+ return true;
+ }
+ if (this.nativePoseOnly) {
+ return false;
+ }
+ if (this.selectedAnimationPromotionPromise) {
+ return this.selectedAnimationPromotionPromise;
+ }
+
+ this.selectedAnimationPromotionPromise = (async () => {
+ const modelVariation = this.loadedModelVariation ?? this.modelName;
+ const assetContainer =
+ await window.gameController?.SpawnController?.getAssetContainer?.(
+ modelVariation
+ );
+ if (!assetContainer || this.disposed || !this.rootNode) {
+ this.selectedAnimationPromotionFailed = true;
+ return false;
+ }
+
+ const temporaryInstance = assetContainer.instantiateModelsToScene();
+ const animationGroups = [...(temporaryInstance.animationGroups ?? [])];
+ const animationRetargeting = retargetDetachedAnimationTargets(
+ animationGroups,
+ [this.rootNode, ...(this.rootNode.getDescendants?.(false) ?? [])]
+ );
+ const animationVitality = inspectAnimationSetVitality(animationGroups);
+ const canPromote =
+ animationGroups.length > 0 &&
+ animationRetargeting.unresolvedTargetCount === 0 &&
+ animationVitality.dynamicGroupCount > 0;
+
+ if (!canPromote) {
+ for (const animationGroup of animationGroups) {
+ animationGroup?.dispose?.();
+ }
+ temporaryInstance.animationGroups = [];
+ temporaryInstance.dispose?.();
+ this.selectedAnimationPromotionFailed = true;
+ return false;
+ }
+
+ // The cloned roots and skeletons are temporary; only their animation
+ // groups are retained after all targets have been deterministically
+ // rebound to this spawn's existing nodes.
+ temporaryInstance.animationGroups = [];
+ temporaryInstance.dispose?.();
+ this.animationGroups = animationGroups;
+ this.animationMap = mapAnimations(animationGroups);
+ this.animationRetargeting = animationRetargeting;
+ if (this.instanceContainer) {
+ this.instanceContainer.animationGroups = animationGroups;
+ }
+ this.staticPreviewPoseApplied = false;
+ this.selectedAnimationPromoted = true;
+ this.selectedAnimationPromotionFailed = false;
+ this.startInitialAnimation({ schedulePostInitialize: false });
+ return true;
+ })();
+
+ try {
+ return await this.selectedAnimationPromotionPromise;
+ } finally {
+ this.selectedAnimationPromotionPromise = null;
+ }
+ }
+
+ demoteSelectedLiveAnimation() {
+ if (!this.selectedAnimationPromoted) {
+ return false;
+ }
+ const applied = this.applyStaticAnimationPose();
+ this.selectedAnimationPromoted = false;
+ return applied;
}
enableLoopedAnimation() {
@@ -1440,21 +3126,41 @@ export class BabylonSpawn {
return;
}
- const anim =
- this.animationGroups.find((ag) => ag.name === 'Clone of p01') ??
- this.getPlayableAnimationGroups()?.[0];
+ const anim = this.getPreferredVisualAnimationGroup();
- if (anim) {
+ if (anim && this.isDynamicVisualAnimationGroup(anim)) {
this.disableLoopedAnimation();
anim.play(true);
this.normalizeAnimatedGroundPose();
+ } else {
+ if (!this.applyStaticAnimationPose()) {
+ this.applyNeutralSkeletonPose();
+ }
+ this.normalizeAnimatedGroundPose();
}
}
dispose() {
+ this.disposed = true;
+ this.selectedAnimationPromotionPromise = null;
+ if (this.postInitializeTimer !== null) {
+ clearTimeout(this.postInitializeTimer);
+ this.postInitializeTimer = null;
+ }
this.disposeNameplate();
- this.rootNode?.dispose();
+ const rootNode = this.rootNode;
this.rootNode = null;
+ // instantiateModelsToScene returns ownership of its root nodes, cloned
+ // skeletons and cloned animation groups. Disposing only the visible root
+ // leaves the latter two registered with the scene across zone changes.
+ this.instanceContainer?.dispose?.();
+ this.instanceContainer = null;
+ if (rootNode && !rootNode.isDisposed?.()) {
+ rootNode.dispose();
+ }
+ this.animationGroups = [];
+ this.animationMap = {};
+ this.animatables = [];
this.instance?.dispose();
this.instance = null;
}
@@ -1472,7 +3178,17 @@ export class BabylonSpawn {
this.nameplateMesh = null;
}
- createNameplate() {
+ createNameplate({ validationRepresentative = false } = {}) {
+ if (!this.isNameplateEligible()) {
+ this.nameplateRequired = false;
+ this.nameplateValidationRepresentative = false;
+ this.previewNameplateDeferred = false;
+ this.disposeNameplate();
+ return;
+ }
+ this.nameplateRequired = true;
+ this.nameplateValidationRepresentative = validationRepresentative === true;
+ this.previewNameplateDeferred = false;
if (
typeof DynamicTexture !== 'function' ||
typeof StandardMaterial !== 'function' ||
@@ -1573,6 +3289,7 @@ export class BabylonSpawn {
nameplate : true,
planeHeight,
spawn : this.metadata?.spawn ?? this.spawnEntry,
+ textLines : [...textLines],
};
this.nameplateMesh = plane;
diff --git a/frontend/src/views/items/ItemEditor.vue b/frontend/src/views/items/ItemEditor.vue
index 7a36cee9..bcaed954 100644
--- a/frontend/src/views/items/ItemEditor.vue
+++ b/frontend/src/views/items/ItemEditor.vue
@@ -2644,14 +2644,12 @@ export default {
return
}
- const routeData = this.$router.resolve({
+ this.$router.push({
path: ROUTE.ITEMS_EVOLVING,
query: {
evoId: this.item.evoid
}
- })
-
- window.open(routeData.href, "_blank", "noopener")
+ }).catch(() => {})
},
getTargetTypeColor(targetType) {
return Items.getTargetTypeColor(targetType);
diff --git a/frontend/src/views/sage/eqsage-loader.ts b/frontend/src/views/sage/eqsage-loader.ts
index 7abf288e..10d6a1de 100644
--- a/frontend/src/views/sage/eqsage-loader.ts
+++ b/frontend/src/views/sage/eqsage-loader.ts
@@ -18,6 +18,9 @@ type EqSageEmbedModule = {
const defaultBaseUrl = '/eqsage-embed'
const cssId = 'eqsage-embed-style'
let embedCacheKey: string | null = null
+// This revision is part of the dynamic-import URL, so a rebuilt embed cannot be
+// mistaken for a previously cached entry module when a tester reloads Sage.
+const embedRuntimeRevision = 'neutral-idle-texture-v27'
const defaultEqDirCandidates = ['C:/EQEmuCW-Live']
const loopbackHosts = new Set(['127.0.0.1', 'localhost', '[::1]', '::1'])
@@ -58,7 +61,7 @@ const getSageFsApiBase = () => `${getBackendBaseUrl()}/api/v1/app/sage-fs`
const getEmbedCacheKey = () => {
const explicitCacheBust = new URLSearchParams(window.location.search).get('sageCacheBust')
if (explicitCacheBust) {
- return `embed-${explicitCacheBust}`
+ return `embed-${explicitCacheBust}-${embedRuntimeRevision}`
}
if (embedCacheKey) {
@@ -80,13 +83,13 @@ const getEmbedCacheKey = () => {
for (const pattern of patterns) {
const match = source.match(pattern)
if (match?.[1]) {
- embedCacheKey = `${match[1]}-${sessionNonce}`
+ embedCacheKey = `${match[1]}-${sessionNonce}-${embedRuntimeRevision}`
return embedCacheKey
}
}
}
- embedCacheKey = `embed-${sessionNonce}`
+ embedCacheKey = `embed-${sessionNonce}-${embedRuntimeRevision}`
return embedCacheKey
}
@@ -117,6 +120,97 @@ const ensureStyle = (baseUrl: string) => {
const uniqueTruthy = (values: Array) =>
Array.from(new Set(values.map((value) => value?.trim()).filter(Boolean))) as string[]
+const sageDialogOverlayId = 'spire-sage-directory-dialog-overlay'
+
+const createSageDialog = (title: string) => {
+ document.getElementById(sageDialogOverlayId)?.remove()
+
+ const overlay = document.createElement('div')
+ overlay.id = sageDialogOverlayId
+ overlay.setAttribute('data-testid', 'spire-sage-directory-dialog')
+ Object.assign(overlay.style, {
+ alignItems: 'center',
+ background: 'rgba(3, 8, 15, 0.76)',
+ display: 'flex',
+ inset: '0',
+ justifyContent: 'center',
+ position: 'fixed',
+ zIndex: '2147483647',
+ })
+
+ const dialog = document.createElement('div')
+ dialog.setAttribute('aria-labelledby', 'spire-sage-directory-dialog-title')
+ dialog.setAttribute('aria-modal', 'true')
+ dialog.setAttribute('role', 'dialog')
+ Object.assign(dialog.style, {
+ background: '#0c1520',
+ border: '1px solid #9b8549',
+ borderRadius: '4px',
+ boxShadow: '0 18px 50px rgba(0, 0, 0, 0.62)',
+ color: '#f4f0df',
+ fontFamily: 'Arial, sans-serif',
+ maxWidth: '560px',
+ padding: '24px',
+ width: 'calc(100% - 48px)',
+ })
+
+ const heading = document.createElement('h2')
+ heading.id = 'spire-sage-directory-dialog-title'
+ heading.textContent = title
+ Object.assign(heading.style, {
+ fontSize: '20px',
+ margin: '0 0 14px',
+ })
+
+ dialog.appendChild(heading)
+ overlay.appendChild(dialog)
+ document.body.appendChild(overlay)
+ return { dialog, overlay }
+}
+
+const styleSageDialogButton = (button: HTMLButtonElement, primary = false) => {
+ Object.assign(button.style, {
+ background: primary ? '#177ddc' : '#182534',
+ border: `1px solid ${primary ? '#55a8f3' : '#66788b'}`,
+ borderRadius: '3px',
+ color: '#fff',
+ cursor: 'pointer',
+ fontSize: '14px',
+ padding: '8px 14px',
+ })
+}
+
+const showSageNotice = (message: string) => new Promise((resolve) => {
+ const { dialog, overlay } = createSageDialog('EverQuest Directory')
+ const body = document.createElement('p')
+ body.textContent = message
+ Object.assign(body.style, {
+ lineHeight: '1.5',
+ margin: '0 0 20px',
+ })
+
+ const closeButton = document.createElement('button')
+ closeButton.type = 'button'
+ closeButton.textContent = 'Close'
+ styleSageDialogButton(closeButton, true)
+
+ const close = () => {
+ document.removeEventListener('keydown', onKeyDown)
+ overlay.remove()
+ resolve()
+ }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ close()
+ }
+ }
+
+ closeButton.addEventListener('click', close, { once: true })
+ document.addEventListener('keydown', onKeyDown)
+ dialog.append(body, closeButton)
+ closeButton.focus()
+})
+
const getSageEqDirCandidates = () => {
const params = new URLSearchParams(window.location.search)
return uniqueTruthy([
@@ -147,7 +241,11 @@ const validateSageFsRoot = async (
},
method: 'POST',
})
- const bridgeAvailable = response.status !== 403 && response.status !== 404
+ const bridgeAvailable =
+ response.status >= 200 &&
+ response.status < 500 &&
+ response.status !== 403 &&
+ response.status !== 404
if (!response.ok) {
let error = `Selected EverQuest directory was rejected (${response.status})`
try {
@@ -215,7 +313,8 @@ const installSpireSageFileBridge = async () => {
}
const resolved = await resolveSageFsRoot()
- if (!resolved.root && !resolved.bridgeAvailable) {
+ const isLocalPreviewHost = loopbackHosts.has(window.location.hostname)
+ if (!resolved.root && !resolved.bridgeAvailable && !isLocalPreviewHost) {
return
}
let activeRoot = resolved.root
@@ -237,18 +336,195 @@ const installSpireSageFileBridge = async () => {
}
const response = await fetch(sageFsUrl(operation, activeRoot, filePath), init)
if (!response.ok) {
- throw new Error(`Sage filesystem ${operation} failed (${response.status})`)
+ let detail = ''
+ try {
+ const body = await response.json()
+ detail = String(body?.error || '').trim()
+ } catch (_error) {
+ // Preserve the status-only error when the response has no JSON body.
+ }
+ throw new Error(
+ `Sage filesystem ${operation} failed${detail ? `: ${detail}` : ''} (${response.status})`
+ )
}
return response
}
+ // The backend serializes cache mutations to protect files shared by several
+ // character archives. Keeping a small client-side window avoids building a
+ // large queue of overlapping requests and reduces socket/memory pressure
+ // during a full character-cache refresh.
+ const maxConcurrentTransfers = 4
+ let activeTransfers = 0
+ const pendingTransferSlots: Array<() => void> = []
+ const waitForTransferSlot = async () => {
+ if (activeTransfers >= maxConcurrentTransfers) {
+ await new Promise((resolve) => pendingTransferSlots.push(resolve))
+ }
+ activeTransfers += 1
+ }
+ const releaseTransferSlot = () => {
+ activeTransfers -= 1
+ pendingTransferSlots.shift()?.()
+ }
+ const sageFsErrorStatus = (error: unknown) =>
+ Number(String(error).match(/\((\d+)\)$/)?.[1] || 0)
+ const isRetryableSageFsError = (error: unknown) => {
+ const status = sageFsErrorStatus(error)
+ return status === 0 || status >= 500
+ }
+ const isWindowsFileLockError = (error: unknown) =>
+ /(?:EPERM|EBUSY|operation not permitted|resource busy)/i.test(String(error))
+ const hasReadableExistingFile = async (filePath: string) => {
+ try {
+ const response = await requestOk('read-file', filePath, { method: 'GET' })
+ const missing = response.headers.get('X-Sage-Preview-Missing') === '1'
+ await response.body?.cancel().catch(() => undefined)
+ return !missing
+ } catch (_error) {
+ return false
+ }
+ }
+ const waitBeforeRetry = (attempt: number) =>
+ new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)))
+ const readTransferRequests = new Map>()
+ const readWithRetryUnshared = async (filePath: string) => {
+ await waitForTransferSlot()
+ try {
+ // A large character zone can briefly contend with model-cache writes on
+ // the same local HTTP bridge. Give transient transport failures a small,
+ // bounded recovery window; missing files still return immediately.
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ const response = await requestOk('read-file', filePath, { method: 'GET' })
+ if (response.headers.get('X-Sage-Preview-Missing') === '1') {
+ return null
+ }
+ return await response.arrayBuffer()
+ } catch (error) {
+ const status = sageFsErrorStatus(error)
+ const retryable = status === 422 || isRetryableSageFsError(error)
+ if (!retryable || attempt === 4) {
+ throw error
+ }
+ await waitBeforeRetry(attempt)
+ }
+ }
+ throw new Error(`Sage filesystem read-file exhausted retries: ${filePath}`)
+ } finally {
+ releaseTransferSlot()
+ }
+ }
+ const readWithRetry = async (filePath: string) => {
+ // Character materials are shared by many spawns. Coalescing identical
+ // in-flight reads prevents one zone from opening dozens of HTTP requests
+ // for the same texture and makes the retry result deterministic for every
+ // consumer. Large model buffers remain independent to avoid cloning them.
+ if (!/\.(?:png|jpe?g|json)$/i.test(filePath)) {
+ return readWithRetryUnshared(filePath)
+ }
+
+ const existing = readTransferRequests.get(filePath)
+ if (existing) {
+ const shared = await existing
+ return shared?.slice(0) ?? null
+ }
+
+ const request = readWithRetryUnshared(filePath)
+ .finally(() => readTransferRequests.delete(filePath))
+ readTransferRequests.set(filePath, request)
+ return request
+ }
+ const writeWithRetry = async (
+ filePath: string,
+ data: ArrayBuffer | ArrayBufferView | string
+ ) => {
+ await waitForTransferSlot()
+ try {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await requestOk('write-file', filePath, {
+ body: data instanceof ArrayBuffer || ArrayBuffer.isView(data)
+ ? data
+ : String(data ?? ''),
+ method: 'POST',
+ })
+ return
+ } catch (error) {
+ const status = sageFsErrorStatus(error)
+ const retryable = status === 422 || isRetryableSageFsError(error)
+ if (
+ isWindowsFileLockError(error) &&
+ await hasReadableExistingFile(filePath)
+ ) {
+ // Antivirus scanners and a concurrent Sage tab can briefly retain
+ // an existing cache file on Windows. Keeping a confirmed-readable
+ // asset is safer than aborting the entire zone load.
+ return
+ }
+ if (!retryable || attempt === 2) {
+ throw error
+ }
+ await waitBeforeRetry(attempt)
+ }
+ }
+ } finally {
+ releaseTransferSlot()
+ }
+ }
+ const deleteWithRetry = async (
+ operation: 'delete-file' | 'delete-folder',
+ filePath: string
+ ) => {
+ // Cache refreshes can issue many overlapping removals for a texture shared
+ // by multiple character archives. Bound those requests like reads/writes,
+ // and retry conflicts plus temporary Windows file locks. A failed cache
+ // cleanup is nonfatal after the retry window because the existing asset is
+ // still usable; malformed, unauthorized paths remain fatal.
+ await waitForTransferSlot()
+ try {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await requestOk(operation, filePath, { method: 'DELETE' })
+ return
+ } catch (error) {
+ const status = sageFsErrorStatus(error)
+ const fatalDeleteStatus = status === 400 || status === 401 || status === 403
+ if (fatalDeleteStatus) {
+ throw error
+ }
+ if (isWindowsFileLockError(error)) {
+ return
+ }
+ const retryable =
+ status === 404 ||
+ status === 409 ||
+ status === 422 ||
+ isRetryableSageFsError(error)
+ if (retryable && attempt === 2) {
+ // Cache removal is idempotent. If another refresh already removed
+ // the entry or Windows still has the old texture open, retain that
+ // usable cached asset and allow the zone load to continue.
+ return
+ }
+ if (!retryable || attempt === 2) {
+ throw error
+ }
+ await waitBeforeRetry(attempt)
+ }
+ }
+ } finally {
+ releaseTransferSlot()
+ }
+ }
+
const selectRootFromPath = async (candidate: string | null) => {
if (!candidate) {
return null
}
const result = await validateSageFsRoot(candidate)
if (!result.root) {
- window.alert(
+ await showSageNotice(
result.error ||
'Selected directory does not look like an accessible EverQuest client directory.'
)
@@ -265,37 +541,53 @@ const installSpireSageFileBridge = async () => {
return response.json()
},
async readFile(filePath: string) {
- const response = await requestOk('read-file', filePath, { method: 'GET' })
- if (response.headers.get('X-Sage-Preview-Missing') === '1') {
- return null
- }
- return response.arrayBuffer()
+ return readWithRetry(filePath)
},
async createIfNotExist(_filePath: string) {
return true
},
async writeFile(filePath: string, data: ArrayBuffer | ArrayBufferView | string) {
- await requestOk('write-file', filePath, {
- body: data instanceof ArrayBuffer || ArrayBuffer.isView(data)
- ? data
- : String(data ?? ''),
- method: 'POST',
- })
+ await writeWithRetry(filePath, data)
},
async deleteFile(filePath: string) {
- await requestOk('delete-file', filePath, { method: 'DELETE' })
+ await deleteWithRetry('delete-file', filePath)
},
async deleteFolder(filePath: string) {
- await requestOk('delete-folder', filePath, { method: 'DELETE' })
+ await deleteWithRetry('delete-folder', filePath)
},
}
;(window as any).electronAPI = {
async hasStandalone() { return true },
async selectDirectory() {
- const defaultRoot = activeRoot || localStorage.getItem('eqdir') || defaultEqDirCandidates[0]
- const candidate = window.prompt('Enter the full path to your EverQuest directory:', defaultRoot)
- return (await selectRootFromPath(candidate)) || ''
+ let response: Response
+ try {
+ response = await fetch(`${getSageFsApiBase()}/select-directory`, {
+ method: 'POST',
+ })
+ } catch (_error) {
+ await showSageNotice(
+ 'The local Spire filesystem bridge is unavailable. Start or restart the local Spire backend, then try again.'
+ )
+ return ''
+ }
+ if (response.status === 204) {
+ return ''
+ }
+ if (!response.ok) {
+ let detail = `The directory selector failed (${response.status}).`
+ try {
+ const payload = await response.json()
+ detail = String(payload?.error || detail)
+ } catch (_error) {
+ // Keep the status-only message when the response is not JSON.
+ }
+ await showSageNotice(detail)
+ return ''
+ }
+ const payload = await response.json()
+ const selectedRoot = typeof payload?.root === 'string' ? payload.root : ''
+ return (await selectRootFromPath(selectedRoot)) || ''
},
getPath(file: { path?: string }) { return file?.path || activeRoot || '' },
onMessage() {},
diff --git a/frontend/vue.config.js b/frontend/vue.config.js
index d19df60d..3670f665 100644
--- a/frontend/vue.config.js
+++ b/frontend/vue.config.js
@@ -6,6 +6,9 @@ module.exports = {
devServer: {
host: "0.0.0.0",
disableHostCheck: true,
+ // Direct entry to Vue Router pages (notably /sage) must work after a
+ // freshly started local dev server, not only after navigating from `/`.
+ historyApiFallback: true,
watchOptions: {
ignored: [/node_modules/, /public/],
},
diff --git a/internal/app/controller.go b/internal/app/controller.go
index b4fbd5cb..8f1b7909 100644
--- a/internal/app/controller.go
+++ b/internal/app/controller.go
@@ -69,6 +69,7 @@ func (d *Controller) Routes() []*routes.Route {
routes.RegisterRoute(http.MethodGet, "app/env", d.env, nil),
routes.RegisterRoute(http.MethodPost, "app/update", d.update, nil),
routes.RegisterRoute(http.MethodPost, "app/sync", d.sync, nil),
+ routes.RegisterRoute(http.MethodPost, "app/sage-fs/select-directory", d.sageFsSelectDirectory, nil),
routes.RegisterRoute(http.MethodPost, "app/sage-fs/validate", d.sageFsValidate, nil),
routes.RegisterRoute(http.MethodGet, "app/sage-fs/readdir", d.sageFsReadDir, nil),
routes.RegisterRoute(http.MethodGet, "app/sage-fs/read-file", d.sageFsReadFile, nil),
@@ -246,6 +247,79 @@ type sageFsEntry struct {
}
var sageFsRootValidationCache sync.Map
+var sageFsFileMu sync.RWMutex
+
+const sageFsIOAttempts = 3
+
+func retrySageFsIO(operation func() error) (int, error) {
+ var err error
+ for attempt := 0; attempt < sageFsIOAttempts; attempt++ {
+ if err = operation(); err == nil {
+ return attempt, nil
+ }
+ if attempt+1 < sageFsIOAttempts {
+ time.Sleep(time.Duration(attempt+1) * 25 * time.Millisecond)
+ }
+ }
+ return sageFsIOAttempts - 1, err
+}
+
+func openSageFsFile(target string) (*os.File, int, error) {
+ var file *os.File
+ retries, err := retrySageFsIO(func() error {
+ var openErr error
+ file, openErr = os.Open(target)
+ if os.IsNotExist(openErr) {
+ return nil
+ }
+ return openErr
+ })
+ if file == nil && err == nil {
+ return nil, retries, os.ErrNotExist
+ }
+ return file, retries, err
+}
+
+func setSageFsRetryHeader(c echo.Context, retries int) {
+ if retries > 0 {
+ c.Response().Header().Set("X-Sage-Fs-Retries", fmt.Sprintf("%d", retries))
+ }
+}
+
+func (d *Controller) sageFsSelectDirectory(c echo.Context) error {
+ return d.sageFsSelectDirectoryWithPicker(c, pickSageFsDirectory)
+}
+
+func (d *Controller) sageFsSelectDirectoryWithPicker(
+ c echo.Context,
+ picker func() (string, error),
+) error {
+ if err := d.requireLocalSageFsRequest(c); err != nil {
+ return err
+ }
+
+ root, err := picker()
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, echo.Map{
+ "error": fmt.Sprintf("Unable to open the directory selector: %v", err),
+ })
+ }
+ if strings.TrimSpace(root) == "" {
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ root, err = normalizeSageFsRoot(root)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
+ }
+ if !isEverQuestClientDirectory(root) {
+ return c.JSON(http.StatusBadRequest, echo.Map{
+ "error": "Selected directory does not look like an EverQuest client directory",
+ })
+ }
+
+ return c.JSON(http.StatusOK, echo.Map{"root": filepath.ToSlash(root)})
+}
func (d *Controller) sageFsValidate(c echo.Context) error {
if err := d.requireLocalSageFsRequest(c); err != nil {
@@ -314,9 +388,12 @@ func (d *Controller) sageFsReadFile(c echo.Context) error {
if err != nil {
return c.JSON(statusCodeForSageFsError(err), echo.Map{"error": err.Error()})
}
+ sageFsFileMu.RLock()
+ defer sageFsFileMu.RUnlock()
- fileInfo, err := os.Stat(target)
- if os.IsNotExist(err) || (err == nil && !fileInfo.Mode().IsRegular()) {
+ file, retries, err := openSageFsFile(target)
+ setSageFsRetryHeader(c, retries)
+ if os.IsNotExist(err) {
c.Response().Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
c.Response().Header().Set("X-Sage-Preview-Missing", "1")
return c.NoContent(http.StatusOK)
@@ -324,12 +401,17 @@ func (d *Controller) sageFsReadFile(c echo.Context) error {
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
+ defer file.Close()
- file, err := os.Open(target)
+ fileInfo, err := file.Stat()
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
- defer file.Close()
+ if !fileInfo.Mode().IsRegular() {
+ c.Response().Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
+ c.Response().Header().Set("X-Sage-Preview-Missing", "1")
+ return c.NoContent(http.StatusOK)
+ }
c.Response().Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
return c.Stream(http.StatusOK, "application/octet-stream", file)
@@ -344,6 +426,8 @@ func (d *Controller) sageFsMkdir(c echo.Context) error {
if err != nil {
return c.JSON(statusCodeForSageFsError(err), echo.Map{"error": err.Error()})
}
+ sageFsFileMu.Lock()
+ defer sageFsFileMu.Unlock()
if err := os.MkdirAll(target, 0755); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
@@ -366,10 +450,19 @@ func (d *Controller) sageFsWriteFile(c echo.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
- if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ sageFsFileMu.Lock()
+ defer sageFsFileMu.Unlock()
+ mkdirRetries, err := retrySageFsIO(func() error {
+ return os.MkdirAll(filepath.Dir(target), 0755)
+ })
+ if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
- if err := os.WriteFile(target, body, 0644); err != nil {
+ writeRetries, err := retrySageFsIO(func() error {
+ return os.WriteFile(target, body, 0644)
+ })
+ setSageFsRetryHeader(c, mkdirRetries+writeRetries)
+ if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
}
@@ -385,6 +478,8 @@ func (d *Controller) sageFsDeleteFile(c echo.Context) error {
if err != nil {
return c.JSON(statusCodeForSageFsError(err), echo.Map{"error": err.Error()})
}
+ sageFsFileMu.Lock()
+ defer sageFsFileMu.Unlock()
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
@@ -402,6 +497,8 @@ func (d *Controller) sageFsDeleteFolder(c echo.Context) error {
if err != nil {
return c.JSON(statusCodeForSageFsError(err), echo.Map{"error": err.Error()})
}
+ sageFsFileMu.Lock()
+ defer sageFsFileMu.Unlock()
if err := os.RemoveAll(target); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()})
diff --git a/internal/app/controller_test.go b/internal/app/controller_test.go
index c0d171ca..1db4fcee 100644
--- a/internal/app/controller_test.go
+++ b/internal/app/controller_test.go
@@ -1,14 +1,108 @@
package app
import (
+ "encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
"github.com/EQEmuTools/spire/internal/env"
"github.com/labstack/echo/v4"
)
+func TestSageFsSelectDirectoryReturnsValidatedNativeSelection(t *testing.T) {
+ t.Setenv("APP_ENV", env.AppEnvLocal)
+
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "eqclient.ini"), []byte("[Defaults]"), 0644); err != nil {
+ t.Fatalf("write EQ client marker: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "global_chr.s3d"), []byte("asset"), 0644); err != nil {
+ t.Fatalf("write EQ asset marker: %v", err)
+ }
+
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/app/sage-fs/select-directory", nil)
+ req.RemoteAddr = "127.0.0.1:51234"
+ req.Header.Set("Origin", "http://127.0.0.1:8080")
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err := (&Controller{}).sageFsSelectDirectoryWithPicker(c, func() (string, error) {
+ return root, nil
+ })
+ if err != nil {
+ t.Fatalf("select directory: %v", err)
+ }
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var response struct {
+ Root string `json:"root"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if response.Root != filepath.ToSlash(root) {
+ t.Fatalf("expected selected root %q, got %q", filepath.ToSlash(root), response.Root)
+ }
+}
+
+func TestSageFsSelectDirectoryReturnsNoContentWhenCanceled(t *testing.T) {
+ t.Setenv("APP_ENV", env.AppEnvLocal)
+
+ e := echo.New()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/app/sage-fs/select-directory", nil)
+ req.RemoteAddr = "127.0.0.1:51234"
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err := (&Controller{}).sageFsSelectDirectoryWithPicker(c, func() (string, error) {
+ return "", nil
+ })
+ if err != nil {
+ t.Fatalf("cancel directory selection: %v", err)
+ }
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("expected status 204, got %d", rec.Code)
+ }
+}
+
+func TestRetrySageFsIOSucceedsAfterTransientFailures(t *testing.T) {
+ attempts := 0
+ retries, err := retrySageFsIO(func() error {
+ attempts++
+ if attempts < sageFsIOAttempts {
+ return errors.New("transient")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("expected transient operation to recover: %v", err)
+ }
+ if attempts != sageFsIOAttempts || retries != sageFsIOAttempts-1 {
+ t.Fatalf("expected %d attempts and %d retries, got %d attempts and %d retries", sageFsIOAttempts, sageFsIOAttempts-1, attempts, retries)
+ }
+}
+
+func TestRetrySageFsIOReturnsPersistentFailure(t *testing.T) {
+ attempts := 0
+ retries, err := retrySageFsIO(func() error {
+ attempts++
+ return errors.New("persistent")
+ })
+ if err == nil || err.Error() != "persistent" {
+ t.Fatalf("expected persistent error, got %v", err)
+ }
+ if attempts != sageFsIOAttempts || retries != sageFsIOAttempts-1 {
+ t.Fatalf("expected %d attempts and %d retries, got %d attempts and %d retries", sageFsIOAttempts, sageFsIOAttempts-1, attempts, retries)
+ }
+}
+
func TestRequireLocalSageFsRequestRejectsNonLoopbackPeerWithoutOrigin(t *testing.T) {
t.Setenv("APP_ENV", env.AppEnvLocal)
diff --git a/internal/app/sage_fs_directory_picker.go b/internal/app/sage_fs_directory_picker.go
new file mode 100644
index 00000000..007cda95
--- /dev/null
+++ b/internal/app/sage_fs_directory_picker.go
@@ -0,0 +1,91 @@
+package app
+
+import (
+ "fmt"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+func pickSageFsDirectory() (string, error) {
+ switch runtime.GOOS {
+ case "darwin":
+ return runSageFsDirectoryPicker(
+ exec.Command(
+ "osascript",
+ "-e",
+ `set selectedFolder to choose folder with prompt "Select your EverQuest directory"`,
+ "-e",
+ "POSIX path of selectedFolder",
+ ),
+ func(err error) bool {
+ exitErr, ok := err.(*exec.ExitError)
+ if !ok {
+ return false
+ }
+ message := string(exitErr.Stderr)
+ return strings.Contains(message, "User canceled") || strings.Contains(message, "(-128)")
+ },
+ )
+ case "windows":
+ const script = `$dialog = New-Object System.Windows.Forms.FolderBrowserDialog; ` +
+ `$dialog.Description = 'Select your EverQuest directory'; ` +
+ `if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { ` +
+ `[Console]::Out.Write($dialog.SelectedPath) }`
+ return runSageFsDirectoryPicker(
+ exec.Command(
+ "powershell.exe",
+ "-NoProfile",
+ "-Sta",
+ "-Command",
+ "Add-Type -AssemblyName System.Windows.Forms; "+script,
+ ),
+ func(error) bool { return false },
+ )
+ default:
+ if zenity, err := exec.LookPath("zenity"); err == nil {
+ return runSageFsDirectoryPicker(
+ exec.Command(
+ zenity,
+ "--file-selection",
+ "--directory",
+ "--title=Select your EverQuest directory",
+ ),
+ func(err error) bool {
+ exitErr, ok := err.(*exec.ExitError)
+ return ok && exitErr.ExitCode() == 1
+ },
+ )
+ }
+ if kdialog, err := exec.LookPath("kdialog"); err == nil {
+ return runSageFsDirectoryPicker(
+ exec.Command(
+ kdialog,
+ "--getexistingdirectory",
+ ".",
+ "--title",
+ "Select your EverQuest directory",
+ ),
+ func(err error) bool {
+ exitErr, ok := err.(*exec.ExitError)
+ return ok && exitErr.ExitCode() == 1
+ },
+ )
+ }
+ return "", fmt.Errorf("no supported native directory selector is installed")
+ }
+}
+
+func runSageFsDirectoryPicker(
+ command *exec.Cmd,
+ isCancellation func(error) bool,
+) (string, error) {
+ output, err := command.Output()
+ if err != nil {
+ if isCancellation(err) {
+ return "", nil
+ }
+ return "", err
+ }
+ return strings.TrimSpace(string(output)), nil
+}
diff --git a/package-lock.json b/package-lock.json
index c5e3ac55..cf0a13b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,9 @@
"name": "spire",
"version": "5.0.4",
"devDependencies": {
- "@playwright/test": "^1.56.1",
- "playwright": "^1.56.1",
- "playwright-core": "^1.56.1"
+ "@playwright/test": "1.56.1",
+ "playwright": "1.56.1",
+ "playwright-core": "1.56.1"
}
},
"node_modules/@playwright/test": {
@@ -29,38 +29,6 @@
"node": ">=18"
}
},
- "node_modules/@playwright/test/node_modules/playwright": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
- "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "playwright-core": "1.56.1"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
- }
- },
- "node_modules/@playwright/test/node_modules/playwright-core": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
- "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -77,13 +45,13 @@
}
},
"node_modules/playwright": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
- "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.58.2"
+ "playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -96,9 +64,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
- "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -117,24 +85,6 @@
"dev": true,
"requires": {
"playwright": "1.56.1"
- },
- "dependencies": {
- "playwright": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
- "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
- "dev": true,
- "requires": {
- "fsevents": "2.3.2",
- "playwright-core": "1.56.1"
- }
- },
- "playwright-core": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
- "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
- "dev": true
- }
}
},
"fsevents": {
@@ -145,19 +95,19 @@
"optional": true
},
"playwright": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
- "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
- "playwright-core": "1.58.2"
+ "playwright-core": "1.56.1"
}
},
"playwright-core": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
- "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true
}
}
diff --git a/package.json b/package.json
index 7d2157c4..d0f2edb3 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,26 @@
"url": "https://github.com/EQEmuTools/spire.git"
},
"scripts": {
- "serve:sage-preview": "node scripts/serve-sage-preview.js"
+ "serve:sage-preview": "node scripts/serve-sage-preview.js",
+ "qa:sage": "node --expose-gc tools/sage-qa/run.mjs",
+ "qa:sage:smoke": "node --expose-gc tools/sage-qa/run.mjs --profile smoke",
+ "qa:sage:compact": "node --expose-gc tools/sage-qa/run.mjs --profile compact-rigs",
+ "qa:sage:models": "node --expose-gc tools/sage-qa/run.mjs --profile model-regression",
+ "qa:sage:viewer": "node --expose-gc tools/sage-qa/run.mjs --profile model-viewer",
+ "qa:sage:review-feedback": "node --expose-gc tools/sage-qa/run.mjs --profile review-feedback",
+ "qa:sage:matrix": "node --expose-gc tools/sage-qa/run.mjs --profile matrix",
+ "qa:sage:soak": "node --expose-gc tools/sage-qa/run.mjs --profile soak",
+ "qa:sage:full": "node --expose-gc tools/sage-qa/run.mjs --profile full",
+ "qa:sage:coverage": "node --expose-gc tools/sage-qa/run.mjs --profile full --coverage-only",
+ "qa:sage:rig-sources": "node frontend/eqsage-embed/node_modules/esbuild/bin/esbuild tools/sage-qa/inspect-character-archive-rigs.mjs --bundle --platform=node --format=esm --outfile=tmp/sage-rig-inspector.mjs && node tmp/sage-rig-inspector.mjs",
+ "qa:sage:fs": "node tools/sage-qa/fs-stress.mjs",
+ "qa:sage:verify-checkpoints": "node tools/sage-qa/verify-checkpoints.mjs",
+ "qa:sage:approve-visuals": "node tools/sage-qa/approve-visual-baselines.mjs",
+ "test:sage-qa": "node tools/sage-qa/test/run.mjs"
},
"devDependencies": {
- "@playwright/test": "^1.56.1",
- "playwright": "^1.56.1",
- "playwright-core": "^1.56.1"
+ "@playwright/test": "1.56.1",
+ "playwright": "1.56.1",
+ "playwright-core": "1.56.1"
}
}
diff --git a/scripts/serve-sage-preview.js b/scripts/serve-sage-preview.js
index f421a9bd..0bae5921 100644
--- a/scripts/serve-sage-preview.js
+++ b/scripts/serve-sage-preview.js
@@ -395,16 +395,16 @@ const applyQuery = (collection, searchParams) => {
let result = [...collection];
- if (whereClauses.length > 0) {
- result = result.filter((entry) =>
- whereClauses.every((clause) => matchesClause(entry, clause))
- );
- }
-
- if (whereOrClauses.length > 0) {
- result = result.filter((entry) =>
- whereOrClauses.some((clause) => matchesClause(entry, clause))
- );
+ if (whereClauses.length > 0 || whereOrClauses.length > 0) {
+ result = result.filter((entry) => {
+ const matchesWhere =
+ whereClauses.length > 0 &&
+ whereClauses.every((clause) => matchesClause(entry, clause));
+ const matchesWhereOr =
+ whereOrClauses.length > 0 &&
+ whereOrClauses.some((clause) => matchesClause(entry, clause));
+ return matchesWhere || matchesWhereOr;
+ });
}
if (orderByFields.length > 0) {
diff --git a/tests/evolving-items-editor.spec.ts b/tests/evolving-items-editor.spec.ts
index 77d8f49f..fb44ae09 100644
--- a/tests/evolving-items-editor.spec.ts
+++ b/tests/evolving-items-editor.spec.ts
@@ -303,7 +303,8 @@ test.describe('Evolving Items Editor', () => {
await page.goBack();
await page.waitForSelector('#item-edit-card', { timeout: 20000 });
await page.locator('.eq-tab-box-fancy a').filter({ hasText: 'Evolving' }).click();
- await page.locator('#evoitem').fill('0');
+ await page.locator('label[for="evoitem"]').click();
+ await expect(page.locator('#evoitem')).not.toBeChecked();
await page.locator('button').filter({ hasText: 'Save Item' }).click();
await expect.poll(() => state.getLastSavedItem()?.evoitem).toBe(0);
diff --git a/tests/sage-preview-ui.spec.ts b/tests/sage-preview-ui.spec.ts
index b28905a1..8c843bd4 100644
--- a/tests/sage-preview-ui.spec.ts
+++ b/tests/sage-preview-ui.spec.ts
@@ -174,6 +174,157 @@ async function expectElementTopmost(locator: Locator) {
).toBe(true)
}
+type SageReadinessSnapshot = {
+ cameraFraming: unknown
+ loadingText: string | null
+ validationReady: boolean
+ zoneLoaded: boolean
+}
+
+const readSageReadiness = (page: Page) =>
+ page.evaluate((): SageReadinessSnapshot => {
+ const loadingText = Array.from(document.querySelectorAll('[role="dialog"] p'))
+ .map((paragraph) => paragraph.textContent?.trim() ?? '')
+ .find((text) => /^Decoded \d+ of \d+ images using \d+ threads$/.test(text)) ?? null
+
+ return {
+ cameraFraming : (window as any).__spireSageCameraFraming ?? null,
+ loadingText,
+ validationReady: Boolean((window as any).__spireSageLastZoneValidation),
+ zoneLoaded : (window as any).gameController?.ZoneController?.zoneLoaded ?? false,
+ }
+ })
+
+async function waitForRealZoneReadiness(page: Page) {
+ const hardTimeoutMs = 360000
+ const idleTimeoutMs = 90000
+ const startedAt = Date.now()
+ let latestSnapshot = await readSageReadiness(page)
+ let lastProgressAt = startedAt
+ let previousLoadingText = latestSnapshot.loadingText
+
+ while (Date.now() - startedAt < hardTimeoutMs) {
+ latestSnapshot = await readSageReadiness(page)
+ if (
+ latestSnapshot.zoneLoaded &&
+ latestSnapshot.validationReady &&
+ latestSnapshot.cameraFraming
+ ) {
+ return latestSnapshot
+ }
+
+ if (latestSnapshot.loadingText && latestSnapshot.loadingText !== previousLoadingText) {
+ previousLoadingText = latestSnapshot.loadingText
+ lastProgressAt = Date.now()
+ }
+
+ if (Date.now() - lastProgressAt >= idleTimeoutMs) {
+ throw new Error(
+ `Sage zone loading stalled for ${idleTimeoutMs}ms: ${JSON.stringify(latestSnapshot)}`
+ )
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 250))
+ }
+
+ throw new Error(
+ `Sage zone loading exceeded ${hardTimeoutMs}ms: ${JSON.stringify(latestSnapshot)}`
+ )
+}
+
+async function selectSpawnFromScene(page: Page, spawnId: number) {
+ await page.evaluate((id) => {
+ const spawnController = (window as any).gameController?.SpawnController
+ const visual = spawnController?.spawns?.[id]
+ const pickedMesh = [
+ visual?.rootNode,
+ ...(visual?.rootNode?.getChildMeshes?.(false) ?? []),
+ ].find((mesh) => Number(mesh?.metadata?.spawn?.id) === Number(id))
+ if (!pickedMesh) {
+ throw new Error(`No pickable scene mesh found for spawn ${id}`)
+ }
+ spawnController.sceneMouseDown({
+ type : 1,
+ pickInfo: { hit: true, pickedMesh },
+ })
+ }, spawnId)
+}
+
+const readSpawnSceneState = (page: Page, spawnId: number) =>
+ page.evaluate((id) => {
+ const spawnController = (window as any).gameController?.SpawnController
+ const visual = spawnController?.spawns?.[id]
+ const root = visual?.rootNode
+ const metadataReferences = [
+ root,
+ ...(root?.getChildMeshes?.(false) ?? []),
+ visual?.instance,
+ visual?.nameplateMesh,
+ ].filter(Boolean).map((node) => ({
+ id: node.metadata?.spawn?.id ?? null,
+ x : node.metadata?.spawn?.x ?? null,
+ y : node.metadata?.spawn?.y ?? null,
+ z : node.metadata?.spawn?.z ?? null,
+ }))
+
+ return {
+ controllerCount: Object.keys(spawnController?.spawns ?? {}).length,
+ exists : Boolean(visual && root && !root.isDisposed?.()),
+ groundY : visual?.getGroundReferenceWorldY?.() ?? null,
+ metadataReferences,
+ modelName : visual?.modelName ?? null,
+ position : root
+ ? { x: root.position.x, y: root.position.y, z: root.position.z }
+ : null,
+ selectedSpawnId: spawnController?.selectedSpawnId ?? null,
+ spawnEntry : visual?.spawnEntry
+ ? {
+ id : visual.spawnEntry.id,
+ spawnentries: visual.spawnEntry.spawnentries?.length ?? 0,
+ x : visual.spawnEntry.x,
+ y : visual.spawnEntry.y,
+ z : visual.spawnEntry.z,
+ }
+ : null,
+ stats: (window as any).__spireSageSpawnStats ?? null,
+ }
+ }, spawnId)
+
+const readDoorSceneState = (page: Page, doorId?: number) =>
+ page.evaluate((id) => {
+ const controller = (window as any).gameController?.ZoneController
+ const meshes = (controller?.doorNode?.getChildren?.() ?? [])
+ .filter((mesh: any) => mesh?.dataReference)
+ const mesh = id === undefined
+ ? null
+ : meshes.find((candidate: any) =>
+ Number(candidate.dataReference?.id) === Number(id)
+ )
+ return {
+ exists : id === undefined ? undefined : Boolean(mesh && !mesh.isDisposed?.()),
+ ids : meshes.map((candidate: any) => candidate.dataReference?.id),
+ position: mesh
+ ? { x: mesh.position.x, y: mesh.position.y, z: mesh.position.z }
+ : null,
+ reference: mesh
+ ? {
+ heading: mesh.dataReference.heading,
+ id : mesh.dataReference.id,
+ pos_x : mesh.dataReference.pos_x,
+ pos_y : mesh.dataReference.pos_y,
+ pos_z : mesh.dataReference.pos_z,
+ size : mesh.dataReference.size,
+ }
+ : null,
+ rotationYDegrees: mesh
+ ? Math.round(mesh.rotation.y * (180 / Math.PI) * 1_000_000) / 1_000_000
+ : null,
+ scale : mesh?.scaling?.y ?? null,
+ sceneDoorCount : meshes.length,
+ stats : (window as any).__spireSageDoorStats ?? null,
+ }
+ }, doorId)
+
test.describe('Sage preview UI', () => {
test.beforeAll(async () => {
const preview = createPreviewServer()
@@ -217,6 +368,63 @@ test.describe('Sage preview UI', () => {
await expect(page.getByRole('dialog', { name: 'Update Spire' })).toHaveCount(0)
})
+ test('shows an in-page notice when the directory bridge is unavailable', async ({ page }) => {
+ await page.route('**/api/v1/app/sage-fs/validate', route =>
+ route.fulfill({
+ status : 503,
+ contentType: 'application/json',
+ body : JSON.stringify({ error: 'Filesystem bridge unavailable' }),
+ })
+ )
+
+ await page.goto(`${previewBaseUrl}/sage`, { waitUntil: 'load' })
+ await expect.poll(() => page.evaluate(() => Boolean((window as any).electronAPI))).toBe(true)
+ const selection = page.evaluate(() => (window as any).electronAPI.selectDirectory())
+
+ const notice = page.getByRole('dialog', { name: 'EverQuest Directory' })
+ await expect(notice).toBeVisible()
+ await expect(notice).toContainText('local Spire filesystem bridge is unavailable')
+ const closeButton = notice.getByRole('button', { name: 'Close' })
+ await expect(closeButton).toBeFocused()
+ await closeButton.click()
+ await expect(selection).resolves.toBe('')
+ })
+
+ test('uses an in-page path form to select a validated EQ directory', async ({ page }) => {
+ const selectedRoot = 'D:/EverQuest'
+ await page.route('**/api/v1/app/sage-fs/validate', async route => {
+ const requestRoot = route.request().postDataJSON()?.root
+ if (requestRoot === selectedRoot) {
+ await route.fulfill({
+ status : 200,
+ contentType: 'application/json',
+ body : JSON.stringify({ root: selectedRoot }),
+ })
+ return
+ }
+
+ await route.fulfill({
+ status : 400,
+ contentType: 'application/json',
+ body : JSON.stringify({ error: 'Choose another EverQuest directory' }),
+ })
+ })
+
+ await page.goto(`${previewBaseUrl}/sage`, { waitUntil: 'load' })
+ await expect.poll(() => page.evaluate(() => Boolean((window as any).electronAPI))).toBe(true)
+ const selection = page.evaluate(() => (window as any).electronAPI.selectDirectory())
+
+ const pathDialog = page.getByRole('dialog', { name: 'Select EverQuest Directory' })
+ const pathInput = pathDialog.getByLabel('Enter the full path to your EverQuest directory:')
+ await expect(pathDialog).toBeVisible()
+ await expect(pathInput).toBeFocused()
+ await pathInput.fill(selectedRoot)
+ await pathDialog.getByRole('button', { name: 'Use Directory' }).click()
+
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('eqdir'))).toBe(selectedRoot)
+ await expect(selection).resolves.toBe(selectedRoot)
+ })
+
test('opens the integrated zone editor canvas after directory and zone selection', async ({ page }) => {
await seedEqDirectory(page)
await page.goto(`${previewBaseUrl}/sage`, { waitUntil: 'load' })
@@ -225,6 +433,7 @@ test.describe('Sage preview UI', () => {
await expect(page.locator('text=Unable to load the EQ Sage zone editor bundle.')).toHaveCount(0)
await expect(page.getByRole('dialog', { name: 'DotNet Quests' })).toHaveCount(0)
await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Open Model Review' })).toBeVisible()
await page.getByText('The Mines of Gloomingdeep').click()
@@ -255,6 +464,85 @@ test.describe('Sage preview UI', () => {
}
})
+ test('treats stale and temporarily locked cache deletes as idempotent', async ({ page }) => {
+ const eqRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spire-sage-eq-'))
+ let deleteAttempts = 0
+ let writeAttempts = 0
+ let fallbackReadAttempts = 0
+
+ try {
+ await fs.promises.writeFile(path.join(eqRoot, 'eqgame.exe'), '')
+ await fs.promises.writeFile(path.join(eqRoot, 'tutorial.s3d'), new Uint8Array([0, 1, 2, 3]))
+ await page.route('**/api/v1/app/sage-fs/delete-file**', async route => {
+ deleteAttempts += 1
+ const locked = route.request().url().includes('locked-texture')
+ await route.fulfill({
+ status : locked ? 500 : 422,
+ contentType: 'application/json',
+ body : JSON.stringify({
+ error: locked
+ ? 'EPERM: operation not permitted'
+ : 'Cache entry was already removed',
+ }),
+ })
+ })
+ await page.route('**/api/v1/app/sage-fs/write-file**', async route => {
+ if (!route.request().url().includes('locked-texture')) {
+ await route.continue()
+ return
+ }
+ writeAttempts += 1
+ await route.fulfill({
+ status : 500,
+ contentType: 'application/json',
+ body : JSON.stringify({ error: 'EPERM: operation not permitted' }),
+ })
+ })
+ await page.route('**/api/v1/app/sage-fs/read-file**', async route => {
+ if (!route.request().url().includes('locked-texture')) {
+ await route.continue()
+ return
+ }
+ fallbackReadAttempts += 1
+ await route.fulfill({
+ status : 200,
+ contentType: 'application/octet-stream',
+ body : Buffer.from([1, 2, 3, 4]),
+ })
+ })
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=delete-idempotency-test`,
+ { waitUntil: 'load' }
+ )
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+
+ await expect(
+ page.evaluate(() =>
+ (window as any).electronFS.deleteFile('eqsage/zones/stale-zone.glb')
+ )
+ ).resolves.toBeUndefined()
+ await expect(
+ page.evaluate(() =>
+ (window as any).electronFS.deleteFile('eqsage/textures/locked-texture.png')
+ )
+ ).resolves.toBeUndefined()
+ expect(deleteAttempts).toBe(4)
+ await expect(
+ page.evaluate(() =>
+ (window as any).electronFS.writeFile(
+ 'eqsage/textures/locked-texture.png',
+ new Uint8Array([5, 6, 7, 8])
+ )
+ )
+ ).resolves.toBeUndefined()
+ expect(writeAttempts).toBe(1)
+ expect(fallbackReadAttempts).toBe(1)
+ } finally {
+ await fs.promises.rm(eqRoot, { force: true, recursive: true })
+ }
+ })
+
test('keeps zone chooser popups clickable above the Sage dialog', async ({ page }) => {
const eqRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spire-sage-eq-'))
@@ -286,7 +574,465 @@ test.describe('Sage preview UI', () => {
}
})
+ test('keeps unlink confirmation clickable above the zone chooser', async ({ page }) => {
+ const eqRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spire-sage-eq-'))
+
+ try {
+ await fs.promises.writeFile(path.join(eqRoot, 'eqgame.exe'), '')
+ await fs.promises.writeFile(path.join(eqRoot, 'tutorial.s3d'), new Uint8Array([0, 1, 2, 3]))
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=confirm-layer-test`,
+ { waitUntil: 'load' }
+ )
+
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ await page.getByRole('button', { name: 'Unlink EQ Directory' }).click()
+
+ const confirmDialog = page.getByRole('dialog', { name: 'Unlink EQ Directory' })
+ await expect(confirmDialog).toBeVisible()
+ const cancelButton = confirmDialog.getByRole('button', { name: 'Cancel' })
+ await expectElementTopmost(cancelButton)
+ await cancelButton.click()
+ await expect(confirmDialog).not.toBeVisible()
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ } finally {
+ await fs.promises.rm(eqRoot, { force: true, recursive: true })
+ }
+ })
+
+ test('exercises the standalone model review lifecycle with stable runtime resources', async ({ page }) => {
+ test.setTimeout(240000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'models', 'hum.glb')),
+ 'requires local Sage generated character assets'
+ )
+
+ const screenshotDirectory = path.resolve('output', 'playwright', 'model-viewer')
+ await fs.promises.mkdir(screenshotDirectory, { recursive: true })
+ const pageErrors: string[] = []
+ const modelReviewErrors: string[] = []
+ const failedModelRequests: string[] = []
+ page.on('pageerror', (error) => pageErrors.push(error.message))
+ page.on('console', (message) => {
+ if (
+ message.type() === 'error' &&
+ message.text().includes('[SageModelReview]')
+ ) {
+ modelReviewErrors.push(message.text())
+ }
+ })
+ page.on('response', (response) => {
+ if (
+ response.status() >= 400 &&
+ /\/eqsage\/models\/(hum|qcf)(?:\d+)?\.glb(?:\?|$)/i.test(response.url())
+ ) {
+ failedModelRequests.push(`${response.status()} ${response.url()}`)
+ }
+ })
+
+ await page.setViewportSize({ width: 1600, height: 1000 })
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageModelReview=1&sageModel=hum&sageCacheBust=model-review-lifecycle-test`,
+ { waitUntil: 'load' }
+ )
+
+ await expect(page.getByRole('main', { name: 'Sage Model Review' })).toBeVisible()
+ await expect(page.locator('canvas#modelReviewCanvas')).toHaveCount(1)
+
+ const waitForReady = async (
+ model: string,
+ view?: string,
+ faceFocus?: boolean
+ ) => {
+ await expect.poll(() => page.evaluate(() => {
+ const review = (window as any).__spireSageModelReview
+ return review && {
+ animationPass : review.diagnostics?.animationPass ?? false,
+ appearancePass : review.diagnostics?.appearance?.invariantPass ?? false,
+ faceFocus : review.faceFocus,
+ model : review.model,
+ orientationPass: review.diagnostics?.orientationPass ?? false,
+ ready : review.ready,
+ view : review.view,
+ }
+ }), { timeout: 120000 }).toMatchObject({
+ animationPass : true,
+ appearancePass : true,
+ model,
+ orientationPass: true,
+ ready : true,
+ ...(view ? { view } : {}),
+ ...(typeof faceFocus === 'boolean' ? { faceFocus } : {}),
+ })
+ }
+
+ const expectModelFramedInVisibleStage = async (
+ usesAnimationEnvelope: boolean,
+ faceFocus = false
+ ) => {
+ await expect.poll(() => page.evaluate(() => {
+ const review = (window as any).__spireSageModelReview
+ const camera = (window as any).gameController?.CameraController?.camera
+ const canvas = document.querySelector('#modelReviewCanvas')
+ const rail = document.querySelector('.model-review-rail')
+ const inspector = document.querySelector('.model-review-inspector')
+ const toolbar = document.querySelector('.model-review-toolbar')
+ if (!review?.framing || !camera?.viewport || !canvas || !rail || !inspector || !toolbar) {
+ return null
+ }
+ const canvasRect = canvas.getBoundingClientRect()
+ const railRect = rail.getBoundingClientRect()
+ const inspectorRect = inspector.getBoundingClientRect()
+ const toolbarRect = toolbar.getBoundingClientRect()
+ const viewport = camera.viewport
+ return {
+ actual: {
+ bottom: canvasRect.height * (1 - viewport.y),
+ left: canvasRect.width * viewport.x,
+ right: canvasRect.width * (viewport.x + viewport.width),
+ top: canvasRect.height * (1 - viewport.y - viewport.height),
+ },
+ expected: {
+ bottom: canvasRect.height,
+ left: railRect.right - canvasRect.left,
+ right: inspectorRect.left - canvasRect.left,
+ top: toolbarRect.bottom - canvasRect.top,
+ },
+ framingDistance: review.framing.distance,
+ cameraDistance: camera.radius,
+ faceFocus: review.framing.faceFocus,
+ usesAnimationEnvelope: review.framing.usesAnimationEnvelope,
+ }
+ }), { timeout: 15000 }).toEqual(expect.objectContaining({
+ actual: expect.objectContaining({
+ bottom: expect.any(Number),
+ left: expect.any(Number),
+ right: expect.any(Number),
+ top: expect.any(Number),
+ }),
+ expected: expect.objectContaining({
+ bottom: expect.any(Number),
+ left: expect.any(Number),
+ right: expect.any(Number),
+ top: expect.any(Number),
+ }),
+ framingDistance: expect.any(Number),
+ cameraDistance: expect.any(Number),
+ faceFocus,
+ usesAnimationEnvelope,
+ }))
+
+ const framing = await page.evaluate(() => {
+ const review = (window as any).__spireSageModelReview
+ const camera = (window as any).gameController.CameraController.camera
+ const canvas = document.querySelector('#modelReviewCanvas')!
+ const rail = document.querySelector('.model-review-rail')!
+ const inspector = document.querySelector('.model-review-inspector')!
+ const toolbar = document.querySelector('.model-review-toolbar')!
+ const canvasRect = canvas.getBoundingClientRect()
+ const viewport = camera.viewport
+ return {
+ actual: {
+ bottom: canvasRect.height * (1 - viewport.y),
+ left: canvasRect.width * viewport.x,
+ right: canvasRect.width * (viewport.x + viewport.width),
+ top: canvasRect.height * (1 - viewport.y - viewport.height),
+ },
+ expected: {
+ bottom: canvasRect.height,
+ left: rail.getBoundingClientRect().right - canvasRect.left,
+ right: inspector.getBoundingClientRect().left - canvasRect.left,
+ top: toolbar.getBoundingClientRect().bottom - canvasRect.top,
+ },
+ framingDistance: review.framing.distance,
+ cameraDistance: camera.radius,
+ faceFocus: review.framing.faceFocus,
+ usesAnimationEnvelope: review.framing.usesAnimationEnvelope,
+ }
+ })
+ for (const edge of ['bottom', 'left', 'right', 'top'] as const) {
+ expect(framing.actual[edge]).toBeCloseTo(framing.expected[edge], 1)
+ }
+ expect(framing.framingDistance).toBeGreaterThan(0)
+ expect(framing.cameraDistance).toBeCloseTo(framing.framingDistance, 4)
+ expect(framing.faceFocus).toBe(faceFocus)
+ expect(framing.usesAnimationEnvelope).toBe(usesAnimationEnvelope)
+ }
+
+ const selectReviewedModel = async (query: string, buttonName: string, model: string) => {
+ await page.getByRole('textbox', { name: 'Search models' }).fill(query)
+ await page.getByRole('button', { name: buttonName, exact: true }).click()
+ await waitForReady(model)
+ }
+
+ await waitForReady('hum', 'front', false)
+ await expectModelFramedInVisibleStage(true, false)
+
+ await expect(page.getByText('Resolved asset').locator('..')).toContainText('HUM')
+ await expect(page.locator('.model-review-count span').first()).toContainText(/\d+ models/)
+ await expect(page.locator('.model-review-badge.is-pass')).toHaveCount(3)
+ await expect(page.getByLabel('Clip').locator('option')).not.toHaveCount(0)
+ expect(await page.evaluate(() => {
+ const scene = (window as any).gameController?.currentScene
+ const enabledLights = (scene?.lights ?? []).filter((light: any) =>
+ light?.isEnabled?.() !== false
+ )
+ return {
+ environmentIntensity: scene?.environmentIntensity,
+ environmentTexture : scene?.environmentTexture ?? null,
+ glowEnabled : (() => {
+ const glowLayer = (window as any).gameController?.ZoneController?.glowLayer
+ return typeof glowLayer?.isEnabled === 'function'
+ ? glowLayer.isEnabled()
+ : glowLayer?.isEnabled ?? glowLayer?._isEnabled
+ })(),
+ lightIntensities : enabledLights.map((light: any) => light.intensity),
+ }
+ })).toEqual({
+ environmentIntensity: 0,
+ environmentTexture : null,
+ glowEnabled : false,
+ lightIntensities : [0.85],
+ })
+
+ await page.screenshot({
+ path : path.join(screenshotDirectory, '01-hum-front-overview.png'),
+ fullPage: false,
+ })
+
+ const fullModelDistance = await page.evaluate(() => (
+ (window as any).__spireSageModelReview?.framing?.distance
+ ))
+ const faceFocusButton = page.getByRole('button', { name: 'Face focus' })
+ await expect(faceFocusButton).toHaveAttribute('aria-pressed', 'false')
+ await page.keyboard.press('4')
+ await waitForReady('hum', 'front', true)
+ await expectModelFramedInVisibleStage(true, true)
+ await expect(faceFocusButton).toHaveAttribute('aria-pressed', 'true')
+ expect(new URL(page.url()).searchParams.get('sageModelView')).toBe('front')
+ expect(new URL(page.url()).searchParams.get('sageModelFaceFocus')).toBe('1')
+ expect(await page.evaluate(() => (
+ (window as any).__spireSageModelReview?.framing?.distance
+ ))).toBeLessThan(fullModelDistance)
+
+ await page.locator('[title="Side view (2)"]').click()
+ await waitForReady('hum', 'side', true)
+ await page.locator('[title="Front view (1)"]').click()
+ await waitForReady('hum', 'front', true)
+
+ await page.getByLabel('Face').selectOption('3')
+ await expect.poll(() => new URL(page.url()).searchParams.get('sageModelFace')).toBe('3')
+ await waitForReady('hum', 'front', true)
+ await page.getByLabel('Body').selectOption('2')
+ await expect.poll(() => new URL(page.url()).searchParams.get('sageModelTexture')).toBe('2')
+ await waitForReady('hum', 'front', true)
+ await page.getByLabel('Helm').selectOption('1')
+ await expect.poll(() => new URL(page.url()).searchParams.get('sageModelHelm')).toBe('1')
+ await waitForReady('hum', 'front', true)
+
+ const clipOptions = await page.getByLabel('Clip').locator('option').evaluateAll((options) =>
+ options.map((option) => ({
+ label: option.textContent?.trim() ?? '',
+ value: (option as HTMLOptionElement).value,
+ }))
+ )
+ expect(clipOptions.length).toBeGreaterThan(1)
+ await page.getByLabel('Clip').selectOption(clipOptions[1].value)
+ const playButton = page.getByRole('button', { name: /animation/ })
+ if (await playButton.getAttribute('aria-label') === 'Play animation') {
+ await playButton.click()
+ }
+ await expect(playButton).toHaveAttribute('aria-label', 'Pause animation')
+ const animationFrame = page.getByLabel('Animation frame')
+ const frameRange = await animationFrame.evaluate((element) => ({
+ max: Number((element as HTMLInputElement).max),
+ min: Number((element as HTMLInputElement).min),
+ }))
+ expect(frameRange.max).toBeGreaterThan(frameRange.min)
+ const targetFrame = frameRange.min + (frameRange.max - frameRange.min) / 2
+ await animationFrame.fill(String(targetFrame))
+ await expect(playButton).toHaveAttribute('aria-label', 'Play animation')
+ await expect(page.locator('.model-review-playback output')).toContainText(
+ String(Math.round(targetFrame))
+ )
+
+ const reviewNote = 'E2E: inspect face 3, body 2, helm 1 with face focus'
+ await page.getByRole('textbox', { name: 'Review note' }).fill(reviewNote)
+ await page.getByRole('button', { name: 'Flag issue' }).click()
+ await expect(page.getByRole('button', { name: 'Flag issue' })).toHaveClass(/is-issue/)
+ await expect(page.locator('.model-review-count span').nth(1)).toContainText('1 reviewed')
+
+ await page.screenshot({
+ path : path.join(screenshotDirectory, '02-hum-face-focus-variant-flagged.png'),
+ fullPage: false,
+ })
+
+ await page.reload({ waitUntil: 'load' })
+ await waitForReady('hum', 'front', true)
+ await expect(page.getByRole('textbox', { name: 'Review note' })).toHaveValue(reviewNote)
+ await expect(page.getByRole('button', { name: 'Flag issue' })).toHaveClass(/is-issue/)
+ expect(new URL(page.url()).searchParams.get('sageModelFace')).toBe('3')
+ expect(new URL(page.url()).searchParams.get('sageModelTexture')).toBe('2')
+ expect(new URL(page.url()).searchParams.get('sageModelHelm')).toBe('1')
+ expect(new URL(page.url()).searchParams.get('sageModelFaceFocus')).toBe('1')
+
+ await faceFocusButton.click()
+ await waitForReady('hum', 'front', false)
+ await expect(faceFocusButton).toHaveAttribute('aria-pressed', 'false')
+ expect(new URL(page.url()).searchParams.get('sageModelFaceFocus')).toBe('0')
+
+ await page.getByRole('button', { name: 'Issues', exact: true }).click()
+ await expect(page.locator('[data-review-model="hum"]')).toHaveCount(1)
+ await page.getByRole('button', { name: 'Unreviewed', exact: true }).click()
+ await expect(page.locator('[data-review-model="hum"]')).toHaveCount(0)
+ await page.getByRole('button', { name: 'All', exact: true }).click()
+
+ await page.getByRole('textbox', { name: 'Search models' }).fill('')
+ const initialModel = await page.evaluate(() => (window as any).__spireSageModelReview?.model)
+ await page.getByRole('button', { name: 'Next model' }).click()
+ await expect.poll(() => page.evaluate(() => (
+ (window as any).__spireSageModelReview?.ready === true
+ ? (window as any).__spireSageModelReview?.model
+ : null
+ )), { timeout: 120000 }).not.toBe(initialModel)
+ await page.keyboard.press('ArrowLeft')
+ await waitForReady(initialModel)
+
+ const readReviewResources = () => page.evaluate(() => {
+ const scene = (window as any).gameController?.currentScene
+ return {
+ animationGroups: scene?.animationGroups?.length ?? 0,
+ materials : scene?.materials?.length ?? 0,
+ meshes : scene?.meshes?.length ?? 0,
+ skeletons : scene?.skeletons?.length ?? 0,
+ textures : scene?.textures?.length ?? 0,
+ transformNodes : scene?.transformNodes?.length ?? 0,
+ }
+ })
+
+ await selectReviewedModel('qcf', 'QCF Human', 'qcf')
+ await expect.poll(() => page.evaluate(() => (
+ (window as any).__spireSageModelReview?.diagnostics?.compactNativeArmNeutralized ?? false
+ ))).toBe(true)
+ await page.locator('[title="Rear view (3)"]').click()
+ await waitForReady('qcf', 'back')
+ await expectModelFramedInVisibleStage(false, false)
+
+ await page.screenshot({
+ path : path.join(screenshotDirectory, '03-qcf-rear-compact-rig.png'),
+ fullPage: false,
+ })
+
+ const settledResources = await readReviewResources()
+ for (let iteration = 0; iteration < 4; iteration++) {
+ await selectReviewedModel('hum', 'HUM Human', 'hum')
+ await selectReviewedModel('qcf', 'QCF Human', 'qcf')
+ }
+ const repeatedResources = await readReviewResources()
+ expect(repeatedResources).toEqual(settledResources)
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageModelReview=1&sageModel=hum&sageModelView=head&sageCacheBust=model-review-legacy-head-link`,
+ { waitUntil: 'load' }
+ )
+ await waitForReady('hum', 'front', true)
+ expect(new URL(page.url()).searchParams.get('sageModelView')).toBe('front')
+ expect(new URL(page.url()).searchParams.get('sageModelFaceFocus')).toBe('1')
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageModelReview=1&sageModel=not-a-model&sageCacheBust=model-review-invalid-deep-link`,
+ { waitUntil: 'load' }
+ )
+ await expect.poll(() => page.evaluate(() => (
+ (window as any).__spireSageModelReview?.ready === true
+ ? (window as any).__spireSageModelReview?.model
+ : null
+ )), { timeout: 120000 }).not.toBe('not-a-model')
+
+ expect(pageErrors).toEqual([])
+ expect(modelReviewErrors).toEqual([])
+ expect(failedModelRequests).toEqual([])
+ })
+
+ test('lets the Sage QA harness drive model viewer checks', async ({ page }) => {
+ test.setTimeout(180000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'models', 'hum.glb')),
+ 'requires local Sage generated character assets'
+ )
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}` +
+ '&sageValidation=models' +
+ '&sageValidateModels=hum' +
+ '&sageValidationModelViews=front,face' +
+ '&sageValidationStepDelay=20' +
+ '&sageValidationPersist=0' +
+ '&sageCacheBust=model-review-qa-harness',
+ { waitUntil: 'load' }
+ )
+
+ await expect(page.getByRole('main', { name: 'Sage Model Review' })).toBeVisible()
+ await expect(page.getByText('Sage Model Validation')).toBeVisible()
+ await expect.poll(() => page.evaluate(() => {
+ const summary = (window as any).__spireSageValidationSummary
+ return summary && {
+ complete: summary.complete,
+ failureCount: summary.failureCount,
+ finished: summary.finished,
+ mode: summary.config?.mode,
+ reports: summary.reports?.map((report: any) => ({
+ all: report.pass?.all,
+ faceFocus: report.faceFocus,
+ framing: report.pass?.framing,
+ label: report.label,
+ model: report.model,
+ view: report.view,
+ })),
+ }
+ }), { timeout: 120000 }).toEqual({
+ complete: true,
+ failureCount: 0,
+ finished: true,
+ mode: 'models',
+ reports: [
+ {
+ all: true,
+ faceFocus: false,
+ framing: true,
+ label: 'front',
+ model: 'hum',
+ view: 'front',
+ },
+ {
+ all: true,
+ faceFocus: true,
+ framing: true,
+ label: 'face',
+ model: 'hum',
+ view: 'front',
+ },
+ ],
+ })
+ await expect(page.getByRole('button', { name: 'Face focus4' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ )
+ expect(await page.evaluate(() => ({
+ qaApiVersion: (window as any).__spireSageModelReview?.qaApiVersion,
+ hasQaDriver: typeof (window as any).__spireSageModelReview?.runQaStep === 'function',
+ }))).toEqual({
+ qaApiVersion: 1,
+ hasQaDriver: true,
+ })
+ })
+
test('frames real local zone geometry instead of opening on a blank safe-point view', async ({ page }) => {
+ test.setTimeout(420000)
const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
test.skip(
!fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
@@ -311,9 +1057,8 @@ test.describe('Sage preview UI', () => {
})
await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
- await expect.poll(async () => (
- page.evaluate(() => (window as any).__spireSageCameraFraming?.mode ?? null)
- ), { timeout: 10000 }).toBe('overview')
+ const readiness = await waitForRealZoneReadiness(page)
+ expect((readiness.cameraFraming as { mode?: string })?.mode).toBe('overview')
expect(await page.evaluate(() => {
const zoneMesh = (window as any).gameController?.ZoneController?.scene?.getMeshByName?.('zone')
return zoneMesh?.getTotalVertices?.() ?? 0
@@ -368,5 +1113,782 @@ test.describe('Sage preview UI', () => {
expect(await page.evaluate(() => (
(window as any).__spireSageLastZoneValidation?.doors?.visuals?.texturedSlotCount ?? 0
))).toBeGreaterThan(0)
+ const validationReport = await page.evaluate(() => (
+ (window as any).__spireSageLastZoneValidation
+ ))
+ expect(
+ validationReport?.pass,
+ `Zone validation summary: ${JSON.stringify({
+ pass : validationReport?.pass,
+ geometry : validationReport?.geometry,
+ runtimeAnimation: validationReport?.visuals?.runtimeAnimation,
+ visualStats : {
+ animationGroupCount : validationReport?.visuals?.animationGroupCount,
+ animatedSkeletonCount: validationReport?.visuals?.animatedSkeletonSpawnCount,
+ nonPlayingAnimationCount:
+ validationReport?.visuals?.nonPlayingAnimationCount,
+ pendingTextureCount : validationReport?.visuals?.pendingTextureCount,
+ skeletonCount : validationReport?.visuals?.skeletonSpawnCount,
+ },
+ })}`
+ ).toMatchObject({
+ all : true,
+ animations: true,
+ doors : true,
+ spawns : true,
+ textures : true,
+ })
+ })
+
+ test('keeps door choices and compact alerts interactive above the zone shell', async ({ page }) => {
+ test.setTimeout(420000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
+ 'requires local Sage generated zone assets'
+ )
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=door-alert-layer-test`,
+ { waitUntil: 'load' }
+ )
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+ await waitForRealZoneReadiness(page)
+
+ await page.getByText('Doors', { exact: true }).click()
+ const doorSelector = page.getByRole('combobox', { name: 'Select Door' })
+ await expect(doorSelector).toBeVisible()
+ await doorSelector.click()
+ const firstDoorOption = page.getByRole('option').first()
+ await expectElementTopmost(firstDoorOption)
+ await firstDoorOption.click()
+ await expect(page.getByRole('button', { name: /Add Door \[/ })).toBeEnabled()
+
+ await page.evaluate(() => {
+ ;(window as any).__spireSageOpenAlert?.('Compact alert validation', 'success')
+ })
+ const alert = page.getByRole('alert').filter({ hasText: 'Compact alert validation' })
+ await expect(alert).toBeVisible()
+ const alertMetrics = await alert.evaluate((element) => {
+ const rect = element.getBoundingClientRect()
+ const outside = document.elementFromPoint(12, window.innerHeight - 12)
+ return {
+ outsideBlocked: element === outside || element.contains(outside),
+ width: rect.width,
+ }
+ })
+ expect(alertMetrics.width).toBeLessThanOrEqual(600)
+ expect(alertMetrics.outsideBlocked).toBe(false)
+ await expect(alert).not.toBeVisible({ timeout: 5000 })
+ })
+
+ test('validates the complete door editor lifecycle across API and scene state', async ({ page }) => {
+ test.setTimeout(420000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
+ 'requires local Sage generated zone assets'
+ )
+
+ const enterBefallen = async () => {
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+ await waitForRealZoneReadiness(page)
+ }
+ const fetchDoors = async () => {
+ const response = await page.request.get(
+ `${previewBaseUrl}/api/v1/doors?where=zone__befallen.version__0&orderBy=doorid`
+ )
+ expect(response.ok()).toBeTruthy()
+ return response.json()
+ }
+ const selectDoorFromScene = (doorId: number) => page.evaluate((id) => {
+ const controller = (window as any).gameController?.ZoneController
+ const mesh = (controller?.doorNode?.getChildren?.() ?? []).find(
+ (candidate: any) => Number(candidate?.dataReference?.id) === Number(id)
+ )
+ if (!mesh) {
+ throw new Error(`No scene door found for ${id}`)
+ }
+ controller.onClick({
+ type : 1,
+ pickInfo: { hit: true, pickedMesh: mesh },
+ })
+ }, doorId)
+ const updateDoorField = async (locator: Locator, value: string, doorId: number) => {
+ const response = page.waitForResponse(candidate =>
+ candidate.url().endsWith(`/api/v1/door/${doorId}`) &&
+ candidate.request().method() === 'PATCH' &&
+ candidate.status() === 200
+ )
+ await locator.fill(value)
+ await locator.press('Tab')
+ await response
+ }
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=door-lifecycle-test`,
+ { waitUntil: 'load' }
+ )
+ await enterBefallen()
+ page.setDefaultTimeout(20_000)
+
+ const initialDoors = await fetchDoors()
+ expect(initialDoors).toHaveLength(1)
+ await expect.poll(() => readDoorSceneState(page)).toMatchObject({
+ sceneDoorCount: 1,
+ stats: {
+ loaded : 1,
+ missingVisualCount: 0,
+ pass : true,
+ requested : 1,
+ sceneDoorCount : 1,
+ staleVisualCount: 0,
+ },
+ })
+
+ await page.getByText('Doors', { exact: true }).click()
+ const doorSelector = page.getByRole('combobox', { name: 'Select Door' })
+ await doorSelector.click()
+ const doorOptions = page.getByRole('option')
+ expect(await doorOptions.count()).toBeGreaterThan(0)
+ await doorOptions.first().click()
+ const addDoorButton = page.getByRole('button', { name: /Add Door \[/ })
+ await expect(addDoorButton).toBeEnabled()
+
+ await page.evaluate(() => {
+ const controller = (window as any).gameController.ZoneController
+ controller.pickRaycastForLoc = (callback: (location: object) => void) => {
+ callback({ x: 11, y: 12, z: 13 })
+ }
+ })
+ const createResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/door') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ await addDoorButton.click()
+ const createdDoor = await (await createResponse).json()
+ expect(createdDoor).toMatchObject({
+ pos_x: 13,
+ pos_y: 11,
+ pos_z: 12,
+ zone : 'befallen',
+ })
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ exists : true,
+ position: { x: 11, y: 12, z: 13 },
+ stats : {
+ loaded : 2,
+ missingVisualCount: 0,
+ pass : true,
+ requested : 2,
+ sceneDoorCount : 2,
+ staleVisualCount: 0,
+ },
+ })
+
+ await selectDoorFromScene(initialDoors[0].id)
+ await expect(page.getByRole('spinbutton', { name: 'Door X' })).toHaveValue(
+ String(initialDoors[0].pos_x)
+ )
+ await selectDoorFromScene(createdDoor.id)
+ const doorX = page.getByRole('spinbutton', { name: 'Door X' })
+ const doorY = page.getByRole('spinbutton', { name: 'Door Y' })
+ const doorZ = page.getByRole('spinbutton', { name: 'Door Z' })
+ const doorHeading = page.getByRole('spinbutton', { name: 'Door Heading' })
+ const doorSize = page.getByRole('spinbutton', { name: 'Door Size' })
+ await expect(doorX).toHaveValue('13')
+ await expect(doorY).toHaveValue('11')
+ await expect(doorZ).toHaveValue('12')
+
+ await updateDoorField(doorX, '25', createdDoor.id)
+ await updateDoorField(doorY, '35', createdDoor.id)
+ await updateDoorField(doorZ, '45', createdDoor.id)
+ await updateDoorField(doorHeading, '128', createdDoor.id)
+ await updateDoorField(doorSize, '125', createdDoor.id)
+ await expect.poll(async () => {
+ const doors = await fetchDoors()
+ return doors.find((door: any) => door.id === createdDoor.id)
+ }).toMatchObject({
+ heading: 128,
+ pos_x : 25,
+ pos_y : 35,
+ pos_z : 45,
+ size : 125,
+ })
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ position : { x: 35, y: 45, z: 25 },
+ rotationYDegrees: 270,
+ scale : 1.25,
+ stats : { pass: true, transformMismatchCount: 0 },
+ })
+
+ const transformResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/door/${createdDoor.id}`) &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200
+ )
+ await page.getByRole('button', { name: /Move\/Rotate\/Scale/ }).click()
+ await expect(page.locator('.raycast-tooltip')).toBeVisible()
+ await page.evaluate((id) => {
+ const controller = (window as any).gameController.ZoneController
+ const mesh = controller.doorNode.getChildren().find(
+ (candidate: any) => Number(candidate?.dataReference?.id) === Number(id)
+ )
+ mesh.position.set(40, 50, 30)
+ mesh.rotation.y = Math.PI * 1.5
+ mesh.scaling.setAll(1.5)
+ }, createdDoor.id)
+ await page.keyboard.press('t')
+ expect(await (await transformResponse).json()).toMatchObject({
+ heading: 128,
+ pos_x : 30,
+ pos_y : 40,
+ pos_z : 50,
+ size : 150,
+ })
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ position: { x: 40, y: 50, z: 30 },
+ scale : 1.5,
+ stats : { pass: true },
+ })
+
+ await page.getByRole('button', { name: /Move\/Rotate\/Scale/ }).click()
+ await page.evaluate((id) => {
+ const controller = (window as any).gameController.ZoneController
+ const mesh = controller.doorNode.getChildren().find(
+ (candidate: any) => Number(candidate?.dataReference?.id) === Number(id)
+ )
+ mesh.position.set(400, 500, 300)
+ mesh.rotation.y = 0
+ mesh.scaling.setAll(3)
+ }, createdDoor.id)
+ await page.keyboard.press('Escape')
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ position : { x: 40, y: 50, z: 30 },
+ rotationYDegrees: 270,
+ scale : 1.5,
+ })
+
+ const deleteResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/door/${createdDoor.id}`) &&
+ response.request().method() === 'DELETE' &&
+ response.status() === 200
+ )
+ await page.getByRole('button', { name: /Remove Door/ }).click()
+ await deleteResponse
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ exists : false,
+ sceneDoorCount: 1,
+ stats : {
+ loaded : 1,
+ missingVisualCount: 0,
+ pass : true,
+ requested : 1,
+ staleVisualCount: 0,
+ },
+ })
+ expect(await fetchDoors()).toHaveLength(1)
+
+ await page.reload({ waitUntil: 'load' })
+ await enterBefallen()
+ await expect.poll(() => readDoorSceneState(page, createdDoor.id)).toMatchObject({
+ exists : false,
+ sceneDoorCount: 1,
+ stats : { pass: true, requested: 1, sceneDoorCount: 1 },
+ })
+ })
+
+ test('cancels stale spawn work during rapid zone reloads', async ({ page }) => {
+ test.setTimeout(420000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
+ 'requires local Sage generated zone assets'
+ )
+
+ const runtimeErrors: string[] = []
+ page.on('pageerror', error => runtimeErrors.push(error.stack || error.message))
+ page.on('console', message => {
+ if (message.type() === 'error') {
+ runtimeErrors.push(message.text())
+ }
+ })
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=rapid-reload-test`,
+ { waitUntil: 'load' }
+ )
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.evaluate(() => {
+ const url = new URL(window.location.href)
+ url.searchParams.set('sageValidation', '1')
+ window.history.replaceState(null, '', url.toString())
+ })
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+ await waitForRealZoneReadiness(page)
+ const initialReportCount = await page.evaluate(() => (
+ (window as any).__spireSageValidationReports?.length ?? 0
+ ))
+
+ const reload = page.getByText('Reload', { exact: true })
+ for (let attempt = 0; attempt < 5; attempt++) {
+ await reload.click()
+ await page.waitForTimeout(75)
+ }
+
+ await expect.poll(() => page.evaluate((previousReportCount) => {
+ const report = (window as any).__spireSageLastZoneValidation
+ return {
+ hasNewReport:
+ ((window as any).__spireSageValidationReports?.length ?? 0) >
+ previousReportCount,
+ spawns: {
+ loaded: report?.spawns?.loaded ?? 0,
+ requested: report?.spawns?.requested ?? 0,
+ },
+ zoneLoaded: (window as any).gameController?.ZoneController?.zoneLoaded ?? false,
+ }
+ }, initialReportCount), { timeout: 45000 }).toMatchObject({
+ hasNewReport: true,
+ spawns: { loaded: 3, requested: 3 },
+ zoneLoaded: true,
+ })
+ await page.waitForTimeout(1500)
+
+ const finalReport = await page.evaluate(() => (
+ (window as any).__spireSageLastZoneValidation
+ ))
+ console.log(`Rapid reload validation: ${JSON.stringify(finalReport?.pass)}`)
+ expect(finalReport?.pass).toMatchObject({ all: true })
+
+ const finalSpawns = await page.evaluate(() => {
+ const spawnController = (window as any).gameController?.SpawnController
+ const ids = Object.values(spawnController?.spawns ?? {})
+ .map((spawn: any) => spawn?.rootNode?.id)
+ .filter(Boolean)
+ return { count: ids.length, unique: new Set(ids).size }
+ })
+ expect(finalSpawns).toEqual({ count: 3, unique: 3 })
+ expect(runtimeErrors.filter(error =>
+ /refreshBoundingInfo|Cannot read properties of null/i.test(error)
+ )).toEqual([])
+ })
+
+ test('persists grid heading and pause when Enter blurs the numeric fields', async ({ page }) => {
+ test.setTimeout(420000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
+ 'requires local Sage generated zone assets'
+ )
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=grid-enter-test`,
+ { waitUntil: 'load' }
+ )
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+ await waitForRealZoneReadiness(page)
+ await expect.poll(() => page.evaluate(() => (
+ Object.keys((window as any).gameController?.SpawnController?.spawns ?? {}).length
+ )), { timeout: 20000 }).toBe(3)
+
+ const selectedGridSpawn = await page.evaluate(async () => {
+ const spawnController = (window as any).gameController?.SpawnController
+ const spawn = Object.values(spawnController?.spawns ?? {})
+ .map((entry: any) => entry?.spawnEntry)
+ .find((entry: any) => Number(entry?.pathgrid) > 0)
+ const clickCallback = spawnController?.clickCallbacks?.[0]
+ if (!spawn || typeof clickCallback !== 'function') {
+ return null
+ }
+ await clickCallback(spawn)
+ return { id: spawn.id, pathgrid: spawn.pathgrid }
+ })
+ expect(selectedGridSpawn).not.toBeNull()
+
+ const heading = page.getByRole('spinbutton', { name: 'Heading' })
+ await expect(heading).toBeVisible()
+ await heading.fill('127')
+ const headingUpdate = page.waitForResponse(response =>
+ response.url().includes('/api/v1/grid_entry/') &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200
+ )
+ await heading.press('Enter')
+ const headingResponse = await headingUpdate
+ expect((await headingResponse.json()).heading).toBe(127)
+ await expect(heading).not.toBeFocused()
+
+ const pause = page.getByRole('spinbutton', { name: 'Pause (Seconds)' })
+ await pause.fill('9')
+ const pauseUpdate = page.waitForResponse(response =>
+ response.url().includes('/api/v1/grid_entry/') &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200
+ )
+ await pause.press('Enter')
+ const pauseResponse = await pauseUpdate
+ expect((await pauseResponse.json()).pause).toBe(9)
+ await expect(pause).not.toBeFocused()
+ })
+
+ test('validates the complete spawn editor lifecycle across API and scene state', async ({ page }) => {
+ test.setTimeout(420000)
+ const eqRoot = process.env.SPIRE_SAGE_EQ_DIR || 'C:/EQEmuCW-Live'
+ test.skip(
+ !fs.existsSync(path.join(eqRoot, 'eqsage', 'zones', 'befallen.glb')),
+ 'requires local Sage generated zone assets'
+ )
+
+ await page.goto(
+ `${previewBaseUrl}/sage?sageEqDir=${encodeURIComponent(eqRoot)}&sageCacheBust=spawn-create-test`,
+ { waitUntil: 'load' }
+ )
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+
+ await waitForRealZoneReadiness(page)
+ page.setDefaultTimeout(20_000)
+ expect(await page.evaluate(() => (
+ (window as any).__spireSageLastZoneValidation?.spawns?.placement ?? null
+ ))).toMatchObject({
+ expectedCount : 3,
+ loadedCount : 3,
+ missingVisualCount : 0,
+ nonFinitePlacementCount: 0,
+ pass : true,
+ positionMismatchCount : 0,
+ staleReferenceCount : 0,
+ })
+ await page.getByText('Spawns', { exact: true }).click()
+ const spawnDialog = page.locator('[role="dialog"]').filter({
+ hasText: '3 filtered spawns',
+ })
+ await expect(spawnDialog).toBeVisible()
+ await expect(page.getByText('3 filtered spawns')).toBeVisible()
+
+ const npcInput = page.locator('input[aria-autocomplete="list"]')
+ const npcSearchResponse = page.waitForResponse(response =>
+ response.url().includes('/api/v1/npc_types') && response.status() === 200
+ )
+ await npcInput.click()
+ await npcInput.pressSequentially('Sage Validation Erudite befallen')
+ await npcSearchResponse
+ const npcOption = page.locator('li[role="option"]')
+ await expect(npcOption).toContainText('Sage Validation Erudite befallen - Level')
+ await npcOption.click({ force: true })
+
+ await page.evaluate(() => {
+ const zoneController = (window as any).gameController.ZoneController
+ zoneController.pickRaycastForLoc = (callback: (location: object) => void) => {
+ callback({ x: 4, y: 2, z: 6 })
+ }
+ })
+ const addSpawnButton = page.locator('button').filter({ hasText: 'Add Spawn' })
+ await expect(addSpawnButton).toBeEnabled()
+ const createSpawnResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/spawn_2') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ const createEntryResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/spawnentry') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ await addSpawnButton.click()
+ const [createdSpawnHttp, createdEntryHttp] = await Promise.all([
+ createSpawnResponse,
+ createEntryResponse,
+ ])
+ const createdSpawn = await createdSpawnHttp.json()
+ const createdEntry = await createdEntryHttp.json()
+ expect(createdEntry.spawngroup_id).toBe(createdSpawn.spawngroup_id)
+ await expect(spawnDialog).not.toBeVisible()
+
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id), {
+ timeout: 20000,
+ }).toMatchObject({
+ controllerCount: 4,
+ exists : true,
+ position : { x: 4, z: 6 },
+ spawnEntry : {
+ id : createdSpawn.id,
+ spawnentries: 1,
+ x : 6,
+ y : 4,
+ z : 2,
+ },
+ stats: {
+ loaded : 4,
+ requested: 4,
+ },
+ })
+ await page.getByText('Spawns', { exact: true }).click()
+ await expect(page.getByText('4 filtered spawns')).toBeVisible()
+ await expect(page.getByText('No associated spawns')).toHaveCount(0)
+ await page.keyboard.press('Escape')
+
+ await selectSpawnFromScene(page, createdSpawn.id)
+ await expect(page.getByText(`Spawn Group ID - ${createdSpawn.id}`)).toBeVisible()
+ const spawnX = page.getByRole('spinbutton', { name: 'Spawn X' })
+ const spawnY = page.getByRole('spinbutton', { name: 'Spawn Y' })
+ const spawnZ = page.getByRole('spinbutton', { name: 'Spawn Z' })
+ await expect(spawnX).toHaveValue('6')
+ await expect(spawnY).toHaveValue('4')
+ await expect(spawnZ).toHaveValue('2')
+
+ const numericMoveResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/spawn_2/${createdSpawn.id}`) &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200
+ )
+ await spawnX.fill('12')
+ await spawnY.fill('13')
+ await spawnZ.fill('3')
+ expect(await (await numericMoveResponse).json()).toMatchObject({
+ id: createdSpawn.id,
+ x : 12,
+ y : 13,
+ z : 3,
+ })
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id)).toMatchObject({
+ position: { x: 13, z: 12 },
+ spawnEntry: { x: 12, y: 13, z: 3 },
+ })
+ expect(
+ Math.abs(Number((await readSpawnSceneState(page, createdSpawn.id)).groundY) - 3)
+ ).toBeLessThan(0.1)
+ expect(
+ (await readSpawnSceneState(page, createdSpawn.id)).metadataReferences
+ ).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: createdSpawn.id, x: 12, y: 13, z: 3 }),
+ ]))
+
+ await page.keyboard.press('Escape')
+ await selectSpawnFromScene(page, createdSpawn.id)
+ await expect(spawnX).toHaveValue('12')
+ await expect(spawnY).toHaveValue('13')
+ await expect(spawnZ).toHaveValue('3')
+
+ await page.evaluate(() => {
+ const zoneController = (window as any).gameController.ZoneController
+ zoneController.pickRaycastForLoc = (callback: (location: object) => void) => {
+ callback({ x: 20, y: 4, z: 30 })
+ }
+ })
+ const raycastMoveResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/spawn_2/${createdSpawn.id}`) &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200
+ )
+ await page.getByText('Choose Raycast Location [R]').click()
+ expect(await (await raycastMoveResponse).json()).toMatchObject({
+ id: createdSpawn.id,
+ x : 30,
+ y : 20,
+ z : 4,
+ })
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id)).toMatchObject({
+ position: { x: 20, z: 30 },
+ spawnEntry: { x: 30, y: 20, z: 4 },
+ })
+ expect(
+ Math.abs(Number((await readSpawnSceneState(page, createdSpawn.id)).groundY) - 4)
+ ).toBeLessThan(0.1)
+
+ const gridCreateResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/grid') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ const pathgridUpdateResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/spawn_2/${createdSpawn.id}`) &&
+ response.request().method() === 'PATCH' &&
+ response.status() === 200 &&
+ Number(response.request().postDataJSON()?.pathgrid) > 0
+ )
+ const firstWaypointResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/grid_entry') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ await page.getByRole('button', { name: 'Add grid waypoint' }).click()
+ const [createdGridHttp, pathgridUpdateHttp, firstWaypointHttp] = await Promise.all([
+ gridCreateResponse,
+ pathgridUpdateResponse,
+ firstWaypointResponse,
+ ])
+ const createdGrid = await createdGridHttp.json()
+ expect(await pathgridUpdateHttp.json()).toMatchObject({
+ id : createdSpawn.id,
+ pathgrid: createdGrid.id,
+ })
+ expect(await firstWaypointHttp.json()).toMatchObject({
+ gridid: createdGrid.id,
+ number: 1,
+ x : 30,
+ y : 20,
+ z : 4,
+ })
+
+ const secondWaypointResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/grid_entry') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ await page.getByRole('button', { name: 'Add grid waypoint' }).click()
+ expect(await (await secondWaypointResponse).json()).toMatchObject({
+ gridid: createdGrid.id,
+ number: 2,
+ x : 45,
+ y : 20,
+ z : 4,
+ })
+ const deleteWaypointButton = page.getByRole('button', {
+ name: 'Delete grid waypoint',
+ })
+ await expect(deleteWaypointButton).toBeEnabled()
+ const deleteWaypointResponse = page.waitForResponse(response =>
+ response.url().includes(`/api/v1/grid_entry/${createdGrid.id}`) &&
+ response.request().method() === 'DELETE' &&
+ response.status() === 200
+ )
+ await deleteWaypointButton.click()
+ await deleteWaypointResponse
+ await expect(deleteWaypointButton).toBeDisabled()
+ const remainingWaypoints = await page.request.get(
+ `${previewBaseUrl}/api/v1/grid_entries?where=gridid__${createdGrid.id}`
+ )
+ expect(await remainingWaypoints.json()).toHaveLength(1)
+
+ await page.getByText('Add/Edit Spawn Entries').click()
+ const entryDialog = page.locator('[role="dialog"]').filter({
+ hasText: 'Add/Edit Spawn Entries',
+ })
+ await expect(entryDialog).toBeVisible()
+ await entryDialog.locator('input[type="number"]').first().fill('50')
+ const addEntryInput = page.locator('input#add-new-spawn')
+ const entryNpcSearchResponse = page.waitForResponse(response =>
+ response.url().includes('/api/v1/npc_types') && response.status() === 200
+ )
+ await addEntryInput.fill('Sage Validation Dark Elf befallen')
+ await entryNpcSearchResponse
+ await page.locator('li[role="option"]', {
+ hasText: 'Sage Validation Dark Elf befallen - Level',
+ }).click()
+ await entryDialog.locator('input[type="number"]').last().fill('50')
+ const addAssociationResponse = page.waitForResponse(response =>
+ response.url().endsWith('/api/v1/spawnentry') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ )
+ await entryDialog.getByText('Save', { exact: true }).click()
+ await addAssociationResponse
+ await expect(entryDialog).not.toBeVisible()
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id), {
+ timeout: 20000,
+ }).toMatchObject({
+ exists : true,
+ selectedSpawnId: createdSpawn.id,
+ spawnEntry : { spawnentries: 2, x: 30, y: 20, z: 4 },
+ })
+ await expect(page.getByText(/and 1 more/)).toBeVisible()
+
+ await page.getByText('Add/Edit Spawn Entries').click()
+ await expect(entryDialog).toBeVisible()
+ await entryDialog.locator('input[type="number"]').first().fill('100')
+ await entryDialog.locator(
+ 'button[aria-label="Remove Sage Validation Dark Elf befallen"]'
+ ).click()
+ const removeAssociationResponse = page.waitForResponse(response =>
+ response.url().includes(`/api/v1/spawnentry/${createdSpawn.spawngroup_id}`) &&
+ response.request().method() === 'DELETE' &&
+ response.status() === 200
+ )
+ await entryDialog.getByText('Save', { exact: true }).click()
+ await removeAssociationResponse
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id), {
+ timeout: 20000,
+ }).toMatchObject({
+ exists : true,
+ selectedSpawnId: createdSpawn.id,
+ spawnEntry : { spawnentries: 1, x: 30, y: 20, z: 4 },
+ })
+
+ const deleteSpawnResponse = page.waitForResponse(response =>
+ response.url().endsWith(`/api/v1/spawn_2/${createdSpawn.id}`) &&
+ response.request().method() === 'DELETE' &&
+ response.status() === 200
+ )
+ await page.getByRole('button', { name: 'Delete spawn' }).click()
+ const confirmDelete = page.getByRole('dialog', { name: 'Delete Spawn' })
+ await expect(confirmDelete).toBeVisible()
+ await confirmDelete.getByRole('button', { name: 'Ok' }).click()
+ await deleteSpawnResponse
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id)).toMatchObject({
+ controllerCount: 3,
+ exists : false,
+ stats : { loaded: 3, requested: 3 },
+ })
+ const deletedSpawnList = await page.request.get(
+ `${previewBaseUrl}/api/v1/spawn_2s?where=id__${createdSpawn.id}`
+ )
+ expect(await deletedSpawnList.json()).toEqual([])
+
+ await page.getByText('Spawns', { exact: true }).click()
+ await expect(page.getByText('3 filtered spawns')).toBeVisible()
+ await page.keyboard.press('Escape')
+
+ await page.reload({ waitUntil: 'load' })
+ await expect(page.getByRole('dialog', { name: 'EQ Sage: Zone Editor' })).toBeVisible()
+ await page.locator('[role="combobox"][aria-label="Expansion Filter"]').click()
+ await page.getByRole('option', { name: 'Original' }).click()
+ await page.keyboard.press('Escape')
+ await page.locator('[role="combobox"][aria-label="Zone"]').click()
+ await page.getByRole('option', { name: 'Befallen - befallen' }).click()
+ await page.getByRole('button', { name: 'Enter Zone Editor' }).click()
+ await waitForRealZoneReadiness(page)
+ await expect.poll(() => readSpawnSceneState(page, createdSpawn.id)).toMatchObject({
+ controllerCount: 3,
+ exists : false,
+ })
+ expect(await page.evaluate(() => (
+ (window as any).__spireSageLastZoneValidation?.spawns?.placement ?? null
+ ))).toMatchObject({
+ expectedCount : 3,
+ loadedCount : 3,
+ missingVisualCount : 0,
+ nonFinitePlacementCount: 0,
+ pass : true,
+ positionMismatchCount : 0,
+ staleReferenceCount : 0,
+ })
})
})
diff --git a/tools/sage-qa/README.md b/tools/sage-qa/README.md
new file mode 100644
index 00000000..313e0f48
--- /dev/null
+++ b/tools/sage-qa/README.md
@@ -0,0 +1,160 @@
+# Sage QA
+
+Sage QA is the repeatable reliability gate for Spire's embedded EverQuest zone editor. It combines the in-app validation harness, race-appearance audit, static texture inspection, Playwright automation, resource-soak analysis, and bounded artifacts behind one command.
+
+## Commands
+
+Run these from the repository root while LocalSpire or another compatible Spire development server is available at `http://127.0.0.1:8080`:
+
+```powershell
+npm run qa:sage:smoke -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:compact -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:compact -- --eq-dir C:\EQEmuCW-Live --visual-models clf --no-zone-validation --no-race-audit --no-static-texture-audit
+npm run qa:sage:full -- --eq-dir C:\EQEmuCW-Live --race-models hum,huf,ogm --no-zone-validation --no-static-texture-audit
+npm run qa:sage:models -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:review-feedback -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:viewer -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:matrix -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:soak -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:full -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:fs -- --eq-dir C:\EQEmuCW-Live
+npm run qa:sage:coverage -- --eq-dir C:\EQEmuCW-Live
+npm run test:sage-qa
+```
+
+Use `npm run qa:sage -- --help` for all overrides. `SAGE_EQ_DIR` and `PLAYWRIGHT_BASE_URL` can replace the corresponding command-line options.
+
+`qa:sage:fs` runs a bounded eight-way bridge stress test by default. It repeatedly reads known EQ assets, round-trips temporary 64 KiB files, verifies their bytes, records server-side retry headers and latency, and removes its temporary folder in a `finally` path. Use `--concurrency` and `--rounds` for controlled expansion without involving WebGL.
+
+When a memory guard or outer process interrupts a long race inventory, resume the missing models with `--race-models`, then prove complete coverage with `qa:sage:verify-checkpoints -- --plan-run --runs `. The verifier requires every planned model exactly within the combined checkpoint set and rejects missing, unexpected, or failed batches.
+
+## Profiles
+
+- `smoke`: three zones, fourteen high-risk race models (including the Qeynos compact rigs), and five deterministic visual samples.
+- `compact-rigs`: focused Ak'Anon/Qeynos/Great Divide gate for compact skeletons, secondary heads, and collapsed classic parent-bone chains.
+- `model-regression`: focused cross-rig animation, skin, head, and runtime-pose coverage with repeated front/rear evidence for every audited model plus targeted body variants.
+- `model-viewer`: focused, repeated QA against the production Model Review workspace, including whole-model and face-focus evidence.
+- `review-feedback`: replays all models with historical reviewer issues, verifies the production viewer now passes its structural, appearance, orientation, and animation diagnostics, and requires no automated issue classification.
+- `matrix`: twelve classic and expansion zones, twenty high-risk race models, and five visual samples.
+- `soak`: three zones over three cycles with post-warmup resource plateau checks.
+- `full`: every available mapped race model, three complete zone cycles, and stricter memory headroom.
+
+Profiles are versioned JSON files in `profiles/`. Add a profile instead of adding one-off query strings or changing the runner. Command-line overrides are intentionally limited to operational concerns such as paths, cycles, batch size, and focused visual-model selection; quality thresholds live in the profile so runs remain reproducible.
+
+## Memory and PC stewardship
+
+The runner is serial by default and owns only one automation browser. It creates a fresh browser context for each race batch and visual sample, then closes it before continuing. The browser itself is closed before final reporting. NPM campaign commands expose V8 garbage collection and invoke it at memory checkpoints, releasing Playwright protocol buffers before the configured runner-RSS guard is evaluated. `--race-models` supports a focused continuation from the durable per-batch checkpoints without raising memory limits or repeating already validated models.
+
+Every expensive phase checks:
+
+- total system memory percentage;
+- minimum free system memory;
+- runner resident memory and external-buffer ceilings;
+- browser-reported JavaScript heap after each zone;
+- Babylon scene meshes, materials, textures, skeletons, animation groups, geometries, and transform nodes.
+
+When any system, runner RSS, or runner external-buffer ceiling is exceeded, the runner waits for the configured settling interval and checks again. It fails safely instead of applying more load. It never kills unrelated processes or clears system caches.
+
+Automation Chromium runs with precise memory reporting enabled so repeated-zone heap samples are useful for growth analysis instead of browser-privacy-rounded approximations. Heap and Babylon resource growth are compared per zone against the high-water mark established by the warm-up and baseline cycles. Only later comparison cycles can fail the plateau gate. This prevents naturally different zone footprints and bounded post-cleanup rebounds from being misclassified as leaks.
+
+Artifacts are JPEG-compressed, traces are retained only for failures by default, and old runs are pruned within `tmp/validation/runs` according to both a run-count and total-size budget. Retention code refuses to delete paths outside the configured artifact root.
+
+Continuous Playwright tracing is automatically disabled when a zone campaign exceeds `artifacts.maxTracedZoneReports` (12 by default). Long scene traces can retain gigabytes of protocol buffers even after the trace file is discarded; JSON diagnostics and failure screenshots remain available without putting that pressure on the PC. Profiles with soak analysis must include a warm-up cycle, a baseline cycle, and at least one comparison cycle.
+
+## Coverage model
+
+`coverage.json` is generated from four sources:
+
+1. `raceData.json` for the race/gender-to-model mapping;
+2. `raceModelMetadata.json` for texture and helm ranges;
+3. `raceAppearancePolicies.json` for models with discrete classic face variants;
+4. the selected EQ directory for actually available GLB models.
+
+The same face-policy JSON is imported by the live race audit, preventing the automation manifest and renderer assertions from drifting apart.
+
+Every material check is scoped to submaterials actually referenced by rendered mesh submeshes. A model fails when any used, non-effect material lacks a ready texture; unused GLB material-table entries do not create false failures. Classic face models must expose exactly eight distinct head-texture signatures (default plus faces 1-7), so a renderer that repeats one valid face can no longer pass by changing only material bookkeeping.
+
+## Deterministic visual evidence
+
+Visual samples are not accepted from the page's pass flag alone. The runner independently collects runtime mesh/vertex counts, used-material texture coverage, skeleton and bone signatures, finite bone matrices, world bounds, arm vectors, and a normalized 16x16 WebGL pixel signature. Before the screenshot is frozen at its fixed normalized frame, animation-eligible models must produce finite pose changes across four fixed timeline fractions. Native-pose-only compact rigs are explicitly classified as static instead of being mislabeled as animated. `Math.random` is seeded before application code runs, browser locale/timezone/color/reduced-motion settings are fixed, service workers are blocked, and unrelated release traffic is fulfilled locally.
+
+Visual QA can run on either the legacy race-audit preview or the production Model Review workspace through `visualValidation.surface`. The `model-viewer` profile uses the latter and records the exact deep link, selected appearance values, camera view, face-focus state, and viewer framing diagnostics with every sample. Use `--visual-surface model-review` to apply that surface to another profile without changing its coverage list.
+
+Each sample is loaded at least twice in fresh browser contexts. Topology/material/skeleton signatures must match exactly; bounds and pixel signatures must remain within versioned profile tolerances. High-risk humanoid samples also require both forearms below the configured horizontal threshold, and selected profiles cap near-white pixel coverage as a second line of defense against white fallback skins.
+
+The `model-regression` profile adds a second, independent comparison against versioned, visually approved baselines in `baselines/model-regression.json`. This catches a consistently wrong renderer that would otherwise reproduce the same upside-down head, shifted geometry, or incorrect skin twice. Mesh, vertex, skeleton, bone, material, and skeleton signatures must match exactly; normalized whole-model pixels, the upper/head region, and foreground framing use narrow tolerances. A configured baseline is mandatory, so a new or renamed high-risk sample fails closed instead of silently skipping reference validation.
+
+Baseline changes are deliberately manual. First run the profile, inspect every selected screenshot, and only then promote the passing evidence with an explicit reviewer:
+
+```powershell
+npm run qa:sage:approve-visuals -- --run tmp/validation/runs/ --models qcf,fsg --reviewed-by "Reviewer name" --confirm-reviewed
+```
+
+The approval command refuses failed, single-run, non-repeatable, or invariant-violating evidence. It records reviewer and source-run provenance. Baseline updates should be reviewed like renderer code; never regenerate them automatically in CI or merely to make a failure green.
+
+The runner executes five mutation canaries before visual work: a visible untextured material, a 10x exploded bound, a topology/pixel-drift repeat, a vertically inverted approved-baseline image, and a motionless animation. All five must be rejected or the campaign aborts. Unit tests separately prove duplicate classic faces, T-poses, partial material coverage, exploded geometry, repeat drift, motionless clips, and a repeatable but visually changed model cannot pass. When calibrating a legitimate model exception, change the narrowest profile tolerance and rerun the canaries plus the affected model twice; never disable the underlying invariant.
+
+Effect-only models are valid without bitmap texture slots when every rendered material is an intentional effect material. The appearance audit records material and effect-only counts per variant so those models do not hide ordinary untextured geometry failures.
+
+The all-model appearance audit instantiates static model containers, so motionless animation groups are reported separately as animation diagnostics instead of being mislabeled as face failures. Runtime T-pose and motion enforcement is performed by the repeated zone matrix after `BabylonSpawn` has initialized and started the actual animation lifecycle. Each live representative is paused and deterministically sought through four timeline fractions; the animation phase fails unless the moving count exactly equals the expected live representative count, with zero stationary clips, zero unresolved detached targets, and zero non-finite matrices. Duplicate same-model NPCs retain a representative static pose to bound memory use, and detached donor targets are repaired onto the instantiated clone hierarchy before playback.
+
+The full profile selects every mapped model that has a base GLB in the EQ asset cache. Explicit profiles fail on unknown model codes rather than silently shrinking coverage.
+
+## Artifacts
+
+Each run receives a unique directory:
+
+```text
+tmp/validation/runs/-/
+ plan.json
+ coverage.json
+ static-texture-audit.json
+ zone-validation.json
+ race-audit-batches.json
+ race-audit-checkpoints/
+ visual-samples.json
+ telemetry.json
+ events.ndjson
+ summary.json
+ summary.html
+ retention.json
+ screenshots/
+ traces/
+```
+
+The HTML report is self-contained except for relative screenshot links. It provides zone, NPC, texture, skeleton, door, animation-resource, JavaScript-heap, race-appearance, visual-sample, and failure summaries.
+
+The browser automation captures page errors, failed requests, HTTP errors, and console errors. All four classes fail by default. Events are appended while the run is active, and every completed race batch receives its own checkpoint, so long campaigns remain observable and retain useful evidence if their outer process is interrupted. A failed filesystem transfer that succeeds through the app's built-in retry remains recorded as recovered evidence but does not fail the campaign; an unrecovered transfer does. The unrelated GitHub release-check endpoint is fulfilled locally in test contexts so campaigns are deterministic, generate no external traffic, and do not mask real browser errors.
+
+Before launching Chromium, the runner walks the complete served JavaScript module graph twice and requires the same successful result. This prevents a Vite watch rebuild from handing the browser an entry chunk whose hashed lazy dependency has just been replaced; a genuinely missing chunk still fails the preflight with its exact URL.
+
+`summary.json` contains compact rollups and references the raw per-phase artifacts rather than duplicating them. This keeps large full-audit reports bounded on disk and avoids loading duplicate payloads in downstream tooling.
+
+## Exit behavior
+
+The command exits nonzero when any required phase fails, a model is unresolved, a zone assertion fails, a visual sample fails, a soak budget is exceeded, a page error occurs, or the system memory guard blocks further work. `--allow-failures` is available only for exploratory artifact collection.
+
+The browser and every context are closed in `finally` paths. A failed run still writes `summary.json`, `summary.html`, screenshots when possible, and the fatal stack.
+
+## Extending the product
+
+Keep additions in one of these layers:
+
+- `profiles/`: coverage scope and quality budgets;
+- `baselines/`: reviewed semantic visual references and their provenance;
+- `lib/coverage.mjs`: inventory and asset-selection rules;
+- `lib/aggregate.mjs`: deterministic pass/fail analysis;
+- `lib/playwright-runner.mjs`: browser orchestration and artifact capture;
+- the in-app validation components: renderer-specific measurements unavailable outside Babylon;
+- `test/`: pure configuration, aggregation, safety, and artifact tests.
+
+Prefer adding a new report field and an aggregation assertion over parsing UI text. Preserve schema version 1 compatibility or increment `schemaVersion` with an explicit migration.
+
+## Recommended cadence
+
+- Local implementation loop: `smoke`.
+- After skeletal retargeting, skin, head, or character-cache changes: `model-regression`.
+- Before merging renderer or editor changes: `matrix`.
+- After lifecycle, caching, loader, or animation changes: `soak` plus `matrix`.
+- Release candidate or scheduled overnight run: `full`.
+- Human review: open `summary.html`, then reproduce any failed model in the in-app Browser using the recorded URL.
diff --git a/tools/sage-qa/approve-visual-baselines.mjs b/tools/sage-qa/approve-visual-baselines.mjs
new file mode 100644
index 00000000..78774c5d
--- /dev/null
+++ b/tools/sage-qa/approve-visual-baselines.mjs
@@ -0,0 +1,83 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {
+ createApprovedVisualBaseline,
+ evaluateVisualBaselineApprovalEligibility,
+ visualBaselineKey,
+} from './lib/visual-invariants.mjs';
+
+const args = Object.create(null);
+for (let index = 2; index < process.argv.length; index += 1) {
+ const token = process.argv[index];
+ if (!token.startsWith('--')) continue;
+ const [rawKey, inlineValue] = token.slice(2).split('=', 2);
+ if (inlineValue !== undefined) {
+ args[rawKey] = inlineValue;
+ } else if (process.argv[index + 1] && !process.argv[index + 1].startsWith('--')) {
+ args[rawKey] = process.argv[index + 1];
+ index += 1;
+ } else {
+ args[rawKey] = true;
+ }
+}
+
+if (!args.run) {
+ throw new Error('Pass --run ');
+}
+if (args['confirm-reviewed'] !== true) {
+ throw new Error('Refusing to approve pixels without --confirm-reviewed after inspecting every selected screenshot');
+}
+if (!`${args['reviewed-by'] ?? ''}`.trim()) {
+ throw new Error('Pass --reviewed-by so baseline provenance is explicit');
+}
+
+const runDirectory = path.resolve(args.run);
+const sourcePath = path.join(runDirectory, 'visual-samples.json');
+const sourceSamples = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
+const requestedModels = new Set(`${args.models ?? ''}`
+ .split(',')
+ .map((model) => model.trim().toLowerCase())
+ .filter(Boolean));
+const selected = sourceSamples.filter((sample) =>
+ requestedModels.size === 0 || requestedModels.has(`${sample.model}`.toLowerCase())
+);
+if (selected.length === 0) throw new Error('No visual samples matched the requested models');
+
+const targetPath = path.resolve(
+ args.output ?? 'tools/sage-qa/baselines/model-regression.json'
+);
+let existing = { schemaVersion: 1, samples: {} };
+try {
+ existing = JSON.parse(await fs.readFile(targetPath, 'utf8'));
+} catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+}
+if (existing.schemaVersion !== 1) throw new Error('Unsupported target baseline schema');
+
+for (const sample of selected) {
+ const eligibility = evaluateVisualBaselineApprovalEligibility(sample);
+ if (!eligibility.pass) {
+ throw new Error(
+ `${sample.model} is not eligible for approval: ${eligibility.violations.join(', ')}`
+ );
+ }
+ existing.samples[visualBaselineKey(sample)] = createApprovedVisualBaseline(
+ sample.observations[0]
+ );
+}
+
+existing.provenance = {
+ reviewedBy: `${args['reviewed-by']}`,
+ sourceRun: path.basename(runDirectory),
+ approvedAt: new Date().toISOString(),
+ sampleCount: Object.keys(existing.samples).length,
+};
+
+const output = `${JSON.stringify(existing, null, 2)}\n`;
+if (args['dry-run'] === true) {
+ process.stdout.write(output);
+} else {
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
+ await fs.writeFile(targetPath, output, 'utf8');
+ process.stdout.write(`Approved ${selected.length} sample(s) into ${targetPath}\n`);
+}
diff --git a/tools/sage-qa/baselines/model-regression.json b/tools/sage-qa/baselines/model-regression.json
new file mode 100644
index 00000000..c1ea3c65
--- /dev/null
+++ b/tools/sage-qa/baselines/model-regression.json
@@ -0,0 +1,11088 @@
+{
+ "schemaVersion": 1,
+ "samples": {
+ "qcm|face=0|texture=0|helm=0": {
+ "meshCount": 13,
+ "vertexCount": 1218,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "qcmch0001:qcmch0001 (base color):64:128|qcmch0002:qcmch0002 (base color):32:16|qcmfa0001:qcmfa0001 (base color):32:64|qcmft0001:qcmft0001 (base color):32:16|qcmft0002:qcmft0002 (base color):8:8|qcmhe0001:qcmhe0001 (base color):128:64|qcmhe0002:qcmhe0002 (base color):8:8|qcmhn0001:qcmhn0001 (base color):64:32|qcmhn0002:qcmhn0002 (base color):32:32|qcmlg0001:qcmlg0001 (base color):128:64|qcmlg0002:qcmlg0002 (base color):64:64|qcmlg0003:qcmlg0003 (base color):64:32|qcmua0001:qcmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|l_point|ne|o_r|pe|r_point|root|shield_point|th_l|th_r|to_l",
+ "foregroundBounds": {
+ "x": 0.4046875,
+ "y": 0,
+ "width": 0.19296875,
+ "height": 0.4041666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 105,
+ 66,
+ 41,
+ 59,
+ 37,
+ 21,
+ 41,
+ 35,
+ 22,
+ 45,
+ 34,
+ 18,
+ 45,
+ 34,
+ 18,
+ 40,
+ 35,
+ 23,
+ 62,
+ 38,
+ 22,
+ 104,
+ 67,
+ 41,
+ 101,
+ 65,
+ 40,
+ 78,
+ 47,
+ 26,
+ 42,
+ 37,
+ 24,
+ 51,
+ 39,
+ 23,
+ 51,
+ 39,
+ 23,
+ 40,
+ 38,
+ 24,
+ 79,
+ 48,
+ 26,
+ 102,
+ 65,
+ 41,
+ 51,
+ 43,
+ 30,
+ 34,
+ 37,
+ 27,
+ 37,
+ 35,
+ 21,
+ 41,
+ 31,
+ 15,
+ 41,
+ 31,
+ 15,
+ 37,
+ 35,
+ 21,
+ 36,
+ 38,
+ 28,
+ 48,
+ 38,
+ 26,
+ 15,
+ 21,
+ 17,
+ 30,
+ 42,
+ 32,
+ 44,
+ 43,
+ 29,
+ 54,
+ 54,
+ 39,
+ 54,
+ 54,
+ 38,
+ 43,
+ 42,
+ 28,
+ 31,
+ 42,
+ 33,
+ 15,
+ 20,
+ 16,
+ 8,
+ 11,
+ 9,
+ 40,
+ 53,
+ 40,
+ 48,
+ 55,
+ 40,
+ 97,
+ 73,
+ 52,
+ 95,
+ 72,
+ 51,
+ 48,
+ 55,
+ 40,
+ 40,
+ 53,
+ 40,
+ 5,
+ 7,
+ 6,
+ 0,
+ 0,
+ 0,
+ 9,
+ 11,
+ 8,
+ 38,
+ 39,
+ 30,
+ 85,
+ 62,
+ 45,
+ 81,
+ 57,
+ 40,
+ 28,
+ 31,
+ 23,
+ 9,
+ 12,
+ 9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 21,
+ 19,
+ 17,
+ 102,
+ 76,
+ 58,
+ 93,
+ 69,
+ 54,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 13,
+ 12,
+ 12,
+ 103,
+ 86,
+ 71,
+ 110,
+ 94,
+ 80,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "qcf|face=0|texture=0|helm=0": {
+ "meshCount": 12,
+ "vertexCount": 1287,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "qcfch0001:qcfch0001 (base color):64:128|qcfch0002:qcfch0002 (base color):32:16|qcffa0001:qcffa0001 (base color):32:64|qcfft0001:qcfft0001 (base color):64:32|qcfft0002:qcfft0002 (base color):8:8|qcfhe0001:qcfhe0001 (base color):64:64|qcfhe0002:qcfhe0002 (base color):32:32|qcfhn0001:qcfhn0001 (base color):64:32|qcfhn0002:qcfhn0002 (base color):32:32|qcflg0001:qcflg0001 (base color):64:128|qcflg0002:qcflg0002 (base color):64:32|qcfua0001:qcfua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.4015625,
+ "y": 0,
+ "width": 0.2171875,
+ "height": 0.4083333333333333
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 119,
+ 127,
+ 124,
+ 61,
+ 64,
+ 67,
+ 98,
+ 77,
+ 67,
+ 106,
+ 84,
+ 67,
+ 98,
+ 77,
+ 66,
+ 42,
+ 33,
+ 30,
+ 48,
+ 52,
+ 52,
+ 134,
+ 127,
+ 121,
+ 127,
+ 138,
+ 137,
+ 57,
+ 62,
+ 62,
+ 87,
+ 65,
+ 35,
+ 106,
+ 85,
+ 39,
+ 104,
+ 80,
+ 42,
+ 10,
+ 8,
+ 8,
+ 106,
+ 116,
+ 114,
+ 96,
+ 106,
+ 105,
+ 46,
+ 52,
+ 51,
+ 100,
+ 111,
+ 110,
+ 43,
+ 47,
+ 43,
+ 92,
+ 97,
+ 76,
+ 83,
+ 92,
+ 82,
+ 25,
+ 28,
+ 27,
+ 109,
+ 121,
+ 120,
+ 30,
+ 33,
+ 33,
+ 0,
+ 0,
+ 0,
+ 125,
+ 136,
+ 135,
+ 84,
+ 95,
+ 94,
+ 113,
+ 122,
+ 120,
+ 104,
+ 114,
+ 113,
+ 92,
+ 100,
+ 99,
+ 62,
+ 68,
+ 68,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 92,
+ 100,
+ 99,
+ 138,
+ 139,
+ 137,
+ 187,
+ 149,
+ 136,
+ 154,
+ 146,
+ 141,
+ 133,
+ 143,
+ 142,
+ 17,
+ 19,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 39,
+ 33,
+ 30,
+ 107,
+ 81,
+ 70,
+ 155,
+ 113,
+ 98,
+ 114,
+ 86,
+ 75,
+ 46,
+ 35,
+ 32,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 31,
+ 22,
+ 16,
+ 106,
+ 78,
+ 68,
+ 45,
+ 32,
+ 25,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 15,
+ 11,
+ 60,
+ 44,
+ 30,
+ 32,
+ 22,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "clm|face=0|texture=0|helm=0": {
+ "meshCount": 16,
+ "vertexCount": 1371,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "clmch0001:clmch0001 (base color):64:64|clmch0002:clmch0002 (base color):32:16|clmfa0001:clmfa0001 (base color):32:64|clmft0001:clmft0001 (base color):64:32|clmft0002:clmft0002 (base color):8:8|clmhe0001:clmhe0001 (base color):64:64|clmhe0002:clmhe0002 (base color):64:32|clmhe0004:clmhe0004 (base color):32:32|clmhe0004:clmhe0004 (base color):32:32|clmhn0001:clmhn0001 (base color):64:32|clmhn0002:clmhn0002 (base color):32:32|clmlg0001:clmlg0001 (base color):64:32|clmlg0001:clmlg0001 (base color):64:32|clmlg0002:clmlg0002 (base color):64:64|clmlg0003:clmlg0003 (base color):64:32|clmua0001:clmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3703125,
+ "y": 0,
+ "width": 0.26484375,
+ "height": 0.5291666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 7,
+ 9,
+ 5,
+ 33,
+ 34,
+ 21,
+ 5,
+ 3,
+ 1,
+ 12,
+ 12,
+ 7,
+ 32,
+ 35,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 21,
+ 23,
+ 13,
+ 14,
+ 15,
+ 9,
+ 21,
+ 21,
+ 12,
+ 15,
+ 15,
+ 9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 13,
+ 17,
+ 8,
+ 0,
+ 0,
+ 0,
+ 14,
+ 15,
+ 8,
+ 26,
+ 31,
+ 17,
+ 28,
+ 31,
+ 17,
+ 14,
+ 14,
+ 8,
+ 0,
+ 0,
+ 0,
+ 12,
+ 16,
+ 8,
+ 26,
+ 34,
+ 17,
+ 8,
+ 9,
+ 5,
+ 15,
+ 18,
+ 11,
+ 31,
+ 35,
+ 19,
+ 31,
+ 34,
+ 20,
+ 14,
+ 17,
+ 10,
+ 15,
+ 17,
+ 9,
+ 21,
+ 27,
+ 13,
+ 20,
+ 25,
+ 14,
+ 30,
+ 27,
+ 14,
+ 20,
+ 23,
+ 13,
+ 36,
+ 52,
+ 37,
+ 34,
+ 46,
+ 31,
+ 20,
+ 22,
+ 12,
+ 30,
+ 30,
+ 16,
+ 10,
+ 12,
+ 7,
+ 0,
+ 0,
+ 0,
+ 18,
+ 17,
+ 9,
+ 42,
+ 45,
+ 26,
+ 56,
+ 57,
+ 36,
+ 58,
+ 60,
+ 37,
+ 37,
+ 39,
+ 22,
+ 8,
+ 10,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 21,
+ 30,
+ 15,
+ 48,
+ 50,
+ 33,
+ 38,
+ 44,
+ 26,
+ 33,
+ 25,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 30,
+ 32,
+ 18,
+ 54,
+ 64,
+ 41,
+ 49,
+ 52,
+ 32,
+ 14,
+ 16,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fsg|face=0|texture=0|helm=0": {
+ "meshCount": 15,
+ "vertexCount": 7920,
+ "skeletonCount": 1,
+ "boneCount": 105,
+ "materialSignature": "fsgch0001:fsgch0001 (base color):64:64|fsgch0002:fsgch0002 (base color):256:256|fsgch0003:fsgch0003 (base color):256:128|fsgft0001:fsgft0001 (base color):128:64|fsghe0001:fsghe0001 (base color):256:256|fsghe0002:fsghe0002 (base color):32:32|fsghe0003:fsghe0003 (base color):128:64|fsghe0004:fsghe0004 (base color):128:128|fsghe0005:fsghe0005 (base color):16:32|fsghn0001:fsghn0001 (base color):64:32|fsghn0002:fsghn0002 (base color):64:64|fsglg0001:fsglg0001 (base color):128:128|fsglg0002:fsglg0002 (base color):64:128|fsgua0001:fsgua0001 (base color):64:128|fsgua0002:fsgua0002 (base color):64:128",
+ "skeletonSignature": "_breathpoint|bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fn11l|fn11r|fn12l|fn12r|fn13l|fn13r|fn21l|fn21r|fn22l|fn22r|fn23l|fn23r|fn31l|fn31r|fn32l|fn32r|fn33l|fn33r|fn41l|fn41r|fn42l|fn42r|fn43l|fn43r|fn51l|fn51r|fn52l|fn52r|fn53l|fn53r|fo_l|fo_r|ha11l|ha11r|ha12l|ha12r|ha13l|ha13r|ha14l|ha14r|ha15l|ha15r|ha16l|ha16r|ha21l|ha21r|ha22l|ha22r|ha23l|ha23r|ha24l|ha24r|ha25l|ha25r|he|ja|l_point|ne|pe|r_point|root|sk1_l|sk1_r|sk2_l|sk2_r|sk3_l|sk3_r|sk4_l|sk4_r|skfr1|skfr2|skrr1|skrr2|sl1_l|sl1_r|sl2_l|sl2_r|sl3_l|sl3_r|sl4_l|sl4_r|sla_l|sla_r|slb_l|slb_r|slc_l|slc_r|sld_l|sld_r|st|th_l|th_r|to_l|to_r|wa",
+ "foregroundBounds": {
+ "x": 0.3765625,
+ "y": 0,
+ "width": 0.225,
+ "height": 0.5236111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 74,
+ 53,
+ 49,
+ 61,
+ 57,
+ 54,
+ 68,
+ 64,
+ 60,
+ 83,
+ 78,
+ 74,
+ 79,
+ 76,
+ 72,
+ 77,
+ 72,
+ 68,
+ 60,
+ 55,
+ 52,
+ 43,
+ 31,
+ 29,
+ 42,
+ 32,
+ 25,
+ 32,
+ 29,
+ 28,
+ 66,
+ 63,
+ 60,
+ 72,
+ 69,
+ 66,
+ 71,
+ 68,
+ 65,
+ 68,
+ 65,
+ 61,
+ 38,
+ 32,
+ 30,
+ 68,
+ 48,
+ 44,
+ 88,
+ 70,
+ 66,
+ 34,
+ 25,
+ 22,
+ 78,
+ 69,
+ 56,
+ 78,
+ 67,
+ 55,
+ 78,
+ 68,
+ 50,
+ 49,
+ 45,
+ 41,
+ 41,
+ 31,
+ 27,
+ 22,
+ 17,
+ 14,
+ 31,
+ 28,
+ 29,
+ 31,
+ 35,
+ 42,
+ 68,
+ 64,
+ 61,
+ 95,
+ 92,
+ 74,
+ 82,
+ 78,
+ 55,
+ 75,
+ 73,
+ 64,
+ 52,
+ 50,
+ 54,
+ 0,
+ 0,
+ 0,
+ 4,
+ 7,
+ 10,
+ 47,
+ 51,
+ 54,
+ 80,
+ 78,
+ 75,
+ 120,
+ 110,
+ 102,
+ 116,
+ 103,
+ 89,
+ 97,
+ 94,
+ 91,
+ 36,
+ 40,
+ 44,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 35,
+ 34,
+ 33,
+ 50,
+ 46,
+ 44,
+ 120,
+ 111,
+ 107,
+ 124,
+ 113,
+ 109,
+ 91,
+ 89,
+ 87,
+ 25,
+ 24,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 91,
+ 78,
+ 72,
+ 82,
+ 68,
+ 60,
+ 49,
+ 47,
+ 45,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 112,
+ 106,
+ 68,
+ 124,
+ 118,
+ 87,
+ 14,
+ 12,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "hum|face=7|texture=0|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5022,
+ "skeletonCount": 1,
+ "boneCount": 104,
+ "materialSignature": "humch0002:humch0002:64:128|humch0003:humch0003:256:256|humchsk01:humchsk01:64:32|humfask01:humfask01:128:128|humfask02:humfask02:128:128|humft0001:humft0001:64:64|humft0002:humft0002:32:64|humft0003:humft0003:64:16|humft0004:humft0004:128:128|humhesk02:humhesk02:32:64|humhesk03:humhesk03:32:32|humhesk06:humhesk06:64:64|humhesk07:humhesk07:64:32|humhesk08:humhesk08:64:16|humhesk71:humhesk71:256:128|humhesk74:humhesk74:32:64|humhesk75:humhesk75:32:32|humhnsk01:humhnsk01:64:64|humhnsk02:humhnsk02:64:64|humhnsk03:humhnsk03:32:32|humlg0001:humlg0001:256:128|humlg0002:humlg0002:128:128|humlg0003:humlg0003:128:32|humuask01:humuask01:64:64|humuask02:humuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hebeard1|hebeard2|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.40625,
+ "y": 0,
+ "width": 0.23359375,
+ "height": 0.40694444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 107,
+ 74,
+ 51,
+ 41,
+ 24,
+ 15,
+ 44,
+ 25,
+ 23,
+ 37,
+ 21,
+ 19,
+ 37,
+ 20,
+ 18,
+ 33,
+ 20,
+ 18,
+ 36,
+ 23,
+ 14,
+ 114,
+ 79,
+ 53,
+ 112,
+ 77,
+ 53,
+ 43,
+ 27,
+ 16,
+ 44,
+ 26,
+ 20,
+ 64,
+ 43,
+ 19,
+ 68,
+ 45,
+ 19,
+ 22,
+ 13,
+ 9,
+ 66,
+ 42,
+ 25,
+ 77,
+ 54,
+ 37,
+ 61,
+ 43,
+ 30,
+ 92,
+ 64,
+ 44,
+ 30,
+ 23,
+ 17,
+ 8,
+ 9,
+ 8,
+ 11,
+ 10,
+ 10,
+ 47,
+ 28,
+ 14,
+ 105,
+ 71,
+ 47,
+ 28,
+ 19,
+ 14,
+ 59,
+ 41,
+ 29,
+ 107,
+ 73,
+ 49,
+ 26,
+ 25,
+ 23,
+ 37,
+ 36,
+ 33,
+ 29,
+ 30,
+ 28,
+ 55,
+ 37,
+ 25,
+ 83,
+ 58,
+ 40,
+ 0,
+ 0,
+ 0,
+ 69,
+ 52,
+ 39,
+ 138,
+ 99,
+ 71,
+ 57,
+ 58,
+ 56,
+ 97,
+ 75,
+ 59,
+ 63,
+ 56,
+ 51,
+ 59,
+ 43,
+ 32,
+ 56,
+ 41,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 63,
+ 45,
+ 32,
+ 91,
+ 78,
+ 65,
+ 108,
+ 75,
+ 53,
+ 94,
+ 82,
+ 69,
+ 122,
+ 92,
+ 69,
+ 24,
+ 17,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 101,
+ 68,
+ 46,
+ 111,
+ 81,
+ 61,
+ 47,
+ 34,
+ 25,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 93,
+ 67,
+ 47,
+ 105,
+ 80,
+ 59,
+ 24,
+ 17,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "ogm|face=4|texture=0|helm=0": {
+ "meshCount": 26,
+ "vertexCount": 5478,
+ "skeletonCount": 1,
+ "boneCount": 92,
+ "materialSignature": "ogmch0002:ogmch0002:64:128|ogmch0003:ogmch0003:256:256|ogmchsk01:ogmchsk01:64:32|ogmfa0001:ogmfa0001:128:128|ogmfa0002:ogmfa0002:128:128|ogmftsk01:ogmftsk01:64:64|ogmftsk02:ogmftsk02:32:64|ogmftsk03:ogmftsk03:64:16|ogmftsk04:ogmftsk04:128:128|ogmhesk03:ogmhesk03:32:32|ogmhesk06:ogmhesk06:64:64|ogmhesk07:ogmhesk07:64:32|ogmhesk08:ogmhesk08:64:16|ogmhesk09:ogmhesk09:64:32|ogmhesk41:ogmhesk41:256:128|ogmhesk42:ogmhesk42:32:64|ogmhesk44:ogmhesk44:32:32|ogmhesk45:ogmhesk45:32:32|ogmhnsk01:ogmhnsk01:64:64|ogmhnsk02:ogmhnsk02:64:64|ogmhnsk03:ogmhnsk03:32:32|ogmlg0001:ogmlg0001:256:128|ogmlgsk02:ogmlgsk02:128:128|ogmlgsk03:ogmlgsk03:128:32|ogmuask01:ogmuask01:64:64|ogmuask02:ogmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.3609375,
+ "y": 0,
+ "width": 0.3375,
+ "height": 0.4152777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 74,
+ 48,
+ 33,
+ 8,
+ 6,
+ 4,
+ 22,
+ 15,
+ 12,
+ 35,
+ 23,
+ 18,
+ 30,
+ 17,
+ 14,
+ 35,
+ 11,
+ 8,
+ 96,
+ 71,
+ 55,
+ 77,
+ 45,
+ 35,
+ 117,
+ 81,
+ 59,
+ 44,
+ 28,
+ 21,
+ 37,
+ 25,
+ 18,
+ 41,
+ 28,
+ 21,
+ 35,
+ 24,
+ 18,
+ 82,
+ 60,
+ 45,
+ 115,
+ 81,
+ 61,
+ 87,
+ 63,
+ 49,
+ 51,
+ 38,
+ 29,
+ 61,
+ 36,
+ 28,
+ 80,
+ 62,
+ 50,
+ 63,
+ 48,
+ 39,
+ 115,
+ 81,
+ 59,
+ 125,
+ 91,
+ 69,
+ 122,
+ 85,
+ 62,
+ 22,
+ 14,
+ 9,
+ 0,
+ 0,
+ 0,
+ 68,
+ 46,
+ 35,
+ 82,
+ 61,
+ 47,
+ 78,
+ 60,
+ 48,
+ 140,
+ 102,
+ 78,
+ 159,
+ 126,
+ 104,
+ 85,
+ 58,
+ 41,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 66,
+ 50,
+ 40,
+ 110,
+ 81,
+ 64,
+ 85,
+ 60,
+ 47,
+ 114,
+ 87,
+ 72,
+ 109,
+ 82,
+ 66,
+ 27,
+ 19,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 81,
+ 59,
+ 47,
+ 101,
+ 73,
+ 57,
+ 73,
+ 58,
+ 49,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 44,
+ 33,
+ 26,
+ 96,
+ 69,
+ 52,
+ 53,
+ 37,
+ 28,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 51,
+ 41,
+ 33,
+ 105,
+ 83,
+ 67,
+ 24,
+ 17,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "qcf|face=0|texture=1|helm=1": {
+ "meshCount": 13,
+ "vertexCount": 1281,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "qcfch0101:qcfch0101:64:128|qcfch0102:qcfch0102:32:16|qcffa0101:qcffa0101:32:64|qcfft0101:qcfft0101:64:32|qcfft0102:qcfft0102:8:8|qcfhe0101:qcfhe0101:64:64|qcfhe0102:qcfhe0102:32:32|qcfhn0101:qcfhn0101:64:32|qcfhn0102:qcfhn0102:32:32|qcflg0101:qcflg0101:64:128|qcflg0102:qcflg0102:64:32|qcfua0101:qcfua0101:32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.40078125,
+ "y": 0,
+ "width": 0.2203125,
+ "height": 0.4166666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 148,
+ 123,
+ 107,
+ 35,
+ 31,
+ 28,
+ 117,
+ 116,
+ 115,
+ 144,
+ 143,
+ 141,
+ 118,
+ 117,
+ 116,
+ 11,
+ 11,
+ 10,
+ 32,
+ 27,
+ 22,
+ 155,
+ 125,
+ 110,
+ 155,
+ 139,
+ 125,
+ 69,
+ 61,
+ 54,
+ 78,
+ 78,
+ 77,
+ 137,
+ 138,
+ 135,
+ 91,
+ 90,
+ 89,
+ 0,
+ 0,
+ 0,
+ 105,
+ 92,
+ 81,
+ 147,
+ 129,
+ 114,
+ 67,
+ 57,
+ 49,
+ 119,
+ 102,
+ 88,
+ 11,
+ 21,
+ 12,
+ 45,
+ 51,
+ 35,
+ 22,
+ 43,
+ 24,
+ 28,
+ 24,
+ 21,
+ 142,
+ 124,
+ 109,
+ 42,
+ 38,
+ 36,
+ 0,
+ 0,
+ 0,
+ 150,
+ 133,
+ 118,
+ 42,
+ 53,
+ 35,
+ 99,
+ 81,
+ 65,
+ 63,
+ 67,
+ 49,
+ 107,
+ 95,
+ 85,
+ 105,
+ 92,
+ 82,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 102,
+ 94,
+ 83,
+ 113,
+ 109,
+ 92,
+ 182,
+ 150,
+ 137,
+ 136,
+ 126,
+ 110,
+ 132,
+ 126,
+ 110,
+ 35,
+ 31,
+ 28,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 10,
+ 20,
+ 11,
+ 78,
+ 77,
+ 59,
+ 182,
+ 143,
+ 127,
+ 117,
+ 99,
+ 84,
+ 19,
+ 37,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 33,
+ 23,
+ 17,
+ 133,
+ 110,
+ 94,
+ 51,
+ 37,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 14,
+ 9,
+ 7,
+ 104,
+ 84,
+ 72,
+ 39,
+ 29,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "qcf|face=0|texture=4|helm=1": {
+ "meshCount": 13,
+ "vertexCount": 1281,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "qcfch0401:qcfch0401:64:128|qcfch0402:qcfch0402:32:16|qcffa0401:qcffa0401:32:64|qcfft0401:qcfft0401:64:32|qcfft0402:qcfft0402:8:8|qcfhe0101:qcfhe0101:64:64|qcfhe0102:qcfhe0102:32:32|qcfhn0401:qcfhn0401:64:32|qcfhn0402:qcfhn0402:32:32|qcflg0401:qcflg0401:64:128|qcflg0402:qcflg0402:64:32|qcfua0401:qcfua0401:32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.40078125,
+ "y": 0,
+ "width": 0.2203125,
+ "height": 0.4166666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 147,
+ 145,
+ 143,
+ 39,
+ 32,
+ 33,
+ 118,
+ 101,
+ 98,
+ 144,
+ 143,
+ 141,
+ 126,
+ 114,
+ 111,
+ 32,
+ 7,
+ 4,
+ 28,
+ 33,
+ 35,
+ 152,
+ 134,
+ 127,
+ 166,
+ 175,
+ 177,
+ 66,
+ 73,
+ 75,
+ 94,
+ 64,
+ 60,
+ 141,
+ 139,
+ 138,
+ 107,
+ 79,
+ 75,
+ 0,
+ 0,
+ 0,
+ 102,
+ 111,
+ 114,
+ 160,
+ 170,
+ 174,
+ 65,
+ 73,
+ 76,
+ 114,
+ 128,
+ 132,
+ 25,
+ 16,
+ 13,
+ 49,
+ 51,
+ 51,
+ 50,
+ 32,
+ 26,
+ 30,
+ 32,
+ 33,
+ 138,
+ 153,
+ 158,
+ 44,
+ 46,
+ 47,
+ 0,
+ 0,
+ 0,
+ 151,
+ 163,
+ 167,
+ 57,
+ 50,
+ 48,
+ 86,
+ 107,
+ 113,
+ 73,
+ 73,
+ 72,
+ 106,
+ 115,
+ 118,
+ 109,
+ 117,
+ 120,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 95,
+ 104,
+ 107,
+ 114,
+ 109,
+ 108,
+ 180,
+ 160,
+ 153,
+ 136,
+ 126,
+ 122,
+ 124,
+ 133,
+ 137,
+ 33,
+ 37,
+ 38,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 20,
+ 13,
+ 11,
+ 90,
+ 67,
+ 57,
+ 177,
+ 137,
+ 120,
+ 122,
+ 95,
+ 83,
+ 36,
+ 24,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 33,
+ 23,
+ 17,
+ 133,
+ 110,
+ 94,
+ 51,
+ 37,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 14,
+ 9,
+ 7,
+ 104,
+ 84,
+ 72,
+ 39,
+ 29,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "goj|face=0|texture=0|helm=0": {
+ "meshCount": 1,
+ "vertexCount": 3882,
+ "skeletonCount": 1,
+ "boneCount": 64,
+ "materialSignature": "gojch0001:gojch0001 (base color):512:512",
+ "skeletonSignature": "_axe|bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch01|ch02|ch03|ch04|ch_cape11|ch_cape12|ch_cape13|ch_cape14|ch_cape21|ch_cape22|ch_cape23|ch_cape24|ch_cape31|ch_cape32|ch_cape33|ch_cape34|ch_cape41|ch_cape51|ch_l|ch_r|fi_l|fi_l11|fi_l12|fi_l13|fi_l21|fi_l22|fi_l31|fi_l32|fi_l33|fi_l41|fi_l42|fi_r|fi_r11|fi_r12|fi_r13|fi_r21|fi_r22|fi_r31|fi_r32|fi_r33|fi_r41|fi_r42|fo_l|fo_r|he|head_point|l_point|ne|pe|pe02|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.38515625,
+ "y": 0,
+ "width": 0.23515625,
+ "height": 0.4625
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 7,
+ 7,
+ 7,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 11,
+ 10,
+ 11,
+ 11,
+ 11,
+ 11,
+ 0,
+ 0,
+ 0,
+ 4,
+ 3,
+ 3,
+ 22,
+ 23,
+ 24,
+ 8,
+ 9,
+ 9,
+ 8,
+ 7,
+ 8,
+ 0,
+ 0,
+ 0,
+ 14,
+ 13,
+ 14,
+ 7,
+ 7,
+ 7,
+ 9,
+ 10,
+ 10,
+ 23,
+ 24,
+ 25,
+ 25,
+ 27,
+ 27,
+ 11,
+ 12,
+ 13,
+ 12,
+ 11,
+ 10,
+ 17,
+ 16,
+ 16,
+ 15,
+ 14,
+ 15,
+ 4,
+ 3,
+ 3,
+ 5,
+ 5,
+ 5,
+ 23,
+ 23,
+ 23,
+ 28,
+ 28,
+ 28,
+ 12,
+ 12,
+ 11,
+ 26,
+ 25,
+ 23,
+ 62,
+ 59,
+ 55,
+ 28,
+ 27,
+ 25,
+ 0,
+ 0,
+ 0,
+ 15,
+ 14,
+ 14,
+ 22,
+ 22,
+ 22,
+ 27,
+ 27,
+ 27,
+ 9,
+ 9,
+ 9,
+ 20,
+ 20,
+ 19,
+ 20,
+ 19,
+ 18,
+ 20,
+ 20,
+ 19,
+ 5,
+ 5,
+ 5,
+ 20,
+ 20,
+ 20,
+ 11,
+ 11,
+ 11,
+ 8,
+ 9,
+ 8,
+ 21,
+ 21,
+ 20,
+ 23,
+ 21,
+ 20,
+ 41,
+ 38,
+ 36,
+ 24,
+ 23,
+ 22,
+ 17,
+ 17,
+ 17,
+ 10,
+ 11,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 25,
+ 24,
+ 20,
+ 20,
+ 19,
+ 18,
+ 18,
+ 18,
+ 22,
+ 21,
+ 20,
+ 24,
+ 24,
+ 24,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 9,
+ 6,
+ 6,
+ 33,
+ 12,
+ 11,
+ 10,
+ 6,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "goj|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 1,
+ "vertexCount": 3882,
+ "skeletonCount": 1,
+ "boneCount": 64,
+ "materialSignature": "gojch0001:gojch0001 (base color):512:512",
+ "skeletonSignature": "_axe|bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch01|ch02|ch03|ch04|ch_cape11|ch_cape12|ch_cape13|ch_cape14|ch_cape21|ch_cape22|ch_cape23|ch_cape24|ch_cape31|ch_cape32|ch_cape33|ch_cape34|ch_cape41|ch_cape51|ch_l|ch_r|fi_l|fi_l11|fi_l12|fi_l13|fi_l21|fi_l22|fi_l31|fi_l32|fi_l33|fi_l41|fi_l42|fi_r|fi_r11|fi_r12|fi_r13|fi_r21|fi_r22|fi_r31|fi_r32|fi_r33|fi_r41|fi_r42|fo_l|fo_r|he|head_point|l_point|ne|pe|pe02|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.403125,
+ "y": 0.001388888888888889,
+ "width": 0.203125,
+ "height": 0.4638888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 14,
+ 14,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 11,
+ 10,
+ 10,
+ 11,
+ 10,
+ 10,
+ 8,
+ 7,
+ 8,
+ 4,
+ 4,
+ 4,
+ 0,
+ 0,
+ 0,
+ 17,
+ 19,
+ 18,
+ 6,
+ 6,
+ 6,
+ 4,
+ 4,
+ 4,
+ 15,
+ 15,
+ 15,
+ 11,
+ 11,
+ 11,
+ 11,
+ 11,
+ 11,
+ 9,
+ 10,
+ 10,
+ 7,
+ 7,
+ 7,
+ 27,
+ 28,
+ 27,
+ 5,
+ 5,
+ 5,
+ 19,
+ 19,
+ 17,
+ 26,
+ 25,
+ 24,
+ 23,
+ 22,
+ 21,
+ 34,
+ 33,
+ 31,
+ 14,
+ 14,
+ 14,
+ 17,
+ 17,
+ 17,
+ 27,
+ 27,
+ 27,
+ 14,
+ 14,
+ 13,
+ 12,
+ 11,
+ 11,
+ 16,
+ 16,
+ 16,
+ 16,
+ 16,
+ 16,
+ 16,
+ 15,
+ 15,
+ 9,
+ 9,
+ 9,
+ 20,
+ 20,
+ 19,
+ 4,
+ 4,
+ 5,
+ 9,
+ 10,
+ 9,
+ 10,
+ 10,
+ 10,
+ 16,
+ 15,
+ 16,
+ 15,
+ 15,
+ 15,
+ 14,
+ 14,
+ 14,
+ 3,
+ 4,
+ 3,
+ 6,
+ 6,
+ 6,
+ 0,
+ 0,
+ 0,
+ 12,
+ 12,
+ 12,
+ 16,
+ 16,
+ 16,
+ 29,
+ 29,
+ 29,
+ 28,
+ 27,
+ 27,
+ 25,
+ 25,
+ 25,
+ 8,
+ 8,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 11,
+ 10,
+ 29,
+ 13,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "sdf|face=0|texture=0|helm=0": {
+ "meshCount": 5,
+ "vertexCount": 3882,
+ "skeletonCount": 1,
+ "boneCount": 52,
+ "materialSignature": "sdfche0001:sdfche0001 (base color):256:256|sdfche0002:sdfche0002 (base color):256:256|sdfhe0001:sdfhe0001 (base color):128:256|sdfhe0002:sdfhe0002 (base color):256:256|sdfua0001:sdfua0001 (base color):256:256",
+ "skeletonSignature": "bone01|bone02|bone03|bone04|bone05|bone06|bone07|bone08|bone09|bone10|bone11|bone12|bone13|bone14|bone15|bone17|bone19|bone20|bone21|bone22|bone23|bone24|bone26|bone27|bone28|bone29|bone31|bone32|bone33|bone34|bone35|bone37|bone38|bone39|bone40|bone41|bone43|bone45|bone48|bone50|bone52|bone54|bone56|bone58|bone60|bone62|bone64|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.3328125,
+ "y": 0,
+ "width": 0.3265625,
+ "height": 0.425
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 27,
+ 33,
+ 37,
+ 9,
+ 11,
+ 12,
+ 28,
+ 34,
+ 37,
+ 30,
+ 37,
+ 41,
+ 28,
+ 35,
+ 39,
+ 13,
+ 16,
+ 17,
+ 11,
+ 13,
+ 14,
+ 17,
+ 22,
+ 23,
+ 34,
+ 41,
+ 46,
+ 19,
+ 23,
+ 25,
+ 18,
+ 21,
+ 24,
+ 27,
+ 33,
+ 37,
+ 24,
+ 29,
+ 33,
+ 17,
+ 20,
+ 21,
+ 19,
+ 23,
+ 25,
+ 5,
+ 6,
+ 7,
+ 16,
+ 20,
+ 22,
+ 24,
+ 30,
+ 33,
+ 15,
+ 19,
+ 21,
+ 21,
+ 25,
+ 28,
+ 22,
+ 27,
+ 30,
+ 19,
+ 23,
+ 25,
+ 14,
+ 17,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 23,
+ 29,
+ 31,
+ 19,
+ 24,
+ 26,
+ 24,
+ 30,
+ 34,
+ 23,
+ 29,
+ 32,
+ 20,
+ 24,
+ 27,
+ 11,
+ 13,
+ 15,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 19,
+ 23,
+ 26,
+ 28,
+ 34,
+ 38,
+ 18,
+ 22,
+ 25,
+ 15,
+ 18,
+ 21,
+ 26,
+ 32,
+ 36,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 10,
+ 11,
+ 22,
+ 26,
+ 29,
+ 26,
+ 33,
+ 36,
+ 11,
+ 14,
+ 16,
+ 23,
+ 28,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 35,
+ 39,
+ 42,
+ 52,
+ 58,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 16,
+ 20,
+ 23,
+ 51,
+ 62,
+ 68,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "sdf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 5,
+ "vertexCount": 3882,
+ "skeletonCount": 1,
+ "boneCount": 52,
+ "materialSignature": "sdfche0001:sdfche0001 (base color):256:256|sdfche0002:sdfche0002 (base color):256:256|sdfhe0001:sdfhe0001 (base color):128:256|sdfhe0002:sdfhe0002 (base color):256:256|sdfua0001:sdfua0001 (base color):256:256",
+ "skeletonSignature": "bone01|bone02|bone03|bone04|bone05|bone06|bone07|bone08|bone09|bone10|bone11|bone12|bone13|bone14|bone15|bone17|bone19|bone20|bone21|bone22|bone23|bone24|bone26|bone27|bone28|bone29|bone31|bone32|bone33|bone34|bone35|bone37|bone38|bone39|bone40|bone41|bone43|bone45|bone48|bone50|bone52|bone54|bone56|bone58|bone60|bone62|bone64|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.37734375,
+ "y": 0,
+ "width": 0.2984375,
+ "height": 0.4236111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 26,
+ 33,
+ 37,
+ 9,
+ 11,
+ 12,
+ 15,
+ 19,
+ 21,
+ 22,
+ 28,
+ 31,
+ 24,
+ 30,
+ 33,
+ 15,
+ 18,
+ 20,
+ 9,
+ 11,
+ 12,
+ 30,
+ 38,
+ 42,
+ 26,
+ 32,
+ 35,
+ 9,
+ 11,
+ 12,
+ 10,
+ 13,
+ 14,
+ 19,
+ 22,
+ 24,
+ 18,
+ 23,
+ 24,
+ 6,
+ 8,
+ 9,
+ 18,
+ 22,
+ 24,
+ 21,
+ 27,
+ 30,
+ 11,
+ 14,
+ 14,
+ 20,
+ 24,
+ 25,
+ 16,
+ 19,
+ 21,
+ 18,
+ 21,
+ 23,
+ 21,
+ 25,
+ 27,
+ 16,
+ 20,
+ 21,
+ 22,
+ 27,
+ 30,
+ 6,
+ 7,
+ 8,
+ 15,
+ 18,
+ 20,
+ 19,
+ 23,
+ 25,
+ 17,
+ 21,
+ 23,
+ 19,
+ 23,
+ 26,
+ 18,
+ 21,
+ 24,
+ 15,
+ 18,
+ 20,
+ 16,
+ 20,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 27,
+ 33,
+ 37,
+ 27,
+ 33,
+ 37,
+ 27,
+ 33,
+ 37,
+ 25,
+ 31,
+ 35,
+ 22,
+ 27,
+ 30,
+ 7,
+ 9,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 28,
+ 30,
+ 21,
+ 25,
+ 28,
+ 19,
+ 23,
+ 26,
+ 15,
+ 18,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 30,
+ 34,
+ 16,
+ 20,
+ 22,
+ 10,
+ 12,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 20,
+ 25,
+ 28,
+ 20,
+ 26,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "shn|face=0|texture=0|helm=0": {
+ "meshCount": 12,
+ "vertexCount": 4950,
+ "skeletonCount": 1,
+ "boneCount": 74,
+ "materialSignature": "shnch0001:shnch0001 (base color):128:256|shnch0002:shnch0002 (base color):128:256|shnch0003:shnch0003 (base color):256:256|shnfa0001:shnfa0001 (base color):128:128|shnha0001:shnha0001 (base color):128:64|shnhe0001:shnhe0001 (base color):256:128|shnhe0002:shnhe0002 (base color):256:64|shnhe0003:shnhe0003 (base color):64:64|shnhe0004:shnhe0004 (base color):64:64|shnlg0001:shnlg0001 (base color):256:128|shnlg0002:shnlg0002 (base color):256:256|shnua0001:shnua0001 (base color):128:128",
+ "skeletonSignature": "bone001|bone002|bone003|bone004|bone005|bone006|bone008|bone009|bone010|bone012|bone013|bone014|bone016|bone017|bone018|bone020|bone021|bone023|bone024|bone026|bone028|bone030|bone032|bone034|bone035|bone037|bone039|bone041|bone043|bone045|bone046|bone047|bone048|bone049|bone051|bone052|bone053|bone054|bone055|bone056|bone058|bone059|bone061|bone062|bone063|bone064|bone065|bone066|bone068|bone069|bone081|bone082|bone083|bone084|bone085|bone086|bone087|bone088|bone089|bone090|bone091|bone092|bone093|bone094|bone095|bone096|bone097|bone098|bone099|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.37265625,
+ "y": 0,
+ "width": 0.27890625,
+ "height": 0.4152777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 15,
+ 12,
+ 4,
+ 30,
+ 31,
+ 13,
+ 22,
+ 21,
+ 7,
+ 65,
+ 58,
+ 28,
+ 65,
+ 57,
+ 27,
+ 23,
+ 22,
+ 7,
+ 17,
+ 15,
+ 4,
+ 37,
+ 40,
+ 16,
+ 31,
+ 40,
+ 16,
+ 39,
+ 39,
+ 15,
+ 17,
+ 21,
+ 10,
+ 74,
+ 68,
+ 39,
+ 63,
+ 60,
+ 33,
+ 17,
+ 21,
+ 8,
+ 0,
+ 0,
+ 0,
+ 28,
+ 36,
+ 15,
+ 40,
+ 45,
+ 22,
+ 12,
+ 14,
+ 5,
+ 26,
+ 27,
+ 7,
+ 142,
+ 126,
+ 74,
+ 104,
+ 91,
+ 44,
+ 14,
+ 14,
+ 5,
+ 10,
+ 10,
+ 4,
+ 38,
+ 38,
+ 18,
+ 37,
+ 43,
+ 21,
+ 11,
+ 13,
+ 6,
+ 68,
+ 65,
+ 31,
+ 161,
+ 151,
+ 115,
+ 132,
+ 120,
+ 75,
+ 15,
+ 19,
+ 6,
+ 11,
+ 14,
+ 6,
+ 30,
+ 40,
+ 18,
+ 25,
+ 36,
+ 14,
+ 25,
+ 32,
+ 15,
+ 79,
+ 73,
+ 34,
+ 135,
+ 122,
+ 78,
+ 123,
+ 109,
+ 62,
+ 30,
+ 33,
+ 15,
+ 26,
+ 35,
+ 16,
+ 19,
+ 28,
+ 11,
+ 17,
+ 18,
+ 8,
+ 41,
+ 39,
+ 17,
+ 66,
+ 61,
+ 25,
+ 80,
+ 75,
+ 38,
+ 88,
+ 76,
+ 30,
+ 38,
+ 40,
+ 17,
+ 36,
+ 36,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 30,
+ 32,
+ 14,
+ 52,
+ 55,
+ 27,
+ 77,
+ 85,
+ 59,
+ 64,
+ 65,
+ 32,
+ 35,
+ 40,
+ 17,
+ 24,
+ 24,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 27,
+ 34,
+ 14,
+ 89,
+ 97,
+ 50,
+ 32,
+ 39,
+ 16,
+ 13,
+ 17,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "shn|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 12,
+ "vertexCount": 4950,
+ "skeletonCount": 1,
+ "boneCount": 74,
+ "materialSignature": "shnch0001:shnch0001 (base color):128:256|shnch0002:shnch0002 (base color):128:256|shnch0003:shnch0003 (base color):256:256|shnfa0001:shnfa0001 (base color):128:128|shnha0001:shnha0001 (base color):128:64|shnhe0001:shnhe0001 (base color):256:128|shnhe0002:shnhe0002 (base color):256:64|shnhe0003:shnhe0003 (base color):64:64|shnhe0004:shnhe0004 (base color):64:64|shnlg0001:shnlg0001 (base color):256:128|shnlg0002:shnlg0002 (base color):256:256|shnua0001:shnua0001 (base color):128:128",
+ "skeletonSignature": "bone001|bone002|bone003|bone004|bone005|bone006|bone008|bone009|bone010|bone012|bone013|bone014|bone016|bone017|bone018|bone020|bone021|bone023|bone024|bone026|bone028|bone030|bone032|bone034|bone035|bone037|bone039|bone041|bone043|bone045|bone046|bone047|bone048|bone049|bone051|bone052|bone053|bone054|bone055|bone056|bone058|bone059|bone061|bone062|bone063|bone064|bone065|bone066|bone068|bone069|bone081|bone082|bone083|bone084|bone085|bone086|bone087|bone088|bone089|bone090|bone091|bone092|bone093|bone094|bone095|bone096|bone097|bone098|bone099|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.3453125,
+ "y": 0,
+ "width": 0.28203125,
+ "height": 0.40694444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 21,
+ 7,
+ 38,
+ 40,
+ 14,
+ 36,
+ 36,
+ 13,
+ 21,
+ 18,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 19,
+ 19,
+ 5,
+ 37,
+ 35,
+ 13,
+ 10,
+ 10,
+ 4,
+ 38,
+ 35,
+ 10,
+ 39,
+ 37,
+ 11,
+ 14,
+ 17,
+ 9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 23,
+ 27,
+ 8,
+ 24,
+ 23,
+ 9,
+ 20,
+ 26,
+ 14,
+ 33,
+ 45,
+ 11,
+ 35,
+ 47,
+ 10,
+ 26,
+ 31,
+ 13,
+ 5,
+ 5,
+ 2,
+ 0,
+ 0,
+ 0,
+ 26,
+ 34,
+ 10,
+ 19,
+ 20,
+ 6,
+ 36,
+ 33,
+ 11,
+ 42,
+ 42,
+ 13,
+ 42,
+ 41,
+ 11,
+ 42,
+ 39,
+ 13,
+ 26,
+ 25,
+ 8,
+ 23,
+ 28,
+ 9,
+ 18,
+ 21,
+ 7,
+ 29,
+ 25,
+ 7,
+ 33,
+ 35,
+ 14,
+ 41,
+ 39,
+ 16,
+ 51,
+ 47,
+ 22,
+ 27,
+ 31,
+ 13,
+ 25,
+ 29,
+ 10,
+ 28,
+ 28,
+ 11,
+ 8,
+ 7,
+ 3,
+ 46,
+ 46,
+ 21,
+ 24,
+ 29,
+ 15,
+ 31,
+ 39,
+ 16,
+ 44,
+ 54,
+ 26,
+ 21,
+ 27,
+ 11,
+ 36,
+ 42,
+ 24,
+ 19,
+ 21,
+ 10,
+ 0,
+ 0,
+ 0,
+ 8,
+ 10,
+ 5,
+ 17,
+ 21,
+ 11,
+ 30,
+ 35,
+ 14,
+ 53,
+ 61,
+ 32,
+ 26,
+ 29,
+ 12,
+ 8,
+ 10,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 9,
+ 12,
+ 6,
+ 38,
+ 41,
+ 16,
+ 69,
+ 75,
+ 35,
+ 34,
+ 37,
+ 14,
+ 3,
+ 5,
+ 1,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "brm|face=0|texture=0|helm=0": {
+ "meshCount": 13,
+ "vertexCount": 1308,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "brmch0001:brmch0001 (base color):64:128|brmch0002:brmch0002 (base color):32:16|brmfa0001:brmfa0001 (base color):32:64|brmft0001:brmft0001 (base color):64:32|brmft0002:brmft0002 (base color):8:8|brmhe0001:brmhe0001 (base color):128:64|brmhe0004:brmhe0004 (base color):32:32|brmhn0001:brmhn0001 (base color):64:32|brmhn0002:brmhn0002 (base color):32:32|brmlg0001:brmlg0001 (base color):128:64|brmlg0002:brmlg0002 (base color):64:64|brmlg0003:brmlg0003 (base color):64:32|brmua0001:brmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3046875,
+ "y": 0,
+ "width": 0.39453125,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 15,
+ 0,
+ 26,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 51,
+ 31,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 34,
+ 22,
+ 5,
+ 33,
+ 21,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 69,
+ 43,
+ 18,
+ 73,
+ 47,
+ 21,
+ 23,
+ 16,
+ 8,
+ 0,
+ 0,
+ 0,
+ 34,
+ 22,
+ 6,
+ 31,
+ 20,
+ 5,
+ 0,
+ 0,
+ 0,
+ 24,
+ 17,
+ 8,
+ 69,
+ 45,
+ 20,
+ 33,
+ 22,
+ 10,
+ 60,
+ 40,
+ 18,
+ 5,
+ 3,
+ 1,
+ 42,
+ 28,
+ 10,
+ 40,
+ 26,
+ 9,
+ 18,
+ 11,
+ 4,
+ 73,
+ 48,
+ 21,
+ 17,
+ 11,
+ 5,
+ 0,
+ 0,
+ 0,
+ 61,
+ 40,
+ 17,
+ 58,
+ 39,
+ 19,
+ 57,
+ 38,
+ 16,
+ 55,
+ 37,
+ 15,
+ 60,
+ 40,
+ 20,
+ 45,
+ 29,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 30,
+ 20,
+ 9,
+ 57,
+ 39,
+ 18,
+ 61,
+ 41,
+ 19,
+ 28,
+ 18,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 50,
+ 34,
+ 21,
+ 38,
+ 26,
+ 15,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 16,
+ 12,
+ 30,
+ 20,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "brm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 13,
+ "vertexCount": 1308,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "brmch0001:brmch0001 (base color):64:128|brmch0002:brmch0002 (base color):32:16|brmfa0001:brmfa0001 (base color):32:64|brmft0001:brmft0001 (base color):64:32|brmft0002:brmft0002 (base color):8:8|brmhe0001:brmhe0001 (base color):128:64|brmhe0004:brmhe0004 (base color):32:32|brmhn0001:brmhn0001 (base color):64:32|brmhn0002:brmhn0002 (base color):32:32|brmlg0001:brmlg0001 (base color):128:64|brmlg0002:brmlg0002 (base color):64:64|brmlg0003:brmlg0003 (base color):64:32|brmua0001:brmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.29140625,
+ "y": 0,
+ "width": 0.41875,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 13,
+ 8,
+ 3,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 35,
+ 23,
+ 4,
+ 35,
+ 23,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 7,
+ 5,
+ 2,
+ 68,
+ 42,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 18,
+ 2,
+ 27,
+ 17,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 67,
+ 41,
+ 17,
+ 50,
+ 32,
+ 13,
+ 36,
+ 23,
+ 10,
+ 0,
+ 0,
+ 0,
+ 35,
+ 23,
+ 5,
+ 36,
+ 23,
+ 5,
+ 0,
+ 0,
+ 0,
+ 36,
+ 23,
+ 10,
+ 50,
+ 33,
+ 14,
+ 14,
+ 9,
+ 4,
+ 72,
+ 46,
+ 19,
+ 50,
+ 32,
+ 14,
+ 37,
+ 24,
+ 6,
+ 37,
+ 25,
+ 6,
+ 50,
+ 33,
+ 14,
+ 72,
+ 46,
+ 19,
+ 14,
+ 9,
+ 4,
+ 0,
+ 0,
+ 0,
+ 16,
+ 11,
+ 5,
+ 69,
+ 45,
+ 18,
+ 50,
+ 33,
+ 12,
+ 51,
+ 33,
+ 12,
+ 69,
+ 45,
+ 19,
+ 17,
+ 11,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 15,
+ 10,
+ 5,
+ 45,
+ 30,
+ 13,
+ 44,
+ 30,
+ 13,
+ 14,
+ 10,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 36,
+ 25,
+ 18,
+ 31,
+ 21,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 20,
+ 14,
+ 28,
+ 19,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fef|face=0|texture=0|helm=0": {
+ "meshCount": 14,
+ "vertexCount": 1362,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "fefch0001:fefch0001 (base color):64:128|fefch0002:fefch0002 (base color):32:16|feffa0001:feffa0001 (base color):32:64|fefft0001:fefft0001 (base color):64:32|fefft0002:fefft0002 (base color):32:32|fefhe0001:fefhe0001 (base color):128:64|fefhe0002:fefhe0002 (base color):32:32|fefhe0004:fefhe0004 (base color):32:32|fefhn0001:fefhn0001 (base color):64:32|fefhn0002:fefhn0002 (base color):32:32|feflg0001:feflg0001 (base color):64:128|feflg0001:feflg0001 (base color):64:128|feflg0002:feflg0002 (base color):64:32|fefua0001:fefua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.35,
+ "y": 0,
+ "width": 0.25546875,
+ "height": 0.46805555555555556
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 51,
+ 19,
+ 20,
+ 98,
+ 32,
+ 36,
+ 114,
+ 47,
+ 52,
+ 76,
+ 38,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 166,
+ 145,
+ 139,
+ 0,
+ 0,
+ 0,
+ 48,
+ 27,
+ 20,
+ 114,
+ 43,
+ 43,
+ 97,
+ 31,
+ 36,
+ 60,
+ 31,
+ 25,
+ 78,
+ 70,
+ 69,
+ 179,
+ 156,
+ 151,
+ 149,
+ 135,
+ 135,
+ 127,
+ 115,
+ 114,
+ 15,
+ 11,
+ 7,
+ 101,
+ 33,
+ 38,
+ 105,
+ 42,
+ 34,
+ 131,
+ 93,
+ 89,
+ 183,
+ 165,
+ 164,
+ 46,
+ 41,
+ 42,
+ 0,
+ 0,
+ 0,
+ 183,
+ 164,
+ 159,
+ 40,
+ 37,
+ 36,
+ 130,
+ 79,
+ 77,
+ 121,
+ 54,
+ 37,
+ 144,
+ 96,
+ 93,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 48,
+ 44,
+ 43,
+ 184,
+ 169,
+ 164,
+ 102,
+ 97,
+ 86,
+ 111,
+ 72,
+ 56,
+ 148,
+ 99,
+ 88,
+ 23,
+ 21,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 84,
+ 77,
+ 74,
+ 195,
+ 172,
+ 164,
+ 176,
+ 142,
+ 129,
+ 168,
+ 122,
+ 110,
+ 36,
+ 33,
+ 32,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 102,
+ 86,
+ 68,
+ 155,
+ 131,
+ 107,
+ 137,
+ 119,
+ 111,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 15,
+ 12,
+ 7,
+ 149,
+ 119,
+ 85,
+ 155,
+ 127,
+ 99,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fef|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 14,
+ "vertexCount": 1362,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "fefch0001:fefch0001 (base color):64:128|fefch0002:fefch0002 (base color):32:16|feffa0001:feffa0001 (base color):32:64|fefft0001:fefft0001 (base color):64:32|fefft0002:fefft0002 (base color):32:32|fefhe0001:fefhe0001 (base color):128:64|fefhe0002:fefhe0002 (base color):32:32|fefhe0004:fefhe0004 (base color):32:32|fefhn0001:fefhn0001 (base color):64:32|fefhn0002:fefhn0002 (base color):32:32|feflg0001:feflg0001 (base color):64:128|feflg0001:feflg0001 (base color):64:128|feflg0002:feflg0002 (base color):64:32|fefua0001:fefua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.4109375,
+ "y": 0,
+ "width": 0.20390625,
+ "height": 0.46944444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 27,
+ 21,
+ 12,
+ 56,
+ 43,
+ 23,
+ 58,
+ 45,
+ 24,
+ 28,
+ 22,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 31,
+ 24,
+ 13,
+ 79,
+ 61,
+ 33,
+ 79,
+ 62,
+ 33,
+ 33,
+ 26,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 162,
+ 146,
+ 144,
+ 105,
+ 92,
+ 89,
+ 85,
+ 71,
+ 54,
+ 104,
+ 81,
+ 43,
+ 102,
+ 79,
+ 43,
+ 49,
+ 38,
+ 20,
+ 0,
+ 0,
+ 0,
+ 12,
+ 10,
+ 10,
+ 0,
+ 0,
+ 0,
+ 118,
+ 107,
+ 104,
+ 129,
+ 114,
+ 102,
+ 134,
+ 102,
+ 78,
+ 115,
+ 85,
+ 55,
+ 26,
+ 20,
+ 11,
+ 35,
+ 32,
+ 30,
+ 162,
+ 145,
+ 143,
+ 0,
+ 0,
+ 0,
+ 84,
+ 81,
+ 81,
+ 165,
+ 147,
+ 138,
+ 131,
+ 122,
+ 112,
+ 121,
+ 97,
+ 62,
+ 67,
+ 61,
+ 60,
+ 178,
+ 164,
+ 160,
+ 41,
+ 36,
+ 34,
+ 0,
+ 0,
+ 0,
+ 79,
+ 71,
+ 68,
+ 187,
+ 152,
+ 141,
+ 162,
+ 132,
+ 101,
+ 150,
+ 125,
+ 96,
+ 176,
+ 155,
+ 146,
+ 46,
+ 42,
+ 40,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 111,
+ 93,
+ 77,
+ 170,
+ 136,
+ 81,
+ 125,
+ 102,
+ 74,
+ 45,
+ 40,
+ 39,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 165,
+ 131,
+ 79,
+ 157,
+ 125,
+ 72,
+ 36,
+ 28,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "shf|face=0|texture=0|helm=0": {
+ "meshCount": 16,
+ "vertexCount": 5772,
+ "skeletonCount": 1,
+ "boneCount": 74,
+ "materialSignature": "shmch0001:shmch0001 (base color):128:256|shmch0002:shmch0002 (base color):128:256|shmch0003:shmch0003 (base color):256:256|shmch0004:shmch0004 (base color):256:128|shmfa0001:shmfa0001 (base color):128:128|shmfa0002:shmfa0002 (base color):128:128|shmha0001:shmha0001 (base color):128:64|shmhe0001:shmhe0001 (base color):256:128|shmhe0002:shmhe0002 (base color):256:64|shmhe0003:shmhe0003 (base color):64:64|shmhe0004:shmhe0004 (base color):64:64|shmlg0001:shmlg0001 (base color):256:128|shmlg0002:shmlg0002 (base color):256:256|shmlg0003:shmlg0003 (base color):256:256|shmlg0004:shmlg0004 (base color):256:128|shmua0001:shmua0001 (base color):128:128",
+ "skeletonSignature": "bone001|bone002|bone003|bone004|bone005|bone006|bone008|bone009|bone010|bone012|bone013|bone014|bone016|bone017|bone018|bone020|bone021|bone023|bone024|bone026|bone028|bone030|bone032|bone034|bone035|bone037|bone039|bone041|bone043|bone045|bone046|bone047|bone048|bone049|bone051|bone052|bone053|bone054|bone055|bone056|bone058|bone059|bone061|bone062|bone063|bone064|bone065|bone066|bone068|bone069|bone081|bone082|bone083|bone084|bone085|bone086|bone087|bone088|bone089|bone090|bone091|bone092|bone093|bone094|bone095|bone096|bone097|bone098|bone099|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.371875,
+ "y": 0,
+ "width": 0.284375,
+ "height": 0.4263888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 10,
+ 11,
+ 9,
+ 14,
+ 17,
+ 18,
+ 37,
+ 30,
+ 20,
+ 27,
+ 32,
+ 27,
+ 27,
+ 32,
+ 28,
+ 28,
+ 24,
+ 17,
+ 9,
+ 9,
+ 5,
+ 23,
+ 24,
+ 24,
+ 65,
+ 59,
+ 38,
+ 29,
+ 29,
+ 23,
+ 13,
+ 14,
+ 13,
+ 40,
+ 48,
+ 39,
+ 33,
+ 39,
+ 34,
+ 12,
+ 12,
+ 13,
+ 15,
+ 13,
+ 8,
+ 94,
+ 83,
+ 53,
+ 86,
+ 68,
+ 35,
+ 41,
+ 35,
+ 20,
+ 17,
+ 26,
+ 20,
+ 89,
+ 102,
+ 91,
+ 43,
+ 53,
+ 43,
+ 13,
+ 10,
+ 6,
+ 29,
+ 21,
+ 5,
+ 73,
+ 66,
+ 51,
+ 89,
+ 79,
+ 53,
+ 39,
+ 29,
+ 5,
+ 42,
+ 50,
+ 46,
+ 113,
+ 120,
+ 113,
+ 68,
+ 77,
+ 71,
+ 8,
+ 11,
+ 10,
+ 45,
+ 37,
+ 15,
+ 90,
+ 76,
+ 47,
+ 78,
+ 69,
+ 30,
+ 54,
+ 49,
+ 19,
+ 37,
+ 45,
+ 37,
+ 58,
+ 70,
+ 59,
+ 44,
+ 55,
+ 47,
+ 63,
+ 51,
+ 10,
+ 73,
+ 65,
+ 26,
+ 32,
+ 29,
+ 13,
+ 0,
+ 0,
+ 0,
+ 63,
+ 63,
+ 43,
+ 24,
+ 29,
+ 27,
+ 34,
+ 37,
+ 35,
+ 26,
+ 32,
+ 28,
+ 52,
+ 45,
+ 17,
+ 53,
+ 51,
+ 39,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 48,
+ 41,
+ 15,
+ 25,
+ 27,
+ 29,
+ 47,
+ 49,
+ 51,
+ 28,
+ 30,
+ 31,
+ 77,
+ 64,
+ 16,
+ 53,
+ 47,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 27,
+ 23,
+ 8,
+ 27,
+ 37,
+ 30,
+ 40,
+ 44,
+ 49,
+ 27,
+ 37,
+ 31,
+ 97,
+ 81,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "shf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 16,
+ "vertexCount": 5772,
+ "skeletonCount": 1,
+ "boneCount": 74,
+ "materialSignature": "shmch0001:shmch0001 (base color):128:256|shmch0002:shmch0002 (base color):128:256|shmch0003:shmch0003 (base color):256:256|shmch0004:shmch0004 (base color):256:128|shmfa0001:shmfa0001 (base color):128:128|shmfa0002:shmfa0002 (base color):128:128|shmha0001:shmha0001 (base color):128:64|shmhe0001:shmhe0001 (base color):256:128|shmhe0002:shmhe0002 (base color):256:64|shmhe0003:shmhe0003 (base color):64:64|shmhe0004:shmhe0004 (base color):64:64|shmlg0001:shmlg0001 (base color):256:128|shmlg0002:shmlg0002 (base color):256:256|shmlg0003:shmlg0003 (base color):256:256|shmlg0004:shmlg0004 (base color):256:128|shmua0001:shmua0001 (base color):128:128",
+ "skeletonSignature": "bone001|bone002|bone003|bone004|bone005|bone006|bone008|bone009|bone010|bone012|bone013|bone014|bone016|bone017|bone018|bone020|bone021|bone023|bone024|bone026|bone028|bone030|bone032|bone034|bone035|bone037|bone039|bone041|bone043|bone045|bone046|bone047|bone048|bone049|bone051|bone052|bone053|bone054|bone055|bone056|bone058|bone059|bone061|bone062|bone063|bone064|bone065|bone066|bone068|bone069|bone081|bone082|bone083|bone084|bone085|bone086|bone087|bone088|bone089|bone090|bone091|bone092|bone093|bone094|bone095|bone096|bone097|bone098|bone099|head_point|l_point|r_point|root|shield_point",
+ "foregroundBounds": {
+ "x": 0.33984375,
+ "y": 0,
+ "width": 0.28828125,
+ "height": 0.41388888888888886
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 12,
+ 14,
+ 16,
+ 29,
+ 28,
+ 25,
+ 30,
+ 26,
+ 21,
+ 18,
+ 15,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 41,
+ 38,
+ 26,
+ 31,
+ 31,
+ 18,
+ 22,
+ 15,
+ 9,
+ 50,
+ 36,
+ 22,
+ 54,
+ 41,
+ 26,
+ 34,
+ 24,
+ 16,
+ 13,
+ 10,
+ 7,
+ 0,
+ 0,
+ 0,
+ 30,
+ 26,
+ 19,
+ 23,
+ 22,
+ 15,
+ 23,
+ 21,
+ 22,
+ 47,
+ 36,
+ 25,
+ 41,
+ 33,
+ 26,
+ 25,
+ 23,
+ 22,
+ 15,
+ 14,
+ 11,
+ 7,
+ 8,
+ 9,
+ 71,
+ 61,
+ 23,
+ 40,
+ 35,
+ 18,
+ 24,
+ 20,
+ 16,
+ 26,
+ 26,
+ 25,
+ 19,
+ 24,
+ 25,
+ 32,
+ 22,
+ 14,
+ 19,
+ 20,
+ 18,
+ 59,
+ 53,
+ 23,
+ 30,
+ 28,
+ 16,
+ 16,
+ 22,
+ 23,
+ 23,
+ 19,
+ 15,
+ 29,
+ 32,
+ 34,
+ 40,
+ 45,
+ 48,
+ 24,
+ 21,
+ 18,
+ 25,
+ 25,
+ 19,
+ 43,
+ 42,
+ 32,
+ 0,
+ 0,
+ 0,
+ 33,
+ 32,
+ 23,
+ 48,
+ 40,
+ 11,
+ 37,
+ 39,
+ 36,
+ 50,
+ 56,
+ 60,
+ 37,
+ 37,
+ 31,
+ 64,
+ 53,
+ 17,
+ 5,
+ 5,
+ 6,
+ 0,
+ 0,
+ 0,
+ 63,
+ 50,
+ 5,
+ 81,
+ 65,
+ 14,
+ 32,
+ 32,
+ 29,
+ 44,
+ 46,
+ 48,
+ 28,
+ 27,
+ 25,
+ 65,
+ 57,
+ 34,
+ 50,
+ 43,
+ 18,
+ 0,
+ 0,
+ 0,
+ 55,
+ 47,
+ 13,
+ 63,
+ 55,
+ 27,
+ 26,
+ 28,
+ 28,
+ 37,
+ 39,
+ 42,
+ 24,
+ 27,
+ 25,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "brf|face=0|texture=0|helm=0": {
+ "meshCount": 13,
+ "vertexCount": 1308,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "brmch0001:brmch0001 (base color):64:128|brmch0002:brmch0002 (base color):32:16|brmfa0001:brmfa0001 (base color):32:64|brmft0001:brmft0001 (base color):64:32|brmft0002:brmft0002 (base color):8:8|brmhe0001:brmhe0001 (base color):128:64|brmhe0004:brmhe0004 (base color):32:32|brmhn0001:brmhn0001 (base color):64:32|brmhn0002:brmhn0002 (base color):32:32|brmlg0001:brmlg0001 (base color):128:64|brmlg0002:brmlg0002 (base color):64:64|brmlg0003:brmlg0003 (base color):64:32|brmua0001:brmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3046875,
+ "y": 0,
+ "width": 0.39453125,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 15,
+ 1,
+ 26,
+ 16,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 51,
+ 31,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 34,
+ 22,
+ 5,
+ 33,
+ 21,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 69,
+ 43,
+ 18,
+ 73,
+ 48,
+ 21,
+ 23,
+ 16,
+ 8,
+ 0,
+ 0,
+ 0,
+ 34,
+ 22,
+ 6,
+ 32,
+ 20,
+ 5,
+ 0,
+ 0,
+ 0,
+ 24,
+ 17,
+ 8,
+ 69,
+ 45,
+ 20,
+ 33,
+ 22,
+ 9,
+ 60,
+ 40,
+ 18,
+ 5,
+ 3,
+ 1,
+ 43,
+ 28,
+ 10,
+ 41,
+ 27,
+ 9,
+ 18,
+ 12,
+ 4,
+ 73,
+ 48,
+ 21,
+ 17,
+ 11,
+ 5,
+ 0,
+ 0,
+ 0,
+ 62,
+ 41,
+ 17,
+ 59,
+ 39,
+ 19,
+ 56,
+ 38,
+ 16,
+ 55,
+ 37,
+ 15,
+ 60,
+ 40,
+ 20,
+ 45,
+ 30,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 30,
+ 20,
+ 9,
+ 57,
+ 39,
+ 18,
+ 61,
+ 41,
+ 19,
+ 28,
+ 18,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 50,
+ 34,
+ 21,
+ 37,
+ 25,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 16,
+ 12,
+ 30,
+ 21,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "brf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 13,
+ "vertexCount": 1308,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "brmch0001:brmch0001 (base color):64:128|brmch0002:brmch0002 (base color):32:16|brmfa0001:brmfa0001 (base color):32:64|brmft0001:brmft0001 (base color):64:32|brmft0002:brmft0002 (base color):8:8|brmhe0001:brmhe0001 (base color):128:64|brmhe0004:brmhe0004 (base color):32:32|brmhn0001:brmhn0001 (base color):64:32|brmhn0002:brmhn0002 (base color):32:32|brmlg0001:brmlg0001 (base color):128:64|brmlg0002:brmlg0002 (base color):64:64|brmlg0003:brmlg0003 (base color):64:32|brmua0001:brmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.29140625,
+ "y": 0,
+ "width": 0.41875,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 12,
+ 7,
+ 3,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 36,
+ 23,
+ 4,
+ 36,
+ 23,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 7,
+ 4,
+ 2,
+ 68,
+ 42,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 18,
+ 2,
+ 27,
+ 17,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 67,
+ 41,
+ 17,
+ 50,
+ 32,
+ 13,
+ 36,
+ 23,
+ 10,
+ 0,
+ 0,
+ 0,
+ 35,
+ 23,
+ 5,
+ 36,
+ 24,
+ 5,
+ 0,
+ 0,
+ 0,
+ 36,
+ 23,
+ 10,
+ 51,
+ 33,
+ 14,
+ 14,
+ 9,
+ 4,
+ 72,
+ 46,
+ 19,
+ 50,
+ 33,
+ 14,
+ 38,
+ 25,
+ 7,
+ 38,
+ 25,
+ 7,
+ 50,
+ 33,
+ 14,
+ 72,
+ 46,
+ 19,
+ 14,
+ 9,
+ 4,
+ 0,
+ 0,
+ 0,
+ 16,
+ 11,
+ 5,
+ 69,
+ 45,
+ 19,
+ 50,
+ 33,
+ 12,
+ 51,
+ 33,
+ 12,
+ 69,
+ 45,
+ 19,
+ 17,
+ 11,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 16,
+ 10,
+ 5,
+ 45,
+ 30,
+ 13,
+ 44,
+ 30,
+ 12,
+ 15,
+ 10,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 36,
+ 25,
+ 19,
+ 32,
+ 22,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 20,
+ 14,
+ 27,
+ 19,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "frf|face=0|texture=0|helm=0": {
+ "meshCount": 11,
+ "vertexCount": 4611,
+ "skeletonCount": 1,
+ "boneCount": 128,
+ "materialSignature": "chr_eye120:chr_eye120 (base color):64:64|frmchsk01:frmchsk01:256:256|frmfask01:frmfask01:128:128|frmfask02:frmfask02:128:128|frmftsk01:frmftsk01:128:256|frmftsk02:frmftsk02:64:64|frmhesk01:frmhesk01:256:128|frmhnsk01:frmhnsk01:256:128|frmlg0001:frmlg0001:128:256|frmlg0002:frmlg0002 (base color):256:256|frmuask01:frmuask01:256:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bksash01|bksash02|bksash03|bksash04|bksash05|bksash06|bksashl01|bksashl02|bksashl03|bksashl04|bksashl05|bksashr01|bksashr02|bksashr03|bksashr04|bksashr05|bofootl|bofootr|btoel01|btoel02|btoel03|btoer01|btoer02|btoer03|cacalfl|cacalfr|chchest|chchest1|chchest2|chest_point|elid_ll01|elid_lu01|elid_rl01|elid_ru01|eyeballs01|eyebrowl01|eyebrowr01|eyel01|eyer|fihandl|fihandr|fiindexl01|fiindexl02|fiindexl03|fiindexr01|fiindexr02|fiindexr03|fipinkyl01|fipinkyl02|fipinkyl03|fipinkyr01|fipinkyr02|fipinkyr03|fipointl01|fipointl02|fipointr01|fipointr02|fipointr03|fithumbl01|fithumbl02|fithumbl03|fithumbr01|fithumbr02|fithumbr03|foforearml|foforearmr|frsash01|frsash02|frsash03|frsash04|frsash05|frsash06|gauntl_point|gauntr_point|hair_point|hair_point_00|head_point|hehead|hehead_point|jaw01|l_point|legl_point|legr_point|ltoel01|ltoel02|ltoel03|ltoer01|ltoer02|ltoer03|mtoel01|mtoel02|mtoel03|mtoer01|mtoer02|mtoer03|neneck01|neneck02|pebip01|pelvis_point|pepelvis|r_point|root|shield_point|shouldl_point|shouldr_point|throatl01|throatl02|throatl03|throatl04|throatm01|throatm02|throatm03|throatm04|throatr01|throatr02|throatr03|throatr04|ththighl|ththighr|tongue01|tongue02|tunic_point",
+ "foregroundBounds": {
+ "x": 0.33828125,
+ "y": 0,
+ "width": 0.31484375,
+ "height": 0.4486111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 62,
+ 79,
+ 22,
+ 21,
+ 27,
+ 8,
+ 0,
+ 0,
+ 0,
+ 71,
+ 70,
+ 43,
+ 66,
+ 66,
+ 41,
+ 12,
+ 16,
+ 4,
+ 53,
+ 68,
+ 20,
+ 67,
+ 82,
+ 30,
+ 47,
+ 61,
+ 17,
+ 65,
+ 86,
+ 25,
+ 30,
+ 41,
+ 11,
+ 51,
+ 50,
+ 31,
+ 67,
+ 66,
+ 40,
+ 41,
+ 51,
+ 18,
+ 65,
+ 83,
+ 26,
+ 35,
+ 44,
+ 15,
+ 15,
+ 19,
+ 7,
+ 69,
+ 87,
+ 28,
+ 49,
+ 64,
+ 17,
+ 26,
+ 26,
+ 17,
+ 56,
+ 54,
+ 41,
+ 49,
+ 64,
+ 17,
+ 46,
+ 57,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 43,
+ 54,
+ 19,
+ 58,
+ 75,
+ 22,
+ 59,
+ 63,
+ 32,
+ 76,
+ 75,
+ 48,
+ 64,
+ 76,
+ 31,
+ 28,
+ 36,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 77,
+ 86,
+ 37,
+ 94,
+ 99,
+ 56,
+ 75,
+ 85,
+ 51,
+ 88,
+ 101,
+ 58,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 71,
+ 83,
+ 40,
+ 77,
+ 91,
+ 48,
+ 82,
+ 90,
+ 61,
+ 94,
+ 109,
+ 60,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 38,
+ 46,
+ 24,
+ 80,
+ 95,
+ 53,
+ 82,
+ 97,
+ 54,
+ 96,
+ 101,
+ 52,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 25,
+ 16,
+ 94,
+ 110,
+ 64,
+ 76,
+ 95,
+ 42,
+ 14,
+ 19,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "frf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 11,
+ "vertexCount": 4611,
+ "skeletonCount": 1,
+ "boneCount": 128,
+ "materialSignature": "chr_eye120:chr_eye120 (base color):64:64|frmchsk01:frmchsk01:256:256|frmfask01:frmfask01:128:128|frmfask02:frmfask02:128:128|frmftsk01:frmftsk01:128:256|frmftsk02:frmftsk02:64:64|frmhesk01:frmhesk01:256:128|frmhnsk01:frmhnsk01:256:128|frmlg0001:frmlg0001:128:256|frmlg0002:frmlg0002 (base color):256:256|frmuask01:frmuask01:256:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bksash01|bksash02|bksash03|bksash04|bksash05|bksash06|bksashl01|bksashl02|bksashl03|bksashl04|bksashl05|bksashr01|bksashr02|bksashr03|bksashr04|bksashr05|bofootl|bofootr|btoel01|btoel02|btoel03|btoer01|btoer02|btoer03|cacalfl|cacalfr|chchest|chchest1|chchest2|chest_point|elid_ll01|elid_lu01|elid_rl01|elid_ru01|eyeballs01|eyebrowl01|eyebrowr01|eyel01|eyer|fihandl|fihandr|fiindexl01|fiindexl02|fiindexl03|fiindexr01|fiindexr02|fiindexr03|fipinkyl01|fipinkyl02|fipinkyl03|fipinkyr01|fipinkyr02|fipinkyr03|fipointl01|fipointl02|fipointr01|fipointr02|fipointr03|fithumbl01|fithumbl02|fithumbl03|fithumbr01|fithumbr02|fithumbr03|foforearml|foforearmr|frsash01|frsash02|frsash03|frsash04|frsash05|frsash06|gauntl_point|gauntr_point|hair_point|hair_point_00|head_point|hehead|hehead_point|jaw01|l_point|legl_point|legr_point|ltoel01|ltoel02|ltoel03|ltoer01|ltoer02|ltoer03|mtoel01|mtoel02|mtoel03|mtoer01|mtoer02|mtoer03|neneck01|neneck02|pebip01|pelvis_point|pepelvis|r_point|root|shield_point|shouldl_point|shouldr_point|throatl01|throatl02|throatl03|throatl04|throatm01|throatm02|throatm03|throatm04|throatr01|throatr02|throatr03|throatr04|ththighl|ththighr|tongue01|tongue02|tunic_point",
+ "foregroundBounds": {
+ "x": 0.3640625,
+ "y": 0,
+ "width": 0.3046875,
+ "height": 0.4486111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 67,
+ 80,
+ 33,
+ 55,
+ 73,
+ 18,
+ 64,
+ 65,
+ 35,
+ 48,
+ 47,
+ 28,
+ 5,
+ 5,
+ 3,
+ 51,
+ 66,
+ 21,
+ 62,
+ 75,
+ 29,
+ 66,
+ 81,
+ 31,
+ 43,
+ 52,
+ 20,
+ 59,
+ 79,
+ 22,
+ 42,
+ 50,
+ 24,
+ 25,
+ 25,
+ 25,
+ 26,
+ 32,
+ 13,
+ 56,
+ 70,
+ 24,
+ 70,
+ 82,
+ 35,
+ 19,
+ 23,
+ 11,
+ 18,
+ 22,
+ 10,
+ 61,
+ 76,
+ 25,
+ 49,
+ 60,
+ 25,
+ 56,
+ 68,
+ 37,
+ 45,
+ 56,
+ 19,
+ 76,
+ 92,
+ 40,
+ 26,
+ 34,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 69,
+ 85,
+ 34,
+ 62,
+ 78,
+ 26,
+ 62,
+ 79,
+ 28,
+ 84,
+ 101,
+ 47,
+ 90,
+ 105,
+ 54,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 61,
+ 74,
+ 34,
+ 66,
+ 83,
+ 31,
+ 64,
+ 82,
+ 28,
+ 90,
+ 108,
+ 53,
+ 120,
+ 139,
+ 86,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 34,
+ 40,
+ 19,
+ 81,
+ 98,
+ 44,
+ 76,
+ 90,
+ 42,
+ 85,
+ 104,
+ 45,
+ 76,
+ 90,
+ 49,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 103,
+ 110,
+ 56,
+ 74,
+ 87,
+ 41,
+ 94,
+ 115,
+ 55,
+ 38,
+ 45,
+ 24,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 51,
+ 60,
+ 22,
+ 87,
+ 103,
+ 54,
+ 92,
+ 109,
+ 63,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "gff|face=0|texture=0|helm=0": {
+ "meshCount": 14,
+ "vertexCount": 1179,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "gffch0001:gffch0001 (base color):64:128|gffch0002:gffch0002 (base color):32:16|gfffa0001:gfffa0001 (base color):32:64|gffft0001:gffft0001 (base color):64:32|gffft0002:gffft0002 (base color):8:8|gffhe0001:gffhe0001 (base color):64:64|gffhe0002:gffhe0002 (base color):64:32|gffhe0004:gffhe0004 (base color):32:32|gffhn0001:gffhn0001 (base color):64:32|gffhn0002:gffhn0002 (base color):32:32|gfflg0001:gfflg0001 (base color):128:64|gfflg0002:gfflg0002 (base color):64:64|gfflg0003:gfflg0003 (base color):64:32|gffua0001:gffua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3796875,
+ "y": 0,
+ "width": 0.20625,
+ "height": 0.4361111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 118,
+ 80,
+ 38,
+ 0,
+ 0,
+ 0,
+ 37,
+ 38,
+ 19,
+ 40,
+ 38,
+ 22,
+ 40,
+ 38,
+ 23,
+ 39,
+ 39,
+ 20,
+ 46,
+ 37,
+ 19,
+ 113,
+ 76,
+ 36,
+ 113,
+ 83,
+ 47,
+ 52,
+ 37,
+ 21,
+ 22,
+ 23,
+ 12,
+ 51,
+ 42,
+ 32,
+ 53,
+ 44,
+ 35,
+ 60,
+ 47,
+ 27,
+ 141,
+ 99,
+ 55,
+ 68,
+ 49,
+ 26,
+ 34,
+ 24,
+ 12,
+ 119,
+ 80,
+ 40,
+ 0,
+ 0,
+ 0,
+ 67,
+ 55,
+ 38,
+ 54,
+ 45,
+ 35,
+ 97,
+ 69,
+ 41,
+ 56,
+ 39,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 121,
+ 88,
+ 53,
+ 80,
+ 53,
+ 26,
+ 92,
+ 78,
+ 51,
+ 57,
+ 49,
+ 39,
+ 59,
+ 48,
+ 36,
+ 60,
+ 43,
+ 25,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 34,
+ 24,
+ 12,
+ 138,
+ 96,
+ 52,
+ 106,
+ 87,
+ 54,
+ 53,
+ 45,
+ 35,
+ 70,
+ 61,
+ 51,
+ 82,
+ 73,
+ 58,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 65,
+ 45,
+ 24,
+ 148,
+ 129,
+ 97,
+ 118,
+ 94,
+ 68,
+ 108,
+ 80,
+ 55,
+ 69,
+ 61,
+ 47,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 10,
+ 6,
+ 68,
+ 39,
+ 20,
+ 124,
+ 88,
+ 61,
+ 23,
+ 17,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 3,
+ 2,
+ 48,
+ 25,
+ 14,
+ 106,
+ 78,
+ 55,
+ 41,
+ 26,
+ 14,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "gff|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 14,
+ "vertexCount": 1179,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "gffch0001:gffch0001 (base color):64:128|gffch0002:gffch0002 (base color):32:16|gfffa0001:gfffa0001 (base color):32:64|gffft0001:gffft0001 (base color):64:32|gffft0002:gffft0002 (base color):8:8|gffhe0001:gffhe0001 (base color):64:64|gffhe0002:gffhe0002 (base color):64:32|gffhe0004:gffhe0004 (base color):32:32|gffhn0001:gffhn0001 (base color):64:32|gffhn0002:gffhn0002 (base color):32:32|gfflg0001:gfflg0001 (base color):128:64|gfflg0002:gfflg0002 (base color):64:64|gfflg0003:gfflg0003 (base color):64:32|gffua0001:gffua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.42890625,
+ "y": 0,
+ "width": 0.16484375,
+ "height": 0.4388888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 8,
+ 6,
+ 3,
+ 39,
+ 40,
+ 19,
+ 41,
+ 43,
+ 20,
+ 21,
+ 20,
+ 11,
+ 34,
+ 34,
+ 17,
+ 45,
+ 47,
+ 22,
+ 25,
+ 25,
+ 13,
+ 0,
+ 0,
+ 0,
+ 44,
+ 28,
+ 13,
+ 25,
+ 24,
+ 13,
+ 38,
+ 38,
+ 20,
+ 35,
+ 34,
+ 19,
+ 39,
+ 39,
+ 21,
+ 37,
+ 37,
+ 20,
+ 14,
+ 14,
+ 8,
+ 0,
+ 0,
+ 0,
+ 114,
+ 76,
+ 37,
+ 115,
+ 79,
+ 43,
+ 88,
+ 69,
+ 35,
+ 73,
+ 77,
+ 42,
+ 74,
+ 77,
+ 42,
+ 36,
+ 37,
+ 18,
+ 6,
+ 4,
+ 2,
+ 106,
+ 70,
+ 33,
+ 0,
+ 0,
+ 0,
+ 61,
+ 42,
+ 22,
+ 116,
+ 86,
+ 47,
+ 111,
+ 96,
+ 64,
+ 102,
+ 87,
+ 57,
+ 10,
+ 11,
+ 5,
+ 81,
+ 52,
+ 25,
+ 76,
+ 53,
+ 29,
+ 0,
+ 0,
+ 0,
+ 125,
+ 90,
+ 51,
+ 122,
+ 103,
+ 72,
+ 73,
+ 59,
+ 42,
+ 81,
+ 67,
+ 45,
+ 92,
+ 62,
+ 32,
+ 133,
+ 91,
+ 46,
+ 28,
+ 19,
+ 10,
+ 0,
+ 0,
+ 0,
+ 103,
+ 85,
+ 62,
+ 45,
+ 25,
+ 17,
+ 31,
+ 13,
+ 8,
+ 32,
+ 14,
+ 9,
+ 101,
+ 68,
+ 36,
+ 31,
+ 22,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 59,
+ 40,
+ 27,
+ 36,
+ 15,
+ 9,
+ 33,
+ 14,
+ 8,
+ 31,
+ 12,
+ 7,
+ 8,
+ 3,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 42,
+ 26,
+ 18,
+ 38,
+ 15,
+ 9,
+ 34,
+ 13,
+ 7,
+ 20,
+ 7,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "kob|face=0|texture=0|helm=0": {
+ "meshCount": 14,
+ "vertexCount": 1620,
+ "skeletonCount": 1,
+ "boneCount": 28,
+ "materialSignature": "kobch0001:kobch0001 (base color):128:256|kobch0001:kobch0001 (base color):128:256|kobfa0001:kobfa0001 (base color):64:64|kobft0001:kobft0001 (base color):64:64|kobft0002:kobft0002 (base color):32:16|kobhe0001:kobhe0001 (base color):64:64|kobhe0002:kobhe0002 (base color):64:32|kobhe0003:kobhe0003 (base color):32:64|kobhe0004:kobhe0004 (base color):16:32|kobhe0005:kobhe0005 (base color):32:16|kobhn0001:kobhn0001 (base color):64:32|koblg0001:koblg0001 (base color):64:128|kobua0001:kobua0001 (base color):64:64|wbthe0006:wbthe0006 (base color):8:8",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|cl_l|cl_r|ea_l|ea_r|fi_l|fi_r|fo_l|fo_r|he|he_point|ja|ne|pe|root|ta1|ta2|ta3|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.34921875,
+ "y": 0,
+ "width": 0.2765625,
+ "height": 0.40694444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 65,
+ 51,
+ 34,
+ 53,
+ 38,
+ 22,
+ 56,
+ 42,
+ 26,
+ 89,
+ 70,
+ 48,
+ 0,
+ 0,
+ 0,
+ 50,
+ 36,
+ 19,
+ 72,
+ 61,
+ 50,
+ 35,
+ 24,
+ 11,
+ 64,
+ 50,
+ 34,
+ 53,
+ 40,
+ 25,
+ 57,
+ 42,
+ 25,
+ 102,
+ 85,
+ 66,
+ 41,
+ 35,
+ 28,
+ 94,
+ 73,
+ 50,
+ 56,
+ 45,
+ 33,
+ 38,
+ 28,
+ 15,
+ 36,
+ 29,
+ 20,
+ 70,
+ 58,
+ 44,
+ 41,
+ 35,
+ 27,
+ 85,
+ 73,
+ 58,
+ 127,
+ 115,
+ 100,
+ 111,
+ 95,
+ 75,
+ 90,
+ 78,
+ 64,
+ 88,
+ 76,
+ 61,
+ 73,
+ 67,
+ 61,
+ 94,
+ 84,
+ 73,
+ 61,
+ 48,
+ 32,
+ 82,
+ 72,
+ 60,
+ 74,
+ 64,
+ 52,
+ 78,
+ 66,
+ 53,
+ 77,
+ 65,
+ 53,
+ 108,
+ 96,
+ 83,
+ 42,
+ 37,
+ 30,
+ 26,
+ 20,
+ 13,
+ 23,
+ 16,
+ 9,
+ 33,
+ 24,
+ 19,
+ 54,
+ 36,
+ 35,
+ 20,
+ 17,
+ 12,
+ 26,
+ 19,
+ 12,
+ 45,
+ 35,
+ 24,
+ 30,
+ 22,
+ 13,
+ 31,
+ 22,
+ 11,
+ 60,
+ 42,
+ 23,
+ 56,
+ 32,
+ 29,
+ 77,
+ 42,
+ 49,
+ 65,
+ 39,
+ 31,
+ 47,
+ 33,
+ 18,
+ 45,
+ 32,
+ 17,
+ 0,
+ 0,
+ 0,
+ 57,
+ 41,
+ 22,
+ 102,
+ 74,
+ 43,
+ 23,
+ 20,
+ 16,
+ 33,
+ 32,
+ 31,
+ 51,
+ 39,
+ 25,
+ 104,
+ 76,
+ 44,
+ 39,
+ 29,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 5,
+ 5,
+ 5,
+ 62,
+ 47,
+ 30,
+ 57,
+ 46,
+ 32,
+ 55,
+ 42,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "kob|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 14,
+ "vertexCount": 1620,
+ "skeletonCount": 1,
+ "boneCount": 28,
+ "materialSignature": "kobch0001:kobch0001 (base color):128:256|kobch0001:kobch0001 (base color):128:256|kobfa0001:kobfa0001 (base color):64:64|kobft0001:kobft0001 (base color):64:64|kobft0002:kobft0002 (base color):32:16|kobhe0001:kobhe0001 (base color):64:64|kobhe0002:kobhe0002 (base color):64:32|kobhe0003:kobhe0003 (base color):32:64|kobhe0004:kobhe0004 (base color):16:32|kobhe0005:kobhe0005 (base color):32:16|kobhn0001:kobhn0001 (base color):64:32|koblg0001:koblg0001 (base color):64:128|kobua0001:kobua0001 (base color):64:64|wbthe0006:wbthe0006 (base color):8:8",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|cl_l|cl_r|ea_l|ea_r|fi_l|fi_r|fo_l|fo_r|he|he_point|ja|ne|pe|root|ta1|ta2|ta3|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.33671875,
+ "y": 0,
+ "width": 0.34140625,
+ "height": 0.3972222222222222
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 49,
+ 34,
+ 16,
+ 55,
+ 39,
+ 22,
+ 65,
+ 58,
+ 50,
+ 53,
+ 35,
+ 17,
+ 49,
+ 33,
+ 18,
+ 81,
+ 75,
+ 68,
+ 39,
+ 26,
+ 12,
+ 44,
+ 30,
+ 14,
+ 73,
+ 50,
+ 25,
+ 57,
+ 44,
+ 31,
+ 74,
+ 70,
+ 64,
+ 44,
+ 29,
+ 13,
+ 41,
+ 32,
+ 21,
+ 87,
+ 82,
+ 75,
+ 46,
+ 29,
+ 12,
+ 70,
+ 48,
+ 22,
+ 64,
+ 45,
+ 23,
+ 41,
+ 27,
+ 13,
+ 30,
+ 27,
+ 23,
+ 22,
+ 14,
+ 7,
+ 22,
+ 16,
+ 10,
+ 34,
+ 30,
+ 25,
+ 43,
+ 27,
+ 12,
+ 74,
+ 51,
+ 26,
+ 52,
+ 36,
+ 19,
+ 35,
+ 23,
+ 11,
+ 41,
+ 28,
+ 13,
+ 41,
+ 25,
+ 11,
+ 45,
+ 30,
+ 14,
+ 40,
+ 27,
+ 13,
+ 33,
+ 22,
+ 11,
+ 77,
+ 55,
+ 29,
+ 59,
+ 42,
+ 22,
+ 26,
+ 18,
+ 9,
+ 50,
+ 35,
+ 18,
+ 56,
+ 38,
+ 19,
+ 52,
+ 36,
+ 19,
+ 45,
+ 32,
+ 15,
+ 20,
+ 14,
+ 7,
+ 78,
+ 55,
+ 29,
+ 19,
+ 14,
+ 7,
+ 46,
+ 32,
+ 17,
+ 19,
+ 14,
+ 6,
+ 42,
+ 30,
+ 16,
+ 28,
+ 21,
+ 10,
+ 25,
+ 17,
+ 8,
+ 54,
+ 38,
+ 20,
+ 18,
+ 13,
+ 7,
+ 0,
+ 0,
+ 0,
+ 21,
+ 15,
+ 8,
+ 81,
+ 58,
+ 32,
+ 88,
+ 63,
+ 33,
+ 87,
+ 62,
+ 32,
+ 62,
+ 45,
+ 25,
+ 23,
+ 17,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 19,
+ 13,
+ 70,
+ 51,
+ 28,
+ 92,
+ 67,
+ 36,
+ 1,
+ 3,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "qcf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 12,
+ "vertexCount": 1287,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "qcfch0001:qcfch0001 (base color):64:128|qcfch0002:qcfch0002 (base color):32:16|qcffa0001:qcffa0001 (base color):32:64|qcfft0001:qcfft0001 (base color):64:32|qcfft0002:qcfft0002 (base color):8:8|qcfhe0001:qcfhe0001 (base color):64:64|qcfhe0002:qcfhe0002 (base color):32:32|qcfhn0001:qcfhn0001 (base color):64:32|qcfhn0002:qcfhn0002 (base color):32:32|qcflg0001:qcflg0001 (base color):64:128|qcflg0002:qcflg0002 (base color):64:32|qcfua0001:qcfua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|ha|he|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.38125,
+ "y": 0,
+ "width": 0.21640625,
+ "height": 0.4013888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 136,
+ 120,
+ 111,
+ 24,
+ 27,
+ 27,
+ 35,
+ 28,
+ 38,
+ 94,
+ 74,
+ 76,
+ 92,
+ 75,
+ 94,
+ 90,
+ 71,
+ 75,
+ 41,
+ 41,
+ 45,
+ 129,
+ 125,
+ 120,
+ 85,
+ 97,
+ 96,
+ 88,
+ 98,
+ 96,
+ 10,
+ 7,
+ 4,
+ 125,
+ 95,
+ 57,
+ 113,
+ 88,
+ 77,
+ 121,
+ 90,
+ 51,
+ 58,
+ 62,
+ 62,
+ 111,
+ 125,
+ 124,
+ 28,
+ 31,
+ 31,
+ 95,
+ 109,
+ 107,
+ 21,
+ 23,
+ 23,
+ 61,
+ 63,
+ 52,
+ 47,
+ 57,
+ 48,
+ 28,
+ 31,
+ 25,
+ 95,
+ 107,
+ 106,
+ 43,
+ 50,
+ 50,
+ 0,
+ 0,
+ 0,
+ 90,
+ 97,
+ 97,
+ 88,
+ 96,
+ 95,
+ 89,
+ 100,
+ 98,
+ 97,
+ 108,
+ 106,
+ 65,
+ 73,
+ 72,
+ 126,
+ 137,
+ 136,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 28,
+ 28,
+ 116,
+ 127,
+ 126,
+ 123,
+ 123,
+ 118,
+ 125,
+ 122,
+ 117,
+ 121,
+ 125,
+ 121,
+ 84,
+ 92,
+ 91,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 109,
+ 87,
+ 78,
+ 122,
+ 91,
+ 79,
+ 147,
+ 108,
+ 94,
+ 141,
+ 106,
+ 93,
+ 42,
+ 32,
+ 28,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 27,
+ 19,
+ 14,
+ 32,
+ 22,
+ 16,
+ 36,
+ 25,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 23,
+ 16,
+ 12,
+ 25,
+ 17,
+ 13,
+ 27,
+ 18,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "qcm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 13,
+ "vertexCount": 1218,
+ "skeletonCount": 1,
+ "boneCount": 24,
+ "materialSignature": "qcmch0001:qcmch0001 (base color):64:128|qcmch0002:qcmch0002 (base color):32:16|qcmfa0001:qcmfa0001 (base color):32:64|qcmft0001:qcmft0001 (base color):32:16|qcmft0002:qcmft0002 (base color):8:8|qcmhe0001:qcmhe0001 (base color):128:64|qcmhe0002:qcmhe0002 (base color):8:8|qcmhn0001:qcmhn0001 (base color):64:32|qcmhn0002:qcmhn0002 (base color):32:32|qcmlg0001:qcmlg0001 (base color):128:64|qcmlg0002:qcmlg0002 (base color):64:64|qcmlg0003:qcmlg0003 (base color):64:32|qcmua0001:qcmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|l_point|ne|o_r|pe|r_point|root|shield_point|th_l|th_r|to_l",
+ "foregroundBounds": {
+ "x": 0.39765625,
+ "y": 0,
+ "width": 0.203125,
+ "height": 0.4041666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 85,
+ 51,
+ 27,
+ 78,
+ 47,
+ 25,
+ 29,
+ 40,
+ 35,
+ 36,
+ 40,
+ 31,
+ 36,
+ 40,
+ 31,
+ 29,
+ 39,
+ 33,
+ 79,
+ 47,
+ 25,
+ 85,
+ 51,
+ 28,
+ 82,
+ 52,
+ 31,
+ 63,
+ 43,
+ 25,
+ 29,
+ 31,
+ 20,
+ 33,
+ 36,
+ 25,
+ 33,
+ 36,
+ 25,
+ 29,
+ 32,
+ 21,
+ 63,
+ 43,
+ 25,
+ 82,
+ 53,
+ 31,
+ 31,
+ 33,
+ 24,
+ 30,
+ 41,
+ 32,
+ 26,
+ 35,
+ 25,
+ 31,
+ 41,
+ 30,
+ 32,
+ 41,
+ 30,
+ 26,
+ 34,
+ 25,
+ 30,
+ 41,
+ 32,
+ 19,
+ 25,
+ 19,
+ 17,
+ 24,
+ 19,
+ 37,
+ 51,
+ 40,
+ 35,
+ 46,
+ 34,
+ 36,
+ 46,
+ 35,
+ 36,
+ 47,
+ 35,
+ 34,
+ 45,
+ 34,
+ 37,
+ 52,
+ 40,
+ 9,
+ 12,
+ 9,
+ 0,
+ 0,
+ 0,
+ 39,
+ 52,
+ 39,
+ 42,
+ 54,
+ 40,
+ 37,
+ 48,
+ 36,
+ 37,
+ 49,
+ 36,
+ 42,
+ 54,
+ 40,
+ 39,
+ 51,
+ 39,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 28,
+ 21,
+ 48,
+ 38,
+ 27,
+ 48,
+ 40,
+ 30,
+ 21,
+ 26,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 29,
+ 29,
+ 26,
+ 34,
+ 34,
+ 32,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 34,
+ 35,
+ 31,
+ 40,
+ 39,
+ 35,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "clm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 16,
+ "vertexCount": 1371,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "clmch0001:clmch0001 (base color):64:64|clmch0002:clmch0002 (base color):32:16|clmfa0001:clmfa0001 (base color):32:64|clmft0001:clmft0001 (base color):64:32|clmft0002:clmft0002 (base color):8:8|clmhe0001:clmhe0001 (base color):64:64|clmhe0002:clmhe0002 (base color):64:32|clmhe0004:clmhe0004 (base color):32:32|clmhe0004:clmhe0004 (base color):32:32|clmhn0001:clmhn0001 (base color):64:32|clmhn0002:clmhn0002 (base color):32:32|clmlg0001:clmlg0001 (base color):64:32|clmlg0001:clmlg0001 (base color):64:32|clmlg0002:clmlg0002 (base color):64:64|clmlg0003:clmlg0003 (base color):64:32|clmua0001:clmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3078125,
+ "y": 0,
+ "width": 0.396875,
+ "height": 0.5430555555555555
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 25,
+ 32,
+ 16,
+ 0,
+ 0,
+ 0,
+ 12,
+ 15,
+ 9,
+ 24,
+ 29,
+ 15,
+ 26,
+ 31,
+ 17,
+ 14,
+ 17,
+ 10,
+ 10,
+ 13,
+ 7,
+ 26,
+ 30,
+ 14,
+ 26,
+ 28,
+ 14,
+ 7,
+ 9,
+ 5,
+ 12,
+ 11,
+ 6,
+ 27,
+ 32,
+ 18,
+ 26,
+ 28,
+ 16,
+ 12,
+ 16,
+ 9,
+ 13,
+ 14,
+ 7,
+ 25,
+ 25,
+ 14,
+ 25,
+ 29,
+ 16,
+ 23,
+ 23,
+ 12,
+ 19,
+ 20,
+ 11,
+ 32,
+ 33,
+ 19,
+ 32,
+ 33,
+ 19,
+ 5,
+ 8,
+ 3,
+ 29,
+ 23,
+ 12,
+ 17,
+ 22,
+ 11,
+ 7,
+ 6,
+ 3,
+ 35,
+ 37,
+ 20,
+ 31,
+ 29,
+ 16,
+ 33,
+ 33,
+ 18,
+ 31,
+ 30,
+ 17,
+ 30,
+ 27,
+ 15,
+ 26,
+ 30,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 10,
+ 5,
+ 55,
+ 59,
+ 37,
+ 43,
+ 47,
+ 27,
+ 48,
+ 52,
+ 30,
+ 34,
+ 38,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 35,
+ 38,
+ 22,
+ 36,
+ 24,
+ 13,
+ 33,
+ 37,
+ 22,
+ 28,
+ 37,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 36,
+ 43,
+ 24,
+ 47,
+ 46,
+ 23,
+ 42,
+ 54,
+ 29,
+ 19,
+ 23,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 24,
+ 32,
+ 16,
+ 66,
+ 79,
+ 46,
+ 58,
+ 68,
+ 38,
+ 6,
+ 13,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "clf|face=0|texture=0|helm=0": {
+ "meshCount": 15,
+ "vertexCount": 1410,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "clfch0001:clfch0001 (base color):64:64|clfch0002:clfch0002 (base color):32:16|clffa0001:clffa0001 (base color):32:64|clfft0001:clfft0001 (base color):64:32|clfft0002:clfft0002 (base color):16:32|clfhe0001:clfhe0001 (base color):64:64|clfhe0002:clfhe0002 (base color):64:32|clfhe0003:clfhe0003 (base color):32:32|clfhe0004:clfhe0004 (base color):32:32|clfhn0001:clfhn0001 (base color):64:32|clfhn0002:clfhn0002 (base color):32:32|clflg0001:clflg0001 (base color):64:32|clflg0002:clflg0002 (base color):64:64|clflg0003:clflg0003 (base color):64:32|clfua0001:clfua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.41484375,
+ "y": 0,
+ "width": 0.1734375,
+ "height": 0.4708333333333333
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 41,
+ 30,
+ 12,
+ 44,
+ 32,
+ 12,
+ 14,
+ 9,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 14,
+ 9,
+ 2,
+ 45,
+ 34,
+ 14,
+ 45,
+ 30,
+ 9,
+ 0,
+ 0,
+ 0,
+ 58,
+ 42,
+ 15,
+ 47,
+ 38,
+ 17,
+ 25,
+ 18,
+ 7,
+ 37,
+ 30,
+ 14,
+ 48,
+ 38,
+ 17,
+ 57,
+ 42,
+ 15,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 57,
+ 42,
+ 14,
+ 61,
+ 47,
+ 21,
+ 69,
+ 54,
+ 24,
+ 66,
+ 51,
+ 23,
+ 61,
+ 48,
+ 22,
+ 54,
+ 40,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 54,
+ 40,
+ 15,
+ 43,
+ 32,
+ 14,
+ 98,
+ 87,
+ 49,
+ 90,
+ 79,
+ 45,
+ 45,
+ 30,
+ 10,
+ 60,
+ 45,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 44,
+ 34,
+ 14,
+ 55,
+ 38,
+ 14,
+ 66,
+ 56,
+ 31,
+ 67,
+ 56,
+ 31,
+ 55,
+ 39,
+ 14,
+ 44,
+ 33,
+ 14,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 16,
+ 13,
+ 6,
+ 56,
+ 40,
+ 16,
+ 59,
+ 49,
+ 28,
+ 62,
+ 51,
+ 29,
+ 51,
+ 35,
+ 13,
+ 16,
+ 13,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 16,
+ 9,
+ 61,
+ 67,
+ 61,
+ 57,
+ 63,
+ 56,
+ 24,
+ 15,
+ 7,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 21,
+ 14,
+ 6,
+ 71,
+ 54,
+ 21,
+ 74,
+ 55,
+ 19,
+ 15,
+ 9,
+ 5,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "clf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 15,
+ "vertexCount": 1410,
+ "skeletonCount": 1,
+ "boneCount": 23,
+ "materialSignature": "clfch0001:clfch0001 (base color):64:64|clfch0002:clfch0002 (base color):32:16|clffa0001:clffa0001 (base color):32:64|clfft0001:clfft0001 (base color):64:32|clfft0002:clfft0002 (base color):16:32|clfhe0001:clfhe0001 (base color):64:64|clfhe0002:clfhe0002 (base color):64:32|clfhe0003:clfhe0003 (base color):32:32|clfhe0004:clfhe0004 (base color):32:32|clfhn0001:clfhn0001 (base color):64:32|clfhn0002:clfhn0002 (base color):32:32|clflg0001:clflg0001 (base color):64:32|clflg0002:clflg0002 (base color):64:64|clflg0003:clflg0003 (base color):64:32|clfua0001:clfua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.4046875,
+ "y": 0,
+ "width": 0.18828125,
+ "height": 0.45694444444444443
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 51,
+ 35,
+ 10,
+ 56,
+ 43,
+ 23,
+ 84,
+ 72,
+ 39,
+ 109,
+ 96,
+ 54,
+ 113,
+ 99,
+ 56,
+ 80,
+ 68,
+ 37,
+ 77,
+ 60,
+ 28,
+ 52,
+ 36,
+ 10,
+ 62,
+ 44,
+ 14,
+ 33,
+ 25,
+ 10,
+ 61,
+ 47,
+ 25,
+ 56,
+ 42,
+ 18,
+ 57,
+ 43,
+ 19,
+ 62,
+ 48,
+ 26,
+ 34,
+ 26,
+ 10,
+ 62,
+ 44,
+ 15,
+ 51,
+ 38,
+ 15,
+ 52,
+ 37,
+ 12,
+ 57,
+ 41,
+ 16,
+ 56,
+ 42,
+ 19,
+ 56,
+ 42,
+ 19,
+ 57,
+ 42,
+ 16,
+ 53,
+ 37,
+ 12,
+ 51,
+ 39,
+ 16,
+ 26,
+ 20,
+ 10,
+ 61,
+ 42,
+ 14,
+ 66,
+ 51,
+ 22,
+ 58,
+ 44,
+ 20,
+ 59,
+ 45,
+ 20,
+ 66,
+ 50,
+ 21,
+ 60,
+ 43,
+ 15,
+ 29,
+ 22,
+ 11,
+ 0,
+ 0,
+ 0,
+ 31,
+ 21,
+ 8,
+ 42,
+ 30,
+ 11,
+ 47,
+ 30,
+ 9,
+ 45,
+ 31,
+ 10,
+ 45,
+ 31,
+ 12,
+ 30,
+ 20,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 53,
+ 34,
+ 12,
+ 60,
+ 43,
+ 15,
+ 61,
+ 43,
+ 15,
+ 52,
+ 33,
+ 13,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 66,
+ 43,
+ 21,
+ 80,
+ 57,
+ 24,
+ 71,
+ 52,
+ 22,
+ 65,
+ 40,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 35,
+ 24,
+ 7,
+ 74,
+ 50,
+ 12,
+ 78,
+ 56,
+ 18,
+ 36,
+ 28,
+ 9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "ogm|face=4|texture=0|helm=0|heading=180": {
+ "meshCount": 26,
+ "vertexCount": 5478,
+ "skeletonCount": 1,
+ "boneCount": 92,
+ "materialSignature": "ogmch0002:ogmch0002:64:128|ogmch0003:ogmch0003:256:256|ogmchsk01:ogmchsk01:64:32|ogmfa0001:ogmfa0001:128:128|ogmfa0002:ogmfa0002:128:128|ogmftsk01:ogmftsk01:64:64|ogmftsk02:ogmftsk02:32:64|ogmftsk03:ogmftsk03:64:16|ogmftsk04:ogmftsk04:128:128|ogmhesk03:ogmhesk03:32:32|ogmhesk06:ogmhesk06:64:64|ogmhesk07:ogmhesk07:64:32|ogmhesk08:ogmhesk08:64:16|ogmhesk09:ogmhesk09:64:32|ogmhesk41:ogmhesk41:256:128|ogmhesk42:ogmhesk42:32:64|ogmhesk44:ogmhesk44:32:32|ogmhesk45:ogmhesk45:32:32|ogmhnsk01:ogmhnsk01:64:64|ogmhnsk02:ogmhnsk02:64:64|ogmhnsk03:ogmhnsk03:32:32|ogmlg0001:ogmlg0001:256:128|ogmlgsk02:ogmlgsk02:128:128|ogmlgsk03:ogmlgsk03:128:32|ogmuask01:ogmuask01:64:64|ogmuask02:ogmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.309375,
+ "y": 0,
+ "width": 0.303125,
+ "height": 0.4152777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 46,
+ 22,
+ 15,
+ 47,
+ 30,
+ 19,
+ 36,
+ 10,
+ 8,
+ 35,
+ 14,
+ 11,
+ 29,
+ 19,
+ 14,
+ 85,
+ 56,
+ 40,
+ 112,
+ 72,
+ 47,
+ 95,
+ 69,
+ 53,
+ 68,
+ 38,
+ 26,
+ 60,
+ 37,
+ 23,
+ 41,
+ 25,
+ 19,
+ 38,
+ 28,
+ 23,
+ 32,
+ 23,
+ 18,
+ 98,
+ 65,
+ 45,
+ 126,
+ 89,
+ 64,
+ 66,
+ 50,
+ 39,
+ 21,
+ 13,
+ 8,
+ 92,
+ 59,
+ 40,
+ 44,
+ 29,
+ 21,
+ 30,
+ 22,
+ 17,
+ 54,
+ 38,
+ 30,
+ 108,
+ 72,
+ 50,
+ 123,
+ 85,
+ 61,
+ 59,
+ 42,
+ 31,
+ 0,
+ 0,
+ 0,
+ 83,
+ 57,
+ 40,
+ 63,
+ 43,
+ 32,
+ 38,
+ 27,
+ 21,
+ 45,
+ 35,
+ 29,
+ 118,
+ 82,
+ 61,
+ 151,
+ 117,
+ 96,
+ 32,
+ 24,
+ 19,
+ 0,
+ 0,
+ 0,
+ 8,
+ 5,
+ 4,
+ 97,
+ 67,
+ 48,
+ 49,
+ 38,
+ 32,
+ 53,
+ 41,
+ 34,
+ 75,
+ 55,
+ 46,
+ 32,
+ 24,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 33,
+ 24,
+ 18,
+ 69,
+ 54,
+ 45,
+ 110,
+ 82,
+ 64,
+ 99,
+ 72,
+ 55,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 121,
+ 88,
+ 66,
+ 105,
+ 72,
+ 52,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 112,
+ 89,
+ 73,
+ 106,
+ 83,
+ 67,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "trm|face=0|texture=0|helm=0": {
+ "meshCount": 27,
+ "vertexCount": 5322,
+ "skeletonCount": 1,
+ "boneCount": 92,
+ "materialSignature": "trmchsk01:trmchsk01:64:32|trmchsk02:trmchsk02:64:128|trmchsk03:trmchsk03:256:256|trmfask01:trmfask01:128:128|trmfask02:trmfask02:128:128|trmftsk01:trmftsk01:64:64|trmftsk02:trmftsk02:32:64|trmftsk03:trmftsk03:64:16|trmftsk04:trmftsk04 (base color):64:16|trmftsk05:trmftsk05:128:128|trmhesk01:trmhesk01:256:128|trmhesk02:trmhesk02:32:64|trmhesk03:trmhesk03:32:32|trmhesk04:trmhesk04:64:64|trmhesk05:trmhesk05:32:32|trmhesk06:trmhesk06:64:64|trmhesk07:trmhesk07:64:32|trmhesk08:trmhesk08:64:16|trmhnsk01:trmhnsk01:64:64|trmhnsk02:trmhnsk02:64:64|trmhnsk03:trmhnsk03:32:32|trmhnsk04:trmhnsk04 (base color):64:16|trmlg0001:trmlg0001:256:128|trmlgsk02:trmlgsk02:128:128|trmlgsk03:trmlgsk03:128:32|trmuask01:trmuask01:64:64|trmuask02:trmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.3671875,
+ "y": 0,
+ "width": 0.22109375,
+ "height": 0.41944444444444445
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 69,
+ 56,
+ 28,
+ 64,
+ 51,
+ 26,
+ 20,
+ 18,
+ 13,
+ 37,
+ 30,
+ 16,
+ 59,
+ 49,
+ 27,
+ 53,
+ 45,
+ 28,
+ 60,
+ 53,
+ 33,
+ 43,
+ 35,
+ 19,
+ 76,
+ 62,
+ 32,
+ 78,
+ 65,
+ 38,
+ 20,
+ 15,
+ 5,
+ 31,
+ 21,
+ 6,
+ 46,
+ 35,
+ 14,
+ 59,
+ 47,
+ 26,
+ 40,
+ 32,
+ 17,
+ 0,
+ 0,
+ 0,
+ 62,
+ 47,
+ 19,
+ 61,
+ 48,
+ 23,
+ 25,
+ 18,
+ 8,
+ 28,
+ 18,
+ 5,
+ 38,
+ 26,
+ 8,
+ 54,
+ 41,
+ 19,
+ 39,
+ 27,
+ 10,
+ 53,
+ 43,
+ 22,
+ 46,
+ 35,
+ 15,
+ 76,
+ 62,
+ 34,
+ 36,
+ 26,
+ 11,
+ 30,
+ 20,
+ 5,
+ 76,
+ 64,
+ 38,
+ 80,
+ 68,
+ 44,
+ 47,
+ 36,
+ 16,
+ 54,
+ 43,
+ 22,
+ 12,
+ 10,
+ 3,
+ 93,
+ 79,
+ 44,
+ 88,
+ 74,
+ 40,
+ 69,
+ 56,
+ 26,
+ 78,
+ 65,
+ 36,
+ 80,
+ 69,
+ 44,
+ 62,
+ 51,
+ 27,
+ 52,
+ 40,
+ 17,
+ 0,
+ 0,
+ 0,
+ 104,
+ 91,
+ 57,
+ 104,
+ 88,
+ 49,
+ 105,
+ 91,
+ 59,
+ 57,
+ 46,
+ 23,
+ 54,
+ 44,
+ 22,
+ 71,
+ 58,
+ 29,
+ 42,
+ 35,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 38,
+ 32,
+ 19,
+ 79,
+ 67,
+ 36,
+ 48,
+ 39,
+ 19,
+ 54,
+ 44,
+ 24,
+ 108,
+ 92,
+ 55,
+ 21,
+ 18,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 32,
+ 27,
+ 14,
+ 49,
+ 44,
+ 25,
+ 50,
+ 42,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "trm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 27,
+ "vertexCount": 5322,
+ "skeletonCount": 1,
+ "boneCount": 92,
+ "materialSignature": "trmchsk01:trmchsk01:64:32|trmchsk02:trmchsk02:64:128|trmchsk03:trmchsk03:256:256|trmfask01:trmfask01:128:128|trmfask02:trmfask02:128:128|trmftsk01:trmftsk01:64:64|trmftsk02:trmftsk02:32:64|trmftsk03:trmftsk03:64:16|trmftsk04:trmftsk04 (base color):64:16|trmftsk05:trmftsk05:128:128|trmhesk01:trmhesk01:256:128|trmhesk02:trmhesk02:32:64|trmhesk03:trmhesk03:32:32|trmhesk04:trmhesk04:64:64|trmhesk05:trmhesk05:32:32|trmhesk06:trmhesk06:64:64|trmhesk07:trmhesk07:64:32|trmhesk08:trmhesk08:64:16|trmhnsk01:trmhnsk01:64:64|trmhnsk02:trmhnsk02:64:64|trmhnsk03:trmhnsk03:32:32|trmhnsk04:trmhnsk04 (base color):64:16|trmlg0001:trmlg0001:256:128|trmlgsk02:trmlgsk02:128:128|trmlgsk03:trmlgsk03:128:32|trmuask01:trmuask01:64:64|trmuask02:trmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.36171875,
+ "y": 0,
+ "width": 0.2671875,
+ "height": 0.4097222222222222
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 92,
+ 78,
+ 45,
+ 59,
+ 45,
+ 19,
+ 41,
+ 32,
+ 15,
+ 71,
+ 57,
+ 26,
+ 53,
+ 40,
+ 16,
+ 64,
+ 50,
+ 21,
+ 48,
+ 37,
+ 15,
+ 0,
+ 0,
+ 0,
+ 61,
+ 50,
+ 26,
+ 63,
+ 48,
+ 18,
+ 33,
+ 21,
+ 6,
+ 50,
+ 37,
+ 14,
+ 61,
+ 49,
+ 23,
+ 63,
+ 51,
+ 24,
+ 39,
+ 30,
+ 13,
+ 23,
+ 16,
+ 5,
+ 13,
+ 9,
+ 3,
+ 61,
+ 47,
+ 17,
+ 35,
+ 24,
+ 8,
+ 42,
+ 29,
+ 10,
+ 58,
+ 44,
+ 18,
+ 60,
+ 48,
+ 21,
+ 55,
+ 44,
+ 20,
+ 36,
+ 25,
+ 9,
+ 0,
+ 0,
+ 0,
+ 58,
+ 44,
+ 16,
+ 55,
+ 42,
+ 18,
+ 39,
+ 28,
+ 8,
+ 35,
+ 22,
+ 6,
+ 43,
+ 30,
+ 11,
+ 36,
+ 26,
+ 8,
+ 54,
+ 42,
+ 17,
+ 0,
+ 0,
+ 0,
+ 26,
+ 19,
+ 7,
+ 55,
+ 40,
+ 14,
+ 38,
+ 28,
+ 9,
+ 46,
+ 32,
+ 11,
+ 64,
+ 52,
+ 26,
+ 53,
+ 40,
+ 16,
+ 26,
+ 19,
+ 7,
+ 0,
+ 0,
+ 0,
+ 52,
+ 46,
+ 28,
+ 90,
+ 76,
+ 43,
+ 70,
+ 57,
+ 27,
+ 82,
+ 68,
+ 34,
+ 71,
+ 60,
+ 33,
+ 88,
+ 73,
+ 38,
+ 24,
+ 20,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 53,
+ 46,
+ 28,
+ 69,
+ 55,
+ 25,
+ 108,
+ 93,
+ 54,
+ 62,
+ 53,
+ 30,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 65,
+ 54,
+ 27,
+ 70,
+ 60,
+ 35,
+ 26,
+ 22,
+ 10,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "dwm|face=0|texture=0|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5118,
+ "skeletonCount": 1,
+ "boneCount": 95,
+ "materialSignature": "dwmch0002:dwmch0002:64:128|dwmch0003:dwmch0003:256:256|dwmchsk01:dwmchsk01:64:32|dwmfa0001:dwmfa0001:128:128|dwmfa0002:dwmfa0002:128:128|dwmft0001:dwmft0001:64:64|dwmft0002:dwmft0002:32:64|dwmft0003:dwmft0003:64:16|dwmft0004:dwmft0004:128:128|dwmhesk01:dwmhesk01:256:128|dwmhesk02:dwmhesk02:32:64|dwmhesk03:dwmhesk03:32:32|dwmhesk04:dwmhesk04:32:32|dwmhesk05:dwmhesk05:32:32|dwmhesk06:dwmhesk06:64:64|dwmhesk07:dwmhesk07:64:32|dwmhesk08:dwmhesk08:64:16|dwmhnsk01:dwmhnsk01:64:64|dwmhnsk02:dwmhnsk02:64:64|dwmhnsk03:dwmhnsk03:32:32|dwmlg0001:dwmlg0001:256:128|dwmlg0002:dwmlg0002:128:128|dwmlg0003:dwmlg0003:128:32|dwmua0001:dwmua0001:64:64|dwmuask02:dwmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hebeard1|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.33046875,
+ "y": 0,
+ "width": 0.3453125,
+ "height": 0.4236111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 103,
+ 71,
+ 51,
+ 48,
+ 38,
+ 30,
+ 6,
+ 9,
+ 11,
+ 12,
+ 13,
+ 26,
+ 12,
+ 13,
+ 25,
+ 12,
+ 12,
+ 24,
+ 36,
+ 21,
+ 12,
+ 104,
+ 69,
+ 48,
+ 116,
+ 70,
+ 43,
+ 124,
+ 84,
+ 60,
+ 35,
+ 27,
+ 29,
+ 25,
+ 28,
+ 49,
+ 20,
+ 22,
+ 39,
+ 15,
+ 16,
+ 31,
+ 51,
+ 40,
+ 31,
+ 49,
+ 41,
+ 33,
+ 98,
+ 65,
+ 45,
+ 130,
+ 86,
+ 60,
+ 91,
+ 69,
+ 64,
+ 44,
+ 49,
+ 77,
+ 41,
+ 43,
+ 65,
+ 32,
+ 35,
+ 60,
+ 110,
+ 75,
+ 54,
+ 52,
+ 33,
+ 22,
+ 35,
+ 24,
+ 18,
+ 141,
+ 96,
+ 70,
+ 63,
+ 49,
+ 47,
+ 31,
+ 34,
+ 56,
+ 27,
+ 29,
+ 47,
+ 59,
+ 54,
+ 70,
+ 137,
+ 93,
+ 67,
+ 25,
+ 15,
+ 10,
+ 0,
+ 0,
+ 0,
+ 32,
+ 21,
+ 14,
+ 31,
+ 21,
+ 16,
+ 97,
+ 64,
+ 47,
+ 77,
+ 52,
+ 44,
+ 36,
+ 30,
+ 30,
+ 119,
+ 87,
+ 67,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 43,
+ 26,
+ 17,
+ 115,
+ 73,
+ 50,
+ 116,
+ 75,
+ 59,
+ 55,
+ 36,
+ 26,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 55,
+ 36,
+ 25,
+ 97,
+ 62,
+ 43,
+ 114,
+ 81,
+ 65,
+ 31,
+ 21,
+ 16,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 16,
+ 12,
+ 106,
+ 72,
+ 52,
+ 114,
+ 79,
+ 61,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "dwm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 25,
+ "vertexCount": 5118,
+ "skeletonCount": 1,
+ "boneCount": 95,
+ "materialSignature": "dwmch0002:dwmch0002:64:128|dwmch0003:dwmch0003:256:256|dwmchsk01:dwmchsk01:64:32|dwmfa0001:dwmfa0001:128:128|dwmfa0002:dwmfa0002:128:128|dwmft0001:dwmft0001:64:64|dwmft0002:dwmft0002:32:64|dwmft0003:dwmft0003:64:16|dwmft0004:dwmft0004:128:128|dwmhesk01:dwmhesk01:256:128|dwmhesk02:dwmhesk02:32:64|dwmhesk03:dwmhesk03:32:32|dwmhesk04:dwmhesk04:32:32|dwmhesk05:dwmhesk05:32:32|dwmhesk06:dwmhesk06:64:64|dwmhesk07:dwmhesk07:64:32|dwmhesk08:dwmhesk08:64:16|dwmhnsk01:dwmhnsk01:64:64|dwmhnsk02:dwmhnsk02:64:64|dwmhnsk03:dwmhnsk03:32:32|dwmlg0001:dwmlg0001:256:128|dwmlg0002:dwmlg0002:128:128|dwmlg0003:dwmlg0003:128:32|dwmua0001:dwmua0001:64:64|dwmuask02:dwmuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|gauntl_point|gauntr_point|hair_point|head_point|hebeard1|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.3078125,
+ "y": 0,
+ "width": 0.35703125,
+ "height": 0.4263888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 102,
+ 71,
+ 50,
+ 70,
+ 40,
+ 24,
+ 11,
+ 14,
+ 29,
+ 10,
+ 12,
+ 28,
+ 10,
+ 12,
+ 28,
+ 10,
+ 12,
+ 23,
+ 24,
+ 22,
+ 18,
+ 38,
+ 28,
+ 21,
+ 90,
+ 55,
+ 35,
+ 100,
+ 58,
+ 37,
+ 22,
+ 20,
+ 35,
+ 12,
+ 15,
+ 32,
+ 13,
+ 16,
+ 34,
+ 14,
+ 16,
+ 33,
+ 76,
+ 44,
+ 28,
+ 76,
+ 44,
+ 27,
+ 32,
+ 20,
+ 13,
+ 129,
+ 81,
+ 55,
+ 34,
+ 26,
+ 35,
+ 23,
+ 27,
+ 50,
+ 29,
+ 33,
+ 58,
+ 24,
+ 25,
+ 45,
+ 81,
+ 48,
+ 30,
+ 77,
+ 44,
+ 28,
+ 0,
+ 0,
+ 0,
+ 132,
+ 82,
+ 55,
+ 98,
+ 67,
+ 58,
+ 52,
+ 56,
+ 87,
+ 54,
+ 56,
+ 84,
+ 74,
+ 66,
+ 84,
+ 123,
+ 77,
+ 52,
+ 33,
+ 21,
+ 15,
+ 0,
+ 0,
+ 0,
+ 39,
+ 26,
+ 18,
+ 67,
+ 44,
+ 32,
+ 65,
+ 62,
+ 85,
+ 68,
+ 57,
+ 70,
+ 57,
+ 44,
+ 43,
+ 44,
+ 29,
+ 20,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 106,
+ 64,
+ 41,
+ 127,
+ 80,
+ 53,
+ 57,
+ 34,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 115,
+ 74,
+ 50,
+ 130,
+ 90,
+ 66,
+ 60,
+ 42,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 66,
+ 45,
+ 33,
+ 104,
+ 74,
+ 56,
+ 46,
+ 33,
+ 25,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "ikm|face=0|texture=0|helm=0": {
+ "meshCount": 29,
+ "vertexCount": 6498,
+ "skeletonCount": 1,
+ "boneCount": 105,
+ "materialSignature": "ikmch0002:ikmch0002:64:128|ikmch0003:ikmch0003:256:256|ikmchsk01:ikmchsk01:64:32|ikmfa0001:ikmfa0001:128:128|ikmfa0002:ikmfa0002:128:128|ikmftsk01:ikmftsk01:64:64|ikmftsk02:ikmftsk02:32:64|ikmftsk03:ikmftsk03:64:16|ikmftsk04:ikmftsk04:32:32|ikmftsk05:ikmftsk05:128:128|ikmhesk01:ikmhesk01:128:128|ikmhesk02:ikmhesk02:128:64|ikmhesk03:ikmhesk03:128:64|ikmhesk04:ikmhesk04:128:64|ikmhesk05:ikmhesk05:256:128|ikmhesk06:ikmhesk06:32:32|ikmhesk07:ikmhesk07:64:32|ikmhesk08:ikmhesk08:64:16|ikmhnsk01:ikmhnsk01:64:64|ikmhnsk02:ikmhnsk02:64:64|ikmhnsk03:ikmhnsk03:32:32|ikmhnsk04:ikmhnsk04:32:32|ikmlg0001:ikmlg0001:256:128|ikmlgsk02:ikmlgsk02:128:128|ikmlgsk03:ikmlgsk03:128:32|ikmtask01:ikmtask01:128:256|ikmuask01:ikmuask01:64:64|ikmuask02:ikmuask02:128:128|ikmuask03:ikmuask03:32:32",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebip01 tail|pebip01 tail1|pebip01 tail2|pebip01 tail3|pebip01 tail4|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.37421875,
+ "y": 0,
+ "width": 0.26796875,
+ "height": 0.4111111111111111
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 33,
+ 40,
+ 39,
+ 38,
+ 34,
+ 27,
+ 49,
+ 31,
+ 17,
+ 34,
+ 40,
+ 38,
+ 53,
+ 53,
+ 50,
+ 76,
+ 68,
+ 58,
+ 73,
+ 72,
+ 70,
+ 0,
+ 0,
+ 0,
+ 9,
+ 10,
+ 10,
+ 61,
+ 29,
+ 11,
+ 74,
+ 46,
+ 22,
+ 48,
+ 32,
+ 21,
+ 51,
+ 52,
+ 47,
+ 66,
+ 63,
+ 53,
+ 74,
+ 69,
+ 63,
+ 16,
+ 13,
+ 10,
+ 0,
+ 0,
+ 0,
+ 65,
+ 46,
+ 23,
+ 56,
+ 48,
+ 30,
+ 45,
+ 22,
+ 9,
+ 33,
+ 37,
+ 35,
+ 47,
+ 51,
+ 48,
+ 25,
+ 26,
+ 23,
+ 34,
+ 39,
+ 37,
+ 31,
+ 31,
+ 24,
+ 72,
+ 66,
+ 47,
+ 57,
+ 53,
+ 39,
+ 29,
+ 33,
+ 30,
+ 32,
+ 38,
+ 37,
+ 13,
+ 16,
+ 16,
+ 19,
+ 19,
+ 17,
+ 29,
+ 34,
+ 33,
+ 46,
+ 47,
+ 41,
+ 56,
+ 56,
+ 44,
+ 58,
+ 54,
+ 41,
+ 50,
+ 56,
+ 54,
+ 37,
+ 43,
+ 42,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 10,
+ 12,
+ 12,
+ 71,
+ 72,
+ 65,
+ 50,
+ 51,
+ 44,
+ 52,
+ 55,
+ 48,
+ 52,
+ 51,
+ 44,
+ 9,
+ 11,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 29,
+ 30,
+ 27,
+ 41,
+ 43,
+ 37,
+ 31,
+ 35,
+ 33,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 37,
+ 36,
+ 33,
+ 42,
+ 44,
+ 39,
+ 34,
+ 35,
+ 32,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "ikm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 29,
+ "vertexCount": 6498,
+ "skeletonCount": 1,
+ "boneCount": 105,
+ "materialSignature": "ikmch0002:ikmch0002:64:128|ikmch0003:ikmch0003:256:256|ikmchsk01:ikmchsk01:64:32|ikmfa0001:ikmfa0001:128:128|ikmfa0002:ikmfa0002:128:128|ikmftsk01:ikmftsk01:64:64|ikmftsk02:ikmftsk02:32:64|ikmftsk03:ikmftsk03:64:16|ikmftsk04:ikmftsk04:32:32|ikmftsk05:ikmftsk05:128:128|ikmhesk01:ikmhesk01:128:128|ikmhesk02:ikmhesk02:128:64|ikmhesk03:ikmhesk03:128:64|ikmhesk04:ikmhesk04:128:64|ikmhesk05:ikmhesk05:256:128|ikmhesk06:ikmhesk06:32:32|ikmhesk07:ikmhesk07:64:32|ikmhesk08:ikmhesk08:64:16|ikmhnsk01:ikmhnsk01:64:64|ikmhnsk02:ikmhnsk02:64:64|ikmhnsk03:ikmhnsk03:32:32|ikmhnsk04:ikmhnsk04:32:32|ikmlg0001:ikmlg0001:256:128|ikmlgsk02:ikmlgsk02:128:128|ikmlgsk03:ikmlgsk03:128:32|ikmtask01:ikmtask01:128:256|ikmuask01:ikmuask01:64:64|ikmuask02:ikmuask02:128:128|ikmuask03:ikmuask03:32:32",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebip01 tail|pebip01 tail1|pebip01 tail2|pebip01 tail3|pebip01 tail4|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.36328125,
+ "y": 0,
+ "width": 0.3046875,
+ "height": 0.40694444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 87,
+ 86,
+ 84,
+ 64,
+ 44,
+ 33,
+ 71,
+ 57,
+ 46,
+ 63,
+ 35,
+ 18,
+ 21,
+ 8,
+ 2,
+ 0,
+ 0,
+ 0,
+ 33,
+ 32,
+ 28,
+ 66,
+ 59,
+ 51,
+ 39,
+ 38,
+ 34,
+ 50,
+ 43,
+ 35,
+ 90,
+ 91,
+ 88,
+ 53,
+ 49,
+ 39,
+ 13,
+ 11,
+ 7,
+ 3,
+ 4,
+ 4,
+ 23,
+ 27,
+ 25,
+ 23,
+ 26,
+ 24,
+ 26,
+ 30,
+ 28,
+ 30,
+ 32,
+ 28,
+ 31,
+ 39,
+ 38,
+ 36,
+ 37,
+ 32,
+ 18,
+ 21,
+ 18,
+ 24,
+ 27,
+ 24,
+ 27,
+ 32,
+ 30,
+ 21,
+ 20,
+ 18,
+ 19,
+ 24,
+ 23,
+ 31,
+ 28,
+ 21,
+ 25,
+ 30,
+ 28,
+ 36,
+ 29,
+ 19,
+ 20,
+ 24,
+ 22,
+ 27,
+ 33,
+ 30,
+ 19,
+ 23,
+ 22,
+ 0,
+ 0,
+ 0,
+ 10,
+ 11,
+ 10,
+ 31,
+ 35,
+ 33,
+ 46,
+ 42,
+ 34,
+ 46,
+ 44,
+ 36,
+ 33,
+ 40,
+ 37,
+ 27,
+ 32,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 10,
+ 10,
+ 57,
+ 56,
+ 48,
+ 57,
+ 57,
+ 49,
+ 42,
+ 42,
+ 38,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 37,
+ 37,
+ 31,
+ 32,
+ 38,
+ 35,
+ 21,
+ 23,
+ 21,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 18,
+ 19,
+ 18,
+ 51,
+ 54,
+ 48,
+ 36,
+ 36,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "huf|face=0|texture=0|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5088,
+ "skeletonCount": 1,
+ "boneCount": 110,
+ "materialSignature": "hufch0002:hufch0002:64:128|hufch0003:hufch0003:256:256|hufchsk01:hufchsk01:64:32|huffask01:huffask01:128:128|huffask02:huffask02:128:128|hufft0001:hufft0001:64:64|hufft0002:hufft0002:32:64|hufft0003:hufft0003:64:16|hufft0004:hufft0004:128:128|hufhesk01:hufhesk01:256:128|hufhesk02:hufhesk02:32:64|hufhesk03:hufhesk03:32:32|hufhesk04:hufhesk04:32:64|hufhesk05:hufhesk05:32:32|hufhesk06:hufhesk06:64:64|hufhesk07:hufhesk07:64:32|hufhesk08:hufhesk08:64:16|hufhnsk01:hufhnsk01:64:64|hufhnsk02:hufhnsk02:64:64|hufhnsk03:hufhnsk03:32:32|huflg0001:huflg0001:256:128|huflg0002:huflg0002:128:128|huflgsk03:huflgsk03:128:32|hufua0001:hufua0001:64:64|hufua0002:hufua0002:128:128",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chbl|chbr|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|hair_point01|head_point|hehair1|hehair2|hehair3|hehair4|hehair6|hehair7|hehair8|hehair9|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint01|robepoint02|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.41875,
+ "y": 0,
+ "width": 0.15390625,
+ "height": 0.4027777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 133,
+ 102,
+ 77,
+ 72,
+ 46,
+ 30,
+ 58,
+ 29,
+ 14,
+ 66,
+ 32,
+ 15,
+ 64,
+ 31,
+ 15,
+ 62,
+ 32,
+ 16,
+ 71,
+ 45,
+ 28,
+ 102,
+ 72,
+ 51,
+ 130,
+ 100,
+ 77,
+ 68,
+ 46,
+ 31,
+ 17,
+ 12,
+ 43,
+ 9,
+ 9,
+ 47,
+ 11,
+ 10,
+ 51,
+ 46,
+ 28,
+ 28,
+ 94,
+ 68,
+ 48,
+ 60,
+ 46,
+ 36,
+ 67,
+ 51,
+ 38,
+ 119,
+ 86,
+ 59,
+ 14,
+ 13,
+ 57,
+ 37,
+ 44,
+ 67,
+ 42,
+ 48,
+ 72,
+ 41,
+ 40,
+ 52,
+ 102,
+ 75,
+ 54,
+ 18,
+ 15,
+ 13,
+ 27,
+ 23,
+ 20,
+ 132,
+ 97,
+ 71,
+ 83,
+ 77,
+ 77,
+ 196,
+ 204,
+ 205,
+ 139,
+ 126,
+ 111,
+ 141,
+ 133,
+ 121,
+ 149,
+ 157,
+ 157,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 136,
+ 126,
+ 113,
+ 201,
+ 209,
+ 209,
+ 183,
+ 183,
+ 178,
+ 114,
+ 86,
+ 62,
+ 178,
+ 173,
+ 164,
+ 76,
+ 77,
+ 75,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 46,
+ 47,
+ 47,
+ 104,
+ 106,
+ 106,
+ 108,
+ 83,
+ 62,
+ 111,
+ 83,
+ 62,
+ 119,
+ 115,
+ 110,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 8,
+ 6,
+ 5,
+ 113,
+ 78,
+ 54,
+ 114,
+ 87,
+ 65,
+ 55,
+ 43,
+ 33,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 113,
+ 86,
+ 66,
+ 100,
+ 76,
+ 57,
+ 48,
+ 37,
+ 28,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "huf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 25,
+ "vertexCount": 5088,
+ "skeletonCount": 1,
+ "boneCount": 110,
+ "materialSignature": "hufch0002:hufch0002:64:128|hufch0003:hufch0003:256:256|hufchsk01:hufchsk01:64:32|huffask01:huffask01:128:128|huffask02:huffask02:128:128|hufft0001:hufft0001:64:64|hufft0002:hufft0002:32:64|hufft0003:hufft0003:64:16|hufft0004:hufft0004:128:128|hufhesk01:hufhesk01:256:128|hufhesk02:hufhesk02:32:64|hufhesk03:hufhesk03:32:32|hufhesk04:hufhesk04:32:64|hufhesk05:hufhesk05:32:32|hufhesk06:hufhesk06:64:64|hufhesk07:hufhesk07:64:32|hufhesk08:hufhesk08:64:16|hufhnsk01:hufhnsk01:64:64|hufhnsk02:hufhnsk02:64:64|hufhnsk03:hufhnsk03:32:32|huflg0001:huflg0001:256:128|huflg0002:huflg0002:128:128|huflgsk03:huflgsk03:128:32|hufua0001:hufua0001:64:64|hufua0002:hufua0002:128:128",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chbl|chbr|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|hair_point01|head_point|hehair1|hehair2|hehair3|hehair4|hehair6|hehair7|hehair8|hehair9|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint01|robepoint02|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.43125,
+ "y": 0,
+ "width": 0.13671875,
+ "height": 0.4027777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 127,
+ 96,
+ 72,
+ 107,
+ 77,
+ 55,
+ 58,
+ 30,
+ 15,
+ 53,
+ 28,
+ 20,
+ 60,
+ 43,
+ 34,
+ 54,
+ 33,
+ 23,
+ 59,
+ 31,
+ 17,
+ 42,
+ 29,
+ 18,
+ 61,
+ 46,
+ 34,
+ 124,
+ 90,
+ 62,
+ 48,
+ 34,
+ 46,
+ 12,
+ 12,
+ 60,
+ 56,
+ 59,
+ 74,
+ 51,
+ 53,
+ 73,
+ 62,
+ 45,
+ 51,
+ 94,
+ 68,
+ 47,
+ 25,
+ 20,
+ 15,
+ 145,
+ 111,
+ 83,
+ 71,
+ 54,
+ 59,
+ 12,
+ 12,
+ 60,
+ 36,
+ 37,
+ 67,
+ 81,
+ 86,
+ 89,
+ 41,
+ 31,
+ 56,
+ 113,
+ 82,
+ 57,
+ 0,
+ 0,
+ 0,
+ 140,
+ 107,
+ 80,
+ 121,
+ 102,
+ 85,
+ 88,
+ 100,
+ 117,
+ 72,
+ 77,
+ 96,
+ 94,
+ 99,
+ 98,
+ 25,
+ 27,
+ 58,
+ 79,
+ 62,
+ 50,
+ 0,
+ 0,
+ 0,
+ 58,
+ 53,
+ 47,
+ 198,
+ 204,
+ 203,
+ 209,
+ 214,
+ 214,
+ 196,
+ 199,
+ 198,
+ 176,
+ 178,
+ 176,
+ 188,
+ 196,
+ 196,
+ 66,
+ 66,
+ 62,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 19,
+ 13,
+ 10,
+ 106,
+ 82,
+ 62,
+ 136,
+ 113,
+ 93,
+ 112,
+ 104,
+ 96,
+ 74,
+ 76,
+ 76,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 54,
+ 37,
+ 26,
+ 124,
+ 88,
+ 62,
+ 142,
+ 106,
+ 81,
+ 65,
+ 48,
+ 36,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 50,
+ 38,
+ 29,
+ 121,
+ 93,
+ 73,
+ 135,
+ 105,
+ 84,
+ 62,
+ 50,
+ 41,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "elm|face=0|texture=0|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5034,
+ "skeletonCount": 1,
+ "boneCount": 104,
+ "materialSignature": "elmch0002:elmch0002:64:128|elmch0003:elmch0003:256:256|elmchsk01:elmchsk01:64:32|elmfask01:elmfask01:128:128|elmfask02:elmfask02:128:128|elmft0001:elmft0001:64:64|elmft0002:elmft0002:32:64|elmft0003:elmft0003:64:16|elmft0004:elmft0004:128:128|elmhesk01:elmhesk01:256:128|elmhesk02:elmhesk02:32:64|elmhesk03:elmhesk03:32:32|elmhesk04:elmhesk04:32:64|elmhesk05:elmhesk05:32:32|elmhesk06:elmhesk06:64:64|elmhesk07:elmhesk07:64:32|elmhesk08:elmhesk08:64:16|elmhnsk01:elmhnsk01:64:64|elmhnsk02:elmhnsk02:64:64|elmhnsk03:elmhnsk03:32:32|elmlg0001:elmlg0001:256:128|elmlg0002:elmlg0002:128:128|elmlg0003:elmlg0003:128:32|elmua0002:elmua0002:128:128|elmuask01:elmuask01:64:64",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|hair_point01|head_point|hebeard1|hebeard2|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.42421875,
+ "y": 0,
+ "width": 0.20625,
+ "height": 0.4013888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 137,
+ 93,
+ 61,
+ 57,
+ 35,
+ 23,
+ 48,
+ 27,
+ 15,
+ 50,
+ 29,
+ 17,
+ 53,
+ 30,
+ 18,
+ 41,
+ 21,
+ 11,
+ 0,
+ 0,
+ 0,
+ 57,
+ 36,
+ 23,
+ 87,
+ 58,
+ 38,
+ 53,
+ 34,
+ 20,
+ 81,
+ 51,
+ 32,
+ 100,
+ 66,
+ 45,
+ 80,
+ 49,
+ 29,
+ 28,
+ 16,
+ 8,
+ 48,
+ 33,
+ 22,
+ 142,
+ 96,
+ 62,
+ 66,
+ 44,
+ 29,
+ 101,
+ 67,
+ 41,
+ 63,
+ 56,
+ 28,
+ 81,
+ 64,
+ 37,
+ 20,
+ 41,
+ 5,
+ 26,
+ 22,
+ 9,
+ 107,
+ 72,
+ 46,
+ 58,
+ 39,
+ 26,
+ 0,
+ 0,
+ 0,
+ 52,
+ 47,
+ 19,
+ 86,
+ 71,
+ 46,
+ 65,
+ 62,
+ 35,
+ 31,
+ 45,
+ 7,
+ 45,
+ 43,
+ 21,
+ 119,
+ 80,
+ 51,
+ 0,
+ 0,
+ 0,
+ 70,
+ 52,
+ 37,
+ 112,
+ 85,
+ 53,
+ 106,
+ 76,
+ 53,
+ 101,
+ 75,
+ 50,
+ 111,
+ 81,
+ 50,
+ 167,
+ 123,
+ 90,
+ 8,
+ 9,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 71,
+ 55,
+ 35,
+ 94,
+ 67,
+ 46,
+ 113,
+ 80,
+ 58,
+ 70,
+ 62,
+ 32,
+ 85,
+ 61,
+ 44,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 19,
+ 14,
+ 10,
+ 125,
+ 84,
+ 57,
+ 125,
+ 90,
+ 68,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 21,
+ 14,
+ 122,
+ 87,
+ 60,
+ 116,
+ 89,
+ 67,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "elm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 25,
+ "vertexCount": 5034,
+ "skeletonCount": 1,
+ "boneCount": 104,
+ "materialSignature": "elmch0002:elmch0002:64:128|elmch0003:elmch0003:256:256|elmchsk01:elmchsk01:64:32|elmfask01:elmfask01:128:128|elmfask02:elmfask02:128:128|elmft0001:elmft0001:64:64|elmft0002:elmft0002:32:64|elmft0003:elmft0003:64:16|elmft0004:elmft0004:128:128|elmhesk01:elmhesk01:256:128|elmhesk02:elmhesk02:32:64|elmhesk03:elmhesk03:32:32|elmhesk04:elmhesk04:32:64|elmhesk05:elmhesk05:32:32|elmhesk06:elmhesk06:64:64|elmhesk07:elmhesk07:64:32|elmhesk08:elmhesk08:64:16|elmhnsk01:elmhnsk01:64:64|elmhnsk02:elmhnsk02:64:64|elmhnsk03:elmhnsk03:32:32|elmlg0001:elmlg0001:256:128|elmlg0002:elmlg0002:128:128|elmlg0003:elmlg0003:128:32|elmua0002:elmua0002:128:128|elmuask01:elmuask01:64:64",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|hair_point01|head_point|hebeard1|hebeard2|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.35546875,
+ "y": 0,
+ "width": 0.21875,
+ "height": 0.4027777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 113,
+ 70,
+ 39,
+ 36,
+ 23,
+ 13,
+ 36,
+ 22,
+ 14,
+ 86,
+ 53,
+ 33,
+ 91,
+ 56,
+ 33,
+ 88,
+ 55,
+ 33,
+ 77,
+ 48,
+ 28,
+ 141,
+ 98,
+ 67,
+ 72,
+ 49,
+ 31,
+ 99,
+ 63,
+ 39,
+ 31,
+ 23,
+ 11,
+ 72,
+ 57,
+ 26,
+ 50,
+ 47,
+ 16,
+ 34,
+ 48,
+ 9,
+ 93,
+ 68,
+ 37,
+ 99,
+ 64,
+ 39,
+ 29,
+ 21,
+ 15,
+ 122,
+ 78,
+ 47,
+ 45,
+ 42,
+ 15,
+ 16,
+ 42,
+ 3,
+ 13,
+ 42,
+ 3,
+ 10,
+ 40,
+ 5,
+ 53,
+ 49,
+ 20,
+ 35,
+ 29,
+ 15,
+ 0,
+ 0,
+ 0,
+ 66,
+ 43,
+ 28,
+ 51,
+ 42,
+ 16,
+ 12,
+ 41,
+ 2,
+ 12,
+ 40,
+ 2,
+ 17,
+ 42,
+ 2,
+ 72,
+ 60,
+ 30,
+ 68,
+ 48,
+ 33,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 94,
+ 68,
+ 40,
+ 26,
+ 42,
+ 6,
+ 20,
+ 46,
+ 2,
+ 15,
+ 40,
+ 3,
+ 107,
+ 85,
+ 51,
+ 86,
+ 62,
+ 44,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 38,
+ 27,
+ 19,
+ 30,
+ 37,
+ 15,
+ 37,
+ 48,
+ 19,
+ 79,
+ 70,
+ 42,
+ 5,
+ 11,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 136,
+ 91,
+ 59,
+ 157,
+ 106,
+ 70,
+ 63,
+ 42,
+ 27,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 106,
+ 75,
+ 52,
+ 149,
+ 113,
+ 83,
+ 30,
+ 21,
+ 14,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "elf|face=0|texture=0|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5064,
+ "skeletonCount": 1,
+ "boneCount": 109,
+ "materialSignature": "elfch0002:elfch0002:64:128|elfch0003:elfch0003:256:256|elfchsk01:elfchsk01:64:32|elffask01:elffask01:128:128|elffask02:elffask02:128:128|elfft0001:elfft0001:64:64|elfft0002:elfft0002:32:64|elfft0003:elfft0003:64:16|elfft0004:elfft0004:128:128|elfhesk01:elfhesk01:256:128|elfhesk02:elfhesk02:32:64|elfhesk03:elfhesk03:32:32|elfhesk04:elfhesk04:32:64|elfhesk05:elfhesk05:32:32|elfhesk06:elfhesk06:64:64|elfhesk07:elfhesk07:64:32|elfhesk08:elfhesk08:64:16|elfhnsk01:elfhnsk01:64:64|elfhnsk02:elfhnsk02:64:64|elfhnsk03:elfhnsk03:32:32|elflg0001:elflg0001:256:128|elflg0002:elflg0002:128:128|elflg0003:elflg0003:128:32|elfuask01:elfuask01:64:64|elfuask02:elfuask02:128:128",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chbl|chbr|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hehair1|hehair2|hehair3|hehair4|hehair6|hehair7|hehair8|hehair9|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint01|robepoint02|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.33046875,
+ "y": 0,
+ "width": 0.2546875,
+ "height": 0.39305555555555555
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 91,
+ 62,
+ 43,
+ 23,
+ 14,
+ 9,
+ 0,
+ 0,
+ 0,
+ 29,
+ 17,
+ 11,
+ 118,
+ 76,
+ 50,
+ 107,
+ 69,
+ 45,
+ 29,
+ 16,
+ 9,
+ 124,
+ 89,
+ 65,
+ 74,
+ 51,
+ 36,
+ 121,
+ 87,
+ 62,
+ 30,
+ 21,
+ 15,
+ 25,
+ 15,
+ 9,
+ 115,
+ 76,
+ 49,
+ 113,
+ 73,
+ 46,
+ 20,
+ 14,
+ 10,
+ 137,
+ 98,
+ 71,
+ 0,
+ 0,
+ 0,
+ 36,
+ 27,
+ 20,
+ 115,
+ 80,
+ 54,
+ 48,
+ 34,
+ 18,
+ 35,
+ 35,
+ 17,
+ 32,
+ 32,
+ 16,
+ 68,
+ 49,
+ 31,
+ 72,
+ 52,
+ 37,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 71,
+ 52,
+ 38,
+ 84,
+ 56,
+ 33,
+ 47,
+ 45,
+ 24,
+ 42,
+ 44,
+ 22,
+ 109,
+ 79,
+ 54,
+ 33,
+ 25,
+ 19,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 146,
+ 105,
+ 75,
+ 105,
+ 80,
+ 49,
+ 70,
+ 61,
+ 33,
+ 154,
+ 115,
+ 86,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 43,
+ 33,
+ 25,
+ 98,
+ 76,
+ 52,
+ 100,
+ 72,
+ 49,
+ 39,
+ 30,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 58,
+ 41,
+ 30,
+ 116,
+ 81,
+ 58,
+ 25,
+ 18,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 58,
+ 42,
+ 31,
+ 118,
+ 88,
+ 66,
+ 18,
+ 13,
+ 10,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "elf|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 25,
+ "vertexCount": 5064,
+ "skeletonCount": 1,
+ "boneCount": 109,
+ "materialSignature": "elfch0002:elfch0002:64:128|elfch0003:elfch0003:256:256|elfchsk01:elfchsk01:64:32|elffask01:elffask01:128:128|elffask02:elffask02:128:128|elfft0001:elfft0001:64:64|elfft0002:elfft0002:32:64|elfft0003:elfft0003:64:16|elfft0004:elfft0004:128:128|elfhesk01:elfhesk01:256:128|elfhesk02:elfhesk02:32:64|elfhesk03:elfhesk03:32:32|elfhesk04:elfhesk04:32:64|elfhesk05:elfhesk05:32:32|elfhesk06:elfhesk06:64:64|elfhesk07:elfhesk07:64:32|elfhesk08:elfhesk08:64:16|elfhnsk01:elfhnsk01:64:64|elfhnsk02:elfhnsk02:64:64|elfhnsk03:elfhnsk03:32:32|elflg0001:elflg0001:256:128|elflg0002:elflg0002:128:128|elflg0003:elflg0003:128:32|elfuask01:elfuask01:64:64|elfuask02:elfuask02:128:128",
+ "skeletonSignature": "back_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chbl|chbr|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hehair1|hehair2|hehair3|hehair4|hehair6|hehair7|hehair8|hehair9|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint01|robepoint02|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.415625,
+ "y": 0,
+ "width": 0.25859375,
+ "height": 0.3902777777777778
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 102,
+ 70,
+ 48,
+ 52,
+ 32,
+ 20,
+ 65,
+ 39,
+ 24,
+ 72,
+ 45,
+ 28,
+ 9,
+ 5,
+ 2,
+ 0,
+ 0,
+ 0,
+ 89,
+ 62,
+ 42,
+ 134,
+ 90,
+ 61,
+ 121,
+ 85,
+ 60,
+ 36,
+ 21,
+ 12,
+ 123,
+ 79,
+ 49,
+ 122,
+ 78,
+ 47,
+ 0,
+ 0,
+ 0,
+ 89,
+ 63,
+ 44,
+ 109,
+ 79,
+ 57,
+ 79,
+ 54,
+ 38,
+ 74,
+ 53,
+ 37,
+ 82,
+ 55,
+ 33,
+ 72,
+ 56,
+ 30,
+ 72,
+ 56,
+ 31,
+ 79,
+ 55,
+ 33,
+ 135,
+ 97,
+ 71,
+ 27,
+ 21,
+ 16,
+ 0,
+ 0,
+ 0,
+ 34,
+ 24,
+ 17,
+ 134,
+ 90,
+ 57,
+ 130,
+ 89,
+ 59,
+ 111,
+ 75,
+ 48,
+ 131,
+ 94,
+ 63,
+ 61,
+ 45,
+ 33,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 158,
+ 116,
+ 83,
+ 151,
+ 109,
+ 76,
+ 137,
+ 97,
+ 67,
+ 154,
+ 114,
+ 82,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 94,
+ 71,
+ 47,
+ 88,
+ 66,
+ 45,
+ 42,
+ 31,
+ 23,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 25,
+ 16,
+ 10,
+ 143,
+ 101,
+ 75,
+ 65,
+ 44,
+ 30,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 16,
+ 12,
+ 8,
+ 126,
+ 94,
+ 71,
+ 60,
+ 43,
+ 32,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fsg|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 15,
+ "vertexCount": 7920,
+ "skeletonCount": 1,
+ "boneCount": 105,
+ "materialSignature": "fsgch0001:fsgch0001 (base color):64:64|fsgch0002:fsgch0002 (base color):256:256|fsgch0003:fsgch0003 (base color):256:128|fsgft0001:fsgft0001 (base color):128:64|fsghe0001:fsghe0001 (base color):256:256|fsghe0002:fsghe0002 (base color):32:32|fsghe0003:fsghe0003 (base color):128:64|fsghe0004:fsghe0004 (base color):128:128|fsghe0005:fsghe0005 (base color):16:32|fsghn0001:fsghn0001 (base color):64:32|fsghn0002:fsghn0002 (base color):64:64|fsglg0001:fsglg0001 (base color):128:128|fsglg0002:fsglg0002 (base color):64:128|fsgua0001:fsgua0001 (base color):64:128|fsgua0002:fsgua0002 (base color):64:128",
+ "skeletonSignature": "_breathpoint|bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_r|fn11l|fn11r|fn12l|fn12r|fn13l|fn13r|fn21l|fn21r|fn22l|fn22r|fn23l|fn23r|fn31l|fn31r|fn32l|fn32r|fn33l|fn33r|fn41l|fn41r|fn42l|fn42r|fn43l|fn43r|fn51l|fn51r|fn52l|fn52r|fn53l|fn53r|fo_l|fo_r|ha11l|ha11r|ha12l|ha12r|ha13l|ha13r|ha14l|ha14r|ha15l|ha15r|ha16l|ha16r|ha21l|ha21r|ha22l|ha22r|ha23l|ha23r|ha24l|ha24r|ha25l|ha25r|he|ja|l_point|ne|pe|r_point|root|sk1_l|sk1_r|sk2_l|sk2_r|sk3_l|sk3_r|sk4_l|sk4_r|skfr1|skfr2|skrr1|skrr2|sl1_l|sl1_r|sl2_l|sl2_r|sl3_l|sl3_r|sl4_l|sl4_r|sla_l|sla_r|slb_l|slb_r|slc_l|slc_r|sld_l|sld_r|st|th_l|th_r|to_l|to_r|wa",
+ "foregroundBounds": {
+ "x": 0.40234375,
+ "y": 0,
+ "width": 0.21015625,
+ "height": 0.5263888888888889
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 0,
+ 0,
+ 0,
+ 62,
+ 59,
+ 57,
+ 75,
+ 71,
+ 68,
+ 78,
+ 74,
+ 70,
+ 93,
+ 89,
+ 85,
+ 83,
+ 78,
+ 75,
+ 76,
+ 71,
+ 67,
+ 57,
+ 46,
+ 42,
+ 62,
+ 46,
+ 39,
+ 65,
+ 53,
+ 50,
+ 73,
+ 68,
+ 65,
+ 92,
+ 83,
+ 76,
+ 82,
+ 76,
+ 65,
+ 89,
+ 82,
+ 78,
+ 67,
+ 57,
+ 54,
+ 42,
+ 30,
+ 23,
+ 58,
+ 48,
+ 47,
+ 72,
+ 56,
+ 55,
+ 52,
+ 45,
+ 37,
+ 74,
+ 63,
+ 51,
+ 86,
+ 78,
+ 59,
+ 78,
+ 63,
+ 50,
+ 39,
+ 35,
+ 27,
+ 108,
+ 84,
+ 79,
+ 6,
+ 11,
+ 17,
+ 11,
+ 22,
+ 32,
+ 40,
+ 39,
+ 39,
+ 76,
+ 71,
+ 67,
+ 81,
+ 77,
+ 73,
+ 77,
+ 72,
+ 68,
+ 29,
+ 30,
+ 31,
+ 12,
+ 22,
+ 31,
+ 0,
+ 0,
+ 0,
+ 84,
+ 82,
+ 81,
+ 71,
+ 71,
+ 71,
+ 96,
+ 87,
+ 78,
+ 104,
+ 88,
+ 79,
+ 99,
+ 91,
+ 82,
+ 67,
+ 68,
+ 68,
+ 25,
+ 28,
+ 29,
+ 0,
+ 0,
+ 0,
+ 24,
+ 22,
+ 21,
+ 93,
+ 91,
+ 90,
+ 113,
+ 98,
+ 92,
+ 108,
+ 90,
+ 84,
+ 52,
+ 42,
+ 37,
+ 54,
+ 50,
+ 49,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 39,
+ 36,
+ 34,
+ 91,
+ 80,
+ 61,
+ 97,
+ 88,
+ 65,
+ 9,
+ 8,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 6,
+ 6,
+ 4,
+ 122,
+ 115,
+ 69,
+ 152,
+ 144,
+ 85,
+ 8,
+ 8,
+ 6,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fem|face=0|texture=0|helm=0": {
+ "meshCount": 15,
+ "vertexCount": 1470,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "femch0001:femch0001 (base color):64:128|femch0002:femch0002 (base color):32:16|femfa0001:femfa0001 (base color):32:64|femft0001:femft0001 (base color):64:32|femft0002:femft0002 (base color):8:8|femhe0001:femhe0001 (base color):128:64|femhe0002:femhe0002 (base color):64:32|femhe0003:femhe0003 (base color):32:32|femhe0005:femhe0005 (base color):32:32|femhn0001:femhn0001 (base color):64:32|femhn0002:femhn0002 (base color):32:32|femlg0001:femlg0001 (base color):128:64|femlg0002:femlg0002 (base color):64:64|femlg0003:femlg0003 (base color):64:32|femua0001:femua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.36796875,
+ "y": 0,
+ "width": 0.265625,
+ "height": 0.42777777777777776
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 64,
+ 42,
+ 14,
+ 0,
+ 0,
+ 0,
+ 84,
+ 77,
+ 67,
+ 62,
+ 45,
+ 22,
+ 70,
+ 56,
+ 38,
+ 101,
+ 105,
+ 107,
+ 0,
+ 0,
+ 0,
+ 68,
+ 45,
+ 15,
+ 86,
+ 60,
+ 19,
+ 59,
+ 38,
+ 2,
+ 28,
+ 19,
+ 6,
+ 96,
+ 68,
+ 21,
+ 91,
+ 64,
+ 19,
+ 25,
+ 16,
+ 4,
+ 64,
+ 42,
+ 4,
+ 95,
+ 74,
+ 40,
+ 86,
+ 73,
+ 55,
+ 155,
+ 116,
+ 72,
+ 76,
+ 63,
+ 42,
+ 139,
+ 103,
+ 40,
+ 138,
+ 102,
+ 40,
+ 63,
+ 56,
+ 46,
+ 146,
+ 118,
+ 97,
+ 84,
+ 68,
+ 53,
+ 34,
+ 30,
+ 29,
+ 103,
+ 89,
+ 87,
+ 91,
+ 86,
+ 78,
+ 145,
+ 104,
+ 32,
+ 142,
+ 101,
+ 30,
+ 89,
+ 87,
+ 82,
+ 92,
+ 86,
+ 88,
+ 37,
+ 28,
+ 25,
+ 0,
+ 0,
+ 0,
+ 43,
+ 48,
+ 56,
+ 91,
+ 94,
+ 98,
+ 139,
+ 112,
+ 66,
+ 136,
+ 108,
+ 59,
+ 91,
+ 99,
+ 110,
+ 63,
+ 72,
+ 85,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 53,
+ 55,
+ 57,
+ 97,
+ 90,
+ 78,
+ 102,
+ 91,
+ 71,
+ 50,
+ 54,
+ 61,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 76,
+ 78,
+ 77,
+ 67,
+ 70,
+ 71,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 5,
+ 6,
+ 7,
+ 138,
+ 142,
+ 142,
+ 125,
+ 131,
+ 134,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "fem|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 15,
+ "vertexCount": 1470,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "femch0001:femch0001 (base color):64:128|femch0002:femch0002 (base color):32:16|femfa0001:femfa0001 (base color):32:64|femft0001:femft0001 (base color):64:32|femft0002:femft0002 (base color):8:8|femhe0001:femhe0001 (base color):128:64|femhe0002:femhe0002 (base color):64:32|femhe0003:femhe0003 (base color):32:32|femhe0005:femhe0005 (base color):32:32|femhn0001:femhn0001 (base color):64:32|femhn0002:femhn0002 (base color):32:32|femlg0001:femlg0001 (base color):128:64|femlg0002:femlg0002 (base color):64:64|femlg0003:femlg0003 (base color):64:32|femua0001:femua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.359375,
+ "y": 0,
+ "width": 0.2765625,
+ "height": 0.41805555555555557
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 130,
+ 90,
+ 37,
+ 31,
+ 23,
+ 20,
+ 29,
+ 19,
+ 4,
+ 70,
+ 45,
+ 4,
+ 71,
+ 46,
+ 4,
+ 31,
+ 20,
+ 3,
+ 60,
+ 42,
+ 25,
+ 136,
+ 99,
+ 49,
+ 133,
+ 100,
+ 84,
+ 69,
+ 49,
+ 34,
+ 65,
+ 58,
+ 47,
+ 104,
+ 67,
+ 4,
+ 107,
+ 69,
+ 0,
+ 72,
+ 59,
+ 39,
+ 77,
+ 54,
+ 38,
+ 169,
+ 144,
+ 133,
+ 47,
+ 42,
+ 34,
+ 92,
+ 100,
+ 113,
+ 93,
+ 104,
+ 122,
+ 125,
+ 89,
+ 25,
+ 132,
+ 93,
+ 24,
+ 93,
+ 101,
+ 114,
+ 95,
+ 104,
+ 120,
+ 35,
+ 34,
+ 34,
+ 0,
+ 0,
+ 0,
+ 89,
+ 104,
+ 129,
+ 91,
+ 103,
+ 122,
+ 135,
+ 106,
+ 55,
+ 146,
+ 112,
+ 55,
+ 91,
+ 103,
+ 122,
+ 88,
+ 103,
+ 127,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 49,
+ 54,
+ 61,
+ 107,
+ 117,
+ 133,
+ 146,
+ 108,
+ 45,
+ 151,
+ 113,
+ 49,
+ 109,
+ 115,
+ 123,
+ 60,
+ 65,
+ 71,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 104,
+ 88,
+ 70,
+ 123,
+ 111,
+ 99,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 85,
+ 93,
+ 98,
+ 104,
+ 110,
+ 114,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 117,
+ 122,
+ 125,
+ 129,
+ 130,
+ 128,
+ 8,
+ 9,
+ 11,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "gfm|face=0|texture=0|helm=0": {
+ "meshCount": 13,
+ "vertexCount": 1428,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "gfmch0001:gfmch0001 (base color):64:128|gfmch0002:gfmch0002 (base color):32:16|gfmfa0001:gfmfa0001 (base color):32:64|gfmft0001:gfmft0001 (base color):64:32|gfmft0002:gfmft0002 (base color):8:8|gfmhe0001:gfmhe0001 (base color):32:32|gfmhe0003:gfmhe0003 (base color):32:32|gfmhn0001:gfmhn0001 (base color):64:32|gfmhn0002:gfmhn0002 (base color):32:32|gfmlg0001:gfmlg0001 (base color):128:64|gfmlg0002:gfmlg0002 (base color):64:64|gfmlg0003:gfmlg0003 (base color):64:32|gfmua0001:gfmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.3171875,
+ "y": 0,
+ "width": 0.36953125,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 63,
+ 29,
+ 5,
+ 0,
+ 0,
+ 0,
+ 22,
+ 16,
+ 1,
+ 47,
+ 52,
+ 28,
+ 45,
+ 52,
+ 24,
+ 20,
+ 24,
+ 2,
+ 0,
+ 0,
+ 0,
+ 67,
+ 32,
+ 6,
+ 69,
+ 34,
+ 9,
+ 20,
+ 10,
+ 2,
+ 0,
+ 0,
+ 0,
+ 46,
+ 40,
+ 35,
+ 47,
+ 39,
+ 33,
+ 0,
+ 0,
+ 0,
+ 20,
+ 10,
+ 2,
+ 51,
+ 25,
+ 6,
+ 40,
+ 22,
+ 7,
+ 76,
+ 45,
+ 20,
+ 0,
+ 0,
+ 0,
+ 93,
+ 96,
+ 94,
+ 91,
+ 95,
+ 95,
+ 0,
+ 0,
+ 0,
+ 98,
+ 59,
+ 28,
+ 39,
+ 22,
+ 8,
+ 15,
+ 8,
+ 3,
+ 113,
+ 82,
+ 56,
+ 59,
+ 63,
+ 61,
+ 134,
+ 137,
+ 132,
+ 132,
+ 136,
+ 133,
+ 51,
+ 55,
+ 53,
+ 110,
+ 82,
+ 58,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 22,
+ 24,
+ 25,
+ 104,
+ 111,
+ 111,
+ 135,
+ 141,
+ 139,
+ 139,
+ 145,
+ 142,
+ 99,
+ 106,
+ 106,
+ 19,
+ 21,
+ 22,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 41,
+ 43,
+ 42,
+ 102,
+ 97,
+ 89,
+ 88,
+ 88,
+ 84,
+ 38,
+ 40,
+ 39,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 110,
+ 114,
+ 114,
+ 104,
+ 110,
+ 109,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 84,
+ 87,
+ 87,
+ 62,
+ 64,
+ 64,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "gfm|face=0|texture=0|helm=0|heading=180": {
+ "meshCount": 13,
+ "vertexCount": 1428,
+ "skeletonCount": 1,
+ "boneCount": 25,
+ "materialSignature": "gfmch0001:gfmch0001 (base color):64:128|gfmch0002:gfmch0002 (base color):32:16|gfmfa0001:gfmfa0001 (base color):32:64|gfmft0001:gfmft0001 (base color):64:32|gfmft0002:gfmft0002 (base color):8:8|gfmhe0001:gfmhe0001 (base color):32:32|gfmhe0003:gfmhe0003 (base color):32:32|gfmhn0001:gfmhn0001 (base color):64:32|gfmhn0002:gfmhn0002 (base color):32:32|gfmlg0001:gfmlg0001 (base color):128:64|gfmlg0002:gfmlg0002 (base color):64:64|gfmlg0003:gfmlg0003 (base color):64:32|gfmua0001:gfmua0001 (base color):32:64",
+ "skeletonSignature": "bi_l|bi_r|bo_l|bo_r|ca_l|ca_r|ch|fi_l|fi_l2|fi_r|fi_r2|fo_l|fo_r|he|head_point|l_point|ne|pe|r_point|root|shield_point|th_l|th_r|to_l|to_r",
+ "foregroundBounds": {
+ "x": 0.30078125,
+ "y": 0,
+ "width": 0.3984375,
+ "height": 0.42916666666666664
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 61,
+ 29,
+ 5,
+ 12,
+ 6,
+ 1,
+ 10,
+ 11,
+ 1,
+ 62,
+ 53,
+ 41,
+ 57,
+ 50,
+ 40,
+ 0,
+ 0,
+ 0,
+ 15,
+ 7,
+ 1,
+ 60,
+ 28,
+ 5,
+ 36,
+ 19,
+ 6,
+ 71,
+ 39,
+ 13,
+ 0,
+ 0,
+ 0,
+ 23,
+ 22,
+ 22,
+ 22,
+ 21,
+ 22,
+ 0,
+ 0,
+ 0,
+ 70,
+ 38,
+ 13,
+ 36,
+ 19,
+ 5,
+ 25,
+ 15,
+ 7,
+ 79,
+ 60,
+ 40,
+ 31,
+ 30,
+ 24,
+ 46,
+ 50,
+ 53,
+ 46,
+ 50,
+ 53,
+ 11,
+ 18,
+ 2,
+ 85,
+ 61,
+ 39,
+ 25,
+ 15,
+ 7,
+ 0,
+ 0,
+ 0,
+ 52,
+ 55,
+ 52,
+ 36,
+ 42,
+ 21,
+ 109,
+ 119,
+ 123,
+ 99,
+ 110,
+ 113,
+ 39,
+ 44,
+ 22,
+ 51,
+ 54,
+ 51,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 73,
+ 82,
+ 83,
+ 164,
+ 174,
+ 175,
+ 160,
+ 170,
+ 170,
+ 69,
+ 78,
+ 79,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 104,
+ 101,
+ 92,
+ 104,
+ 101,
+ 92,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 111,
+ 115,
+ 115,
+ 102,
+ 106,
+ 107,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 76,
+ 80,
+ 81,
+ 83,
+ 87,
+ 88,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "hum|face=7|texture=0|helm=0|heading=180": {
+ "meshCount": 25,
+ "vertexCount": 5022,
+ "skeletonCount": 1,
+ "boneCount": 104,
+ "materialSignature": "humch0002:humch0002:64:128|humch0003:humch0003:256:256|humchsk01:humchsk01:64:32|humfask01:humfask01:128:128|humfask02:humfask02:128:128|humft0001:humft0001:64:64|humft0002:humft0002:32:64|humft0003:humft0003:64:16|humft0004:humft0004:128:128|humhesk02:humhesk02:32:64|humhesk03:humhesk03:32:32|humhesk06:humhesk06:64:64|humhesk07:humhesk07:64:32|humhesk08:humhesk08:64:16|humhesk71:humhesk71:256:128|humhesk74:humhesk74:32:64|humhesk75:humhesk75:32:32|humhnsk01:humhnsk01:64:64|humhnsk02:humhnsk02:64:64|humhnsk03:humhnsk03:32:32|humlg0001:humlg0001:256:128|humlg0002:humlg0002:128:128|humlg0003:humlg0003:128:32|humuask01:humuask01:64:64|humuask02:humuask02:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hebeard1|hebeard2|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.35859375,
+ "y": 0,
+ "width": 0.2296875,
+ "height": 0.4041666666666667
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 108,
+ 72,
+ 48,
+ 61,
+ 39,
+ 24,
+ 30,
+ 18,
+ 16,
+ 37,
+ 21,
+ 19,
+ 41,
+ 23,
+ 20,
+ 43,
+ 25,
+ 22,
+ 52,
+ 31,
+ 18,
+ 97,
+ 64,
+ 41,
+ 86,
+ 61,
+ 43,
+ 92,
+ 58,
+ 36,
+ 26,
+ 17,
+ 12,
+ 23,
+ 18,
+ 15,
+ 21,
+ 18,
+ 16,
+ 23,
+ 19,
+ 16,
+ 37,
+ 21,
+ 11,
+ 100,
+ 66,
+ 42,
+ 24,
+ 16,
+ 10,
+ 110,
+ 74,
+ 47,
+ 65,
+ 42,
+ 27,
+ 16,
+ 16,
+ 16,
+ 14,
+ 14,
+ 14,
+ 16,
+ 17,
+ 16,
+ 63,
+ 42,
+ 27,
+ 111,
+ 76,
+ 50,
+ 0,
+ 0,
+ 0,
+ 111,
+ 78,
+ 53,
+ 95,
+ 65,
+ 45,
+ 16,
+ 17,
+ 16,
+ 9,
+ 9,
+ 9,
+ 20,
+ 20,
+ 19,
+ 55,
+ 40,
+ 30,
+ 59,
+ 41,
+ 28,
+ 0,
+ 0,
+ 0,
+ 66,
+ 46,
+ 33,
+ 139,
+ 100,
+ 73,
+ 45,
+ 42,
+ 39,
+ 37,
+ 38,
+ 38,
+ 47,
+ 48,
+ 48,
+ 96,
+ 75,
+ 60,
+ 70,
+ 51,
+ 37,
+ 0,
+ 0,
+ 0,
+ 36,
+ 26,
+ 20,
+ 74,
+ 53,
+ 37,
+ 62,
+ 57,
+ 51,
+ 92,
+ 78,
+ 67,
+ 80,
+ 67,
+ 57,
+ 17,
+ 17,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 121,
+ 78,
+ 48,
+ 116,
+ 74,
+ 44,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 139,
+ 100,
+ 70,
+ 127,
+ 90,
+ 61,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "hum|face=6|texture=16|helm=0": {
+ "meshCount": 25,
+ "vertexCount": 5022,
+ "skeletonCount": 1,
+ "boneCount": 104,
+ "materialSignature": "humch1602:humch1602:64:128|humch1603:humch1603:256:256|humchsk01:humchsk01:64:32|humfa1601:humfa1601:128:128|humfa1602:humfa1602:128:128|humft0401:humft0401:64:64|humft0402:humft0402:32:64|humft0403:humft0403:64:16|humft0404:humft0404:128:128|humhesk02:humhesk02:32:64|humhesk03:humhesk03:32:32|humhesk06:humhesk06:64:64|humhesk07:humhesk07:64:32|humhesk08:humhesk08:64:16|humhesk61:humhesk61:256:128|humhesk64:humhesk64:32:64|humhesk65:humhesk65:32:32|humhnsk01:humhnsk01 (base color):64:64|humhnsk02:humhnsk02 (base color):64:64|humhnsk03:humhnsk03 (base color):32:32|humlg0401:humlg0401:256:128|humlg0402:humlg0402:128:128|humlg0403:humlg0403:128:32|humua1601:humua1601:64:64|humua1602:humua1602:128:128",
+ "skeletonSignature": "back_point|beard_point|bibicepl|bibicepr|biclavl|biclavr|bideltl|bideltl01|bideltl02|bideltr01|bideltr02|bideltr03|bofootl|bofootr|cacalfl|cacalfr|cakneel|cakneer|chchest|chchest1|chchest2|chest_point|faeyebrowl|faeyebrowr|faeyel|faeyelidlbot|faeyelidltop|faeyelidrbot|faeyelidrtop|faeyer|fajaw|falipbottom|falipcorners|faliptop|fanose|fifingerl1|fifingerl2|fifingerr1|fifingerr2|fihandl|fihandr|fithumbl1|fithumbl2|fithumbr1|fithumbr2|foforearml|foforearmr|forobearml1|forobearml2|forobearml3|forobearml4|forobearmr1|forobearmr2|forobearmr3|forobearmr4|gauntl_point|gauntr_point|hair_point|head_point|hebeard1|hebeard2|hehair1|hehair2|hehead|l_point|legl_point|legr_point|neneck|pebip01|pebuttl|pebuttr|pelvis_point|pepelvis|petunic|r_point|robepoint03|robepoint04|robepoint05|robepoint06|robepoint07|robepoint08|robepoint09|robepoint1|robepoint10|robepoint11|robepoint12|robepoint13|robepoint14|robepoint15|robepoint16|robepoint17|robepoint18|robepoint2|root|shield_point|shouldl_point|shouldr_point|thlp|thrp|ththighl|ththighr|totoel|totoer|tunic_point",
+ "foregroundBounds": {
+ "x": 0.40625,
+ "y": 0,
+ "width": 0.23359375,
+ "height": 0.40694444444444444
+ },
+ "whitePixelRatio": 0,
+ "pixelSignature": [
+ 108,
+ 73,
+ 48,
+ 52,
+ 36,
+ 22,
+ 94,
+ 79,
+ 55,
+ 83,
+ 69,
+ 48,
+ 84,
+ 70,
+ 49,
+ 67,
+ 57,
+ 40,
+ 36,
+ 23,
+ 14,
+ 114,
+ 79,
+ 53,
+ 122,
+ 101,
+ 90,
+ 44,
+ 38,
+ 36,
+ 87,
+ 75,
+ 56,
+ 91,
+ 86,
+ 79,
+ 85,
+ 81,
+ 73,
+ 42,
+ 37,
+ 28,
+ 71,
+ 43,
+ 25,
+ 80,
+ 55,
+ 37,
+ 66,
+ 65,
+ 67,
+ 93,
+ 91,
+ 94,
+ 112,
+ 108,
+ 108,
+ 137,
+ 134,
+ 137,
+ 131,
+ 128,
+ 131,
+ 96,
+ 90,
+ 88,
+ 106,
+ 81,
+ 66,
+ 29,
+ 20,
+ 14,
+ 67,
+ 65,
+ 67,
+ 114,
+ 111,
+ 115,
+ 127,
+ 124,
+ 127,
+ 126,
+ 123,
+ 124,
+ 131,
+ 127,
+ 130,
+ 104,
+ 101,
+ 104,
+ 92,
+ 90,
+ 93,
+ 0,
+ 0,
+ 0,
+ 70,
+ 69,
+ 71,
+ 150,
+ 146,
+ 151,
+ 153,
+ 149,
+ 154,
+ 119,
+ 104,
+ 96,
+ 138,
+ 134,
+ 136,
+ 108,
+ 105,
+ 107,
+ 61,
+ 60,
+ 61,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 75,
+ 73,
+ 76,
+ 148,
+ 126,
+ 114,
+ 107,
+ 73,
+ 52,
+ 156,
+ 142,
+ 138,
+ 148,
+ 144,
+ 149,
+ 27,
+ 27,
+ 27,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 101,
+ 68,
+ 47,
+ 111,
+ 80,
+ 60,
+ 56,
+ 42,
+ 34,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 93,
+ 67,
+ 47,
+ 105,
+ 80,
+ 59,
+ 24,
+ 17,
+ 12,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ }
+ },
+ "provenance": {
+ "reviewedBy": "Codex visual review",
+ "sourceRun": "2026-07-18T11-03-20-250Z-model-regression",
+ "approvedAt": "2026-07-18T11:37:06.021Z",
+ "sampleCount": 53
+ }
+}
diff --git a/tools/sage-qa/fs-stress.mjs b/tools/sage-qa/fs-stress.mjs
new file mode 100644
index 00000000..5ed0a308
--- /dev/null
+++ b/tools/sage-qa/fs-stress.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseArgs } from './lib/args.mjs';
+import { resolveEqDirectory } from './lib/profile.mjs';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDirectory, '..', '..');
+const args = parseArgs(process.argv.slice(2));
+const baseUrl = `${args.baseUrl ?? process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8080'}`.replace(/\/$/, '');
+const eqDirectory = await resolveEqDirectory({ requested: args.eqDir });
+const concurrency = Math.max(1, Math.min(16, Number(args.concurrency ?? 8)));
+const rounds = Math.max(1, Math.min(20, Number(args.rounds ?? 5)));
+const runId = new Date().toISOString().replace(/[:.]/g, '-');
+const stressDirectory = `${eqDirectory.replaceAll('\\', '/')}/eqsage/.spire-qa/fs-stress-${runId}`;
+const events = [];
+
+const endpoint = (operation, filePath) => {
+ const query = new URLSearchParams({ root: eqDirectory, path: filePath });
+ return `${baseUrl}/api/v1/app/sage-fs/${operation}?${query}`;
+};
+
+const request = async (operation, filePath, init = {}) => {
+ const startedAt = Date.now();
+ const response = await fetch(endpoint(operation, filePath), {
+ ...init,
+ signal: AbortSignal.timeout(30000),
+ });
+ const retries = Number(response.headers.get('x-sage-fs-retries') ?? 0);
+ const event = {
+ operation,
+ filePath,
+ status: response.status,
+ retries,
+ durationMs: Date.now() - startedAt,
+ };
+ events.push(event);
+ if (!response.ok) {
+ const body = await response.text().catch(() => '');
+ throw new Error(`${operation} returned HTTP ${response.status}: ${body}`);
+ }
+ return response;
+};
+
+const readTargets = [
+ 'eqsage/textures/avifa0101.png',
+ 'eqsage/textures/bknch0003.png',
+ 'eqsage/textures/frmlg0002.png',
+ 'eqsage/textures/orchn0201.png',
+ 'eqsage/textures/peghe0001.png',
+ 'eqsage/textures/wlmua0401.png',
+ 'eqsage/textures/xegfa0001.png',
+ 'eqsage/data/version.json',
+].map((relative) => `${eqDirectory.replaceAll('\\', '/')}/${relative}`);
+
+let failure = null;
+try {
+ for (let round = 0; round < rounds; round += 1) {
+ await Promise.all(Array.from({ length: concurrency }, async (_, worker) => {
+ const existingPath = readTargets[(round * concurrency + worker) % readTargets.length];
+ const existing = await request('read-file', existingPath, { method: 'GET' });
+ if ((await existing.arrayBuffer()).byteLength === 0) {
+ throw new Error(`existing read returned no data: ${existingPath}`);
+ }
+
+ const filePath = `${stressDirectory}/round-${round}-worker-${worker}.bin`;
+ const payload = Buffer.alloc(64 * 1024, (round * concurrency + worker) % 251);
+ await request('write-file', filePath, { method: 'POST', body: payload });
+ const written = Buffer.from(await (await request('read-file', filePath, { method: 'GET' })).arrayBuffer());
+ if (!written.equals(payload)) {
+ throw new Error(`round-trip mismatch: ${filePath}`);
+ }
+ }));
+ }
+} catch (error) {
+ failure = error;
+} finally {
+ await request('delete-folder', stressDirectory, { method: 'DELETE' }).catch((error) => {
+ failure ??= error;
+ });
+}
+
+const result = {
+ schemaVersion: 1,
+ runId,
+ baseUrl,
+ eqDirectory,
+ concurrency,
+ rounds,
+ operationCount: events.length,
+ internalRetryCount: events.reduce((total, event) => total + event.retries, 0),
+ httpErrorCount: events.filter((event) => event.status >= 400).length,
+ maximumDurationMs: Math.max(0, ...events.map((event) => event.durationMs)),
+ pass: failure === null,
+ failure: failure?.message ?? null,
+ events,
+};
+const outputDirectory = path.join(repoRoot, 'tmp', 'validation', 'fs-stress');
+await fs.mkdir(outputDirectory, { recursive: true });
+const outputPath = path.join(outputDirectory, `${runId}.json`);
+await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
+console.log(JSON.stringify({ ...result, events: undefined, outputPath }, null, 2));
+if (!result.pass) process.exitCode = 1;
diff --git a/tools/sage-qa/inspect-character-archive-rigs.mjs b/tools/sage-qa/inspect-character-archive-rigs.mjs
new file mode 100644
index 00000000..32445f8f
--- /dev/null
+++ b/tools/sage-qa/inspect-character-archive-rigs.mjs
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises';
+import { register } from 'node:module';
+import path from 'node:path';
+
+// Sage's browser bundle intentionally uses extensionless relative imports.
+// Register a narrow resolver before dynamically importing that parser graph so
+// this diagnostic remains executable under current Node ESM semantics too.
+register('./lib/extensionless-loader.mjs', import.meta.url);
+
+const parseArgs = (values) => {
+ const args = {};
+ for (let index = 0; index < values.length; index++) {
+ const value = values[index];
+ if (!value.startsWith('--')) continue;
+ const [rawKey, inlineValue] = value.slice(2).split('=', 2);
+ const key = rawKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+ args[key] = inlineValue ?? values[++index];
+ }
+ return args;
+};
+
+const asCsv = (value) => `${value ?? ''}`
+ .split(',')
+ .map((entry) => entry.trim().toLowerCase())
+ .filter(Boolean);
+
+const args = parseArgs(process.argv.slice(2));
+if (!args.eqDir || !args.archives || !args.models) {
+ console.error(
+ 'Usage: node tools/sage-qa/inspect-character-archive-rigs.mjs ' +
+ '--eq-dir --archives --models '
+ );
+ process.exitCode = 2;
+} else {
+ const [{ PFSArchive }, { Wld }] = await Promise.all([
+ import('../../frontend/eqsage-embed/sage/lib/pfs/pfs.js'),
+ import('../../frontend/eqsage-embed/sage/lib/s3d/wld/wld.js'),
+ ]);
+ const eqDirectory = path.resolve(args.eqDir);
+ const archives = asCsv(args.archives);
+ const requestedModels = new Set(asCsv(args.models));
+ const report = {
+ schemaVersion: 1,
+ eqDirectory,
+ requestedModels: [...requestedModels],
+ archives: [],
+ };
+
+ for (const archiveName of archives) {
+ const archivePath = path.join(eqDirectory, archiveName);
+ const archiveReport = {
+ archive: archiveName,
+ exists: false,
+ wldFiles: [],
+ models: {},
+ error: null,
+ };
+ report.archives.push(archiveReport);
+
+ try {
+ const bytes = await fs.readFile(archivePath);
+ const arrayBuffer = bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength
+ );
+ const pfs = new PFSArchive();
+ archiveReport.exists = pfs.openFromFile(arrayBuffer) === true;
+ if (!archiveReport.exists) {
+ archiveReport.error = 'invalid-pfs-archive';
+ continue;
+ }
+
+ for (const fileName of pfs.files.keys()) {
+ if (!fileName.toLowerCase().endsWith('.wld')) continue;
+ archiveReport.wldFiles.push(fileName);
+ const wld = new Wld(pfs.getFile(fileName), fileName);
+ for (const track of wld.tracks) {
+ if (!track.isNameParsed) track.parseTrackData();
+ }
+
+ for (const modelName of requestedModels) {
+ const skeletons = wld.skeletons.filter(
+ (skeleton) => `${skeleton?.modelBase ?? ''}`.toLowerCase() === modelName
+ );
+ const tracks = wld.tracks.filter(
+ (track) => `${track?.modelName ?? ''}`.toLowerCase() === modelName
+ );
+ if (skeletons.length === 0 && tracks.length === 0) continue;
+
+ const existing = archiveReport.models[modelName] ?? {
+ skeletonCount: 0,
+ boneNames: [],
+ poseTrackCount: 0,
+ playableTrackCount: 0,
+ animationNames: [],
+ };
+ existing.skeletonCount += skeletons.length;
+ existing.boneNames = Array.from(new Set([
+ ...existing.boneNames,
+ ...skeletons.flatMap((skeleton) =>
+ (skeleton.skeleton ?? []).map((bone) =>
+ `${bone?.name ?? ''}`.trim().toLowerCase()
+ )
+ ),
+ ].filter(Boolean))).sort();
+ existing.poseTrackCount += tracks.filter((track) => track.isPoseAnimation).length;
+ existing.playableTrackCount += tracks.filter((track) => !track.isPoseAnimation).length;
+ existing.animationNames = Array.from(new Set([
+ ...existing.animationNames,
+ ...tracks
+ .filter((track) => !track.isPoseAnimation)
+ .map((track) => `${track.animationName ?? ''}`.toLowerCase())
+ .filter(Boolean),
+ ])).sort();
+ archiveReport.models[modelName] = existing;
+ }
+ }
+ } catch (error) {
+ archiveReport.error = error?.message ?? String(error);
+ }
+ }
+
+ report.summary = {
+ archiveCount: report.archives.length,
+ missingArchiveCount: report.archives.filter((archive) => !archive.exists).length,
+ archivesWithPlayableTracks: report.archives
+ .filter((archive) => Object.values(archive.models).some(
+ (model) => model.playableTrackCount > 0
+ ))
+ .map((archive) => archive.archive),
+ };
+ console.log(JSON.stringify(report, null, 2));
+}
diff --git a/tools/sage-qa/lib/aggregate.mjs b/tools/sage-qa/lib/aggregate.mjs
new file mode 100644
index 00000000..7e030f75
--- /dev/null
+++ b/tools/sage-qa/lib/aggregate.mjs
@@ -0,0 +1,178 @@
+import { bytesToMB } from './memory.mjs';
+
+const sum = (items, selector) => items.reduce((total, item) => total + Number(selector(item) ?? 0), 0);
+
+export const aggregateZoneValidation = (payload) => {
+ const reports = payload?.reports ?? [];
+ const modelCodes = new Set();
+ for (const report of reports) {
+ for (const model of Object.keys(report.visuals?.byModel ?? {})) modelCodes.add(model.toLowerCase());
+ }
+ return {
+ finished: payload?.finished ?? reports.length >= Number(payload?.config?.expectedReports ?? reports.length),
+ complete: payload?.complete === true,
+ failureCount: Number(payload?.failureCount ?? reports.filter((report) => !report.pass?.all).length),
+ reportCount: reports.length,
+ uniqueZoneCount: new Set(reports.map((report) => report.zone)).size,
+ uniqueModelCount: modelCodes.size,
+ modelCodes: [...modelCodes].sort(),
+ npcCount: sum(reports, (report) => report.spawns?.loaded),
+ npcRootCount: sum(reports, (report) => report.rootNodeCount),
+ npcTextureReadyCount: sum(reports, (report) => report.visuals?.readyTextureCount),
+ npcTextureSlotCount: sum(reports, (report) => report.visuals?.materialSlotCount),
+ appearanceTextureDecodeFailureCount: sum(
+ reports,
+ (report) => report.visuals?.appearanceTextureDecodeFailureCount
+ ),
+ tPoseRiskCount: sum(reports, (report) => report.visuals?.tPoseRiskCount),
+ motionlessAnimationCount: sum(reports, (report) => report.visuals?.motionlessAnimationCount),
+ nonFiniteBoneMatrixCount: sum(reports, (report) => report.visuals?.nonFiniteBoneMatrixCount),
+ animationGroupCount: sum(reports, (report) => report.visuals?.animationGroupCount),
+ playingAnimationGroupCount: sum(reports, (report) => report.visuals?.playingAnimationGroupCount),
+ excessAnimationGroupCount: sum(reports, (report) => report.visuals?.excessAnimationGroupCount),
+ textureFallbackCount: sum(reports, (report) => report.visuals?.fallbackTextureCount),
+ texturelessSpawnCount: sum(reports, (report) => report.visuals?.texturelessSpawnCount),
+ nameplateExpectedCount: sum(reports, (report) => report.visuals?.nameplateExpectedCount),
+ nameplateCount: sum(reports, (report) => report.visuals?.nameplateCount),
+ nameplateFailureCount: sum(reports, (report) => report.visuals?.nameplateFailureCount),
+ visibleDoorCount: sum(reports, (report) => report.doors?.loaded),
+ hiddenDoorCount: sum(reports, (report) => report.doors?.hidden),
+ doorTextureReadyCount: sum(reports, (report) => report.doors?.visuals?.readyTextureCount),
+ failures: reports.filter((report) => !report.pass?.all).map((report) => ({
+ zone: report.zone,
+ cycle: report.validationSequence?.cycle ?? 1,
+ pass: report.pass,
+ loadError: report.loadError ?? null,
+ })),
+ };
+};
+
+export const aggregateRaceAudits = (batches = []) => {
+ const results = batches.flatMap((batch) => batch.audit?.results ?? []);
+ const failures = results.filter((result) => !`${result.status ?? ''}`.startsWith('pass-'));
+ const animationDiagnostics = results
+ .filter((result) => result.bindPoseOnlyAnimation === true)
+ .map((result) => ({
+ model: result.model,
+ name: result.name,
+ status: result.status,
+ animationVitality: result.animationVitality,
+ staticPoseFallbackAvailable: result.staticPoseFallbackAvailable,
+ }));
+ return {
+ batchCount: batches.length,
+ auditedModelCount: results.length,
+ uniqueModelCount: new Set(results.map((result) => result.model)).size,
+ appearanceVariantCountAudited: sum(results, (result) => result.appearanceVariantCountAudited),
+ faceVariantCountAudited: sum(results, (result) => result.faceVariantCountAudited),
+ animationDiagnosticCount: animationDiagnostics.length,
+ animationDiagnosticModels: animationDiagnostics.map((result) => result.model),
+ animationDiagnostics,
+ failureCount: failures.length,
+ failures,
+ complete: batches.every((batch) => batch.audit?.complete === true) && failures.length === 0,
+ };
+};
+
+const resourceDelta = (baseline, current, key) => {
+ const before = Number(baseline?.sceneResources?.[key] ?? 0);
+ const after = Number(current?.sceneResources?.[key] ?? 0);
+ const absolute = after - before;
+ const percent = before > 0 ? (absolute / before) * 100 : (after > 0 ? 100 : 0);
+ return { key, before, after, absolute, percent };
+};
+
+export const analyzeSoak = (reports = [], config = {}) => {
+ if (!config.enabled) return { enabled: false, pass: true, violations: [] };
+ const warmupCycles = Number(config.warmupCycles ?? 1);
+ const resourceKeys = config.resourceKeys ?? [];
+ const baselineEndCycle = warmupCycles + 1;
+ const baselineReports = reports.filter(
+ (report) => Number(report.validationSequence?.cycle ?? 1) <= baselineEndCycle
+ );
+ const comparisonReports = reports.filter(
+ (report) => Number(report.validationSequence?.cycle ?? 1) > baselineEndCycle
+ );
+ const baselines = new Map();
+ const deltas = [];
+ const violations = [];
+
+ for (const report of baselineReports) {
+ const baseline = baselines.get(report.zone) ?? { ...report, sceneResources: {} };
+ const sceneResources = { ...baseline.sceneResources };
+ for (const key of resourceKeys) {
+ sceneResources[key] = Math.max(
+ Number(sceneResources[key] ?? 0),
+ Number(report.sceneResources?.[key] ?? 0)
+ );
+ }
+ baselines.set(report.zone, { ...baseline, sceneResources });
+ }
+
+ for (const report of comparisonReports) {
+ const baseline = baselines.get(report.zone);
+ if (!baseline) continue;
+ for (const key of resourceKeys) {
+ const delta = resourceDelta(baseline, report, key);
+ deltas.push({ zone: report.zone, cycle: report.validationSequence?.cycle, ...delta });
+ if (
+ delta.absolute > Number(config.resourceToleranceAbsolute ?? 0) &&
+ delta.percent > Number(config.resourceTolerancePercent ?? 0)
+ ) {
+ violations.push(
+ `${report.zone} ${key} grew from ${delta.before} to ${delta.after} ` +
+ `(+${delta.absolute}, ${delta.percent.toFixed(1)}%)`
+ );
+ }
+ }
+ }
+
+ const heapSamples = reports
+ .filter((report) => Number(report.runtimeMemory?.jsHeapUsedBytes ?? 0) > 0)
+ .map((report) => ({
+ zone: report.zone,
+ cycle: report.validationSequence?.cycle,
+ usedMB: bytesToMB(report.runtimeMemory.jsHeapUsedBytes),
+ }));
+ const heapBaselines = new Map();
+ const heapDeltas = [];
+ for (const sample of heapSamples) {
+ if (Number(sample.cycle ?? 1) <= baselineEndCycle) {
+ const baseline = heapBaselines.get(sample.zone);
+ if (!baseline || sample.usedMB > baseline.usedMB) {
+ heapBaselines.set(sample.zone, sample);
+ }
+ continue;
+ }
+ const baseline = heapBaselines.get(sample.zone);
+ if (!baseline) continue;
+ heapDeltas.push({
+ zone: sample.zone,
+ cycle: sample.cycle,
+ beforeMB: baseline.usedMB,
+ afterMB: sample.usedMB,
+ growthMB: sample.usedMB - baseline.usedMB,
+ });
+ }
+ const heapGrowthMB = Math.max(0, ...heapDeltas.map((delta) => delta.growthMB));
+ if (heapGrowthMB > Number(config.maxJsHeapGrowthMB ?? Infinity)) {
+ violations.push(
+ `JavaScript heap grew ${heapGrowthMB.toFixed(1)} MB after warmup ` +
+ `(budget ${config.maxJsHeapGrowthMB} MB)`
+ );
+ }
+
+ return {
+ enabled: true,
+ pass: violations.length === 0,
+ warmupCycles,
+ baselineEndCycle,
+ baselineReportCount: baselineReports.length,
+ comparedReportCount: comparisonReports.length,
+ resourceDeltas: deltas,
+ heapSamples,
+ heapDeltas,
+ heapGrowthMB,
+ violations,
+ };
+};
diff --git a/tools/sage-qa/lib/args.mjs b/tools/sage-qa/lib/args.mjs
new file mode 100644
index 00000000..0e62575c
--- /dev/null
+++ b/tools/sage-qa/lib/args.mjs
@@ -0,0 +1,62 @@
+const toCamelCase = (value) => value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+
+const ALIASES = {
+ b: 'baseUrl',
+ e: 'eqDir',
+ h: 'help',
+ o: 'outputRoot',
+ p: 'profile',
+};
+
+export const parseArgs = (argv = []) => {
+ const args = { positional: [] };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const token = argv[index];
+ if (token === '--') {
+ args.positional.push(...argv.slice(index + 1));
+ break;
+ }
+ if (!token.startsWith('-') || token === '-') {
+ args.positional.push(token);
+ continue;
+ }
+
+ if (token.startsWith('--no-')) {
+ args[toCamelCase(token.slice(5))] = false;
+ continue;
+ }
+
+ const normalized = token.startsWith('--') ? token.slice(2) : token.slice(1);
+ const equalsIndex = normalized.indexOf('=');
+ const rawKey = equalsIndex >= 0 ? normalized.slice(0, equalsIndex) : normalized;
+ const key = ALIASES[rawKey] ?? toCamelCase(rawKey);
+ if (equalsIndex >= 0) {
+ args[key] = normalized.slice(equalsIndex + 1);
+ continue;
+ }
+
+ const next = argv[index + 1];
+ if (next !== undefined && !next.startsWith('-')) {
+ args[key] = next;
+ index += 1;
+ } else {
+ args[key] = true;
+ }
+ }
+
+ return args;
+};
+
+export const asBoolean = (value, fallback = false) => {
+ if (value === undefined || value === null) return fallback;
+ if (typeof value === 'boolean') return value;
+ if (/^(1|true|yes|on)$/i.test(`${value}`)) return true;
+ if (/^(0|false|no|off)$/i.test(`${value}`)) return false;
+ return fallback;
+};
+
+export const asNumber = (value, fallback) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+};
diff --git a/tools/sage-qa/lib/artifacts.mjs b/tools/sage-qa/lib/artifacts.mjs
new file mode 100644
index 00000000..1d26e612
--- /dev/null
+++ b/tools/sage-qa/lib/artifacts.mjs
@@ -0,0 +1,91 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+const slugify = (value) => `${value}`
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'run';
+
+const assertInside = (root, candidate) => {
+ const resolvedRoot = path.resolve(root);
+ const resolvedCandidate = path.resolve(candidate);
+ if (resolvedCandidate === resolvedRoot || !resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`)) {
+ throw new Error(`Refusing artifact operation outside ${resolvedRoot}: ${resolvedCandidate}`);
+ }
+ return resolvedCandidate;
+};
+
+export const createRunDirectory = async ({ outputRoot, profileName, now = new Date() }) => {
+ const runId = `${now.toISOString().replace(/[:.]/g, '-')}-${slugify(profileName)}`;
+ const root = path.resolve(outputRoot);
+ const runDirectory = assertInside(root, path.join(root, runId));
+ await fs.mkdir(runDirectory, { recursive: true });
+ await fs.mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
+ await fs.mkdir(path.join(runDirectory, 'traces'), { recursive: true });
+ return { runId, runDirectory, outputRoot: root };
+};
+
+export const writeJson = async (filePath, value) => {
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+};
+
+export const writeText = async (filePath, value) => {
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await fs.writeFile(filePath, value, 'utf8');
+};
+
+export const appendEvent = async (runDirectory, event) => {
+ await fs.appendFile(
+ path.join(runDirectory, 'events.ndjson'),
+ `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`,
+ 'utf8'
+ );
+};
+
+export const directorySize = async (directory) => {
+ let total = 0;
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const candidate = path.join(directory, entry.name);
+ if (entry.isDirectory()) total += await directorySize(candidate);
+ else if (entry.isFile()) total += (await fs.stat(candidate)).size;
+ }
+ return total;
+};
+
+export const pruneRuns = async ({ outputRoot, keepRuns, maxTotalMB, currentRunDirectory }) => {
+ const root = path.resolve(outputRoot);
+ await fs.mkdir(root, { recursive: true });
+ const current = path.resolve(currentRunDirectory);
+ const entries = [];
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const directory = assertInside(root, path.join(root, entry.name));
+ const stat = await fs.stat(directory);
+ entries.push({ directory, mtimeMs: stat.mtimeMs, size: await directorySize(directory) });
+ }
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
+
+ const maxBytes = Math.max(0, Number(maxTotalMB ?? 0)) * 1024 * 1024;
+ const retained = [];
+ const removed = [];
+ let retainedBytes = 0;
+ for (const [index, entry] of entries.entries()) {
+ const isCurrent = entry.directory === current;
+ const withinCount = index < Math.max(1, Number(keepRuns ?? 1));
+ const withinSize = maxBytes <= 0 || retainedBytes + entry.size <= maxBytes;
+ if (isCurrent || (withinCount && withinSize)) {
+ retained.push(entry);
+ retainedBytes += entry.size;
+ continue;
+ }
+ await fs.rm(entry.directory, { recursive: true, force: true });
+ removed.push(entry);
+ }
+ return {
+ retainedRuns: retained.length,
+ removedRuns: removed.length,
+ retainedBytes,
+ removedBytes: removed.reduce((total, entry) => total + entry.size, 0),
+ };
+};
diff --git a/tools/sage-qa/lib/coverage.mjs b/tools/sage-qa/lib/coverage.mjs
new file mode 100644
index 00000000..cc11f75a
--- /dev/null
+++ b/tools/sage-qa/lib/coverage.mjs
@@ -0,0 +1,127 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+const readJson = async (filePath) => JSON.parse(await fs.readFile(filePath, 'utf8'));
+
+const rangeCount = (minimum, maximum) => {
+ const min = Number(minimum ?? 0);
+ const max = Number(maximum ?? 0);
+ return Number.isFinite(min) && Number.isFinite(max) && max >= min ? max - min + 1 : 1;
+};
+
+const normalizeModel = (value) => `${value ?? ''}`.trim().toLowerCase();
+
+export const buildCoverageManifest = async ({ repoRoot, eqDirectory }) => {
+ const commonRoot = path.join(
+ repoRoot,
+ 'frontend',
+ 'eqsage-embed',
+ 'src',
+ 'viewer',
+ 'common'
+ );
+ const [raceData, modelMetadata, appearancePolicies, inventory] = await Promise.all([
+ readJson(path.join(commonRoot, 'raceData.json')),
+ readJson(path.join(commonRoot, 'raceModelMetadata.json')),
+ readJson(path.join(commonRoot, 'raceAppearancePolicies.json')),
+ readJson(path.join(repoRoot, 'internal', 'http', 'staticmaps', 'race-inventory-map.json')),
+ ]);
+
+ const availableFileNames = await fs.readdir(path.join(eqDirectory, 'eqsage', 'models'));
+ const availableModels = new Set(
+ availableFileNames
+ .filter((name) => /\.glb$/i.test(name))
+ .map((name) => path.parse(name).name.toLowerCase())
+ .filter(Boolean)
+ );
+ const classicFaceModels = new Set(appearancePolicies.classicFaceModels ?? []);
+ const inventoryByRace = new Map(
+ (inventory.races ?? []).map((race) => [Number(race.race_id), race])
+ );
+ const models = new Map();
+
+ for (const race of raceData) {
+ const inventoryRace = inventoryByRace.get(Number(race.id));
+ for (const [genderKey, gender] of [['0', 'male'], ['1', 'female'], ['2', 'neutral']]) {
+ const model = normalizeModel(race[genderKey]);
+ if (!model) continue;
+ if (!models.has(model)) {
+ const metadata = modelMetadata[model] ?? {};
+ const faceVariantCount = classicFaceModels.has(model) ? 8 : 1;
+ const textureVariantCount = rangeCount(metadata.minTexture, metadata.maxTexture);
+ const helmVariantCount = rangeCount(metadata.minHelmTexture, metadata.maxHelmTexture);
+ models.set(model, {
+ model,
+ available: availableModels.has(model),
+ classicFaces: classicFaceModels.has(model),
+ faceVariantCount,
+ textureVariantCount,
+ helmVariantCount,
+ expectedAppearanceChecks: faceVariantCount + Math.max(0, textureVariantCount - 1) + Math.max(0, helmVariantCount - 1),
+ playable: Boolean(inventoryRace?.is_playable),
+ races: [],
+ sourceFiles: metadata.sourceFiles ?? [],
+ zones: [],
+ });
+ }
+ const entry = models.get(model);
+ entry.races.push({
+ id: Number(race.id),
+ name: race.name,
+ gender,
+ });
+ for (const source of inventoryRace?.sources ?? []) {
+ if (!(source.models ?? []).some((candidate) => normalizeModel(candidate.code) === model)) continue;
+ for (const zone of source.zones ?? []) {
+ if (zone.short_name) entry.zones.push(zone.short_name.toLowerCase());
+ }
+ }
+ }
+ }
+
+ const modelEntries = [...models.values()]
+ .map((entry) => ({
+ ...entry,
+ races: entry.races.sort((a, b) => a.id - b.id || a.gender.localeCompare(b.gender)),
+ zones: [...new Set(entry.zones)].sort(),
+ }))
+ .sort((a, b) => a.model.localeCompare(b.model));
+ const availableMappedModels = modelEntries.filter((entry) => entry.available);
+
+ return {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ eqDirectory,
+ summary: {
+ raceDefinitionCount: raceData.length,
+ mappedModelCount: modelEntries.length,
+ availableMappedModelCount: availableMappedModels.length,
+ missingMappedModelCount: modelEntries.length - availableMappedModels.length,
+ classicFaceModelCount: modelEntries.filter((entry) => entry.classicFaces).length,
+ expectedAppearanceCheckCount: availableMappedModels.reduce(
+ (sum, entry) => sum + entry.expectedAppearanceChecks,
+ 0
+ ),
+ },
+ models: modelEntries,
+ };
+};
+
+export const resolveModelSelection = (coverage, selection = {}) => {
+ const mode = selection.mode ?? 'explicit';
+ const available = coverage.models.filter((model) => model.available);
+ let selected;
+ if (mode === 'available') selected = available;
+ else if (mode === 'playable') selected = available.filter((model) => model.playable);
+ else if (mode === 'all-mapped') selected = coverage.models;
+ else {
+ const requested = new Set((selection.models ?? []).map(normalizeModel).filter(Boolean));
+ selected = coverage.models.filter((model) => requested.has(model.model));
+ const known = new Set(selected.map((model) => model.model));
+ const unknown = [...requested].filter((model) => !known.has(model));
+ if (unknown.length) {
+ throw new Error(`Unknown race model code(s): ${unknown.join(', ')}`);
+ }
+ }
+ return selected.map((model) => model.model).sort();
+};
diff --git a/tools/sage-qa/lib/extensionless-loader.mjs b/tools/sage-qa/lib/extensionless-loader.mjs
new file mode 100644
index 00000000..6b0fde77
--- /dev/null
+++ b/tools/sage-qa/lib/extensionless-loader.mjs
@@ -0,0 +1,12 @@
+export async function resolve(specifier, context, nextResolve) {
+ try {
+ return await nextResolve(specifier, context);
+ } catch (error) {
+ const isRelative = specifier.startsWith('./') || specifier.startsWith('../');
+ const hasExtension = /\.[a-z0-9]+$/i.test(specifier);
+ if (error?.code !== 'ERR_MODULE_NOT_FOUND' || !isRelative || hasExtension) {
+ throw error;
+ }
+ return nextResolve(`${specifier}.js`, context);
+ }
+}
diff --git a/tools/sage-qa/lib/memory.mjs b/tools/sage-qa/lib/memory.mjs
new file mode 100644
index 00000000..d9827513
--- /dev/null
+++ b/tools/sage-qa/lib/memory.mjs
@@ -0,0 +1,73 @@
+import os from 'node:os';
+
+const MB = 1024 * 1024;
+
+export const captureMemorySnapshot = (stage = 'unknown') => {
+ const totalBytes = os.totalmem();
+ const freeBytes = os.freemem();
+ const processMemory = process.memoryUsage();
+ return {
+ stage,
+ timestamp: new Date().toISOString(),
+ system: {
+ totalBytes,
+ freeBytes,
+ usedBytes: totalBytes - freeBytes,
+ usedPercent: totalBytes > 0 ? ((totalBytes - freeBytes) / totalBytes) * 100 : 0,
+ },
+ runner: {
+ rssBytes: processMemory.rss,
+ heapTotalBytes: processMemory.heapTotal,
+ heapUsedBytes: processMemory.heapUsed,
+ externalBytes: processMemory.external,
+ },
+ };
+};
+
+export const evaluateMemoryBudget = (snapshot, budget) => {
+ const violations = [];
+ const freeMB = snapshot.system.freeBytes / MB;
+ if (snapshot.system.usedPercent > budget.maxUsedPercent) {
+ violations.push(
+ `system memory use ${snapshot.system.usedPercent.toFixed(1)}% exceeds ${budget.maxUsedPercent}%`
+ );
+ }
+ if (freeMB < budget.minFreeMB) {
+ violations.push(`free memory ${freeMB.toFixed(0)} MB is below ${budget.minFreeMB} MB`);
+ }
+ const runnerRssMB = Number(snapshot.runner?.rssBytes ?? 0) / MB;
+ if (Number.isFinite(budget.maxRunnerRssMB) && runnerRssMB > budget.maxRunnerRssMB) {
+ violations.push(
+ `runner RSS ${runnerRssMB.toFixed(0)} MB exceeds ${budget.maxRunnerRssMB} MB`
+ );
+ }
+ const runnerExternalMB = Number(snapshot.runner?.externalBytes ?? 0) / MB;
+ if (Number.isFinite(budget.maxRunnerExternalMB) && runnerExternalMB > budget.maxRunnerExternalMB) {
+ violations.push(
+ `runner external memory ${runnerExternalMB.toFixed(0)} MB exceeds ${budget.maxRunnerExternalMB} MB`
+ );
+ }
+ return { pass: violations.length === 0, violations, freeMB };
+};
+
+const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+export const ensureMemoryBudget = async ({ budget, stage, onSnapshot = () => {} }) => {
+ const attempts = Math.max(1, Number(budget.checkAttempts ?? 1));
+ let evaluation;
+ let snapshot;
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ if (typeof globalThis.gc === 'function') {
+ globalThis.gc();
+ await wait(0);
+ }
+ snapshot = captureMemorySnapshot(stage);
+ evaluation = evaluateMemoryBudget(snapshot, budget);
+ onSnapshot(snapshot, evaluation);
+ if (evaluation.pass) return { snapshot, evaluation };
+ if (attempt < attempts) await wait(Math.max(0, Number(budget.settleMs ?? 0)));
+ }
+ throw new Error(`Memory budget blocked ${stage}: ${evaluation.violations.join('; ')}`);
+};
+
+export const bytesToMB = (bytes) => Number(bytes ?? 0) / MB;
diff --git a/tools/sage-qa/lib/playwright-runner.mjs b/tools/sage-qa/lib/playwright-runner.mjs
new file mode 100644
index 00000000..c3b8b843
--- /dev/null
+++ b/tools/sage-qa/lib/playwright-runner.mjs
@@ -0,0 +1,1497 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {
+ buildModelReviewUrl,
+ buildRaceAuditUrl,
+ buildZoneValidationUrl,
+} from './urls.mjs';
+import {
+ compareApprovedVisualBaseline,
+ comparePreviewEvidence,
+ evaluatePreviewEvidence,
+ runVisualInvariantCanaries,
+ visualBaselineKey,
+} from './visual-invariants.mjs';
+
+const chunk = (items, size) => {
+ const chunks = [];
+ for (let index = 0; index < items.length; index += size) {
+ chunks.push(items.slice(index, index + size));
+ }
+ return chunks;
+};
+
+const shouldCaptureScreenshot = (mode, pass, isFinal = false) =>
+ mode === 'always' || (mode === 'failures' && !pass) || (mode === 'final' && isFinal);
+
+const COMPACT_NATIVE_ARM_NORMALIZATION_MODELS = new Set([
+ 'qcf',
+ 'clm',
+ 'clf',
+ 'com',
+ 'cof',
+]);
+// A ratio of -0.5 means the upper arm is at least 30 degrees below horizontal.
+// The previous -0.25 threshold allowed visibly T-pose-like compact rigs through.
+const MAX_COMPACT_ARM_HORIZONTAL_RATIO = -0.5;
+
+const attachTelemetry = (page, telemetry, scope) => {
+ const successfulRequests = new Map();
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ const location = message.location();
+ const recoveredStatus = [...successfulRequests.entries()].find(
+ ([key]) => key.endsWith(` ${location?.url ?? ''}`)
+ )?.[1];
+ telemetry.consoleErrors.push({
+ scope,
+ text: message.text(),
+ location,
+ ...(recoveredStatus
+ ? { recovered: true, recoveredStatus }
+ : {}),
+ });
+ }
+ });
+ page.on('pageerror', (error) => {
+ telemetry.pageErrors.push({ scope, message: error.message, stack: error.stack ?? null });
+ });
+ page.on('requestfailed', (request) => {
+ const key = `${request.method()} ${request.url()}`;
+ const recoveredStatus = successfulRequests.get(key);
+ telemetry.requestFailures.push({
+ scope,
+ method: request.method(),
+ url: request.url(),
+ failure: request.failure()?.errorText ?? 'unknown',
+ ...(recoveredStatus
+ ? { recovered: true, recoveredStatus }
+ : {}),
+ });
+ });
+ page.on('response', (response) => {
+ if (response.status() < 400) {
+ const method = response.request().method();
+ const url = response.url();
+ successfulRequests.set(`${method} ${url}`, response.status());
+ const recoveredFailure = [...telemetry.requestFailures]
+ .reverse()
+ .find((failure) =>
+ failure.scope === scope &&
+ failure.method === method &&
+ failure.url === url &&
+ failure.recovered !== true
+ );
+ if (recoveredFailure) {
+ recoveredFailure.recovered = true;
+ recoveredFailure.recoveredStatus = response.status();
+ }
+ const recoveredHttpError = [...telemetry.httpErrors]
+ .reverse()
+ .find((error) =>
+ error.scope === scope &&
+ error.method === method &&
+ error.url === url &&
+ error.recovered !== true
+ );
+ if (recoveredHttpError) {
+ recoveredHttpError.recovered = true;
+ recoveredHttpError.recoveredStatus = response.status();
+ }
+ if (recoveredFailure || recoveredHttpError) {
+ const recoveredConsoleError = [...telemetry.consoleErrors]
+ .reverse()
+ .find((error) =>
+ error.scope === scope &&
+ error.location?.url === url &&
+ error.recovered !== true
+ );
+ if (recoveredConsoleError) recoveredConsoleError.recovered = true;
+ }
+ }
+ if (response.status() >= 400) {
+ telemetry.httpErrors.push({
+ scope,
+ status: response.status(),
+ method: response.request().method(),
+ url: response.url(),
+ });
+ }
+ });
+};
+
+const startTrace = async (context, mode) => {
+ if (mode === 'never') return false;
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
+ return true;
+};
+
+const finishTrace = async ({ context, active, tracePath, keep }) => {
+ if (!active) return;
+ await context.tracing.stop({ path: tracePath });
+ if (!keep) await fs.rm(tracePath, { force: true });
+};
+
+const createContext = async ({ browser, profile }) => {
+ const context = await browser.newContext({
+ viewport: profile.viewport,
+ deviceScaleFactor: 1,
+ ignoreHTTPSErrors: true,
+ serviceWorkers: 'block',
+ locale: 'en-US',
+ timezoneId: 'UTC',
+ colorScheme: 'dark',
+ reducedMotion: 'reduce',
+ });
+
+ await context.addInitScript(({ seed }) => {
+ let state = seed >>> 0;
+ Math.random = () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+ window.__spireSageQaSeed = seed;
+ }, { seed: Number(profile.visualValidation?.seed ?? 0x5a6e2026) });
+
+ // The release banner is unrelated to renderer QA. Keeping it deterministic
+ // prevents every disposable context from depending on external network access.
+ await context.route(
+ 'https://api.github.com/repos/Valorith/spire/releases?per_page=10',
+ (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
+ );
+ return context;
+};
+
+const screenshot = async (page, filePath) => {
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ await page.screenshot({ path: filePath, type: 'jpeg', quality: 85, fullPage: false });
+};
+
+const stabilizePreview = (page, fixedAnimationFraction, requestedModel) => page.evaluate(
+ ({ fraction, requestedModelCode }) => {
+ const scene =
+ window.gameController?.currentScene ??
+ window.gameController?.ZoneController?.currentScene ??
+ null;
+ if (!scene) return { available: false, animationGroupCount: 0 };
+ const normalizeName = (value) => `${value ?? ''}`
+ .replace(/^Clone of /, '')
+ .toLowerCase();
+ const model = normalizeName(requestedModelCode).slice(0, 3);
+ const controllerSpawns = Object.values(
+ window.gameController?.SpawnController?.spawns ?? {}
+ );
+ const sceneSpawns = [
+ ...(scene.rootNodes ?? []),
+ ...(scene.transformNodes ?? []),
+ ...(scene.meshes ?? []),
+ ].map((node) => node?.babylonSpawn).filter(Boolean);
+ const spawns = [...new Set([...sceneSpawns, ...controllerSpawns])];
+ const previewSpawns = spawns.filter((entry) =>
+ /face preview/i.test(`${entry?.spawn?.name ?? entry?.rootNode?.name ?? ''}`)
+ );
+ const spawn = previewSpawns.find(
+ (entry) =>
+ normalizeName(entry?.modelName).slice(0, 3) === model
+ ) ?? previewSpawns[0] ?? spawns.find(
+ (entry) => normalizeName(entry?.modelName).slice(0, 3) === model
+ );
+ const root = spawn?.rootNode ?? null;
+ const nodes = root
+ ? [root, ...(root.getDescendants?.(false) ?? [])]
+ : [];
+ const nodeSet = new Set(nodes);
+ const renderedMeshes = nodes.filter(
+ (node) => typeof node?.getTotalVertices === 'function' && node.getTotalVertices() > 0
+ );
+ const skeletons = [...new Set([
+ ...(spawn?.instanceContainer?.skeletons ?? []),
+ ...(spawn?.skeletons ?? []),
+ root?.skeleton,
+ ...renderedMeshes.map((mesh) => mesh?.skeleton),
+ ].filter(Boolean))];
+ const nativePoseOnly = spawn?.nativePoseOnly === true;
+ const previewAnimationDonorExpected =
+ spawn?.previewAnimationDonor?.expected === true;
+ const expectedMotion =
+ !!spawn &&
+ skeletons.length > 0 &&
+ (!nativePoseOnly || previewAnimationDonorExpected);
+ const preferredGroup = spawn?.getPreferredVisualAnimationGroup?.() ?? null;
+ const frameFractions = [0.1, 0.37, 0.63, 0.9];
+ const capturePose = (animationGroup) => {
+ const values = [];
+ let nonFiniteValueCount = 0;
+ const append = (matrix) => {
+ for (const value of Array.from(matrix ?? [])) {
+ if (Number.isFinite(value)) values.push(Number(value));
+ else {
+ values.push(null);
+ nonFiniteValueCount++;
+ }
+ }
+ };
+ for (const skeleton of skeletons) {
+ skeleton.computeAbsoluteTransforms?.();
+ for (const bone of skeleton.bones ?? []) {
+ append(bone?.getFinalMatrix?.()?.m ?? bone?._finalMatrix?.m);
+ }
+ }
+ const seenTargets = new Set();
+ for (const targetedAnimation of animationGroup?.targetedAnimations ?? []) {
+ const target = targetedAnimation?.target;
+ if (!target || seenTargets.has(target)) continue;
+ seenTargets.add(target);
+ if (typeof target.getFinalMatrix === 'function') {
+ append(target.getFinalMatrix()?.m ?? target?._finalMatrix?.m);
+ } else if (nodeSet.has(target) && typeof target.getWorldMatrix === 'function') {
+ target.computeWorldMatrix?.(true);
+ append(target.getWorldMatrix()?.m);
+ }
+ }
+ return { values, nonFiniteValueCount, targetCount: seenTargets.size };
+ };
+ let motion = {
+ available: !!spawn && (!expectedMotion || !!preferredGroup),
+ expectedMotion,
+ nativePoseOnly,
+ groupName: preferredGroup?.name ?? null,
+ frameCount: 0,
+ maximumPoseDelta: 0,
+ changedValueCount: 0,
+ nonFiniteValueCount: 0,
+ moving: false,
+ };
+ if (preferredGroup && expectedMotion) {
+ const from = Number(preferredGroup.from ?? 0);
+ const to = Number(preferredGroup.to ?? from);
+ const samples = [];
+ preferredGroup.pause?.();
+ for (const sampleFraction of frameFractions) {
+ preferredGroup.goToFrame?.(from + ((to - from) * sampleFraction));
+ scene.render?.();
+ scene.render?.();
+ samples.push(capturePose(preferredGroup));
+ }
+ const baseline = samples[0]?.values ?? [];
+ let maximumPoseDelta = 0;
+ let changedValueCount = 0;
+ let nonFiniteValueCount = 0;
+ for (const sample of samples) {
+ nonFiniteValueCount += sample.nonFiniteValueCount;
+ if (sample.values.length !== baseline.length) continue;
+ for (let index = 0; index < baseline.length; index += 1) {
+ const left = baseline[index];
+ const right = sample.values[index];
+ if (!Number.isFinite(left) || !Number.isFinite(right)) continue;
+ const delta = Math.abs(right - left);
+ maximumPoseDelta = Math.max(maximumPoseDelta, delta);
+ changedValueCount += delta > 0.00001 ? 1 : 0;
+ }
+ }
+ motion = {
+ ...motion,
+ frameCount: samples.length,
+ valueCount: baseline.length,
+ targetCount: samples[0]?.targetCount ?? 0,
+ maximumPoseDelta,
+ changedValueCount,
+ nonFiniteValueCount,
+ moving: maximumPoseDelta > 0.00001 && changedValueCount > 0,
+ };
+ }
+ scene.animationTimeScale = 0;
+ for (const group of scene.animationGroups ?? []) {
+ try {
+ const from = Number(group.from ?? 0);
+ const to = Number(group.to ?? from);
+ group.pause?.();
+ group.goToFrame?.(from + ((to - from) * fraction));
+ } catch (_error) {}
+ }
+ scene.render?.();
+ scene.render?.();
+ // The preview component initially frames the camera while its selected
+ // animation is still advancing. Re-seeking the animation alone therefore
+ // leaves a timing-dependent camera position even though the model pose is
+ // deterministic. Reframe from the frozen, skinned bounds so the same model
+ // cannot move around the screenshot between processes or repetitions.
+ const boundsForMeshes = (meshes) => {
+ const minimum = { x: Infinity, y: Infinity, z: Infinity };
+ const maximum = { x: -Infinity, y: -Infinity, z: -Infinity };
+ for (const mesh of meshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
+ for (const axis of ['x', 'y', 'z']) {
+ minimum[axis] = Math.min(minimum[axis], Number(box?.minimumWorld?.[axis]));
+ maximum[axis] = Math.max(maximum[axis], Number(box?.maximumWorld?.[axis]));
+ }
+ } catch (_error) {}
+ }
+ const values = [minimum.x, minimum.y, minimum.z, maximum.x, maximum.y, maximum.z];
+ if (!values.every(Number.isFinite)) return null;
+ return {
+ width: maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth: maximum.z - minimum.z,
+ minimum,
+ maximum,
+ };
+ };
+ const materialNamesForMesh = (mesh) => {
+ const material = mesh?.material;
+ if (!Array.isArray(material?.subMaterials)) {
+ return material?.name ? [`${material.name}`] : [];
+ }
+ const usedIndices = new Set(
+ (mesh.subMeshes ?? [])
+ .map((subMesh) => Number(subMesh?.materialIndex))
+ .filter((index) => Number.isInteger(index) && index >= 0)
+ );
+ const candidates = usedIndices.size > 0
+ ? [...usedIndices].map((index) => material.subMaterials[index])
+ : material.subMaterials;
+ return candidates.map((candidate) => `${candidate?.name ?? ''}`).filter(Boolean);
+ };
+ const previewBounds = boundsForMeshes(renderedMeshes);
+ let cameraFraming = null;
+ const camera = scene.activeCamera;
+ const modelReview = window.__spireSageModelReview;
+ if (
+ camera &&
+ previewBounds &&
+ modelReview?.ready === true &&
+ typeof modelReview.reframe === 'function'
+ ) {
+ cameraFraming = modelReview.reframe();
+ scene.render?.();
+ scene.render?.();
+ } else if (camera && previewBounds) {
+ const params = new URLSearchParams(window.location.search);
+ const close = params.has('sageRaceFacePreviewClose');
+ const requestedDistance = Number(params.get('sageRaceFacePreviewDistance') ?? 4.5);
+ const headMaterialPattern = /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
+ const headMeshes = renderedMeshes.filter((mesh) =>
+ [mesh?.name, ...materialNamesForMesh(mesh)].some((name) =>
+ headMaterialPattern.test(`${name ?? ''}`)
+ )
+ );
+ const closeBounds = close ? boundsForMeshes(headMeshes) : null;
+ const height = Math.max(1, previewBounds.height);
+ const target = {
+ x: (previewBounds.minimum.x + previewBounds.maximum.x) / 2,
+ y: (previewBounds.minimum.y + previewBounds.maximum.y) / 2,
+ z: (previewBounds.minimum.z + previewBounds.maximum.z) / 2,
+ };
+ if (close) {
+ if (closeBounds) {
+ target.x = (closeBounds.minimum.x + closeBounds.maximum.x) / 2;
+ target.y = (closeBounds.minimum.y + closeBounds.maximum.y) / 2;
+ target.z = (closeBounds.minimum.z + closeBounds.maximum.z) / 2;
+ } else {
+ target.y = previewBounds.minimum.y + height * 0.82;
+ }
+ target.y += 1.1;
+ }
+ const closeSpan = closeBounds
+ ? Math.max(1, closeBounds.width, closeBounds.height, closeBounds.depth)
+ : 1;
+ const distance = close
+ ? Math.max(8, Number.isFinite(requestedDistance) ? requestedDistance : 4.5, closeSpan * 1.8)
+ : Math.max(7, height * 1.7, previewBounds.depth * 1.8);
+ camera.position.x = target.x - distance;
+ camera.position.y = target.y + (close ? 0 : height * 0.08);
+ camera.position.z = target.z;
+ const targetVector = camera.getTarget?.()?.clone?.() ?? camera.position.clone?.();
+ if (targetVector) {
+ targetVector.x = target.x;
+ targetVector.y = target.y;
+ targetVector.z = target.z;
+ camera.setTarget?.(targetVector);
+ }
+ const previewLight = scene.lights?.find(
+ (light) => `${light?.name ?? ''}` === 'race-face-preview-light'
+ );
+ previewLight?.position?.copyFrom?.(camera.position);
+ scene.render?.();
+ scene.render?.();
+ cameraFraming = { close, distance, target };
+ }
+ return {
+ available: true,
+ animationGroupCount: scene.animationGroups?.length ?? 0,
+ fraction,
+ motion,
+ cameraFraming,
+ surface: modelReview?.ready === true ? 'model-review' : 'race-audit',
+ };
+ },
+ {
+ fraction: Number(fixedAnimationFraction ?? 0.35),
+ requestedModelCode: `${requestedModel ?? ''}`,
+ }
+);
+
+const collectPreviewEvidence = (page, requestedModel) => page.evaluate(
+ async ({ model, compactModels }) => {
+ const normalizeName = (value) => `${value ?? ''}`
+ .replace(/^Clone of /, '')
+ .toLowerCase();
+ const getTexture = (material) =>
+ material?.albedoTexture ??
+ material?._albedoTexture ??
+ material?.diffuseTexture ??
+ material?._diffuseTexture ??
+ material?.emissiveTexture ??
+ material?._emissiveTexture ??
+ null;
+ const isEffectOnlyMaterial = (material) =>
+ material?.metadata?.boundary === true ||
+ material?.metadata?.gltf?.extras?.boundary === true ||
+ material?.metadata?.extras?.boundary === true;
+ const textureReady = (texture) => {
+ if (!texture) return false;
+ if (typeof texture.isReady === 'function') return texture.isReady();
+ if (typeof texture.isReady === 'boolean') return texture.isReady;
+ return texture?._texture?.isReady !== false;
+ };
+ const textureSize = (texture) => {
+ const size = texture?.getSize?.() ?? {};
+ const internal = texture?.getInternalTexture?.() ?? texture?._texture;
+ return {
+ width: Number(size.width ?? internal?.width ?? 0),
+ height: Number(size.height ?? internal?.height ?? 0),
+ };
+ };
+ const controllerSpawns = Object.values(
+ window.gameController?.SpawnController?.spawns ?? {}
+ );
+ const scene =
+ window.gameController?.currentScene ??
+ window.gameController?.ZoneController?.currentScene ??
+ null;
+ const sceneSpawns = [
+ ...(scene?.rootNodes ?? []),
+ ...(scene?.transformNodes ?? []),
+ ...(scene?.meshes ?? []),
+ ]
+ .map((node) => node?.babylonSpawn)
+ .filter(Boolean);
+ const uniqueSpawns = [...new Set([...sceneSpawns, ...controllerSpawns])];
+ const previewSpawns = uniqueSpawns.filter((entry) =>
+ /face preview/i.test(`${entry?.spawn?.name ?? entry?.rootNode?.name ?? ''}`)
+ );
+ const spawn = previewSpawns.find(
+ (entry) =>
+ normalizeName(entry?.modelName).slice(0, 3) === model
+ ) ?? previewSpawns[0] ?? uniqueSpawns.find(
+ (entry) => normalizeName(entry?.modelName).slice(0, 3) === model
+ );
+ if (!spawn?.rootNode) {
+ return {
+ model,
+ available: false,
+ pass: false,
+ reason: 'preview-spawn-not-found',
+ };
+ }
+
+ const nodes = [
+ spawn.rootNode,
+ ...(spawn.rootNode.getDescendants?.(false) ?? []),
+ ];
+ const renderedMeshes = [...new Set(nodes)].filter(
+ (node) =>
+ typeof node?.getTotalVertices === 'function' &&
+ node.getTotalVertices() > 0 &&
+ node.isEnabled?.() !== false &&
+ node.isVisible !== false &&
+ Number(node.visibility ?? 1) > 0
+ );
+ const skeletons = [...new Set([
+ ...(spawn.skeletons ?? []),
+ ...renderedMeshes.map((mesh) => mesh.skeleton),
+ ].filter(Boolean))];
+ const boneRecords = skeletons.flatMap((skeleton) => {
+ const mesh = renderedMeshes.find((candidate) => candidate.skeleton === skeleton);
+ return (skeleton.bones ?? []).map((bone) => ({ bone, mesh }));
+ });
+ const materialSlots = [];
+ const seenMaterials = new Set();
+ const addMaterial = (material) => {
+ if (!material) return;
+ const key = material.uniqueId ?? material.name;
+ if (key !== undefined && seenMaterials.has(key)) return;
+ if (key !== undefined) seenMaterials.add(key);
+ materialSlots.push(material);
+ };
+ for (const mesh of renderedMeshes) {
+ const material = mesh.material;
+ if (!Array.isArray(material?.subMaterials)) {
+ addMaterial(material);
+ continue;
+ }
+ const usedIndices = new Set(
+ (mesh.subMeshes ?? [])
+ .map((subMesh) => Number(subMesh?.materialIndex))
+ .filter((index) => Number.isInteger(index) && index >= 0)
+ );
+ const candidates = usedIndices.size > 0
+ ? [...usedIndices].map((index) => material.subMaterials[index])
+ : material.subMaterials;
+ candidates.forEach(addMaterial);
+ }
+ const bounds = (() => {
+ const minimum = { x: Infinity, y: Infinity, z: Infinity };
+ const maximum = { x: -Infinity, y: -Infinity, z: -Infinity };
+ for (const mesh of renderedMeshes) {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
+ for (const axis of ['x', 'y', 'z']) {
+ minimum[axis] = Math.min(minimum[axis], Number(box?.minimumWorld?.[axis]));
+ maximum[axis] = Math.max(maximum[axis], Number(box?.maximumWorld?.[axis]));
+ }
+ } catch (_error) {}
+ }
+ const values = [minimum.x, minimum.y, minimum.z, maximum.x, maximum.y, maximum.z];
+ if (!values.every(Number.isFinite)) return null;
+ return {
+ width: maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth: maximum.z - minimum.z,
+ minimum,
+ maximum,
+ };
+ })();
+ const findNode = (name) => nodes.find(
+ (node) => normalizeName(node?.name) === name
+ );
+ const findBone = (names) => boneRecords.find(
+ ({ bone }) => names.includes(normalizeName(bone?.name))
+ );
+ const position = (candidate) => {
+ try {
+ const node = candidate?.node ?? candidate;
+ const bone = candidate?.bone;
+ node?.computeWorldMatrix?.(true);
+ const point = bone
+ ? bone.getAbsolutePosition?.(candidate.mesh)
+ : node?.getAbsolutePosition?.();
+ return point
+ ? { x: Number(point.x), y: Number(point.y), z: Number(point.z) }
+ : null;
+ } catch (_error) {
+ return null;
+ }
+ };
+ const arm = (side) => {
+ const upperNode = findNode(`bi_${side}`);
+ const upperBone = upperNode ? null : findBone([
+ `bi_${side}`,
+ `bibicep${side}`,
+ `biclav${side}`,
+ ]);
+ const upper = upperNode ? { node: upperNode } : upperBone;
+ const preferredLowerNames = [
+ `for_${side}`,
+ `fo_${side}`,
+ `arm_${side}`,
+ ];
+ const descendants = upperNode?.getDescendants?.(false) ?? [];
+ const lowerNode = preferredLowerNames
+ .map((name) => descendants.find(
+ (node) => normalizeName(node?.name) === name
+ ))
+ .find(Boolean) ?? upperNode?.getChildren?.()[0] ?? null;
+ const lowerBone = lowerNode ? null : findBone([
+ `for_${side}`,
+ `fo_${side}`,
+ `arm_${side}`,
+ `foforearm${side}`,
+ ]);
+ const lower = lowerNode ? { node: lowerNode } : lowerBone;
+ const upperPosition = position(upper);
+ const lowerPosition = position(lower);
+ if (!upperPosition || !lowerPosition) {
+ return {
+ available: false,
+ upper: upper?.node?.name ?? upper?.bone?.name ?? null,
+ lower: lower?.node?.name ?? lower?.bone?.name ?? null,
+ };
+ }
+ const dx = lowerPosition.x - upperPosition.x;
+ const dy = lowerPosition.y - upperPosition.y;
+ const dz = lowerPosition.z - upperPosition.z;
+ const length = Math.hypot(dx, dy, dz);
+ return {
+ available: length > 0.00001,
+ upper: upper?.node?.name ?? upper?.bone?.name ?? null,
+ lower: lower?.node?.name ?? lower?.bone?.name ?? null,
+ upperPosition,
+ lowerPosition,
+ verticalRatio: length > 0.00001 ? dy / length : null,
+ };
+ };
+
+ const compactExpected = compactModels.includes(model);
+ const leftArm = arm('l');
+ const rightArm = arm('r');
+ const neutralized = spawn.compactNativeArmNeutralized === true;
+ const boneNames = skeletons.flatMap((skeleton) =>
+ (skeleton.bones ?? []).map((bone) => normalizeName(bone.name))
+ ).sort();
+ let nonFiniteBoneMatrixCount = 0;
+ for (const skeleton of skeletons) {
+ for (const bone of skeleton.bones ?? []) {
+ const values = bone?.getFinalMatrix?.()?.m ?? bone?._finalMatrix?.m ?? [];
+ nonFiniteBoneMatrixCount += Array.from(values).filter(
+ (value) => !Number.isFinite(value)
+ ).length;
+ }
+ }
+ const textures = materialSlots.map(getTexture).filter(Boolean);
+ const ordinarySlots = materialSlots.filter((material) => !isEffectOnlyMaterial(material));
+ const untexturedSlots = ordinarySlots.filter((material) => !getTexture(material));
+ const materialSignature = materialSlots.map((material) => {
+ const texture = getTexture(material);
+ const size = textureSize(texture);
+ return [
+ `${material?.name ?? ''}`.toLowerCase(),
+ `${texture?.name ?? texture?.url ?? 'untextured'}`.toLowerCase(),
+ size.width,
+ size.height,
+ ].join(':');
+ }).sort().join('|');
+
+ const materialNamesForMesh = (mesh) => {
+ const material = mesh?.material;
+ if (!Array.isArray(material?.subMaterials)) {
+ return material?.name ? [normalizeName(material.name)] : [];
+ }
+ const usedIndices = new Set(
+ (mesh.subMeshes ?? [])
+ .map((subMesh) => Number(subMesh?.materialIndex))
+ .filter((index) => Number.isInteger(index) && index >= 0)
+ );
+ const candidates = usedIndices.size > 0
+ ? [...usedIndices].map((index) => material.subMaterials[index])
+ : material.subMaterials;
+ return candidates
+ .map((candidate) => normalizeName(candidate?.name).split('_mdf')[0])
+ .filter(Boolean);
+ };
+ const boundsForMesh = (mesh) => {
+ try {
+ mesh.computeWorldMatrix?.(true);
+ mesh.refreshBoundingInfo?.(true, true);
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
+ const minimum = Object.fromEntries(
+ ['x', 'y', 'z'].map((axis) => [axis, Number(box?.minimumWorld?.[axis])])
+ );
+ const maximum = Object.fromEntries(
+ ['x', 'y', 'z'].map((axis) => [axis, Number(box?.maximumWorld?.[axis])])
+ );
+ if (![...Object.values(minimum), ...Object.values(maximum)].every(Number.isFinite)) {
+ return null;
+ }
+ return {
+ width: maximum.x - minimum.x,
+ height: maximum.y - minimum.y,
+ depth: maximum.z - minimum.z,
+ minimum,
+ maximum,
+ center: {
+ x: (minimum.x + maximum.x) / 2,
+ y: (minimum.y + maximum.y) / 2,
+ z: (minimum.z + maximum.z) / 2,
+ },
+ };
+ } catch (_error) {
+ return null;
+ }
+ };
+ const influencingBoneNamesForMesh = (mesh) => {
+ try {
+ const skeleton = mesh?.skeleton ?? skeletons[0];
+ const bones = skeleton?.bones ?? [];
+ const indices = mesh?.getVerticesData?.('matricesIndices') ?? [];
+ const weights = mesh?.getVerticesData?.('matricesWeights') ?? [];
+ const extraIndices = mesh?.getVerticesData?.('matricesIndicesExtra') ?? [];
+ const extraWeights = mesh?.getVerticesData?.('matricesWeightsExtra') ?? [];
+ const names = new Set();
+ const addWeightedIndices = (boneIndices, boneWeights) => {
+ const count = Math.min(boneIndices.length, boneWeights.length);
+ for (let index = 0; index < count; index++) {
+ if (Number(boneWeights[index]) <= 0) {
+ continue;
+ }
+ const boneIndex = Number(boneIndices[index]);
+ const boneName = normalizeName(bones[boneIndex]?.name);
+ if (boneName) {
+ names.add(boneName);
+ }
+ }
+ };
+ addWeightedIndices(indices, weights);
+ addWeightedIndices(extraIndices, extraWeights);
+ return [...names].sort();
+ } catch (_error) {
+ return [];
+ }
+ };
+ // HE is the character head/face material family. FA is forearm armor, so
+ // treating it as a head could let a headless model pass this probe.
+ const headMaterialPattern = /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}$/i;
+ const headMeshes = renderedMeshes
+ .map((mesh) => ({
+ name: mesh?.name ?? '',
+ vertexCount: Number(mesh?.getTotalVertices?.() ?? 0),
+ materialNames: materialNamesForMesh(mesh),
+ influencingBoneNames: influencingBoneNamesForMesh(mesh),
+ bounds: boundsForMesh(mesh),
+ }))
+ .filter((entry) => entry.materialNames.some((name) =>
+ headMaterialPattern.test(name)
+ ));
+ const headBoneRecords = [
+ findBone(['he', 'head', 'hehead']),
+ findBone(['ne', 'neck', 'neneck01']),
+ ].filter(Boolean);
+ const headBonePositions = headBoneRecords.map((record) => ({
+ name: record?.bone?.name ?? '',
+ position: position(record),
+ }));
+
+ const pixels = await (async () => {
+ try {
+ const engine = scene?.getEngine?.();
+ const canvas = engine?.getRenderingCanvas?.();
+ const width = Number(canvas?.width ?? 0);
+ const height = Number(canvas?.height ?? 0);
+ scene?.render?.();
+ const raw = await engine?.readPixels?.(0, 0, width, height);
+ if (!raw || width <= 0 || height <= 0) return null;
+ const values = ArrayBuffer.isView(raw) ? raw : new Uint8Array(raw);
+ const multiplier = values instanceof Float32Array ? 255 : 1;
+ const colorAt = (x, y) => {
+ const offset = ((y * width) + x) * 4;
+ return [0, 1, 2].map((channel) =>
+ Math.max(0, Math.min(255, Number(values[offset + channel] ?? 0) * multiplier))
+ );
+ };
+ const cornerColors = [
+ colorAt(0, 0),
+ colorAt(width - 1, 0),
+ colorAt(0, height - 1),
+ colorAt(width - 1, height - 1),
+ ];
+ const background = [0, 1, 2].map((channel) =>
+ cornerColors.reduce((sum, color) => sum + color[channel], 0) / cornerColors.length
+ );
+ let foregroundPixelCount = 0;
+ let whitePixelCount = 0;
+ let minX = width;
+ let minY = height;
+ let maxX = -1;
+ let maxY = -1;
+ const foreground = new Uint8Array(width * height);
+ for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ const [red, green, blue] = colorAt(x, y);
+ const distance = Math.hypot(
+ red - background[0],
+ green - background[1],
+ blue - background[2]
+ );
+ if (distance <= 18) continue;
+ foreground[(y * width) + x] = 1;
+ foregroundPixelCount += 1;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ if (
+ red >= 245 && green >= 245 && blue >= 245 &&
+ Math.max(red, green, blue) - Math.min(red, green, blue) <= 8
+ ) {
+ whitePixelCount += 1;
+ }
+ }
+ }
+ const gridSize = 16;
+ const sums = Array.from({ length: gridSize * gridSize }, () => [0, 0, 0, 0]);
+ if (foregroundPixelCount > 0) {
+ const boxWidth = Math.max(1, maxX - minX + 1);
+ const boxHeight = Math.max(1, maxY - minY + 1);
+ for (let y = minY; y <= maxY; y += 1) {
+ for (let x = minX; x <= maxX; x += 1) {
+ if (!foreground[(y * width) + x]) continue;
+ const gridX = Math.min(gridSize - 1, Math.floor(((x - minX) / boxWidth) * gridSize));
+ const gridY = Math.min(gridSize - 1, Math.floor(((y - minY) / boxHeight) * gridSize));
+ const bucket = sums[(gridY * gridSize) + gridX];
+ const color = colorAt(x, y);
+ bucket[0] += color[0];
+ bucket[1] += color[1];
+ bucket[2] += color[2];
+ bucket[3] += 1;
+ }
+ }
+ }
+ return {
+ width,
+ height,
+ foregroundPixelCount,
+ whitePixelCount,
+ whitePixelRatio: foregroundPixelCount > 0
+ ? whitePixelCount / foregroundPixelCount
+ : null,
+ foregroundBounds: foregroundPixelCount > 0
+ ? {
+ x: minX / width,
+ y: minY / height,
+ width: (maxX - minX + 1) / width,
+ height: (maxY - minY + 1) / height,
+ }
+ : null,
+ signature: sums.flatMap(([red, green, blue, samples]) =>
+ samples > 0
+ ? [red, green, blue].map((value) => Math.round(value / samples))
+ : [0, 0, 0]
+ ),
+ };
+ } catch (error) {
+ return { error: error?.message ?? String(error), foregroundPixelCount: 0, signature: [] };
+ }
+ })();
+ return {
+ model,
+ available: true,
+ meshCount: renderedMeshes.length,
+ vertexCount: renderedMeshes.reduce(
+ (total, mesh) => total + Number(mesh.getTotalVertices?.() ?? 0),
+ 0
+ ),
+ runtimeBounds: bounds,
+ renderedMaterialCount: materialSlots.length,
+ texturedRenderedMaterialCount: textures.length,
+ untexturedRenderedMaterialCount: untexturedSlots.length,
+ untexturedRenderedMaterials: untexturedSlots.map((material) => material.name).sort(),
+ pendingTextureCount: textures.filter((texture) => !textureReady(texture)).length,
+ materialSignature,
+ headMeshes,
+ headBonePositions,
+ skeletonCount: skeletons.length,
+ boneCount: boneNames.length,
+ skeletonSignature: boneNames.join('|'),
+ nonFiniteBoneMatrixCount,
+ nativePoseOnly: spawn.nativePoseOnly === true,
+ previewAnimationDonorExpected:
+ spawn.previewAnimationDonor?.expected === true,
+ previewAnimationDonorPass:
+ spawn.previewAnimationDonor?.pass === true,
+ previewAnimationDonorName:
+ spawn.previewAnimationDonor?.donorName ?? null,
+ previewAnimationDonorFailureReason:
+ spawn.previewAnimationDonor?.failureReason ?? null,
+ previewAnimationDonorGroupCount: Number(
+ spawn.previewAnimationDonor?.attachedGroupCount ?? 0
+ ),
+ previewAnimationDonorTargetCount: Number(
+ spawn.previewAnimationDonor?.attachedTargetCount ?? 0
+ ),
+ previewAnimationDonorBindRelativeTargetCount: Number(
+ spawn.previewAnimationDonor?.bindRelativeTargetCount ?? 0
+ ),
+ previewAnimationDonorBindLockedRotationTargetNames:
+ spawn.previewAnimationDonor?.bindLockedRotationTargetNames ?? [],
+ previewAnimationDonorUnmatchedTargetNames:
+ spawn.previewAnimationDonor?.unmatchedTargetNames ?? [],
+ requestedModelVariation: spawn.requestedModelVariation ?? null,
+ loadedModelVariation: spawn.loadedModelVariation ?? null,
+ resolvedModelAsset: spawn.resolvedModelAsset ?? null,
+ bodyVariantFallback: spawn.bodyVariantFallback === true,
+ bodyVariantTextureFallbackApplied:
+ spawn.bodyVariantTextureFallbackApplied === true,
+ bodyVariantTextureFallbackAppliedCount: Number(
+ spawn.bodyVariantTextureFallbackAppliedCount ?? 0
+ ),
+ bodyVariantTextureFallbackAvailableCount: Number(
+ spawn.bodyVariantTextureFallbackAvailableCount ?? 0
+ ),
+ bodyVariantTextureCoverageRequiredCount: Number(
+ spawn.bodyVariantTextureCoverageRequiredCount ?? 0
+ ),
+ bodyVariantTextureCoverageAppliedCount: Number(
+ spawn.bodyVariantTextureCoverageAppliedCount ?? 0
+ ),
+ secondaryHeadBoneRemapFailureCount: Number(
+ spawn.secondaryHeadBoneRemapFailureCount ?? 0
+ ),
+ compactExpected,
+ neutralized,
+ targetCount: spawn.compactNativeArmTargetCount ?? 0,
+ leftArm,
+ rightArm,
+ pixels,
+ };
+ },
+ {
+ model: `${requestedModel ?? ''}`.slice(0, 3).toLowerCase(),
+ compactModels: [...COMPACT_NATIVE_ARM_NORMALIZATION_MODELS],
+ }
+);
+
+export const getBrowserLaunchOptions = (profile) => ({
+ headless: !profile.headed,
+ args: [
+ '--disable-dev-shm-usage',
+ '--enable-precise-memory-info',
+ // Current Chromium requires explicit opt-in before its bundled SwiftShader
+ // implementation may provide WebGL to a headless page. Without this flag,
+ // model-review campaigns wait for readiness until their timeout while the
+ // actual viewer has already failed with "WebGL not supported".
+ '--enable-unsafe-swiftshader',
+ ],
+});
+
+export const createTelemetry = () => ({
+ consoleErrors: [],
+ pageErrors: [],
+ requestFailures: [],
+ httpErrors: [],
+});
+
+export const summarizeTelemetry = (telemetry) => ({
+ consoleErrorCount: telemetry.consoleErrors.filter((entry) => !entry.recovered).length,
+ recoveredConsoleErrorCount: telemetry.consoleErrors.filter((entry) => entry.recovered).length,
+ pageErrorCount: telemetry.pageErrors.length,
+ requestFailureCount: telemetry.requestFailures.filter((entry) => !entry.recovered).length,
+ recoveredRequestFailureCount: telemetry.requestFailures.filter((entry) => entry.recovered).length,
+ httpErrorCount: telemetry.httpErrors.filter((entry) => !entry.recovered).length,
+ recoveredHttpErrorCount: telemetry.httpErrors.filter((entry) => entry.recovered).length,
+});
+
+export const runZoneValidation = async ({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ runId,
+ runDirectory,
+ telemetry,
+ beforeZone,
+ onEvent,
+}) => {
+ const config = profile.zoneValidation;
+ if (!config.enabled) return null;
+ const expectedReports = config.zones.length * config.cycles;
+ const traceMode = expectedReports <= profile.artifacts.maxTracedZoneReports
+ ? profile.artifacts.traceMode
+ : 'never';
+ const isolatedZones = config.isolateZones !== false;
+ const zoneGroups = isolatedZones
+ ? config.zones.map((zone) => [zone])
+ : [config.zones];
+ const reports = [];
+ const urls = [];
+ const tracePaths = [];
+ let pass = true;
+
+ onEvent({
+ type: 'phase-start',
+ phase: 'zone-validation',
+ zones: config.zones,
+ cycles: config.cycles,
+ expectedReports,
+ isolatedZones,
+ traceMode,
+ traceSuppressedForMemory: traceMode !== profile.artifacts.traceMode,
+ });
+
+ for (let index = 0; index < zoneGroups.length; index += 1) {
+ const zones = zoneGroups[index];
+ const zoneLabel = zones.length === 1 ? zones[0] : 'combined';
+ const safeZoneLabel = `${zoneLabel}`.replace(/[^a-z0-9_-]+/gi, '-');
+ await beforeZone?.({ index, count: zoneGroups.length, zones });
+ const scope = `zones:${profile.name}:${zoneLabel}`;
+ const context = await createContext({ browser, profile });
+ const tracePath = path.join(
+ runDirectory,
+ 'traces',
+ isolatedZones
+ ? `zone-validation-${safeZoneLabel}.zip`
+ : 'zone-validation.zip'
+ );
+ const traceActive = await startTrace(context, traceMode);
+ const page = await context.newPage();
+ attachTelemetry(page, telemetry, scope);
+ let result = null;
+ let zonePass = false;
+ let intentionalClose = false;
+ const url = buildZoneValidationUrl({
+ baseUrl,
+ route: profile.route,
+ eqDirectory,
+ zones,
+ cycles: config.cycles,
+ cacheBust: `${runId}-zones-${safeZoneLabel}`,
+ });
+ urls.push(url);
+ onEvent({
+ type: 'zone-start',
+ phase: 'zone-validation',
+ zone: zoneLabel,
+ url,
+ expectedReports: zones.length * config.cycles,
+ traceMode,
+ });
+ try {
+ const crashOrClose = new Promise((_, reject) => {
+ page.once('crash', () => reject(new Error(`Zone validation tab crashed: ${zoneLabel}`)));
+ page.once('close', () => {
+ if (!intentionalClose) {
+ reject(new Error(`Zone validation tab closed unexpectedly: ${zoneLabel}`));
+ }
+ });
+ });
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
+ await Promise.race([
+ page.waitForFunction(
+ (expected) => {
+ const summary = window.__spireSageValidationSummary;
+ return Boolean(summary?.finished) ||
+ Number(summary?.reports?.length ?? 0) >= expected;
+ },
+ zones.length * config.cycles,
+ { timeout: config.timeoutMs }
+ ),
+ crashOrClose,
+ ]);
+ result = await page.evaluate(
+ () => window.__spireSageValidationSummary ?? null
+ );
+ zonePass = result?.complete === true;
+ reports.push(...(result?.reports ?? []));
+ if (shouldCaptureScreenshot(profile.artifacts.screenshotMode, zonePass, true)) {
+ await screenshot(
+ page,
+ path.join(
+ runDirectory,
+ 'screenshots',
+ `zone-validation-${safeZoneLabel}-final.jpg`
+ )
+ );
+ }
+ onEvent({
+ type: 'zone-complete',
+ phase: 'zone-validation',
+ zone: zoneLabel,
+ pass: zonePass,
+ reportCount: result?.reports?.length ?? 0,
+ });
+ } catch (error) {
+ const partial = await page.evaluate(
+ () => window.__spireSageValidationSummary ?? null
+ ).catch(() => null);
+ reports.push(...(partial?.reports ?? []));
+ reports.push({
+ zone: zoneLabel,
+ validationSequence: { cycle: 0 },
+ pass: { all: false },
+ loadError: error.message,
+ });
+ await screenshot(
+ page,
+ path.join(
+ runDirectory,
+ 'screenshots',
+ `zone-validation-${safeZoneLabel}-error.jpg`
+ )
+ ).catch(() => {});
+ onEvent({
+ type: 'zone-error',
+ phase: 'zone-validation',
+ zone: zoneLabel,
+ error: error.message,
+ });
+ zonePass = false;
+ } finally {
+ pass = pass && zonePass;
+ const keepTrace =
+ traceMode === 'always' || (traceMode === 'failures' && !zonePass);
+ await finishTrace({
+ context,
+ active: traceActive,
+ tracePath,
+ keep: keepTrace,
+ }).catch(() => {});
+ if (keepTrace) tracePaths.push(tracePath);
+ intentionalClose = true;
+ await context.close().catch(() => {});
+ }
+ }
+
+ const failureCount = reports.filter((report) => report?.pass?.all !== true).length;
+ const complete =
+ pass &&
+ failureCount === 0 &&
+ reports.length >= expectedReports;
+ const raw = {
+ config: {
+ zones: config.zones,
+ cycles: config.cycles,
+ expectedReports,
+ isolatedZones,
+ },
+ reports,
+ finished: true,
+ complete,
+ failureCount,
+ };
+ onEvent({
+ type: 'phase-complete',
+ phase: 'zone-validation',
+ pass: complete,
+ reportCount: reports.length,
+ });
+ return {
+ pass: complete,
+ raw,
+ url: urls[0] ?? null,
+ urls,
+ traceMode,
+ tracePaths,
+ traceSuppressedForMemory: traceMode !== profile.artifacts.traceMode,
+ };
+};
+
+export const runRaceAuditBatches = async ({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ models,
+ runId,
+ runDirectory,
+ telemetry,
+ beforeBatch,
+ onBatchComplete,
+ onEvent,
+}) => {
+ if (!profile.raceAudit.enabled || models.length === 0) return [];
+ const batches = chunk(models, profile.raceAudit.batchSize);
+ const results = [];
+ for (let index = 0; index < batches.length; index += 1) {
+ const batch = batches[index];
+ await beforeBatch?.({ index, count: batches.length, models: batch });
+ const scope = `race-batch:${index + 1}`;
+ const context = await createContext({ browser, profile });
+ const tracePath = path.join(runDirectory, 'traces', `race-batch-${index + 1}.zip`);
+ const traceActive = await startTrace(context, profile.artifacts.traceMode);
+ const page = await context.newPage();
+ attachTelemetry(page, telemetry, scope);
+ let pass = false;
+ try {
+ const url = buildRaceAuditUrl({
+ baseUrl,
+ route: profile.route,
+ eqDirectory,
+ bootstrapZone: profile.raceAudit.bootstrapZone,
+ models: batch,
+ cacheBust: `${runId}-race-${index + 1}`,
+ forceRefresh: profile.raceAudit.forceRefresh === true,
+ });
+ onEvent({ type: 'batch-start', phase: 'race-audit', batch: index + 1, batchCount: batches.length, models: batch });
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
+ await page.waitForFunction(
+ (expected) => {
+ const audit = window.__spireSageRaceFaceAudit;
+ return audit?.complete === true && Number(audit?.auditedModelCount ?? 0) >= expected;
+ },
+ batch.length,
+ { timeout: profile.raceAudit.timeoutMs }
+ );
+ const audit = await page.evaluate(() => window.__spireSageRaceFaceAudit ?? null);
+ pass = audit?.complete === true && Number(audit?.failureCount ?? 0) === 0;
+ if (shouldCaptureScreenshot(profile.artifacts.screenshotMode, pass, index === batches.length - 1)) {
+ await screenshot(page, path.join(runDirectory, 'screenshots', `race-batch-${index + 1}.jpg`));
+ }
+ const result = { batch: index + 1, models: batch, pass, audit, url };
+ results.push(result);
+ await onBatchComplete?.({ index, count: batches.length, result });
+ onEvent({ type: 'batch-complete', phase: 'race-audit', batch: index + 1, pass, failures: audit?.failureCount ?? 0 });
+ } catch (error) {
+ await screenshot(page, path.join(runDirectory, 'screenshots', `race-batch-${index + 1}-error.jpg`)).catch(() => {});
+ onEvent({ type: 'batch-error', phase: 'race-audit', batch: index + 1, error: error.message });
+ throw error;
+ } finally {
+ const keepTrace = profile.artifacts.traceMode === 'always' || (profile.artifacts.traceMode === 'failures' && !pass);
+ await finishTrace({ context, active: traceActive, tracePath, keep: keepTrace }).catch(() => {});
+ await context.close();
+ }
+ }
+ return results;
+};
+
+export const runVisualSamples = async ({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ runId,
+ runDirectory,
+ telemetry,
+ beforeSample,
+ onEvent,
+}) => {
+ const canaries = runVisualInvariantCanaries();
+ onEvent({ type: 'invariant-canaries', phase: 'visual-samples', ...canaries });
+ if (!canaries.pass) {
+ throw new Error('Visual invariant mutation canaries did not reject every broken control');
+ }
+ const approvedBaselinePath = profile.visualValidation?.approvedBaselinePath
+ ? path.resolve(profile.visualValidation.approvedBaselinePath)
+ : null;
+ if (profile.visualValidation?.requireApprovedBaseline === true && !approvedBaselinePath) {
+ throw new Error('An approved visual baseline is required but approvedBaselinePath is not configured');
+ }
+ const approvedBaselineDocument = approvedBaselinePath
+ ? JSON.parse(await fs.readFile(approvedBaselinePath, 'utf8'))
+ : null;
+ if (approvedBaselineDocument && approvedBaselineDocument.schemaVersion !== 1) {
+ throw new Error(`Unsupported approved visual baseline schema in ${approvedBaselinePath}`);
+ }
+ const approvedBaselines = approvedBaselineDocument?.samples ?? {};
+ const results = [];
+ const visualSurface = profile.visualValidation?.surface ?? 'race-audit';
+ const repetitions = Math.max(2, Math.trunc(Number(
+ profile.visualValidation?.repetitions ?? 2
+ )));
+ for (let index = 0; index < profile.visualSamples.length; index += 1) {
+ const sample = profile.visualSamples[index];
+ const orientationSuffix = Object.hasOwn(sample, 'view')
+ ? `-view-${`${sample.view}`.trim().toLowerCase()}${sample.faceFocus === true ? '-face-focus' : ''}`
+ : Object.hasOwn(sample, 'heading')
+ ? `-heading-${sample.heading}`
+ : '';
+ await beforeSample?.({ index, count: profile.visualSamples.length, sample });
+ const observations = [];
+ const observationAnalyses = [];
+ const approvedBaselineAnalyses = [];
+ const auditPasses = [];
+ let firstAudit = null;
+ let firstUrl = null;
+ let sampleError = null;
+ try {
+ onEvent({ type: 'sample-start', phase: 'visual-samples', sample });
+ for (let repetition = 0; repetition < repetitions; repetition += 1) {
+ const scope = `visual:${sample.model}:repeat-${repetition + 1}`;
+ const context = await createContext({ browser, profile });
+ const page = await context.newPage();
+ attachTelemetry(page, telemetry, scope);
+ try {
+ const cacheBust = `${runId}-visual-${index + 1}-repeat-${repetition + 1}`;
+ const url = visualSurface === 'model-review'
+ ? buildModelReviewUrl({
+ baseUrl,
+ route: profile.route,
+ eqDirectory,
+ sample,
+ cacheBust,
+ })
+ : buildRaceAuditUrl({
+ baseUrl,
+ route: profile.route,
+ eqDirectory,
+ bootstrapZone: profile.raceAudit.bootstrapZone ?? 'blackburrow',
+ models: [sample.model],
+ preview: sample,
+ cacheBust,
+ });
+ firstUrl ??= url;
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
+ if (visualSurface === 'model-review') {
+ const reviewUrl = new URL(url);
+ await page.waitForFunction(
+ ({ model, view, faceFocus, face, texture, helmTexture }) => {
+ const review = window.__spireSageModelReview;
+ return review?.ready === true &&
+ review.model === model &&
+ review.view === view &&
+ review.faceFocus === faceFocus &&
+ Number(review.selection?.face) === face &&
+ Number(review.selection?.texture) === texture &&
+ Number(review.selection?.helmTexture) === helmTexture;
+ },
+ {
+ model: `${sample.model}`.trim().toLowerCase(),
+ view: reviewUrl.searchParams.get('sageModelView'),
+ faceFocus: reviewUrl.searchParams.get('sageModelFaceFocus') === '1',
+ face: Number(sample.face ?? 0),
+ texture: Number(sample.texture ?? 0),
+ helmTexture: Number(sample.helmTexture ?? 0),
+ },
+ { timeout: profile.visualValidation?.timeoutMs ?? 120000 }
+ );
+ } else {
+ await page.waitForFunction(
+ () => window.__spireSageRaceFaceAudit?.complete === true,
+ null,
+ { timeout: profile.visualValidation?.timeoutMs ?? 120000 }
+ );
+ }
+ const audit = visualSurface === 'model-review'
+ ? await page.evaluate(() => {
+ const review = window.__spireSageModelReview;
+ const diagnostics = review?.diagnostics ?? null;
+ const pass = review?.ready === true &&
+ diagnostics?.pass === true &&
+ review?.animationSafety?.pass !== false;
+ return {
+ complete: review?.ready === true,
+ failureCount: pass ? 0 : 1,
+ results: [{
+ bounds: diagnostics?.bounds ?? null,
+ fallbackTextureCount: Number(
+ diagnostics?.appearance?.fallbackTextureCount ?? 0
+ ),
+ faceVariantDeterminism: { pass: true },
+ semanticHeadOrientation:
+ diagnostics?.semanticHeadOrientation ?? null,
+ status: pass ? 'pass' : 'fail',
+ verticallyFlippedHeadTextureCount: Number(
+ Array.isArray(diagnostics?.headOrientation)
+ ? diagnostics.headOrientation.filter((item) => item.risk).length
+ : 0
+ ),
+ }],
+ viewer: {
+ faceFocus: review?.faceFocus ?? false,
+ framing: review?.framing ?? null,
+ model: review?.model ?? null,
+ qaApiVersion: review?.qaApiVersion ?? null,
+ automatedReviewSuggestion:
+ review?.automatedReviewSuggestion ?? null,
+ animationSafety: review?.animationSafety ?? null,
+ selection: review?.selection ?? null,
+ view: review?.view ?? null,
+ },
+ };
+ })
+ : await page.evaluate(() => window.__spireSageRaceFaceAudit ?? null);
+ firstAudit ??= audit;
+ const auditResult = audit?.results?.[0] ?? null;
+ const expectedAutomatedResponseConfigured =
+ Object.hasOwn(sample, 'expectedAutomatedResponse');
+ const actualAutomatedResponse =
+ audit?.viewer?.automatedReviewSuggestion?.response ?? null;
+ const automatedResponsePass =
+ !expectedAutomatedResponseConfigured ||
+ actualAutomatedResponse === sample.expectedAutomatedResponse;
+ const stabilization = await stabilizePreview(
+ page,
+ profile.visualValidation?.fixedAnimationFraction,
+ sample.model
+ );
+ const rawEvidence = await collectPreviewEvidence(page, sample.model);
+ const evidence = {
+ ...rawEvidence,
+ staticBounds: auditResult?.bounds ?? null,
+ fallbackTextureCount: Number(auditResult?.fallbackTextureCount ?? 0),
+ headOrientationRiskCount: Number(
+ auditResult?.verticallyFlippedHeadTextureCount ?? 0
+ ),
+ semanticHeadOrientation:
+ auditResult?.semanticHeadOrientation ?? null,
+ animationMotion: stabilization?.motion ?? null,
+ stabilization,
+ };
+ const invariantOptions = {
+ ...profile.visualValidation,
+ ...(sample.invariants ?? {}),
+ maximumArmVerticalRatio: evidence.compactExpected
+ ? MAX_COMPACT_ARM_HORIZONTAL_RATIO
+ : Number(
+ sample.invariants?.maximumArmVerticalRatio ??
+ profile.visualValidation?.maximumArmVerticalRatio ??
+ -0.3
+ ),
+ };
+ const analysis = evaluatePreviewEvidence(evidence, invariantOptions);
+ const approvedBaselineAnalysis = compareApprovedVisualBaseline(
+ evidence,
+ approvedBaselines[visualBaselineKey(sample)],
+ invariantOptions
+ );
+ observations.push(evidence);
+ observationAnalyses.push(analysis);
+ approvedBaselineAnalyses.push(approvedBaselineAnalysis);
+ auditPasses.push(
+ audit?.complete === true &&
+ Number(audit?.failureCount ?? 0) === 0 &&
+ auditResult?.faceVariantDeterminism?.pass !== false &&
+ automatedResponsePass
+ );
+ const baseName = `${String(index + 1).padStart(2, '0')}-${sample.model}-face-${sample.face ?? 0}-texture-${sample.texture ?? 0}${orientationSuffix}`;
+ const fileName = repetition === 0
+ ? `${baseName}.jpg`
+ : `${baseName}-repeat-${repetition + 1}.jpg`;
+ await screenshot(page, path.join(runDirectory, 'screenshots', fileName));
+ } catch (error) {
+ sampleError = error;
+ const fileName = `${String(index + 1).padStart(2, '0')}-${sample.model}-repeat-${repetition + 1}-error.jpg`;
+ await screenshot(page, path.join(runDirectory, 'screenshots', fileName)).catch(() => {});
+ break;
+ } finally {
+ await context.close();
+ }
+ }
+ if (sampleError) throw sampleError;
+ const repeatability = comparePreviewEvidence(
+ observations,
+ {
+ ...profile.visualValidation,
+ ...(sample.invariants ?? {}),
+ }
+ );
+ const pass =
+ observations.length === repetitions &&
+ auditPasses.every(Boolean) &&
+ observationAnalyses.every((analysis) => analysis.pass) &&
+ approvedBaselineAnalyses.every((analysis) => analysis.pass) &&
+ repeatability.pass;
+ const fileName = `${String(index + 1).padStart(2, '0')}-${sample.model}-face-${sample.face ?? 0}-texture-${sample.texture ?? 0}${orientationSuffix}.jpg`;
+ const result = {
+ ...sample,
+ surface: visualSurface,
+ pass,
+ repetitions,
+ status: firstAudit?.results?.[0]?.status ?? 'unknown',
+ auditPasses,
+ observations,
+ observationAnalyses,
+ approvedBaselineKey: visualBaselineKey(sample),
+ approvedBaselineAnalyses,
+ repeatability,
+ relativeScreenshot: `screenshots/${fileName}`,
+ url: firstUrl,
+ };
+ results.push(result);
+ onEvent({
+ type: 'sample-complete',
+ phase: 'visual-samples',
+ pass: result.pass,
+ sample: result,
+ });
+ } catch (error) {
+ const fileName = `${String(index + 1).padStart(2, '0')}-${sample.model}-error.jpg`;
+ results.push({ ...sample, pass: false, error: error.message, relativeScreenshot: `screenshots/${fileName}` });
+ onEvent({ type: 'sample-error', phase: 'visual-samples', sample, error: error.message });
+ }
+ }
+ return results;
+};
diff --git a/tools/sage-qa/lib/profile.mjs b/tools/sage-qa/lib/profile.mjs
new file mode 100644
index 00000000..9b09995e
--- /dev/null
+++ b/tools/sage-qa/lib/profile.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { asBoolean, asNumber } from './args.mjs';
+
+const DEFAULT_PROFILE = {
+ schemaVersion: 1,
+ route: '/sage',
+ viewport: { width: 1280, height: 720 },
+ zoneValidation: {
+ enabled: true,
+ zones: ['blackburrow'],
+ cycles: 1,
+ timeoutMs: 240000,
+ isolateZones: true,
+ },
+ raceAudit: {
+ enabled: true,
+ // Structural QA should validate the exact cached artifact used by normal
+ // zone rendering. Missing artifacts are still generated on demand; a
+ // destructive all-source refresh is an explicit archive diagnostic.
+ forceRefresh: false,
+ bootstrapZone: 'blackburrow',
+ batchSize: 12,
+ timeoutMs: 360000,
+ modelSelection: { mode: 'explicit', models: [] },
+ },
+ staticTextureAudit: { enabled: true, timeoutMs: 120000 },
+ visualSamples: [],
+ visualValidation: {
+ surface: 'race-audit',
+ // Archive generation may legitimately need the longer race-audit timeout,
+ // but a cached visual sample should never be allowed to stall a campaign
+ // for that entire window.
+ timeoutMs: 120000,
+ repetitions: 2,
+ seed: 1517166630,
+ fixedAnimationFraction: 0.35,
+ minimumMeshCount: 1,
+ minimumVertexCount: 3,
+ minimumExtent: 0.001,
+ maximumBoundsAspectRatio: 80,
+ minimumStaticBoundsRatio: 0.15,
+ maximumStaticBoundsRatio: 5,
+ minimumForegroundPixels: 500,
+ maximumWhitePixelRatio: null,
+ requireFullMaterialCoverage: true,
+ requireArmsDown: false,
+ requireAnimationMotion: true,
+ minimumAnimationPoseDelta: 0.00001,
+ minimumAnimationFrameCount: 3,
+ maximumArmVerticalRatio: -0.3,
+ maximumRepeatBoundsDelta: 0.025,
+ maximumRepeatPixelDelta: 0.04,
+ approvedBaselinePath: 'tools/sage-qa/baselines/model-regression.json',
+ requireApprovedBaseline: false,
+ maximumApprovedPixelDelta: 0.035,
+ maximumApprovedHeadPixelDelta: 0.025,
+ maximumApprovedForegroundBoundsDelta: 0.06,
+ },
+ soak: {
+ enabled: false,
+ warmupCycles: 1,
+ resourceKeys: ['meshes', 'materials', 'textures', 'skeletons', 'animationGroups'],
+ resourceTolerancePercent: 2,
+ resourceToleranceAbsolute: 4,
+ maxJsHeapGrowthMB: 384,
+ },
+ memory: {
+ maxUsedPercent: 88,
+ minFreeMB: 4096,
+ maxRunnerRssMB: 2048,
+ maxRunnerExternalMB: 1536,
+ checkAttempts: 2,
+ settleMs: 2000,
+ },
+ artifacts: {
+ screenshotMode: 'failures',
+ traceMode: 'failures',
+ maxTracedZoneReports: 12,
+ keepRuns: 10,
+ maxTotalMB: 2048,
+ },
+ diagnostics: {
+ failOnPageErrors: true,
+ failOnConsoleErrors: true,
+ failOnRequestFailures: true,
+ failOnHttpErrors: true,
+ },
+};
+
+const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
+const MODEL_REVIEW_RESPONSES = new Set([
+ null,
+ 'nothing-visible',
+ 'model-distorted',
+ 'head-missing',
+ 'improper-animation',
+ 'no-animation',
+ 't-pose',
+ 'head-mesh-upside-down',
+ 'other',
+]);
+
+export const deepMerge = (base, override) => {
+ if (!isObject(base) || !isObject(override)) return override ?? base;
+ const result = { ...base };
+ for (const [key, value] of Object.entries(override)) {
+ result[key] = isObject(value) && isObject(base[key])
+ ? deepMerge(base[key], value)
+ : value;
+ }
+ return result;
+};
+
+const pathExists = (candidate) => fs.access(candidate).then(() => true).catch(() => false);
+
+export const resolveEqDirectory = async ({ requested, env = process.env } = {}) => {
+ const candidates = [
+ requested,
+ env.SAGE_EQ_DIR,
+ 'C:/EQEmuCW-Live',
+ 'C:/EQEmuCW',
+ ].filter(Boolean);
+
+ for (const candidate of candidates) {
+ const absolute = path.resolve(candidate);
+ if (
+ await pathExists(path.join(absolute, 'eqsage', 'models')) &&
+ await pathExists(path.join(absolute, 'eqsage', 'textures'))
+ ) {
+ return absolute;
+ }
+ }
+ throw new Error('No EQ directory with eqsage/models and eqsage/textures was found. Pass --eq-dir .');
+};
+
+const normalizeZones = (zones) => [...new Set((zones ?? [])
+ .map((zone) => `${zone}`.trim().toLowerCase())
+ .filter(Boolean))];
+
+export const filterVisualSamples = (samples, requestedModels) => {
+ const requested = new Set(normalizeZones(
+ Array.isArray(requestedModels)
+ ? requestedModels
+ : `${requestedModels ?? ''}`.split(',')
+ ));
+ return (samples ?? []).filter((sample) =>
+ requested.has(`${sample?.model ?? ''}`.trim().toLowerCase())
+ );
+};
+
+export const validateProfile = (profile) => {
+ const errors = [];
+ if (profile.schemaVersion !== 1) errors.push('schemaVersion must be 1');
+ if (!profile.name) errors.push('name is required');
+ if (profile.zoneValidation.enabled && profile.zoneValidation.zones.length === 0) {
+ errors.push('zoneValidation.zones must contain at least one zone');
+ }
+ if (profile.zoneValidation.cycles < 1) errors.push('zoneValidation.cycles must be at least 1');
+ if (typeof profile.zoneValidation.isolateZones !== 'boolean') {
+ errors.push('zoneValidation.isolateZones must be a boolean');
+ }
+ if (typeof profile.raceAudit.forceRefresh !== 'boolean') {
+ errors.push('raceAudit.forceRefresh must be a boolean');
+ }
+ if (profile.raceAudit.enabled && profile.raceAudit.batchSize < 1) {
+ errors.push('raceAudit.batchSize must be at least 1');
+ }
+ if (!['explicit', 'available', 'playable', 'all-mapped'].includes(profile.raceAudit.modelSelection.mode)) {
+ errors.push('raceAudit.modelSelection.mode is invalid');
+ }
+ if (!['never', 'final', 'failures', 'always'].includes(profile.artifacts.screenshotMode)) {
+ errors.push('artifacts.screenshotMode is invalid');
+ }
+ if (!['never', 'failures', 'always'].includes(profile.artifacts.traceMode)) {
+ errors.push('artifacts.traceMode is invalid');
+ }
+ if (profile.artifacts.maxTracedZoneReports < 0) {
+ errors.push('artifacts.maxTracedZoneReports must be at least 0');
+ }
+ if (Number(profile.visualValidation?.repetitions) < 2) {
+ errors.push('visualValidation.repetitions must be at least 2');
+ }
+ if (Number(profile.visualValidation?.timeoutMs) < 1000) {
+ errors.push('visualValidation.timeoutMs must be at least 1000');
+ }
+ if (!['race-audit', 'model-review'].includes(profile.visualValidation?.surface)) {
+ errors.push('visualValidation.surface must be race-audit or model-review');
+ }
+ for (const [index, sample] of (profile.visualSamples ?? []).entries()) {
+ if (
+ Object.hasOwn(sample, 'expectedAutomatedResponse') &&
+ !MODEL_REVIEW_RESPONSES.has(sample.expectedAutomatedResponse)
+ ) {
+ errors.push(
+ `visualSamples[${index}].expectedAutomatedResponse is invalid`
+ );
+ }
+ }
+ if (
+ Number(profile.visualValidation?.fixedAnimationFraction) < 0 ||
+ Number(profile.visualValidation?.fixedAnimationFraction) > 1
+ ) {
+ errors.push('visualValidation.fixedAnimationFraction must be between 0 and 1');
+ }
+ if (Number(profile.visualValidation?.maximumRepeatBoundsDelta) < 0) {
+ errors.push('visualValidation.maximumRepeatBoundsDelta must be at least 0');
+ }
+ if (Number(profile.visualValidation?.maximumRepeatPixelDelta) < 0) {
+ errors.push('visualValidation.maximumRepeatPixelDelta must be at least 0');
+ }
+ for (const key of [
+ 'minimumAnimationPoseDelta',
+ 'minimumAnimationFrameCount',
+ 'maximumApprovedPixelDelta',
+ 'maximumApprovedHeadPixelDelta',
+ 'maximumApprovedForegroundBoundsDelta',
+ ]) {
+ if (Number(profile.visualValidation?.[key]) < 0) {
+ errors.push(`visualValidation.${key} must be at least 0`);
+ }
+ }
+ if (
+ profile.visualValidation?.requireApprovedBaseline === true &&
+ !`${profile.visualValidation?.approvedBaselinePath ?? ''}`.trim()
+ ) {
+ errors.push('visualValidation.approvedBaselinePath is required when approved baselines are enforced');
+ }
+ if (
+ profile.soak?.enabled &&
+ profile.zoneValidation.cycles < Number(profile.soak.warmupCycles ?? 1) + 2
+ ) {
+ errors.push('zoneValidation.cycles must provide warmup, baseline, and comparison cycles when soak is enabled');
+ }
+ if (profile.memory.maxUsedPercent <= 0 || profile.memory.maxUsedPercent > 100) {
+ errors.push('memory.maxUsedPercent must be between 1 and 100');
+ }
+ if (profile.memory.maxRunnerRssMB <= 0) errors.push('memory.maxRunnerRssMB must be greater than 0');
+ if (profile.memory.maxRunnerExternalMB <= 0) errors.push('memory.maxRunnerExternalMB must be greater than 0');
+ if (errors.length) throw new Error(`Invalid Sage QA profile:\n- ${errors.join('\n- ')}`);
+ return profile;
+};
+
+export const loadProfile = async ({ repoRoot, profile: profileName = 'smoke', args = {} }) => {
+ const profilePath = profileName.endsWith('.json') || profileName.includes('/') || profileName.includes('\\')
+ ? path.resolve(profileName)
+ : path.join(repoRoot, 'tools', 'sage-qa', 'profiles', `${profileName}.json`);
+ const loaded = JSON.parse(await fs.readFile(profilePath, 'utf8'));
+ const merged = deepMerge(DEFAULT_PROFILE, loaded);
+
+ if (args.cycles !== undefined) merged.zoneValidation.cycles = Math.max(1, Math.trunc(asNumber(args.cycles, 1)));
+ if (args.zones !== undefined) {
+ merged.zoneValidation.zones = `${args.zones}`.split(',');
+ }
+ if (args.timeoutMs !== undefined) merged.zoneValidation.timeoutMs = Math.max(1000, asNumber(args.timeoutMs, merged.zoneValidation.timeoutMs));
+ if (args.batchSize !== undefined) merged.raceAudit.batchSize = Math.max(1, Math.trunc(asNumber(args.batchSize, merged.raceAudit.batchSize)));
+ if (args.raceModels !== undefined) {
+ merged.raceAudit.modelSelection = {
+ mode: 'explicit',
+ models: `${args.raceModels}`.split(','),
+ };
+ }
+ if (args.zoneValidation !== undefined) merged.zoneValidation.enabled = asBoolean(args.zoneValidation, merged.zoneValidation.enabled);
+ if (args.raceAudit !== undefined) merged.raceAudit.enabled = asBoolean(args.raceAudit, merged.raceAudit.enabled);
+ if (args.raceForceRefresh !== undefined) {
+ merged.raceAudit.forceRefresh = asBoolean(
+ args.raceForceRefresh,
+ merged.raceAudit.forceRefresh
+ );
+ }
+ if (args.staticTextureAudit !== undefined) merged.staticTextureAudit.enabled = asBoolean(args.staticTextureAudit, merged.staticTextureAudit.enabled);
+ if (args.headed !== undefined) merged.headed = asBoolean(args.headed, false);
+ if (args.visualModels !== undefined) {
+ merged.visualSamples = filterVisualSamples(
+ merged.visualSamples,
+ args.visualModels
+ );
+ }
+ if (args.visualSurface !== undefined) {
+ merged.visualValidation.surface = `${args.visualSurface}`.trim().toLowerCase();
+ }
+
+ merged.zoneValidation.zones = normalizeZones(merged.zoneValidation.zones);
+ merged.raceAudit.modelSelection.models = normalizeZones(merged.raceAudit.modelSelection.models);
+ merged.profilePath = profilePath;
+ return validateProfile(merged);
+};
+
+export const DEFAULTS = DEFAULT_PROFILE;
diff --git a/tools/sage-qa/lib/report.mjs b/tools/sage-qa/lib/report.mjs
new file mode 100644
index 00000000..30f4a49a
--- /dev/null
+++ b/tools/sage-qa/lib/report.mjs
@@ -0,0 +1,114 @@
+const escapeHtml = (value) => `${value ?? ''}`
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+
+const number = (value) => Number(value ?? 0).toLocaleString('en-US');
+
+const passBadge = (pass) => `${pass ? 'PASS' : 'FAIL'} `;
+
+export const renderHtmlReport = (run) => {
+ const zoneReports = run.zoneValidation?.raw?.reports ?? [];
+ const raceFailures = run.raceAudit?.summary?.failures ?? [];
+ const animationDiagnostics = run.raceAudit?.summary?.animationDiagnostics ?? [];
+ const memoryRows = (run.memorySnapshots ?? []).map((snapshot) => `
+
+ ${escapeHtml(snapshot.stage)}
+ ${escapeHtml(snapshot.timestamp)}
+ ${(snapshot.system.usedPercent ?? 0).toFixed(1)}%
+ ${(snapshot.system.freeBytes / 1048576).toFixed(0)} MB
+ ${(snapshot.runner.rssBytes / 1048576).toFixed(0)} MB
+ `).join('');
+ const zoneRows = zoneReports.map((report) => `
+
+ ${passBadge(report.pass?.all)}
+ ${escapeHtml(report.zone)}
+ ${report.validationSequence?.cycle ?? 1}
+ ${number(report.spawns?.loaded)}/${number(report.spawns?.requested)}
+ ${number(report.visuals?.readyTextureCount)}/${number(report.visuals?.materialSlotCount)}
+ ${number(report.visuals?.animatedSkeletonSpawnCount)}/${number(report.visuals?.skeletonSpawnCount)}
+ ${number(report.visuals?.runtimeAnimation?.movingSpawnCount)}/${number(report.visuals?.runtimeAnimation?.probedSpawnCount)}
+ ${number(report.visuals?.playingAnimationGroupCount)}/${number(report.visuals?.animationGroupCount)}
+ ${number(report.visuals?.excessAnimationGroupCount)}
+ ${number(report.visuals?.appearanceTextureDecodeFailureCount)}
+ ${number(report.visuals?.nameplateCount)}/${number(report.visuals?.nameplateExpectedCount)}
+ ${number(report.doors?.loaded)}/${number(report.doors?.visibleRequested)}
+ ${number(report.sceneResources?.animationGroups)}
+ ${report.runtimeMemory?.jsHeapUsedBytes ? `${(report.runtimeMemory.jsHeapUsedBytes / 1048576).toFixed(0)} MB` : 'n/a'}
+ `).join('');
+ const samples = (run.visualSamples ?? []).map((sample) => {
+ const violations = [
+ ...(sample.observationAnalyses ?? []).flatMap((analysis) => analysis.violations ?? []),
+ ...(sample.approvedBaselineAnalyses ?? []).flatMap((analysis) => analysis.violations ?? []),
+ ...(sample.repeatability?.violations ?? []),
+ ];
+ return `
+
+
+ ${escapeHtml(sample.model.toUpperCase())} face ${sample.face ?? 0}, texture ${sample.texture ?? 0}, helm ${sample.helmTexture ?? 0}${Object.hasOwn(sample, 'heading') ? `, heading ${sample.heading}` : ''}${Object.hasOwn(sample, 'view') ? `, view ${escapeHtml(sample.view)}${sample.faceFocus === true ? ' (face focus)' : ''}` : ''} · surface ${escapeHtml(sample.surface ?? 'race-audit')} · ${number(sample.repetitions ?? 1)} independent render(s) · motion ${sample.observations?.[0]?.animationMotion?.expectedMotion === false ? 'native static pose' : sample.observations?.[0]?.animationMotion?.moving === true ? 'verified' : 'missing'} · approved baseline ${(sample.approvedBaselineAnalyses ?? []).some((analysis) => analysis.skipped === false) ? 'checked' : 'not available'} ${passBadge(sample.pass)}${violations.length ? `${escapeHtml([...new Set(violations)].join(', '))}` : ''}
+ `;
+ }).join('');
+ const failureItems = [
+ ...(run.failures ?? []),
+ ...raceFailures.map((failure) => `Race ${failure.model}: ${failure.status}`),
+ ...(run.soak?.violations ?? []),
+ ].map((failure) => `${escapeHtml(typeof failure === 'string' ? failure : JSON.stringify(failure))} `).join('');
+
+ return `
+
+
+
+
+ Sage QA ${escapeHtml(run.runId)}
+
+
+
+ Sage QA ${passBadge(run.pass)}
+ ${escapeHtml(run.runId)} · ${escapeHtml(run.profile?.name)} · ${escapeHtml(run.startedAt)} to ${escapeHtml(run.completedAt)}
+
+
Zones${number(run.zoneValidation?.summary?.reportCount)}
+
NPCs${number(run.zoneValidation?.summary?.npcCount)}
+
Zone models${number(run.zoneValidation?.summary?.uniqueModelCount)}
+
Audited models${number(run.raceAudit?.summary?.auditedModelCount)}
+
Appearance checks${number(run.raceAudit?.summary?.appearanceVariantCountAudited)}
+
Animation diagnostics${number(run.raceAudit?.summary?.animationDiagnosticCount)}
+
Texture slots${number(run.zoneValidation?.summary?.npcTextureReadyCount)}
+
Texture decode failures${number(run.zoneValidation?.summary?.appearanceTextureDecodeFailureCount)}
+
Playing animation groups${number(run.zoneValidation?.summary?.playingAnimationGroupCount)}/${number(run.zoneValidation?.summary?.animationGroupCount)}
+
Excess animation groups${number(run.zoneValidation?.summary?.excessAnimationGroupCount)}
+
Independent visual renders${number((run.visualSamples ?? []).reduce((total, sample) => total + Number(sample.repetitions ?? 0), 0))}
+
+ Zone matrix
+ Status Zone Cycle NPCs Textures Skeletons Moving probes Playing animation groups Excess animation groups Texture decode failures Nameplates Doors Scene animation groups JS heap ${zoneRows}
+ Memory stewardship
+ Stage Time System used System free Runner RSS ${memoryRows}
+ ${samples ? `Visual samples ${samples}
` : ''}
+ Static animation diagnostics
+ ${animationDiagnostics.length
+ ? `${number(animationDiagnostics.length)} static model container(s) expose animation groups with no measurable pose delta. These are retained as coverage diagnostics; runtime T-pose correctness is enforced by the zone matrix.
${escapeHtml(animationDiagnostics.map((item) => item.model).join(', '))}
`
+ : 'None.
'}
+ Failures
+ ${failureItems ? `` : 'None.
'}
+
+`;
+};
diff --git a/tools/sage-qa/lib/server-readiness.mjs b/tools/sage-qa/lib/server-readiness.mjs
new file mode 100644
index 00000000..c44dcb0c
--- /dev/null
+++ b/tools/sage-qa/lib/server-readiness.mjs
@@ -0,0 +1,150 @@
+import { createHash } from 'node:crypto';
+import fs from 'node:fs/promises';
+
+const sleep = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
+
+export const verifyServedEmbedEntry = async ({
+ baseUrl,
+ buildEntryPath,
+ entryPath = '/eqsage-embed/eqsage-embed.js',
+ fetchImpl = fetch,
+}) => {
+ const expected = await fs.readFile(buildEntryPath);
+ const url = new URL(entryPath, baseUrl);
+ url.searchParams.set('sageQaBundleProbe', Date.now().toString(36));
+ const response = await fetchImpl(url, {
+ cache: 'no-store',
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!response.ok) {
+ throw new Error(`Served Sage entry returned HTTP ${response.status}: ${url}`);
+ }
+ const served = Buffer.from(await response.arrayBuffer());
+ const expectedSha256 = sha256(expected);
+ const servedSha256 = sha256(served);
+ if (expectedSha256 !== servedSha256) {
+ throw new Error(
+ 'The running Spire server is serving a stale Sage bundle. ' +
+ `Expected ${expectedSha256.slice(0, 12)}, received ${servedSha256.slice(0, 12)}. ` +
+ 'Restart the frontend development server before QA.'
+ );
+ }
+ return {
+ pass: true,
+ buildEntryPath,
+ url: url.href,
+ byteLength: served.length,
+ sha256: servedSha256,
+ };
+};
+
+export const collectModuleSpecifiers = (source) => {
+ const specifiers = new Set();
+ const patterns = [
+ /\bfrom\s*["']([^"']+\.js(?:\?[^"']*)?)["']/g,
+ /\bimport\s*\(\s*["']([^"']+\.js(?:\?[^"']*)?)["']\s*\)/g,
+ /\bimport\s*["']([^"']+\.js(?:\?[^"']*)?)["']/g,
+ ];
+ for (const pattern of patterns) {
+ for (const match of `${source ?? ''}`.matchAll(pattern)) {
+ specifiers.add(match[1]);
+ }
+ }
+ return [...specifiers];
+};
+
+const isVersionedViteChunk = (url) =>
+ /-[A-Za-z0-9_-]{8,14}\.js$/i.test(new URL(url).pathname);
+
+export const inspectModuleGraph = async ({
+ baseUrl,
+ entryPath = '/eqsage-embed/eqsage-embed.js',
+ fetchImpl = fetch,
+ maxModules = 1500,
+}) => {
+ const origin = new URL(baseUrl).origin;
+ const pending = [new URL(entryPath, baseUrl).href];
+ const visited = new Set();
+
+ while (pending.length > 0) {
+ const moduleUrl = pending.shift();
+ if (visited.has(moduleUrl)) continue;
+ if (visited.size >= maxModules) {
+ throw new Error(`Sage module graph exceeded ${maxModules} modules`);
+ }
+ const response = await fetchImpl(moduleUrl, {
+ cache: 'no-store',
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!response.ok) {
+ throw new Error(`Sage module returned HTTP ${response.status}: ${moduleUrl}`);
+ }
+ const source = await response.text();
+ visited.add(moduleUrl);
+ for (const specifier of collectModuleSpecifiers(source)) {
+ const dependency = new URL(specifier, moduleUrl);
+ if (
+ dependency.origin === origin &&
+ isVersionedViteChunk(dependency.href) &&
+ !visited.has(dependency.href)
+ ) {
+ pending.push(dependency.href);
+ }
+ }
+ }
+
+ return {
+ moduleCount: visited.size,
+ modules: [...visited].sort(),
+ };
+};
+
+// Local Vite rebuilds replace the hashed chunk set atomically from the
+// filesystem's perspective, but a browser opened mid-rebuild can receive an
+// entry chunk that points at a dependency which has already been removed. Two
+// identical successful graph walks provide a bounded readiness barrier without
+// hiding genuine missing-chunk failures.
+export const waitForModuleGraphReady = async ({
+ baseUrl,
+ entryPath,
+ fetchImpl = fetch,
+ timeoutMs = 45000,
+ settleMs = 1500,
+ onAttempt = null,
+}) => {
+ const startedAt = Date.now();
+ let previousSignature = null;
+ let lastError = null;
+ let attempt = 0;
+
+ while (Date.now() - startedAt < timeoutMs) {
+ attempt++;
+ try {
+ const graph = await inspectModuleGraph({
+ baseUrl,
+ entryPath,
+ fetchImpl,
+ });
+ const signature = graph.modules.join('\n');
+ onAttempt?.({ attempt, pass: true, moduleCount: graph.moduleCount });
+ if (signature === previousSignature) {
+ return { ...graph, attempts: attempt };
+ }
+ previousSignature = signature;
+ lastError = null;
+ } catch (error) {
+ lastError = error;
+ previousSignature = null;
+ onAttempt?.({ attempt, pass: false, error: error?.message ?? String(error) });
+ }
+ await sleep(settleMs);
+ }
+
+ throw new Error(
+ `Sage module graph did not stabilize within ${timeoutMs}ms` +
+ (lastError ? `: ${lastError.message}` : '')
+ );
+};
diff --git a/tools/sage-qa/lib/static-audit.mjs b/tools/sage-qa/lib/static-audit.mjs
new file mode 100644
index 00000000..93556755
--- /dev/null
+++ b/tools/sage-qa/lib/static-audit.mjs
@@ -0,0 +1,43 @@
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+
+export const runStaticTextureAudit = ({ repoRoot, eqDirectory, timeoutMs = 120000 }) =>
+ new Promise((resolve, reject) => {
+ const script = path.join(
+ repoRoot,
+ 'frontend',
+ 'eqsage-embed',
+ 'scripts',
+ 'audit-race-skin-textures.mjs'
+ );
+ const child = spawn(process.execPath, [script, '--eq-dir', eqDirectory], {
+ cwd: path.dirname(script),
+ env: process.env,
+ windowsHide: true,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ const timer = setTimeout(() => {
+ child.kill();
+ reject(new Error(`Static texture audit exceeded ${timeoutMs} ms`));
+ }, timeoutMs);
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ child.on('error', (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ if (code !== 0) {
+ reject(new Error(`Static texture audit exited ${code}: ${stderr || stdout}`));
+ return;
+ }
+ try {
+ resolve({ ...JSON.parse(stdout.trim()), stderr: stderr.trim() });
+ } catch (error) {
+ reject(new Error(`Static texture audit returned invalid JSON: ${error.message}`));
+ }
+ });
+ });
diff --git a/tools/sage-qa/lib/summary.mjs b/tools/sage-qa/lib/summary.mjs
new file mode 100644
index 00000000..17f90231
--- /dev/null
+++ b/tools/sage-qa/lib/summary.mjs
@@ -0,0 +1,33 @@
+const count = (value) => Array.isArray(value) ? value.length : 0;
+
+export const compactRunSummary = (run) => ({
+ ...run,
+ staticTextureAudit: run.staticTextureAudit ? {
+ pass: run.staticTextureAudit.pass,
+ mappedAvailableModelCount: run.staticTextureAudit.mappedAvailableModelCount ?? 0,
+ affectedAvailableModelCount: run.staticTextureAudit.affectedAvailableModelCount ?? 0,
+ affectedMissingModelCount: run.staticTextureAudit.affectedMissingModelCount ?? 0,
+ artifact: 'static-texture-audit.json',
+ } : null,
+ zoneValidation: run.zoneValidation ? {
+ pass: run.zoneValidation.pass,
+ summary: run.zoneValidation.summary,
+ url: run.zoneValidation.url,
+ artifact: 'zone-validation.json',
+ } : null,
+ raceAudit: run.raceAudit ? {
+ summary: run.raceAudit.summary,
+ artifact: 'race-audit-batches.json',
+ } : null,
+ diagnostics: run.diagnostics ? {
+ ...(run.diagnosticSummary ?? {
+ consoleErrorCount: count(run.diagnostics.consoleErrors),
+ recoveredConsoleErrorCount: 0,
+ pageErrorCount: count(run.diagnostics.pageErrors),
+ requestFailureCount: count(run.diagnostics.requestFailures),
+ recoveredRequestFailureCount: 0,
+ httpErrorCount: count(run.diagnostics.httpErrors),
+ }),
+ artifact: 'telemetry.json',
+ } : null,
+});
diff --git a/tools/sage-qa/lib/urls.mjs b/tools/sage-qa/lib/urls.mjs
new file mode 100644
index 00000000..7974e145
--- /dev/null
+++ b/tools/sage-qa/lib/urls.mjs
@@ -0,0 +1,99 @@
+const normalizeEqPath = (eqDirectory) => `${eqDirectory}`.replace(/\\/g, '/');
+
+const baseSageUrl = ({ baseUrl, route = '/sage', eqDirectory, cacheBust }) => {
+ const url = new URL(route, baseUrl);
+ url.searchParams.set('sageEqDir', normalizeEqPath(eqDirectory));
+ url.searchParams.set('sageCacheBust', cacheBust ?? `${Date.now()}`);
+ return url;
+};
+
+export const buildZoneValidationUrl = ({
+ baseUrl,
+ route,
+ eqDirectory,
+ zones,
+ cycles = 1,
+ cacheBust,
+}) => {
+ const url = baseSageUrl({ baseUrl, route, eqDirectory, cacheBust });
+ url.searchParams.set('sageValidateZones', zones.join(','));
+ url.searchParams.set('sageValidationCycles', `${cycles}`);
+ url.searchParams.set('sageValidationPersist', '0');
+ return url.toString();
+};
+
+export const buildRaceAuditUrl = ({
+ baseUrl,
+ route,
+ eqDirectory,
+ bootstrapZone,
+ models,
+ cacheBust,
+ preview,
+ forceRefresh = false,
+}) => {
+ const url = baseSageUrl({ baseUrl, route, eqDirectory, cacheBust });
+ url.searchParams.set('sageValidateZones', bootstrapZone);
+ url.searchParams.set('sageValidationPersist', '0');
+ url.searchParams.set('sageRaceAudit', '1');
+ url.searchParams.set('sageRaceAuditModels', models.join(','));
+ url.searchParams.set('sageRaceAuditPersist', '0');
+ url.searchParams.set('sageRaceAuditForceRefresh', forceRefresh ? '1' : '0');
+ if (preview) {
+ // The preceding structural race batch already performs the expensive
+ // archive refresh. Isolated visual repeats must render that exact cached
+ // artifact instead of regenerating it independently and obscuring runtime
+ // repeatability with archive-processing time.
+ url.searchParams.set('sageRaceAuditForceRefresh', '0');
+ url.searchParams.set('sageRaceFacePreview', preview.model);
+ url.searchParams.set('sageRaceFacePreviewFace', `${preview.face ?? 0}`);
+ url.searchParams.set('sageRaceFacePreviewTexture', `${preview.texture ?? 0}`);
+ url.searchParams.set('sageRaceFacePreviewHelmTexture', `${preview.helmTexture ?? 0}`);
+ url.searchParams.set('sageRaceFacePreviewClose', '1');
+ url.searchParams.set('sageRaceFacePreviewDistance', `${preview.distance ?? 8}`);
+ if (Object.hasOwn(preview, 'heading')) {
+ url.searchParams.set('sageRaceFacePreviewHeading', `${preview.heading}`);
+ }
+ }
+ return url.toString();
+};
+
+const resolveModelReviewView = (sample = {}) => {
+ const requested = `${sample.view ?? ''}`.trim().toLowerCase();
+ if (requested === 'face' || requested === 'head') {
+ return { view: 'front', faceFocus: true };
+ }
+ if (requested === 'rear') {
+ return { view: 'back', faceFocus: sample.faceFocus === true };
+ }
+ if (['front', 'side', 'back'].includes(requested)) {
+ return { view: requested, faceFocus: sample.faceFocus === true };
+ }
+ const heading = Number(sample.heading ?? 0);
+ if (Math.abs(heading - 180) < 0.001) {
+ return { view: 'back', faceFocus: sample.faceFocus === true };
+ }
+ if (Math.abs(heading - 90) < 0.001 || Math.abs(heading - 270) < 0.001) {
+ return { view: 'side', faceFocus: sample.faceFocus === true };
+ }
+ return { view: 'front', faceFocus: sample.faceFocus === true };
+};
+
+export const buildModelReviewUrl = ({
+ baseUrl,
+ route,
+ eqDirectory,
+ sample,
+ cacheBust,
+}) => {
+ const url = baseSageUrl({ baseUrl, route, eqDirectory, cacheBust });
+ const { view, faceFocus } = resolveModelReviewView(sample);
+ url.searchParams.set('sageModelReview', '1');
+ url.searchParams.set('sageModel', `${sample.model ?? ''}`.trim().toLowerCase());
+ url.searchParams.set('sageModelFace', `${Number(sample.face ?? 0)}`);
+ url.searchParams.set('sageModelTexture', `${Number(sample.texture ?? 0)}`);
+ url.searchParams.set('sageModelHelm', `${Number(sample.helmTexture ?? 0)}`);
+ url.searchParams.set('sageModelView', view);
+ url.searchParams.set('sageModelFaceFocus', faceFocus ? '1' : '0');
+ return url.toString();
+};
diff --git a/tools/sage-qa/lib/visual-invariants.mjs b/tools/sage-qa/lib/visual-invariants.mjs
new file mode 100644
index 00000000..ecd62276
--- /dev/null
+++ b/tools/sage-qa/lib/visual-invariants.mjs
@@ -0,0 +1,666 @@
+const finite = (value) => Number.isFinite(Number(value));
+const count = (value) => finite(value) ? Number(value) : 0;
+
+const dimensions = (bounds) => [
+ Number(bounds?.width),
+ Number(bounds?.height),
+ Number(bounds?.depth),
+];
+
+const relativeDelta = (left, right) => {
+ const denominator = Math.max(Math.abs(left), Math.abs(right), 1e-6);
+ return Math.abs(left - right) / denominator;
+};
+
+const meanAbsolutePixelDelta = (left = [], right = []) => {
+ if (left.length === 0 || left.length !== right.length) return Infinity;
+ return left.reduce(
+ (total, value, index) => total + Math.abs(Number(value) - Number(right[index])),
+ 0
+ ) / (left.length * 255);
+};
+
+const meanAbsolutePixelRegionDelta = (
+ left = [],
+ right = [],
+ { width = 8, rows = 3 } = {}
+) => {
+ const channelCount = 3;
+ const length = Math.min(left.length, right.length, width * rows * channelCount);
+ if (length === 0 || left.length !== right.length) return Infinity;
+ let total = 0;
+ for (let index = 0; index < length; index += 1) {
+ total += Math.abs(Number(left[index]) - Number(right[index]));
+ }
+ return total / (length * 255);
+};
+
+const approvedPixelSignature = (signature = []) => {
+ const sourceWidth = 16;
+ const sourceHeight = 16;
+ const channelCount = 3;
+ if (signature.length !== sourceWidth * sourceHeight * channelCount) {
+ return [...signature];
+ }
+ const result = [];
+ for (let y = 0; y < sourceHeight; y += 2) {
+ for (let x = 0; x < sourceWidth; x += 2) {
+ for (let channel = 0; channel < channelCount; channel += 1) {
+ const values = [
+ signature[((y * sourceWidth) + x) * channelCount + channel],
+ signature[((y * sourceWidth) + x + 1) * channelCount + channel],
+ signature[(((y + 1) * sourceWidth) + x) * channelCount + channel],
+ signature[(((y + 1) * sourceWidth) + x + 1) * channelCount + channel],
+ ];
+ result.push(Math.round(values.reduce((sum, value) => sum + Number(value), 0) / 4));
+ }
+ }
+ }
+ return result;
+};
+
+export const visualBaselineKey = (sample = {}) => {
+ const parts = [
+ `${sample.model ?? ''}`.trim().toLowerCase(),
+ `face=${Number(sample.face ?? 0)}`,
+ `texture=${Number(sample.texture ?? 0)}`,
+ `helm=${Number(sample.helmTexture ?? 0)}`,
+ ];
+ if (Object.hasOwn(sample, 'heading')) {
+ parts.push(`heading=${Number(sample.heading)}`);
+ }
+ if (Object.hasOwn(sample, 'view')) {
+ parts.push(`view=${`${sample.view}`.trim().toLowerCase()}`);
+ }
+ if (sample.faceFocus === true) {
+ parts.push('face-focus=1');
+ }
+ return parts.join('|');
+};
+
+export const createApprovedVisualBaseline = (evidence = {}) => ({
+ meshCount: count(evidence.meshCount),
+ vertexCount: count(evidence.vertexCount),
+ skeletonCount: count(evidence.skeletonCount),
+ boneCount: count(evidence.boneCount),
+ materialSignature: `${evidence.materialSignature ?? ''}`,
+ skeletonSignature: `${evidence.skeletonSignature ?? ''}`,
+ foregroundBounds: evidence.pixels?.foregroundBounds ?? null,
+ whitePixelRatio: Number(evidence.pixels?.whitePixelRatio ?? 0),
+ pixelSignature: approvedPixelSignature(evidence.pixels?.signature),
+});
+
+export const DEFAULT_VISUAL_INVARIANTS = Object.freeze({
+ minimumMeshCount: 1,
+ minimumVertexCount: 3,
+ minimumExtent: 0.001,
+ maximumBoundsAspectRatio: 80,
+ minimumStaticBoundsRatio: 0.15,
+ maximumStaticBoundsRatio: 5,
+ minimumForegroundPixels: 500,
+ maximumWhitePixelRatio: null,
+ requireFullMaterialCoverage: true,
+ requireArmsDown: false,
+ requireAnimationMotion: true,
+ minimumAnimationPoseDelta: 0.00001,
+ minimumAnimationFrameCount: 3,
+ maximumArmVerticalRatio: -0.3,
+ maximumRepeatBoundsDelta: 0.025,
+ maximumRepeatPixelDelta: 0.04,
+ maximumApprovedPixelDelta: 0.035,
+ maximumApprovedHeadPixelDelta: 0.025,
+ maximumApprovedForegroundBoundsDelta: 0.06,
+ minimumSemanticHeadVertexCount: 20,
+ minimumSemanticHeadHeightRatio: 0.55,
+});
+
+export const evaluatePreviewEvidence = (
+ evidence,
+ options = {}
+) => {
+ const config = { ...DEFAULT_VISUAL_INVARIANTS, ...options };
+ const violations = [];
+ const runtimeDimensions = dimensions(evidence?.runtimeBounds);
+
+ if (evidence?.available !== true) violations.push('preview-spawn-not-found');
+ if (count(evidence?.meshCount) < config.minimumMeshCount) {
+ violations.push('missing-runtime-meshes');
+ }
+ if (count(evidence?.vertexCount) < config.minimumVertexCount) {
+ violations.push('missing-runtime-vertices');
+ }
+ if (!runtimeDimensions.every(finite)) {
+ violations.push('non-finite-runtime-bounds');
+ } else {
+ const minimum = Math.min(...runtimeDimensions);
+ const maximum = Math.max(...runtimeDimensions);
+ if (minimum < config.minimumExtent) violations.push('collapsed-runtime-bounds');
+ if (maximum / Math.max(minimum, config.minimumExtent) > config.maximumBoundsAspectRatio) {
+ violations.push('implausible-runtime-bounds');
+ }
+ }
+
+ const staticDimensions = dimensions(evidence?.staticBounds);
+ if (runtimeDimensions.every(finite) && staticDimensions.every(finite)) {
+ // Exported character roots can remap source axes (for example source X
+ // becoming Babylon Y). Compare sorted extents so legitimate coordinate
+ // transforms pass while collapsed or exploded dimensions still fail.
+ const runtimeSorted = [...runtimeDimensions].sort((a, b) => a - b);
+ const staticSorted = [...staticDimensions].sort((a, b) => a - b);
+ const ratios = runtimeSorted.map((value, index) =>
+ value / Math.max(staticSorted[index], config.minimumExtent)
+ );
+ if (ratios.some((ratio) =>
+ ratio < config.minimumStaticBoundsRatio || ratio > config.maximumStaticBoundsRatio
+ )) {
+ violations.push('runtime-static-bounds-mismatch');
+ }
+ }
+
+ if (
+ config.requireFullMaterialCoverage &&
+ count(evidence?.untexturedRenderedMaterialCount) > 0
+ ) {
+ violations.push('untextured-rendered-material');
+ }
+ if (count(evidence?.pendingTextureCount) > 0) violations.push('texture-pending');
+ if (count(evidence?.fallbackTextureCount) > 0) violations.push('texture-fallback');
+ if (count(evidence?.headOrientationRiskCount) > 0) {
+ violations.push('head-texture-orientation');
+ }
+ if (
+ evidence?.semanticHeadOrientation?.required === true &&
+ evidence.semanticHeadOrientation.pass !== true
+ ) {
+ violations.push('head-geometry-inverted');
+ }
+ if (
+ evidence?.bodyVariantFallback === true &&
+ evidence?.bodyVariantTextureFallbackApplied !== true
+ ) {
+ violations.push('body-variant-fallback');
+ }
+ if (
+ evidence?.bodyVariantTextureFallbackApplied === true &&
+ count(evidence?.bodyVariantTextureFallbackAppliedCount) +
+ count(evidence?.bodyVariantTextureCoverageAppliedCount) === 0
+ ) {
+ violations.push('body-variant-fallback-unproven');
+ }
+ if (
+ count(evidence?.bodyVariantTextureCoverageAppliedCount) !==
+ count(evidence?.bodyVariantTextureCoverageRequiredCount)
+ ) {
+ violations.push('body-variant-coverage-incomplete');
+ }
+ if (
+ evidence?.requestedModelVariation &&
+ evidence?.loadedModelVariation &&
+ evidence.requestedModelVariation !== evidence.loadedModelVariation &&
+ evidence?.bodyVariantTextureFallbackApplied !== true
+ ) {
+ violations.push('body-variant-mismatch');
+ }
+ if (count(evidence?.secondaryHeadBoneRemapFailureCount) > 0) {
+ violations.push('secondary-head-bone-remap-failure');
+ }
+ if (count(evidence?.nonFiniteBoneMatrixCount) > 0) {
+ violations.push('invalid-bone-matrix');
+ }
+ const skeletonNames = new Set(
+ `${evidence?.skeletonSignature ?? ''}`
+ .toLowerCase()
+ .split('|')
+ .map((name) => name.trim())
+ .filter(Boolean)
+ );
+ const semanticHeadName = ['he', 'head', 'hehead'].find((name) =>
+ skeletonNames.has(name)
+ );
+ const headMeshes = evidence?.headMeshes ?? [];
+ const hasCharacterHeadMaterial = `${evidence?.materialSignature ?? ''}`
+ .split('|')
+ .some((entry) => /^[a-z0-9]{3}he(?:\d{2}|sk)\d{2}:/i.test(entry));
+ if (semanticHeadName && hasCharacterHeadMaterial && headMeshes.length === 0) {
+ violations.push('missing-head-geometry');
+ }
+ if (semanticHeadName && headMeshes.length > 0) {
+ const headVertexCount = headMeshes.reduce(
+ (total, mesh) => total + count(mesh?.vertexCount),
+ 0
+ );
+ if (headVertexCount < config.minimumSemanticHeadVertexCount) {
+ violations.push('head-geometry-too-small');
+ }
+ if (!headMeshes.some((mesh) =>
+ (mesh?.influencingBoneNames ?? []).some((name) =>
+ `${name}`.toLowerCase() === semanticHeadName
+ )
+ )) {
+ violations.push('head-geometry-misbound');
+ }
+ const minimumY = Number(evidence?.runtimeBounds?.minimum?.y);
+ const height = Number(evidence?.runtimeBounds?.height);
+ const headCenterRatios = headMeshes
+ .map((mesh) =>
+ (Number(mesh?.bounds?.center?.y) - minimumY) / height
+ )
+ .filter(Number.isFinite);
+ if (
+ Number.isFinite(minimumY) &&
+ Number.isFinite(height) &&
+ height > 0 &&
+ headCenterRatios.length > 0 &&
+ Math.max(...headCenterRatios) < config.minimumSemanticHeadHeightRatio
+ ) {
+ violations.push('head-geometry-displaced');
+ }
+ }
+ if (
+ evidence?.previewAnimationDonorExpected === true &&
+ evidence?.previewAnimationDonorPass !== true
+ ) {
+ violations.push('preview-animation-donor-not-attached-to-instance');
+ }
+ if (
+ evidence?.previewAnimationDonorExpected === true &&
+ (
+ count(evidence?.previewAnimationDonorGroupCount) === 0 ||
+ count(evidence?.previewAnimationDonorTargetCount) === 0 ||
+ count(evidence?.previewAnimationDonorBindRelativeTargetCount) !==
+ count(evidence?.previewAnimationDonorTargetCount)
+ )
+ ) {
+ violations.push('preview-animation-donor-empty');
+ }
+ if (evidence?.previewAnimationDonorExpected === true) {
+ const lockedNames = new Set(
+ (evidence?.previewAnimationDonorBindLockedRotationTargetNames ?? [])
+ .map((name) => `${name}`.toLowerCase())
+ );
+ const primaryHeadName = skeletonNames.has('he')
+ ? 'he'
+ : skeletonNames.has('head')
+ ? 'head'
+ : skeletonNames.has('hehead')
+ ? 'hehead'
+ : null;
+ if (primaryHeadName && !lockedNames.has(primaryHeadName)) {
+ violations.push('preview-animation-donor-head-rotation-unlocked');
+ }
+ }
+ if (count(evidence?.pixels?.foregroundPixelCount) < config.minimumForegroundPixels) {
+ violations.push('insufficient-visible-model-pixels');
+ }
+ if (
+ config.maximumWhitePixelRatio !== null &&
+ finite(evidence?.pixels?.whitePixelRatio) &&
+ Number(evidence.pixels.whitePixelRatio) > Number(config.maximumWhitePixelRatio)
+ ) {
+ violations.push('excessive-white-model-pixels');
+ }
+
+ const armsDownRequired = config.requireArmsDown || evidence?.compactExpected === true;
+ if (armsDownRequired) {
+ for (const [side, arm] of [['left', evidence?.leftArm], ['right', evidence?.rightArm]]) {
+ if (arm?.available !== true) {
+ violations.push(`${side}-arm-geometry-missing`);
+ } else if (Number(arm.verticalRatio) > Number(config.maximumArmVerticalRatio)) {
+ violations.push(`${side}-arm-horizontal`);
+ }
+ }
+ }
+ if (evidence?.compactExpected === true) {
+ if (evidence?.nativePoseOnly !== true) violations.push('compact-native-pose-disabled');
+ if (evidence?.neutralized !== true) violations.push('compact-arm-neutralization-missing');
+ }
+
+ const animationMotion = evidence?.animationMotion;
+ const donorAnimationExpected =
+ evidence?.previewAnimationDonorExpected === true &&
+ evidence?.previewAnimationDonorPass === true &&
+ count(evidence?.previewAnimationDonorGroupCount) > 0;
+ const animationExpected =
+ donorAnimationExpected ||
+ animationMotion?.expectedMotion === true ||
+ (
+ count(evidence?.skeletonCount) > 0 &&
+ evidence?.nativePoseOnly !== true &&
+ animationMotion?.expectedMotion !== false
+ );
+ if (
+ config.requireAnimationMotion &&
+ animationExpected
+ ) {
+ if (animationMotion?.available !== true) {
+ violations.push('animation-motion-probe-unavailable');
+ }
+ if (count(animationMotion?.frameCount) < config.minimumAnimationFrameCount) {
+ violations.push('animation-motion-samples-missing');
+ }
+ if (count(animationMotion?.nonFiniteValueCount) > 0) {
+ violations.push('animation-motion-non-finite');
+ }
+ if (
+ animationMotion?.moving !== true ||
+ Number(animationMotion?.maximumPoseDelta ?? 0) <
+ Number(config.minimumAnimationPoseDelta)
+ ) {
+ violations.push('animation-motionless');
+ }
+ }
+
+ return {
+ pass: violations.length === 0,
+ violations: [...new Set(violations)],
+ runtimeDimensions,
+ config,
+ };
+};
+
+export const comparePreviewEvidence = (observations = [], options = {}) => {
+ const config = { ...DEFAULT_VISUAL_INVARIANTS, ...options };
+ const violations = [];
+ const baseline = observations[0];
+ if (!baseline || observations.length < 2) {
+ return { pass: false, violations: ['insufficient-independent-repetitions'] };
+ }
+
+ for (let index = 1; index < observations.length; index += 1) {
+ const current = observations[index];
+ for (const key of ['meshCount', 'vertexCount', 'skeletonCount', 'boneCount']) {
+ if (count(current?.[key]) !== count(baseline?.[key])) {
+ violations.push(`repeat-${index + 1}-${key}-changed`);
+ }
+ }
+ for (const key of ['materialSignature', 'skeletonSignature']) {
+ if (`${current?.[key] ?? ''}` !== `${baseline?.[key] ?? ''}`) {
+ violations.push(`repeat-${index + 1}-${key}-changed`);
+ }
+ }
+ const baselineDimensions = dimensions(baseline.runtimeBounds);
+ const currentDimensions = dimensions(current.runtimeBounds);
+ if (
+ !baselineDimensions.every(finite) ||
+ !currentDimensions.every(finite) ||
+ baselineDimensions.some((value, axis) =>
+ relativeDelta(value, currentDimensions[axis]) > config.maximumRepeatBoundsDelta
+ )
+ ) {
+ violations.push(`repeat-${index + 1}-bounds-changed`);
+ }
+ const pixelDelta = meanAbsolutePixelDelta(
+ baseline?.pixels?.signature,
+ current?.pixels?.signature
+ );
+ if (pixelDelta > config.maximumRepeatPixelDelta) {
+ violations.push(`repeat-${index + 1}-pixels-changed`);
+ }
+ }
+
+ return {
+ pass: violations.length === 0,
+ violations: [...new Set(violations)],
+ };
+};
+
+export const compareApprovedVisualBaseline = (
+ evidence,
+ approvedBaseline,
+ options = {}
+) => {
+ const config = { ...DEFAULT_VISUAL_INVARIANTS, ...options };
+ if (!approvedBaseline) {
+ return {
+ pass: options.requireApprovedBaseline !== true,
+ skipped: options.requireApprovedBaseline !== true,
+ violations: options.requireApprovedBaseline === true
+ ? ['approved-visual-baseline-missing']
+ : [],
+ };
+ }
+
+ const violations = [];
+ for (const key of ['meshCount', 'vertexCount', 'skeletonCount', 'boneCount']) {
+ if (count(evidence?.[key]) !== count(approvedBaseline?.[key])) {
+ violations.push(`approved-${key}-changed`);
+ }
+ }
+ for (const key of ['materialSignature', 'skeletonSignature']) {
+ if (`${evidence?.[key] ?? ''}` !== `${approvedBaseline?.[key] ?? ''}`) {
+ violations.push(`approved-${key}-changed`);
+ }
+ }
+
+ const pixelDelta = meanAbsolutePixelDelta(
+ approvedPixelSignature(evidence?.pixels?.signature),
+ approvedBaseline?.pixelSignature
+ );
+ if (pixelDelta > config.maximumApprovedPixelDelta) {
+ violations.push('approved-pixels-changed');
+ }
+ const headPixelDelta = meanAbsolutePixelRegionDelta(
+ approvedPixelSignature(evidence?.pixels?.signature),
+ approvedBaseline?.pixelSignature
+ );
+ if (headPixelDelta > config.maximumApprovedHeadPixelDelta) {
+ violations.push('approved-head-region-changed');
+ }
+
+ const currentBounds = evidence?.pixels?.foregroundBounds;
+ const approvedBounds = approvedBaseline?.foregroundBounds;
+ for (const key of ['x', 'y', 'width', 'height']) {
+ if (
+ !finite(currentBounds?.[key]) ||
+ !finite(approvedBounds?.[key]) ||
+ Math.abs(Number(currentBounds[key]) - Number(approvedBounds[key])) >
+ config.maximumApprovedForegroundBoundsDelta
+ ) {
+ violations.push('approved-foreground-bounds-changed');
+ break;
+ }
+ }
+
+ return {
+ pass: violations.length === 0,
+ skipped: false,
+ violations: [...new Set(violations)],
+ pixelDelta,
+ headPixelDelta,
+ };
+};
+
+// Baseline approval is the reviewed escape hatch for intentional visual
+// changes, so a mismatch against the *old* approved pixels is expected. It is
+// eligible only when every independent audit, invariant and repeatability gate
+// passed; this prevents approval from laundering a load, pose or texture bug.
+export const evaluateVisualBaselineApprovalEligibility = (sample) => {
+ const violations = [];
+ if ((sample?.observations ?? []).length < 2) {
+ violations.push('insufficient-observations');
+ }
+ if (
+ (sample?.auditPasses ?? []).length < 2 ||
+ (sample?.auditPasses ?? []).some((pass) => pass !== true)
+ ) {
+ violations.push('race-audit-failed');
+ }
+ if ((sample?.observationAnalyses ?? []).some((analysis) => analysis?.pass !== true)) {
+ violations.push('visual-invariant-failed');
+ }
+ if (sample?.repeatability?.pass !== true) {
+ violations.push('repeatability-failed');
+ }
+ if (sample?.error) {
+ violations.push('sample-error');
+ }
+ return { pass: violations.length === 0, violations };
+};
+
+const healthyCanary = () => ({
+ available: true,
+ meshCount: 4,
+ vertexCount: 400,
+ staticBounds: { width: 2, height: 6, depth: 1.5 },
+ untexturedRenderedMaterialCount: 0,
+ pendingTextureCount: 0,
+ fallbackTextureCount: 0,
+ headOrientationRiskCount: 0,
+ requestedModelVariation: 'hum',
+ loadedModelVariation: 'hum',
+ bodyVariantFallback: false,
+ secondaryHeadBoneRemapFailureCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ animationMotion: {
+ available: true,
+ expectedMotion: true,
+ moving: true,
+ frameCount: 4,
+ maximumPoseDelta: 0.25,
+ nonFiniteValueCount: 0,
+ },
+ skeletonCount: 1,
+ boneCount: 24,
+ materialSignature: 'body:texture|head:texture',
+ skeletonSignature: 'bi_l|bi_r|he|pe',
+ headMeshes: [{
+ name: 'head',
+ vertexCount: 80,
+ influencingBoneNames: ['he'],
+ bounds: { center: { x: 0, y: 5.3, z: 0 } },
+ }],
+ headBonePositions: [{ name: 'he', position: { x: 0, y: 5.3, z: 0 } }],
+ runtimeBounds: {
+ width: 2,
+ height: 6,
+ depth: 1.5,
+ minimum: { x: -1, y: 0, z: -0.75 },
+ maximum: { x: 1, y: 6, z: 0.75 },
+ },
+ pixels: {
+ foregroundPixelCount: 5000,
+ whitePixelRatio: 0.01,
+ foregroundBounds: { x: 0.3, y: 0.1, width: 0.4, height: 0.8 },
+ signature: Array.from({ length: 16 * 16 * 3 }, (_, index) => (index * 17) % 256),
+ },
+});
+
+export const runVisualInvariantCanaries = () => {
+ const healthy = healthyCanary();
+ const whiteMaterial = structuredClone(healthy);
+ whiteMaterial.untexturedRenderedMaterialCount = 1;
+ const exploded = structuredClone(healthy);
+ exploded.runtimeBounds.width = 60;
+ const changedRepeat = structuredClone(healthy);
+ changedRepeat.vertexCount += 1;
+ changedRepeat.pixels.signature = changedRepeat.pixels.signature.map((value) => 255 - value);
+ const approvedBaseline = createApprovedVisualBaseline(healthy);
+ const upsideDown = structuredClone(healthy);
+ const rowLength = 16 * 3;
+ upsideDown.pixels.signature = Array.from({ length: 16 }, (_, row) =>
+ healthy.pixels.signature.slice((15 - row) * rowLength, (16 - row) * rowLength)
+ ).flat();
+ const motionless = structuredClone(healthy);
+ motionless.animationMotion.moving = false;
+ motionless.animationMotion.maximumPoseDelta = 0;
+ const bodyVariantFallback = structuredClone(healthy);
+ bodyVariantFallback.requestedModelVariation = 'hum01';
+ bodyVariantFallback.loadedModelVariation = 'hum';
+ bodyVariantFallback.bodyVariantFallback = true;
+ const secondaryHeadRemapFailure = structuredClone(healthy);
+ secondaryHeadRemapFailure.secondaryHeadBoneRemapFailureCount = 1;
+ const detachedAnimationDonor = structuredClone(healthy);
+ detachedAnimationDonor.previewAnimationDonorExpected = true;
+ detachedAnimationDonor.previewAnimationDonorPass = false;
+ detachedAnimationDonor.previewAnimationDonorGroupCount = 0;
+ detachedAnimationDonor.previewAnimationDonorTargetCount = 0;
+ detachedAnimationDonor.previewAnimationDonorBindRelativeTargetCount = 0;
+ const unlockedAnimationDonor = structuredClone(healthy);
+ unlockedAnimationDonor.previewAnimationDonorExpected = true;
+ unlockedAnimationDonor.previewAnimationDonorPass = true;
+ unlockedAnimationDonor.previewAnimationDonorGroupCount = 2;
+ unlockedAnimationDonor.previewAnimationDonorTargetCount = 20;
+ unlockedAnimationDonor.previewAnimationDonorBindRelativeTargetCount = 20;
+ unlockedAnimationDonor.previewAnimationDonorBindLockedRotationTargetNames = [];
+ const idleAnimationDonor = structuredClone(healthy);
+ idleAnimationDonor.previewAnimationDonorExpected = true;
+ idleAnimationDonor.previewAnimationDonorPass = true;
+ idleAnimationDonor.previewAnimationDonorGroupCount = 2;
+ idleAnimationDonor.previewAnimationDonorTargetCount = 20;
+ idleAnimationDonor.previewAnimationDonorBindRelativeTargetCount = 20;
+ idleAnimationDonor.previewAnimationDonorBindLockedRotationTargetNames = ['he'];
+ idleAnimationDonor.animationMotion = {
+ available: false,
+ expectedMotion: false,
+ moving: false,
+ frameCount: 0,
+ maximumPoseDelta: 0,
+ nonFiniteValueCount: 0,
+ };
+ const misboundHead = structuredClone(healthy);
+ misboundHead.headMeshes[0].vertexCount = 12;
+ misboundHead.headMeshes[0].influencingBoneNames = ['ca_r', 'fo_r'];
+ misboundHead.headMeshes[0].bounds.center.y = 2;
+ const coordinateAxisRemap = structuredClone(healthy);
+ coordinateAxisRemap.staticBounds = { width: 6, height: 1.5, depth: 2 };
+
+ const cases = [
+ {
+ name: 'white-material',
+ rejected: !evaluatePreviewEvidence(whiteMaterial).pass,
+ },
+ {
+ name: 'exploded-bounds',
+ rejected: !evaluatePreviewEvidence(exploded).pass,
+ },
+ {
+ name: 'nondeterministic-repeat',
+ rejected: !comparePreviewEvidence([healthy, changedRepeat]).pass,
+ },
+ {
+ name: 'approved-baseline-orientation-drift',
+ rejected: !compareApprovedVisualBaseline(upsideDown, approvedBaseline).pass,
+ },
+ {
+ name: 'motionless-animation',
+ rejected: !evaluatePreviewEvidence(motionless).pass,
+ },
+ {
+ name: 'body-variant-fallback',
+ rejected: !evaluatePreviewEvidence(bodyVariantFallback).pass,
+ },
+ {
+ name: 'secondary-head-remap-failure',
+ rejected: !evaluatePreviewEvidence(secondaryHeadRemapFailure).pass,
+ },
+ {
+ name: 'detached-animation-donor',
+ rejected: !evaluatePreviewEvidence(detachedAnimationDonor).pass,
+ },
+ {
+ name: 'unlocked-animation-donor-head',
+ rejected: !evaluatePreviewEvidence(unlockedAnimationDonor).pass,
+ },
+ {
+ name: 'attached-animation-donor-not-playing',
+ rejected: !evaluatePreviewEvidence(idleAnimationDonor).pass,
+ },
+ {
+ name: 'misbound-head-geometry',
+ rejected: !evaluatePreviewEvidence(misboundHead).pass,
+ },
+ ];
+ const acceptedCases = [
+ {
+ name: 'coordinate-axis-remap',
+ accepted: evaluatePreviewEvidence(coordinateAxisRemap).pass,
+ },
+ ];
+ return {
+ pass:
+ cases.every((entry) => entry.rejected) &&
+ acceptedCases.every((entry) => entry.accepted),
+ cases,
+ acceptedCases,
+ };
+};
diff --git a/tools/sage-qa/profiles/compact-rigs.json b/tools/sage-qa/profiles/compact-rigs.json
new file mode 100644
index 00000000..45630a74
--- /dev/null
+++ b/tools/sage-qa/profiles/compact-rigs.json
@@ -0,0 +1,37 @@
+{
+ "schemaVersion": 1,
+ "name": "compact-rigs",
+ "description": "Fast regression loop for compact rigs that borrow animation from classic skeleton hierarchies.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": ["akanon", "qeynos", "greatdivide"],
+ "cycles": 1,
+ "timeoutMs": 420000
+ },
+ "raceAudit": {
+ "enabled": true,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 4,
+ "timeoutMs": 420000,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": ["qcm", "qcf", "clm", "clf"]
+ }
+ },
+ "visualSamples": [
+ { "model": "qcm", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "qcf", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "clm", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "clf", "face": 0, "texture": 0, "helmTexture": 0 }
+ ],
+ "visualValidation": {
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.22
+ },
+ "artifacts": {
+ "screenshotMode": "final",
+ "traceMode": "failures",
+ "keepRuns": 12,
+ "maxTotalMB": 1536
+ }
+}
diff --git a/tools/sage-qa/profiles/full.json b/tools/sage-qa/profiles/full.json
new file mode 100644
index 00000000..26141285
--- /dev/null
+++ b/tools/sage-qa/profiles/full.json
@@ -0,0 +1,58 @@
+{
+ "schemaVersion": 1,
+ "name": "full",
+ "description": "Release-grade validation of all available mapped race models plus repeated multi-zone loading.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": [
+ "qeynos", "freporte", "gfaydark", "crushbone", "unrest", "rivervale",
+ "blackburrow", "befallen", "fieldofbone", "kaesora", "iceclad", "greatdivide"
+ ],
+ "cycles": 3,
+ "timeoutMs": 1800000
+ },
+ "raceAudit": {
+ "enabled": true,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 24,
+ "timeoutMs": 900000,
+ "modelSelection": { "mode": "available", "models": [] }
+ },
+ "visualSamples": [
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "huf", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 1, "helmTexture": 1, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 4, "helmTexture": 1, "invariants": { "requireArmsDown": true } },
+ { "model": "hum", "face": 6, "texture": 16, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "elm", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "ogm", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "trm", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "dwm", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "ikm", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "fsg", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } }
+ ],
+ "visualValidation": {
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.18
+ },
+ "soak": {
+ "enabled": true,
+ "warmupCycles": 1,
+ "resourceTolerancePercent": 2,
+ "resourceToleranceAbsolute": 6,
+ "maxJsHeapGrowthMB": 512
+ },
+ "memory": {
+ "maxUsedPercent": 85,
+ "minFreeMB": 8192,
+ "checkAttempts": 3,
+ "settleMs": 3000
+ },
+ "artifacts": {
+ "screenshotMode": "failures",
+ "traceMode": "never",
+ "maxTracedZoneReports": 12,
+ "keepRuns": 6,
+ "maxTotalMB": 3072
+ }
+}
diff --git a/tools/sage-qa/profiles/matrix.json b/tools/sage-qa/profiles/matrix.json
new file mode 100644
index 00000000..3f83f0d6
--- /dev/null
+++ b/tools/sage-qa/profiles/matrix.json
@@ -0,0 +1,51 @@
+{
+ "schemaVersion": 1,
+ "name": "matrix",
+ "description": "Broad classic and expansion-zone renderer matrix with high-risk race variants.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": [
+ "qeynos",
+ "freporte",
+ "gfaydark",
+ "crushbone",
+ "unrest",
+ "rivervale",
+ "blackburrow",
+ "befallen",
+ "fieldofbone",
+ "kaesora",
+ "iceclad",
+ "greatdivide"
+ ],
+ "cycles": 1,
+ "timeoutMs": 900000
+ },
+ "raceAudit": {
+ "enabled": true,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 10,
+ "timeoutMs": 600000,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": [
+ "hum", "huf", "bam", "baf", "elm", "elf", "dam", "daf",
+ "dwm", "dwf", "trm", "trf", "ogm", "ogf", "ikm", "ikf",
+ "gnm", "gnf", "fsg", "wlm"
+ ]
+ }
+ },
+ "visualSamples": [
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0 },
+ { "model": "elm", "face": 6, "texture": 0, "helmTexture": 0 },
+ { "model": "ogm", "face": 4, "texture": 0, "helmTexture": 0 },
+ { "model": "trm", "face": 3, "texture": 0, "helmTexture": 0 },
+ { "model": "fsg", "face": 0, "texture": 0, "helmTexture": 0 }
+ ],
+ "artifacts": {
+ "screenshotMode": "final",
+ "traceMode": "failures",
+ "keepRuns": 10,
+ "maxTotalMB": 2048
+ }
+}
diff --git a/tools/sage-qa/profiles/model-regression.json b/tools/sage-qa/profiles/model-regression.json
new file mode 100644
index 00000000..095f7cf4
--- /dev/null
+++ b/tools/sage-qa/profiles/model-regression.json
@@ -0,0 +1,96 @@
+{
+ "schemaVersion": 1,
+ "name": "model-regression",
+ "description": "Focused skeletal-model regression gate for cross-rig animation, missing skin, head orientation, and runtime pose failures.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": ["qeynos", "greatdivide", "blackburrow", "pojustice"],
+ "cycles": 1,
+ "timeoutMs": 720000
+ },
+ "raceAudit": {
+ "enabled": true,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 8,
+ "timeoutMs": 720000,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": [
+ "qcm", "qcf", "clm", "clf", "com", "cof", "fsg", "hum", "huf",
+ "elm", "elf", "ogm", "trm", "dwm", "ikm", "brm", "brf",
+ "fem", "fef", "frf", "gfm", "gff", "goj", "kob", "sdf", "shf", "shn"
+ ]
+ }
+ },
+ "visualSamples": [
+ { "model": "qcm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "qcm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 1, "helmTexture": 1, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "qcf", "face": 0, "texture": 4, "helmTexture": 1, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "clm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "clm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "clf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "clf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "com", "face": 0, "texture": 0, "helmTexture": 0, "distance": 16, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "com", "face": 0, "texture": 0, "helmTexture": 0, "distance": 16, "heading": 180, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "cof", "face": 0, "texture": 0, "helmTexture": 0, "distance": 16, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "cof", "face": 0, "texture": 0, "helmTexture": 0, "distance": 16, "heading": 180, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "fsg", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "fsg", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "hum", "face": 6, "texture": 16, "helmTexture": 0, "invariants": { "requireArmsDown": true, "requireApprovedBaseline": false } },
+ { "model": "huf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "huf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "elm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "elm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "elf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "elf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "ogm", "face": 4, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "ogm", "face": 4, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "trm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "trm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "dwm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "dwm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "ikm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "ikm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "goj", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "goj", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180 },
+ { "model": "sdf", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "sdf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180 },
+ { "model": "shf", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "shf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180 },
+ { "model": "shn", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "shn", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180 },
+ { "model": "brf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "brf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "brm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "brm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "fef", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "fef", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "fem", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "fem", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "frf", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "frf", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "gff", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "gff", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "gfm", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "gfm", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } },
+ { "model": "kob", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "kob", "face": 0, "texture": 0, "helmTexture": 0, "heading": 180, "invariants": { "requireArmsDown": true } }
+ ],
+ "visualValidation": {
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.18,
+ "approvedBaselinePath": "tools/sage-qa/baselines/model-regression.json",
+ "requireApprovedBaseline": true
+ },
+ "artifacts": {
+ "screenshotMode": "final",
+ "traceMode": "failures",
+ "keepRuns": 12,
+ "maxTotalMB": 1536
+ }
+}
diff --git a/tools/sage-qa/profiles/model-viewer.json b/tools/sage-qa/profiles/model-viewer.json
new file mode 100644
index 00000000..5b220dc5
--- /dev/null
+++ b/tools/sage-qa/profiles/model-viewer.json
@@ -0,0 +1,46 @@
+{
+ "schemaVersion": 1,
+ "name": "model-viewer",
+ "description": "Focused QA of the production Sage model-review surface, including deterministic whole-model and face-focused evidence.",
+ "zoneValidation": {
+ "enabled": false,
+ "zones": ["blackburrow"],
+ "cycles": 1
+ },
+ "raceAudit": {
+ "enabled": false,
+ "forceRefresh": false,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 1,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": []
+ }
+ },
+ "staticTextureAudit": {
+ "enabled": false
+ },
+ "visualSamples": [
+ { "model": "hum", "face": 0, "texture": 0, "helmTexture": 0, "view": "front", "invariants": { "requireArmsDown": true } },
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0, "view": "face", "faceFocus": true, "invariants": { "requireArmsDown": true } },
+ { "model": "qcf", "face": 0, "texture": 0, "helmTexture": 0, "view": "back", "invariants": { "requireArmsDown": true } },
+ { "model": "sef", "face": 0, "texture": 0, "helmTexture": 0, "view": "face", "faceFocus": true },
+ { "model": "cpf", "face": 0, "texture": 0, "helmTexture": 0, "view": "face", "faceFocus": true },
+ { "model": "iks", "face": 0, "texture": 0, "helmTexture": 0, "view": "front" },
+ { "model": "iks", "face": 0, "texture": 0, "helmTexture": 0, "view": "face", "faceFocus": true },
+ { "model": "iks", "face": 0, "texture": 0, "helmTexture": 1, "view": "face", "faceFocus": true }
+ ],
+ "visualValidation": {
+ "surface": "model-review",
+ "timeoutMs": 120000,
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.18,
+ "requireApprovedBaseline": false
+ },
+ "artifacts": {
+ "screenshotMode": "always",
+ "traceMode": "failures",
+ "keepRuns": 12,
+ "maxTotalMB": 1536
+ }
+}
diff --git a/tools/sage-qa/profiles/review-feedback.json b/tools/sage-qa/profiles/review-feedback.json
new file mode 100644
index 00000000..6bac8d23
--- /dev/null
+++ b/tools/sage-qa/profiles/review-feedback.json
@@ -0,0 +1,89 @@
+{
+ "schemaVersion": 1,
+ "name": "review-feedback",
+ "description": "Replays every model with historical Model Review feedback and requires the fixed viewer to render without an automated issue classification.",
+ "zoneValidation": {
+ "enabled": false,
+ "zones": ["blackburrow"],
+ "cycles": 1
+ },
+ "raceAudit": {
+ "enabled": false,
+ "forceRefresh": false,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 1,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": []
+ }
+ },
+ "staticTextureAudit": {
+ "enabled": false
+ },
+ "visualSamples": [
+ { "model": "abh", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "akf", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "ala", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "alg", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "alr", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "arm", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "b01", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "b02", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "b03", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bac", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bar", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "ber", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bff", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bfr", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bgf", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bgg", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "boat", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "brc", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+ { "model": "bri", "view": "front", "historicalIssue": "nothing-visible", "expectedAutomatedResponse": null },
+
+ { "model": "ahf", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "ahm", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "aie", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "amy", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "ans", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "apx", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "aro", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "asm", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "avk", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "axa", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bal", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bas", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bat", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bdr", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bel", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bfc", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "blv", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bnf", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bnm", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bnr", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bny", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+ { "model": "bre", "view": "front", "historicalIssue": "model-distorted", "expectedAutomatedResponse": null },
+
+ { "model": "all", "view": "face", "faceFocus": true, "historicalIssue": "head-missing", "expectedAutomatedResponse": null },
+ { "model": "avi", "view": "face", "faceFocus": true, "historicalIssue": "head-missing", "expectedAutomatedResponse": null },
+ { "model": "bea", "view": "face", "faceFocus": true, "historicalIssue": "head-missing", "expectedAutomatedResponse": null },
+ { "model": "bgm", "view": "face", "faceFocus": true, "historicalIssue": "head-missing", "expectedAutomatedResponse": null },
+ { "model": "brf", "view": "face", "faceFocus": true, "historicalIssue": "head-missing", "expectedAutomatedResponse": null },
+
+ { "model": "b05", "view": "front", "historicalIssue": "improper-animation", "expectedAutomatedResponse": null },
+ { "model": "b09", "view": "front", "historicalIssue": "improper-animation", "expectedAutomatedResponse": null }
+ ],
+ "visualValidation": {
+ "surface": "model-review",
+ "timeoutMs": 180000,
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.18,
+ "requireApprovedBaseline": false
+ },
+ "artifacts": {
+ "screenshotMode": "always",
+ "traceMode": "failures",
+ "keepRuns": 6,
+ "maxTotalMB": 2048
+ }
+}
diff --git a/tools/sage-qa/profiles/smoke.json b/tools/sage-qa/profiles/smoke.json
new file mode 100644
index 00000000..0a4beef6
--- /dev/null
+++ b/tools/sage-qa/profiles/smoke.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 1,
+ "name": "smoke",
+ "description": "Fast representative validation for local development and pull requests.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": ["blackburrow", "unrest", "qeynos"],
+ "cycles": 1,
+ "timeoutMs": 300000
+ },
+ "raceAudit": {
+ "enabled": true,
+ "bootstrapZone": "blackburrow",
+ "batchSize": 8,
+ "timeoutMs": 420000,
+ "modelSelection": {
+ "mode": "explicit",
+ "models": [
+ "hum", "huf", "elm", "ogm", "trm", "dwm", "ikm", "fsg",
+ "gsf", "tpn", "abh", "tbu", "qcm", "qcf"
+ ]
+ }
+ },
+ "visualSamples": [
+ { "model": "hum", "face": 7, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "ogm", "face": 4, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "fsg", "face": 0, "texture": 0, "helmTexture": 0, "invariants": { "requireArmsDown": true } },
+ { "model": "qcm", "face": 0, "texture": 0, "helmTexture": 0 },
+ { "model": "qcf", "face": 0, "texture": 0, "helmTexture": 0 }
+ ],
+ "visualValidation": {
+ "repetitions": 2,
+ "maximumWhitePixelRatio": 0.18
+ },
+ "artifacts": {
+ "screenshotMode": "final",
+ "traceMode": "failures",
+ "keepRuns": 12,
+ "maxTotalMB": 1536
+ }
+}
diff --git a/tools/sage-qa/profiles/soak.json b/tools/sage-qa/profiles/soak.json
new file mode 100644
index 00000000..025e4316
--- /dev/null
+++ b/tools/sage-qa/profiles/soak.json
@@ -0,0 +1,32 @@
+{
+ "schemaVersion": 1,
+ "name": "soak",
+ "description": "Repeated zone-transition validation with resource plateau and heap-growth budgets.",
+ "zoneValidation": {
+ "enabled": true,
+ "zones": ["unrest", "rivervale", "greatdivide"],
+ "cycles": 3,
+ "timeoutMs": 900000,
+ "isolateZones": false
+ },
+ "raceAudit": {
+ "enabled": false,
+ "modelSelection": { "mode": "explicit", "models": [] }
+ },
+ "staticTextureAudit": { "enabled": false },
+ "visualSamples": [],
+ "soak": {
+ "enabled": true,
+ "warmupCycles": 1,
+ "resourceKeys": ["meshes", "materials", "textures", "skeletons", "animationGroups", "transformNodes"],
+ "resourceTolerancePercent": 2,
+ "resourceToleranceAbsolute": 6,
+ "maxJsHeapGrowthMB": 384
+ },
+ "artifacts": {
+ "screenshotMode": "failures",
+ "traceMode": "never",
+ "keepRuns": 8,
+ "maxTotalMB": 1024
+ }
+}
diff --git a/tools/sage-qa/run.mjs b/tools/sage-qa/run.mjs
new file mode 100644
index 00000000..3066b884
--- /dev/null
+++ b/tools/sage-qa/run.mjs
@@ -0,0 +1,375 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { parseArgs, asBoolean } from './lib/args.mjs';
+import { aggregateRaceAudits, aggregateZoneValidation, analyzeSoak } from './lib/aggregate.mjs';
+import { appendEvent, createRunDirectory, pruneRuns, writeJson, writeText } from './lib/artifacts.mjs';
+import { buildCoverageManifest, resolveModelSelection } from './lib/coverage.mjs';
+import { ensureMemoryBudget } from './lib/memory.mjs';
+import {
+ createTelemetry,
+ getBrowserLaunchOptions,
+ runRaceAuditBatches,
+ runVisualSamples,
+ runZoneValidation,
+ summarizeTelemetry,
+} from './lib/playwright-runner.mjs';
+import { loadProfile, resolveEqDirectory } from './lib/profile.mjs';
+import { renderHtmlReport } from './lib/report.mjs';
+import { runStaticTextureAudit } from './lib/static-audit.mjs';
+import { compactRunSummary } from './lib/summary.mjs';
+import {
+ verifyServedEmbedEntry,
+ waitForModuleGraphReady,
+} from './lib/server-readiness.mjs';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(scriptDirectory, '..', '..');
+
+const HELP = `Sage QA
+
+Usage:
+ npm run qa:sage -- --profile [options]
+
+Options:
+ -p, --profile Validation profile (default: smoke)
+ -e, --eq-dir EverQuest directory containing eqsage assets
+ -b, --base-url Running Spire URL (default: http://127.0.0.1:8080)
+ -o, --output-root Artifact root (default: tmp/validation/runs)
+ --headed Show the automation browser
+ --cycles Override zone-validation cycles
+ --zones Validate only these zones
+ --batch-size Override race-audit batch size
+ --race-models Audit only these model codes
+ --visual-models Run only matching visual samples from the profile
+ --visual-surface Use race-audit or model-review for visual QA
+ --no-race-audit Skip browser race audit
+ --no-zone-validation Skip zone validation
+ --no-static-texture-audit Skip static texture audit
+ --coverage-only Generate coverage without launching a browser
+ --dry-run Resolve and validate the execution plan only
+ --list-profiles List built-in profiles
+ -h, --help Show this help
+`;
+
+const listProfiles = async () => {
+ const directory = path.join(scriptDirectory, 'profiles');
+ const names = (await fs.readdir(directory))
+ .filter((name) => name.endsWith('.json'))
+ .map((name) => path.basename(name, '.json'))
+ .sort();
+ console.log(names.join('\n'));
+};
+
+const checkServer = async (baseUrl) => {
+ const response = await fetch(new URL('/', baseUrl), { signal: AbortSignal.timeout(10000) });
+ if (!response.ok) throw new Error(`Spire server returned HTTP ${response.status} at ${baseUrl}`);
+};
+
+const eventLine = (event) => {
+ if (event.type.endsWith('start')) return `START ${event.phase}${event.batch ? ` batch ${event.batch}/${event.batchCount}` : ''}`;
+ if (event.type.endsWith('complete')) return `${event.pass === false ? 'FAIL' : 'PASS'} ${event.phase}${event.batch ? ` batch ${event.batch}` : ''}`;
+ if (event.type.endsWith('error')) return `ERROR ${event.phase}: ${event.error}`;
+ return `${event.type} ${event.phase ?? ''}`.trim();
+};
+
+const main = async () => {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help) {
+ console.log(HELP);
+ return;
+ }
+ if (args.listProfiles) {
+ await listProfiles();
+ return;
+ }
+
+ const profile = await loadProfile({ repoRoot, profile: args.profile ?? 'smoke', args });
+ const eqDirectory = await resolveEqDirectory({ requested: args.eqDir });
+ const baseUrl = args.baseUrl ?? process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8080';
+ const outputRoot = path.resolve(args.outputRoot ?? path.join(repoRoot, 'tmp', 'validation', 'runs'));
+ const artifacts = await createRunDirectory({ outputRoot, profileName: profile.name });
+ const startedAt = new Date().toISOString();
+ const events = [];
+ let eventWrite = Promise.resolve();
+ const memorySnapshots = [];
+ const failures = [];
+ const telemetry = createTelemetry();
+ let browser = null;
+ let staticTextureAudit = null;
+ let zoneValidation = null;
+ let raceBatches = [];
+ let visualSamples = [];
+ let servedBundle = null;
+ let coverage;
+ let selectedModels = [];
+
+ const onEvent = (event) => {
+ const enriched = { timestamp: new Date().toISOString(), ...event };
+ events.push(enriched);
+ eventWrite = eventWrite
+ .then(() => appendEvent(artifacts.runDirectory, event))
+ .catch((error) => console.warn(`[sage-qa] event checkpoint failed: ${error.message}`));
+ console.log(`[sage-qa] ${eventLine(enriched)}`);
+ };
+ const memoryCheck = async (stage) => ensureMemoryBudget({
+ budget: profile.memory,
+ stage,
+ onSnapshot: (snapshot, evaluation) => {
+ memorySnapshots.push({ ...snapshot, budgetPass: evaluation.pass, budgetViolations: evaluation.violations });
+ console.log(
+ `[sage-qa] memory ${stage}: ${snapshot.system.usedPercent.toFixed(1)}% used, ` +
+ `${(snapshot.system.freeBytes / 1048576).toFixed(0)} MB free`
+ );
+ },
+ });
+
+ const baseRun = {
+ schemaVersion: 1,
+ runId: artifacts.runId,
+ startedAt,
+ repoRoot,
+ eqDirectory,
+ baseUrl,
+ profile,
+ };
+
+ try {
+ await memoryCheck('startup');
+ coverage = await buildCoverageManifest({ repoRoot, eqDirectory });
+ selectedModels = profile.raceAudit.enabled
+ ? resolveModelSelection(coverage, profile.raceAudit.modelSelection)
+ : [];
+ await writeJson(path.join(artifacts.runDirectory, 'coverage.json'), coverage);
+ await writeJson(path.join(artifacts.runDirectory, 'plan.json'), {
+ ...baseRun,
+ selectedModelCount: selectedModels.length,
+ selectedModels,
+ expectedZoneReports: profile.zoneValidation.enabled
+ ? profile.zoneValidation.zones.length * profile.zoneValidation.cycles
+ : 0,
+ });
+
+ if (args.coverageOnly || args.dryRun) {
+ const mode = args.coverageOnly ? 'coverage-only' : 'dry-run';
+ const result = {
+ ...baseRun,
+ mode,
+ pass: true,
+ completedAt: new Date().toISOString(),
+ coverage: coverage.summary,
+ selectedModelCount: selectedModels.length,
+ memorySnapshots,
+ failures: [],
+ };
+ await writeJson(path.join(artifacts.runDirectory, 'summary.json'), result);
+ await writeText(path.join(artifacts.runDirectory, 'summary.html'), renderHtmlReport(result));
+ console.log(`[sage-qa] ${mode} complete: ${artifacts.runDirectory}`);
+ return;
+ }
+
+ await checkServer(baseUrl);
+ servedBundle = await verifyServedEmbedEntry({
+ baseUrl,
+ buildEntryPath: path.join(
+ repoRoot,
+ 'frontend',
+ 'public',
+ 'eqsage-embed',
+ 'eqsage-embed.js'
+ ),
+ });
+ await writeJson(path.join(artifacts.runDirectory, 'served-bundle.json'), servedBundle);
+ onEvent({
+ type: 'preflight-complete',
+ phase: 'served-bundle-identity',
+ pass: true,
+ sha256: servedBundle.sha256,
+ });
+
+ if (profile.staticTextureAudit.enabled) {
+ await memoryCheck('before-static-texture-audit');
+ onEvent({ type: 'phase-start', phase: 'static-texture-audit' });
+ staticTextureAudit = await runStaticTextureAudit({
+ repoRoot,
+ eqDirectory,
+ timeoutMs: profile.staticTextureAudit.timeoutMs,
+ });
+ staticTextureAudit.pass = Number(staticTextureAudit.affectedMissingModelCount ?? 0) === 0;
+ if (!staticTextureAudit.pass) failures.push('Static texture audit found affected models without available assets');
+ await writeJson(path.join(artifacts.runDirectory, 'static-texture-audit.json'), staticTextureAudit);
+ onEvent({ type: 'phase-complete', phase: 'static-texture-audit', pass: staticTextureAudit.pass });
+ }
+
+ const needsBrowser = profile.zoneValidation.enabled || profile.raceAudit.enabled || profile.visualSamples.length > 0;
+ if (needsBrowser) {
+ const moduleGraph = await waitForModuleGraphReady({
+ baseUrl,
+ onAttempt: ({ attempt, pass, moduleCount, error }) => {
+ console.log(
+ pass
+ ? `[sage-qa] module graph attempt ${attempt}: ${moduleCount} modules ready`
+ : `[sage-qa] module graph attempt ${attempt}: ${error}`
+ );
+ },
+ });
+ onEvent({
+ type: 'preflight-complete',
+ phase: 'module-graph-readiness',
+ pass: true,
+ moduleCount: moduleGraph.moduleCount,
+ attempts: moduleGraph.attempts,
+ });
+ await memoryCheck('before-browser-launch');
+ const { chromium } = await import('playwright');
+ browser = await chromium.launch(getBrowserLaunchOptions(profile));
+ }
+
+ if (profile.zoneValidation.enabled) {
+ await memoryCheck('before-zone-validation');
+ zoneValidation = await runZoneValidation({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ runId: artifacts.runId,
+ runDirectory: artifacts.runDirectory,
+ telemetry,
+ beforeZone: ({ index }) =>
+ memoryCheck(`before-zone-validation-${index + 1}`),
+ onEvent,
+ });
+ zoneValidation.summary = aggregateZoneValidation(zoneValidation.raw);
+ if (!zoneValidation.summary.complete) failures.push(`Zone validation failed (${zoneValidation.summary.failureCount} failures)`);
+ await writeJson(path.join(artifacts.runDirectory, 'zone-validation.json'), zoneValidation);
+ }
+
+ raceBatches = await runRaceAuditBatches({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ models: selectedModels,
+ runId: artifacts.runId,
+ runDirectory: artifacts.runDirectory,
+ telemetry,
+ beforeBatch: ({ index }) => memoryCheck(`before-race-batch-${index + 1}`),
+ onBatchComplete: ({ result }) => writeJson(
+ path.join(artifacts.runDirectory, 'race-audit-checkpoints', `batch-${result.batch}.json`),
+ result
+ ),
+ onEvent,
+ });
+ const raceSummary = aggregateRaceAudits(raceBatches);
+ if (profile.raceAudit.enabled && !raceSummary.complete) {
+ failures.push(`Race audit failed (${raceSummary.failureCount} model failures)`);
+ }
+ await writeJson(path.join(artifacts.runDirectory, 'race-audit-batches.json'), raceBatches);
+
+ visualSamples = await runVisualSamples({
+ browser,
+ profile,
+ baseUrl,
+ eqDirectory,
+ runId: artifacts.runId,
+ runDirectory: artifacts.runDirectory,
+ telemetry,
+ beforeSample: ({ index }) => memoryCheck(`before-visual-sample-${index + 1}`),
+ onEvent,
+ });
+ if (visualSamples.some((sample) => !sample.pass)) {
+ failures.push(`Visual sample validation failed (${visualSamples.filter((sample) => !sample.pass).length} failures)`);
+ }
+ await writeJson(path.join(artifacts.runDirectory, 'visual-samples.json'), visualSamples);
+
+ await browser?.close();
+ browser = null;
+ await memoryCheck('after-browser-close');
+
+ const soak = analyzeSoak(zoneValidation?.raw?.reports ?? [], profile.soak);
+ if (!soak.pass) failures.push(...soak.violations.map((violation) => `Soak: ${violation}`));
+ const diagnosticSummary = summarizeTelemetry(telemetry);
+
+ if (profile.diagnostics.failOnPageErrors && diagnosticSummary.pageErrorCount) {
+ failures.push(`${diagnosticSummary.pageErrorCount} browser page error(s)`);
+ }
+ if (profile.diagnostics.failOnConsoleErrors && diagnosticSummary.consoleErrorCount) {
+ failures.push(`${diagnosticSummary.consoleErrorCount} unrecovered browser console error(s)`);
+ }
+ if (profile.diagnostics.failOnRequestFailures && diagnosticSummary.requestFailureCount) {
+ failures.push(`${diagnosticSummary.requestFailureCount} unrecovered browser request failure(s)`);
+ }
+ if (profile.diagnostics.failOnHttpErrors && diagnosticSummary.httpErrorCount) {
+ failures.push(`${diagnosticSummary.httpErrorCount} browser HTTP error response(s)`);
+ }
+
+ const result = {
+ ...baseRun,
+ completedAt: new Date().toISOString(),
+ pass: failures.length === 0,
+ failures,
+ coverage: coverage.summary,
+ staticTextureAudit,
+ zoneValidation,
+ raceAudit: { summary: raceSummary, batches: raceBatches },
+ visualSamples,
+ servedBundle,
+ soak,
+ diagnostics: telemetry,
+ diagnosticSummary,
+ memorySnapshots,
+ };
+ await writeJson(path.join(artifacts.runDirectory, 'telemetry.json'), telemetry);
+ await writeJson(path.join(artifacts.runDirectory, 'summary.json'), compactRunSummary(result));
+ await writeText(path.join(artifacts.runDirectory, 'summary.html'), renderHtmlReport(result));
+ await eventWrite;
+ await writeText(
+ path.join(artifacts.runDirectory, 'events.ndjson'),
+ events.map((event) => JSON.stringify(event)).join('\n') + (events.length ? '\n' : '')
+ );
+ const retention = await pruneRuns({
+ outputRoot: artifacts.outputRoot,
+ keepRuns: profile.artifacts.keepRuns,
+ maxTotalMB: profile.artifacts.maxTotalMB,
+ currentRunDirectory: artifacts.runDirectory,
+ });
+ await writeJson(path.join(artifacts.runDirectory, 'retention.json'), retention);
+
+ console.log(`[sage-qa] ${result.pass ? 'PASS' : 'FAIL'} ${profile.name}: ${artifacts.runDirectory}`);
+ console.log(`[sage-qa] HTML report: ${path.join(artifacts.runDirectory, 'summary.html')}`);
+ if (!result.pass && !asBoolean(args.allowFailures, false)) process.exitCode = 1;
+ } catch (error) {
+ failures.push(error.message);
+ const result = {
+ ...baseRun,
+ completedAt: new Date().toISOString(),
+ pass: false,
+ failures,
+ coverage: coverage?.summary ?? null,
+ staticTextureAudit,
+ zoneValidation,
+ raceAudit: { summary: aggregateRaceAudits(raceBatches), batches: raceBatches },
+ visualSamples,
+ servedBundle,
+ diagnostics: telemetry,
+ diagnosticSummary: summarizeTelemetry(telemetry),
+ memorySnapshots,
+ fatalError: { message: error.message, stack: error.stack ?? null },
+ };
+ await writeJson(path.join(artifacts.runDirectory, 'summary.json'), compactRunSummary(result)).catch(() => {});
+ await writeText(path.join(artifacts.runDirectory, 'summary.html'), renderHtmlReport(result)).catch(() => {});
+ await eventWrite.catch(() => {});
+ await writeText(
+ path.join(artifacts.runDirectory, 'events.ndjson'),
+ events.map((event) => JSON.stringify(event)).join('\n') + (events.length ? '\n' : '')
+ ).catch(() => {});
+ console.error(`[sage-qa] FAIL ${profile.name}: ${error.stack ?? error.message}`);
+ console.error(`[sage-qa] Artifacts: ${artifacts.runDirectory}`);
+ process.exitCode = 1;
+ } finally {
+ await browser?.close().catch(() => {});
+ }
+};
+
+await main();
diff --git a/tools/sage-qa/test/run.mjs b/tools/sage-qa/test/run.mjs
new file mode 100644
index 00000000..39f0fc9d
--- /dev/null
+++ b/tools/sage-qa/test/run.mjs
@@ -0,0 +1,34 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const testDirectory = path.dirname(fileURLToPath(import.meta.url));
+const testFiles = (await fs.readdir(testDirectory))
+ .filter((name) => name.endsWith('.test.mjs'))
+ .map((name) => path.join(testDirectory, name))
+ .sort();
+
+if (testFiles.length === 0) {
+ throw new Error(`No Sage QA test files found in ${testDirectory}`);
+}
+
+const child = spawn(process.execPath, ['--test', ...testFiles], {
+ cwd: path.resolve(testDirectory, '..', '..', '..'),
+ stdio: 'inherit',
+ windowsHide: true,
+});
+
+child.on('error', (error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
+
+child.on('exit', (code, signal) => {
+ if (signal) {
+ console.error(`Sage QA tests terminated by ${signal}`);
+ process.exitCode = 1;
+ } else {
+ process.exitCode = code ?? 1;
+ }
+});
diff --git a/tools/sage-qa/test/unit.test.mjs b/tools/sage-qa/test/unit.test.mjs
new file mode 100644
index 00000000..6225edb6
--- /dev/null
+++ b/tools/sage-qa/test/unit.test.mjs
@@ -0,0 +1,1636 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { parseArgs } from '../lib/args.mjs';
+import { aggregateRaceAudits, aggregateZoneValidation, analyzeSoak } from '../lib/aggregate.mjs';
+import { createRunDirectory, pruneRuns, writeText } from '../lib/artifacts.mjs';
+import { buildCoverageManifest, resolveModelSelection } from '../lib/coverage.mjs';
+import { evaluateMemoryBudget } from '../lib/memory.mjs';
+import { getBrowserLaunchOptions, summarizeTelemetry } from '../lib/playwright-runner.mjs';
+import {
+ deepMerge,
+ filterVisualSamples,
+ loadProfile,
+ validateProfile,
+} from '../lib/profile.mjs';
+import { renderHtmlReport } from '../lib/report.mjs';
+import { compactRunSummary } from '../lib/summary.mjs';
+import {
+ collectModuleSpecifiers,
+ verifyServedEmbedEntry,
+ waitForModuleGraphReady,
+} from '../lib/server-readiness.mjs';
+import {
+ buildModelReviewUrl,
+ buildRaceAuditUrl,
+ buildZoneValidationUrl,
+} from '../lib/urls.mjs';
+import {
+ compareApprovedVisualBaseline,
+ comparePreviewEvidence,
+ createApprovedVisualBaseline,
+ evaluatePreviewEvidence,
+ evaluateVisualBaselineApprovalEligibility,
+ runVisualInvariantCanaries,
+ visualBaselineKey,
+} from '../lib/visual-invariants.mjs';
+import {
+ getCharacterAnimationCompatibility,
+ STATIC_POSE_ONLY_CHARACTER_MODELS,
+ shouldUseNativeCharacterPose,
+} from '../../../frontend/eqsage-embed/sage/lib/util/character-animation-policy.js';
+import {
+ getCharacterHeadOrientationPolicy,
+} from '../../../frontend/eqsage-embed/sage/lib/util/character-texture-orientation.js';
+import {
+ evaluateAppearanceVariant,
+ evaluateSemanticHeadOrientation,
+ evaluateFaceVariantDeterminism,
+ getSemanticHeadOrientationPolicy,
+ inspectHeadTextureOrientation,
+ isCharacterAppearanceMaterialName,
+ isKnownEffectOnlyCharacterModel,
+} from '../../../frontend/eqsage-embed/src/viewer/helpers/appearanceValidation.js';
+import {
+ evaluateAnimatedBoundsSafety,
+ evaluateHeadRotationSafety,
+ evaluateCharacterAnimationReadiness,
+ isStaticPoseOnlyCharacterModel,
+ inspectAnimationGroupVitality,
+ retargetDetachedAnimationTargets,
+ selectPreferredVisualAnimationGroup,
+} from '../../../frontend/eqsage-embed/src/viewer/helpers/animationValidation.js';
+import {
+ evaluateNameplatePlacement,
+} from '../../../frontend/eqsage-embed/src/viewer/helpers/nameplateValidation.js';
+import {
+ FIXED_MODEL_REVIEW_CODES,
+ removeFixedModelReviews,
+} from '../../../frontend/eqsage-embed/src/components/model-review/model-review-storage.js';
+import {
+ getCharacterArchiveBaseModelName,
+ getCharacterBodyModelVariation,
+ getCharacterSourceFamilyStem,
+ orderCharacterModelSourceFiles,
+ PREVIEW_ALIAS_FIRST_MODELS,
+ PREVIEW_CLIENT_FALLBACKS,
+} from '../../../frontend/eqsage-embed/src/viewer/common/raceModelResolution.js';
+import {
+ degreesToEqHeading,
+ eqHeadingToDegrees,
+ getRenderableDoors,
+ isInvisibleDoor,
+ stripDoorEditorFields,
+ toDoorPayload,
+ toDoorPlacement,
+} from '../../../frontend/eqsage-embed/src/components/spire/door-placement.js';
+import {
+ applyTextureAnimationFrame,
+ getMaterialBaseColorTexture,
+ isTextureAnimationFrameReady,
+ resolveTextureAnimationFrameUrl,
+} from '../../../frontend/eqsage-embed/src/viewer/helpers/textureAnimation.js';
+
+test('parseArgs supports aliases, values, booleans, and negated flags', () => {
+ assert.deepEqual(
+ parseArgs(['-p', 'matrix', '--cycles=3', '--headed', '--no-race-audit', 'extra']),
+ {
+ positional: ['extra'],
+ profile: 'matrix',
+ cycles: '3',
+ headed: true,
+ raceAudit: false,
+ }
+ );
+});
+
+test('head texture orientation diagnostics compare effective geometry and runtime flips', () => {
+ const internalTexture = {
+ _spireSageRequestedInvertY: false,
+ _spireSageUploadInvertY: false,
+ };
+ const material = {
+ name: 'HUMHE0001',
+ metadata: { spireSkinnedVFlipped: true },
+ albedoTexture: {
+ name: 'humhe0001',
+ vOffset: 0,
+ vScale: 1,
+ _texture: internalTexture,
+ },
+ };
+ const policy = { geometryUvFlipped: true, runtimeTextureVFlipped: false };
+
+ assert.equal(inspectHeadTextureOrientation(material, policy).risk, false);
+ material.albedoTexture.vScale = -1;
+ assert.equal(inspectHeadTextureOrientation(material, policy).risk, true);
+ material.albedoTexture.vScale = 1;
+ internalTexture._spireSageUploadInvertY = true;
+ assert.equal(inspectHeadTextureOrientation(material, policy).risk, true);
+});
+
+test('zone texture animation resolves frames beside the GLB base texture', () => {
+ assert.equal(
+ resolveTextureAnimationFrameUrl(
+ { url: '/eq/textures/leaf01.bmp' },
+ 'leaf02.bmp'
+ ),
+ '/eq/textures/leaf02.bmp'
+ );
+ assert.equal(
+ resolveTextureAnimationFrameUrl({ name: 'water01.bmp' }, 'water02.bmp'),
+ '/eq/textures/water02.bmp'
+ );
+ assert.equal(
+ resolveTextureAnimationFrameUrl({}, '/eq/textures/fire02.bmp'),
+ '/eq/textures/fire02.bmp'
+ );
+});
+
+test('zone texture animation never replaces a valid frame with an unresolved frame', () => {
+ const originalInternalTexture = { isReady: true, id: 'original' };
+ const targetTexture = { _texture: originalInternalTexture };
+ const pendingFrame = {
+ _texture: { isReady: false, id: 'pending' },
+ isReady: () => false,
+ };
+ assert.equal(isTextureAnimationFrameReady(pendingFrame), false);
+ assert.equal(applyTextureAnimationFrame(targetTexture, pendingFrame), false);
+ assert.equal(targetTexture._texture, originalInternalTexture);
+
+ const readyInternalTexture = { isReady: true, id: 'ready' };
+ const readyFrame = {
+ _texture: readyInternalTexture,
+ isReady: () => true,
+ };
+ assert.equal(applyTextureAnimationFrame(targetTexture, readyFrame), true);
+ assert.equal(targetTexture._texture, readyInternalTexture);
+});
+
+test('zone texture animation targets only the material base-color slot', () => {
+ const albedoTexture = { name: 'albedo' };
+ const normalTexture = { name: 'normal' };
+ assert.equal(
+ getMaterialBaseColorTexture({
+ albedoTexture,
+ getActiveTextures: () => [normalTexture],
+ }),
+ albedoTexture
+ );
+ assert.equal(
+ getMaterialBaseColorTexture({
+ diffuseTexture: albedoTexture,
+ getActiveTextures: () => [normalTexture],
+ }),
+ albedoTexture
+ );
+});
+
+test('placed zone objects do not use a distance-based null LOD', async () => {
+ const controllerSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/controllers/ZoneController.js'),
+ 'utf8'
+ );
+ const instantiateObjectsSource = controllerSource.slice(
+ controllerSource.indexOf('async instantiateObjects('),
+ controllerSource.indexOf('async addTextureAnimations(')
+ );
+
+ assert.doesNotMatch(instantiateObjectsSource, /addLODLevel\s*\(/);
+});
+
+test('deepMerge preserves nested defaults while applying profile overrides', () => {
+ assert.deepEqual(
+ deepMerge({ a: { b: 1, c: 2 }, d: 3 }, { a: { b: 9 } }),
+ { a: { b: 9, c: 2 }, d: 3 }
+ );
+});
+
+test('visual sample filtering supports focused, case-insensitive reruns', () => {
+ const samples = [{ model: 'qcf' }, { model: 'clm' }, { model: 'clf' }];
+ assert.deepEqual(
+ filterVisualSamples(samples, 'CLF,qcf'),
+ [{ model: 'qcf' }, { model: 'clf' }]
+ );
+});
+
+test('review feedback profile preserves the complete historical issue corpus', async () => {
+ const profile = await loadProfile({
+ repoRoot: process.cwd(),
+ profile: 'review-feedback',
+ args: {},
+ });
+ const issueCounts = profile.visualSamples.reduce((counts, sample) => ({
+ ...counts,
+ [sample.historicalIssue]: (counts[sample.historicalIssue] ?? 0) + 1,
+ }), {});
+
+ assert.equal(profile.visualValidation.surface, 'model-review');
+ assert.equal(profile.visualSamples.length, 48);
+ assert.deepEqual(issueCounts, {
+ 'nothing-visible': 19,
+ 'model-distorted': 22,
+ 'head-missing': 5,
+ 'improper-animation': 2,
+ });
+ assert.ok(
+ profile.visualSamples.every((sample) =>
+ Object.hasOwn(sample, 'expectedAutomatedResponse') &&
+ sample.expectedAutomatedResponse === null
+ )
+ );
+ assert.deepEqual(
+ [...FIXED_MODEL_REVIEW_CODES].sort(),
+ profile.visualSamples.map((sample) => sample.model).sort()
+ );
+});
+
+test('fixed model review reset preserves unrelated review history', () => {
+ const preserved = { status: 'pass', updatedAt: 'preserve-me' };
+ const reviews = Object.fromEntries([
+ ...FIXED_MODEL_REVIEW_CODES.map((model) => [model, { status: 'issue' }]),
+ ['brl', preserved],
+ ]);
+
+ assert.deepEqual(removeFixedModelReviews(reviews), { brl: preserved });
+ assert.deepEqual(removeFixedModelReviews(null), {});
+});
+
+test('model regression gives every audited race deterministic front and rear visual coverage', async () => {
+ const profile = await loadProfile({
+ repoRoot: process.cwd(),
+ profile: 'model-regression',
+ args: {},
+ });
+ const auditedModels = profile.raceAudit.modelSelection.models;
+
+ assert.equal(profile.visualValidation.timeoutMs, 120000);
+ assert.ok(
+ profile.visualValidation.timeoutMs < profile.raceAudit.timeoutMs,
+ 'cached visual samples must fail faster than archive-generation audits'
+ );
+
+ for (const auditedModel of auditedModels) {
+ const samples = profile.visualSamples.filter(
+ ({ model }) => model.toLowerCase() === auditedModel.toLowerCase()
+ );
+ const headings = new Set(samples.map(({ heading = 0 }) => Number(heading)));
+
+ assert.ok(
+ headings.has(0),
+ `${auditedModel} is audited but has no deterministic front visual sample`
+ );
+ assert.ok(
+ headings.has(180),
+ `${auditedModel} is audited but has no deterministic rear visual sample`
+ );
+ }
+});
+
+test('zone override replaces the profile zone list deterministically', async () => {
+ const profile = await loadProfile({
+ repoRoot: process.cwd(),
+ profile: 'model-regression',
+ args: { zones: 'POJUSTICE,blackburrow,pojustice' },
+ });
+ assert.deepEqual(profile.zoneValidation.zones, ['pojustice', 'blackburrow']);
+});
+
+test('rear visual samples have distinct deterministic URLs and baseline keys', () => {
+ const sample = {
+ model: 'goj',
+ face: 0,
+ texture: 0,
+ helmTexture: 0,
+ heading: 180,
+ };
+ const url = new URL(buildRaceAuditUrl({
+ baseUrl: 'http://127.0.0.1:8080',
+ route: '/sage',
+ eqDirectory: 'C:\\EQ',
+ bootstrapZone: 'pojustice',
+ models: ['goj'],
+ cacheBust: 'rear-test',
+ preview: sample,
+ }));
+ assert.equal(url.searchParams.get('sageRaceFacePreviewHeading'), '180');
+ assert.equal(url.searchParams.get('sageRaceAuditForceRefresh'), '0');
+ assert.equal(
+ visualBaselineKey(sample),
+ 'goj|face=0|texture=0|helm=0|heading=180'
+ );
+ assert.equal(
+ visualBaselineKey({ ...sample, heading: 0 }),
+ 'goj|face=0|texture=0|helm=0|heading=0'
+ );
+});
+
+test('model-review visual URLs preserve appearance, view, and face-focus state', () => {
+ const faceUrl = new URL(buildModelReviewUrl({
+ baseUrl: 'http://127.0.0.1:8080',
+ route: '/sage',
+ eqDirectory: 'C:\\EQ',
+ cacheBust: 'model-review-test',
+ sample: {
+ model: 'HUM',
+ face: 7,
+ texture: 16,
+ helmTexture: 2,
+ view: 'face',
+ faceFocus: true,
+ },
+ }));
+ assert.equal(faceUrl.searchParams.get('sageModelReview'), '1');
+ assert.equal(faceUrl.searchParams.get('sageModel'), 'hum');
+ assert.equal(faceUrl.searchParams.get('sageModelFace'), '7');
+ assert.equal(faceUrl.searchParams.get('sageModelTexture'), '16');
+ assert.equal(faceUrl.searchParams.get('sageModelHelm'), '2');
+ assert.equal(faceUrl.searchParams.get('sageModelView'), 'front');
+ assert.equal(faceUrl.searchParams.get('sageModelFaceFocus'), '1');
+ assert.equal(
+ visualBaselineKey({
+ model: 'hum',
+ face: 7,
+ texture: 16,
+ helmTexture: 2,
+ view: 'face',
+ faceFocus: true,
+ }),
+ 'hum|face=7|texture=16|helm=2|view=face|face-focus=1'
+ );
+
+ const rearUrl = new URL(buildModelReviewUrl({
+ baseUrl: 'http://127.0.0.1:8080',
+ route: '/sage',
+ eqDirectory: 'C:\\EQ',
+ sample: { model: 'qcf', heading: 180 },
+ }));
+ assert.equal(rearUrl.searchParams.get('sageModelView'), 'back');
+ assert.equal(rearUrl.searchParams.get('sageModelFaceFocus'), '0');
+});
+
+test('model-viewer profile runs visual evidence on the production review surface', async () => {
+ const profile = await loadProfile({
+ repoRoot: process.cwd(),
+ profile: 'model-viewer',
+ args: {},
+ });
+ assert.equal(profile.visualValidation.surface, 'model-review');
+ assert.equal(profile.zoneValidation.enabled, false);
+ assert.equal(profile.raceAudit.enabled, false);
+ assert.ok(profile.visualSamples.some((sample) => sample.faceFocus === true));
+});
+
+test('classic body texture variants resolve to deterministic archive assets', () => {
+ assert.equal(getCharacterBodyModelVariation('HUM', 0), 'hum');
+ assert.equal(getCharacterBodyModelVariation('hum', 16), 'hum01');
+ assert.equal(getCharacterBodyModelVariation('huf', 23), 'huf02');
+ assert.equal(getCharacterBodyModelVariation('qcf', 4), 'qcf');
+ assert.equal(getCharacterArchiveBaseModelName('hum01'), 'hum');
+ assert.equal(getCharacterArchiveBaseModelName('qcfhe06'), 'qcf');
+ assert.equal(getCharacterSourceFamilyStem('globalhum_chr.s3d'), 'globalhum_chr');
+ assert.equal(getCharacterSourceFamilyStem('globalhum_chr2.s3d'), 'globalhum_chr');
+ assert.equal(getCharacterSourceFamilyStem('globalhum.eqg'), null);
+});
+
+test('dedicated character archives are processed before zone copies', () => {
+ assert.deepEqual(
+ orderCharacterModelSourceFiles('sdf', [
+ 'paludal_chr.s3d',
+ 'pojustice_chr.s3d',
+ 'SDF_CHR.S3D',
+ 'twilight_chr.s3d',
+ 'sdf_chr.s3d',
+ ]),
+ [
+ 'sdf_chr.s3d',
+ 'paludal_chr.s3d',
+ 'pojustice_chr.s3d',
+ 'twilight_chr.s3d',
+ ]
+ );
+ assert.deepEqual(
+ orderCharacterModelSourceFiles('dam', [
+ 'global_chr.s3d',
+ 'globaldam_chr.s3d',
+ ]),
+ ['globaldam_chr.s3d', 'global_chr.s3d']
+ );
+ assert.deepEqual(
+ orderCharacterModelSourceFiles('kob', [
+ 'akanon_chr.s3d',
+ 'warrens_chr.s3d',
+ 'poknowledge_chr.s3d',
+ 'crushbone_chr.s3d',
+ ]),
+ [
+ 'poknowledge_chr.s3d',
+ 'akanon_chr.s3d',
+ 'crushbone_chr.s3d',
+ 'warrens_chr.s3d',
+ ]
+ );
+});
+
+test('skeleton animation readiness rejects missing and bind-pose-only clips', () => {
+ assert.deepEqual(
+ evaluateCharacterAnimationReadiness({
+ skeletonCount: 1,
+ animationVitality: { playableGroupCount: 0, visuallyPosedGroupCount: 0 },
+ }),
+ { pass: false, violations: ['missing-playable-animation'] }
+ );
+ assert.deepEqual(
+ evaluateCharacterAnimationReadiness({
+ skeletonCount: 1,
+ animationVitality: { playableGroupCount: 3, visuallyPosedGroupCount: 0 },
+ }),
+ { pass: false, violations: ['animation-matches-bind-pose'] }
+ );
+ assert.equal(
+ evaluateCharacterAnimationReadiness({
+ skeletonCount: 1,
+ animationVitality: { playableGroupCount: 0, visuallyPosedGroupCount: 0 },
+ staticPoseFallbackAvailable: true,
+ }).pass,
+ true
+ );
+});
+
+test('animated bounds safety rejects exploding geometry without penalizing large models', () => {
+ const exploding = evaluateAnimatedBoundsSafety({
+ baselineMaxDimension: 12,
+ currentMaxDimension: 1522,
+ });
+ assert.equal(exploding.pass, false);
+ assert.ok(exploding.ratio > 100);
+
+ const stableLargeModel = evaluateAnimatedBoundsSafety({
+ baselineMaxDimension: 280,
+ currentMaxDimension: 350,
+ });
+ assert.equal(stableLargeModel.pass, true);
+ assert.equal(stableLargeModel.measurable, true);
+});
+
+test('head rotation safety rejects upside-down animation samples', () => {
+ const identity = { x: 0, y: 0, z: 0, w: 1 };
+ const normalIdle = { x: Math.sin(Math.PI / 12), y: 0, z: 0, w: Math.cos(Math.PI / 12) };
+ const inverted = { x: 1, y: 0, z: 0, w: 0 };
+
+ assert.equal(evaluateHeadRotationSafety({
+ baselineQuaternion: identity,
+ currentQuaternion: normalIdle,
+ }).pass, true);
+ const result = evaluateHeadRotationSafety({
+ baselineQuaternion: identity,
+ currentQuaternion: inverted,
+ });
+ assert.equal(result.pass, false);
+ assert.ok(result.angleDegrees > 170);
+});
+
+test('semantic head orientation is explicit, model scoped, and fail closed', () => {
+ const policy = getSemanticHeadOrientationPolicy('IKS');
+ assert.deepEqual(policy.upperMaterials, ['ikshe0001', 'ikshe0006']);
+ assert.deepEqual(policy.lowerMaterials, ['ikshe0005']);
+ assert.equal(getSemanticHeadOrientationPolicy('ikm'), null);
+
+ assert.equal(evaluateSemanticHeadOrientation({
+ policy,
+ upperCenterY: 5.97,
+ lowerCenterY: 5.69,
+ modelHeight: 6.2,
+ }).pass, true);
+ assert.equal(evaluateSemanticHeadOrientation({
+ policy,
+ upperCenterY: 5.40,
+ lowerCenterY: 5.70,
+ modelHeight: 6.2,
+ }).pass, false);
+ assert.equal(evaluateSemanticHeadOrientation({
+ policy,
+ upperCenterY: null,
+ lowerCenterY: null,
+ modelHeight: 6.2,
+ }).pass, false);
+});
+
+test('spawn head safety samples deforming head bones, not attachment helpers', async () => {
+ const source = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js'),
+ 'utf8'
+ );
+ assert.match(source, /PRIMARY_HEAD_BONE_PATTERN = \/\^\(\?:hehead\|head\)\$\/i/);
+ assert.doesNotMatch(source, /PRIMARY_HEAD_BONE_PATTERN[^\n]+head_point/);
+});
+
+test('appearance swaps reject semantic object material names', () => {
+ assert.equal(isCharacterAppearanceMaterialName('HUMCH0001'), true);
+ assert.equal(isCharacterAppearanceMaterialName('HUFUAsk01'), true);
+ assert.equal(isCharacterAppearanceMaterialName('QCFHE0101'), true);
+ assert.equal(isCharacterAppearanceMaterialName('ERBEDSIDE'), false);
+ assert.equal(isCharacterAppearanceMaterialName('ERBEDTOP'), false);
+ assert.equal(isCharacterAppearanceMaterialName('PRECRATE2'), false);
+});
+
+test('S3D character exporter preserves alternate body mesh filenames', async () => {
+ const source = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/sage/lib/s3d/s3d-decoder.js'),
+ 'utf8'
+ );
+ assert.match(source, /writeEQFile\(path, `\$\{baseName\}\.glb`, bytes\)/);
+ assert.doesNotMatch(
+ source,
+ /writeEQFile\(path, `\$\{skeleton\.modelBase\}\.glb`, bytes\)/
+ );
+});
+
+test('preview invariant accepts only a proven high-texture body compatibility path', () => {
+ const compatible = {
+ available: true,
+ renderPass: true,
+ meshCount: 1,
+ vertexCount: 100,
+ runtimeBounds: { width: 1, height: 2, depth: 1 },
+ staticBounds: { width: 1, height: 2, depth: 1 },
+ materialSlotCount: 3,
+ texturedSlotCount: 3,
+ untexturedRenderedMaterialCount: 0,
+ pendingTextureCount: 0,
+ suspiciousTinyTextureCount: 0,
+ fallbackTextureCount: 0,
+ headOrientationRiskCount: 0,
+ requestedModelVariation: 'hum01',
+ loadedModelVariation: 'hum',
+ bodyVariantFallback: true,
+ bodyVariantTextureFallbackApplied: true,
+ bodyVariantTextureFallbackAppliedCount: 3,
+ bodyVariantTextureCoverageRequiredCount: 7,
+ bodyVariantTextureCoverageAppliedCount: 7,
+ secondaryHeadBoneRemapFailureCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ pixels: { foregroundPixelCount: 1000, whitePixelRatio: 0 },
+ skeletonCount: 0,
+ animationMotion: { available: false },
+ };
+ assert.equal(evaluatePreviewEvidence(compatible).pass, true);
+ compatible.bodyVariantTextureFallbackApplied = false;
+ assert.equal(evaluatePreviewEvidence(compatible).pass, false);
+ compatible.bodyVariantTextureFallbackApplied = true;
+ compatible.bodyVariantTextureFallbackAppliedCount = 0;
+ assert.equal(evaluatePreviewEvidence(compatible).pass, true);
+ compatible.bodyVariantTextureCoverageAppliedCount = 0;
+ compatible.bodyVariantTextureCoverageRequiredCount = 0;
+ assert.equal(evaluatePreviewEvidence(compatible).pass, false);
+ compatible.bodyVariantTextureFallbackAppliedCount = 3;
+ compatible.bodyVariantTextureCoverageRequiredCount = 7;
+ compatible.bodyVariantTextureCoverageAppliedCount = 6;
+ assert.equal(evaluatePreviewEvidence(compatible).pass, false);
+});
+
+test('appearance invariant rejects one untextured rendered region', () => {
+ const result = evaluateAppearanceVariant({
+ renderPass: true,
+ materialSlotCount: 5,
+ effectOnlyMaterialCount: 0,
+ texturedSlotCount: 4,
+ untexturedRenderedMaterialCount: 1,
+ pendingTextureCount: 0,
+ suspiciousTinyTextureCount: 0,
+ headOrientationRiskCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ headTextureSignatures: ['humhe00:humhe0001.png'],
+ }, { requireHeadTexture: true });
+ assert.equal(result.invariantPass, false);
+ assert.ok(result.invariantViolations.includes('untextured-rendered-material'));
+});
+
+test('visual evidence reports semantic head inversion as a named failure', () => {
+ const result = evaluatePreviewEvidence({
+ available: true,
+ meshCount: 1,
+ vertexCount: 3,
+ runtimeBounds: { width: 1, height: 2, depth: 1 },
+ staticBounds: { width: 1, height: 2, depth: 1 },
+ semanticHeadOrientation: { required: true, measurable: true, pass: false },
+ pixels: { foregroundPixelCount: 1000, whitePixelRatio: 0 },
+ }, {
+ requireAnimationMotion: false,
+ requireFullMaterialCoverage: false,
+ });
+ assert.ok(result.violations.includes('head-geometry-inverted'));
+});
+
+test('classic face invariant requires eight distinct texture signatures', () => {
+ const valid = Array.from({ length: 8 }, (_, face) => ({
+ face,
+ headTextureSignatures: [`hum-face-${face}`],
+ }));
+ assert.equal(evaluateFaceVariantDeterminism(valid).pass, true);
+ const duplicate = valid.map((variant) => ({ ...variant }));
+ duplicate[7].headTextureSignatures = duplicate[0].headTextureSignatures;
+ const result = evaluateFaceVariantDeterminism(duplicate);
+ assert.equal(result.pass, false);
+ assert.ok(result.violations.includes('face-variant-texture-duplicate'));
+});
+
+test('visual mutation canaries reject white, exploded, and inconsistent evidence', () => {
+ const result = runVisualInvariantCanaries();
+ assert.equal(result.pass, true);
+ assert.deepEqual(
+ result.cases.map(({ name, rejected }) => [name, rejected]),
+ [
+ ['white-material', true],
+ ['exploded-bounds', true],
+ ['nondeterministic-repeat', true],
+ ['approved-baseline-orientation-drift', true],
+ ['motionless-animation', true],
+ ['body-variant-fallback', true],
+ ['secondary-head-remap-failure', true],
+ ['detached-animation-donor', true],
+ ['unlocked-animation-donor-head', true],
+ ['attached-animation-donor-not-playing', true],
+ ['misbound-head-geometry', true],
+ ]
+ );
+ assert.deepEqual(
+ result.acceptedCases.map(({ name, accepted }) => [name, accepted]),
+ [['coordinate-axis-remap', true]]
+ );
+});
+
+test('approved visual baseline rejects a consistently changed appearance', () => {
+ const evidence = {
+ meshCount: 3,
+ vertexCount: 300,
+ skeletonCount: 1,
+ boneCount: 20,
+ nativePoseOnly: false,
+ animationMotion: {
+ available: true,
+ expectedMotion: true,
+ moving: true,
+ frameCount: 4,
+ maximumPoseDelta: 0.2,
+ nonFiniteValueCount: 0,
+ },
+ materialSignature: 'body|head',
+ skeletonSignature: 'bi_l|bi_r|he',
+ pixels: {
+ foregroundBounds: { x: 0.3, y: 0.1, width: 0.4, height: 0.8 },
+ whitePixelRatio: 0,
+ signature: [10, 20, 30],
+ },
+ };
+ const approved = createApprovedVisualBaseline(evidence);
+ assert.equal(compareApprovedVisualBaseline(evidence, approved).pass, true);
+ const changed = structuredClone(evidence);
+ changed.pixels.signature = [240, 230, 220];
+ const result = compareApprovedVisualBaseline(changed, approved);
+ assert.equal(result.pass, false);
+ assert.ok(result.violations.includes('approved-head-region-changed'));
+ assert.equal(
+ compareApprovedVisualBaseline(evidence, null, { requireApprovedBaseline: true }).pass,
+ false
+ );
+});
+
+test('baseline approval permits reviewed pixel drift but never failed invariants', () => {
+ const reviewedDrift = {
+ pass: false,
+ observations: [{}, {}],
+ auditPasses: [true, true],
+ observationAnalyses: [{ pass: true }, { pass: true }],
+ approvedBaselineAnalyses: [{ pass: false }, { pass: false }],
+ repeatability: { pass: true },
+ };
+ assert.equal(evaluateVisualBaselineApprovalEligibility(reviewedDrift).pass, true);
+ reviewedDrift.observationAnalyses[1] = { pass: false };
+ assert.equal(evaluateVisualBaselineApprovalEligibility(reviewedDrift).pass, false);
+});
+
+test('preview evidence independently rejects a T-pose and repeat drift', () => {
+ const base = {
+ available: true,
+ meshCount: 3,
+ vertexCount: 300,
+ runtimeBounds: { width: 2, height: 6, depth: 1.5 },
+ staticBounds: { width: 2, height: 6, depth: 1.5 },
+ untexturedRenderedMaterialCount: 0,
+ pendingTextureCount: 0,
+ fallbackTextureCount: 0,
+ headOrientationRiskCount: 0,
+ nonFiniteBoneMatrixCount: 0,
+ skeletonCount: 1,
+ boneCount: 20,
+ nativePoseOnly: false,
+ animationMotion: {
+ available: true,
+ expectedMotion: true,
+ moving: true,
+ frameCount: 4,
+ maximumPoseDelta: 0.2,
+ nonFiniteValueCount: 0,
+ },
+ materialSignature: 'body|head',
+ skeletonSignature: 'bi_l|bi_r|he',
+ leftArm: { available: true, verticalRatio: -0.8 },
+ rightArm: { available: true, verticalRatio: -0.8 },
+ pixels: { foregroundPixelCount: 5000, whitePixelRatio: 0.01, signature: [10, 20, 30] },
+ };
+ assert.equal(evaluatePreviewEvidence(base, { requireArmsDown: true }).pass, true);
+ const tPose = structuredClone(base);
+ tPose.leftArm.verticalRatio = 0;
+ assert.equal(evaluatePreviewEvidence(tPose, { requireArmsDown: true }).pass, false);
+ const nearTPose = structuredClone(base);
+ nearTPose.leftArm.verticalRatio = -0.2;
+ assert.equal(
+ evaluatePreviewEvidence(nearTPose, { requireArmsDown: true }).pass,
+ false
+ );
+ const shallowCompactPose = structuredClone(base);
+ shallowCompactPose.compactExpected = true;
+ shallowCompactPose.nativePoseOnly = true;
+ shallowCompactPose.neutralized = true;
+ shallowCompactPose.leftArm.verticalRatio = -0.4;
+ assert.equal(evaluatePreviewEvidence(shallowCompactPose, {
+ requireArmsDown: true,
+ maximumArmVerticalRatio: -0.5,
+ }).pass, false);
+ const drift = structuredClone(base);
+ drift.runtimeBounds.width = 2.4;
+ assert.equal(comparePreviewEvidence([base, drift]).pass, false);
+ const motionless = structuredClone(base);
+ motionless.animationMotion.moving = false;
+ motionless.animationMotion.maximumPoseDelta = 0;
+ const motionResult = evaluatePreviewEvidence(motionless);
+ assert.equal(motionResult.pass, false);
+ assert.ok(motionResult.violations.includes('animation-motionless'));
+ const detachedDonor = structuredClone(base);
+ detachedDonor.previewAnimationDonorExpected = true;
+ detachedDonor.previewAnimationDonorPass = false;
+ detachedDonor.previewAnimationDonorGroupCount = 0;
+ detachedDonor.previewAnimationDonorTargetCount = 0;
+ detachedDonor.previewAnimationDonorBindRelativeTargetCount = 0;
+ const donorResult = evaluatePreviewEvidence(detachedDonor);
+ assert.equal(donorResult.pass, false);
+ assert.ok(
+ donorResult.violations.includes(
+ 'preview-animation-donor-not-attached-to-instance'
+ )
+ );
+ const unlockedDonorHead = structuredClone(base);
+ unlockedDonorHead.previewAnimationDonorExpected = true;
+ unlockedDonorHead.previewAnimationDonorPass = true;
+ unlockedDonorHead.previewAnimationDonorGroupCount = 1;
+ unlockedDonorHead.previewAnimationDonorTargetCount = 20;
+ unlockedDonorHead.previewAnimationDonorBindRelativeTargetCount = 20;
+ unlockedDonorHead.previewAnimationDonorBindLockedRotationTargetNames = [];
+ const unlockedHeadResult = evaluatePreviewEvidence(unlockedDonorHead);
+ assert.equal(unlockedHeadResult.pass, false);
+ assert.ok(
+ unlockedHeadResult.violations.includes(
+ 'preview-animation-donor-head-rotation-unlocked'
+ )
+ );
+ const idleDonor = structuredClone(base);
+ idleDonor.previewAnimationDonorExpected = true;
+ idleDonor.previewAnimationDonorPass = true;
+ idleDonor.previewAnimationDonorGroupCount = 1;
+ idleDonor.previewAnimationDonorTargetCount = 20;
+ idleDonor.previewAnimationDonorBindRelativeTargetCount = 20;
+ idleDonor.previewAnimationDonorBindLockedRotationTargetNames = ['he'];
+ idleDonor.animationMotion = {
+ available: false,
+ expectedMotion: false,
+ moving: false,
+ frameCount: 0,
+ maximumPoseDelta: 0,
+ nonFiniteValueCount: 0,
+ };
+ const idleDonorResult = evaluatePreviewEvidence(idleDonor);
+ assert.equal(idleDonorResult.pass, false);
+ assert.ok(idleDonorResult.violations.includes('animation-motionless'));
+});
+
+test('alias-first race refresh uses the resolved model source inventory', async () => {
+ const source = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/validation/race-face-audit.jsx'),
+ 'utf8'
+ );
+ assert.match(
+ source,
+ /raceModelMetadata\[refreshModel\]\?\.sourceFiles\s*\?\?\s*entry\.sourceFiles/
+ );
+ assert.doesNotMatch(
+ source,
+ /refreshModel\s*===\s*entry\.model\s*\?\s*entry\.sourceFiles\s*:\s*\[\]/
+ );
+});
+
+test('character animation policy accepts exact classic skeleton coverage', () => {
+ const bones = Object.fromEntries(
+ ['pepelvis', 'chchest', 'neneck', 'hehead', 'bibicepl', 'bibicepr'].map(
+ (name) => [name, {}]
+ )
+ );
+ const result = getCharacterAnimationCompatibility({
+ targetPoseTracks: bones,
+ donorPoseTracks: bones,
+ nativeAnimationKeys: ['pos'],
+ });
+ assert.equal(result.exactCoverage, 1);
+ assert.equal(result.useNativePoseOnly, false);
+});
+
+test('character animation policy isolates incompatible compact skeletons', () => {
+ const targetPoseTracks = Object.fromEntries(
+ ['pe', 'ch', 'ne', 'he', 'bi_l', 'bi_r', 'fo_l', 'fo_r'].map(
+ (name) => [name, {}]
+ )
+ );
+ const donorPoseTracks = Object.fromEntries(
+ ['pepelvis', 'chchest', 'neneck', 'hehead', 'bibicepl', 'bibicepr'].map(
+ (name) => [name, {}]
+ )
+ );
+ assert.equal(shouldUseNativeCharacterPose({
+ targetPoseTracks,
+ donorPoseTracks,
+ nativeAnimationKeys: ['pos'],
+ }), true);
+});
+
+test('Coldain models are deterministically classified as native-pose-only', () => {
+ assert.equal(isStaticPoseOnlyCharacterModel('com'), true);
+ assert.equal(isStaticPoseOnlyCharacterModel('cof'), true);
+ assert.equal(isStaticPoseOnlyCharacterModel('hum'), false);
+});
+
+test('known compact rigs are generation-order-independent native-pose models', () => {
+ assert.deepEqual(
+ [...STATIC_POSE_ONLY_CHARACTER_MODELS].sort(),
+ ['clf', 'clm', 'cof', 'com', 'qcf', 'qcm']
+ );
+ assert.equal(isStaticPoseOnlyCharacterModel('QCF'), true);
+ assert.equal(isStaticPoseOnlyCharacterModel('qcf01'), true);
+ assert.equal(isStaticPoseOnlyCharacterModel('unknown', 'CLM'), true);
+ assert.equal(isStaticPoseOnlyCharacterModel('hum', 'elf'), false);
+});
+
+test('effect-only models require a narrow explicit allowlist', () => {
+ assert.equal(isKnownEffectOnlyCharacterModel('GSF'), true);
+ assert.equal(isKnownEffectOnlyCharacterModel('gsm01'), true);
+ assert.equal(isKnownEffectOnlyCharacterModel('nwm'), false);
+ assert.equal(PREVIEW_CLIENT_FALLBACKS.gsf, undefined);
+ assert.equal(PREVIEW_ALIAS_FIRST_MODELS.has('gsf'), false);
+});
+
+test('character animation policy preserves a model with native motion', () => {
+ assert.equal(shouldUseNativeCharacterPose({
+ targetPoseTracks: { pe: {}, ch: {}, ne: {}, he: {}, bi_l: {} },
+ donorPoseTracks: { pepelvis: {}, chchest: {} },
+ nativeAnimationKeys: ['pos', 'p01'],
+ }), false);
+});
+
+test('visual animation selection rejects a constant p01 when a dynamic clip exists', () => {
+ const target = { id: 'bone' };
+ const group = (name, values) => ({
+ name,
+ targetedAnimations: [{
+ target,
+ animation: {
+ targetProperty: 'rotationQuaternion',
+ getKeys: () => values.map((value, frame) => ({ frame, value })),
+ },
+ }],
+ });
+ const pose = group('pos', [0]);
+ const constantP01 = group('Clone of p01', [1, 1, 1]);
+ const dynamicP02 = group('Clone of p02', [0, 0.5, 1]);
+
+ assert.equal(
+ inspectAnimationGroupVitality(constantP01, pose).dynamicTargetCount,
+ 0
+ );
+ assert.equal(
+ selectPreferredVisualAnimationGroup([pose, constantP01, dynamicP02]),
+ dynamicP02
+ );
+});
+
+test('visual animation selection retains p01 when p01 changes over time', () => {
+ const target = { id: 'bone' };
+ const group = (name, values) => ({
+ name,
+ targetedAnimations: [{
+ target,
+ animation: {
+ targetProperty: 'rotationQuaternion',
+ getKeys: () => values.map((value, frame) => ({ frame, value })),
+ },
+ }],
+ });
+ const pose = group('pos', [0]);
+ const dynamicP01 = group('Clone of p01', [0, 1]);
+ const dynamicP02 = group('Clone of p02', [0, 2]);
+
+ assert.equal(
+ selectPreferredVisualAnimationGroup([pose, dynamicP01, dynamicP02]),
+ dynamicP01
+ );
+});
+
+test('visual animation selection prefers the classic neutral o01 idle over p01', () => {
+ const target = { id: 'bone' };
+ const group = (name, values) => ({
+ name,
+ targetedAnimations: [{
+ target,
+ animation: {
+ targetProperty: 'rotationQuaternion',
+ getKeys: () => values.map((value, frame) => ({ frame, value })),
+ },
+ }],
+ });
+ const pose = group('pos', [0]);
+ const combatReadyP01 = group('Clone of p01', [0, 1]);
+ const neutralO01 = group('Clone of o01', [0, 0.5]);
+
+ assert.equal(
+ selectPreferredVisualAnimationGroup([pose, combatReadyP01, neutralO01]),
+ neutralO01
+ );
+});
+
+test('visual animation selection prefers a native no-offset idle clip', () => {
+ const target = { id: 'bone' };
+ const group = (name, values) => ({
+ name,
+ targetedAnimations: [{
+ target,
+ animation: {
+ targetProperty: 'rotationQuaternion',
+ getKeys: () => values.map((value, frame) => ({ frame, value })),
+ },
+ }],
+ });
+ const swim = group('swim_ba_1_gbn.ani', [0, 1]);
+ const idle = group('idle_ba_1_gbn.ani', [0, 2]);
+ const idleNoOffset = group('idle_ba_1_gbn.ani-nooffset', [0, 3]);
+
+ assert.equal(
+ selectPreferredVisualAnimationGroup([swim, idle, idleNoOffset]),
+ idleNoOffset
+ );
+});
+
+test('validation preview animates and labels every spawn instead of one per model', async () => {
+ const controllerSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/controllers/SpawnController.js'),
+ 'utf8'
+ );
+ const spawnSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js'),
+ 'utf8'
+ );
+ const contextSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/zone/zone-context.jsx'),
+ 'utf8'
+ );
+ const raceAuditSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/validation/race-face-audit.jsx'),
+ 'utf8'
+ );
+ const previewFinalizationSource = controllerSource.slice(
+ controllerSource.indexOf('const loadedSpawns = Object.values(this.spawns)'),
+ controllerSource.indexOf('} finally {', controllerSource.indexOf('const loadedSpawns = Object.values(this.spawns)'))
+ );
+
+ assert.doesNotMatch(previewFinalizationSource, /liveModelNames/);
+ assert.match(previewFinalizationSource, /loadedSpawn\.startInitialAnimation/);
+ assert.match(previewFinalizationSource, /loadedSpawns\[index\]\.createNameplate/);
+ assert.match(controllerSource, /neutralIdleSelectionFailureCount/);
+ assert.match(spawnSource, /retainSelectedVisualAnimationResources\(anim\)/);
+ assert.match(spawnSource, /this\.animationGroups = \[selectedAnimationGroup\]/);
+ assert.match(controllerSource, /excessAnimationGroupCount/);
+ assert.match(controllerSource, /policyExtras\?\.spireNativePoseOnly !== true/);
+ assert.match(controllerSource, /missing-playable-animation/);
+ assert.match(controllerSource, /stopAfterFirstSuccessfulSource: true/);
+ assert.match(controllerSource, /instanceContainer\.__spireNativePoseOnly/);
+ assert.match(spawnSource, /previewAnimationDonor\?\.expected === true/);
+ assert.match(spawnSource, /instanceContainer\.__spireNativePoseOnly === true/);
+ assert.match(contextSource, /visualStats\.excessAnimationGroupCount/);
+ assert.match(raceAuditSource, /loadValidatedCharacterContainer/);
+ assert.match(raceAuditSource, /getFirstAssetContainer/);
+ assert.match(raceAuditSource, /Never hand it the SpawnController's shared live cache entry/);
+ assert.match(raceAuditSource, /loadAssetContainerFromEQ/);
+});
+
+test('appearance materials share one decode and expose asynchronous failures to QA', async () => {
+ const spawnSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/models/BabylonSpawn.js'),
+ 'utf8'
+ );
+ const controllerSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/viewer/controllers/SpawnController.js'),
+ 'utf8'
+ );
+ const contextSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/zone/zone-context.jsx'),
+ 'utf8'
+ );
+
+ assert.match(spawnSource, /pendingAppearanceMaterialsByScene = new WeakMap/);
+ assert.match(spawnSource, /const getAppearanceTextureData = async/);
+ assert.match(spawnSource, /APPEARANCE_TEXTURE_DECODE_ATTEMPTS = 2/);
+ assert.match(spawnSource, /APPEARANCE_TEXTURE_DECODE_TIMEOUT_MS = 10000/);
+ assert.match(spawnSource, /const startAppearanceTextureDecode/);
+ assert.match(spawnSource, /URL\.createObjectURL/);
+ assert.match(spawnSource, /new Blob\(\[textureData\], \{ type: 'image\/png' \}\)/);
+ assert.match(spawnSource, /URL\.revokeObjectURL/);
+ assert.match(spawnSource, /texture\?\.dispose\?\.\(\)/);
+ assert.match(spawnSource, /newFullName = null;[\s\S]*getBodyVariantCoverageTextureName/);
+ assert.match(spawnSource, /spireAppearanceTexturePending: true/);
+ assert.match(spawnSource, /spireAppearanceTextureDecodeFailed = true/);
+ assert.match(spawnSource, /appearanceTextureDecodeFailureCount\+\+/);
+ assert.match(controllerSource, /material\.metadata\?\.spireAppearanceTextureDecodeFailed/);
+ assert.match(controllerSource, /appearanceTexturePendingCount/);
+ assert.match(contextSource, /requiredStableReadyPolls = 3/);
+ assert.match(contextSource, /visualStats\.validationSettle/);
+ assert.match(contextSource, /visualStats\.appearanceTextureDecodeFailureCount/);
+});
+
+test('runtime animation QA deduplicates expensive frame seeks without rendering per sample', async () => {
+ const contextSource = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/zone/zone-context.jsx'),
+ 'utf8'
+ );
+ const probeSource = contextSource.slice(
+ contextSource.indexOf('const getPlayingSpawnAnimationProbes'),
+ contextSource.indexOf('const getMaterialSlots')
+ );
+
+ assert.match(probeSource, /const observedModelAssets = new Set\(\)/);
+ assert.match(probeSource, /observedModelAssets\.has\(modelAssetKey\)/);
+ assert.match(probeSource, /spawn\.resolvedModelAsset/);
+ assert.doesNotMatch(probeSource, /playingGroups\[0\]\?\.name/);
+ assert.equal((probeSource.match(/scene\?\.render\?\.\(\)/g) ?? []).length, 1);
+});
+
+test('zone QA isolates normal zones, fails fast on tab crashes, and guards memory between them', async () => {
+ const runnerSource = await fs.readFile(
+ path.resolve('tools/sage-qa/lib/playwright-runner.mjs'),
+ 'utf8'
+ );
+ const runSource = await fs.readFile(
+ path.resolve('tools/sage-qa/run.mjs'),
+ 'utf8'
+ );
+ const zoneSource = runnerSource.slice(
+ runnerSource.indexOf('export const runZoneValidation'),
+ runnerSource.indexOf('export const runRaceAuditBatches')
+ );
+
+ assert.match(zoneSource, /config\.isolateZones !== false/);
+ assert.match(zoneSource, /page\.once\('crash'/);
+ assert.match(zoneSource, /await beforeZone\?\./);
+ assert.match(runSource, /before-zone-validation-\$\{index \+ 1\}/);
+});
+
+test('validation harness explicitly remounts repeated single-zone cycles', async () => {
+ const source = await fs.readFile(
+ path.resolve('frontend/eqsage-embed/src/components/validation/validation-harness.jsx'),
+ 'utf8'
+ );
+
+ assert.match(source, /selectedZoneRef\.current === zoneName/);
+ assert.match(source, /setSelectedZone\(null\)/);
+ assert.match(source, /window\.setTimeout\(commitSelection, 50\)/);
+ assert.match(source, /const commitSelection = \(\) => \{[\s\S]*setZoneDialogOpen\(false\);[\s\S]*setSelectedZone\(nextZone\)/);
+});
+
+test('visual preview stabilization falls back to the resolved face-preview alias', async () => {
+ const source = await fs.readFile(
+ path.resolve('tools/sage-qa/lib/playwright-runner.mjs'),
+ 'utf8'
+ );
+ const stabilizationSource = source.slice(
+ source.indexOf('const stabilizePreview'),
+ source.indexOf('const collectPreviewEvidence')
+ );
+
+ assert.match(stabilizationSource, /const previewSpawns = spawns\.filter/);
+ assert.match(stabilizationSource, /\?\? previewSpawns\[0\] \?\? spawns\.find/);
+});
+
+test('detached donor animations are retargeted to the instantiated clone hierarchy', () => {
+ const sourcePelvis = { name: 'pe' };
+ const clonedRoot = { name: 'Clone of root' };
+ const clonedPelvis = { name: 'Clone of pe' };
+ const targetedAnimation = { target: sourcePelvis, animation: {} };
+ const result = retargetDetachedAnimationTargets(
+ [{ name: 'Clone of p01', targetedAnimations: [targetedAnimation] }],
+ [clonedRoot, clonedPelvis]
+ );
+
+ assert.equal(targetedAnimation.target, clonedPelvis);
+ assert.deepEqual(result, {
+ detachedTargetCount: 1,
+ retargetedTargetCount: 1,
+ unresolvedTargetCount: 0,
+ });
+});
+
+test('character head orientation preserves QCF integrated UVs without changing secondary heads', () => {
+ const qcf = getCharacterHeadOrientationPolicy('QCFHE0001_MDF');
+ const qcfFace = getCharacterHeadOrientationPolicy('QCFHEsk01_MDF');
+ const qcfSecondary = getCharacterHeadOrientationPolicy('QCFHE0101_MDF');
+ const qcm = getCharacterHeadOrientationPolicy('QCMHE0001_MDF');
+ const brm = getCharacterHeadOrientationPolicy('BRMHE0001_MDF');
+ const brmSecondary = getCharacterHeadOrientationPolicy('BRMHE0101_MDF');
+ const fef = getCharacterHeadOrientationPolicy('FEFHE0001_MDF');
+ const gff = getCharacterHeadOrientationPolicy('GFFHE0001_MDF');
+ const shf = getCharacterHeadOrientationPolicy('SHFHE0001_MDF');
+ assert.equal(qcf.isCharacterHead, true);
+ assert.equal(qcf.usesNativeHeadUv, true);
+ assert.equal(qcf.geometryUvFlipped, false);
+ assert.equal(qcfFace.usesNativeHeadUv, true);
+ assert.equal(qcfFace.geometryUvFlipped, false);
+ assert.equal(qcfSecondary.usesNativeHeadUv, false);
+ assert.equal(qcfSecondary.geometryUvFlipped, true);
+ assert.equal(qcm.isCharacterHead, true);
+ assert.equal(qcm.usesNativeHeadUv, false);
+ assert.equal(qcm.geometryUvFlipped, true);
+ assert.equal(brm.usesNativeHeadUv, true);
+ assert.equal(brm.geometryUvFlipped, false);
+ assert.equal(brmSecondary.usesNativeHeadUv, false);
+ assert.equal(brmSecondary.geometryUvFlipped, true);
+ assert.equal(fef.usesNativeHeadUv, true);
+ assert.equal(fef.geometryUvFlipped, false);
+ assert.equal(gff.usesNativeHeadUv, true);
+ assert.equal(gff.geometryUvFlipped, false);
+ assert.equal(shf.usesNativeHeadUv, true);
+ assert.equal(shf.geometryUvFlipped, false);
+});
+
+test('module readiness discovers static and dynamic Vite chunk dependencies', () => {
+ assert.deepEqual(
+ collectModuleSpecifiers(`
+ import './assets/side.js';
+ import value from "./assets/static.js";
+ const lazy = import('./assets/lazy.js');
+ `).sort(),
+ ['./assets/lazy.js', './assets/side.js', './assets/static.js']
+ );
+});
+
+test('served bundle identity rejects stale in-memory frontend output', async () => {
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sage-bundle-'));
+ const entryPath = path.join(directory, 'eqsage-embed.js');
+ await fs.writeFile(entryPath, 'export const revision = "current";\n');
+ const fetchImpl = async () => ({
+ ok: true,
+ status: 200,
+ arrayBuffer: async () => Buffer.from('export const revision = "stale";\n'),
+ });
+ await assert.rejects(
+ verifyServedEmbedEntry({
+ baseUrl: 'http://test.local',
+ buildEntryPath: entryPath,
+ fetchImpl,
+ }),
+ /serving a stale Sage bundle/
+ );
+ await fs.rm(directory, { recursive: true, force: true });
+});
+
+test('module readiness waits for two identical successful graph walks', async () => {
+ const responses = new Map([
+ ['http://test.local/eqsage-embed/eqsage-embed.js', `import './assets/main-AbCd1234.js';`],
+ ['http://test.local/eqsage-embed/assets/main-AbCd1234.js', `import('./lazy-EfGh5678.js');`],
+ ['http://test.local/eqsage-embed/assets/lazy-EfGh5678.js', 'export default true;'],
+ ]);
+ let requestCount = 0;
+ const result = await waitForModuleGraphReady({
+ baseUrl: 'http://test.local',
+ settleMs: 0,
+ timeoutMs: 1000,
+ fetchImpl: async (url) => {
+ requestCount++;
+ const source = responses.get(url);
+ return {
+ ok: source !== undefined,
+ status: source === undefined ? 404 : 200,
+ text: async () => source ?? '',
+ };
+ },
+ });
+ assert.equal(result.moduleCount, 3);
+ assert.equal(result.attempts, 2);
+ assert.equal(requestCount, 6);
+});
+
+test('validateProfile rejects unsafe or malformed configuration', () => {
+ assert.throws(() => validateProfile({
+ schemaVersion: 1,
+ name: 'bad',
+ zoneValidation: { enabled: true, zones: [], cycles: 0 },
+ raceAudit: { enabled: true, batchSize: 0, modelSelection: { mode: 'bogus' } },
+ artifacts: { screenshotMode: 'sometimes', traceMode: 'maybe', maxTracedZoneReports: -1 },
+ memory: { maxUsedPercent: 0 },
+ }), /Invalid Sage QA profile/);
+});
+
+test('validateProfile rejects a soak profile without warmup, baseline, and comparison cycles', () => {
+ assert.throws(() => validateProfile({
+ schemaVersion: 1,
+ name: 'ineffective-soak',
+ zoneValidation: { enabled: true, zones: ['unrest'], cycles: 2 },
+ raceAudit: { enabled: false, batchSize: 1, modelSelection: { mode: 'explicit' } },
+ soak: { enabled: true, warmupCycles: 1 },
+ artifacts: { screenshotMode: 'never', traceMode: 'never', maxTracedZoneReports: 0 },
+ memory: { maxUsedPercent: 88, maxRunnerRssMB: 2048, maxRunnerExternalMB: 1536 },
+ }), /warmup, baseline, and comparison/);
+});
+
+test('memory budget reports both percentage and free-memory violations', () => {
+ const result = evaluateMemoryBudget({
+ system: { usedPercent: 95, freeBytes: 512 * 1024 * 1024 },
+ }, {
+ maxUsedPercent: 88,
+ minFreeMB: 4096,
+ maxRunnerRssMB: 2048,
+ maxRunnerExternalMB: 1536,
+ });
+ assert.equal(result.pass, false);
+ assert.equal(result.violations.length, 2);
+});
+
+test('memory budget blocks excessive runner RSS and external buffers', () => {
+ const result = evaluateMemoryBudget({
+ system: { usedPercent: 40, freeBytes: 20 * 1024 * 1024 * 1024 },
+ runner: { rssBytes: 3 * 1024 * 1024 * 1024, externalBytes: 2 * 1024 * 1024 * 1024 },
+ }, {
+ maxUsedPercent: 88,
+ minFreeMB: 4096,
+ maxRunnerRssMB: 2048,
+ maxRunnerExternalMB: 1536,
+ });
+ assert.equal(result.pass, false);
+ assert.equal(result.violations.length, 2);
+});
+
+test('zone aggregation reconciles NPCs, textures, doors, and observed models', () => {
+ const result = aggregateZoneValidation({
+ finished: true,
+ complete: true,
+ failureCount: 0,
+ reports: [{
+ zone: 'unrest',
+ rootNodeCount: 2,
+ pass: { all: true },
+ spawns: { loaded: 2 },
+ visuals: {
+ byModel: { hum: {}, ske: {} },
+ readyTextureCount: 8,
+ materialSlotCount: 8,
+ appearanceTextureDecodeFailureCount: 0,
+ tPoseRiskCount: 0,
+ motionlessAnimationCount: 0,
+ animationGroupCount: 2,
+ playingAnimationGroupCount: 2,
+ excessAnimationGroupCount: 0,
+ nameplateExpectedCount: 2,
+ nameplateCount: 2,
+ nameplateFailureCount: 0,
+ },
+ doors: { loaded: 3, hidden: 1, visuals: { readyTextureCount: 4 } },
+ }],
+ });
+ assert.equal(result.complete, true);
+ assert.equal(result.npcCount, 2);
+ assert.equal(result.npcRootCount, 2);
+ assert.equal(result.uniqueModelCount, 2);
+ assert.equal(result.visibleDoorCount, 3);
+ assert.equal(result.nameplateCount, 2);
+ assert.equal(result.nameplateFailureCount, 0);
+ assert.equal(result.appearanceTextureDecodeFailureCount, 0);
+ assert.equal(result.motionlessAnimationCount, 0);
+ assert.equal(result.animationGroupCount, 2);
+ assert.equal(result.playingAnimationGroupCount, 2);
+ assert.equal(result.excessAnimationGroupCount, 0);
+});
+
+test('door heading conversion preserves EQ orientation across a full turn', () => {
+ for (const heading of [0, 64, 128, 256, 384, 511]) {
+ const degrees = eqHeadingToDegrees(heading);
+ const roundTrip = degreesToEqHeading(degrees);
+ assert.ok(Math.abs(roundTrip - heading) < 0.000001);
+ }
+ assert.equal(eqHeadingToDegrees(Number.NaN), 180);
+ assert.equal(degreesToEqHeading(Number.NaN), 0);
+});
+
+test('door placement and payload round-trip axes, heading, scale, and editor fields', () => {
+ const source = {
+ id : 42,
+ heading: 128,
+ name : 'DOOR1',
+ pos_x : 30,
+ pos_y : 40,
+ pos_z : 50,
+ size : 125,
+ version: 0,
+ zone : 'befallen',
+ };
+ const placement = toDoorPlacement(source);
+ assert.deepEqual(
+ {
+ rotateY: placement.rotateY,
+ scale : placement.scale,
+ x : placement.x,
+ y : placement.y,
+ z : placement.z,
+ },
+ { rotateY: 270, scale: 1.25, x: 40, y: 50, z: 30 }
+ );
+
+ const payload = stripDoorEditorFields(toDoorPayload(placement, {
+ short_name: 'befallen',
+ version : 0,
+ }));
+ assert.deepEqual(
+ {
+ heading: payload.heading,
+ pos_x : payload.pos_x,
+ pos_y : payload.pos_y,
+ pos_z : payload.pos_z,
+ size : payload.size,
+ },
+ { heading: 128, pos_x: 30, pos_y: 40, pos_z: 50, size: 125 }
+ );
+ for (const key of ['rotateX', 'rotateY', 'rotateZ', 'scale', 'x', 'y', 'z']) {
+ assert.equal(key in payload, false);
+ }
+});
+
+test('door visibility policy excludes non-renderable teleport and trigger entries', () => {
+ const doors = [
+ { id: 1, opentype: 31 },
+ { id: 2, opentype: 50 },
+ { id: 3, opentype: 53 },
+ { id: 4, opentype: 54 },
+ ];
+ assert.equal(isInvisibleDoor(doors[0]), false);
+ assert.equal(isInvisibleDoor(doors[1]), true);
+ assert.deepEqual(getRenderableDoors(doors).map((door) => door.id), [1]);
+});
+
+test('nameplate placement requires the entire plane to clear the model top', () => {
+ const pass = evaluateNameplatePlacement({
+ bodyTopLocalY: 4,
+ nameplateCenterLocalY: 4.54,
+ planeHeight: 0.8,
+ rootScaleY: 1,
+ requiredWorldClearance: 0.12,
+ });
+ assert.equal(pass.pass, true);
+ assert.ok(pass.clearanceWorldY >= 0.12);
+
+ const intersecting = evaluateNameplatePlacement({
+ bodyTopLocalY: 4,
+ nameplateCenterLocalY: 4.2,
+ planeHeight: 0.8,
+ rootScaleY: 1,
+ requiredWorldClearance: 0.12,
+ });
+ assert.equal(intersecting.pass, false);
+ assert.ok(intersecting.clearanceWorldY < 0);
+});
+
+test('nameplate placement evaluates clearance in scaled world space', () => {
+ const result = evaluateNameplatePlacement({
+ bodyTopLocalY: 2,
+ nameplateCenterLocalY: 2.24,
+ planeHeight: 0.4,
+ rootScaleY: 3,
+ requiredWorldClearance: 0.12,
+ });
+ assert.equal(result.pass, true);
+ assert.ok(Math.abs(result.clearanceWorldY - 0.12) < 0.000001);
+});
+
+test('race aggregation counts appearance checks and model failures', () => {
+ const result = aggregateRaceAudits([{ audit: {
+ complete: true,
+ results: [
+ { model: 'hum', status: 'pass-discrete-head', appearanceVariantCountAudited: 10, faceVariantCountAudited: 8 },
+ { model: 'diag', name: 'Diagnostic', status: 'pass-animation-diagnostic', bindPoseOnlyAnimation: true, appearanceVariantCountAudited: 2, faceVariantCountAudited: 0 },
+ { model: 'bad', status: 'untextured-model', appearanceVariantCountAudited: 1, faceVariantCountAudited: 0 },
+ ],
+ } }]);
+ assert.equal(result.auditedModelCount, 3);
+ assert.equal(result.appearanceVariantCountAudited, 13);
+ assert.equal(result.animationDiagnosticCount, 1);
+ assert.deepEqual(result.animationDiagnosticModels, ['diag']);
+ assert.equal(result.failureCount, 1);
+ assert.equal(result.complete, false);
+});
+
+test('soak analysis ignores warmup and detects post-warmup resource growth', () => {
+ const report = (cycle, meshes, heapMB) => ({
+ zone: 'unrest',
+ validationSequence: { cycle },
+ sceneResources: { meshes },
+ runtimeMemory: { jsHeapUsedBytes: heapMB * 1024 * 1024 },
+ });
+ const result = analyzeSoak([
+ report(1, 100, 100),
+ report(2, 100, 120),
+ report(3, 120, 180),
+ ], {
+ enabled: true,
+ warmupCycles: 1,
+ resourceKeys: ['meshes'],
+ resourceTolerancePercent: 2,
+ resourceToleranceAbsolute: 4,
+ maxJsHeapGrowthMB: 40,
+ });
+ assert.equal(result.pass, false);
+ assert.equal(result.violations.length, 2);
+});
+
+test('soak heap analysis compares matching zones instead of different zone footprints', () => {
+ const report = (zone, cycle, heapMB) => ({
+ zone,
+ validationSequence: { cycle },
+ sceneResources: {},
+ runtimeMemory: { jsHeapUsedBytes: heapMB * 1024 * 1024 },
+ });
+ const result = analyzeSoak([
+ report('small', 2, 100),
+ report('large', 2, 900),
+ report('small', 3, 110),
+ report('large', 3, 880),
+ ], {
+ enabled: true,
+ warmupCycles: 1,
+ resourceKeys: [],
+ maxJsHeapGrowthMB: 50,
+ });
+ assert.equal(result.pass, true);
+ assert.equal(result.heapGrowthMB, 10);
+ assert.equal(result.heapDeltas.length, 2);
+});
+
+test('soak resource analysis accepts a bounded rebound below the warmup high-water mark', () => {
+ const report = (cycle, materials, textures) => ({
+ zone: 'gfaydark',
+ validationSequence: { cycle },
+ sceneResources: { materials, textures },
+ });
+ const result = analyzeSoak([
+ report(1, 1656, 2722),
+ report(2, 1556, 2522),
+ report(3, 1596, 2602),
+ ], {
+ enabled: true,
+ warmupCycles: 1,
+ resourceKeys: ['materials', 'textures'],
+ resourceTolerancePercent: 2,
+ resourceToleranceAbsolute: 16,
+ maxJsHeapGrowthMB: 512,
+ });
+ assert.equal(result.pass, true);
+ assert.equal(result.comparedReportCount, 1);
+ assert.equal(result.resourceDeltas.length, 2);
+ assert.deepEqual(
+ result.resourceDeltas.map(({ before, after }) => [before, after]),
+ [[1656, 1596], [2722, 2602]]
+ );
+});
+
+test('validation URLs carry cycles and disable EQ-directory report clutter', () => {
+ const zone = new URL(buildZoneValidationUrl({
+ baseUrl: 'http://127.0.0.1:8080',
+ route: '/sage',
+ eqDirectory: 'C:\\EQ',
+ zones: ['unrest', 'rivervale'],
+ cycles: 3,
+ cacheBust: 'test',
+ }));
+ assert.equal(zone.searchParams.get('sageValidationCycles'), '3');
+ assert.equal(zone.searchParams.get('sageValidationPersist'), '0');
+ const race = new URL(buildRaceAuditUrl({
+ baseUrl: 'http://127.0.0.1:8080',
+ route: '/sage',
+ eqDirectory: 'C:\\EQ',
+ bootstrapZone: 'blackburrow',
+ models: ['hum'],
+ cacheBust: 'test',
+ }));
+ assert.equal(race.searchParams.get('sageRaceAuditForceRefresh'), '0');
+ assert.equal(race.searchParams.get('sageRaceAuditPersist'), '0');
+});
+
+test('coverage manifest intersects mapped models with available EQ assets', async (context) => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sage-qa-coverage-'));
+ context.after(() => fs.rm(root, { recursive: true, force: true }));
+ const common = path.join(root, 'frontend', 'eqsage-embed', 'src', 'viewer', 'common');
+ const staticMaps = path.join(root, 'internal', 'http', 'staticmaps');
+ const eqDirectory = path.join(root, 'eq');
+ await fs.mkdir(common, { recursive: true });
+ await fs.mkdir(staticMaps, { recursive: true });
+ await fs.mkdir(path.join(eqDirectory, 'eqsage', 'models'), { recursive: true });
+ await fs.writeFile(path.join(common, 'raceData.json'), JSON.stringify([{ id: 1, name: 'Human', 0: 'HUM', 1: 'HUF', 2: '' }]));
+ await fs.writeFile(path.join(common, 'raceModelMetadata.json'), JSON.stringify({
+ hum: { minTexture: 0, maxTexture: 2, minHelmTexture: 0, maxHelmTexture: 1 },
+ huf: { minTexture: 0, maxTexture: 0, minHelmTexture: 0, maxHelmTexture: 0 },
+ }));
+ await fs.writeFile(path.join(common, 'raceAppearancePolicies.json'), JSON.stringify({ classicFaceModels: ['hum', 'huf'] }));
+ await fs.writeFile(path.join(staticMaps, 'race-inventory-map.json'), JSON.stringify({
+ races: [{ race_id: 1, is_playable: true, sources: [] }],
+ }));
+ await fs.writeFile(path.join(eqDirectory, 'eqsage', 'models', 'hum.glb'), 'fixture');
+
+ const coverage = await buildCoverageManifest({ repoRoot: root, eqDirectory });
+ assert.equal(coverage.summary.mappedModelCount, 2);
+ assert.equal(coverage.summary.availableMappedModelCount, 1);
+ assert.deepEqual(resolveModelSelection(coverage, { mode: 'available' }), ['hum']);
+ assert.equal(coverage.models.find((model) => model.model === 'hum').expectedAppearanceChecks, 11);
+});
+
+test('artifact retention only prunes old run directories inside its root', async (context) => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sage-qa-artifacts-'));
+ context.after(() => fs.rm(root, { recursive: true, force: true }));
+ const first = await createRunDirectory({ outputRoot: root, profileName: 'first', now: new Date('2026-01-01T00:00:00Z') });
+ await writeText(path.join(first.runDirectory, 'data.bin'), 'x'.repeat(32));
+ await new Promise((resolve) => setTimeout(resolve, 15));
+ const second = await createRunDirectory({ outputRoot: root, profileName: 'second', now: new Date('2026-01-02T00:00:00Z') });
+ const retention = await pruneRuns({
+ outputRoot: root,
+ keepRuns: 1,
+ maxTotalMB: 10,
+ currentRunDirectory: second.runDirectory,
+ });
+ assert.equal(retention.removedRuns, 1);
+ await assert.rejects(fs.access(first.runDirectory));
+ await fs.access(second.runDirectory);
+});
+
+test('HTML report escapes failures and renders the run status', () => {
+ const html = renderHtmlReport({
+ runId: 'test',
+ pass: false,
+ profile: { name: 'smoke' },
+ startedAt: 'start',
+ completedAt: 'end',
+ failures: [''],
+ memorySnapshots: [],
+ });
+ assert.match(html, /FAIL/);
+ assert.match(html, /<unsafe>/);
+});
+
+test('compact summary references raw artifacts without duplicating batch and zone payloads', () => {
+ const compact = compactRunSummary({
+ zoneValidation: { pass: true, summary: { reportCount: 2 }, raw: { reports: [{ large: true }] }, url: 'http://test' },
+ raceAudit: { summary: { auditedModelCount: 20 }, batches: [{ large: true }] },
+ diagnostics: { consoleErrors: [{}], pageErrors: [], requestFailures: [], httpErrors: [] },
+ });
+ assert.equal(compact.zoneValidation.raw, undefined);
+ assert.equal(compact.zoneValidation.artifact, 'zone-validation.json');
+ assert.equal(compact.raceAudit.batches, undefined);
+ assert.equal(compact.raceAudit.artifact, 'race-audit-batches.json');
+ assert.equal(compact.diagnostics.consoleErrorCount, 1);
+});
+
+test('diagnostic summary separates recovered transfer retries from unresolved failures', () => {
+ const summary = summarizeTelemetry({
+ consoleErrors: [{ recovered: true }, {}],
+ pageErrors: [{}],
+ requestFailures: [{ recovered: true }, {}],
+ httpErrors: [{ recovered: true }, {}],
+ });
+ assert.deepEqual(summary, {
+ consoleErrorCount: 1,
+ recoveredConsoleErrorCount: 1,
+ pageErrorCount: 1,
+ requestFailureCount: 1,
+ recoveredRequestFailureCount: 1,
+ httpErrorCount: 1,
+ recoveredHttpErrorCount: 1,
+ });
+});
+
+test('browser launch enables precise heap measurements without changing headed behavior', () => {
+ const options = getBrowserLaunchOptions({ headed: true });
+ assert.equal(options.headless, false);
+ assert.ok(options.args.includes('--enable-precise-memory-info'));
+ assert.ok(options.args.includes('--enable-unsafe-swiftshader'));
+});
diff --git a/tools/sage-qa/verify-checkpoints.mjs b/tools/sage-qa/verify-checkpoints.mjs
new file mode 100644
index 00000000..8def155d
--- /dev/null
+++ b/tools/sage-qa/verify-checkpoints.mjs
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { parseArgs } from './lib/args.mjs';
+
+const args = parseArgs(process.argv.slice(2));
+if (!args.planRun || !args.runs) {
+ throw new Error('Usage: npm run qa:sage:verify-checkpoints -- --plan-run --runs ');
+}
+
+const planRun = path.resolve(args.planRun);
+const runDirectories = `${args.runs}`.split(',').map((entry) => path.resolve(entry.trim())).filter(Boolean);
+const plan = JSON.parse(await fs.readFile(path.join(planRun, 'plan.json'), 'utf8'));
+const expectedModels = new Set(plan.selectedModels ?? []);
+const validatedModels = new Set();
+const duplicateModels = new Set();
+const batchFailures = [];
+let batchCount = 0;
+
+for (const runDirectory of runDirectories) {
+ const checkpointDirectory = path.join(runDirectory, 'race-audit-checkpoints');
+ const entries = await fs.readdir(checkpointDirectory, { withFileTypes: true }).catch(() => []);
+ for (const entry of entries.filter((candidate) => candidate.isFile() && candidate.name.endsWith('.json'))) {
+ const checkpoint = JSON.parse(await fs.readFile(path.join(checkpointDirectory, entry.name), 'utf8'));
+ batchCount += 1;
+ if (!checkpoint.pass || Number(checkpoint.audit?.failureCount ?? 0) > 0) {
+ batchFailures.push({ runDirectory, file: entry.name, failureCount: checkpoint.audit?.failureCount ?? null });
+ }
+ for (const model of checkpoint.models ?? []) {
+ if (validatedModels.has(model)) duplicateModels.add(model);
+ validatedModels.add(model);
+ }
+ }
+}
+
+const missingModels = [...expectedModels].filter((model) => !validatedModels.has(model));
+const unexpectedModels = [...validatedModels].filter((model) => !expectedModels.has(model));
+const result = {
+ schemaVersion: 1,
+ planRun,
+ runDirectories,
+ expectedModelCount: expectedModels.size,
+ validatedModelCount: validatedModels.size,
+ batchCount,
+ duplicateModelCount: duplicateModels.size,
+ missingModels,
+ unexpectedModels,
+ batchFailures,
+ pass: missingModels.length === 0 && unexpectedModels.length === 0 && batchFailures.length === 0,
+};
+const outputPath = path.join(runDirectories.at(-1), 'checkpoint-composite.json');
+await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
+console.log(JSON.stringify({ ...result, outputPath }, null, 2));
+if (!result.pass) process.exitCode = 1;