spine-rigc 0.28.0 → 0.30.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/src/validate.ts CHANGED
@@ -65,13 +65,14 @@ import {
65
65
  TOPLEVEL_CONSTRAINT_ARRAYS,
66
66
  type SpineGeneration,
67
67
  } from './generation.ts';
68
- import { colourTypeName, readPngInfo } from './png.ts';
68
+ import { colourTypeName, readPngHeader } from './png.ts';
69
69
  import {
70
70
  CHANNELS_BY_KIND,
71
71
  KEY_TIME_EPSILON,
72
72
  PHYSICS_POSE_RULES,
73
73
  physicsKeyRefusal,
74
74
  physicsRuleFor,
75
+ SLOT_COLOR_CHANNELS,
75
76
  walkTimelines,
76
77
  } from './timelines.ts';
77
78
  import type { RigInfo } from './types.ts';
@@ -195,6 +196,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
195
196
  A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
196
197
  A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
197
198
  A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
199
+ A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
198
200
  };
199
201
 
200
202
  /**
@@ -297,7 +299,9 @@ export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
297
299
  export const SKIP_NO_ATTACHMENT_REGION_JOIN =
298
300
  'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
299
301
  export const SKIP_NO_TWO_COLOR_TINT =
300
- 'no slot declares a "dark" colour and no animation keys an "rgba2" timeline, so there is no two-colour tint to read back';
302
+ 'no slot declares a "dark" colour and no animation keys an "rgba2" or "rgb2" timeline, so there is no two-colour tint to read back';
303
+ export const SKIP_NO_SEPARABLE_COLOR =
304
+ 'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
301
305
  export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
302
306
  /**
303
307
  * A09's, which predates this list and joins it rather than being rewritten: it
@@ -464,9 +468,24 @@ const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]
464
468
  * is worth nothing to a compiler refusing a key. `PHYSICS_POSE_RULES` is the
465
469
  * index, so a rule added there with no sentence here fails to type-check rather
466
470
  * than printing `undefined`.
471
+ *
472
+ * ⚠️ These are the SETUP pose's sentences and nothing else, which matters on the
473
+ * two rows whose keyed bound is wider than their resting one — `mix` since issue
474
+ * #610 and `strength` since #727. A key of 0 on either is accepted, so "it is
475
+ * muted" and "nothing pulls it back" are read here by a rig that states the
476
+ * number at rest, and what a key is refused with is the row's own `why`.
477
+ *
478
+ * `animations` is how many animations the skeleton declares, and only the `mix`
479
+ * sentence reads it: that bound is the one a key can satisfy instead of the
480
+ * setup pose (`inertAtSetup`, issue #743), so the refusal has to say that the
481
+ * other half was looked for and how wide the search was. A rig with no animation
482
+ * at all then says "none of the 0 animations" rather than implying somebody
483
+ * keyed something.
467
484
  */
468
- const SETUP_POSE_SAYS: Record<string, (pose: PhysicsConstraintPose) => string> = {
469
- mix: (pose) => `has mix ${pose.mix}; it is muted`,
485
+ const SETUP_POSE_SAYS: Record<string, (pose: PhysicsConstraintPose, animations: number) => string> = {
486
+ mix: (pose, animations) =>
487
+ `has mix ${pose.mix} and none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0; ` +
488
+ 'it is muted — rest it above 0, or key its mix above 0 in an animation',
470
489
  mass: (pose) => `has massInverse ${pose.massInverse} (mass must be > 0)`,
471
490
  strength: (pose) => `has strength ${pose.strength}; nothing pulls it back`,
472
491
  damping: (pose) => `has damping ${pose.damping}; outside (0,1) it never settles`,
@@ -941,6 +960,127 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
941
960
  return wrote ? 'overwrites' : 'inert';
942
961
  }
943
962
 
963
+ /**
964
+ * The one page rectangle every region on a page shares: the size the atlas
965
+ * declares for it against the size of the file it names.
966
+ *
967
+ * ⭐ **What a size that disagrees with the file is and is not**, measured rather
968
+ * than assumed (issue #715). `TextureAtlas` computes every region's UVs as a
969
+ * fraction of the DECLARED size — `region.u = region.x / page.width`, spine-core
970
+ * 4.3.13 `dist/TextureAtlas.js:162-171` — `MeshAttachment.computeUVs` takes its
971
+ * `textureWidth` from the same field (`dist/attachments/MeshAttachment.js:125`),
972
+ * `RegionAttachment.computeUVs` reads nothing but `u/v/u2/v2`
973
+ * (`dist/attachments/RegionAttachment.js:152-167`), and `TextureAtlasPage.setTexture`
974
+ * never writes `width`/`height`. So **nothing in the region mapping reads the
975
+ * texture's own size**, and a page whose PNG is the declared page RESCALED is
976
+ * addressed at the same fraction of the picture whatever size the file is:
977
+ * measured on a coordinate-ramp page where every texel names its own position,
978
+ * rigc's own rasteriser drew 116,480 pixels in both and 0 in exactly one at a
979
+ * uniform 0.5.
980
+ *
981
+ * ⇒ That is why this is a clause about **texel** readers rather than about
982
+ * drawing, and why it is nevertheless not renderer policy. Three readers address
983
+ * the page at the coordinates the atlas states, and two of them are rigc's own:
984
+ * `A19`'s alpha scan below, the region lift `partPlate` traces a mesh generator
985
+ * over in [`src/compile.ts`](compile.ts), and `spine-html`'s region tier, which
986
+ * cuts each part with `drawImage(image, x, y, w, h, …)` and says in its own
987
+ * comment that it tests against the image rather than the `size:` line. Measured
988
+ * on the same page: the lift returned 768 of 768 texels from somewhere else.
989
+ *
990
+ * 🔑 And the format already states coarser texels honestly — `scale:`, which
991
+ * rigc reads (`AtlasPage.scale`) and `--atlas-in` divides by. The same art
992
+ * declared that way builds green and renders identically, so this refusal names
993
+ * a repair the format provides rather than one rigc invented.
994
+ */
995
+ interface PageGridReading {
996
+ /** file width / declared width, and the same for height. Both 1 when they agree. */
997
+ readonly x: number;
998
+ readonly y: number;
999
+ /** One ratio for both axes — the only relation a `scale:` line can state. */
1000
+ readonly uniform: boolean;
1001
+ }
1002
+
1003
+ /** `null` when the page declares no positive size for the ratios to divide by. */
1004
+ function pageGridReading(
1005
+ declared: { width: number; height: number },
1006
+ file: { width: number; height: number },
1007
+ ): PageGridReading | null {
1008
+ if (declared.width <= 0 || declared.height <= 0) return null;
1009
+ return {
1010
+ x: file.width / declared.width,
1011
+ y: file.height / declared.height,
1012
+ // Cross-multiplied rather than compared as two divisions: the question is
1013
+ // whether one rational number describes both axes, and two floats that
1014
+ // round to the same digits are not that.
1015
+ uniform: file.width * declared.height === file.height * declared.width,
1016
+ };
1017
+ }
1018
+
1019
+ /** A ratio, printed the one way every message here prints one. */
1020
+ function gridRatio(n: number): string {
1021
+ return n.toFixed(4);
1022
+ }
1023
+
1024
+ /**
1025
+ * The first number on this page that a `scale:` re-declaration could not carry,
1026
+ * or `null` when every one of them lands on a whole texel of the file.
1027
+ *
1028
+ * Every number in a region block is in the page's own texel units — `bounds` and
1029
+ * `offsets` alike — so re-declaring the page at the file's size means scaling all
1030
+ * eight by the same ratio, and a region that then lands between texels is one no
1031
+ * reader could cut out. Stated as integer arithmetic (`n * file % declared`)
1032
+ * rather than as a float test, so the answer does not depend on how the ratio
1033
+ * rounded. ⚠️ One ratio for both axes, so this is the UNIFORM case's question
1034
+ * and is only ever asked there — a page whose two axes differ has no `scale:`
1035
+ * line to be re-declared with at all.
1036
+ */
1037
+ function firstNumberOffTheCoarseGrid(
1038
+ regions: ReadonlyArray<{
1039
+ name: string;
1040
+ x: number;
1041
+ y: number;
1042
+ width: number;
1043
+ height: number;
1044
+ offsetX: number;
1045
+ offsetY: number;
1046
+ originalWidth: number;
1047
+ originalHeight: number;
1048
+ }>,
1049
+ declared: number,
1050
+ file: number,
1051
+ ): string | null {
1052
+ for (const region of regions) {
1053
+ const numbers: Array<[string, number]> = [
1054
+ ['bounds x', region.x],
1055
+ ['bounds y', region.y],
1056
+ ['bounds width', region.width],
1057
+ ['bounds height', region.height],
1058
+ ['offsets offsetX', region.offsetX],
1059
+ ['offsets offsetY', region.offsetY],
1060
+ ['offsets originalWidth', region.originalWidth],
1061
+ ['offsets originalHeight', region.originalHeight],
1062
+ ];
1063
+ for (const [field, value] of numbers) {
1064
+ if ((value * file) % declared === 0) continue;
1065
+ return (
1066
+ `region ${JSON.stringify(region.name.trim())}'s \`${field}\` of ${value} becomes ` +
1067
+ `${((value * file) / declared).toFixed(4)} on the file's own grid, which is not a whole texel`
1068
+ );
1069
+ }
1070
+ }
1071
+ return null;
1072
+ }
1073
+
1074
+ /** What every size-mismatch sentence says before it says what to do about it. */
1075
+ const PAGE_GRID_PREAMBLE =
1076
+ 'A runtime does not read the file\'s own size anywhere in the region mapping — `TextureAtlas` computes every ' +
1077
+ "region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core " +
1078
+ '`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ' +
1079
+ "the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a " +
1080
+ 'declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ' +
1081
+ "validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that " +
1082
+ 'cuts each part out of the page by source rectangle.';
1083
+
944
1084
  export function validate(input: ValidateInput): ValidateReport {
945
1085
  const failures: Failure[] = [];
946
1086
  const passed: string[] = [];
@@ -2873,6 +3013,63 @@ export function validate(input: ValidateInput): ValidateReport {
2873
3013
  }
2874
3014
  }
2875
3015
  }
3016
+ // --- which constraints an animation switches ON (issue #743) ----------
3017
+ //
3018
+ // 🔑 The setup pose is the rig AT REST, and one of the four bounds is a
3019
+ // state rather than a break there: `PHYSICS_POSE_RULES` marks `mix`
3020
+ // `inertAtSetup`, because `update` opens with `if (mix === 0) return;`
3021
+ // (`PhysicsConstraint.js:109-111`) and nothing else in the pose has such a
3022
+ // branch. So a constraint that rests muted and is keyed above 0 by an
3023
+ // animation is a rig the runtime plays as authored, and refusing it would
3024
+ // refuse a design: physics off at rest, switched on by the animation that
3025
+ // needs it.
3026
+ //
3027
+ // 📏 Measured on a generated physics fixture, 36 steps at 60 fps with the
3028
+ // constraint's own bone swung by its parent: resting at `mix` 0 with an
3029
+ // animation keying `mix` to 1 poses the bone IDENTICALLY to the same rig
3030
+ // resting at 1 (max |dx| 0.000000) and up to 7.771177 away from the twin
3031
+ // that keys nothing — which poses identically to one keyed to 0 only
3032
+ // (max |dx| 0.000000). Two states, and the file says which.
3033
+ //
3034
+ // ⚠️ The escape is the rule's own field rather than the word "mix": a
3035
+ // setup `mass` of 0 is `massInverse` Infinity BEFORE anything plays, and
3036
+ // [measured] at rest it reads NaN on every frame although an animation
3037
+ // keys `mass` to 1. A key cannot rescue a value that has already broken
3038
+ // the rig it is resting in.
3039
+ const unmuted = new Map<PhysicsConstraintData, Set<string>>();
3040
+ const raised = new PhysicsConstraintPose();
3041
+ for (const animation of data.animations) {
3042
+ for (const timeline of animation.timelines) {
3043
+ if (!(timeline instanceof PhysicsConstraintTimeline)) continue;
3044
+ const rule = physicsRuleFor(PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] ?? '');
3045
+ if (rule === undefined || !rule.inertAtSetup) continue;
3046
+ const entries = timeline.getFrameEntries();
3047
+ let keysInside = false;
3048
+ for (let i = 0; i < timeline.frames.length && !keysInside; i += entries) {
3049
+ // The runtime's own accessor, for the reason the second arm uses it:
3050
+ // the pose field is where the integrator reads the number, and for
3051
+ // `mass` that is not the number the key states.
3052
+ timeline.set(raised, timeline.frames[i + 1]);
3053
+ keysInside = rule.poseOk(raised[rule.field]);
3054
+ }
3055
+ if (!keysInside) continue;
3056
+ // -1 is the global form: the timeline names no constraint and the
3057
+ // runtime applies it to every physics constraint whose own data
3058
+ // declares that property global (`Animation.js:2067-2075`). No rig
3059
+ // spec can reach it — the compiler refuses a track naming a constraint
3060
+ // the skeleton has not got, the empty name among them — but a file
3061
+ // rigc did not write can carry one, and `timeline.global` is the
3062
+ // runtime's own answer to which constraints it reaches.
3063
+ for (const one of data.constraints) {
3064
+ if (!(one instanceof PhysicsConstraintData)) continue;
3065
+ if (timeline.constraintIndex === -1 ? !timeline.global(one) : data.constraints[timeline.constraintIndex] !== one) continue;
3066
+ const by = unmuted.get(one) ?? new Set<string>();
3067
+ by.add(rule.timeline);
3068
+ unmuted.set(one, by);
3069
+ }
3070
+ }
3071
+ }
3072
+ let mutedUntilKeyed = 0;
2876
3073
  for (const constraint of data.constraints) {
2877
3074
  if (!(constraint instanceof PhysicsConstraintData)) continue;
2878
3075
  const where = `physics "${constraint.name}"`;
@@ -2888,10 +3085,14 @@ export function validate(input: ValidateInput): ValidateReport {
2888
3085
  // sentence.
2889
3086
  for (const rule of PHYSICS_POSE_RULES) {
2890
3087
  if (rule.poseOk(pose[rule.field])) continue;
3088
+ if (rule.inertAtSetup && unmuted.get(constraint)?.has(rule.timeline)) {
3089
+ mutedUntilKeyed++;
3090
+ continue;
3091
+ }
2891
3092
  const drivesAMesh = rule.timeline === 'damping' && meshBoneNames.has(constraint.bone.name);
2892
3093
  fail(
2893
3094
  'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
2894
- `${where} ${SETUP_POSE_SAYS[rule.timeline](pose)}` +
3095
+ `${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length)}` +
2895
3096
  (drivesAMesh ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
2896
3097
  );
2897
3098
  }
@@ -2899,6 +3100,15 @@ export function validate(input: ValidateInput): ValidateReport {
2899
3100
  fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${constraint.step} (fps must be > 0)`);
2900
3101
  }
2901
3102
  }
3103
+ // What the PASS would otherwise not say: this rig rests with physics off
3104
+ // on that many constraints and an animation is what switches them on. A
3105
+ // pass carries no detail — `passed` is a list of names — so `stats` is the
3106
+ // channel that exists, and a number is what belongs in a line printed as
3107
+ // `k=v`: naming every such constraint and the animations that reach it
3108
+ // would be a paragraph on one line, and the rig where nobody meant it is
3109
+ // the rig where the NUMBER is the surprise. Absent rather than 0 when
3110
+ // nothing rests muted, so it appears only where it says something.
3111
+ if (mutedUntilKeyed) stats.physicsMutedUntilKeyed = mutedUntilKeyed;
2902
3112
 
2903
3113
  // --- the same criterion, on every physics timeline key (issue #610) ----
2904
3114
  //
@@ -3038,12 +3248,24 @@ export function validate(input: ValidateInput): ValidateReport {
3038
3248
  /**
3039
3249
  * Does any animation key `<group>.<constraint>.<timeline>`?
3040
3250
  *
3041
- * ⭐ The reason the two assertions below need this, and the reason they are
3042
- * not A23 with a different type name: a constraint whose mixes are all 0 at
3043
- * setup is **the idiom**, not a defect — spineboy's aim rig is exactly that,
3044
- * and issue #88 landed the timelines that turn one on. So "muted" is only a
3045
- * finding when nothing turns it on, and that question lives in the animations
3046
- * rather than in the constraint.
3251
+ * ⭐ The reason the two assertions below need this: a constraint whose mixes
3252
+ * are all 0 at setup is **the idiom**, not a defect — spineboy's aim rig is
3253
+ * exactly that, and issue #88 landed the timelines that turn one on. So
3254
+ * "muted" is only a finding when nothing turns it on, and that question
3255
+ * lives in the animations rather than in the constraint.
3256
+ *
3257
+ * ⚠️ This comment used to add "and the reason they are not A23 with a
3258
+ * different type name", which said the idiom stops at path and slider
3259
+ * constraints. It does not: a production rig rests 87 of its 87 physics
3260
+ * constraints at `mix` 0 and keys them up in four of its six animations
3261
+ * (issue #743), so A23 asks the same question now — and does NOT ask it
3262
+ * here. Two reasons, both measured: this reads the raw JSON and takes a
3263
+ * non-empty key array as a rescue, so a `mix` timeline that keys **0 only**
3264
+ * counts, and [measured] such a rig poses its bone exactly where one with no
3265
+ * timeline at all does; and it cannot see the unnamed global form, which the
3266
+ * runtime applies to every constraint declaring that property global. A23's
3267
+ * arm reads the key VALUES through the runtime's own accessor and asks
3268
+ * `PhysicsConstraintTimeline.global` who they reach.
3047
3269
  */
3048
3270
  const keyedBy = (group: string, name: string, timeline: string): boolean => {
3049
3271
  if (!raw || !isObj(raw.animations)) return false;
@@ -3770,7 +3992,8 @@ export function validate(input: ValidateInput): ValidateReport {
3770
3992
  // `parseInt` over fixed offsets and stores whatever comes back, so
3771
3993
  // `"4020"` loads `b = NaN` — a colour that is neither the author's nor
3772
3994
  // an error.
3773
- // 3. An `rgba2` timeline on a slot with no `dark` at all. `Slot`'s
3995
+ // 3. An `rgba2` (or, since issue #730, `rgb2`) timeline on a slot with no
3996
+ // `dark` at all. `Slot`'s
3774
3997
  // constructor allocates `SlotPose.darkColor` only `if
3775
3998
  // (data.setupPose.darkColor != null)`, and `RGBA2Timeline.apply1` then
3776
3999
  // writes `dark.r` unconditionally — so the file parses, and the first
@@ -3811,20 +4034,31 @@ export function validate(input: ValidateInput): ValidateReport {
3811
4034
  for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
3812
4035
  if (isObj(slot) && typeof slot.dark === 'string') declared.set(String(slot.name), slot.dark);
3813
4036
  }
3814
- const keyed: Array<{ anim: string; slot: string; keys: unknown[] }> = [];
4037
+ // `rgb2` joined in issue #730, and it is this rule's subject rather than a
4038
+ // new one's because it is the same tint with the light alpha left out:
4039
+ // `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
4040
+ // with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
4041
+ // does (measured on a forged file: `TypeError: null is not an object
4042
+ // (evaluating 'dark.r = …')`). The one thing it does differently is the
4043
+ // alpha it does NOT pose, which is `A45`'s question, not this one's.
4044
+ const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
3815
4045
  const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
3816
4046
  for (const [animName, anim] of Object.entries(rawAnimations)) {
3817
4047
  if (!isObj(anim) || !isObj(anim.slots)) continue;
3818
4048
  for (const [slotName, timelines] of Object.entries(anim.slots)) {
3819
- if (!isObj(timelines) || !Array.isArray(timelines.rgba2)) continue;
3820
- keyed.push({ anim: animName, slot: slotName, keys: timelines.rgba2 });
4049
+ if (!isObj(timelines)) continue;
4050
+ for (const timeline of ['rgba2', 'rgb2'] as const) {
4051
+ const keys = timelines[timeline];
4052
+ if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
4053
+ }
3821
4054
  }
3822
4055
  }
3823
4056
  if (declared.size === 0 && keyed.length === 0) {
3824
4057
  return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
3825
4058
  }
3826
4059
  stats.darkSlots = declared.size;
3827
- stats.rgba2Timelines = keyed.length;
4060
+ stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
4061
+ stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
3828
4062
 
3829
4063
  // -- clause 1: the setup pose ----------------------------------------
3830
4064
  for (const [name, hex] of declared) {
@@ -3864,7 +4098,7 @@ export function validate(input: ValidateInput): ValidateReport {
3864
4098
  }
3865
4099
  }
3866
4100
 
3867
- // -- clauses 2 and 3: every rgba2 timeline ----------------------------
4101
+ // -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
3868
4102
  //
3869
4103
  // ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
3870
4104
  // throw happens inside `state.apply`, which applies every timeline of the
@@ -3872,13 +4106,14 @@ export function validate(input: ValidateInput): ValidateReport {
3872
4106
  // same animation would take this assertion down with a `threw:` line
3873
4107
  // instead of the two named failures it has already worked out.
3874
4108
  const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
3875
- for (const { anim: animName, slot: slotName, keys } of keyed) {
3876
- const where = `animation "${animName}" slot "${slotName}" rgba2`;
4109
+ for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
4110
+ const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
3877
4111
  if (!declared.has(slotName)) {
3878
4112
  fail(
3879
4113
  'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3880
4114
  `${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
3881
- 'it and applying this animation throws instead of tinting — give the slot a `dark`, or key "rgba"',
4115
+ `it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
4116
+ `"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
3882
4117
  );
3883
4118
  continue;
3884
4119
  }
@@ -3925,16 +4160,21 @@ export function validate(input: ValidateInput): ValidateReport {
3925
4160
  fail(
3926
4161
  'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3927
4162
  `${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
3928
- `${JSON.stringify(rawKey.dark)} — an rgba2 key needs both, each six or eight hex digits — and the ` +
4163
+ `${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
3929
4164
  `runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
3930
4165
  );
3931
4166
  continue;
3932
4167
  }
3933
- if (foundLight.some((n, i) => off(n, wantLight[i]))) {
4168
+ // An `rgb2` key's light colour is three channels, and the posed alpha
4169
+ // is not its to state — `RGB2Timeline` leaves it where it was — so the
4170
+ // comparison is over the channels the timeline writes. For `rgba2`
4171
+ // that is all four, and the line reads as it always did.
4172
+ const lightChannels = timeline === 'rgba2' ? 4 : 3;
4173
+ if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
3934
4174
  fail(
3935
4175
  'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
3936
- `${where} (t=${time}): light posed (${show(foundLight)}), the key states ${JSON.stringify(rawKey.light)} ` +
3937
- `= (${show(wantLight)})`,
4176
+ `${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
4177
+ `${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
3938
4178
  );
3939
4179
  }
3940
4180
  if (foundDark.some((n, i) => off(n, wantDark[i]))) {
@@ -3948,6 +4188,175 @@ export function validate(input: ValidateInput): ValidateReport {
3948
4188
  }
3949
4189
  });
3950
4190
 
4191
+ // --- A45: the separable colour timelines own their channels ------------
4192
+ //
4193
+ // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
4194
+ // `alpha` pose no dark colour, so under A43's name a failure would be a
4195
+ // verdict saying "two-colour tint" about a file that states one colour —
4196
+ // the name would say something other than what it decides (the rule #712
4197
+ // applied to A43 itself, against a clause on A10). And the SKIP is the
4198
+ // sharper half: a rig with a `dark` and no separable timeline has A43's
4199
+ // subject present, so a clause there could only PASS on it, which is a pass
4200
+ // for an absent subject.
4201
+ //
4202
+ // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
4203
+ // clauses are about the file against the runtime:
4204
+ //
4205
+ // 1. **One channel, one timeline.** Each colour timeline poses its
4206
+ // channels at EVERY time — before its first key it writes the setup
4207
+ // value — so when two of one slot share a channel, the one applied
4208
+ // later (the one the file states later) overwrites the other
4209
+ // everywhere and the first one's keys on it are read by nothing
4210
+ // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
4211
+ // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
4212
+ // loads without a word.
4213
+ // 2. **Posed as written.** Stepped to each key's own time, the channels
4214
+ // the timeline writes are the key's: a colour that is not six hex
4215
+ // digits loads as NaN, and a key whose time another key repeats is
4216
+ // read by nothing, and both parse in silence.
4217
+ //
4218
+ // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
4219
+ // setup alpha is NOT this rule's to see, and nothing that reads only the
4220
+ // file can see it: the result is a correct `rgba`, which is also what an
4221
+ // author keying the light colour and holding its alpha would write. What
4222
+ // differs is what happens UNDER another track that moves the alpha, which
4223
+ // is the consumer's composing rather than the object (CLAUDE.md). That
4224
+ // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
4225
+ //
4226
+ // ⚠️ The required values are parsed HERE, as A43's are, and the channel
4227
+ // table is `timelines.ts`'s rather than the loaded timelines' property ids:
4228
+ // a check that asked the parser what a timeline writes would agree with it
4229
+ // whatever it did. A selftest control holds that table to the runtime's ids.
4230
+ check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => {
4231
+ const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
4232
+ /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
4233
+ const readRgbHex = (hex: unknown): [number, number, number] | null => {
4234
+ if (typeof hex !== 'string') return null;
4235
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
4236
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
4237
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
4238
+ };
4239
+ /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
4240
+ const HEX_STEP = 1 / 510;
4241
+ const FLOAT_STEP = 1e-6;
4242
+ const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
4243
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
4244
+
4245
+ // -- the subjects, read off the FILE ---------------------------------
4246
+ const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
4247
+ const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
4248
+ for (const [animName, anim] of Object.entries(rawAnimations)) {
4249
+ if (!isObj(anim) || !isObj(anim.slots)) continue;
4250
+ for (const [slotName, timelines] of Object.entries(anim.slots)) {
4251
+ if (!isObj(timelines)) continue;
4252
+ if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
4253
+ subjects.push({ anim: animName, slot: slotName, timelines });
4254
+ }
4255
+ }
4256
+ }
4257
+ if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
4258
+ stats.separableColorTimelines = subjects.reduce(
4259
+ (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
4260
+ 0,
4261
+ );
4262
+
4263
+ for (const { anim: animName, slot: slotName, timelines } of subjects) {
4264
+ const at = `animation "${animName}" slot "${slotName}"`;
4265
+ // In FILE order, which is the order `readAnimation` pushes them and so
4266
+ // the order they apply in.
4267
+ const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
4268
+
4269
+ // -- clause 1: one channel, one timeline ---------------------------
4270
+ let shared = false;
4271
+ for (const channel of ['rgb', 'alpha', 'dark'] as const) {
4272
+ const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
4273
+ if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
4274
+ shared = true;
4275
+ const last = writers[writers.length - 1];
4276
+ fail(
4277
+ NAME,
4278
+ `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
4279
+ `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
4280
+ `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
4281
+ 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
4282
+ );
4283
+ }
4284
+ if (shared) continue;
4285
+
4286
+ // -- clause 2: posed as written ------------------------------------
4287
+ //
4288
+ // ⚠️ Two things this does NOT do, both measured rather than skipped:
4289
+ // it does not read the loaded timeline's CLASS back, and it does not
4290
+ // hold a channel the timeline leaves alone to the setup pose. Against
4291
+ // the linked parser neither can fail — every `rgb` / `alpha` array that
4292
+ // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
4293
+ // three other outcomes (an empty array, a slot the skeleton lacks, a
4294
+ // name outside the switch) throw at `A00` — so either would be a clause
4295
+ // nobody can see fire. The selftest reads both off spine-core directly
4296
+ // (`S82`–`S84`), where they are measurements of the runtime rather than
4297
+ // checks on a file.
4298
+ if (!data.findAnimation(animName)) {
4299
+ fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
4300
+ continue;
4301
+ }
4302
+ for (const name of ['rgb', 'alpha'] as const) {
4303
+ const keys = timelines[name];
4304
+ if (!Array.isArray(keys)) continue;
4305
+ const where = `${at} ${name}`;
4306
+ for (const rawKey of keys) {
4307
+ if (!isObj(rawKey)) continue;
4308
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
4309
+ const skeleton = new Skeleton(data);
4310
+ const state = new AnimationState(new AnimationStateData(data));
4311
+ state.setAnimation(0, animName, false);
4312
+ skeleton.setupPose();
4313
+ skeleton.update(0);
4314
+ skeleton.updateWorldTransform(Physics.reset);
4315
+ state.update(time);
4316
+ state.apply(skeleton);
4317
+ const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
4318
+ if (!posed) {
4319
+ fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
4320
+ continue;
4321
+ }
4322
+ const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
4323
+ if (name === 'rgb') {
4324
+ const stated = readRgbHex(rawKey.color);
4325
+ if (stated === null) {
4326
+ fail(
4327
+ NAME,
4328
+ `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
4329
+ `digits — and the runtime poses (${show(light.slice(0, 3))})`,
4330
+ );
4331
+ } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
4332
+ fail(
4333
+ NAME,
4334
+ `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
4335
+ `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
4336
+ );
4337
+ }
4338
+ } else {
4339
+ // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
4340
+ // omits it there, so absence is read the parser's way rather than
4341
+ // as a malformed key.
4342
+ const stated = rawKey.value === undefined ? 0 : rawKey.value;
4343
+ if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
4344
+ fail(
4345
+ NAME,
4346
+ `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
4347
+ (typeof stated !== 'number'
4348
+ ? ' — an alpha key\'s value is a number'
4349
+ : stated < 0 || stated > 1
4350
+ ? ' — the runtime clamps a posed alpha to 0..1'
4351
+ : ''),
4352
+ );
4353
+ }
4354
+ }
4355
+ }
4356
+ }
4357
+ }
4358
+ });
4359
+
3951
4360
  // --- A44: a linked mesh states no geometry of its own ------------------
3952
4361
  //
3953
4362
  // 🚨 The one shape the parser reads in SILENCE. `readAttachment` returns from
@@ -4053,13 +4462,62 @@ export function validate(input: ValidateInput): ValidateReport {
4053
4462
  for (const page of atlas.pages) {
4054
4463
  const abs = resolve(input.atlasDir, page.name);
4055
4464
  if (!existsSync(abs)) continue; // A17 owns this
4056
- const info = readPngInfo(abs);
4057
- if (page.width !== info.width || page.height !== info.height) {
4465
+ // 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
4466
+ // #732). The reader's throw used to reach this rule's catch and print as
4467
+ // `threw: not a PNG (bad signature)`: the verdict right, the sentence a
4468
+ // stack message that named neither what the file was nor what rigc reads.
4469
+ // `pngProblem` ([`src/png.ts`](png.ts)) is the one sentence every reader
4470
+ // of a page states; this rule prefixes the size the atlas declares, the
4471
+ // value the file would have had to carry.
4472
+ //
4473
+ // ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
4474
+ // header carries its dimensions in a fixed field, and reading them would
4475
+ // print a number no oracle in this tree has checked (rigc links no WebP
4476
+ // reader to compare a parse against), about a page that is refused here
4477
+ // either way — and measured, the variant that PASSED a WebP page whose
4478
+ // size agreed built green under the default profile and wrote a
4479
+ // directory that `render` then refused.
4480
+ const header = readPngHeader(abs);
4481
+ if (header.problem !== null) {
4058
4482
  fail(
4059
4483
  'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
4060
- `page "${page.name}" declares ${page.width}x${page.height} but the PNG is ${info.width}x${info.height}`,
4484
+ `page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so the ` +
4485
+ `size was not measured: ${header.problem}`,
4061
4486
  );
4062
4487
  }
4488
+ const info = header.info;
4489
+ if (info !== null && (page.width !== info.width || page.height !== info.height)) {
4490
+ // 🔒 **Still validity, and now for a measured reason rather than an
4491
+ // inherited one** (issue #715). The card that opened this expected the
4492
+ // clause to move behind the profile switch once the runtime's mapping
4493
+ // was measured — it is declared-size-relative, so a rescaled page
4494
+ // draws. What settles it the other way is that the sentence below can
4495
+ // name a repair the FORMAT already provides (`scale:`), and that two of
4496
+ // the three readers a wrong grid breaks are rigc's own and run under
4497
+ // both profiles. See `pageGridReading` for the routines and the figures.
4498
+ const grid = pageGridReading(page, info);
4499
+ const said = `page "${page.name}" declares ${page.width}x${page.height} and its PNG is ${info.width}x${info.height}`;
4500
+ if (grid === null) {
4501
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${said}. ${PAGE_GRID_PREAMBLE}`);
4502
+ } else {
4503
+ const ratios =
4504
+ `${said} — ${gridRatio(grid.x)} of the declared width and ${gridRatio(grid.y)} of the declared ` +
4505
+ 'height.';
4506
+ const onPage = atlas.regions.filter((region) => region.page.name === page.name);
4507
+ const off = grid.uniform ? firstNumberOffTheCoarseGrid(onPage, page.width, info.width) : null;
4508
+ const repair = !grid.uniform
4509
+ ? 'The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ' +
4510
+ `declared honestly at all: re-export the page at ${page.width}x${page.height}, or repack it.`
4511
+ : off !== null
4512
+ ? 'The format states coarser texels with the `scale:` header, but this page cannot be re-declared ' +
4513
+ `that way: ${off}. Re-export the page at ${page.width}x${page.height}, or repack it.`
4514
+ : 'The format states coarser texels with the `scale:` header and rigc builds that: declare ' +
4515
+ `\`size: ${info.width}, ${info.height}\` with \`scale: ${gridRatio(grid.x)}\` and multiply every ` +
4516
+ `\`bounds\`/\`offsets\` on this page by ${gridRatio(grid.x)}, and every part keeps the size it ` +
4517
+ 'has now.';
4518
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${ratios} ${PAGE_GRID_PREAMBLE} ${repair}`);
4519
+ }
4520
+ }
4063
4521
  // 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
4064
4522
  // CONVENTION, not the atlas format's rules — a packed page with
4065
4523
  // `rotate: 90` is what the Spine packer produces and every official example
@@ -4255,6 +4713,30 @@ export function validate(input: ValidateInput): ValidateReport {
4255
4713
  const abs = resolve(input.atlasDir, page.name);
4256
4714
  if (!existsSync(abs)) continue;
4257
4715
  const on = sharedPages.get(page.name) ?? [];
4716
+ // 🔒 **A page that is not a PNG is a non-measurement for every part on
4717
+ // it** (issue #732), stated the way #705's and #715's are: a FAIL naming
4718
+ // what was not read, because `skip()` is per assertion and would delete
4719
+ // the verdicts on every other page of the same report. The file's
4720
+ // identity is `A06`'s to judge, and this sentence points there. A page
4721
+ // holding nothing but the full-stage base plate has no part to judge and
4722
+ // says nothing, exactly as a readable one would.
4723
+ const header = readPngHeader(abs);
4724
+ if (header.problem !== null) {
4725
+ const shared = on.length > 1;
4726
+ const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
4727
+ if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
4728
+ const subject = shared
4729
+ ? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
4730
+ : `part image "${page.name}" is`;
4731
+ fail(
4732
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
4733
+ `${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
4734
+ 'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
4735
+ "belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
4736
+ '--profile spine-html: the default --profile spine does not run this check.',
4737
+ );
4738
+ continue;
4739
+ }
4258
4740
  if (on.length > 1) {
4259
4741
  // 🚨 A rotated region is refused by A06 under this profile, so this
4260
4742
  // reading was assumed to be cosmetic — a rectangle printed beside a
@@ -4289,6 +4771,37 @@ export function validate(input: ValidateInput): ValidateReport {
4289
4771
  // requires: a part this rule could not read must not be certified by
4290
4772
  // it.
4291
4773
  const plate = readPlate(abs);
4774
+ // 🚨 **The scan's coordinates are the atlas's, so a file that is not the
4775
+ // declared grid is a non-measurement for every region on it** (issue
4776
+ // #715), and #705's clause above does not cover it. That one fires when
4777
+ // a rectangle has NO texel on the page; a page whose image is a rescale
4778
+ // of the declared size leaves most rectangles partly on it, at
4779
+ // coordinates that address a different part of the picture. Measured on
4780
+ // a two-region pack at a uniform 0.5: the opaque part's failure
4781
+ // DISAPPEARED — the scan found a transparent texel 32 texels away from
4782
+ // it and returned — while the other printed #705's sentence over 0 of
4783
+ // 256 texels. A verdict and a silence, both about texels nobody located.
4784
+ //
4785
+ // A FAIL for #705's reason, word for word: `skip()` is per assertion and
4786
+ // would delete the verdicts on every other page in the same report.
4787
+ if (plate.width !== page.width || plate.height !== page.height) {
4788
+ for (const region of on) {
4789
+ if (baseRegions.has(region.name)) continue;
4790
+ const { width, height } = pageFootprint(region);
4791
+ fail(
4792
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
4793
+ `part "${region.name}" is not measured: this rule opens the page at the coordinates the atlas ` +
4794
+ `states, and page "${page.name}" declares ${page.width}x${page.height} over a ` +
4795
+ `${plate.width}x${plate.height} image, so the ${width}x${height} rectangle at ${region.x},` +
4796
+ `${region.y} is not where "${region.name}"'s texels are on this file and this rule states ` +
4797
+ 'nothing about whether it can draw a transparent pixel. The page grid is ' +
4798
+ "A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and it names the ratio and how to declare the page " +
4799
+ 'honestly. This is renderer policy, and it belongs to --profile spine-html: the default ' +
4800
+ '--profile spine does not run this check.',
4801
+ );
4802
+ }
4803
+ continue;
4804
+ }
4292
4805
  for (const region of on) {
4293
4806
  if (baseRegions.has(region.name)) continue;
4294
4807
  const { width, height } = pageFootprint(region);
@@ -4341,7 +4854,7 @@ export function validate(input: ValidateInput): ValidateReport {
4341
4854
  }
4342
4855
  continue;
4343
4856
  }
4344
- const info = readPngInfo(abs);
4857
+ const info = header.info;
4345
4858
  if (info.hasTransparency) continue;
4346
4859
  if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
4347
4860
  fail(