{"record":{"id":"22a5a95a3692b191","repo":"moeru-ai/airi","slug":"spine-zip-must-contain-at-least-one-skeleton-atlas","errorCode":null,"errorMessage":"Spine ZIP must contain at least one skeleton+atlas pair","messagePattern":"Spine ZIP must contain at least one skeleton\\+atlas pair","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui-spine/src/utils/spine-zip-loader.ts","lineNumber":249,"sourceCode":"  const entries: Record<string, string> = {}\n  const atlasTexts: Record<string, string> = {}\n  const blobUrls: Record<string, string> = {}\n\n  // Pass 1: inventory file paths and read atlas text bodies.\n  await Promise.all(Object.keys(archive.files).map(async (name) => {\n    const entry = archive.files[name]\n    if (entry.dir)\n      return\n\n    entries[name] = name\n\n    if (isAtlasPath(name))\n      atlasTexts[name] = await entry.async('string')\n  }))\n\n  const variants = detectAllSpineLayouts(entries, atlasTexts)\n  if (variants.length === 0)\n    throw new Error('Spine ZIP must contain at least one skeleton+atlas pair')\n  const layout = variants[0].layout\n\n  // Pass 2: materialize assets for ALL variants.\n  // NOTICE:\n  // Spine's Downloader has a heuristic for rawDataUris: if the value doesn't\n  // contain \".\", it treats it as a data: URI and calls atob(). In Electron,\n  // blob URLs are `blob:null/<uuid>` (no dots), so the Downloader fails.\n  // Even with data: URIs, Spine's atob-based decode can corrupt binary data.\n  // We store raw decoded data (Uint8Array / string) alongside blob URLs and\n  // monkey-patch the Downloader's download methods to serve from memory.\n  // Removal condition: Spine ships a Blob-aware or buffer-aware loader.\n  const rawData: Record<string, Uint8Array | string> = {}\n\n  // Collect all unique paths across all variants.\n  const allTexturePaths = new Set<string>()\n  const allSkeletonPaths = new Set<string>()\n  const allAtlasPaths = new Set<string>()\n  for (const v of variants) {","sourceCodeStart":231,"sourceCodeEnd":267,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui-spine/src/utils/spine-zip-loader.ts#L231-L267","documentation":"Thrown in the Spine ZIP loading pass when detectAllSpineLayouts returns an empty variant list even though atlas files exist (error 97 covers the no-atlas case). It means atlases were read, but the first-pass pairing found no same-basename skeleton (.skel/.json) beside any atlas, so there is no primary layout to materialize assets for.","triggerScenarios":"Same-family causes as the pairing failure: atlas present but skeleton missing entirely, basenames mismatched (hero.atlas + hero2.skel), skeletons in a different directory than the atlas, or only .json skeletons whose basenames differ from atlases after double extension stripping.","commonSituations":"Partial Spine exports (atlas+png shipped, skeleton forgotten); renaming skeleton files after export; multi-character archives organized by asset type (all atlases in one folder, all skeletons in another); version-specific export naming (e.g. 'hero.skel' vs 'hero_binary.skel').","solutions":["Verify each atlas has a sibling skeleton with the identical basename: hero.atlas + hero.skel (or hero.json) in the same folder","Re-export from Spine and zip the export directory unchanged","When bundling multiple characters, mirror the per-character folder structure instead of grouping by file type","Call detectAllSpineLayouts first in your own code and render a per-atlas diagnostic (which atlas failed to pair) before attempting the full load"],"exampleFix":"// before\nconst result = await loadSpineModelFromZip(file) // throws 'at least one skeleton+atlas pair'\n\n// after\nconst variants = detectAllSpineLayouts(entries, atlasTexts)\nif (variants.length === 0) {\n  const missing = Object.keys(atlasTexts).map(a => `${a} (needs ${a.replace(/\\.atlas(\\.txt)?$/, '')}.skel or .json)`)\n  throw new Error(`Unpaired atlases: ${missing.join('; ')}`)\n}\nconst result = await loadSpineModelFromZip(file)","handlingStrategy":"validation","validationCode":"const variants = detectAllSpineLayouts(entries, atlasTexts)\nif (variants.length === 0) {\n  const unpaired = Object.keys(atlasTexts).map(a => a.replace(/\\.atlas(\\.txt)?$/, ''))\n  throw new Error(`Atlases without matching skeleton: ${unpaired.join(', ')}`)\n}","typeGuard":null,"tryCatchPattern":"catch (e) { if (e.message.includes('skeleton+atlas pair')) { showUserError('Every .atlas needs a same-named .skel or .json beside it'); return } throw e }","preventionTips":["Mirror per-character folders instead of grouping assets by type","Re-export from Spine and zip unchanged","Diagnose with detectAllSpineLayouts before the full load to name the failing atlas"],"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"}