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
- 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
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
- 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
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
- Spine ZIP must contain a .skel or .json skeleton file…
- 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/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)