spine-rigc 1.9.3 → 2.0.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/.claude-plugin/plugin.json +1 -1
- package/NOTICE.md +37 -21
- package/README.md +38 -20
- package/bin/rigc.cjs +45 -2
- package/cli_core.ts +35 -0
- package/docs/AUTHORING.md +27 -15
- package/package.json +4 -5
- package/skills/rigc/SKILL.md +8 -5
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +53 -3
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +44 -2
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/emitted/index.ts +119 -0
- package/src/assertions/report.ts +64 -0
- package/src/cli/core_commands.ts +82 -7
- package/src/cli/shared.ts +567 -59
- package/src/cli/spine_commands.ts +36 -366
- package/src/validate.ts +16 -86
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A05, restated over the emitted text (issue #1060): the clause
|
|
3
|
+
* `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
|
|
4
|
+
* text rigc wrote, read by rigc's own reader rather than by spine-core's. The
|
|
5
|
+
* round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
|
|
6
|
+
* the gate of the entry that links none of the runtime
|
|
7
|
+
* (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
|
|
8
|
+
* same lines on every recipe and on this assertion's mutants.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import { SKIP_NO_TIMELINE } from '../reasons.ts';
|
|
12
|
+
import { CHANNELS_BY_KIND, walkTimelines } from '../../timelines.ts';
|
|
13
|
+
import { isObj, type Json } from '../values.ts';
|
|
14
|
+
|
|
15
|
+
export function a05CurveArrayLength({ fail, skip }: Verdicts, raw: Json | null): void {
|
|
16
|
+
// Two clauses, so the SKIP needs both to be empty (#580). The vocabulary
|
|
17
|
+
// clause below measures every timeline it is handed — an unchecked name is a
|
|
18
|
+
// finding whether or not any key on it carries a curve — so a skeleton with
|
|
19
|
+
// timelines and no curve at all has still been measured. What measures
|
|
20
|
+
// nothing is a skeleton `walkTimelines` never calls back on.
|
|
21
|
+
let timelines = 0;
|
|
22
|
+
walkTimelines(raw, (path, kind, name, keys) => {
|
|
23
|
+
timelines++;
|
|
24
|
+
const table = CHANNELS_BY_KIND[kind];
|
|
25
|
+
if (!(name in table)) {
|
|
26
|
+
fail('A05_CURVE_ARRAY_LENGTH', `${path}: unchecked ${kind} timeline "${name}" — extend the validator`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const channels = table[name];
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
if (!isObj(key) || !('curve' in key)) continue;
|
|
32
|
+
const curve = key.curve;
|
|
33
|
+
if (channels === null) {
|
|
34
|
+
fail('A05_CURVE_ARRAY_LENGTH', `${path}: timeline "${name}" cannot carry a curve`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (curve === 'stepped') continue;
|
|
38
|
+
if (!Array.isArray(curve)) {
|
|
39
|
+
fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve is ${JSON.stringify(curve)}, expected "stepped" or an array`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (curve.length !== channels * 4) {
|
|
43
|
+
fail(
|
|
44
|
+
'A05_CURVE_ARRAY_LENGTH',
|
|
45
|
+
`${path} (t=${String(key.time ?? 0)}): curve has ${curve.length} numbers, "${name}" needs ${channels} channels x 4 = ${channels * 4}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
for (const n of curve) {
|
|
49
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
50
|
+
fail('A05_CURVE_ARRAY_LENGTH', `${path}: curve holds a non-finite value ${JSON.stringify(n)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (timelines === 0) return skip('A05_CURVE_ARRAY_LENGTH', SKIP_NO_TIMELINE);
|
|
56
|
+
}
|
|
@@ -18,12 +18,61 @@ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
|
18
18
|
import { pageFootprint, pageGridSentence } from '../../atlas.ts';
|
|
19
19
|
import { readPngHeader } from '../../png.ts';
|
|
20
20
|
|
|
21
|
-
export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
|
|
21
|
+
export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
|
|
22
22
|
if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
|
|
23
23
|
if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
|
|
24
|
+
// Every sentence this body prints goes through here, so the end of the body
|
|
25
|
+
// can tell "nothing failed" from "something failed" (issue #1055, below).
|
|
26
|
+
let failures = 0;
|
|
27
|
+
const fail = (assertion: string, detail: string): void => {
|
|
28
|
+
failures++;
|
|
29
|
+
failed(assertion, detail);
|
|
30
|
+
};
|
|
31
|
+
// 🔒 **A page whose file is not on disk is a page whose size was not
|
|
32
|
+
// measured, and the body says so rather than passing** (issue #1055). Until
|
|
33
|
+
// this the loop below walked past such a page and the assertion came out
|
|
34
|
+
// PASS — on a build with every page file deleted, a PASS over a size clause
|
|
35
|
+
// that had opened nothing, measured on 33 of the 38 recipe-and-profile
|
|
36
|
+
// pairs of the tree's 19 recipes (the other five failed clauses that read
|
|
37
|
+
// only the atlas's numbers: overlap and rotation, under `spine-html`). What the body does instead is decided by what `A17` prints on
|
|
38
|
+
// the same input, so that one missing file is named once, by the rule whose
|
|
39
|
+
// subject it is: A17 FAILs naming every such file and its path, which keeps
|
|
40
|
+
// the report red; this body adds no FAIL of its own about the file (a
|
|
41
|
+
// second naming), and it does not PASS (a certificate over a size nobody
|
|
42
|
+
// read). It SKIPs with a reason that names the pages it did not read and
|
|
43
|
+
// points at A17 — `unreadSizes` below, at both of the body's exits.
|
|
44
|
+
//
|
|
45
|
+
// ⚠️ Only when nothing else here failed. `skip()` is per assertion, and a
|
|
46
|
+
// skip beside failures puts A06 in two of the report's buckets (the #705
|
|
47
|
+
// argument in `a19.ts`); and a FAIL certifies nothing, so the unread page
|
|
48
|
+
// is A17's alone there. ⚠️ Not the tree's other multi-clause rule either
|
|
49
|
+
// (`../reasons.ts`, #580: a multi-clause assertion SKIPs only when EVERY
|
|
50
|
+
// clause had nothing to measure). That rule is about a clause whose subject
|
|
51
|
+
// list is EMPTY — zero is a count, and a count was measured. A page the
|
|
52
|
+
// atlas declares is a member of the size clause's subject that is present
|
|
53
|
+
// and was not read, and the PASS this replaces claimed it was.
|
|
54
|
+
const unread: string[] = [];
|
|
55
|
+
const unreadSizes = (): void => {
|
|
56
|
+
if (failures > 0 || unread.length === 0) return;
|
|
57
|
+
const total = atlas.pages.length;
|
|
58
|
+
skip(
|
|
59
|
+
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
60
|
+
unread.length === total
|
|
61
|
+
? `no page's size was measured: none of the ${total} page file(s) the atlas declares is on disk, which ` +
|
|
62
|
+
"A17_ATLAS_PAGE_FILES_EXIST names file by file. The clauses that read only the atlas's own numbers found " +
|
|
63
|
+
"nothing to fail, and that is not a pass while every page's size is unread"
|
|
64
|
+
: `the size of ${unread.length} of the ${total} page(s) the atlas declares was not measured — ` +
|
|
65
|
+
`${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
|
|
66
|
+
`A17_ATLAS_PAGE_FILES_EXIST names. The ${total - unread.length} page(s) that were read, and the clauses ` +
|
|
67
|
+
"that read only the atlas's own numbers, found nothing to fail, and that is not a pass while a page's size is unread",
|
|
68
|
+
);
|
|
69
|
+
};
|
|
24
70
|
for (const page of atlas.pages) {
|
|
25
71
|
const abs = resolve(input.atlasDir, page.name);
|
|
26
|
-
if (!existsSync(abs))
|
|
72
|
+
if (!existsSync(abs)) {
|
|
73
|
+
unread.push(page.name); // A17 names the file; this body says only that it did not read it
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
27
76
|
// 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
|
|
28
77
|
// #732). The reader's throw used to reach this rule's catch and print as
|
|
29
78
|
// `threw: not a PNG (bad signature)`: the verdict right, the sentence a
|
|
@@ -118,7 +167,7 @@ export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }:
|
|
|
118
167
|
);
|
|
119
168
|
}
|
|
120
169
|
}
|
|
121
|
-
if (!policy) return;
|
|
170
|
+
if (!policy) return unreadSizes();
|
|
122
171
|
// ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
|
|
123
172
|
// clause used to be the first alternative alone — every region's UVs
|
|
124
173
|
// `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
|
|
@@ -192,4 +241,5 @@ export function a06AtlasPageSizeMatchesPng({ fail, skip }: Verdicts, { atlas }:
|
|
|
192
241
|
);
|
|
193
242
|
}
|
|
194
243
|
}
|
|
244
|
+
unreadSizes();
|
|
195
245
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A07, restated over the emitted text (issue #1060): the clause
|
|
3
|
+
* `validate()` runs in `src/validate.ts`, word for word, over the atlas text — the
|
|
4
|
+
* text rigc wrote, read by rigc's own reader rather than by spine-core's. The
|
|
5
|
+
* round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
|
|
6
|
+
* the gate of the entry that links none of the runtime
|
|
7
|
+
* (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
|
|
8
|
+
* same lines on every recipe and on this assertion's mutants.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import { SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
12
|
+
|
|
13
|
+
export function a07AtlasTextShape({ fail, skip }: Verdicts, atlasText: string): void {
|
|
14
|
+
const atlasLines = atlasText.replace(/\n$/, '').split('\n');
|
|
15
|
+
const atlasPageLines = atlasLines.filter((line) => line.trim().length > 0).length;
|
|
16
|
+
if (atlasPageLines === 0) {
|
|
17
|
+
// One of the exported reason constants, not a sentence of its own: the
|
|
18
|
+
// static-rig suite's arithmetic (S50, #580) counts a rule as skipped
|
|
19
|
+
// for want of a SUBJECT only when its reason is one of those constants.
|
|
20
|
+
return skip('A07_ATLAS_TEXT_SHAPE', SKIP_NO_ATLAS_PAGE);
|
|
21
|
+
}
|
|
22
|
+
// A blank line at either END of the file is named as that end (issues #803,
|
|
23
|
+
// #810), and a run of them is one finding stating its length. They are taken
|
|
24
|
+
// off both ends before the walk below, so the walk sees the page blocks and
|
|
25
|
+
// the blank lines between them and nothing else: a blank line after the last
|
|
26
|
+
// region is not a page block, and "consecutive blank lines" and "the last
|
|
27
|
+
// page block declares no region" are about blocks. `TextureAtlas` reads a
|
|
28
|
+
// blank at either end as nothing (`TextureAtlas.js:98-100` skips the leading
|
|
29
|
+
// run, `:116-118` ends a page block on each blank line), and rigc writes
|
|
30
|
+
// neither (`writeAtlasText`, and `--atlas-in` through `canonicalAtlasShape`),
|
|
31
|
+
// so a file that has one was written by something else and is refused.
|
|
32
|
+
let leading = 0;
|
|
33
|
+
while (leading < atlasLines.length && atlasLines[leading].trim().length === 0) leading++;
|
|
34
|
+
if (leading > 0) {
|
|
35
|
+
fail('A07_ATLAS_TEXT_SHAPE', `line 1: the file begins with ${leading === 1 ? 'a blank line' : `${leading} blank lines`}`);
|
|
36
|
+
}
|
|
37
|
+
let trailing = 0;
|
|
38
|
+
while (trailing < atlasLines.length - leading && atlasLines[atlasLines.length - 1 - trailing].trim().length === 0) trailing++;
|
|
39
|
+
const blockEnd = atlasLines.length - trailing;
|
|
40
|
+
let expectPage = true;
|
|
41
|
+
let sawRegionForPage = false;
|
|
42
|
+
for (let i = leading; i < blockEnd; i++) {
|
|
43
|
+
const line = atlasLines[i];
|
|
44
|
+
if (line.trim().length === 0) {
|
|
45
|
+
if (expectPage) fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: consecutive blank lines`);
|
|
46
|
+
else if (!sawRegionForPage) {
|
|
47
|
+
fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: blank line before this page had any region`);
|
|
48
|
+
}
|
|
49
|
+
expectPage = true;
|
|
50
|
+
sawRegionForPage = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (expectPage) {
|
|
54
|
+
expectPage = false;
|
|
55
|
+
continue; // page name line
|
|
56
|
+
}
|
|
57
|
+
if (line.includes(':')) continue; // key: value line
|
|
58
|
+
// A bare non-key line is a region name, and it is used untrimmed.
|
|
59
|
+
if (line !== line.trim()) {
|
|
60
|
+
fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: region name has stray whitespace: ${JSON.stringify(line)}`);
|
|
61
|
+
}
|
|
62
|
+
sawRegionForPage = true;
|
|
63
|
+
}
|
|
64
|
+
if (!sawRegionForPage) fail('A07_ATLAS_TEXT_SHAPE', 'the last page block declares no region');
|
|
65
|
+
if (trailing > 0) {
|
|
66
|
+
fail('A07_ATLAS_TEXT_SHAPE', `line ${blockEnd + 1}: the file ends with ${trailing === 1 ? 'a blank line' : `${trailing} blank lines`}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A16, restated over the emitted text (issue #1060): the clause
|
|
3
|
+
* `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
|
|
4
|
+
* text rigc wrote, read by rigc's own reader rather than by spine-core's. The
|
|
5
|
+
* round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
|
|
6
|
+
* the gate of the entry that links none of the runtime
|
|
7
|
+
* (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
|
|
8
|
+
* same lines on every recipe and on this assertion's mutants.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import { spineGeneration, type SpineGeneration } from '../../generation.ts';
|
|
12
|
+
import { isObj, type Json } from '../values.ts';
|
|
13
|
+
|
|
14
|
+
const SPINE_4_3: SpineGeneration = '4.3';
|
|
15
|
+
|
|
16
|
+
export function a16SkeletonVersion43({ fail }: Verdicts, raw: Json | null): void {
|
|
17
|
+
const declared = isObj(raw?.skeleton) ? (raw.skeleton as Json).spine : undefined;
|
|
18
|
+
if (typeof declared !== 'string' || spineGeneration(declared) !== SPINE_4_3) {
|
|
19
|
+
fail(
|
|
20
|
+
'A16_SKELETON_VERSION_4_3',
|
|
21
|
+
`skeleton.spine is ${JSON.stringify(declared)}, expected 4.3, 4.3.<patch> or 4.3.<patch>-<suffix>`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A18, the body (issue #1060, step 4e of #380): a second, independent compile
|
|
3
|
+
* writes the same model document, byte for byte.
|
|
4
|
+
*
|
|
5
|
+
* One body, two suppliers, as every moved assertion is — but split. The
|
|
6
|
+
* runtime's side (`validate()` in `src/validate.ts`) compares the second
|
|
7
|
+
* compile's Spine pair as well, `skeleton.json` and `skeleton.atlas`, and
|
|
8
|
+
* hands those findings over as `encoding`, in the order it always printed
|
|
9
|
+
* them; the model side (`../model/index.ts`) compares the document alone,
|
|
10
|
+
* which is all a build with no Spine pair writes (`cli_core.ts build`). So
|
|
11
|
+
* the model's clause is written once, here, and the two sides cannot spell it
|
|
12
|
+
* apart. When neither side was handed a second compile — a re-gate of files on
|
|
13
|
+
* disk — there is nothing determinate about a comparison that never ran, and
|
|
14
|
+
* it SKIPs with the reason the round trip always gave (the vacuous-pass trap
|
|
15
|
+
* `A09` is guarded against too).
|
|
16
|
+
*
|
|
17
|
+
* Moved out of `src/validate.ts` unchanged but for the seam; `firstDifferingLine`
|
|
18
|
+
* moved with it, the one reader of it.
|
|
19
|
+
*/
|
|
20
|
+
import type { Verdicts } from '../harness.ts';
|
|
21
|
+
|
|
22
|
+
const A18 = 'A18_DETERMINISTIC_EMIT';
|
|
23
|
+
|
|
24
|
+
/** What A18 reads: whether a second compile was handed over, the encoding's findings, and the two documents. */
|
|
25
|
+
export interface DeterminismFacts {
|
|
26
|
+
/** Whether a second, independent compile was handed over — without one the assertion has nothing to compare. */
|
|
27
|
+
again: boolean;
|
|
28
|
+
/** The findings of the clauses only one side compares (the Spine pair, on the runtime's), each a detail, in the order they print. */
|
|
29
|
+
encoding: readonly string[];
|
|
30
|
+
/** The first compile's model document — `undefined` where the caller handed a second compile and no first document. */
|
|
31
|
+
first: string | undefined;
|
|
32
|
+
/** The second compile's model document; read only where `again`. */
|
|
33
|
+
second: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where two texts first differ, as A18 names it: the 1-based line and each
|
|
38
|
+
* side's line, trimmed and cut to 120 characters.
|
|
39
|
+
*/
|
|
40
|
+
export function firstDifferingLine(first: string, second: string): string {
|
|
41
|
+
const a = first.split('\n');
|
|
42
|
+
const b = second.split('\n');
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
|
|
45
|
+
const cut = (line: string | undefined): string => (line === undefined ? '(end of text)' : JSON.stringify(line.trim().slice(0, 120)));
|
|
46
|
+
return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function a18DeterministicEmit({ fail, skip }: Verdicts, facts: DeterminismFacts): void {
|
|
50
|
+
if (!facts.again) return skip(A18, 'no second compile to compare against (re-gating artifacts on disk)');
|
|
51
|
+
for (const detail of facts.encoding) fail(A18, detail);
|
|
52
|
+
// The model document is compared like the Spine pair (issue #922): it is
|
|
53
|
+
// written beside them, and a map iterated in an order nothing fixed would
|
|
54
|
+
// reach it before it reached either of them — the document writes every
|
|
55
|
+
// `Map` of the model as an array in the map's order. On a build with no
|
|
56
|
+
// Spine pair it is the only artifact there is to compare.
|
|
57
|
+
if (facts.first === undefined) {
|
|
58
|
+
fail(A18, 'a second compile was handed over with no first model document to compare its skeleton.model.json against');
|
|
59
|
+
} else if (facts.second !== facts.first) {
|
|
60
|
+
fail(A18, `recompiling produced a different skeleton.model.json (first differing line ${firstDifferingLine(facts.first, facts.second)})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -21,13 +21,20 @@ import { colourTypeName, readPngHeader } from '../../png.ts';
|
|
|
21
21
|
import { readPlate } from '../../../tools/plate.ts';
|
|
22
22
|
import type { RigInfo } from '../../types.ts';
|
|
23
23
|
|
|
24
|
-
export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
|
|
24
|
+
export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
|
|
25
25
|
// A SKIP for the same reason A06's is (#568). This one is invisible under
|
|
26
26
|
// `spine`, where the profile excludes the rule before its body runs — and
|
|
27
27
|
// that is exactly why it was worth finding: `--profile spine-html` reported
|
|
28
28
|
// it, and A27 below, green on an atlas nothing had read.
|
|
29
29
|
if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
|
|
30
30
|
if (atlas.pages.length === 0) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS_PAGE);
|
|
31
|
+
// Every sentence this body prints goes through here, so its end can tell
|
|
32
|
+
// "nothing failed" from "something failed" (issue #1055, at the page loop).
|
|
33
|
+
let failures = 0;
|
|
34
|
+
const fail = (assertion: string, detail: string): void => {
|
|
35
|
+
failures++;
|
|
36
|
+
failed(assertion, detail);
|
|
37
|
+
};
|
|
31
38
|
const stageW = stage.width ?? 0;
|
|
32
39
|
const stageH = stage.height ?? 0;
|
|
33
40
|
// 🔒 **Which image is the base plate is decided ONCE, and the rig's own
|
|
@@ -96,10 +103,34 @@ export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: Atl
|
|
|
96
103
|
if (on) on.push(region);
|
|
97
104
|
else sharedPages.set(region.page.name, [region]);
|
|
98
105
|
}
|
|
106
|
+
// 🔒 **A page whose file is not on disk is a page whose parts were not
|
|
107
|
+
// read, and the body says so rather than passing** (issue #1055). Until
|
|
108
|
+
// this the loop walked past such a page, and on a build with every page
|
|
109
|
+
// file deleted the assertion printed PASS on all 19 of the tree's recipes
|
|
110
|
+
// under `spine-html` having opened nothing. The rule for what it prints
|
|
111
|
+
// instead is `a06.ts`'s, read off what `A17` prints on the same input: A17
|
|
112
|
+
// FAILs naming each missing file, so the report is already red and the file
|
|
113
|
+
// already named; a FAIL here would be the second naming and a PASS a
|
|
114
|
+
// certificate over alpha nobody read. So the body SKIPs, naming the pages it
|
|
115
|
+
// did not read and pointing at A17 — only when it failed nothing else,
|
|
116
|
+
// because `skip()` is per assertion (#705's argument, below) and a FAIL
|
|
117
|
+
// certifies nothing.
|
|
118
|
+
//
|
|
119
|
+
// ⚠️ A page counts only when it carries a part this rule judges, by the
|
|
120
|
+
// predicate the non-PNG branch below uses: a missing page holding nothing
|
|
121
|
+
// but the base plate had nothing for this rule to read either way, so it
|
|
122
|
+
// moves nothing here (the size it was not measured at is `A06`'s to say).
|
|
123
|
+
let pagesWithParts = 0;
|
|
124
|
+
const unread: string[] = [];
|
|
99
125
|
for (const page of atlas.pages) {
|
|
100
126
|
const abs = resolve(input.atlasDir, page.name);
|
|
101
|
-
if (!existsSync(abs)) continue;
|
|
102
127
|
const on = sharedPages.get(page.name) ?? [];
|
|
128
|
+
const carriesPart = on.length > 1 ? on.some((region) => !baseRegions.has(region.name)) : !basePages.has(page.name);
|
|
129
|
+
if (carriesPart) pagesWithParts++;
|
|
130
|
+
if (!existsSync(abs)) {
|
|
131
|
+
if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
103
134
|
// 🔒 **A page that is not a PNG is a non-measurement for every part on
|
|
104
135
|
// it** (issue #732), stated the way #705's and #715's are: a FAIL naming
|
|
105
136
|
// what was not read, because `skip()` is per assertion and would delete
|
|
@@ -292,4 +323,15 @@ export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: Atl
|
|
|
292
323
|
'spine does not run this check.',
|
|
293
324
|
);
|
|
294
325
|
}
|
|
326
|
+
if (failures > 0 || unread.length === 0) return;
|
|
327
|
+
skip(
|
|
328
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
329
|
+
unread.length === pagesWithParts
|
|
330
|
+
? `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
|
|
331
|
+
'A17_ATLAS_PAGE_FILES_EXIST names file by file, and that is not a pass while every part is unread'
|
|
332
|
+
: `${unread.length} of the ${pagesWithParts} page(s) carrying a part were not read — ` +
|
|
333
|
+
`${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
|
|
334
|
+
`A17_ATLAS_PAGE_FILES_EXIST names. Every part on the ${pagesWithParts - unread.length} page(s) that were ` +
|
|
335
|
+
"read can draw a transparent pixel, and that is not a pass while a part's alpha is unread",
|
|
336
|
+
);
|
|
295
337
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A31, restated over the emitted text (issue #1060): the clause
|
|
3
|
+
* `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
|
|
4
|
+
* text rigc wrote, read by rigc's own reader rather than by spine-core's. The
|
|
5
|
+
* round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
|
|
6
|
+
* the gate of the entry that links none of the runtime
|
|
7
|
+
* (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
|
|
8
|
+
* same lines on every recipe and on this assertion's mutants.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import { isObj, type Json } from '../values.ts';
|
|
12
|
+
|
|
13
|
+
export function a31DrawOrderOffsetsResolve({ fail, skip }: Verdicts, raw: Json | null): void {
|
|
14
|
+
if (!raw) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
15
|
+
if (!Array.isArray(raw.slots) || !isObj(raw.animations)) {
|
|
16
|
+
return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'the skeleton declares no slots or no animations');
|
|
17
|
+
}
|
|
18
|
+
const slotIndex = new Map<string, number>();
|
|
19
|
+
(raw.slots as unknown[]).forEach((slot, i) => {
|
|
20
|
+
if (isObj(slot) && typeof slot.name === 'string') slotIndex.set(slot.name, i);
|
|
21
|
+
});
|
|
22
|
+
const slotCount = (raw.slots as unknown[]).length;
|
|
23
|
+
let sawATimeline = false;
|
|
24
|
+
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
25
|
+
if (!isObj(anim) || !Array.isArray(anim.drawOrder)) continue;
|
|
26
|
+
sawATimeline = true;
|
|
27
|
+
(anim.drawOrder as unknown[]).forEach((key, k) => {
|
|
28
|
+
const at = `animation "${animName}" drawOrder key ${k}`;
|
|
29
|
+
if (!isObj(key) || !Array.isArray(key.offsets)) return; // no offsets = setup order
|
|
30
|
+
let previous = -1;
|
|
31
|
+
for (const entry of key.offsets as unknown[]) {
|
|
32
|
+
if (!isObj(entry) || typeof entry.slot !== 'string' || typeof entry.offset !== 'number') {
|
|
33
|
+
fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: an offset is not { slot: string, offset: number }`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const index = slotIndex.get(entry.slot);
|
|
37
|
+
if (index === undefined) {
|
|
38
|
+
fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', `${at}: slot "${entry.slot}" is not in the skeleton`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (index <= previous) {
|
|
42
|
+
const detail =
|
|
43
|
+
`${at}: slot "${entry.slot}" is at index ${index}, after an entry at index ${previous} — ` +
|
|
44
|
+
'offsets must be in ascending slot order or the loader never finishes reading them';
|
|
45
|
+
fail('A31_DRAW_ORDER_OFFSETS_RESOLVE', detail);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
previous = index;
|
|
49
|
+
const landing = index + entry.offset;
|
|
50
|
+
if (!Number.isInteger(entry.offset) || landing < 0 || landing >= slotCount) {
|
|
51
|
+
fail(
|
|
52
|
+
'A31_DRAW_ORDER_OFFSETS_RESOLVE',
|
|
53
|
+
`${at}: slot "${entry.slot}" is at index ${index} and offset ${entry.offset} puts it at ${landing}, ` +
|
|
54
|
+
`outside the ${slotCount} slots`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
|
|
61
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A35, restated over the emitted text (issue #1060): the clause
|
|
3
|
+
* `validate()` runs in `src/validate.ts`, word for word, over the skeleton JSON — the
|
|
4
|
+
* text rigc wrote, read by rigc's own reader rather than by spine-core's. The
|
|
5
|
+
* round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
|
|
6
|
+
* the gate of the entry that links none of the runtime
|
|
7
|
+
* (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
|
|
8
|
+
* same lines on every recipe and on this assertion's mutants.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import { isObj, type Json } from '../values.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long the array a deform key edits is, read off one raw attachment — or
|
|
15
|
+
* `null` when the file does not say.
|
|
16
|
+
*
|
|
17
|
+
* The rule is `readVertices`' own: the attachment's `vertices` is coordinates
|
|
18
|
+
* when its length equals `worldVerticesLength`, and a weight run otherwise. A
|
|
19
|
+
* deform array is therefore one `x, y` pair per **vertex** in the first case and
|
|
20
|
+
* one per **bone influence** (`vertices.length / 3`) in the second — the same
|
|
21
|
+
* count with two different meanings, which is exactly why this measures rather
|
|
22
|
+
* than assumes.
|
|
23
|
+
*
|
|
24
|
+
* `null` for the two shapes that cannot be measured from this object alone: a
|
|
25
|
+
* type with no vertices at all (nothing to deform, and the parser throws on it),
|
|
26
|
+
* and a `linkedmesh`, whose geometry belongs to another attachment.
|
|
27
|
+
*/
|
|
28
|
+
function deformArrayLength(att: Json): number | null {
|
|
29
|
+
const type = typeof att.type === 'string' ? att.type : 'region';
|
|
30
|
+
let worldVerticesLength: number;
|
|
31
|
+
if (type === 'mesh') {
|
|
32
|
+
if (!Array.isArray(att.uvs)) return null;
|
|
33
|
+
worldVerticesLength = att.uvs.length;
|
|
34
|
+
} else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
|
|
35
|
+
if (typeof att.vertexCount !== 'number') return null;
|
|
36
|
+
worldVerticesLength = att.vertexCount * 2;
|
|
37
|
+
} else {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(att.vertices)) return null;
|
|
41
|
+
const vertices = att.vertices as unknown[];
|
|
42
|
+
if (vertices.length === worldVerticesLength) return worldVerticesLength;
|
|
43
|
+
// ⚠️ Weighted, and the length is the INFLUENCE COUNT — which is the sum of the
|
|
44
|
+
// per-vertex bone counts, not a division of this array's length. The file
|
|
45
|
+
// holds `boneCount` followed by `boneIndex, x, y, weight` per influence, so a
|
|
46
|
+
// one-bone vertex is FIVE numbers; `readVertices` unpacks that into three
|
|
47
|
+
// numbers per influence, which is where the parser's own `/3*2` comes from and
|
|
48
|
+
// exactly why it cannot be applied to the raw form. Applied here it measured
|
|
49
|
+
// `gallery/flex`'s 77-vertex leaf at 256.667 against its true 154 and put
|
|
50
|
+
// A35's bar two thirds too wide on every weighted mesh — the silence A35
|
|
51
|
+
// exists to break, arriving inside A35.
|
|
52
|
+
//
|
|
53
|
+
// Derived here rather than shared with `src/compile.ts`'s own walk on purpose:
|
|
54
|
+
// the gate re-derives from the emitted file so that it is not checking the
|
|
55
|
+
// compiler's assumptions with the compiler's code. Both had this wrong, which
|
|
56
|
+
// is an argument for a control on each and not for one implementation.
|
|
57
|
+
let influences = 0;
|
|
58
|
+
for (let i = 0; i < vertices.length; ) {
|
|
59
|
+
const n = vertices[i++];
|
|
60
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) return null;
|
|
61
|
+
influences += n;
|
|
62
|
+
i += n * 4;
|
|
63
|
+
if (i > vertices.length) return null;
|
|
64
|
+
}
|
|
65
|
+
return influences * 2;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function a35DeformKeysFitTheAttachment({ fail, skip }: Verdicts, raw: Json | null): void {
|
|
69
|
+
if (!raw) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
70
|
+
if (!isObj(raw.animations)) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton declares no animations');
|
|
71
|
+
/** skin name -> slot -> attachment, straight off the raw JSON. */
|
|
72
|
+
const skins = new Map<string, Json>();
|
|
73
|
+
for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
|
|
74
|
+
if (isObj(skin) && typeof skin.name === 'string' && isObj(skin.attachments)) {
|
|
75
|
+
skins.set(skin.name, skin.attachments as Json);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let sawATimeline = false;
|
|
79
|
+
let measured = 0;
|
|
80
|
+
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
81
|
+
if (!isObj(anim) || !isObj(anim.attachments)) continue;
|
|
82
|
+
for (const [skinName, slotMap] of Object.entries(anim.attachments as Json)) {
|
|
83
|
+
if (!isObj(slotMap)) continue;
|
|
84
|
+
for (const [slotName, attMap] of Object.entries(slotMap)) {
|
|
85
|
+
if (!isObj(attMap)) continue;
|
|
86
|
+
for (const [attName, timelines] of Object.entries(attMap)) {
|
|
87
|
+
if (!isObj(timelines) || !Array.isArray(timelines.deform)) continue;
|
|
88
|
+
sawATimeline = true;
|
|
89
|
+
const at = `animation "${animName}" deform ${skinName}/${slotName}/${attName}`;
|
|
90
|
+
const attachment = isObj(skins.get(skinName)?.[slotName])
|
|
91
|
+
? ((skins.get(skinName)![slotName] as Json)[attName] as unknown)
|
|
92
|
+
: undefined;
|
|
93
|
+
if (!isObj(attachment)) {
|
|
94
|
+
fail(
|
|
95
|
+
'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
|
|
96
|
+
`${at}: skin "${skinName}" has no attachment "${attName}" on slot "${slotName}"; the parser throws ` +
|
|
97
|
+
'`Timeline attachment not found`',
|
|
98
|
+
);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const length = deformArrayLength(attachment);
|
|
102
|
+
if (length === null) {
|
|
103
|
+
// A linked mesh takes its geometry from another attachment, so the
|
|
104
|
+
// raw file cannot state this one's length. Saying nothing beats
|
|
105
|
+
// inventing a bound: an assertion with a default is how "nothing to
|
|
106
|
+
// measure" becomes a measurement of the wrong thing.
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
measured++;
|
|
110
|
+
const keys = timelines.deform as unknown[];
|
|
111
|
+
if (keys.length === 0) {
|
|
112
|
+
fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at}: the key array is empty; the parser skips the timeline in silence`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
keys.forEach((key, k) => {
|
|
116
|
+
if (!isObj(key)) {
|
|
117
|
+
fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: not an object`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const vertices = key.vertices;
|
|
121
|
+
if (vertices === undefined || vertices === null) return; // "back to setup" — nothing to fit
|
|
122
|
+
if (!Array.isArray(vertices)) {
|
|
123
|
+
fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: vertices is ${JSON.stringify(vertices)}, not an array`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const offset = key.offset === undefined ? 0 : key.offset;
|
|
127
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
|
|
128
|
+
fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: offset is ${JSON.stringify(key.offset)}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// ⛔ No parity clause here, and the header says why: an odd `offset`
|
|
132
|
+
// and an odd-length run are both what a trimmed editor export looks
|
|
133
|
+
// like, and the parser has no pair arithmetic to be misaligned
|
|
134
|
+
// against (#262).
|
|
135
|
+
if (offset + vertices.length > length) {
|
|
136
|
+
fail(
|
|
137
|
+
'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
|
|
138
|
+
`${at} key ${k}: the run covers ${offset}..${offset + vertices.length} of a ${length}-long deform ` +
|
|
139
|
+
'array; everything past the end is copied into a Float32Array and dropped without a word',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const n of vertices as unknown[]) {
|
|
143
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
144
|
+
fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: the run holds a non-finite value ${JSON.stringify(n)}`);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!sawATimeline) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'no animation carries a deform timeline');
|
|
154
|
+
if (measured === 0) {
|
|
155
|
+
return skip(
|
|
156
|
+
'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
|
|
157
|
+
'every deform timeline here keys an attachment whose vertex count the raw file does not state (a linked mesh)',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|