moeru-ai/airi · error · Error

Spine ZIP must contain a .skel or .json skeleton file…

Error message

Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas

What it means

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.

Solutions

  1. Keep each character's files together and identically named: hero.skel (or hero.json) + hero.atlas + hero.png in the same directory
  2. Re-export from Spine with default names and zip the export folder as-is
  3. Check for nested zips or a doubled top folder that separates atlas from skeleton
  4. Pre-validate with detectAllSpineLayouts on the entry map and report which atlases failed to pair before calling detectSpineLayout

Example fix

// before
const layout = detectSpineLayout(entries, atlasText) // throws

// after
const variants = detectAllSpineLayouts(entries, atlasText)
if (variants.length === 0) {
  const atlases = Object.keys(entries).filter(isAtlasPath)
  throw new Error(`No skeleton matches these atlases: ${atlases.join(', ')}. Ship hero.skel|hero.json next to hero.atlas with identical basenames.`)
}
const layout = variants[0].layout
Defensive patterns

Strategy: validation

Validate before calling

import { detectAllSpineLayouts, isAtlasPath } from '../spine-zip-loader'
const variants = detectAllSpineLayouts(entries, atlasText)
if (variants.length === 0) {
  const atlases = Object.keys(entries).filter(isAtlasPath)
  throw new Error(`No skeleton matches atlases: ${atlases.join(', ')}`)
}

Type guard

const hasPairedSpineLayout = (entries: Record<string, string>, atlasText: Record<string, string>): boolean => detectAllSpineLayouts(entries, atlasText).length > 0

Try / catch

catch (e) { if (e.message.includes('paired with a .atlas')) { guideRenameSkeletonNextToAtlas(); return } throw e }

Prevention

When it happens

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

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

Related errors


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

Appendix: source

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

function dirname(path: string) {
  const slash = path.lastIndexOf('/')
  return slash === -1 ? '' : path.slice(0, slash + 1)
}

/**
 * Inspect a Spine ZIP and resolve the skeleton, atlas, and texture page
 * paths.
 *
 * Heuristics:
 * 1. Find all `.atlas`/`.atlas.txt` files paired with same-basename skeletons.
 * 2. For each pair, walk the atlas to extract texture page filenames.
 * 3. Return the first matched pair as the primary layout.
 */
export function detectSpineLayout(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelLayout {
  const variants = detectAllSpineLayouts(entries, atlasText)
  if (variants.length === 0)
    throw new Error('Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas')
  return variants[0].layout
}

/**
 * Detects all skeleton+atlas pairs in a ZIP, returning them as named
 * variants. Useful for ZIPs containing multiple outfits/characters in
 * separate folders.
 */
export function detectAllSpineLayouts(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelVariant[] {
  const allFiles = Object.keys(entries)
  const atlasCandidates = allFiles.filter(isAtlasPath)
  if (atlasCandidates.length === 0)
    throw new Error('Spine ZIP must contain a .atlas (or .atlas.txt) file')

  const variants: SpineModelVariant[] = []
  const usedAtlases = new Set<string>()

  // First pass: match each atlas with a same-basename skeleton.

View on GitHub (pinned to 677329427f)