Skip to content

Repository files navigation

rigc - Rig compiler for Spine

npm version npm downloads license

Rig compiler for Spine. Declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work.

What rigc is, and what it is not

rigc emits Spine's own skeleton data format. That is the whole positioning, and it cuts both ways:

  • The output loads in any Spine runtime, and it imports into the Spine editor. A compiled rig is a starting point on a timeline, not a finished shot — an AI drafts, a human refines in the editor. rigc is complementary to the editor. It is not a replacement for it, and it is not a way around one.
  • rigc links @esotericsoftware/spine-core to validate what it emits — the round-trip through the official parser is the only reason its output can be trusted at all. So the Spine Runtimes License Agreement applies to rigc exactly as it applies to any other runtime integration.

Licensing, stated plainly

rigc's own code is MIT (see LICENSE). That says nothing about Spine, and the following is a restatement of Esoteric Software's terms, not a term of ours:

  1. rigc's output is Spine skeleton data.
  2. Playing Spine skeleton data in a product requires a Spine Runtime.
  3. The Spine Runtimes License requires each user of such a product to own a Spine editor licence.
  4. rigc links spine-core itself, so the same obligation covers running rigc.

Using rigc, or shipping rigc's output in a product, requires a Spine editor licence. rigc does not change that requirement in either direction — it neither adds one nor removes one. If you were going to need an editor licence to ship a Spine animation, you still do; rigc is not a route around it.

See NOTICE.md for the full notice.

The problem rigc is aimed at is narrow. An agent asked to author a rig has no way to tell whether it succeeded: Spine's JSON parser accepts a great deal of nonsense without a murmur — a constraint in the 4.2 shape simply vanishes, a size: that disagrees with the PNG collapses every UV, a four-number curve array yields NaN, a mesh whose vertex count happens to equal its UV count silently loses its bone weights. Every one of those loads clean, plays, and is wrong. rigc's answer is to make the failure legible: compile from a spec, round-trip through the real parser, run a list of named assertions, and write nothing unless all of them are green.

Install

📦 rigc measures loose PNGs directly — one atlas page per image. It is not an atlas packer: packing several regions onto one page is tracked as issue #4, not something the tool does today.

rigc runs on Bun. The package ships its TypeScript sources and Bun runs them, so there is no build step and no dist/ that can drift from the repository it was cut from.

The npm package is spine-rigc; the command it installs is rigc. npm refuses the name rigc as too similar to packages that already exist, so the project, this repository and the executable keep their name and only the registry entry is spelled out.

bunx spine-rigc --help    # run it without installing
bun add -g spine-rigc     # or install the command
bun add -d spine-rigc     # or pin it in a project

npx spine-rigc works too, as long as Bun is on PATH — the executable is a Bun script, and npm only writes the shim that calls it.

Installed, the command is rigc. The examples below spell it bun cli.ts because they are written from a clone of this repository (bun install, then run the CLI in place); the two are interchangeable — rigc build … is bun cli.ts build ….

Two commands are repository workflows rather than package ones: bench and check measure against Spine's official example projects and the reference frames rendered from them, which are fetched rather than redistributed (see NOTICE.md). They need a clone and bun run fetch-examples, and say so by name when the corpus is absent.

First rig in ten minutes

A whole rig, end to end, in a scratch directory: three tiny plates, two JSON files, one build, one validate. No clone, no art pipeline, nothing fetched.

🚫 Every value below is invented for this section — a doll that exists nowhere else in this repository. That is AUTHORING.md §3's rule applied here: no example value in these documents is copied out of a reference export, so nothing you read in a quickstart is an answer to anything the ladder measures.

1. Install the command.

bun add -g spine-rigc     # installs `rigc`

Or skip the install and prefix every command below with bunx , e.g. bunx spine-rigc build ….

2. Make a directory and three plates. rigc measures PNGs rather than trusting a number you typed (R5), so the art has to exist. These three are solid colours a few dozen pixels across — a hull, a mast and a lamp:

mkdir -p buoy/images && cd buoy
bun -e '
const parts = {
  "images/hull.png": "iVBORw0KGgoAAAANSUhEUgAAADgAAAAMCAYAAAA3bX6lAAAAKElEQVR42mOI8bL6P5wxw6gHRz046sFRD456cNSDox4c9eCoBwcrBgDSZ+mdl2OiDgAAAABJRU5ErkJggg==",
  "images/mast.png": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAA0CAYAAAC3t3ldAAAAH0lEQVR42mO4dunIf3yYYVTBqIJRBaMKRhWMKhgcCgBGJo4s9YnopgAAAABJRU5ErkJggg==",
  "images/lamp.png": "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAHElEQVR42mP4v8HhPzUww6hBowaNGjRq0HAzCADvdrVmFPbc+QAAAABJRU5ErkJggg=="
};
for (const [p, b] of Object.entries(parts)) await Bun.write(p, Buffer.from(b, "base64"));
'

3. The rig spec — buoy.rig.json. Structure only: bones, the slots array in draw order, and one skin mapping each slot to a plate.

{
  "spec": "rigc-rig/1",
  "name": "buoy",
  "images": "images",
  "skeleton": { "width": 200, "height": 200 },
  "bones": [
    { "name": "root" },
    { "name": "hull", "parent": "root", "x": 0, "y": 0 },
    { "name": "mast", "parent": "hull", "x": 0, "y": 4 },
    { "name": "lamp", "parent": "mast", "x": 0, "y": 52 }
  ],
  "slots": [
    { "name": "mast", "bone": "mast", "attachment": "mast" },
    { "name": "hull", "bone": "hull", "attachment": "hull" },
    { "name": "lamp", "bone": "lamp", "attachment": "lamp" }
  ],
  "skins": {
    "default": {
      "mast": { "mast": { "image": "mast.png", "y": 26 } },
      "hull": { "hull": { "image": "hull.png" } },
      "lamp": { "lamp": { "image": "lamp.png" } }
    }
  }
}

Three things in there are worth naming, because each is a rule rather than a style: the slots array is the setup draw order (R4) — index 0 is furthest back, so the mast is behind the hull; the attachment carries an image instead of a width/height (R5), which is what makes the size in the skeleton and the size in the atlas incapable of drifting apart; and the mast's "y": 26 offsets the plate within its slot so the bone sits at the mast's foot rather than its middle.

4. The motion spec — buoy.motion.json. Time only, aimed at the rig by name:

{
  "spec": "rigc-motion/1",
  "archetype": "buoy",
  "cut": "buoy",
  "easings": { "swing": [0.42, 0, 0.58, 1] },
  "animations": {
    "bob": {
      "duration": 2,
      "loop": true,
      "tracks": [
        {
          "bone": "hull",
          "property": "translatey",
          "keys": [
            { "t": 0,   "v": [0],  "ease": "swing" },
            { "t": 0.5, "v": [5],  "ease": "swing" },
            { "t": 1.5, "v": [-5], "ease": "swing" },
            { "t": 2,   "v": [0] }
          ]
        },
        {
          "bone": "mast",
          "property": "rotate",
          "keys": [
            { "t": 0, "v": [-6], "ease": "swing" },
            { "t": 1, "v": [6],  "ease": "swing" },
            { "t": 2, "v": [-6] }
          ]
        }
      ]
    }
  }
}

archetype must equal the rig's name. duration is declared and then checked against what actually compiled (R7). The last key of each track carries no easing — there is nothing after it to ease towards, and saying otherwise is a compile error.

5. Build, then re-gate what it wrote.

rigc build --rig buoy.rig.json --motion buoy.motion.json --images images --out spine
rigc validate spine

build prints every assertion by name, then the shape of what it emitted, then the two files:

  ..    pages=3 regions=3 bones=4 slots=3 animations=1 version=4.3.13 regionAttachments=3 meshAttachments=0 physicsConstraints=0 rig=buoy profile=spine
rigc: wrote …/buoy/spine/skeleton.json
rigc: wrote …/buoy/spine/skeleton.atlas

profile=spine is the rulebook that judged it: is this valid Spine 4.3 that any runtime plays correctly? That is the default, and the Profiles section below is where the other one lives. validate then re-reads those artifacts from disk and ends rigc: green. That is a rig. spine/skeleton.json is Spine 4.3 skeleton data — it loads in a Spine runtime and it imports into the Spine editor.

Try breaking it, because the validator's messages are the interface here and they are worth meeting once on purpose. With spine/ built, rename images/hull.png to images/raft.png and re-run rigc validate spine:

FAIL  A17_ATLAS_PAGE_FILES_EXIST: page "../images/hull.png" is not on disk at …/images/hull.png
rigc: 1 assertion(s) failed

Put the name back and it is green again. The same gate runs inside build, and a FAIL there stops it before it writes — a red build leaves no half-built artifact on disk to mistake for a result, and there is no flag that changes that.

6. See what you built.

🚨 Green is a claim about validity and about nothing else. A rig whose head sits visibly off its torso passes every assertion, loads in spine-core and steps numerically clean — the offsets are the ones your spec asked for, and no assertion can know you did not mean them. The only remedy is looking, and both commands below need nothing you do not already have: no reference frames, no second package, no server.

rigc render  --candidate spine          # PNG frames + a contact sheet, in render/
rigc preview --candidate spine          # one .html file that plays it: preview.html

render samples every animation at 12 fps and writes render/<animation>/f0000.png… with a contact.png beside them — every frame of the shot as one labelled grid, which is the picture to open first, because spacing is a comparison across frames. It draws with rigc's own rasteriser (the one check measures with), so it needs no browser and no network, and the frames.json it leaves beside the directories makes the result a frame set like any other — the world box every frame is a picture of. --animation <name> narrows it to one, --fps and --max change the rate and the frame size.

preview writes a single self-contained .html: your skeleton, your atlas and every page's PNG bytes are embedded in it as data URIs, and it plays them in the official Spine Web Player. Double-click it, or attach it to a message — the file carries the whole artifact. It is also the strongest interop statement in this repository: a rig that plays there has been played by Esoteric Software's own runtime rather than by ours.

⚖️ The player itself is referenced, not embedded — the page loads it from unpkg, so the first open needs a network, and rigc redistributes nothing Esoteric Software owns (see NOTICE.md). Everything the player draws is inside your file.

7. Let someone choose. Sooner or later you will have two builds that both pass the gate and no instrument that can separate them. vote puts them in one page side by side, labelled A and B with no paths on screen, and takes an answer back:

rigc vote --candidate spine-a --candidate spine-b   # -> ballot.html, open it and pick one
rigc vote --record vote-<id>.json                   # -> checks the answer into votes.jsonl

The voter picks a winner or says "tie / no preference"; the page hands them a small JSON file to save; --record checks that file against the ballot's own hashes and appends one line to an append-only ledger, refusing by name anything that does not belong to it. See Letting someone choose.

Where to go next.

  • 📘 docs/AUTHORING.md is the real guide — both files field by field, the emission rules, every named failure mapped to the file that has to change, and §8–§9 for reproducing a shot you were given as pictures. It ships inside the npm package too, at node_modules/spine-rigc/docs/AUTHORING.md.
  • rigc explain --rig buoy.rig.json --motion buoy.motion.json --out spine prints the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key — and writes nothing. It is what to reach for when a rig compiles and still looks wrong.
  • 🚨 A green gate does not mean the animation is right, and no assertion could. If you have reference pictures of the shot, rigc check --candidate spine --frames <dir> is the half of the loop that can see a wrong animation — AUTHORING.md §9.
  • docs/LADDER.md is the benchmark: the same job, from a brief and rendered frames, scored. docs/PILOT.md is how to run an agent through it and score what comes back.
  • 🤖 Handing the authoring to an AI agent? docs/PROMPTING.md is the operator's page — the six prompt clauses a measured pilot run paid for, and what you can leave unsaid.

The yardstick

The measure of whether this works is Spine's own official example projects — the 1-weight-and-mass8-follow-through series as a difficulty ladder (one animation principle per rig, in roughly ascending order), and spineboy as the graduation exam. The question is structural and per-frame: given the same source art and a spec, does a compiled rig match the official export in bone hierarchy, timeline shape, mesh topology and posed vertex positions?

scripts/fetch-examples.sh downloads those projects into a gitignored examples/ directory (they are not redistributed here — see NOTICE.md for the per-example licence terms).

Comparing a rig against a reference — rigc diff

bun cli.ts diff candidate.json reference.json [--json report.json]

diff reads two skeletons and reports a ratio per measure, grouped into six sections — bones, slots, attachments, constraints, animations, events — and it does not combine them into a score. A single "87% match" cannot tell a rig with the right skeleton and the wrong timing apart from a rig with the right timing and the wrong skeleton, and those are opposite diagnoses.

Three properties the measures are built to have:

  • diff X X is 1.000 on every measure. A comparison tool that cannot recognise identity is reporting noise, and noise looks like a small honest gap. The selftest asserts it.

  • A difference moves as few measures as possible. Reordering two slots moves slots.order and nothing else — not the slot-to-bone bindings, not the setup attachments — so the report says where a rig is wrong, not just how much. Each selftest case names the exact set of measures its edit may disturb.

  • Name-agnostic figures sit beside name-matched ones. A candidate that builds the right tree under its own bone names scores 0 on bones.parent_by_name and 1.000 on bones.depth_histogram and bones.degree_sequence. Reporting only the first calls a correct rig a total failure; reporting only the second calls any 14-bone tree a match. That holds at the section level too: bones and slots are the two sections whose measures are mostly name-keyed, so each reports two figures, and the pair is the finding —

    bones 0.567 (name-matched) · 1.000 (name-agnostic)
    

    reads "the tree is right and the vocabulary is different", which the single mean on its own could not say. They are two comparisons with their own measure sets rather than two halves of one; the name-matched figure is unchanged, so older reports stay comparable. sections[].nameAgnostic in the JSON lists them.

An assertion or measure with nothing to compare reports its total as 0 and says so, exactly as the validator's SKIP does — a vacuous 1.000 that looks earned is the same false green in a different costume.

Checking a rig against the pictures — rigc check

bun cli.ts check --candidate path/to/spine --frames bench/reference/3-timing-and-spacing

Neither the gate nor diff can see a wrong animation. The gate checks validity: it parses the skeleton, steps every animation and refuses anything degenerate, and it has no opinion about whether the animation is the one that was asked for. diff checks structure: a reversed easing is the same timeline, the same key count and the same curve kind. Three honest ladder runs have now produced zero validator FAILs between them, and one of them shipped a build in which every easing in the file was reversed — green, and sincerely reported as done.

check is the instrument for that. It renders the candidate with the same rasteriser that drew the reference frames, onto the same pixel grid, and reports per animation and per frame:

  • MAE over the union alpha — the mean absolute RGB difference over the pixels either side covers, 0..255. The whole-frame figure is printed beside it and never instead of it: most of a frame is background on both sides, so that number is small for every candidate and the gap between a good one and a bad one smaller still.
  • The framing — where the candidate's drawn pixels sit against the reference's, as a scale, an offset and a residual. It is printed first because it is upstream of everything else: get it wrong and the error arrives disguised as motion. On a skeleton root it is decided per animation directory: a set whose own pixels land in the box frames.json records is measured there, exactly, and the rest share one fitted framing. --framing shared measures every set in the shared one — the whole-root behaviour before issue #100, and worth 15–25 MAE on a character. A fitted framing then gets one last pass that searches whole-pixel offsets (±2 px) for the lowest MAE and takes the best one, because a fit registers extent and the best fit of two extents is not the best alignment of two pictures — a constant pixel is worth up to 30 % of a set's figure (issue #146). The line says what it moved and what that was worth, and it says so when the identity won as well. A box that is not an estimate — frames.json's own, or one you pinned — is never moved: there the same search is reported as a finding, because a constant pixel inside the right box is the candidate's own figure sitting off, not framing.
  • The whole shot, against the contact sheet — a set that ships a couple of stills and folds every sampled frame into one contact.png (rung 2's do, spineboy's @30fps sets do) used to be compared on the stills alone, honestly reported and empty behind: nothing at all was measured about the frames in between. check now samples the candidate at the set's own rate and compares it against the sheet's own tiles, whose grid it measures off the sheet (issue #36). MAE only, and a sheet that is not a grid of those frames is refused by name.
  • Per-frame change — how many pixels each side moved since its own previous frame, compared against each other. It is the only measure here that looks at the relation between two frames rather than at one, and it is what catches a held pose the candidate does not hold, or a one-frame event that never fired: both are cheap in every individual frame and invisible to an aggregate.
  • Per-slot drift — where each of the candidate's own slots landed against the reference frame, in pixels. MAE says how wrong; a slot's drift says which part, which way, how far. Where the reference merged two parts into one blob — the trap AUTHORING §8 opens with, and it counts as merged even when one part is most of the blob (issue #37) — the slot is template-matched against its own rendered pixels instead, with a confidence; and where nothing inside the distance that slot could plausibly have moved matches it, the answer is no match rather than a number about some other part.
  • Per-chain attribution — the same two, rolled up onto the unit an author repairs. check cuts the candidate's own bone tree into chains at its branch points and prints, per chain per set, the worst slot drift with its slot and frame, the mean, the error per pixel inside it, and its share of the set's error over the reference's own drawn pixels — plus one rollup line per chain across every set. A figure with a dozen joints otherwise collapses to one number a shot, and "motion ✗" over sixteen shots does not say which limb to re-key.

🔒 It never reads the reference skeleton. It opens the candidate and PNG frames, and nothing else: every reference-side read goes through one guard that refuses a path which is not a .png or the frame set's frames.json, and the selftest makes that guard fire. That is what lets check sit inside an authoring loop where bench cannot — running it as often as you like does not stop a run being an authoring run.

The candidate is framed by its own drawn pixels, not by the reference's world box. A candidate is authored in its own coordinate system and under the ladder's honesty rule could not be authored in any other, so both sides are measured the same way — the content box of what each actually draws — and one similarity transform, fitted by least squares over every edge of every frame, carries the candidate's onto the reference's. Two skeletons depicting the same shot land on the same pixels whatever coordinates they were authored in, an invisible transparent margin cannot move the result, and no single quad corner in a single frame can set the scale for a run.

There is no pass mark in the tool, for the same reason diff has none. The ladder's pass definition and its thresholds are a document read by a person over the whole table — docs/GATE.md states the clauses and docs/LADDER.md's Operating rules derives them — and not an exit code either command could produce.

Benchmark ladder — the rungs, and where they stand

🎓 The ladder is complete, 2026-08-28. All eight numbered rungs and the spineboy graduation exam are cleared under gate v2.1 and hold under v2.2, every clause PASS or SKIP: worst attributable slot drift 5.55 px against a 6.0 px bar, and 0 of 124 frame-change disagreements. Recompiling the same spec in a different session reproduced every field of the measurement record to the digit. The rungs stay in place as regression gates.

⚠️ What that certifies, stated exactly. That the tool, the guide and the protocol reach the bar across a bounded series of honest attempts, each residual diagnosed and fixed — spineboy took five, and the last inherited its predecessor's specs under the run protocol's inheritance clause. It is not that an agent authors a spineboy-scale rig from the brief alone in one run: the ladder has not demonstrated that, and each row records which of the two it is.

docs/LADDER.md is the live ledger: the rung order (blockers → rung 3 first → 1 · 2 · 4 · 5 → 6 → 8 → 7 → spineboy), what each rung gates on, how a rung is scored, the honesty rule that keeps the reference export away from the authoring agent, the operating rules — what a pass is, and the numbered thresholds of the current gate (gate v2.2, stated in docs/GATE.md) that decide one — and a status table. Run one with:

bun cli.ts bench 3 --candidate path/to/candidate/spine

bench validates the candidate under --profile spine, diffs it against that rung's reference export, and prints both. It exits non-zero only when validation fails: the diff has no threshold, because there is no rung score. Add --frames <dir> and it folds in the check table below, so a ladder row carries fidelity as well as structure.

What the rungs need

docs/SPEC_COVERAGE.md surveys the full Spine 4.3 export surface against what rigc emits and against what the nine examples measurably use (bun run bench:usage regenerates the counts). Three blockers sat before rung 1: B1, the bone tree was code in archetype.ts rather than data, so no example could be expressed at all; B2, A16's regex rejected the "4.3.75-beta" that every example declares; and B3, every example ships a packed atlas (13–50 regions per page) against rigc's one-part-per-page model, which A06 enforced unconditionally. B1 and B2 are closed; B3's validator half is (the packed-atlas clauses live behind --profile, above) and its emitter half — no packer, no atlas importer — is not. Ordered gap list in Part 4 of that document; live status, and B1's proof, in docs/LADDER.md.

Looking at a rig — rigc render and rigc preview

The validator cannot see a wrong pose and says so honestly; check can, and needs reference frames a first user does not have. That left looking as the one thing the package could not do, and these two commands are it. Both take a compiled artifact — the directory build --out wrote — and neither needs a reference, a clone or a server:

rigc render  --candidate spine [--animation <name>] [--fps 12] [--max 256] [--out render/]
rigc preview --candidate spine [--animation <name>] [--out preview.html]

render writes render/<animation>/f0000.png… plus a contact.png grid of every frame and a frames.json sidecar describing the world box they are pictures of — the same frame-set shape bench/render_reference.ts writes and rigc check reads, drawn by the same rasteriser, so the output is a frame set rather than a pile of images. preview writes one self-contained .html that plays the artifact in the official Spine Web Player, with the skeleton, the atlas and every page embedded as data URIs; the player is loaded from unpkg rather than copied, so the first open needs a network and rigc redistributes nothing Esoteric Software owns (NOTICE.md).

They complement each other rather than overlap. render is offline, deterministic and measurable — its pixels are the ones check reports on. preview is the interop proof: what plays there was played by Esoteric's own runtime, not by ours.

Letting someone choose — rigc vote

Sometimes looking is not enough on its own, because there is more than one candidate and no instrument that can separate them: a pose fit with two local optima that measure the same, a key density that is a matter of taste, a first draft with no reference to compare against. vote is the deliberate human gate for exactly that residue, and only for that residue.

rigc vote --candidate spine-a --candidate spine-b [--animation <name>] [--out ballot.html]
rigc vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl] [--again]

The first form writes one self-contained ballot.html: two to four compiled candidates side by side, each in its own official player, looping, with one button that restarts them together. The panes are labelled A, B, C, D and show no paths — a voter who can see that B came out of experiments/ is not comparing pictures any more — so the path→label mapping lives in a manifest embedded in the same file and is never rendered. A voter picks a winner or says "tie / no preference", optionally writes a sentence, and copies or downloads a small JSON result the page prints the filename for.

The second form checks that result against the ballot's own manifest and appends it to an append-only JSONL ledger. Nothing is trusted: the result carries a content digest per candidate, and a result whose digests are not this ballot's, whose choice is not on it, or whose reason code contradicts its choice is refused by a named rule (V02_CANDIDATE_DIGESTS_ARE_THE_BALLOTS and friends) with nothing appended. A second vote on one ballot needs --again.

The loop it is built for, in one line: the agent compiles N candidates that all pass the gate → rigc vote writes the ballot → a human opens it, watches, and votes → rigc vote --record checks the answer into votes.jsonl → the agent reads the ledger and proceeds. Compile first, vote last: a candidate reaches a ballot only because it already validated green, so the human is never asked to read JSON, a diff or a spec.

Three properties are worth stating because they are what make the ledger usable by the next agent rather than by a reader:

  • A tie is a recorded outcome, not a missing one. The ledger distinguishes a ballot with a winner, a ballot the human called a tie, and a ballot nobody opened. both-unacceptable is the tie that means propose again, and it is unreachable if ties are not recordable.
  • The winner is a digest, not a label. B means nothing outside one ballot; the digest identifies the same pixels anywhere. Every line also carries its coverage — which candidates the vote compared — so completeness is computable rather than assumed.
  • Every line carries a reason code from a closed enumeration, and the enumeration is enforced: "tie, because this one is better" is refused.

Same player, same posture as preview: referenced from a CDN, never vendored, and the file contains only your own art (NOTICE.md).

Run viewer — watching a run instead of reading it

🔎 This is the ladder's instrument, not the way to look at your own rig — that is the section above. The viewer is reference-bound and repository-bound, and it deliberately never ships.

check.txt says a candidate's worst frame is f0012 at 56 MAE. The viewer shows you f0012.

bun run viewer      # http://localhost:5173

Pick a run, a candidate and an animation. The left pane plays the candidate's emitted skeleton.json — rendered by spine-html, plain DOM, one CSS matrix per slot — and the right pane shows the reference frames for the same animation from bench/reference/, indexed by the scrubber's time at the frame set's own fps. Both panes use the world box the run was measured in (bench.json's check.viewport, per frame set where the run framed them separately), so the two pictures are comparable exactly as far as the check's numbers say they are — and the pane label names which box that was. Under them: bench.json's section means and the framing plus per-animation summary from check.txt.

It is also the smallest end-to-end proof the two modules have. rigc emits Spine data; spine-html consumes Spine data; neither is checking its own work when the skeleton one wrote comes up animating in the other.

Every run under bench/runs/ is listed, including the ones that predate a convention — those are greyed out with the reason (a missing atlas page usually means bun run fetch-examples has not run) rather than dropped, because the ladder's history is part of what the viewer is for.

🚫 There is no build, and that is deliberate. The viewer reads the working tree: the runs, the reference frames, and examples/ — which is Esoteric Software's art, fetched rather than redistributed and non-commercial even then (see NOTICE.md). A bundle would copy those pixels into a distributable artifact. So there is one mode, vite dev on localhost, the dev server serves nothing outside bench/ and examples/, and vite build fails on purpose. viewer/ is not in package.json's files, so it never ships either; it is also outside the root tsconfig.json (it needs the DOM lib, which the rest of the repository must not have) and is type-checked on its own with bunx tsc -p viewer --noEmit. bun run lint covers it like everything else.

What exists today

Inputs — three files, one domain each. Only the middle one is required.

  • A cut manifest (FaceManifest in src/types.ts) owns measured art. Crop rectangle, the base plate, one entry per part with its offset and size, mask polygons, the state machine, bone anchors, and — for a joint cut — the entry point, the insertion axis (deg in screen degrees plus a unit vector, cross-checked against each other), stroke amplitudes and any measured ceilings. The compiler never re-measures art: every number here is produced by a measuring tool or by the pipeline that cut the plates, and rigc only reads it. Optional — a skeleton with no measured art behind it (any of the benchmark examples) has none.
  • A rig spec (RigSpec in src/rig.ts, spec: "rigc-rig/1") owns skeleton structure: bones, slots, skins and their attachments, the 4.3 typed constraints array, and the invariants the emitted JSON cannot state about itself. Its vocabulary is deliberately Spine's own — same concepts, same field names, same defaults, cited to SkeletonJson.ts line numbers — so an agent that has read Spine's documentation can author one without learning a second vocabulary. rigc's additions sit on top and are namespaced: from on a bone takes its position from the manifest instead of a literal that would drift away from the art; image on an attachment names a PNG and rigc measures it; generator on a mesh invokes a builder from src/mesh.ts; invariants carries the axis bone, the forbidden parentage, the mesh budget.
  • A motion spec (MotionSpec, spec: "rigc-motion/1") owns time: the rig it was authored against, named easing handles, setup overrides, a physics tuning table, and the animations — each with a declared duration, a loop flag, its tracks, and five timeline families that sit on the animation rather than in tracks: drawOrder and events, which name no target at all, and ik, transform and deform, whose keys carry named fields instead of one value (an IK mix and softness, six transform mixes, a sparse run of vertex offsets) — which is also where 4.3 writes each of them.

Outputs — two files per cut, written to the cut's out directory:

  • skeleton.json — Spine 4.3 skeleton data. Bones, slots in draw order, one skin, animations, and constraints in the 4.3 single constraints array.
  • skeleton.atlas — a one-part-per-page atlas: every region covers its whole page, pma: false. That convention is what makes the region/attachment/filename join key checkable exactly rather than by convention.

Where the three meet. A manifest part joins a rig slot by its rig_slot field (falling back to slot), and that slot's position in the rig's slots array is the draw order — a manifest whose draw_order numbers disagree is a compile error rather than a silent overrule. A slot filled by both a manifest part and a rig skin is likewise refused, as is a setup pose declared in both the rig and the motion spec: one fact, one author. A missing anchor is a compile error by design, so that copying another cut's numbers is not the path of least resistance.

Two things are code and stay code, because neither is a table of numbers: the mesh generators in src/mesh.ts, which encode a deformation model (what is pinned, what may move, how authority falls off), and the coordinate contract in src/transform.ts.

The validator

src/validate.ts parses the emitted artifacts with spine-core and then runs 36 named assertions over the loaded skeleton. Each one exists because the failure it catches is silent: the file loads, animates, and lies.

Assertions whose data is absent are reported as SKIP, never folded into the pass count — an assertion with nothing to check has not checked anything.

Profiles — "wrong" versus "not how we do it here"

Not all 36 rules are about Spine. Some are about spine-html, the renderer this compiler was built to feed, and about one project's frame budget; they fire on real, correct, editor-produced Spine data, because the official example projects carry clipping attachments, unweighted meshes, 116-triangle meshes and packed atlases — all valid, none of them things spine-html will draw. Others are about rigc's own rigs and mean nothing at all on a skeleton rigc did not compile — they read the rig spec's invariants block, and they SKIP when it is absent rather than counting as passes.

So validate and build take a --profile:

Profile Runs For
spine the 22 validity rules the default. Is this valid Spine 4.3 that any runtime plays correctly?
spine-html all 36 Opt-in. Is this a rig this project can ship?

spine is the default because it is the question this package's output answers: the artifact imports into the Spine editor and plays in any 4.3 runtime, and that is what the 22 validity rules are about. The other 14 are somebody's policy — one renderer's, one canvas budget's, one compiler's own formations' — and a rig arriving from anywhere else has no stake in them. Ask for them with --profile spine-html when you want them.

The Profile column below says which is which — both = validity, renderer and archetype = spine-html only, and both ◑ = a mixed assertion whose validity half always runs while its policy clauses are gated (A06's pma/rotation/full-page clauses, A08's "the two names must be identical", A20's "a mesh must be weighted at all"). A report always names the profile it ran and lists what that profile left out, on PROF lines: a --profile spine green means valid Spine, never passes the renderer policy.

Assertion Profile Holds that
A00_ROUNDTRIP_PARSE both spine-core parses the skeleton and the atlas without throwing
A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS both no 4.1/4.2-shaped physics/ik/… array — 4.3 folds them into one typed constraints array, and the old shape loads clean while the constraint vanishes
A02_NO_BONE_TRANSFORM_KEY both no bone uses 4.2's transform; 4.3 renamed it inherit, and the old key silently falls back to Normal inheritance
A03_REGION_WIDTH_HEIGHT_FINITE both every region attachment loaded a finite, positive width and height (a missing field loads as NaN, with no error)
A04_MESH_TRIANGLES_AND_ENCODING both triangles are a multiple of 3, indices are in range, and the vertex array's encoding agrees with the UV count
A05_CURVE_ARRAY_LENGTH both curve arrays carry 4 numbers per value channel and hold no non-finite value; timelines that cannot take a curve do not carry one. Covers all eleven 4.3 timeline groups — bone, slot, ik, transform, path, physics, slider, deform, drawOrder, drawOrderFolder, events
A06_ATLAS_PAGE_SIZE_MATCHES_PNG both ◑ each page's declared size: matches the PNG on disk, and its region covers the whole page
A07_ATLAS_TEXT_SHAPE both the atlas text obeys the parser's whitespace rules — no stray indentation on region names, no blank line splitting a page block
A08_REGION_NAMES_MATCH_ATTACHMENTS both ◑ every attachment name resolves to a region of exactly that name
A09_ANIMATION_DURATION_MATCHES_SPEC both the compiled duration equals the duration the spec declared (skeleton JSON has no duration field — the last key is the duration). Two tolerances: a frame of slack for a duration declared long, but a key landing past the declared end is held to the grid the times are stored on, because nothing playing the animation ever reaches it. SKIPs without a motion spec
A10_NO_NAN_AFTER_STEPPING both stepping every animation frame by frame produces no NaN anywhere in the pose
A11_NO_CLIPPING_ATTACHMENTS renderer no clipping attachments (the renderer skips them silently)
A12_NO_DARK_COLOR renderer no dark / two-colour tint on slots or timelines — parsed, then ignored
A13_MESH_BUDGET renderer no more mesh slots than the rig's invariants.meshSlots, and no mesh past its invariants.meshTriangles. SKIPs when the rig declares neither
A14_NO_FULL_FRAME_MESH renderer no mesh spans the whole stage (a full-frame mesh is a full-frame canvas that can never dirty-skip)
A15_IDLE_NO_MESH_BONE_KEYS renderer idle keys no bone that drives a mesh, directly or as its control bone
A16_SKELETON_VERSION_4_3 both the skeleton.spine version label is on the 4.3 line (the parser never checks it)
A17_ATLAS_PAGE_FILES_EXIST both every page the atlas declares is a file on disk
A18_DETERMINISTIC_EMIT both a second, independent compile of the same inputs is byte-identical. SKIPs when re-gating artifacts already on disk
A19_OVERLAY_PNGS_HAVE_ALPHA renderer every overlay part image can be transparent somewhere — an alpha channel (colour type 4 or 6) or a tRNS chunk, which is where indexed and greyscale PNGs keep theirs. Only the base plate — identified structurally as the region covering the stage — may be opaque
A20_MESH_WEIGHTS_COHERENT both ◑ every weighted vertex has at least one bone, no negative weight, bone indices in range, and each vertex's weights sum to 1. spine-html also requires that a mesh be weighted at all and that no binding sit at weight 0
A21_MESH_RIM_PINNED archetype a ring mesh's rim vertices are pinned to the anchor bone and its hull is a real ring; a ribbon's entry row stays put. SKIPs on authored geometry — rigc did not place its rim
A22_MESH_UVS_IN_UNIT_RANGE both every UV lies inside its region
A23_PHYSICS_CONSTRAINT_EFFECTIVE both each physics constraint actually drives a component, is not muted by mix: 0, has non-zero mass, and has damping < 1 so it settles
A24_AXIS_SPACE_STROKE archetype the stroke is authored in axis space — no screen-space Y component anywhere in the axis subtree, and no keys at all on the axis bone (its rotation is the one per-cut setup value)
A25_DETACHED_BONE_PARENTAGE archetype bones that must stay detached are not parented under a moving part
A26_SLOT_DRAW_ORDER archetype the slots array — which is the draw order — matches the rig spec's slot table
A27_REGION_NAME_MATCHES_PAGE_FILENAME renderer each region's name equals its page's basename, closing the second link of the attachment → region → file chain
A28_RIBBON_ROWS_SHARE_WEIGHTS archetype both vertices of a ribbon row carry the same bones at the same weights, so the strip can lengthen and curve but never widen. SKIPs on authored geometry — rigc did not pair its rows
A29_STROKE_WITHIN_CONTACT_DEPTH archetype the stroke plus any inward keys stays within the cut's measured contact depth (skipped when the manifest declares none)
A30_STROKE_WITHIN_CAP_CONTAINMENT archetype the stroke stays within the cut's measured containment ceiling, and nothing in the axis subtree scales — a scale key changes the contour the ceiling was measured on (skipped when the manifest declares none)
A31_DRAW_ORDER_OFFSETS_RESOLVE both every draw-order key resolves to a real permutation: known slots, one entry per slot, each landing inside the slots array, offsets in ascending slot order. The only assertion that runs before A00 — descending offsets make readDrawOrder's forward-only cursor spin rather than return, so the round trip is refused by name instead of attempted
A32_EVENT_KEYS_RESOLVE both every event key fires an event the skeleton declares, no key sits earlier in time than the one before it, and volume/balance appear only on an event with an audio path. Only the first of those is loud in the parser; the other two load clean and drop the firing or the value in silence. SKIPs when no animation carries an event timeline
A33_VERTEX_ATTACHMENT_GEOMETRY both every bounding box and clipping polygon states a vertexCount that agrees with its vertex array, its weighted run decodes to that many vertices with bone indices in range, and a clipping end names a slot that exists. All three load clean: a missing count reads as zero and empties the polygon, and a missing end slot makes the clip run to the bottom of the draw order. SKIPs when the skeleton carries neither type
A34_CONSTRAINT_TIMELINE_TARGETS both every ik / transform timeline names a constraint of that type and carries at least one key. The name-and-type miss is loud in the parser (IK Constraint not found) and this one says which constraints the skeleton does have; the empty key array is silent — readAnimation reads key 0, finds nothing and skips the timeline without a word. SKIPs when no animation carries one
A35_DEFORM_KEYS_FIT_THE_ATTACHMENT both every deform key's run lands inside the attachment's own deform array, starts on an even index, holds an even count of finite numbers, and names a skin/slot/attachment triple that resolves. The array is one x, y pair per vertex on an unweighted attachment and one per bone influence on a weighted one, so its length is measured from the attachment rather than assumed. An overlong run is the format's quietest defect: Utils.arrayCopy into a Float32Array drops everything past the end, so part of the mesh deforms and it looks nearly right. SKIPs when no animation carries a deform timeline

Usage

📘 Writing a spec? Read docs/AUTHORING.md first. It is the guide an agent rigs from: both input files with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build → read the report → fix → repeat loop, the map from every named failure to the file that has to change, and the list of format features rigc refuses by name so you do not spend a loop discovering them. It travels inside the npm package too, so an agent working from an install has it on disk at node_modules/spine-rigc/docs/AUTHORING.md.

Compile by spelling out the paths. --manifest is optional; --images <dir> says where a rig spec's image references live (it overrides the rig's own images field):

bun cli.ts build \
  --rig      path/to/my_rig.rig.json \
  --motion   path/to/my.motion.json \
  --out      path/to/spine \
  [--manifest path/to/manifest.json] [--images path/to/images]

By default, atlas page paths point back at the source art wherever it lives — often outside --out — so add --copy-images when spine/ itself needs to be self-contained (zipped, committed, or handed off on its own): it copies every referenced page PNG into --out and rewrites the atlas to match.

…or register cuts in a cuts.json and build them by name. Every path in the table resolves relative to the cuts.json file itself, so the table lives with the project that owns the art:

{
  "my_cut": {
    "rig": "rigs/my_rig.rig.json",
    "manifest": "output/my_cut/manifest.json",
    "motion": "specs/my_cut.motion.json",
    "out": "output/my_cut/spine"
  }
}
bun cli.ts build --cut my_cut --cuts path/to/cuts.json

build compiles, then validates, and writes only if the gate is green. Other commands:

bun cli.ts explain  --cut my_cut --cuts path/to/cuts.json   # the compiled rig as a table
bun cli.ts validate path/to/spine                           # re-gate artifacts already on disk
bun cli.ts validate --profile spine-html path/to/spine      # …and this project's policy too (see Profiles)
bun cli.ts diff candidate.json reference.json               # structural comparison
bun cli.ts check --candidate path/to/spine \
                 --frames bench/reference/3-timing-and-spacing   # against pictures
bun cli.ts bench 3 --candidate path/to/spine                # one rung of the ladder
bun cli.ts render  --candidate path/to/spine                # PNG frames + a contact sheet
bun cli.ts preview --candidate path/to/spine                # one .html that plays it
bun cli.ts vote    --candidate path/to/a --candidate path/to/b   # one .html that asks which
bun cli.ts vote    --record vote-<id>.json                  # check the answer into votes.jsonl

validate on a bare directory checks what it can see. Adding --cut/--cuts lets it re-derive the declared durations and the structural expectations too, and the report says which it had. build and validate both default to --profile spine, the 22 validity rules; --profile spine-html adds this project's renderer and archetype policy on top.

render and preview are the two that need no reference at all — see Looking at a rig. Run either straight after a green build, on the same directory --out wrote. vote is the same idea with more than one candidate in the page and an answer coming back — see Letting someone choose.

Checks

bun run typecheck    # bunx tsc --noEmit over cli.ts, selftest.ts, src/, bench/, tools/, fixtures/
bun run lint         # one rule: @typescript-eslint/no-explicit-any, as an error
bun run selftest     # the validator's own negative controls (next section)

All three run on every push and pull request — .github/workflows/ci.yml. Bun runs the sources directly, so the first two are not on the path of anything; they exist because a convention nothing checks is a convention. tsconfig.json is strict: false with strictNullChecks: true and says in place why the rest is not on yet; eslint.config.js says why it carries exactly one rule.

Selftest

bun run selftest                            # everything below; no arguments needed
bun run selftest --cuts path/to/cuts.json   # …plus an extra suite over those cuts

A gate nobody has seen fail is not a gate. The selftest compiles a rig, breaks the result one way at a time — 45 deliberate breaks, each modelled on a mistake that was actually made or actually measured — and asserts that the named assertion fires for each. Two further edits are tolerance controls the gate must let through, because a widened assertion can fail by firing too often as easily as by firing too rarely.

The rigs it breaks are generated. fixtures/public.ts writes three synthetic cuts into a temp directory on every run, and between them they carry every structure the assertions have an opinion about — region attachments, attachment swaps, rgba fades, a ring mesh on a control bone, a ribbon on a bone chain, an axis bone whose subtree travels along it, a detached emitter, physics constraints, and two measured ceilings. Every plate is a checkerboard with PLACEHOLDER burned into it: they exist to be structurally real, and no claim about appearance is made from any of them.

A fifth suite breaks an input instead of an artifact: nine malformed rig specs that the compiler must refuse by name — a forward parent reference, a duplicate bone name, a slot naming a missing bone, an ik target that does not exist, an attachment image that is not on disk, an authored mesh binding a bone the rig does not have, one that uses raw bone indices without asking for them, a wrong spec field, and a constraint type the emitter cannot write. Each of those produces a file Spine's own parser would accept while quietly meaning something else.

A motion spec can be wrong the same way, and the shape that costs the most is the quietest: a key time that lands past the animation's declared duration is never sampled, so the motion it was meant to carry simply does not happen. Five controls hold that line — a key sitting exactly on a duration of 68/12 s is legal and must compile, a key that 4 dp rounding pushed 0.000034 s past one is refused by name, the same overshoot in an artifact the compiler never saw is caught by A09, an animation whose last key is a frame short of its declared end is still accepted because that direction is a different question, and a 32-second animation keyed exactly on its own duration is not failed for the float32 grid its times come back on.

There is a positive control per suite as well: the pristine artifacts must come back with zero failures, because a validator that failed everything would otherwise look like a validator that worked.

rigc check gets the same treatment, and its pair is deliberately the same rig twice: the rung 3 transcription against rung 3's frames, and then that transcription with every key time reversed. Reversing leaves the structure untouched — same timelines, same key count, same duration, and the gate stays green, which the control asserts — and changes only what the shot looks like. Faithful reads 0.67 px of slot drift; reversed reads 66.8 px. A third control makes the frames-only read guard refuse a reference skeleton, because an honesty invariant nobody has seen refuse anything is not an invariant.

The mesh path gets the same pair, on the rung 6 transcription against rung 6's frames. Faithful reads a median 0.08 px of drift on the mesh-bearing slots; the break is the one an authored mesh is actually exposed to — its weights bind bones by index, so inserting a bone anywhere ahead of them rebinds every vertex in silence — and it reads 31 px with the gate still green. Four further controls run on a generated fixture and need no corpus: a ring mesh is posed at all, its pixels reach the coverage mask check reads, an all-zero deform is the identity while a real one moves the centroid, and two triangles sharing an edge draw it once.

Point the run at a cuts.json and an extra suite compiles every cut in it, gates the result, and compiles it a second time for A18. That one is a positive control on purpose: what real art adds is geometry a fixture cannot fake — measured offsets, a measured axis, a measured ceiling, a mesh built over a contour nobody drew by hand — so the question it asks is whether the whole gate still comes back green on it. Without a cuts file it says it was skipped and the run passes on the public suite alone; a cuts path that is named and missing exits 2.

Two suites measure against the Spine example corpus, which is downloaded rather than redistributed. When examples/ is absent they say so loudly and the summary repeats it — an absent corpus is a hole in the run, not a pass — and a run in which nothing substantive executed exits 2 rather than printing green.

Layout

tsconfig.json   type-check config (noEmit); eslint.config.js — the no-any gate
cli.ts          build / validate / explain / diff / check / bench / render / preview / vote
selftest.ts     the validator's own negative controls, and diff's and check's
fixtures/       public.ts — the three synthetic cuts the selftest breaks
src/
  compile.ts    rig + motion spec (+ manifest) -> skeleton JSON + atlas text (pure data assembly)
  rig.ts        the rig spec — `spec: "rigc-rig/1"`, the skeleton as data
  validate.ts   spine-core round trip + the 36 assertions
  diff.ts       structural comparison of two skeletons, one ratio per measure
  render.ts     the rasteriser (regions + meshes), shared by the reference renderer,
                `rigc render` and check
  preview.ts    the single-file HTML player page — the artifact embedded as data
                URIs, played by the official Spine Web Player (referenced, not vendored)
  ballot.ts     the same page with 2–4 candidates in it and a vote coming back —
                candidate digests, the ballot manifest, and the refusals that
                stand between a saved vote and the ledger
  check.ts      a candidate against rendered frames — pixels and per-slot drift,
                and it never opens the reference skeleton
  ladder.ts     which example is which rung, and which file in it is the reference
  timelines.ts  the 4.3 timeline catalogue and its walker (shared, pure JSON)
  mesh.ts       ring and ribbon mesh builders, weighted-vertex encoding
  transform.ts  crop pixels (y down) <-> Spine world (y up), world transforms
  png.ts        PNG header reader (size, colour type, tRNS; no pixel decode)
  errors.ts     CompileError, and NotImplementedError for what the format holds
                and the emitter does not write
  types.ts      manifest, motion spec, and emitted-JSON shapes
tools/          measurement and plate helpers (see below)
scripts/        fetch-examples.sh
bench/          count_features.ts — what the example corpus actually uses
                render_reference.ts — a rung's official export as PNG frames
                briefs/ — what an authoring agent is told about a rung
                reference/ — those frames, with the licence they travel under
                runs/ — one directory per attempt, and the run protocol
                transcriptions/ — rung specs transcribed from a reference export,
                which measure expressiveness and NOT authoring (see LADDER.md)
viewer/         the run viewer — dev server only, no build (see above)
                  vite.config.ts  /api/inventory and /repo/<path>, and the build refusal
                  inventory.ts    what is under bench/runs, resolved to URLs
                  main.ts         the two panes, the transport, the report
docs/           AUTHORING.md (how to author a rig), GATE.md (the clause statements
                a candidate is graded against), LADDER.md (live rung status),
                SPEC_COVERAGE.md (format survey),
                feature_matrix.{csv,json}
.github/        workflows/ — ci.yml (the gates) and release.yml (release-please)
CONTRIBUTING.md how to propose a change; RELEASING.md — how a version is cut

tools/ are standalone utilities, each taking its paths as arguments:

Tool Does
measure_contact_depth.ts measures a cut's contact depth from its plates, with the two-sided proof it has to satisfy. Both slot names are required: which plate is the mass and which is the occluder is a fact about one cut, and a default would measure the wrong pair and still print a number
contact.ts plate-vs-plate overlap measurement — the largest advance that keeps two footprints disjoint
plate.ts / png_probe.mjs minimal PNG read/write and decode. The writer emits colour type 6 only; the reader takes every colour type and bit depth PNG allows except interlaced, expanding indexed palettes (PLTE + tRNS) and greyscale to RGBA — because the gate accepts that art, so the renderer has to as well (issue #226)
font5x7.ts bitmap labels for diagnostic images and generated plates

Contributing

Issues are the ledger; see CONTRIBUTING.md for what a change has to clear before it lands. Releases are cut by release-please — RELEASING.md.

Licence

MIT — see LICENSE. Third-party terms, including the Spine editor licence requirement that this project inherits, are in NOTICE.md.

About

Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.

Topics

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages