spine-rigc 1.9.4 → 2.0.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/.claude-plugin/plugin.json +1 -1
- package/NOTICE.md +37 -21
- package/README.md +47 -24
- package/bin/rigc.cjs +45 -2
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +78 -14
- package/package.json +4 -5
- package/skills/rigc/SKILL.md +15 -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 +8 -0
- 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 +79 -30
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/emitted/index.ts +121 -0
- package/src/assertions/report.ts +64 -0
- package/src/cli/core_commands.ts +83 -7
- package/src/cli/shared.ts +574 -61
- package/src/cli/spine_commands.ts +36 -366
- package/src/compile.ts +63 -0
- package/src/validate.ts +16 -86
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data,
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"rigc": "./bin/rigc.cjs"
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"files": [
|
|
56
56
|
"bin/rigc.cjs",
|
|
57
57
|
"cli.ts",
|
|
58
|
+
"cli_core.ts",
|
|
58
59
|
"src",
|
|
59
60
|
"tools/plate.ts",
|
|
60
61
|
"tools/font5x7.ts",
|
|
@@ -90,10 +91,8 @@
|
|
|
90
91
|
"viewer": "vite --config viewer/vite.config.ts",
|
|
91
92
|
"prepublishOnly": "bun run typecheck && bun run lint && bun run selftest"
|
|
92
93
|
},
|
|
93
|
-
"dependencies": {
|
|
94
|
-
"@esotericsoftware/spine-core": "4.3.13"
|
|
95
|
-
},
|
|
96
94
|
"devDependencies": {
|
|
95
|
+
"@esotericsoftware/spine-core": "4.3.13",
|
|
97
96
|
"@types/bun": "^1.4.0",
|
|
98
97
|
"eslint": "^10.9.0",
|
|
99
98
|
"spine-html": "^0.4.1",
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rigc
|
|
3
|
-
description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output
|
|
3
|
+
description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output with named assertions before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, or where the source is a Live2D, Unity or video model whose pictures you can render, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Bun 1.2 or later. The tool is the npm package spine-rigc (bunx spine-rigc, or bun add -d spine-rigc); the command it installs is rigc.
|
|
6
6
|
---
|
|
@@ -8,8 +8,9 @@ compatibility: Requires Bun 1.2 or later. The tool is the npm package spine-rigc
|
|
|
8
8
|
# rigc — a rig compiler for agents
|
|
9
9
|
|
|
10
10
|
rigc compiles a rig spec and a motion spec into Spine 4.3 skeleton data and an
|
|
11
|
-
atlas,
|
|
12
|
-
|
|
11
|
+
atlas, runs its named assertions — rigc's own validator in the published package,
|
|
12
|
+
held to a `@esotericsoftware/spine-core` round trip's verdicts in its CI, all but
|
|
13
|
+
the parse itself — and writes **only if every one is green**. You cannot see the
|
|
13
14
|
rig you are authoring. The validator's messages and the shipped guides are the
|
|
14
15
|
whole interface, and this skill only says which of them to open.
|
|
15
16
|
|
|
@@ -17,8 +18,10 @@ whole interface, and this skill only says which of them to open.
|
|
|
17
18
|
|
|
18
19
|
- **Validation is never bypassed.** `build` writes nothing on a red gate; there is
|
|
19
20
|
no `--no-validate`, and none may be added — AUTHORING §0 says so in as many
|
|
20
|
-
words. Correctness is the whole of the reason: the
|
|
21
|
-
|
|
21
|
+
words. Correctness is the whole of the reason: the assertions are what make the
|
|
22
|
+
output trustworthy, and they are held to the official parser's verdicts in the
|
|
23
|
+
repository's CI — all but the parse itself, which runs only where the runtime is
|
|
24
|
+
installed and is reported as a SKIP elsewhere.
|
|
22
25
|
- **The compiler never invents a value.** A field the spec leaves out is a
|
|
23
26
|
`CompileError` naming that field. Fill the spec; do not expect a default read off
|
|
24
27
|
the art — AUTHORING §2 and §5.
|
|
@@ -41,6 +44,13 @@ Codex, Gemini CLI and Antigravity read skills from `.agents/skills/` in the
|
|
|
41
44
|
workspace and none of them reads `node_modules`; `rigc skills install` puts every
|
|
42
45
|
skill the package ships there, and `rigc skills --help` says what it refuses.
|
|
43
46
|
|
|
47
|
+
The package links no Spine runtime. `validate`, `preview` and `vote` below run
|
|
48
|
+
through `@esotericsoftware/spine-core`: install it beside the package
|
|
49
|
+
(`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs them, and
|
|
50
|
+
`build` runs the round trip as well. Without it they are refused by name, and
|
|
51
|
+
`build` writes the same files gated without the parse — `rigc --version` names the
|
|
52
|
+
entry that ran.
|
|
53
|
+
|
|
44
54
|
## The loop
|
|
45
55
|
|
|
46
56
|
1. `rigc build --rig <spec> --motion <spec> --images <dir> --out <dir>` compiles,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A01, 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 { TOPLEVEL_CONSTRAINT_ARRAYS } from '../../generation.ts';
|
|
12
|
+
import type { Json } from '../values.ts';
|
|
13
|
+
|
|
14
|
+
export function a01NoLegacyToplevelConstraintArrays({ fail }: Verdicts, raw: Json | null): void {
|
|
15
|
+
for (const key of TOPLEVEL_CONSTRAINT_ARRAYS) {
|
|
16
|
+
if (raw && key in raw) {
|
|
17
|
+
fail(
|
|
18
|
+
'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS',
|
|
19
|
+
`top-level "${key}" array present; 4.3 wants it inside "constraints" with type:"${key}"`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A02, 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 { LEGACY_BONE_INHERIT_KEY } from '../../generation.ts';
|
|
12
|
+
import { isObj, type Json } from '../values.ts';
|
|
13
|
+
|
|
14
|
+
export function a02NoBoneTransformKey({ fail }: Verdicts, raw: Json | null): void {
|
|
15
|
+
const bones = Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : [];
|
|
16
|
+
for (const bone of bones) {
|
|
17
|
+
if (isObj(bone) && LEGACY_BONE_INHERIT_KEY in bone) {
|
|
18
|
+
fail('A02_NO_BONE_TRANSFORM_KEY', `bone "${String(bone.name)}" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -81,6 +81,14 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
|
|
|
81
81
|
// of a page states; this rule prefixes the size the atlas declares, the
|
|
82
82
|
// value the file would have had to carry.
|
|
83
83
|
//
|
|
84
|
+
// 🔒 **And this is the one line that names it** (issue #1064). A file's
|
|
85
|
+
// readability is this rule's subject — it runs under both profiles and
|
|
86
|
+
// asks `pngProblem` first, so on every input where `A19` would find the
|
|
87
|
+
// same page unreadable, this FAIL is already printed. `A19` therefore
|
|
88
|
+
// SKIPs naming the page and pointing here when it failed nothing else,
|
|
89
|
+
// and says nothing about the page when it did: one unreadable file, one
|
|
90
|
+
// line, the way #1055 made one missing file A17's alone.
|
|
91
|
+
//
|
|
84
92
|
// ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
|
|
85
93
|
// header carries its dimensions in a fixed field, and reading them would
|
|
86
94
|
// print a number no oracle in this tree has checked (rigc links no WebP
|
|
@@ -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
|
+
}
|
|
@@ -122,6 +122,7 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
122
122
|
// moves nothing here (the size it was not measured at is `A06`'s to say).
|
|
123
123
|
let pagesWithParts = 0;
|
|
124
124
|
const unread: string[] = [];
|
|
125
|
+
const unreadable: string[] = [];
|
|
125
126
|
for (const page of atlas.pages) {
|
|
126
127
|
const abs = resolve(input.atlasDir, page.name);
|
|
127
128
|
const on = sharedPages.get(page.name) ?? [];
|
|
@@ -131,28 +132,42 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
131
132
|
if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
|
-
// 🔒 **A page that is
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
135
|
+
// 🔒 **A page that is on disk and cannot be read as a PNG is named once,
|
|
136
|
+
// by `A06`, and this body only says it did not read it** (issue #1064,
|
|
137
|
+
// the other half of #1055's rule). Until this it FAILed here with a
|
|
138
|
+
// second sentence about the same file (#732's `not measured … belongs to
|
|
139
|
+
// A06`), so one unreadable page was two red lines: measured on the tree's
|
|
140
|
+
// 19 recipes with the first page replaced by text bytes, by a PNG cut
|
|
141
|
+
// inside its IDAT and by one with a wrong signature byte, A06 FAILed
|
|
142
|
+
// naming the file on 19 of 19 under both profiles and this rule added its
|
|
143
|
+
// FAIL on the 12 whose first page carries a part (`spine-html`).
|
|
144
|
+
//
|
|
145
|
+
// ⭐ Who names it is read off the two bodies, not chosen. Both ask
|
|
146
|
+
// `readPngHeader` (`pngProblem`, the one reader of a page's identity) the
|
|
147
|
+
// same question about the same file, A06 runs under both profiles and this
|
|
148
|
+
// rule under `spine-html` alone, and A06 FAILs on every page for which it
|
|
149
|
+
// answers with a problem — so on every input that reaches this branch, A06
|
|
150
|
+
// has already named the file and the report is red. A FAIL here would be
|
|
151
|
+
// the second naming and a PASS a certificate over alpha nobody read, so
|
|
152
|
+
// the page joins `#1055`'s unread list, with its own owner beside it.
|
|
153
|
+
//
|
|
154
|
+
// ⚠️ #732 chose a FAIL because `skip()` is per assertion and would delete
|
|
155
|
+
// the verdicts on every other page. Under #1055's rule that argument no
|
|
156
|
+
// longer forces it: the SKIP below is printed only when this body failed
|
|
157
|
+
// nothing else, so the verdicts it would replace are all passes, and its
|
|
158
|
+
// reason says how many pages they covered. With another failure standing,
|
|
159
|
+
// the failures stand and the unreadable page is A06's alone. A page
|
|
160
|
+
// holding nothing but the base plate had nothing for this rule to read
|
|
161
|
+
// either way and moves nothing, exactly as a missing one does.
|
|
162
|
+
//
|
|
163
|
+
// ⚠️ Only what `pngProblem` refuses comes here. A page whose header it
|
|
164
|
+
// accepts and whose texels the decoder then refuses (a broken zlib stream
|
|
165
|
+
// in an intact chunk walk) reaches `readPlate` below, which A06 never
|
|
166
|
+
// calls — this rule is the one reader that finds that file unreadable,
|
|
167
|
+
// and its line is unchanged by this.
|
|
141
168
|
const header = readPngHeader(abs);
|
|
142
169
|
if (header.problem !== null) {
|
|
143
|
-
|
|
144
|
-
const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
|
|
145
|
-
if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
|
|
146
|
-
const subject = shared
|
|
147
|
-
? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
|
|
148
|
-
: `part image "${page.name}" is`;
|
|
149
|
-
fail(
|
|
150
|
-
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
151
|
-
`${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
|
|
152
|
-
'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
|
|
153
|
-
"belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
|
|
154
|
-
'--profile spine-html: the default --profile spine does not run this check.',
|
|
155
|
-
);
|
|
170
|
+
if (carriesPart) unreadable.push(page.name); // A06 names the file; this body says only that it did not read it
|
|
156
171
|
continue;
|
|
157
172
|
}
|
|
158
173
|
if (on.length > 1) {
|
|
@@ -323,15 +338,49 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
323
338
|
'spine does not run this check.',
|
|
324
339
|
);
|
|
325
340
|
}
|
|
326
|
-
if (failures > 0 || unread.length === 0) return;
|
|
327
|
-
skip(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
341
|
+
if (failures > 0 || unread.length + unreadable.length === 0) return;
|
|
342
|
+
skip('A19_OVERLAY_PNGS_HAVE_ALPHA', unreadAlpha(pagesWithParts, unread, unreadable));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The SKIP's reason over the pages carrying a part that this rule did not
|
|
347
|
+
* read: `missing` (not on disk, which A17 names — issue #1055) and
|
|
348
|
+
* `unreadable` (on disk and not a PNG rigc can read, which A06 names — issue
|
|
349
|
+
* #1064). With only missing pages it is #1055's sentence to the byte; each
|
|
350
|
+
* group names its own owner, so one file is pointed at the one rule that
|
|
351
|
+
* names it.
|
|
352
|
+
*/
|
|
353
|
+
function unreadAlpha(pagesWithParts: number, missing: readonly string[], unreadable: readonly string[]): string {
|
|
354
|
+
const A17 = 'A17_ATLAS_PAGE_FILES_EXIST';
|
|
355
|
+
const A06 = 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG';
|
|
356
|
+
const names = (pages: readonly string[]): string => pages.map((name) => JSON.stringify(name)).join(', ');
|
|
357
|
+
const total = missing.length + unreadable.length;
|
|
358
|
+
if (total === pagesWithParts) {
|
|
359
|
+
if (unreadable.length === 0) {
|
|
360
|
+
return (
|
|
361
|
+
`no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
|
|
362
|
+
`${A17} names file by file, and that is not a pass while every part is unread`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (missing.length === 0) {
|
|
366
|
+
return (
|
|
367
|
+
`no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part can be read as PNG, ` +
|
|
368
|
+
`which ${A06} names file by file, and that is not a pass while every part is unread`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return (
|
|
372
|
+
`no part's alpha was read: of the ${pagesWithParts} page file(s) carrying a part, ${missing.length} are not ` +
|
|
373
|
+
`on disk, which ${A17} names file by file, and ${unreadable.length} cannot be read as PNG, which ${A06} ` +
|
|
374
|
+
'names file by file, and that is not a pass while every part is unread'
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const because = [
|
|
378
|
+
...(missing.length > 0 ? [`${names(missing)} — because the file is not on disk, which ${A17} names`] : []),
|
|
379
|
+
...(unreadable.length > 0 ? [`${names(unreadable)} — because the file cannot be read as PNG, which ${A06} names`] : []),
|
|
380
|
+
].join(', and — ');
|
|
381
|
+
return (
|
|
382
|
+
`${total} of the ${pagesWithParts} page(s) carrying a part were not read — ${because}. Every part on the ` +
|
|
383
|
+
`${pagesWithParts - total} page(s) that were read can draw a transparent pixel, and that is not a pass while ` +
|
|
384
|
+
"a part's alpha is unread"
|
|
336
385
|
);
|
|
337
386
|
}
|
|
@@ -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
|
+
}
|