moeru-ai/airi · error · Error

Spine ZIP must contain at least one skeleton+atlas pair

Error message

Spine ZIP must contain at least one skeleton+atlas pair

What it means

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.

Solutions

  1. Verify each atlas has a sibling skeleton with the identical basename: hero.atlas + hero.skel (or hero.json) in the same folder
  2. Re-export from Spine and zip the export directory unchanged
  3. When bundling multiple characters, mirror the per-character folder structure instead of grouping by file type
  4. Call detectAllSpineLayouts first in your own code and render a per-atlas diagnostic (which atlas failed to pair) before attempting the full load

Example fix

// before
const result = await loadSpineModelFromZip(file) // throws 'at least one skeleton+atlas pair'

// after
const variants = detectAllSpineLayouts(entries, atlasTexts)
if (variants.length === 0) {
  const missing = Object.keys(atlasTexts).map(a => `${a} (needs ${a.replace(/\.atlas(\.txt)?$/, '')}.skel or .json)`)
  throw new Error(`Unpaired atlases: ${missing.join('; ')}`)
}
const result = await loadSpineModelFromZip(file)
Defensive patterns

Strategy: validation

Validate before calling

const variants = detectAllSpineLayouts(entries, atlasTexts)
if (variants.length === 0) {
  const unpaired = Object.keys(atlasTexts).map(a => a.replace(/\.atlas(\.txt)?$/, ''))
  throw new Error(`Atlases without matching skeleton: ${unpaired.join(', ')}`)
}

Try / catch

catch (e) { if (e.message.includes('skeleton+atlas pair')) { showUserError('Every .atlas needs a same-named .skel or .json beside it'); return } throw e }

Prevention

When it happens

Trigger: 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.

Common situations: 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').

Related errors


AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18). Data as JSON: /api/errors/22a5a95a3692b191. Report an issue: GitHub.

Appendix: source

Thrown at packages/stage-ui-spine/src/utils/spine-zip-loader.ts:249

  const entries: Record<string, string> = {}
  const atlasTexts: Record<string, string> = {}
  const blobUrls: Record<string, string> = {}

  // Pass 1: inventory file paths and read atlas text bodies.
  await Promise.all(Object.keys(archive.files).map(async (name) => {
    const entry = archive.files[name]
    if (entry.dir)
      return

    entries[name] = name

    if (isAtlasPath(name))
      atlasTexts[name] = await entry.async('string')
  }))

  const variants = detectAllSpineLayouts(entries, atlasTexts)
  if (variants.length === 0)
    throw new Error('Spine ZIP must contain at least one skeleton+atlas pair')
  const layout = variants[0].layout

  // Pass 2: materialize assets for ALL variants.
  // NOTICE:
  // Spine's Downloader has a heuristic for rawDataUris: if the value doesn't
  // contain ".", it treats it as a data: URI and calls atob(). In Electron,
  // blob URLs are `blob:null/<uuid>` (no dots), so the Downloader fails.
  // Even with data: URIs, Spine's atob-based decode can corrupt binary data.
  // We store raw decoded data (Uint8Array / string) alongside blob URLs and
  // monkey-patch the Downloader's download methods to serve from memory.
  // Removal condition: Spine ships a Blob-aware or buffer-aware loader.
  const rawData: Record<string, Uint8Array | string> = {}

  // Collect all unique paths across all variants.
  const allTexturePaths = new Set<string>()
  const allSkeletonPaths = new Set<string>()
  const allAtlasPaths = new Set<string>()
  for (const v of variants) {

View on GitHub (pinned to 677329427f)