// Optional, offline compatibility check using a caller-provided official Spine 4.2 core IIFE. // node tools/verify-spine-runtime.mjs --runtime-file /path/spine-core.js --input-dir output/spine import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import {readImage} from '../lib/images.mjs'; const options = {}; for (let i = 2; i < process.argv.length; i += 2) { const flag = process.argv[i]; if (!['--runtime-file', '--input-dir'].includes(flag) || !process.argv[i + 1]) { throw new Error('Usage: node tools/verify-spine-runtime.mjs --runtime-file spine-core.js --input-dir output/spine'); } options[flag] = process.argv[i + 1]; } if (!options['--runtime-file'] || !options['--input-dir']) throw new Error('Both --runtime-file and --input-dir are required'); const context = vm.createContext({console}); vm.runInContext(readFileSync(options['--runtime-file'], 'utf8'), context, {timeout: 10000}); const spine = context.spine; const input = path.resolve(options['--input-dir']); const assembly = JSON.parse(readFileSync(path.join(input, 'assembly.json'))); const json = JSON.parse(readFileSync(path.join(input, 'skeleton.json'))); assert.match(json.skeleton.spine, /^4\.2\./u); const atlas = new spine.TextureAtlas(readFileSync(path.join(input, 'atlas/skeleton.atlas'), 'utf8')); for (const page of atlas.pages) { const png = readImage(path.join(input, 'atlas', page.name), {pngOnly: true}); assert.equal(page.width, png.width); assert.equal(page.height, png.height); // CPU geometry needs region metadata, not a WebGL texture upload. page.setTexture({setFilters() {}, setWraps() {}, getImage() { return {width: png.width, height: png.height}; }}); } const data = new spine.SkeletonJson(new spine.AtlasAttachmentLoader(atlas)).readSkeletonData(json); const skeleton = new spine.Skeleton(data); function vertices(slot) { const values = new Float32Array(8); slot.getAttachment().computeWorldVertices(slot, values, 0, 2); assert.ok([...values].every(Number.isFinite)); return values; } function close(actual, expected, label) { assert.ok(Math.abs(actual - expected) < .002, `${label}: ${actual} != ${expected}`); } skeleton.setToSetupPose(); skeleton.updateWorldTransform(spine.Physics.none); const originals = new Map(); const assets = new Map(assembly.parts.map((p) => [p.id, p])); for (const slot of skeleton.slots) { const values = vertices(slot); originals.set(slot.data.name, values); const [x0, y0, x1, y1] = assets.get(slot.data.name).crop_xyxy; const xs = [values[0], values[2], values[4], values[6]]; const ys = [values[1], values[3], values[5], values[7]]; close(Math.min(...xs), x0 - assembly.width / 2, 'setup x0'); close(Math.max(...xs), x1 - assembly.width / 2, 'setup x1'); close(Math.min(...ys), assembly.height - y1, 'setup y0'); close(Math.max(...ys), assembly.height - y0, 'setup y1'); const region = atlas.findRegion(slot.data.name); assert.equal(region.originalWidth, x1 - x0); assert.equal(region.originalHeight, y1 - y0); } let samples = 0; for (const animation of data.animations) { if (animation.name === 'setup') continue; const boneName = animation.name.replace(/^check__/u, ''); const frames = json.animations[animation.name].bones[boneName].rotate; for (const time of [.5, 1.5]) { skeleton.setToSetupPose(); skeleton.updateWorldTransform(spine.Physics.none); const bone = skeleton.findBone(boneName); const px = bone.worldX, py = bone.worldY; const angle = frames.find((f) => f.time === time).value * Math.PI / 180; animation.apply(skeleton, 0, time, false, [], 1, spine.MixBlend.replace, spine.MixDirection.mixIn); skeleton.updateWorldTransform(spine.Physics.none); for (const slot of skeleton.slots) { let parent = slot.bone; while (parent && parent !== bone) parent = parent.parent; const before = originals.get(slot.data.name), after = vertices(slot); for (let i = 0; i < 8; i += 2) { const x = before[i] - px, y = before[i + 1] - py; close(after[i], parent ? px + x * Math.cos(angle) - y * Math.sin(angle) : before[i], 'rotated x'); close(after[i + 1], parent ? py + x * Math.sin(angle) + y * Math.cos(angle) : before[i + 1], 'rotated y'); } } samples++; } } console.log(JSON.stringify({input, spine_version: data.version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length, rotation_samples: samples, setup_geometry_verified: true, descendant_rotation_verified: true, browser_render_verified: false}, null, 2));