'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`);