spine-rigc 0.25.4 → 0.25.6

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
@@ -146,15 +146,17 @@ because they are written from a clone of this repository (`bun install`, then ru
146
146
  the CLI in place); the two are interchangeable — `rigc build …` is
147
147
  `bun cli.ts build …`.
148
148
 
149
- Two commands are repository workflows rather than package ones: `bench` and
150
- `check` measure against Spine's official example projects — fetched, never
151
- committed — and against reference frames this project renders from them, which
152
- **are** committed, each example's own `license.txt` beside them under the
153
- redistribution grant those files carry; the images stay **non-commercial only**.
154
- The reasoning is in
149
+ One command is a repository workflow rather than a package one: `bench` measures
150
+ against Spine's official example projects — fetched, never committed — and against
151
+ reference frames this project renders from them, which **are** committed, each
152
+ example's own `license.txt` beside them under the redistribution grant those files
153
+ carry; the images stay **non-commercial only**. The reasoning is in
155
154
  [`bench/reference/README.md`](https://github.com/firejune/rigc/blob/main/bench/reference/README.md)
156
- and the terms in [NOTICE.md](NOTICE.md). Both commands need a clone and `bun run
157
- fetch-examples`, and say so by name when the corpus is absent.
155
+ and the terms in [NOTICE.md](NOTICE.md). It needs a clone and `bun run
156
+ fetch-examples`, and says so by name when the corpus is absent. `check` is not one
157
+ of them: it reads whatever frames you point it at, so it runs from the installed
158
+ package on pictures of your own — which is what *Where to go next* below tells you
159
+ to do with it, and it is the one instrument here that can see a wrong animation.
158
160
 
159
161
  ### Install it into your agent
160
162
 
@@ -405,6 +407,17 @@ that does not belong to it. See
405
407
  could. If you have reference pictures of the shot,
406
408
  `rigc check --candidate spine --frames <dir>` is the half of the loop that can
407
409
  see a wrong animation — AUTHORING.md §9.
410
+ - 🧭 **No reference pictures, because the rig is your own?** Then make them:
411
+ `rigc render` the first build you are happy with and keep those frames. Every
412
+ later build is checked against them, and the first such check — the same build
413
+ against frames of itself — is the floor the rest are read against, because
414
+ `check` grades nothing and has no pass mark. It is the same instrument and the
415
+ same commands; what changes is that the reference is a build of yours you have
416
+ already looked at, so what it measures is **what your edit did**. Every drift
417
+ the report prints carries the bound its own match gives it on the line under
418
+ it, so a figure is read against that rather than against a number from a page;
419
+ AUTHORING.md §9.2 says what the two halves of that bound are and why the floor
420
+ is not zero.
408
421
  - [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) is the benchmark: the same job, from a brief
409
422
  and rendered frames, scored. [docs/PILOT.md](https://github.com/firejune/rigc/blob/main/docs/PILOT.md) is how to run an
410
423
  agent through it and score what comes back.
@@ -511,19 +524,24 @@ commands take it and what its default is.
511
524
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
512
525
  | `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies. A bone with two or more anchored descendants is **determined** rather than searched, and the residual that over-determination leaves is reported |
513
526
  | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
527
+ | `bonedist --candidate … --reference … --bones …` | per-frame, per-bone world-transform distance against another skeleton — the ladder's stage 3, run on its own. `--bones <correspondence.json \| identity>` is required rather than defaulted: a candidate is entitled to its own bone names, so the pairing is stated |
514
528
  | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
515
529
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
516
530
 
517
- `diff`, `check` and `bench` measure against something you were given; the first two
518
- work on any frames you have, and `bench` is a repository workflow that needs a clone
519
- and `bun run fetch-examples`. The reasoning behind all three is in
531
+ `diff`, `bonedist`, `check` and `bench` measure against something you were given; the
532
+ first three work on any reference you have, and `bench` is a repository workflow that needs a clone
533
+ and `bun run fetch-examples`. The reasoning behind them is in
520
534
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
521
535
 
522
536
  `build` and `validate` both default to `--profile spine` — the 28 validity rules, which
523
537
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
524
538
  adds all 43: the other 15 are one renderer's policy and one canvas budget's, and they
525
- fire on perfectly correct editor-produced Spine data, so reach for that profile when
526
- you are shipping into *that* project rather than to be thorough. A report always names
539
+ fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
540
+ ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
541
+ yourself: author under `--profile spine-html` and read the extra 15 as findings, and
542
+ gate the release under `--profile spine`.** A `deform` key that folds a mesh inside
543
+ out is written out under the default and refused by name under `spine-html`, which
544
+ is the shape of what that split buys you. A report always names
527
545
  the profile it ran and lists what that profile left out.
528
546
 
529
547
  Several cuts can also be registered in a `cuts.json` and built by name
@@ -666,6 +684,7 @@ letting `A17` blame the editor for the harness's own doing.
666
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 43 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 |
667
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 |
668
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
+ | 📐 [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 |
669
688
 
670
689
  ## Why you can trust the output
671
690
 
package/cli.ts CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  type BoneDistReport,
58
58
  } from './src/bonedist.ts';
59
59
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
60
- import { compile, CompileError, relativeImagesPath, type CompileOptions } from './src/compile.ts';
60
+ import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
61
61
  import {
62
62
  skeletonDataFromText,
63
63
  surveyDeformKeys,
@@ -67,9 +67,9 @@ import {
67
67
  type DeformSpan,
68
68
  } from './src/deformmeasure.ts';
69
69
  import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
70
- import { ingest, IngestError, type IngestFindingKind, type IngestStage } from './src/ingest.ts';
71
- import { copyAtlasImages } from './src/emit.ts';
72
- import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
70
+ import { ingest, IngestError, INGEST_GUTTERS, type IngestStage } from './src/ingest.ts';
71
+ import { copyAtlasPages } from './src/emit.ts';
72
+ import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas, parseAtlasText } from './src/atlas.ts';
73
73
  import { parseJsonWithPosition } from './src/json-position.ts';
74
74
  import { KEY_TIME_EPSILON } from './src/timelines.ts';
75
75
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
@@ -117,6 +117,7 @@ import {
117
117
  } from './src/render.ts';
118
118
  import {
119
119
  assertionCountForProfile,
120
+ attachmentRegionJoins,
120
121
  CLI_DEFAULT_PROFILE,
121
122
  reportLines,
122
123
  validate,
@@ -125,7 +126,7 @@ import {
125
126
  } from './src/validate.ts';
126
127
  import { parseMotionSpec } from './src/motion.ts';
127
128
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from './src/depth.ts';
128
- import type { CompileResult } from './src/types.ts';
129
+ import type { CompileResult, DroppedState } from './src/types.ts';
129
130
 
130
131
  /**
131
132
  * One entry of a cuts.json, every path relative to the cuts.json file.
@@ -148,6 +149,14 @@ export type CutTable = Record<string, CutEntry>;
148
149
 
149
150
  class UsageError extends Error {}
150
151
 
152
+ /**
153
+ * `explain` refusing a pair it cannot pose — a usage error in kind, printed
154
+ * without the usage block for `PoseError`'s and `IngestError`'s reason: the
155
+ * message names an attachment, a region and two flags, and reprinting every
156
+ * command's usage under it buries the one line that says what to change.
157
+ */
158
+ class ExplainError extends Error {}
159
+
151
160
  // ---------------------------------------------------------------------------
152
161
  // package metadata — the installed version and repository, for `--version`
153
162
  // and for naming a remedy `bench` can only give from a repo checkout.
@@ -1133,6 +1142,19 @@ function readIntFlag(flags: Record<string, string>, name: string, fallback: numb
1133
1142
  return Number(raw);
1134
1143
  }
1135
1144
 
1145
+ /**
1146
+ * One `DROP` line, written once because two outcomes print it.
1147
+ *
1148
+ * A build that succeeds prints it in its report; a build that REFUSES prints it
1149
+ * under the refusal (issue #671), and the two have to be the same line or the
1150
+ * failing run would be quoting a different fact from the one the green run
1151
+ * shows. What it names — a file or a region — is `droppedStateReason`'s, in the
1152
+ * compiler, beside the code that decided which of the two was consulted.
1153
+ */
1154
+ function dropLine(dropped: DroppedState): string {
1155
+ return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
1156
+ }
1157
+
1136
1158
  function cmdBuild(flags: Record<string, string>): void {
1137
1159
  const { label, opts } = resolveCut(flags);
1138
1160
  const profile = readProfile(flags);
@@ -1195,9 +1217,7 @@ function cmdBuild(flags: Record<string, string>): void {
1195
1217
  : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`);
1196
1218
  console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
1197
1219
  }
1198
- for (const d of result.droppedStates) {
1199
- console.log(` DROP ${d.slot}/${d.state}: ${d.why ?? `no PNG at ${d.path}`} (state not emitted)`);
1200
- }
1220
+ for (const d of result.droppedStates) console.log(dropLine(d));
1201
1221
  // "The optional slots are optional" is a claim about this code path, so this
1202
1222
  // code path says which ones it left out rather than being silently right.
1203
1223
  for (const a of result.absentParts) {
@@ -1231,14 +1251,21 @@ function cmdBuild(flags: Record<string, string>): void {
1231
1251
  // correct for a build sitting beside the project it came from and breaks the
1232
1252
  // moment the directory is zipped, committed or moved on its own (issue #217).
1233
1253
  // Opt-in only: the default stays exactly what it has always been.
1254
+ //
1255
+ // What is copied is what the ATLAS names, not what the image list holds: under
1256
+ // `--atlas-in` the two are different lists, and rebuilding the text from the
1257
+ // second wrote a file the pack never contained — zero bytes for a rig that
1258
+ // declares no parts, one fabricated page per part for a rig that does, both of
1259
+ // them green here because the gate above had already read the compile's own
1260
+ // text (issue #693, `src/emit.ts`).
1234
1261
  let atlasText = result.atlasText;
1235
1262
  if (flags['copy-images'] !== undefined) {
1236
- const copied = copyAtlasImages(result.images, opts.outDir);
1263
+ const copied = copyAtlasPages(atlasText, opts.outDir);
1237
1264
  atlasText = copied.atlasText;
1238
1265
  console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1239
1266
  for (const p of copied.pages) {
1240
- const note = p.to === basename(p.from) ? '' : ` (renamed from ${basename(p.from)} — basename collision)`;
1241
- console.log(` .. ${p.region.padEnd(24)} <- ${p.to}${note}`);
1267
+ const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
1268
+ console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
1242
1269
  }
1243
1270
  }
1244
1271
 
@@ -2423,6 +2450,81 @@ function cmdBoneDist(flags: Record<string, string>): void {
2423
2450
  if (flags.json !== undefined) writeJson(flags.json, report);
2424
2451
  }
2425
2452
 
2453
+ /**
2454
+ * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2455
+ *
2456
+ * 🚨 `explain` poses the rig — `deformReportLines` loads the emitted pair through
2457
+ * `spine-core` to measure what each deform key did — and a pose resolves EVERY
2458
+ * attachment against the atlas, whether or not anything deforms it. On the specs
2459
+ * `ingest --art none` writes there is nothing to resolve against: the entries
2460
+ * state a size and name no `image`, so the compile atlases nothing, and the load
2461
+ * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
2462
+ * rear-upper-arm)` with a spine-core stack trace under it and exit 1 (measured on
2463
+ * `examples/spineboy/export/spineboy-ess.json`, issue #697). That is the tool
2464
+ * telling an agent about its own internals instead of about the rig, on the one
2465
+ * input `docs/INGEST.md` §2.0 documents as the route through a foreign skeleton.
2466
+ *
2467
+ * ⭐ Both halves of the join are rigc's own readers rather than a second opinion
2468
+ * on somebody else's format: the walk is `attachmentRegionJoins`, which `PS127`
2469
+ * measures against the loader's own `findRegion` calls, and the region names come
2470
+ * from `parseAtlasText`, which is what `--atlas-in` already resolves against.
2471
+ * Names are compared EXACTLY — as `A08` compares them and as `findRegion`
2472
+ * matches them — so a padded region name is a miss on both sides.
2473
+ *
2474
+ * ⚠️ What it deliberately does not do is catch the pose. A blanket `try` around
2475
+ * `skeletonDataFromText` would convert any exception the runtime raises into a
2476
+ * sentence claiming the cause is missing art, and a rigc defect reported under
2477
+ * somebody else's name is the doctrine's second bullet inverted. This refuses
2478
+ * the case it can NAME, before a line of the report is printed, and leaves
2479
+ * anything else to arrive as itself.
2480
+ */
2481
+ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2482
+ const regionNames = parseAtlasText(result.atlasText).pages.flatMap((page) => page.regions.map((region) => region.name));
2483
+ const have = new Set(regionNames);
2484
+ const misses: Array<{ at: string; attachment: string; lookup: string }> = [];
2485
+ let lookups = 0;
2486
+ for (const join of attachmentRegionJoins(JSON.parse(result.skeletonText))) {
2487
+ // A `sequence` this walk will not guess at names no region it can check, and
2488
+ // `A08` passes over it for the same reason.
2489
+ if (join.lookups === null) continue;
2490
+ for (const lookup of join.lookups) {
2491
+ lookups++;
2492
+ if (!have.has(lookup)) {
2493
+ misses.push({
2494
+ at: `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`,
2495
+ attachment: join.name,
2496
+ lookup,
2497
+ });
2498
+ }
2499
+ }
2500
+ }
2501
+ if (misses.length === 0) return;
2502
+ const first = misses[0];
2503
+ // Reported because it was measured, and absent where there is none — `A08`'s
2504
+ // own near-miss clause, in `A08`'s own words.
2505
+ const near = regionNames.find((region) => region.trim().toLowerCase() === first.lookup.toLowerCase());
2506
+ const has =
2507
+ regionNames.length === 0
2508
+ ? 'it declares no region at all'
2509
+ : `it declares ${regionNames.length} region(s) and none of them is that${
2510
+ near === undefined ? '' : `, though it does have ${JSON.stringify(near)}`
2511
+ }`;
2512
+ const remedy =
2513
+ opts.atlasInPath === undefined
2514
+ ? 'Art reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>` resolves the parts ' +
2515
+ 'against a pack somebody already made, and an "image" per attachment resolves them as loose PNGs under ' +
2516
+ '`--images <dir>` — a spec that states a size and names no image is what `ingest --art none` writes, and ' +
2517
+ '`--atlas-in` is what reads it'
2518
+ : `Either the spec's region name or ${opts.atlasInPath} is the one that moved: fix the name, or point ` +
2519
+ '`--atlas-in` at the pack that has it';
2520
+ throw new ExplainError(
2521
+ `${first.at}: attachment ${JSON.stringify(first.attachment)} wants region ${JSON.stringify(first.lookup)}, ` +
2522
+ `which this build's atlas does not have (${has}). \`explain\` poses the rig to measure its deform keys and a ` +
2523
+ `pose resolves every attachment against the atlas, so there is nothing to pose it against. ${remedy}. ` +
2524
+ `${misses.length} of ${lookups} attachment lookup(s) here resolve to no region.`,
2525
+ );
2526
+ }
2527
+
2426
2528
  function cmdExplain(flags: Record<string, string>): void {
2427
2529
  const { label, opts } = resolveCut(flags);
2428
2530
  console.log(`rigc explain ${label}`);
@@ -2431,6 +2533,13 @@ function cmdExplain(flags: Record<string, string>): void {
2431
2533
  console.log(` .. rig ${opts.rigPath}`);
2432
2534
  console.log(` .. motion ${opts.motionPath}`);
2433
2535
  const result = compile(opts);
2536
+ // Before a line of the report, rather than at the pose two hundred lines in:
2537
+ // the blocks that need the art — DEFORM, meshes, dropped states — all sit
2538
+ // BELOW the pose, so a run that printed the bone and timeline dump and then
2539
+ // refused would be a report missing everything the missing art decides, with
2540
+ // the sentence saying so scrolled off the top. It is the invocation that has
2541
+ // to change, so it is refused before the report it cannot finish (issue #697).
2542
+ refuseUnposableArt(result, opts);
2434
2543
  // `compile` has already parsed this file, so the read below cannot fail — but
2435
2544
  // it goes through the same parser rather than a cast, because the cast was the
2436
2545
  // last one in the repository and issue #307 was about exactly that.
@@ -2720,8 +2829,12 @@ function cmdExplain(flags: Record<string, string>): void {
2720
2829
  }
2721
2830
 
2722
2831
  if (result.droppedStates.length) {
2723
- console.log('\ndropped states (listed in the manifest, no PNG on disk)');
2724
- for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${d.path}`);
2832
+ // The heading said "no PNG on disk" and the line printed the path, on a
2833
+ // command that takes `--atlas-in` like `build` does — so an explain of a
2834
+ // pack build named a file it never opened. Same renderer as the other two
2835
+ // printers now, for the same reason they share one (issue #671).
2836
+ console.log('\ndropped states (listed in the manifest, no art behind them)');
2837
+ for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${droppedStateReason(d)}`);
2725
2838
  }
2726
2839
 
2727
2840
  console.log('\nmix table (player config, not skeleton JSON)');
@@ -2829,10 +2942,13 @@ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
2829
2942
  // Grouped by kind rather than printed in discovery order: a blocker is what
2830
2943
  // decides the exit code, and a reader scanning for one should not have to
2831
2944
  // read past a hundred DURATION lines to find it.
2832
- const GUTTER: Record<IngestFindingKind, string> = { blocker: 'BLOCK', judgement: 'JUDGE', lossy: 'LOSS ' };
2945
+ // The gutter words are `INGEST_GUTTERS`, which is also the column
2946
+ // `docs/INGEST.md` §2.0's finding-code table is keyed on (issue #675); the
2947
+ // pad to one width is this printer's, so the codes line up.
2948
+ const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
2833
2949
  for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
2834
2950
  for (const finding of result.findings.filter((f) => f.kind === kind)) {
2835
- console.log(` ${GUTTER[kind]} ${finding.code}: ${finding.where} — ${finding.detail}`);
2951
+ console.log(` ${INGEST_GUTTERS[kind].padEnd(width)} ${finding.code}: ${finding.where} — ${finding.detail}`);
2836
2952
  }
2837
2953
  }
2838
2954
  console.log(`rigc: wrote ${join(outDir, 'rig.json')}`);
@@ -3049,6 +3165,18 @@ interface CommandDoc {
3049
3165
  * nine, and nothing had ever compared the two.
3050
3166
  */
3051
3167
  overrides?: Record<string, { value?: string; meaning?: string }>;
3168
+ /**
3169
+ * Lines printed under the flag table: what this command's own figures mean.
3170
+ *
3171
+ * ⚠️ Not a second place to describe a flag. It exists for what is true of the
3172
+ * **command** and of no flag it takes — and the case that earned it is issue
3173
+ * #678: `pose` and `chainfit` each say *"it is a reporting threshold, not a
3174
+ * pass bar"* on the flag that carries their threshold, and `check` has no such
3175
+ * flag, so its page said nothing at all about whether any of its figures is a
3176
+ * bar to beat. An agent reading `slot drift worst 3.7 px` off a correct rig had
3177
+ * no page to consult and no exit code to read it in.
3178
+ */
3179
+ notes?: string[];
3052
3180
  }
3053
3181
 
3054
3182
  const COMMANDS: CommandDoc[] = [
@@ -3078,8 +3206,20 @@ const COMMANDS: CommandDoc[] = [
3078
3206
  },
3079
3207
  {
3080
3208
  name: 'explain',
3081
- usage: ['rigc explain (same arguments as build, minus --profile — it never gates)'],
3082
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'cut', 'cuts'],
3209
+ usage: [
3210
+ 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] (it never gates, and writes nothing)',
3211
+ 'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3212
+ 'rigc explain --cut <name> --cuts <cuts.json>',
3213
+ ],
3214
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts'],
3215
+ notes: [
3216
+ 'this line said "the same arguments as build, minus --profile" and was false in both',
3217
+ 'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3218
+ 'command read what `ingest --art none` writes was reachable and undocumented, while',
3219
+ '--pack, --page-size, --padding and --copy-images are build\'s and do nothing here —',
3220
+ 'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3221
+ 'above, and that is now the whole of it.',
3222
+ ],
3083
3223
  },
3084
3224
  {
3085
3225
  name: 'validate',
@@ -3126,6 +3266,19 @@ const COMMANDS: CommandDoc[] = [
3126
3266
  'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3127
3267
  },
3128
3268
  },
3269
+ notes: [
3270
+ 'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
3271
+ 'grades, no number has to beat anything, and the exit code says only whether the',
3272
+ 'comparison could be MADE: 0 when it ran — including the build with every easing',
3273
+ 'reversed, which is the defect this command exists for — 1 when it could not (frames',
3274
+ 'that are not there, a skin the frames do not record, a candidate that will not load),',
3275
+ '2 on the flags.',
3276
+ '',
3277
+ 'So read a figure against a floor you measured yourself: render the first green build',
3278
+ 'and keep its frames, then check every later build against them. The identity run of',
3279
+ 'that pair is the floor, and it is not zero — docs/AUTHORING.md §9.2 states it, what',
3280
+ 'it comes from, and which column separates a wrong curve from a moved key.',
3281
+ ],
3129
3282
  },
3130
3283
  {
3131
3284
  name: 'bench',
@@ -3267,9 +3420,14 @@ function commandHelp(name: string): string {
3267
3420
  const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
3268
3421
  const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
3269
3422
  const width = Math.max(...labels.map((l) => l.length)) + 2;
3270
- return ['usage:', ...doc.usage.map((u) => ` ${u}`), '', 'flags:', ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`)].join(
3271
- '\n',
3272
- );
3423
+ return [
3424
+ 'usage:',
3425
+ ...doc.usage.map((u) => ` ${u}`),
3426
+ '',
3427
+ 'flags:',
3428
+ ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`),
3429
+ ...(doc.notes === undefined ? [] : ['', ...doc.notes]),
3430
+ ].join('\n');
3273
3431
  }
3274
3432
 
3275
3433
  const USAGE = [
@@ -3410,6 +3568,13 @@ try {
3410
3568
  }
3411
3569
  if (err instanceof CompileError) {
3412
3570
  console.error(`rigc compile error: ${err.message}`);
3571
+ // The drops the compile recorded before it stopped, in the same line the
3572
+ // green build prints (issue #671). They were reported from the compile
3573
+ // RESULT alone, so the run that failed BECAUSE a file was missing was the
3574
+ // one run that never named the file. On stderr with the refusal rather than
3575
+ // on stdout, so redirecting one stream does not separate a fact from the
3576
+ // sentence it explains.
3577
+ for (const dropped of err.droppedStates ?? []) console.error(dropLine(dropped));
3413
3578
  process.exit(1);
3414
3579
  }
3415
3580
  if (err instanceof CheckError) {
@@ -3427,6 +3592,13 @@ try {
3427
3592
  console.error(`rigc pose: ${err.message}`);
3428
3593
  process.exit(2);
3429
3594
  }
3595
+ // A refusal of the invocation, like the two below it, and exit 2 for the same
3596
+ // reason: nothing was posed and nothing was written, so it is the command line
3597
+ // that has to change (issue #697).
3598
+ if (err instanceof ExplainError) {
3599
+ console.error(`rigc explain: ${err.message}`);
3600
+ process.exit(2);
3601
+ }
3430
3602
  // Same kind as a PoseError, and printed the same way for the same reason: the
3431
3603
  // messages name a path, a bone or an attachment, and reprinting the whole
3432
3604
  // usage under them buries the one line that says what to change.