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.
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-coreto 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.
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:
- rigc's output is Spine skeleton data.
- Playing Spine skeleton data in a product requires a Spine Runtime.
- The Spine Runtimes License requires each user of such a product to own a Spine editor licence.
- rigc links
spine-coreitself, 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.
📦 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 projectnpx 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.
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 spinebuild 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.htmlrender 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.jsonlThe 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 spineprints 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 measure of whether this works is Spine's own official example projects —
the 1-weight-and-mass … 8-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).
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 Xis 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.orderand 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_nameand 1.000 onbones.depth_histogramandbones.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:bonesandslotsare 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[].nameAgnosticin 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.
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.jsonrecords is measured there, exactly, and the rest share one fitted framing.--framing sharedmeasures 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@30fpssets do) used to be compared on the stills alone, honestly reported and empty behind: nothing at all was measured about the frames in between.checknow 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.
checkcuts 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.
🎓 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.
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/spinebench 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.
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.
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.
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-unacceptableis the tie that means propose again, and it is unreachable if ties are not recordable. - The winner is a digest, not a label.
Bmeans nothing outside one ballot; the digest identifies the same pixels anywhere. Every line also carries itscoverage— 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).
🔎 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:5173Pick 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.
Inputs — three files, one domain each. Only the middle one is required.
- A cut manifest (
FaceManifestinsrc/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 (degin screen degrees plus aunitvector, 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 (
RigSpecinsrc/rig.ts,spec: "rigc-rig/1") owns skeleton structure: bones, slots, skins and their attachments, the 4.3 typedconstraintsarray, 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 toSkeletonJson.tsline 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:fromon a bone takes its position from the manifest instead of a literal that would drift away from the art;imageon an attachment names a PNG and rigc measures it;generatoron a mesh invokes a builder fromsrc/mesh.ts;invariantscarries 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 intracks:drawOrderandevents, which name no target at all, andik,transformanddeform, 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 singleconstraintsarray.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.
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.
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 |
📘 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.jsonbuild 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.jsonlvalidate 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.
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.
bun run selftest # everything below; no arguments needed
bun run selftest --cuts path/to/cuts.json # …plus an extra suite over those cutsA 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.
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 |
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.
MIT — see LICENSE. Third-party terms, including the Spine editor licence requirement that this project inherits, are in NOTICE.md.