spine-rigc 0.25.0 → 0.25.1

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
@@ -503,7 +503,7 @@ commands take it and what its default is.
503
503
  | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
504
504
  | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
505
505
  | `validate <dir>` | re-gates artifacts already on disk |
506
- | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h` supplies the stage for a skeleton that declares none, and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
506
+ | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. `--stage x,y,w,h` supplies the stage for a skeleton that declares none — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
507
507
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
508
508
  | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
509
509
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
@@ -539,7 +539,7 @@ rebuild it. It is the only command that runs against `build`'s direction, and th
539
539
  only one whose contract is an equality rather than a rulebook:
540
540
 
541
541
  ```bash
542
- rigc ingest hero.json --out specs/ --stage 0,0,1024,768 --images parts/
542
+ rigc ingest hero.json --out specs/ --images parts/
543
543
  rigc build --rig specs/rig.json --motion specs/motion.json --out build/
544
544
  rigc diff build/skeleton.json hero.json
545
545
  ```
@@ -580,7 +580,10 @@ One thing it drops on purpose and says so: a path attachment's `lengths`, which
580
580
  - **The stage.** `skeleton.width`/`height`: rigc always writes one, and a skeleton that
581
581
  carries none is refused by name unless `--stage x,y,w,h` supplies it. It is not
582
582
  derivable — posing the rig gives the *animated* extent, which is a different number
583
- from the setup box. ⚠️ This said *"an editor export carries none"* until #594 measured
583
+ from the setup box. ⛔ **And `--stage` beside a box the file already states is refused
584
+ too**, for the opposite reason: two sources for one value, where the file is the record
585
+ of what was measured. It used to be read after the box and therefore never
586
+ ([#626](https://github.com/firejune/rigc/issues/626)). ⚠️ This said *"an editor export carries none"* until #594 measured
584
587
  it: **all twelve exports in the example corpus carry a stage** and none of them needs
585
588
  the flag. It is still the value that costs least to get wrong, because `diff` reports
586
589
  the box and gates nothing on it.
package/cli.ts CHANGED
@@ -67,7 +67,7 @@ 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, type IngestFindingKind, type IngestStage } from './src/ingest.ts';
70
+ import { ingest, IngestError, type IngestFindingKind, type IngestStage } from './src/ingest.ts';
71
71
  import { copyAtlasImages } from './src/emit.ts';
72
72
  import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
73
73
  import { parseJsonWithPosition } from './src/json-position.ts';
@@ -2965,7 +2965,9 @@ const FLAG_MEANINGS: Record<string, string> = {
2965
2965
  'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
2966
2966
  "is the caller's value, and without it the missing stage is reported as a blocker. ⚠️ An editor export MAY " +
2967
2967
  'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
2968
- 'so the flag is for a file that really has none rather than for editor exports as a class',
2968
+ 'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
2969
+ 'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
2970
+ 'file is the record of what was measured',
2969
2971
  help: "show this command's flags and exit",
2970
2972
  };
2971
2973
 
@@ -3310,11 +3312,12 @@ const USAGE = [
3310
3312
  'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
3311
3313
  'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
3312
3314
  'point instead of something to retype:',
3313
- ' rigc ingest hero.json --out specs/ --stage 0,0,1024,768 rig.json + motion.json',
3315
+ ' rigc ingest hero.json --out specs/ --images parts/ rig.json + motion.json',
3314
3316
  'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
3315
3317
  'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
3316
3318
  'atlas — so two things are the caller\'s and are refused rather than guessed: the',
3317
- 'setup stage (--stage, only when the skeleton itself declares none) and how the spec',
3319
+ 'setup stage (--stage, only when the skeleton itself declares none — beside a box the',
3320
+ 'file states, the flag is refused rather than ignored) and how the spec',
3318
3321
  'reaches the art (--art). --images <dir> is the third and the only optional one: it',
3319
3322
  'WRITES the rig spec\'s own images directory, relative to --out, so the rebuild needs',
3320
3323
  'no flag.',
@@ -3431,5 +3434,15 @@ try {
3431
3434
  console.error(`rigc chainfit: ${err.message}`);
3432
3435
  process.exit(2);
3433
3436
  }
3437
+ // A usage error in kind — an option that contradicts the file it was given —
3438
+ // and exit 2 for that reason rather than 1: nothing was compiled and nothing
3439
+ // was written, so it is the invocation that has to change (issue #626). It is
3440
+ // raised in `src/ingest.ts` rather than here because the library caller who
3441
+ // passes the same contradiction deserves the same refusal, and one rule in one
3442
+ // place is what stops the two from drifting apart.
3443
+ if (err instanceof IngestError) {
3444
+ console.error(`rigc ingest: ${err.message}`);
3445
+ process.exit(2);
3446
+ }
3434
3447
  throw err;
3435
3448
  }
package/docs/AUTHORING.md CHANGED
@@ -362,7 +362,7 @@ Everything above starts from two spec files you wrote. `rigc ingest` starts from
362
362
  an existing rig is a starting point instead of 250 KB of arrays to retype.
363
363
 
364
364
  ```bash
365
- bun cli.ts ingest hero.json --out specs/ --stage 0,0,1024,768 --images parts/
365
+ bun cli.ts ingest hero.json --out specs/ --images parts/
366
366
  # .. out /abs/path/specs
367
367
  # .. art loose
368
368
  # .. images ../parts/ (the rig spec's own, from /abs/path/specs)
@@ -397,7 +397,7 @@ repository builds on every run.
397
397
  | `--art loose` (default) | name an `image` per attachment — `<path or placeholder>.png` — so the rebuild resolves loose PNGs and rigc measures them |
398
398
  | `--art none` | state `width`/`height` only, so the rebuild is `build --atlas-in <pack.atlas>` and every part resolves out of the pack |
399
399
  | `--images <dir>` | **write** the rig spec's own `images` directory, spelled relative to `--out`, so the rebuild is a plain `build --rig … --motion … --out …`. Without it the field is left out and every `image` resolves against `--out` itself, which holds the specs and no art — so every rebuild has to repeat `build --images <dir>`. Refused together with `--art none`, which writes no `image` for it to be the base of |
400
- | `--stage x,y,w,h` | the setup bounding box, **for a skeleton that declares none**. An editor export *may* be one; every export under `examples/` carries a box and `ingest` reads it straight through |
400
+ | `--stage x,y,w,h` | the setup bounding box, **for a skeleton that declares none**. An editor export *may* be one; every export under `examples/` carries a box and `ingest` reads it straight through — so passing the flag at one of them is **refused**, naming both boxes, rather than silently doing nothing ([#626](https://github.com/firejune/rigc/issues/626)) |
401
401
  | `--name <n>` | the rig spec's `name`, which the motion spec's `archetype` must equal (default: the file's basename) |
402
402
 
403
403
  ⚠️ **`ingest --images` and `build --images` point opposite ways.** `build --images`
@@ -414,6 +414,16 @@ editor's setup box. So a file that declares none is a **blocker**, named, unless
414
414
  `--stage x,y,w,h` supplies it; supply it from the project the file came from, or from
415
415
  the editor's own canvas.
416
416
 
417
+ ⛔ **The flag is refused beside a box the file states.** Two sources for one value, and
418
+ the file is the one that was measured — so `ingest` names both boxes and stops rather
419
+ than writing one of them and saying nothing. Drop the flag, or correct `skeleton` in the
420
+ source if its box is wrong ([#626](https://github.com/firejune/rigc/issues/626)). What
421
+ it does **not** do is refuse an *omitted origin*: inside a declared extent an omitted
422
+ `x`/`y` is `0` — the reading `build` emits and `diff` compares
423
+ ([#620](https://github.com/firejune/rigc/issues/620)) — so the written spec states it
424
+ and a `LOSS HEADER_ORIGIN` line says the source omitted it and that the rebuild will
425
+ spell it ([#622](https://github.com/firejune/rigc/issues/622)).
426
+
417
427
  ⚠️ **This said an editor export "never" carries one until
418
428
  [#594](https://github.com/firejune/rigc/issues/594) measured the corpus.** All twelve
419
429
  exports under `examples/` declare `x`, `y`, `width` and `height`, `ingest` takes the
@@ -443,7 +453,7 @@ the first:
443
453
  | --- | --- |
444
454
  | `BLOCK` | the spec format cannot say it, so the rebuild will **not** be the file that was read — `linkedmesh`, `point`, an attachment `sequence`, an unknown field on a bone, slot or constraint, a timeline family the motion spec has no track for. The command exits non-zero **and still writes both specs**, because a spec plus a list of what is missing from it beats no spec |
445
455
  | `JUDGE` | the skeleton cannot answer and somebody has to: the stage, and each animation's duration |
446
- | `LOSS` | the skeleton says it and rigc re-derives it, on purpose. 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 |
456
+ | `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) |
447
457
 
448
458
  📝 **Do not delete the `note`.** Both written specs carry one saying the file is
449
459
  decompiled and naming the skeleton it came from. A decompiled spec is
package/docs/FACE.md CHANGED
@@ -1403,6 +1403,15 @@ shift through the vertex's coordinate instead of its index, so the next
1403
1403
  renumbering cannot move it. A `vertices` run is positional by format — that is
1404
1404
  `fromVertex`'s whole job — and a *generator* of one has no reason to be.
1405
1405
 
1406
+ 🔒 **And that repair is now measured rather than asserted.** `bun run selftest`
1407
+ runs this script again against a rig whose vertices have been renumbered every
1408
+ way the compiler accepts — the outline rotated along its own walk, the outline
1409
+ reflected, the interior reordered — and requires the run it writes to come back
1410
+ as the same geometry once the renumbering is undone, while the run's own
1411
+ positions have visibly moved. Nothing here declares that property: the subject
1412
+ is any script on any page whose product is a `vertices` run, read off the
1413
+ product.
1414
+
1406
1415
  **What comes back from both:**
1407
1416
 
1408
1417
  | | good | (a) one band inverted | (b) mesh folded |
package/docs/INGEST.md CHANGED
@@ -467,8 +467,10 @@ resolved rather than guessed, for §0.2's reason: `spineboy/export` holds two, a
467
467
 
468
468
  **What it will not do is invent.** Everything the spec format cannot hold is a
469
469
  finding with a code — `BLOCK` for a construct the rebuild will be missing, `JUDGE`
470
- for the two values a skeleton does not carry, `LOSS` for the one number rigc
471
- re-derives on purpose. A blocker exits non-zero and still writes both files.
470
+ for the two values a skeleton does not carry, `LOSS` wherever the source's spelling and
471
+ rigc's differ on purpose (a number rigc re-derives, a field the spec has no home for, or
472
+ a default the source left to the format and the rebuild writes out). A blocker exits
473
+ non-zero and still writes both files.
472
474
 
473
475
  **Two values are not in a skeleton**, so `ingest` asks rather than guesses:
474
476
 
@@ -483,7 +485,11 @@ re-derives on purpose. A blocker exits non-zero and still writes both files.
483
485
  reports it as two measures of its own (`stage_present`, `stage_box`, since
484
486
  [#578](https://github.com/firejune/rigc/issues/578)) and they are `(reported)`, so
485
487
  nothing on the ladder reads them and an absurd box is green nearly everywhere. The
486
- corpus half of the selftest's `IG` suite is the one gate that does read them;
488
+ corpus half of the selftest's `IG` suite is the one gate that does read them. ⛔ **The
489
+ flag is refused beside a box the file states** — two sources for one value, both named,
490
+ and the file is the record of what was measured
491
+ ([#626](https://github.com/firejune/rigc/issues/626)). It used to be read only *after*
492
+ the file's box, so `--stage` at any of the twelve did nothing and said nothing;
487
493
  - **each animation's duration** — the format has no such field. The largest key time
488
494
  is used, stated in the motion spec's `note`, and recorded as a finding per
489
495
  animation. Edit it if you know the real number.
@@ -620,7 +626,7 @@ rebuild against its source produces differences of exactly three kinds, in every
620
626
  | Kind | Example, candidate vs reference | Why |
621
627
  | --- | --- | --- |
622
628
  | **header bookkeeping**, 3 per file | `skeleton.hash: undefined vs "VFWbaK2UoCM"`, `skeleton.audio: undefined vs null`, `skeleton.spine: "4.3.13" vs "4.3.75-beta"` | the rig spec has no field for `hash` or `audio`, and the version is the runtime rigc links. `ingest` reports all three as findings — `HEADER_BOOKKEEPING` and `HEADER_REDERIVED` |
623
- | **an omitted default written out** | `…rotate[0].time: 0 vs undefined` | the editor omits a zero `time`; rigc writes it. AUTHORING §10.5's *do not imitate the exporter's omissions*, from the other side |
629
+ | **an omitted default written out** | `…rotate[0].time: 0 vs undefined` | the editor omits a zero `time`; rigc writes it. AUTHORING §10.5's *do not imitate the exporter's omissions*, from the other side. The header has one of these too and it is the one `ingest` now names: a stage at the origin is written `width`/`height` with no `x`/`y`, and the rebuild spells both — `LOSS HEADER_ORIGIN`, with the box unchanged ([#622](https://github.com/firejune/rigc/issues/622)). No file in this corpus takes that branch: all twelve declare an origin away from 0 |
624
630
  | **the emitted precision** | `…curve[0]: 0.066667 vs 0.06666667`, `uvs[0]: 0 vs 2.554152e-7` | rigc emits six decimals |
625
631
 
626
632
  ⇒ **So the corpus gate is `diff` at 1.000 rather than a byte comparison**, and it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,14 +29,15 @@ not do for you.
29
29
  ## Start here: `rigc ingest`
30
30
 
31
31
  ```bash
32
- rigc ingest hero.json --out specs/ --stage 0,0,1024,768
32
+ rigc ingest hero.json --out specs/
33
33
  rigc build --rig specs/rig.json --motion specs/motion.json --images parts/ --out build/
34
34
  ```
35
35
 
36
36
  It reads the skeleton — **only** the skeleton — and writes the rig spec and motion
37
37
  spec that rebuild it, byte for byte. Two values it refuses rather than guessing: the
38
38
  **stage** (`--stage`, for a skeleton that declares none — an editor export *may* be
39
- one, though every one in the example corpus carries a box) and each animation's
39
+ one, though every one in the example corpus carries a box, and passing the flag at a
40
+ file that declares a box is refused rather than ignored) and each animation's
40
41
  **duration** (the largest key time, recorded as a finding).
41
42
  Read `findings.json`: a `BLOCK` line means the rebuild will be missing something and
42
43
  the command exits non-zero. Keep the `note` both specs carry. INGEST §2.0.
package/src/ingest.ts CHANGED
@@ -26,7 +26,8 @@
26
26
  * log. Exactly two values are not in a skeleton at all (the stage and an
27
27
  * animation's duration) and both are `judgement` findings; every construct the
28
28
  * spec format cannot hold is a `blocker`; everything rigc re-derives rather than
29
- * carries is `lossy`.
29
+ * carries is `lossy`. The one thing it refuses outright rather than recording is
30
+ * an OPTION that contradicts the file — see `IngestError`.
30
31
  *
31
32
  * ## What it does not read
32
33
  *
@@ -48,6 +49,24 @@ import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
48
49
  import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
49
50
  import type { MotionSpec } from './types.ts';
50
51
 
52
+ /**
53
+ * The invocation this module refuses outright, rather than recording.
54
+ *
55
+ * ⚠️ **A finding is about the FILE; this is about the call.** Everything below
56
+ * that a skeleton cannot answer is a `finding` and the specs are still written,
57
+ * because a spec plus a list of what is missing from it beats no spec. An
58
+ * `IngestError` is the other thing: an option that contradicts the file it was
59
+ * given, where writing anything at all would be writing something the caller did
60
+ * not ask for. It is one re-run away from everything, which is what makes
61
+ * refusing cheaper than recording here (issue #626).
62
+ */
63
+ export class IngestError extends Error {
64
+ constructor(message: string) {
65
+ super(message);
66
+ this.name = 'IngestError';
67
+ }
68
+ }
69
+
51
70
  // ---------------------------------------------------------------------------
52
71
  // findings
53
72
  // ---------------------------------------------------------------------------
@@ -59,8 +78,12 @@ import type { MotionSpec } from './types.ts';
59
78
  * NOT be the one that was read. Non-zero exit.
60
79
  * - `judgement` — the skeleton does not carry it and somebody has to decide.
61
80
  * There are exactly two: the stage, and an animation's duration.
62
- * - `lossy` — the skeleton carries it and rigc re-derives it rather than taking
63
- * it, which is correct and is said out loud (`lengths`, the `spine` version).
81
+ * - `lossy` — the skeleton's spelling and rigc's differ, on purpose, and the
82
+ * difference is named: a value rigc re-derives rather than takes (`lengths`,
83
+ * the `spine` version), a field the spec has no home for (`hash`, `audio`), or
84
+ * a default the source left to the format and the rebuild writes out
85
+ * (`HEADER_ORIGIN`, issue #622). The rebuilt file is a different file in that
86
+ * field; it is not a different rig.
64
87
  */
65
88
  export type IngestFindingKind = 'blocker' | 'judgement' | 'lossy';
66
89
 
@@ -112,7 +135,17 @@ export interface IngestOptions {
112
135
  * `cli.ts` refuses that pair rather than writing a field nothing reads.
113
136
  */
114
137
  images?: string;
115
- /** Supplied stage. Used ONLY when the skeleton carries no width/height. */
138
+ /**
139
+ * Supplied stage, for a skeleton that declares none.
140
+ *
141
+ * ⛔ **Beside a skeleton that declares one this is an `IngestError`, not an
142
+ * override** (issue #626). It used to be read only after the early return in
143
+ * `ingestHeader`, so a caller who passed it alongside a declared box got the
144
+ * file's box, no finding and exit 0 — and the sharper case is the caller who
145
+ * meant to correct a wrong box and believed they had. The file is the record
146
+ * of what was measured; two sources for one value is a question, and rigc
147
+ * refuses it rather than answering it quietly.
148
+ */
116
149
  stage?: IngestStage;
117
150
  /** The source file's basename, for the provenance note. No path: no leak. */
118
151
  source: string;
@@ -573,6 +606,32 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
573
606
 
574
607
  type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
575
608
 
609
+ /**
610
+ * Does this header declare a stage?
611
+ *
612
+ * 🔒 One reading, three callers below — the refusal, the origin default and the
613
+ * early return — because they are three statements about the same header and two
614
+ * spellings of "declares a stage" would be two things that have to agree. The
615
+ * EXTENT is what declares one: an origin for a box that is not there is a shape
616
+ * no export carries, which is how `diff`'s `stageFacts` and `compile`'s stage
617
+ * guard already read it.
618
+ */
619
+ function declaresStage(head: JsonObject): boolean {
620
+ return head.width !== undefined && head.height !== undefined;
621
+ }
622
+
623
+ /**
624
+ * A stage as one string, for a message that has to put two of them side by side.
625
+ *
626
+ * The origin is spelled `0` where it is absent, for the reason `HEADER_ORIGIN`
627
+ * writes it: inside a declared extent that is what the omission means, so a
628
+ * refusal that printed the file's box as `undefined,undefined,…` would be
629
+ * quoting the file against the reading every other part of this tree holds.
630
+ */
631
+ function spellStage(x: unknown, y: unknown, width: unknown, height: unknown): string {
632
+ return [x ?? 0, y ?? 0, width, height].map((value) => String(value)).join(',');
633
+ }
634
+
576
635
  /**
577
636
  * The rig spec's `skeleton` block — and the one judgement in this module.
578
637
  *
@@ -585,16 +644,35 @@ type Note = (kind: IngestFindingKind, code: string, where: string, detail: strin
585
644
  *
586
645
  * ⚠️ This said an editor export's `skeleton` block is `hash`, `spine`, `images`,
587
646
  * `audio` **and no box at all** until issue #594 measured the corpus: all twelve
588
- * exports under `examples/` carry `x`/`y`/`width`/`height`, and the early return
589
- * below is the branch they take. The blocker is for a file that really has none,
590
- * and this module has no example of one.
647
+ * exports under `examples/` carry `x`/`y`/`width`/`height`, and the declared-stage
648
+ * branch below is the one they take. The blocker is for a file that really has
649
+ * none, and this module has no example of one.
591
650
  *
592
651
  * ⭐ It is still the judgement that costs least to get wrong. `diff` does report
593
652
  * the box — `stage_present` and `stage_box`, since issue #578 — but they sit in
594
653
  * the `(reported)` block that no rung consults, so a deliberately absurd unit box
595
654
  * is green everywhere a candidate is scored.
655
+ *
656
+ * 🔇 **Both of its silences were here, and both were around the DECLARED branch
657
+ * rather than the missing one.** That branch used to be a bare early return, so
658
+ * an origin the source omitted left no trace at all (issue #622) and a `--stage`
659
+ * given beside a declared box was read after it and therefore never (issue #626).
660
+ * Neither was wrong — the rebuild carried the right numbers both times — which is
661
+ * exactly the shape this whole module exists to convert into something named:
662
+ * a decompiler that is right for a reason it never states is a decompiler nobody
663
+ * can check.
596
664
  */
597
665
  function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonObject {
666
+ // 🚨 Before a line of transcription, because a header the caller contradicted
667
+ // is not a header to start writing a spec from (issue #626).
668
+ if (declaresStage(head) && opts.stage !== undefined) {
669
+ throw new IngestError(
670
+ `the skeleton declares a stage of ${spellStage(head.x, head.y, head.width, head.height)} and --stage supplied ` +
671
+ `${spellStage(opts.stage.x, opts.stage.y, opts.stage.width, opts.stage.height)}: two sources for one value. ` +
672
+ 'rigc will not overwrite a box the file states — the file is the record of what was measured, and the flag ' +
673
+ 'is for a skeleton that declares none. Drop --stage, or correct `skeleton` in the source if its box is wrong',
674
+ );
675
+ }
598
676
  const out: JsonObject = {};
599
677
  for (const field of RIG_KEYS.RigSkeletonHeader) if (head[field] !== undefined) out[field] = head[field];
600
678
  for (const key of Object.keys(head)) {
@@ -621,7 +699,33 @@ function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonOb
621
699
  `the editor writes "${key}" and the rig spec has no field for it; it is dropped and nothing reads it back`,
622
700
  );
623
701
  }
624
- if (out.width !== undefined && out.height !== undefined) return out;
702
+ if (declaresStage(head)) {
703
+ // ⭐ **Inside a declared extent, an omitted origin IS `0`** (issue #620),
704
+ // which is a reading of the format rather than a value invented for a gap:
705
+ // `compile` assembles `header.x = rig.skeleton?.x ?? 0` under this same
706
+ // guard, `diff`'s `stageFacts` reads the omission the same way, and
707
+ // `stageFacts`'s own comment carries the four measurements behind it. So the
708
+ // spec states what the file meant instead of leaving the rebuild to a
709
+ // default in another module — and the half that has to be said out loud is
710
+ // the other one: the rebuilt header SPELLS a field the source omitted
711
+ // (issue #622).
712
+ const omitted = ['x', 'y'].filter((field) => head[field] === undefined);
713
+ if (omitted.length > 0) {
714
+ out.x = head.x ?? 0;
715
+ out.y = head.y ?? 0;
716
+ note(
717
+ 'lossy',
718
+ 'HEADER_ORIGIN',
719
+ `skeleton.${omitted.join('/')}`,
720
+ `the source declares a ${String(head.width)}x${String(head.height)} stage and omits ` +
721
+ `${omitted.map((field) => `"${field}"`).join(' and ')}; inside a declared extent an omitted origin is 0, ` +
722
+ 'which is what `compile` emits and what `diff` compares (#620), so the rig spec states x=' +
723
+ `${String(out.x)}, y=${String(out.y)} rather than leaving the rebuild to a default in another module. ` +
724
+ `⚠️ The rebuild WILL spell ${omitted.length > 1 ? 'those fields' : 'that field'}: same box, different bytes`,
725
+ );
726
+ }
727
+ return out;
728
+ }
625
729
  if (opts.stage === undefined) {
626
730
  note(
627
731
  'blocker',