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
- 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
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
- 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
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
- Spine ZIP must contain at least one skeleton+atlas pair
- Spine ZIP must contain a .atlas (or .atlas.txt) file
- Archive entry not found
- Cannot find file
- Empty settings file
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)