增加对3.7 3.8的二进制读取

This commit is contained in:
tianmo committed 2026-09-09 21:56:13 +08:00
1 parent 1b67933cac
commit 4c5033d2f9
10 files changed
+526 -52

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { decode, install } = require('../js/spine-legacy-binary');
const utils = require('../js/spine-utils');
const clipping = require('../js/spine-canvas-clipping');
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
const names = ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor'];
let poses = 0;
let animations = 0;
function near(actual, expected, tolerance, label) {
assert(Number.isFinite(actual) && Number.isFinite(expected) && Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} != ${expected} (tolerance ${tolerance})`);
}
function arrayNear(actual, expected, tolerance, label) {
assert.equal(actual.length, expected.length, label + ' length');
for (let i = 0; i < actual.length; i++) near(actual[i], expected[i], tolerance, label + '[' + i + ']');
}
function runtime(version) {
const context = { console };
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
return install(context.spine, version);
}
for (const version of ['3.6', '3.7']) {
const spine = runtime(version);
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
for (const name of names) {
const directory = path.join(fixtures, version, name);
const bytes = fs.readFileSync(path.join(directory, name + '-pro.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, name + '-pro.json'), 'utf8'));
const decoded = decode(bytes, version);
assert.equal(decoded.skeleton.hash, json.skeleton.hash);
assert.equal(decoded.skeleton.spine, json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, name + '-pro.skel')), json.skeleton.spine);
// Region geometry uses a real official atlas; no native Canvas dependency
// is needed to verify animation poses and renderer triangle output.
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, name + '.atlas'), 'utf8'), () => texture);
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
// In the pinned 3.6 raptor exports, the same-hash JSON puts the stepped
// IK curve on frame 0, while the SKEL stores it on frame 1. Assert the
// exact discrepancy, then align only this JSON comparison oracle.
if (version === '3.6' && name === 'raptor') {
const binIK = decoded.animations['gun-holster'].ik['front-arm-goal'];
const jsonIK = json.animations['gun-holster'].ik['front-arm-goal'];
assert.equal(binIK[0].curve, undefined); assert.equal(binIK[1].curve, 'stepped');
assert.equal(jsonIK[0].curve, 'stepped'); assert.equal(jsonIK[1].curve, undefined);
delete jsonIK[0].curve; jsonIK[1].curve = 'stepped';
}
for (const scale of [1, 0.5]) {
const binaryLoader = new spine.SkeletonBinary(attachmentLoader); binaryLoader.scale = scale;
const jsonLoader = new spine.SkeletonJson(attachmentLoader); jsonLoader.scale = scale;
const data = binaryLoader.readSkeletonData(bytes), expected = jsonLoader.readSkeletonData(json);
assert.deepEqual(Array.from(data.bones, b => b.name), Array.from(expected.bones, b => b.name));
assert.deepEqual(Array.from(data.slots, s => s.name), Array.from(expected.slots, s => s.name));
assert.deepEqual(Array.from(data.skins, s => s.name), Array.from(expected.skins, s => s.name));
assert.deepEqual(Array.from(data.animations, a => a.name), Array.from(expected.animations, a => a.name));
for (let i = 0; i < data.bones.length; i++) {
for (const k of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'length'])
near(data.bones[i][k], expected.bones[i][k], 0.0052, name + '/bone/' + k);
}
for (const skin of data.skins) {
const reference = expected.findSkin(skin.name);
for (const slot of Object.keys(skin.attachments)) {
for (const [key, a] of Object.entries(skin.attachments[slot])) {
const b = reference.getAttachment(Number(slot), key);
assert(b, key); assert.equal(a.constructor.name, b.constructor.name);
for (const field of ['vertices', 'regionUVs', 'triangles', 'bones', 'lengths']) {
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : 0.0052, name + '/' + key + '/' + field);
}
}
}
}
for (const animation of data.animations) {
const refAnimation = expected.findAnimation(animation.name);
near(animation.duration, refAnimation.duration, 0.00006, 'duration');
assert.equal(animation.timelines.length, refAnimation.timelines.length);
for (let i = 0; i < animation.timelines.length; i++) {
const a = animation.timelines[i], b = refAnimation.timelines[i];
assert.equal(a.constructor.name, b.constructor.name);
arrayNear(a.frames, b.frames, 0.0052, name + '/' + animation.name + '/frames');
if (a.curves) arrayNear(a.curves, b.curves, 0.0052, name + '/' + animation.name + '/curves');
if (a.attachmentNames) assert.deepEqual(Array.from(a.attachmentNames), Array.from(b.attachmentNames));
if (a.frameVertices) for (let f = 0; f < a.frameVertices.length; f++) arrayNear(a.frameVertices[f], b.frameVertices[f], 0.0104, 'deform');
}
animations++;
// Avoid exactly half-duration: rounding JSON times can put samples
// on opposite sides of an attachment keyframe (eg spineboy jump).
for (const fraction of [0, 0.13, 0.473, 0.79, 1]) for (const skin of data.skins) {
const pair = [data, expected].map(d => {
const sk = new spine.Skeleton(d); sk.setSkinByName(skin.name); sk.setToSetupPose();
const state = new spine.AnimationState(new spine.AnimationStateData(d));
state.setAnimation(0, animation.name, false); state.update(d.findAnimation(animation.name).duration * fraction);
state.apply(sk); sk.updateWorldTransform(); return sk;
});
assert.deepEqual(Array.from(pair[0].drawOrder, s => s.data.name), Array.from(pair[1].drawOrder, s => s.data.name));
assert.deepEqual(Array.from(pair[0].slots, s => s.getAttachment()?.name), Array.from(pair[1].slots, s => s.getAttachment()?.name));
for (let i = 0; i < pair[0].bones.length; i++) for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY']) {
// Quantized JSON weights/curves compound along bone chains.
near(pair[0].bones[i][field], pair[1].bones[i][field], 2.1, name + '/' + animation.name + '/' + field);
}
const renderer = clipping.createRenderer(spine, { globalAlpha: 1 }, version);
let triangles = 0;
renderer.drawTriangle = (_image, ...coordinates) => { assert(coordinates.every(Number.isFinite)); triangles++; };
renderer.draw(pair[0]); assert(triangles > 0, name + ' visible geometry'); poses++;
}
}
}
// A damaged file must terminate with an error, never be silently accepted.
for (const end of [0, 1, 12, Math.floor(bytes.length / 2), bytes.length - 1]) assert.throws(() => decode(bytes.subarray(0, end), version));
assert.throws(() => decode(Buffer.concat([bytes, Buffer.from([0])]), version), /尾部/);
assert.throws(() => decode(bytes, version === '3.6' ? '3.7' : '3.6'), /不一致/);
const wrapped = Buffer.concat([Buffer.from([9, 8, 7]), bytes, Buffer.from([6])]);
assert.equal(decode(wrapped.subarray(3, -1), version).skeleton.hash, json.skeleton.hash);
console.log('PASS ' + version + ' / ' + name);
}
}
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) assert.equal(utils.chooseRuntimeDir(version + '.1', true), version);
// Small hand-authored wire fixtures cover fields absent from the official
// example set: point attachments, audio events, UTF-16 surrogate names, signed
// event integers and the nonessential=false header. Values are intentionally
// distinct so a field-order mistake cannot accidentally pass.
function edgeFixture(version, nonessential) {
const chunks = [];
const byte = n => chunks.push(Buffer.from([n]));
const int = n => { const b = Buffer.alloc(4); b.writeInt32BE(n); chunks.push(b); };
const float = n => { const b = Buffer.alloc(4); b.writeFloatBE(n); chunks.push(b); };
const variable = n => { n >>>= 0; while (n >= 128) { byte((n & 127) | 128); n >>>= 7; } byte(n); };
const string = s => {
if (s === null) return variable(0);
const out = [];
for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); if (c < 128) out.push(c); else if (c < 2048) out.push(192 | c >> 6, 128 | c & 63); else out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63); }
variable(out.length + 1); chunks.push(Buffer.from(out));
};
const v37 = version === '3.7';
string('edge'); string(version + '.90'); float(100); float(200); byte(nonessential ? 1 : 0);
if (nonessential) { float(24); string('图像/'); if (v37) string('声音/'); }
variable(1); string('根😀'); // root bone
[0, 3, 4, 1, 1, 0, 0, 10].forEach(float); variable(0); if (nonessential) int(-1);
variable(1); string('slot'); variable(0); int(-1); int(-1); string('point'); variable(0);
variable(1); string('ik'); variable(0); variable(1); variable(0); variable(0); float(0.25); byte(255);
if (v37) { byte(1); byte(0); byte(1); }
variable(0); variable(0); // transform / path constraints
variable(1); variable(0); variable(1); string('point'); string(null); byte(5);
float(35); float(7); float(9); if (nonessential) int(-1);
variable(0); // additional skins
variable(1); string('事件🔔'); variable(245); float(1.25); string('默认');
if (v37) { string('声音.wav'); float(0.75); float(-0.5); }
variable(1); string('anim'); variable(0); variable(0); // slot / bone timelines
variable(1); variable(0); variable(1); float(0.25); float(0.6); byte(255); if (v37) { byte(0); byte(1); }
variable(0); variable(0); variable(0); variable(0); // transform / path / deform / draw order
variable(1); float(0.5); variable(0); variable(641); float(2.5); byte(0);
if (v37) { float(0.4); float(0.2); }
return Buffer.concat(chunks);
}
for (const version of ['3.6', '3.7']) for (const nonessential of [false, true]) {
const bytes = edgeFixture(version, nonessential), d = decode(bytes, version);
assert.equal(d.bones[0].name, '根😀'); assert.equal(d.events['事件🔔'].int, -123);
assert.equal(d.animations.anim.events[0].int, -321); assert.equal(d.animations.anim.events[0].string, '默认');
assert.equal(d.skins.default.slot.point.rotation, 35);
assert.equal(d.skeleton.fps, nonessential ? 24 : undefined);
if (version === '3.7') {
assert.equal(d.ik[0].compress, true); assert.equal(d.ik[0].uniform, true);
assert.equal(d.animations.anim.ik.ik[0].stretch, true);
assert.equal(d.events['事件🔔'].audio, '声音.wav'); near(d.animations.anim.events[0].volume, 0.4, 1e-6, 'audio volume');
}
const spine = runtime(version);
const loader = new spine.SkeletonBinary({ newPointAttachment: (_skin, name) => new spine.PointAttachment(name) });
const data = loader.readSkeletonData(bytes);
assert.equal(data.bones[0].name, '根😀'); assert.equal(data.events[0].intValue, -123);
assert.equal(data.animations[0].duration, 0.5);
}
console.log(`PASS: 14 official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);