spine-rigc 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -533,9 +533,9 @@ first three work on any reference you have, and `bench` is a repository workflow
533
533
  and `bun run fetch-examples`. The reasoning behind them is in
534
534
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
535
535
 
536
- `build` and `validate` both default to `--profile spine` — the 29 validity rules, which
536
+ `build` and `validate` both default to `--profile spine` — the 30 validity rules, which
537
537
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
538
- adds all 44: the other 15 are one renderer's policy and one canvas budget's, and they
538
+ adds all 45: the other 15 are one renderer's policy and one canvas budget's, and they
539
539
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
540
540
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
541
541
  yourself: author under `--profile spine-html` and read the extra 15 as findings, and
@@ -681,7 +681,7 @@ letting `A17` blame the editor for the harness's own doing.
681
681
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
682
682
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
683
683
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
684
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 44 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
684
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 45 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
685
685
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
686
686
  | 🗺️ [ROADMAP.md](https://github.com/firejune/rigc/blob/main/ROADMAP.md) | where this is going, and where it has been. What 1.0 has to mean before the number is claimed — conditions rather than a feature list, because direction here comes from what users hit |
687
687
  | 📐 [CLAUDE.md](https://github.com/firejune/rigc/blob/main/CLAUDE.md) | **the doctrine** — why the validator's messages are the product, why nothing reaches disk before green, why no number is ever invented, and what a change has to keep. [CONTRIBUTING.md](https://github.com/firejune/rigc/blob/main/CONTRIBUTING.md) calls it worth ten minutes before a first patch. Repository material |
@@ -738,7 +738,7 @@ quality."* All six, with their verdicts, are in
738
738
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
739
739
 
740
740
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
741
- see, every rung, the run viewer, the 44 assertions and the selftest behind them — is
741
+ see, every rung, the run viewer, the 45 assertions and the selftest behind them — is
742
742
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
743
743
  Live rung status is
744
744
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/cli.ts CHANGED
@@ -66,10 +66,24 @@ import {
66
66
  type DeformKeyMeasure,
67
67
  type DeformSpan,
68
68
  } from './src/deformmeasure.ts';
69
- import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
69
+ import {
70
+ diffLines,
71
+ diffSkeletons,
72
+ reportedFigures,
73
+ sectionFigures,
74
+ type DiffAnimationPair,
75
+ type DiffReport,
76
+ } from './src/diff.ts';
70
77
  import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
71
78
  import { copyAtlasPages } from './src/emit.ts';
72
- import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas, parseAtlasText } from './src/atlas.ts';
79
+ import {
80
+ DEFAULT_PADDING,
81
+ DEFAULT_PAGE_SIZE,
82
+ packAtlas,
83
+ pageFootprint,
84
+ parseAtlasText,
85
+ type AtlasRegion,
86
+ } from './src/atlas.ts';
73
87
  import { parseJsonWithPosition } from './src/json-position.ts';
74
88
  import { KEY_TIME_EPSILON } from './src/timelines.ts';
75
89
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
@@ -218,13 +232,16 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
218
232
  /**
219
233
  * The flags a command is allowed to spell more than once.
220
234
  *
221
- * Only `vote --candidate` is, because a ballot is *by definition* several
222
- * candidates. Everywhere else a repeat is a mistake and is refused: `check
235
+ * `vote --candidate` is, because a ballot is *by definition* several
236
+ * candidates, and `diff --as` is, because a skeleton has as many shots as it
237
+ * has and one pairing per flag is the only spelling that keeps each pair a pair
238
+ * (issue #720). Everywhere else a repeat is a mistake and is refused: `check
223
239
  * --candidate a --candidate b` used to take `b` silently, which is a report
224
240
  * about a rig the caller did not think they were asking about.
225
241
  */
226
242
  const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
227
243
  vote: new Set(['candidate']),
244
+ diff: new Set(['as']),
228
245
  };
229
246
 
230
247
  /**
@@ -1155,6 +1172,45 @@ function dropLine(dropped: DroppedState): string {
1155
1172
  return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
1156
1173
  }
1157
1174
 
1175
+ /**
1176
+ * The rectangle a region occupies **on its page**, for a line that has already
1177
+ * said where the region is — and the empty string where the page rectangle is
1178
+ * the one `bounds:` already states.
1179
+ *
1180
+ * ## The fact no surface an author reads carried (issue #718)
1181
+ *
1182
+ * The atlas line beside this clause prints the DRAWING's size, because that is
1183
+ * what an attachment's width and height mean. A packer that turned the drawing a
1184
+ * quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
1185
+ * author holding the pack and the build report had neither end of the rectangle
1186
+ * they have to cut out of the page to measure a part against a rendered frame —
1187
+ * and the one place rigc printed it was `A06`'s overlap text, reachable only
1188
+ * under `--profile spine-html`. The knowledge was in the tree the whole time:
1189
+ * `pageFootprint` has derived this rectangle for every reader of it since issue
1190
+ * #579, and nothing an author reads said it.
1191
+ *
1192
+ * ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
1193
+ * `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
1194
+ * filed on — four readers derived this rectangle and two derived it wrongly — so
1195
+ * the clause asks the function whether its answer differs from the `bounds:`
1196
+ * line, and prints only then.
1197
+ *
1198
+ * 🔸 A consequence worth stating rather than leaving to be discovered: a region
1199
+ * whose KEPT rectangle is square is silent here, because a quarter turn leaves
1200
+ * its footprint the same two numbers and there is nothing the pack does not
1201
+ * already say. The general rule — `bounds` is the unturned size, the footprint
1202
+ * is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
1203
+ * rectangle to recover the drawing — belongs to an author's own reading and is
1204
+ * stated in `docs/AUTHORING.md` §0.2, which holds for every region including
1205
+ * that one.
1206
+ */
1207
+ function pageRectangle(region: AtlasRegion): string {
1208
+ const foot = pageFootprint(region);
1209
+ return foot.width === region.width && foot.height === region.height
1210
+ ? ''
1211
+ : `, occupies ${foot.width}x${foot.height}`;
1212
+ }
1213
+
1158
1214
  function cmdBuild(flags: Record<string, string>): void {
1159
1215
  const { label, opts } = resolveCut(flags);
1160
1216
  const profile = readProfile(flags);
@@ -1208,13 +1264,23 @@ function cmdBuild(flags: Record<string, string>): void {
1208
1264
  // that declares a `scale:` also says so and shows the texels it was read
1209
1265
  // from: the size on the left is the DRAWING's and the rectangle is the
1210
1266
  // pack's, and issue #267 is the report that printed the second as the first.
1267
+ //
1268
+ // `pageRectangle` closes the line's last silence (issue #718), and it is
1269
+ // placed LAST rather than beside the turn it follows from, which is where
1270
+ // the card put it. The two clauses collide nowhere else, and the collision
1271
+ // is real: `scale 0.5 (373x106 texels)` is itself a size, so
1272
+ // `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
1273
+ // were the scaled quantity. As a trailing clause of the whole location
1274
+ // phrase it is unambiguous with a `scale:` line and identical to the card's
1275
+ // wording without one, which is every pack that has no `scale:` to state.
1211
1276
  const where =
1212
1277
  img.atlas === undefined
1213
1278
  ? img.page
1214
1279
  : `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
1215
1280
  (img.atlasScale === undefined
1216
1281
  ? ''
1217
- : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`);
1282
+ : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
1283
+ pageRectangle(img.atlas);
1218
1284
  console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
1219
1285
  }
1220
1286
  for (const d of result.droppedStates) console.log(dropLine(d));
@@ -1403,7 +1469,69 @@ function cmdValidate(flags: Record<string, string>, positional: string[]): void
1403
1469
  console.log('rigc: green');
1404
1470
  }
1405
1471
 
1406
- function cmdDiff(flags: Record<string, string>, positional: string[]): void {
1472
+ /**
1473
+ * `--as <candidate>=<reference>`, one pair per occurrence.
1474
+ *
1475
+ * ⚠️ Spelled with a pair where `check --as <name>` takes one name, and the
1476
+ * difference is in what the two commands have on the other side. `check`
1477
+ * measures against a rendered frame SET, which already carries the reference
1478
+ * animation's name in its own directory, so one name closes the gap. `diff` has
1479
+ * two skeletons and either may have its own vocabulary, so one name says which
1480
+ * shot on which side and leaves the other unanswered. The direction — candidate
1481
+ * first — is the one `bonedist`'s correspondence file already writes its
1482
+ * `animations` map in.
1483
+ *
1484
+ * Every refusal here is a UsageError because every one of them is about the
1485
+ * flag's own value, and each names what it read: a value with no `=`, an empty
1486
+ * side, a name repeated on either side, and a name no animation on that side
1487
+ * answers to. ⛔ The last of those is a refusal rather than a dropped pair for
1488
+ * the reason a miss is refused by name everywhere else in this tool — a typo
1489
+ * that quietly measured less would be a report about a pairing the caller did
1490
+ * not ask for.
1491
+ */
1492
+ function readAnimationPairs(values: string[], candidate: unknown, reference: unknown): DiffAnimationPair[] {
1493
+ const animationsOf = (root: unknown): string[] => {
1494
+ const anims = (root as { animations?: unknown } | null)?.animations;
1495
+ return typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? Object.keys(anims) : [];
1496
+ };
1497
+ const have = { candidate: animationsOf(candidate), reference: animationsOf(reference) };
1498
+ const pairs: DiffAnimationPair[] = [];
1499
+ for (const value of values) {
1500
+ const at = value.indexOf('=');
1501
+ if (at < 0) {
1502
+ throw new UsageError(
1503
+ `--as ${JSON.stringify(value)} is not a pair. It takes <candidate>=<reference> — two animation names joined ` +
1504
+ 'by `=`, because diff compares two skeletons and either may have its own name for the shot. The candidate ' +
1505
+ `has [${have.candidate.join(', ') || 'none'}] and the reference has [${have.reference.join(', ') || 'none'}].`,
1506
+ );
1507
+ }
1508
+ const pair = { candidate: value.slice(0, at), reference: value.slice(at + 1) };
1509
+ if (pair.candidate === '' || pair.reference === '') {
1510
+ throw new UsageError(
1511
+ `--as ${JSON.stringify(value)} leaves the ${pair.candidate === '' ? 'candidate' : 'reference'} side empty; ` +
1512
+ 'it takes <candidate>=<reference>, a name on each side',
1513
+ );
1514
+ }
1515
+ for (const side of ['candidate', 'reference'] as const) {
1516
+ if (!have[side].includes(pair[side])) {
1517
+ throw new UsageError(
1518
+ `--as ${JSON.stringify(value)} names no ${side} animation: the ${side} has ` +
1519
+ `[${have[side].join(', ') || 'none'}] and not ${JSON.stringify(pair[side])}`,
1520
+ );
1521
+ }
1522
+ if (pairs.some((p) => p[side] === pair[side])) {
1523
+ throw new UsageError(
1524
+ `--as pairs the ${side} animation ${JSON.stringify(pair[side])} twice; each animation may be in one pair, ` +
1525
+ 'or the block would compare one shot against two',
1526
+ );
1527
+ }
1528
+ }
1529
+ pairs.push(pair);
1530
+ }
1531
+ return pairs;
1532
+ }
1533
+
1534
+ function cmdDiff(flags: Record<string, string>, lists: Record<string, string[]>, positional: string[]): void {
1407
1535
  const [candidate, reference] = positional;
1408
1536
  if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
1409
1537
  const candidatePath = resolve(candidate);
@@ -1411,7 +1539,10 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
1411
1539
  for (const path of [candidatePath, referencePath]) {
1412
1540
  if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
1413
1541
  }
1414
- const report = diffSkeletons(readJsonFile(candidatePath), readJsonFile(referencePath));
1542
+ const candidateJson = readJsonFile(candidatePath);
1543
+ const referenceJson = readJsonFile(referencePath);
1544
+ const animationPairs = readAnimationPairs(lists.as ?? [], candidateJson, referenceJson);
1545
+ const report = diffSkeletons(candidateJson, referenceJson, { animationPairs });
1415
1546
  console.log('rigc diff');
1416
1547
  for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
1417
1548
  if (flags.json !== undefined) {
@@ -3266,8 +3397,26 @@ const COMMANDS: CommandDoc[] = [
3266
3397
  },
3267
3398
  {
3268
3399
  name: 'diff',
3269
- usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
3270
- flags: ['json'],
3400
+ usage: ['rigc diff <candidate.json> <reference.json> [--as <candidate>=<reference>]… [--json <out>]'],
3401
+ flags: ['as', 'json'],
3402
+ overrides: {
3403
+ as: {
3404
+ value: '<candidate>=<reference>',
3405
+ meaning:
3406
+ 'pair a candidate animation with a reference one, so the name-agnostic `animations` block can be ' +
3407
+ 'measured over shots the two files call different things. Repeatable, one pair each. An INPUT and never ' +
3408
+ 'derived: two skeletons cannot say which of their shots are the same shot. Without it the block appears ' +
3409
+ 'only when each side has exactly one animation, which pairs by position, and is otherwise absent rather ' +
3410
+ 'than guessed',
3411
+ },
3412
+ },
3413
+ notes: [
3414
+ 'the `animations` block reads two figures once something has paired the shots, exactly as',
3415
+ '`bones` and `slots` do: name-matched, where `names` lives, and name-agnostic over the pair.',
3416
+ 'A candidate that followed a brief withholding the animation name reads `count` 1/1 and 0.000',
3417
+ 'on every other name-matched measure — including `duration` and `key_counts` it may have got',
3418
+ 'exactly right — so read the pair and not the section mean.',
3419
+ ],
3271
3420
  },
3272
3421
  {
3273
3422
  name: 'check',
@@ -3562,7 +3711,7 @@ try {
3562
3711
  else if (command === 'ingest') cmdIngest(flags, positional);
3563
3712
  else if (command === 'validate') cmdValidate(flags, positional);
3564
3713
  else if (command === 'explain') cmdExplain(flags);
3565
- else if (command === 'diff') cmdDiff(flags, positional);
3714
+ else if (command === 'diff') cmdDiff(flags, lists, positional);
3566
3715
  else if (command === 'check') cmdCheck(flags);
3567
3716
  else if (command === 'bench') cmdBench(flags, positional);
3568
3717
  else if (command === 'bonedist') cmdBoneDist(flags);
package/docs/AUTHORING.md CHANGED
@@ -169,7 +169,7 @@ What the flags mean:
169
169
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
170
170
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
171
171
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
172
- | `--profile` | `spine` = the 29 validity rules (**the default**) · `spine-html` = all 44, opt-in |
172
+ | `--profile` | `spine` = the 30 validity rules (**the default**) · `spine-html` = all 45, opt-in |
173
173
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
174
174
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote`, so is a name that only *some* candidates have |
175
175
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
@@ -178,7 +178,7 @@ What the flags mean:
178
178
  | `--again` | `vote --record` only: record a second vote on a ballot the ledger already has. Without it a repeat is refused by name rather than doubled |
179
179
  | `--frame` | `pose` and `chainfit`: one pose frame — the picture to read part placements out of. One frame per call; several key poses are several calls, and correlating them is yours (§11) |
180
180
  | `--scale` | the scale window to search, as **frame pixels per part pixel**, `<min>,<max>` (default `0.5,2`). The report states what it searched, and a window that does not contain the truth does not reliably refuse — §11. For `chainfit` it sizes the **internal anchor pass** and is refused beside `--anchor` — §12.4 |
181
- | `--rotation` | the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright. For `chainfit`, again the internal anchor pass — the chains' own window is `--hinge` |
181
+ | `--rotation` | the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright — a window narrower than the coarse step is divided rather than refused, and `search` states the step that division produced (§11.3). For `chainfit`, again the internal anchor pass — the chains' own window is `--hinge` |
182
182
  | `--max-residual` | `pose` and `chainfit`: above this residual a placement is **refused by name** instead of reported flat (default `0.25`). A reporting threshold, not a pass bar — the placement is still in the JSON |
183
183
  | `--anchor` | `chainfit` only: a `rigc pose` report for **this** frame, whose confident placements become the anchors the chains hang off. Without it that pass runs internally — §12.2 |
184
184
  | `--hinge` | `chainfit` only: the window each child bone's local rotation is searched over, in **Spine** degrees about its setup value, `<min>,<max>` (default `-180,180`) — §12.4 |
@@ -389,6 +389,37 @@ silhouette and an authored mesh's fit figure is the same number. The one thing
389
389
  that does not change is what rigc **writes**: its own packer never turns a
390
390
  region.
391
391
 
392
+ 🔑 **That is a claim about rigc's instruments, and you may be holding only the
393
+ pack.** An author who has to cut a part out of the page by hand — to measure it
394
+ against a rendered frame, or to look at it at all — needs what the `rotate:` line
395
+ means for the numbers beside it, and the pack states none of this:
396
+
397
+ - `bounds: x, y, w, h` gives `w x h` in the **drawing's** orientation, so it is
398
+ the part's size **unturned**: the same two numbers the same drawing carries at
399
+ `rotate: 0`;
400
+ - the rectangle on the **page** is therefore `h x w` at `x, y` — the transpose —
401
+ at `rotate: 90` and at `rotate: 270` alike, while `w x h` is what a region
402
+ occupies at `rotate: 0` and at `rotate: 180`;
403
+ - cut that rectangle out of the page and turn it **clockwise** to recover the
404
+ drawing at `rotate: 90`, and **counter-clockwise** at `rotate: 270`; a half
405
+ turn has no direction to name.
406
+
407
+ Each direction there is **measured** rather than reasoned about: it comes off
408
+ `MeshAttachment.computeUVs` in the linked runtime, the one routine there that
409
+ says where a region's texels are for all four values, and the selftest derives
410
+ the words in that list from the same routine instead of reading them. ⚠️ Do not
411
+ take the turn from `TextureAtlas`'s own `u2`/`v2` — those transpose at a quarter
412
+ turn one way and not at the other, so one of the two pairs describes a rectangle
413
+ the page does not have ([#579](https://github.com/firejune/rigc/issues/579)).
414
+
415
+ `build` now prints the page rectangle on the line for a turned region, so the
416
+ report carries the rectangle to cut instead of leaving it to be derived
417
+ ([#718](https://github.com/firejune/rigc/issues/718)):
418
+
419
+ ```bash
420
+ # .. pendulum 105x139 <- ../export/atlas.png @ 710,16 rotate 90, occupies 139x105
421
+ ```
422
+
392
423
  ⚠️ Two limits here are real and neither is about rotation:
393
424
 
394
425
  - `--atlas-in` cannot recover what a `scale:` quantised away (above), turned or not;
@@ -500,6 +531,23 @@ the first:
500
531
  | `JUDGE` | the skeleton cannot answer and somebody has to: the stage, and each animation's duration |
501
532
  | `LOSS` | the skeleton's spelling and rigc's differ, on purpose, and the line says how. A path attachment's `lengths` is the one that matters — it is `PathConstraint`'s own four-sample measurement rather than an arc length (#560), so a transcribed one would freeze whatever produced the source. The header ones are cheaper: `HEADER_BOOKKEEPING` for a field the spec has no home for, `HEADER_REDERIVED` for the version string, `HEADER_ORIGIN` for an origin the source left to the format and the rebuild writes out (#622) |
502
533
 
534
+ ⛔ **It reads one generation of the format, and a file from another one ends loud.**
535
+ Spine data is locked to the generation that exported it and a mismatch does not
536
+ throw: 4.3 takes constraints from the top-level `constraints` array alone, so a
537
+ 4.0–4.2 file's `ik`/`transform`/`path`/`physics` arrays load as nothing at all — 1,302
538
+ shipped skeletons parsed on a 4.3 runtime and loaded 0 of 8,672 constraints
539
+ ([#706](https://github.com/firejune/rigc/issues/706) row 1). So `ingest` reads
540
+ `skeleton.spine` before it reads a field of the file. A file from another generation is
541
+ a `BLOCK GENERATION_UNSUPPORTED` naming the generation, the string it was read from,
542
+ and what a 4.3 reader loses **on that file**: the constraints parked in those arrays
543
+ counted by kind, the bones carrying 4.2's `transform` where 4.3 spells `inherit`, and
544
+ the physics constraints omitting `inertia`/`damping`, whose default is not the same
545
+ number in the two. A label naming no generation rigc knows — or a header stating none —
546
+ is a `BLOCK GENERATION_UNKNOWN`, never rounded to the nearest: a catalog that rounded
547
+ handed 19 skeletons labelled `3.8.99` a 4.2 runtime and every one of them posed as NaN
548
+ (row 7). Reading a file with *that generation's own* defaults is #706's item 2 and is
549
+ not in this tool — re-export as 4.3, or transcribe by hand ([INGEST.md](INGEST.md) §2).
550
+
503
551
  📝 **Do not delete the `note`.** Both written specs carry one saying the file is
504
552
  decompiled and naming the skeleton it came from. A decompiled spec is
505
553
  indistinguishable from an authored one by inspection, every gate here calls it green —
@@ -516,7 +564,7 @@ The other commands:
516
564
  ```bash
517
565
  bun cli.ts explain --rig … --motion … --out … # the compiled rig as a table
518
566
  bun cli.ts validate path/to/spine # re-gate artifacts already on disk
519
- bun cli.ts diff candidate.json reference.json
567
+ bun cli.ts diff candidate.json reference.json [--as <candidate>=<reference>]…
520
568
  bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin …]
521
569
  bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
522
570
  bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
@@ -550,6 +598,19 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
550
598
  deliberately does not combine them into a score: a rig with the right skeleton
551
599
  and the wrong timing and a rig with the right timing and the wrong skeleton call
552
600
  for opposite fixes. A measure with nothing to compare says `0/0` and says so.
601
+
602
+ ⭐ **If your animation is not called what the reference's is, say so with
603
+ `--as <candidate>=<reference>`** (repeatable, one pair each) — or let it pair by
604
+ position, which happens with no flag when each side carries exactly one
605
+ animation. Without a pairing every animation measure but `count` is keyed on the
606
+ name, so a rig whose shot is right down to the key counts reads **0.000** across
607
+ the section. The pairing gets `animations` the same two figures `bones` and
608
+ `slots` carry, and reading them as a pair — name-agnostic 1.000 beside `names`
609
+ 0.000 — is what says the shot is right and the name is yours. The measures, the
610
+ refusals and what makes the block absent instead of guessed:
611
+ [INGEST.md](INGEST.md) §1.3.1, and *The measure inventory* in
612
+ [BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md),
613
+ which is repository material rather than part of this package.
553
614
  - **`check`** renders your candidate into the reference frames' own pixel grid and
554
615
  compares pixels — the only thing here that can see a wrong animation. **§9.**
555
616
  🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
@@ -1064,6 +1125,22 @@ loader's sentence and all the report had. Since
1064
1125
  `A08_REGION_NAMES_MATCH_ATTACHMENTS`, with the skin, the slot, the placeholder
1065
1126
  and the attachment's own name beside the path.
1066
1127
 
1128
+ Where each vertex sits on the **art**:
1129
+
1130
+ | Field | Meaning |
1131
+ | --- | --- |
1132
+ | `uvs` | one `u, v` per vertex, in the order the geometry below uses. Both are fractions of the region rather than pixels, and the array's length is what fixes the vertex count — a mesh states no `vertexCount`. **`(0, 0)` is the region's top-left pixel and `(1, 1)` its bottom-right**, so `u` grows toward the right edge and `v` grows *downward* toward the bottom edge: the crop-pixel convention §11.2 states for a manifest, and not Spine's y-up world. What a **turned** region (`rotate: 90`, in a pack somebody else made) does to that is §0.2's subject |
1133
+
1134
+ ⭐ **Measured, because an assumption about a corner is invisible in a build that
1135
+ went green.** On a 64×48 plate whose four quadrants are four flat colours,
1136
+ `rigc render` drew a quad given `uvs` running `0 → 0.5` on both axes as 3,120 px
1137
+ of the **top-left** quadrant's colour and not one pixel of any other, and the same
1138
+ quad given `0.5 → 1` as 3,120 px of the **bottom-right**. Beside it, the runtime's own
1139
+ `MeshAttachment.computeUVs`, handed the emitted atlas region, put `(0, 0)` at page
1140
+ pixel `(0.00, 0.00)` and `(1, 1)` at `(64.00, 48.00)` — the region's own two
1141
+ corners, in the page's y-down pixels. `CUR44` in the selftest holds the sentence
1142
+ above against that second measurement.
1143
+
1067
1144
  Geometry comes in one of two fields:
1068
1145
 
1069
1146
  | Field | Meaning |
@@ -1220,7 +1297,12 @@ the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`). Measured
1220
1297
  a forged skeleton — a link declaring 5 uvs, 3 triangles, `hull: 5` and
1221
1298
  `edges: [0, 2]` beside a 4-vertex source loaded with the **source's** 8-long
1222
1299
  `worldVerticesLength`, 6 triangles, `hullLength` 8 and 10 edges. Nothing the author
1223
- wrote reached anything and nothing said so.
1300
+ wrote reached anything and nothing said so. ⇒ The same fact is held against a
1301
+ skeleton rigc did **not** write, where the compiler never sees the spec:
1302
+ `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` (§5.2) names the attachment, the
1303
+ keys and whose geometry is drawn instead, and `ingest` reports one as
1304
+ `ATTACHMENT_LINK_GEOMETRY` before dropping it
1305
+ ([INGEST §2.0](INGEST.md)) — [#710](https://github.com/firejune/rigc/issues/710).
1224
1306
 
1225
1307
  🚫 **A chain is refused, and so is a link to itself.** A `source` that names
1226
1308
  another linked mesh resolves in the order the file was read: measured through
@@ -1245,6 +1327,105 @@ and uvs. `A13_MESH_BUDGET` counts it as a mesh of its own: the runtime draws it
1245
1327
  one, so a link in a second slot is a second mesh slot against
1246
1328
  `invariants.meshSlots`.
1247
1329
 
1330
+ **The three types that carry geometry and no art** — a bounding box, a clipping
1331
+ polygon and a path. None of them resolves an atlas region, so none of them has an
1332
+ `image`, a `path` key, a `width`/`height` or uvs, and a skin holding only these
1333
+ builds an atlas with **no pages** (above). What they do share is one geometry
1334
+ shape, and it is the mesh's with one field added:
1335
+
1336
+ - 🚨 **`vertexCount` is required and has no parser default.** A mesh takes its
1337
+ count from `uvs.length`; these have no uvs, so the parser reads
1338
+ `map.vertexCount << 1` as the length to expect — and with the field absent that
1339
+ is `undefined << 1` = **0**, which sends `readVertices` down the WEIGHTED branch,
1340
+ decodes the coordinate list as a weight run, and hands back an attachment with no
1341
+ vertices at all. Nothing throws, and none of the three draws a pixel, so nothing
1342
+ downstream notices. rigc refuses it by name: `vertexCount is undefined; a polygon
1343
+ needs at least 3 vertices, stated outright`.
1344
+ - **The two encodings are the mesh's own**, traps included: `weights` binds bones
1345
+ by NAME and is the form to use; `vertices` is an unweighted `x, y` run when
1346
+ `vertices.length === vertexCount * 2` and Spine's index-encoded weighted run
1347
+ otherwise, and the second of those needs `"boneIndexing": "raw"` said out loud.
1348
+ `A33_VERTEX_ATTACHMENT_GEOMETRY` (§5.2) gates all three types and accepts either
1349
+ encoding on each — measured by building both on each of the three.
1350
+ - 📐 **Which space the numbers are in.** An unweighted `x, y` is in the **slot's
1351
+ bone's** local space; a `weights` binding's `x`/`y` is in **that binding's own
1352
+ bone's** local space, and the slot's bone plays no part in it. Measured through
1353
+ spine-core on a rig where the two are different bones: `(0, 0)`, `(30, 0)`,
1354
+ `(30, 20)` written unweighted on a slot whose bone sits at `(-60, 25)` turned
1355
+ −21° posed at `(-60.0000, 25.0000)`, `(-31.9926, 14.2490)` and
1356
+ `(-24.8252, 32.9206)`, which is that bone's own transform of them; the same three
1357
+ points bound by name to a bone at `(110, 70)` turned 37° and scaled `1.3, 0.8`,
1358
+ in the very same slot, posed at `(110.0000, 70.0000)`, `(141.1468, 93.4708)` and
1359
+ `(131.5177, 106.2490)` — the BOUND bone's transform, to four decimals.
1360
+ - **A `deform` timeline reaches all three** (§4.11); none of them is drawn, so a
1361
+ render can show you nothing about any of them.
1362
+
1363
+ **Bounding box** ([Spine: bounding boxes](http://esotericsoftware.com/spine-bounding-boxes)) —
1364
+ `"type": "boundingbox"`. A polygon the game hit-tests against — a hurt box, a pick
1365
+ region, a trigger volume — that moves with the skeleton and draws nothing.
1366
+
1367
+ | Field | Meaning |
1368
+ | --- | --- |
1369
+ | `type` | `"boundingbox"`. **Required**: an omitted `type` is `"region"`, and a region has nowhere to put these keys — measured, the refusal reads `attachment "mask" (region) has 2 keys this compiler does not read: "vertexCount", "vertices"` |
1370
+ | `vertexCount` | **required**, 3 or more. No default — the paragraph above is why |
1371
+ | `vertices` | the unweighted `x, y` run, in the slot bone's local space; or the index-encoded weighted run, behind `boneIndexing` |
1372
+ | `weights` | the by-name form: one entry per vertex, each a list of `{ "bone": …, "x": …, "y": …, "weight": … }`, each pair in that bone's local space. Never beside `vertices` |
1373
+ | `boneIndexing` | `"name"` (the default) or `"raw"`, which opts a weighted `vertices` run into Spine's index encoding. `"raw"` beside `weights` is refused: `weights` always binds by name |
1374
+ | `color` | `rrggbbaa`. **No default in the file** — omitted, the parser never calls `setFromString` and the attachment keeps the runtime's own colour. It is an editor affordance, the colour the polygon is drawn in there; rigc emits it verbatim when stated and leaves the key out when not |
1375
+
1376
+ **Clipping polygon** ([Spine: clipping](http://esotericsoftware.com/spine-clipping)) —
1377
+ `"type": "clipping"`. A mask: the polygon clips every slot drawn from the one
1378
+ carrying it up to and including `end`, so a window, a portal or a wipe is one
1379
+ attachment rather than a second set of art.
1380
+
1381
+ | Field | Meaning |
1382
+ | --- | --- |
1383
+ | `type` | `"clipping"`. **Required** |
1384
+ | `vertexCount`, `vertices`, `weights`, `boneIndexing`, `color` | exactly as on a bounding box |
1385
+ | `end` | the last slot the clip applies to, **by name**. Absent is the parser's own encoding for *clip everything after this one*, which is why a typo cannot be told from an omission once the file is loaded: `findSlot` returns null on a miss and the parser assigns that null without a word, so the clip runs to the bottom of the draw order and takes every slot below it with it. rigc refuses a name the rig does not declare — `end names slot "X", which this rig does not declare` — and `A33` refuses it again on a skeleton rigc did not write |
1386
+ | `convex` | default **false**. True tells the runtime the polygon is convex so it can clip without triangulating it, and a polygon that deforms concave is clipped by its convex hull instead (`ClippingAttachment.convex`). Nothing here checks that the polygon is in fact convex |
1387
+ | `inverse` | default **false**. True makes everything **outside** the polygon visible instead of everything inside, and inverse clipping is always treated as convex (`ClippingAttachment.inverse`) |
1388
+
1389
+ ⚠️ **A clipping attachment is refused by the renderer profile and by nothing
1390
+ else.** `A11_NO_CLIPPING_ATTACHMENTS` (§5.2) fires under `--profile spine-html`
1391
+ because that renderer skips clipping silently; it is one renderer's policy rather
1392
+ than anything about the data, and the default `spine` profile builds one.
1393
+
1394
+ **Path** ([Spine: paths](http://esotericsoftware.com/spine-paths)) —
1395
+ `"type": "path"`. The composite cubic Bezier a path **constraint** (§3.5.1) slides
1396
+ bones along. No runtime draws it, and it deforms with the slot's bone like any
1397
+ other vertex attachment.
1398
+
1399
+ | Field | Meaning |
1400
+ | --- | --- |
1401
+ | `type` | `"path"`. **Required** |
1402
+ | `vertexCount`, `vertices`, `weights`, `boneIndexing`, `color` | as on a bounding box — except that these vertices are knots **and** their handles, which the count rule below is about |
1403
+ | `closed` | default **false**. True joins the last knot back to the first |
1404
+ | `constantSpeed` | default **true** — note the direction. Leaving it out asks for the expensive-and-correct traversal, in which the runtime re-measures the path every frame and `lengths` is never read. `false` makes the runtime trust the emitted `lengths` instead: cheaper, exact only while the path holds its setup shape, and the reason a deformed path wants the default |
1405
+ | `lengths` | 🚫 **refused by name.** rigc measures the setup length of each curve off the geometry and emits it, the way it measures a region's size off its PNG: `"lengths" is not authored — rigc measures the setup arc length of each curve…`. The field is declared only so the refusal can say that rather than report a misspelt key. What the numbers are — and why *arc length* is the wrong name for them — is §10.6 |
1406
+
1407
+ 🚨 **`vertexCount` counts knots AND handles, and it has to be a multiple of 3.**
1408
+ The parser hands `vertexCount << 1` to `readVertices` and then walks the result in
1409
+ groups of six: the first and last points are the outer control handles of the end
1410
+ knots and are dropped, leaving a `3K + 1` chain. So an OPEN path of K curves states
1411
+ `vertexCount = 3(K + 1)`, minimum **6**, and a CLOSED one states `3K`, minimum
1412
+ **3**. A count that is not a multiple of 3 does not throw —
1413
+ `Utils.newArray(vertexCount / 3, 0)` accepts a fractional size, the groups of six
1414
+ then straddle the knots, and the constraint slides bones along a curve nobody drew
1415
+ — so rigc refuses both shapes: `vertexCount is N, which is not a multiple of 3`
1416
+ and `vertexCount is N and an open path needs at least 6`.
1417
+
1418
+ ```json
1419
+ "track": { "track": { "type": "path", "vertexCount": 9,
1420
+ "vertices": [-30, 0, 0, 0, 30, 0, 60, 0, 90, 0, 120, 0, 150, 0, 180, 0, 210, 0] } }
1421
+ ```
1422
+
1423
+ Nine points are two curves. The outer handles at `x = -30` and `x = 210` are
1424
+ dropped, so the chain runs from `x = 0` to `x = 180` and rigc emits
1425
+ `"lengths": [90, 180]` beside it — measured, not stated. That is the path
1426
+ §3.5.1's constraint example rides: with `position: 0.25` its `cart` bone poses at
1427
+ `worldX = 45.000000`.
1428
+
1248
1429
  The generators are `ring`, `ribbon`, `contour` and `grid` (see
1249
1430
  [`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
1250
1431
  than a table of numbers, which is why they are code invoked by data. The last two
@@ -4769,7 +4950,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4769
4950
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
4770
4951
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) — as it is for `A06`, `A19` and `A27`; see `A07` ([#608](https://github.com/firejune/rigc/issues/608)) |
4771
4952
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
4772
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)) **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4953
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
4773
4954
  | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not ([#684](https://github.com/firejune/rigc/issues/684)). Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
4774
4955
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1 |
4775
4956
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
@@ -4794,6 +4975,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
4794
4975
  | `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first ([#540](https://github.com/firejune/rigc/issues/540)). The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
4795
4976
  | `A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER` | both | a slider whose animation keys a property of a constraint **at or before it** in `constraints` (§3.5.2) — a slider's `mix` or `time`, an ik or transform mix, a path `position`, `spacing` or `mix`, any physics value. That array is the update order for every kind, and each constraint reads its own applied pose when its turn comes — `Slider.update` takes `mix` as the alpha it applies with and `time` as the time it applies at, `PhysicsConstraint.update` returns on `mix` 0 before reading the rest — so the key lands after the only read of it and `Posed.resetConstrained` discards it before the next frame: what the driven constraint drives is dead at every position of the driving dial, although its pose still holds the number ([#658](https://github.com/firejune/rigc/issues/658), [#665](https://github.com/firejune/rigc/issues/665)). The detail names the slider, the driven constraint with its kind, both array indices, the property, the runtime class whose `update` reads it, and the animation the key sits in. Fix by moving the driver earlier, or by keying that property from a slider that already is. **The two indices equal is the same failure**: a slider cannot key its own `mix` or `time`, and one muted at setup that keys its own `mix` up never applies anything at all — `A37` is silent there, because it asks whether *an* animation keys the mix and not which one. **Two shapes it deliberately leaves out**, both measured: a `physics` `reset` key, which fires on a crossed frame time and so never fires from a slider at all, in either order — the reorder would repair nothing; and a physics timeline naming no constraint, which is every physics constraint declaring that property global and IS refused for the ones already run. Disjoint from `A40` by construction: `A40` asks who writes a shared property last and excludes every slider whose `mix` is keyed, this asks whether anything reads what was written. **SKIP** when the skeleton declares no slider, and when no slider's animation keys a constraint property — that SKIP names any `reset` keys it found — a pass means a driver and a driven were compared |
4796
4977
  | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` — there is then no two-colour tint to read back |
4978
+ | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read ([#710](https://github.com/firejune/rigc/issues/710)) |
4797
4979
 
4798
4980
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
4799
4981
  clauses are gated by profile.
@@ -7308,7 +7490,7 @@ Per part:
7308
7490
  | `ambiguous` | at least one alternate is inside the ambiguity margin. **Choose with something this instrument cannot see** — anatomy, the other frame, or `rigc vote` |
7309
7491
  | `rotationFree` | the part is self-similar under rotation, so `rotationDeg` is a placeholder and the value is yours |
7310
7492
  | `rotationSelfSimilarity` | the number `rotationFree` is a threshold on. A part just over the line is worth a look |
7311
- | `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part` |
7493
+ | `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part`. A `no-match` whose best placement stopped **on a wall of the search window** names the wall in its detail — see §11.4 |
7312
7494
  | `coarse` | the grid this part was actually searched on. A handful of cells means the part is small relative to the frame and the first pass had little to go on |
7313
7495
  | `notes` | the same facts in prose, in the order they were found |
7314
7496
 
@@ -7326,6 +7508,16 @@ The report also carries `frame.background` (how the picture's empty space was
7326
7508
  identified — a flat colour, transparency, or `unknown`), `search` (every window
7327
7509
  and threshold that was applied), and `caveats`.
7328
7510
 
7511
+ 🔒 **`search` states what ran, not what was asked for.** `search.rotation` carries
7512
+ `degrees`, the ladder the coarse pass actually walked, and its `stepDeg` is read
7513
+ off that ladder rather than off the constant the ladder was capped at. The coarse
7514
+ step `15°` is a **ceiling** on the step, not the step: a narrower window is
7515
+ divided into whole steps no coarser than it, so
7516
+ `--rotation -5,5` prints `rotation -5°–5° in 2 step(s) of 10°` and walks those two
7517
+ angles and no others. Nothing reported leaves the window either — the refinement
7518
+ and the quarter-turn probes are held inside it, and a window spanning a full turn
7519
+ holds nothing because it already contains every angle.
7520
+
7329
7521
  ### 11.4 What it cannot see — read this before using the numbers
7330
7522
 
7331
7523
  - ⚠️ **Residuals degrade under occlusion, and there is no depth solver here.** A
@@ -7341,6 +7533,16 @@ and threshold that was applied), and `caveats`.
7341
7533
  answer is the best placement available *inside* `--scale` / `--rotation` and its
7342
7534
  residual can look reasonable. This is why the window is a reported field: if the
7343
7535
  numbers surprise you, check `search` before you trust them.
7536
+ - 🔒 **A refusal that stopped on a wall of the window says which wall.** When a
7537
+ `no-match`'s best placement sits on the floor or the ceiling of `--scale`, or on
7538
+ an edge of a `--rotation` window narrower than a full turn, the detail names it:
7539
+ `best placement at scale 0.500, the floor of --scale 0.5,2 — the truth may lie below the window`.
7540
+ That is the case where the window is the first thing to move rather than the
7541
+ frame or the threshold — eleven parts of one frame came back refused at
7542
+ `scale=0.500` against art rendered at `0.311` per part pixel, and the message
7543
+ said only that the residual was above `--max-residual`. It is printed on a
7544
+ **refusal and nowhere else**: an accepted placement sitting on a wall is a window
7545
+ chosen to bracket the answer, which is the flag working.
7344
7546
  - ⚠️ **A frame whose border has no dominant colour reports `background.unknown`.**
7345
7547
  Every pixel then counts as material, the silhouette signal is gone, and the
7346
7548
  residual is colour agreement alone. The report says so rather than being quietly