{"record":{"id":"259a51fa57c5a949","repo":"moeru-ai/airi","slug":"spine-zip-must-contain-a-skel-or-json-skeleton-f","errorCode":null,"errorMessage":"Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas","messagePattern":"Spine ZIP must contain a \\.skel or \\.json skeleton file paired with a \\.atlas","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui-spine/src/utils/spine-zip-loader.ts","lineNumber":106,"sourceCode":"\nfunction dirname(path: string) {\n  const slash = path.lastIndexOf('/')\n  return slash === -1 ? '' : path.slice(0, slash + 1)\n}\n\n/**\n * Inspect a Spine ZIP and resolve the skeleton, atlas, and texture page\n * paths.\n *\n * Heuristics:\n * 1. Find all `.atlas`/`.atlas.txt` files paired with same-basename skeletons.\n * 2. For each pair, walk the atlas to extract texture page filenames.\n * 3. Return the first matched pair as the primary layout.\n */\nexport function detectSpineLayout(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelLayout {\n  const variants = detectAllSpineLayouts(entries, atlasText)\n  if (variants.length === 0)\n    throw new Error('Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas')\n  return variants[0].layout\n}\n\n/**\n * Detects all skeleton+atlas pairs in a ZIP, returning them as named\n * variants. Useful for ZIPs containing multiple outfits/characters in\n * separate folders.\n */\nexport function detectAllSpineLayouts(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelVariant[] {\n  const allFiles = Object.keys(entries)\n  const atlasCandidates = allFiles.filter(isAtlasPath)\n  if (atlasCandidates.length === 0)\n    throw new Error('Spine ZIP must contain a .atlas (or .atlas.txt) file')\n\n  const variants: SpineModelVariant[] = []\n  const usedAtlases = new Set<string>()\n\n  // First pass: match each atlas with a same-basename skeleton.","sourceCodeStart":88,"sourceCodeEnd":124,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui-spine/src/utils/spine-zip-loader.ts#L88-L124","documentation":"Thrown by detectSpineLayout when detectAllSpineLayouts returns no variants — meaning .atlas/.atlas.txt files exist and were examined, but none could be paired with a same-basename skeleton (.skel or .json) in the same directory. Spine runtimes need a skeleton+atlas pair; an atlas without its skeleton (or with mismatched names/directories) is unusable.","triggerScenarios":"ZIP contains 'hero.atlas' but the skeleton is named 'hero_v2.skel' or lives in a subfolder ('skeletons/hero.skel' vs 'hero.atlas' at root); skeleton shipped only as .json while pairing expects the same basename but with different double-stripped extension; atlas files whose basename, after stripping two extensions, no longer matches the skeleton name.","commonSituations":"Renaming files after export so skeleton and atlas basenames diverge; Spine exports where .skel and .atlas were separated into different folders by a packager; JSON-exported skeletons paired with binary-style naming expectations; multiple-character bundles that split assets by type rather than by character.","solutions":["Keep each character's files together and identically named: hero.skel (or hero.json) + hero.atlas + hero.png in the same directory","Re-export from Spine with default names and zip the export folder as-is","Check for nested zips or a doubled top folder that separates atlas from skeleton","Pre-validate with detectAllSpineLayouts on the entry map and report which atlases failed to pair before calling detectSpineLayout"],"exampleFix":"// before\nconst layout = detectSpineLayout(entries, atlasText) // throws\n\n// after\nconst variants = detectAllSpineLayouts(entries, atlasText)\nif (variants.length === 0) {\n  const atlases = Object.keys(entries).filter(isAtlasPath)\n  throw new Error(`No skeleton matches these atlases: ${atlases.join(', ')}. Ship hero.skel|hero.json next to hero.atlas with identical basenames.`)\n}\nconst layout = variants[0].layout","handlingStrategy":"validation","validationCode":"import { detectAllSpineLayouts, isAtlasPath } from '../spine-zip-loader'\nconst variants = detectAllSpineLayouts(entries, atlasText)\nif (variants.length === 0) {\n  const atlases = Object.keys(entries).filter(isAtlasPath)\n  throw new Error(`No skeleton matches atlases: ${atlases.join(', ')}`)\n}","typeGuard":"const hasPairedSpineLayout = (entries: Record<string, string>, atlasText: Record<string, string>): boolean => detectAllSpineLayouts(entries, atlasText).length > 0","tryCatchPattern":"catch (e) { if (e.message.includes('paired with a .atlas')) { guideRenameSkeletonNextToAtlas(); return } throw e }","preventionTips":["Export from Spine with default names; keep skeleton and atlas basenames identical in one folder","Detect nested zips and wrong-folder packaging before loading","Use detectAllSpineLayouts directly to get per-variant diagnostics"],"tags":["spine","zip","skeleton-atlas-pairing","asset-validation"],"backgroundTag":"spine-model-load-failed","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","contentChangedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}