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/ingest.ts CHANGED
@@ -44,7 +44,7 @@
44
44
  * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
45
45
  * spec.
46
46
  */
47
- import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
47
+ import { PHYSICS_COMPONENTS, SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
48
48
  import { CompileError } from './errors.ts';
49
49
  import { CHANNELS_BY_KIND } from './timelines.ts';
50
50
  import {
@@ -292,6 +292,47 @@ const CONSTRAINT_FIELDS: Record<string, string[]> = {
292
292
  slider: carried('RigSliderConstraint', 'name', 'type'),
293
293
  };
294
294
 
295
+ /**
296
+ * The physics constraints that drive nothing, by name, each with the component
297
+ * fields it DOES state — every one of them at most 0 (issue #731).
298
+ *
299
+ * 🔑 The runtime's own predicate, not a reading of it: `PhysicsConstraint.update`
300
+ * decides what it applies with `this.data.x > 0` and its four siblings
301
+ * (`PhysicsConstraint.js:112`) and nothing else, so a constraint with none of
302
+ * `PHYSICS_COMPONENTS` above 0 moves no bone. `build` refuses exactly that shape
303
+ * by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the gate — so carrying one
304
+ * through made the decompiled spec of a file an editor exports unbuildable as a
305
+ * whole, over a constraint that did nothing in it.
306
+ *
307
+ * ⚠️ A component that is present and NOT a number is not inert: the parser takes
308
+ * it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
309
+ * constraint is carried and rigc's own parser says what is wrong with it.
310
+ */
311
+ function inertPhysics(root: JsonObject): Map<string, string[]> {
312
+ const out = new Map<string, string[]>();
313
+ for (const raw of arr(root.constraints)) {
314
+ const constraint = obj(raw);
315
+ if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
316
+ const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
317
+ if (!stated.every((field) => typeof constraint[field] === 'number' && !((constraint[field] as number) > 0))) continue;
318
+ out.set(
319
+ constraint.name,
320
+ stated.map((field) => `${field} ${String(constraint[field])}`),
321
+ );
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** What omitting one inert physics constraint took with it, for its finding. */
327
+ interface PhysicsOmission {
328
+ /** `animation "<a>" <timeline>`, in file order. */
329
+ timelines: string[];
330
+ /** The skins whose `physics` member list named it. */
331
+ skins: string[];
332
+ /** Per animation, the latest key time on an omitted timeline of this constraint. */
333
+ lastKeys: Map<string, number>;
334
+ }
335
+
295
336
  /**
296
337
  * The per-skin member lists (`SkeletonJson` reads them before `attachments`).
297
338
  *
@@ -446,8 +487,11 @@ const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
446
487
 
447
488
  /**
448
489
  * The slot timelines the FORMAT has and the motion spec has no track for —
449
- * `rgb`, `alpha` and `rgb2` as this is written, and whatever is left the next
450
- * time the spec grows one.
490
+ * **none** since issue #730 spelled `rgb`, `alpha` and `rgb2`, which were the
491
+ * last three. It stays derived rather than deleted, because an empty
492
+ * difference of two tables is a measurement and a deleted one is a claim: the
493
+ * day the format grows a seventh slot timeline, this is where it appears, and
494
+ * the blocker's sentence names it without an edit.
451
495
  *
452
496
  * ⭐ Both sides are derived, and that is the whole reason it exists rather than
453
497
  * being spelled into the blocker's sentence. `docs/INGEST.md`'s row for this
@@ -588,6 +632,19 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
588
632
  // declares no stage; when it does, this is the one place that changes.
589
633
  const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
590
634
 
635
+ // -- physics constraints that drive nothing (issue #731) ------------------
636
+ // Read before the skins, because a skin's `physics` member list is one of the
637
+ // three places such a constraint is named and each has to let go of it.
638
+ const inert = inertPhysics(root);
639
+ const omissions = new Map<string, PhysicsOmission>();
640
+ const omission = (name: string): PhysicsOmission => {
641
+ const found = omissions.get(name);
642
+ if (found !== undefined) return found;
643
+ const made: PhysicsOmission = { timelines: [], skins: [], lastKeys: new Map() };
644
+ omissions.set(name, made);
645
+ return made;
646
+ };
647
+
591
648
  // -- bones ----------------------------------------------------------------
592
649
  // Inverts `buildBone`, which copies every declared field and omits the rest.
593
650
  const bones = arr(root.bones).map((raw) => {
@@ -645,7 +702,17 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
645
702
  let anyList = false;
646
703
  for (const list of SKIN_LISTS) {
647
704
  if (entry[list] !== undefined) {
648
- lists[list] = entry[list];
705
+ let members = entry[list];
706
+ if (list === 'physics' && Array.isArray(members)) {
707
+ const named = members.filter((member): member is string => typeof member === 'string' && inert.has(member));
708
+ for (const member of named) omission(member).skins.push(nameOf(entry));
709
+ members = members.filter((member) => !(typeof member === 'string' && inert.has(member)));
710
+ // A list that named nothing BUT omitted constraints goes with them: an
711
+ // empty list and an absent one read the same, and only one of them is
712
+ // what the rebuild writes for a skin with no physics member.
713
+ if (named.length > 0 && (members as unknown[]).length === 0) continue;
714
+ }
715
+ lists[list] = members;
649
716
  anyList = true;
650
717
  }
651
718
  }
@@ -669,6 +736,9 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
669
736
  note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
670
737
  continue;
671
738
  }
739
+ // Omitted rather than carried, and said below once the timelines it takes
740
+ // with it are known (`PHYSICS_DRIVES_NOTHING`).
741
+ if (type === 'physics' && typeof constraint.name === 'string' && inert.has(constraint.name)) continue;
672
742
  const out: JsonObject = { name: constraint.name, type };
673
743
  for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
674
744
  for (const key of Object.keys(constraint)) {
@@ -681,7 +751,43 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
681
751
  // -- animations -----------------------------------------------------------
682
752
  const animations: JsonObject = {};
683
753
  for (const [animName, raw] of objEntries(root.animations)) {
684
- animations[animName] = ingestAnimation(animName, raw, root, note);
754
+ animations[animName] = ingestAnimation(animName, raw, root, note, inert, (name, property, lastKey) => {
755
+ const one = omission(name);
756
+ one.timelines.push(`animation "${animName}" ${property}`);
757
+ one.lastKeys.set(animName, Math.max(one.lastKeys.get(animName) ?? 0, lastKey));
758
+ });
759
+ }
760
+
761
+ // One finding per inert constraint, in the file's own order, naming what went
762
+ // with it. ⚠️ An animation's duration is the largest key time it has LEFT, so
763
+ // an omitted timeline that held the last key shortens the rebuilt animation —
764
+ // the one place this omission is not a no-op, and the detail says so there.
765
+ for (const [name, stated] of inert) {
766
+ const gone = omission(name);
767
+ const shortened = [...gone.lastKeys]
768
+ .map(([animName, lastKey]) => [animName, lastKey, Number(obj(animations[animName]).duration)] as const)
769
+ .filter(([, lastKey, duration]) => lastKey > duration)
770
+ .map(
771
+ ([animName, lastKey, duration]) =>
772
+ `animation "${animName}" had its last key at ${lastKey}s on one of them, so the rebuilt animation ends at ${duration}s`,
773
+ );
774
+ note(
775
+ 'lossy',
776
+ 'PHYSICS_DRIVES_NOTHING',
777
+ `constraint "${name}" (physics)`,
778
+ `drives no component: ${PHYSICS_COMPONENTS.join(', ')} are ` +
779
+ (stated.length ? `absent or at most 0 (it states ${stated.join(', ')})` : 'all absent') +
780
+ ', and `PhysicsConstraint.update` applies one only above 0, so it moves no bone and `build` would refuse it ' +
781
+ 'by name (A23_PHYSICS_CONSTRAINT_EFFECTIVE). The rig spec omits it' +
782
+ (gone.timelines.length
783
+ ? `, and with it the ${gone.timelines.length} timeline(s) keyed to it, which would name a constraint the ` +
784
+ `rebuild does not have: ${gone.timelines.join('; ')}`
785
+ : '; no timeline keys it') +
786
+ (gone.skins.length ? `; it is taken off the physics list of skin(s) ${gone.skins.map((skin) => `"${skin}"`).join(', ')}` : '') +
787
+ (shortened.length
788
+ ? `. One thing does move, because a duration is the last key an animation has left: ${shortened.join('; ')}`
789
+ : '. The rebuild differs from the source by exactly these no-ops'),
790
+ );
685
791
  }
686
792
 
687
793
  // -- assemble -------------------------------------------------------------
@@ -1053,6 +1159,53 @@ function ingestAttachment(
1053
1159
  const type = att.type === undefined ? 'region' : String(att.type);
1054
1160
  const out: JsonObject = {};
1055
1161
 
1162
+ // A LINKED mesh, in either of the format's two spellings. The test is the
1163
+ // parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
1164
+ // decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1165
+ // `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
1166
+ // is read as an ordinary mesh, whose `uvs` it does not have, and the parser
1167
+ // throws on it. Reading the second as a link would be this module inventing a
1168
+ // construct the file does not contain (issue #691).
1169
+ const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
1170
+
1171
+ // The three types the parser gives a texture `path` to, which is exactly the
1172
+ // three that call `carryArt` below. `readAttachment` reaches
1173
+ // `getValue(map, "path", name)` in the `region` branch (`SkeletonJson.js:529`)
1174
+ // and in the shared `mesh`/`linkedmesh` branch (`:560`); `boundingbox`,
1175
+ // `path`, `point` and `clipping` are constructed from the name alone and
1176
+ // resolve no region at all.
1177
+ const resolvesRegion = linked || type === 'region' || type === 'mesh';
1178
+
1179
+ /**
1180
+ * The atlas region a dropped `name` was carrying, which the rebuild has to
1181
+ * state as `path` or lose.
1182
+ *
1183
+ * 🚨 **The name is not bookkeeping — it is the region key** (issue #742).
1184
+ * `readAttachment` reads `name = getValue(map, "name", placeholder)` and then
1185
+ * `path = getValue(map, "path", name)`, so `path` defaults to the NAME and not
1186
+ * to the placeholder. A source that states a `name` and no `path` therefore
1187
+ * draws the region that name spells; rigc drops the name (the rig spec has no
1188
+ * field for one — see below) and used to write no `path` either, so the
1189
+ * rebuild asked the atlas for the PLACEHOLDER. Measured on a forged export
1190
+ * whose three attachments state `name` and no `path`: the source loads against
1191
+ * its own pack and the rebuild failed `A08_REGION_NAMES_MATCH_ATTACHMENTS`
1192
+ * three times and `A00_ROUNDTRIP_PARSE` with it.
1193
+ *
1194
+ * ⚠️ A name EQUAL to the placeholder resolves the same region either way, so
1195
+ * nothing is written for it — the rebuild would otherwise spell a field the
1196
+ * source did not, and `A18`'s reference is the source file.
1197
+ *
1198
+ * 🔑 This is not `at.contested`'s business, and that is the half the card
1199
+ * nearly missed. Where a placeholder is contested rigc composes a name of its
1200
+ * own and `nameSkinAttachment` pins `path` at the PLACEHOLDER — so a contested
1201
+ * source naming its own regions lost them with no finding at all, which is
1202
+ * quieter than the uncontested case rather than safer.
1203
+ */
1204
+ const keptPath =
1205
+ att.path === undefined && resolvesRegion && typeof att.name === 'string' && att.name !== placeholder
1206
+ ? att.name
1207
+ : undefined;
1208
+
1056
1209
  // `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
1057
1210
  // where a placeholder is contested — so the rig spec has no field for it and
1058
1211
  // it is dropped. Where the contest survives the round trip the same name comes
@@ -1063,17 +1216,34 @@ function ingestAttachment(
1063
1216
  'ATTACHMENT_NAME',
1064
1217
  at.where,
1065
1218
  `the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
1066
- 'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541)',
1219
+ 'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541). ' +
1220
+ (keptPath !== undefined
1221
+ ? `That name was also the ATLAS REGION this attachment resolves, because \`path\` defaults to the name and ` +
1222
+ `not to the placeholder (\`SkeletonJson.js:526\`, \`:529\`, \`:560\`) — so the region key is KEPT as ` +
1223
+ `"path": ${JSON.stringify(keptPath)} and the name alone is lost`
1224
+ : att.path !== undefined
1225
+ ? `The region key is the source's own \`path\` ${JSON.stringify(att.path)}, carried unchanged, so the ` +
1226
+ 'name alone is lost'
1227
+ : resolvesRegion
1228
+ ? 'The name is the placeholder itself, so the rebuild resolves the same atlas region and no `path` is ' +
1229
+ 'written for it'
1230
+ : `An attachment of type ${JSON.stringify(type)} resolves no atlas region — the parser builds it from ` +
1231
+ 'the name alone — so there is no region key to keep and the name is the whole loss'),
1067
1232
  );
1068
1233
  }
1069
1234
 
1070
1235
  /** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
1071
1236
  const carryArt = (): void => {
1072
1237
  if (att.path !== undefined) out.path = att.path;
1238
+ else if (keptPath !== undefined) out.path = keptPath;
1073
1239
  // `buildRigRegion` writes `path` when the image basename differs from the
1074
- // placeholder, so naming the image after `path ?? placeholder` reproduces
1075
- // the same `path` decision AND the same atlas region name.
1076
- if (opts.art === 'loose') out.image = `${att.path === undefined ? placeholder : String(att.path)}.png`;
1240
+ // placeholder, so naming the image after the region this attachment RESOLVES
1241
+ // reproduces the same `path` decision AND the same atlas region name. Read
1242
+ // off `out.path` rather than off `att.path`, so that the region a dropped
1243
+ // `name` was carrying names the loose PNG too — otherwise `path` and `image`
1244
+ // would disagree and `attachmentPath` would emit the first while the atlas
1245
+ // was packed under the second.
1246
+ if (opts.art === 'loose') out.image = `${out.path === undefined ? placeholder : String(out.path)}.png`;
1077
1247
  if (att.width !== undefined) out.width = att.width;
1078
1248
  if (att.height !== undefined) out.height = att.height;
1079
1249
  };
@@ -1094,15 +1264,6 @@ function ingestAttachment(
1094
1264
 
1095
1265
  const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
1096
1266
 
1097
- // A LINKED mesh, in either of the format's two spellings. The test is the
1098
- // parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
1099
- // decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
1100
- // `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
1101
- // is read as an ordinary mesh, whose `uvs` it does not have, and the parser
1102
- // throws on it. Reading the second as a link would be this module inventing a
1103
- // construct the file does not contain (issue #691).
1104
- const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
1105
-
1106
1267
  if (linked) {
1107
1268
  out.type = 'linkedmesh';
1108
1269
  carryArt();
@@ -1192,7 +1353,14 @@ function ingestAttachment(
1192
1353
  }
1193
1354
 
1194
1355
  /** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
1195
- function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, note: Note): JsonObject {
1356
+ function ingestAnimation(
1357
+ animName: string,
1358
+ anim: JsonObject,
1359
+ root: JsonObject,
1360
+ note: Note,
1361
+ inert: ReadonlyMap<string, unknown>,
1362
+ omitted: (constraint: string, property: string, lastKey: number) => void,
1363
+ ): JsonObject {
1196
1364
  const tracks: JsonObject[] = [];
1197
1365
  let maxT = 0;
1198
1366
  const seeT = (t: number): void => {
@@ -1283,6 +1451,20 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
1283
1451
  const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
1284
1452
  for (const [name, timelines] of objEntries(anim[group])) {
1285
1453
  for (const [property, keys] of arrEntries(timelines)) {
1454
+ // A timeline keyed to a physics constraint `ingest` omitted (issue #731)
1455
+ // goes with it: carried, it names a constraint the rebuilt rig has not
1456
+ // got, and `build` refuses the whole motion spec over it. Its keys are
1457
+ // not seen by `seeT` — they are not in the rebuild — and their latest
1458
+ // time is handed back so the finding can say when that shortened one.
1459
+ if (group === 'physics' && inert.has(name)) {
1460
+ let lastKey = 0;
1461
+ for (const raw of keys) {
1462
+ const t = obj(raw).time;
1463
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1464
+ }
1465
+ omitted(name, property, lastKey);
1466
+ continue;
1467
+ }
1286
1468
  const shape = shapes[property];
1287
1469
  const where = `animation "${animName}" ${group} "${name}" ${property}`;
1288
1470
  if (shape === undefined) {
@@ -1358,14 +1540,58 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
1358
1540
  return entry;
1359
1541
  }),
1360
1542
  });
1543
+ } else if (property === 'alpha') {
1544
+ // Inverts `compileTrack`'s alpha branch, whose key is `{time, value}` —
1545
+ // the one colour shape the format stores as a number, read by
1546
+ // `readTimeline1` with a per-key default of **0**. That is a value
1547
+ // track's shape exactly, so it goes through the same inversion and
1548
+ // inherits its two findings: an omitted `value` is written out at 0 and
1549
+ // reported as `TIMELINE_KEY_RESTATED`, and a field the shape has no
1550
+ // place for is a `TIMELINE_FIELD` blocker (issue #730).
1551
+ valueTrack({ slot }, property, keys, [['value', 0]], where);
1552
+ } else if (property === 'rgb' || property === 'rgb2') {
1553
+ // Inverts `compileTrack`'s other two separable shapes (issue #730):
1554
+ // `rgb` is `{time, color: "rrggbb"}` and `rgb2` is `{time, light:
1555
+ // "rrggbb", dark: "rrggbb"}`, and the spec's `v` is their channels in
1556
+ // `readCurve`'s order. Each keeps its own name and its own key times:
1557
+ // folding an `rgb` and an `alpha` into one `rgba` would state each
1558
+ // channel at the other's key times, a value nobody keyed.
1559
+ tracks.push({
1560
+ slot,
1561
+ property,
1562
+ keys: keys.map((raw) => {
1563
+ const key = obj(raw);
1564
+ const v =
1565
+ property === 'rgb'
1566
+ ? hexToRgb(String(key.color))
1567
+ : [...hexToRgb(String(key.light)), ...hexToRgb(String(key.dark))];
1568
+ const entry: JsonObject = { t: timeOf(key), v };
1569
+ easing(key, entry);
1570
+ return entry;
1571
+ }),
1572
+ });
1361
1573
  } else {
1574
+ // 🔒 Composed from both tables, so it says the true thing whichever of
1575
+ // two states it is reached in. A name the FORMAT has and the spec does
1576
+ // not is the first; since issue #730 carried the last three there is no
1577
+ // such name (`UNSPELT_SLOT_TRACKS` is empty), and what still reaches
1578
+ // here is a name the format does not have at all — which
1579
+ // `SkeletonJson.readAnimation` throws on (`Invalid timeline type for a
1580
+ // slot`), so the sentence says so instead of printing an empty
1581
+ // "remaining" list about a file no runtime loads.
1582
+ const inFormat = property in CHANNELS_BY_KIND.slot;
1362
1583
  note(
1363
1584
  'blocker',
1364
1585
  'SLOT_TIMELINE',
1365
1586
  where,
1366
- `timeline "${property}" is in the format and the motion spec has no track for it — a slot track is ` +
1367
- `${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here. The format's ` +
1368
- `remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`,
1587
+ (inFormat
1588
+ ? `timeline "${property}" is in the format and the motion spec has no track for it`
1589
+ : `timeline "${property}" is not a slot timeline the format has — the runtime's reader throws ` +
1590
+ '"Invalid timeline type for a slot" on it, so no player loads this file') +
1591
+ ` — a slot track is ${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here` +
1592
+ (UNSPELT_SLOT_TRACKS.length > 0
1593
+ ? `. The format's remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`
1594
+ : ''),
1369
1595
  );
1370
1596
  }
1371
1597
  }
package/src/png.ts CHANGED
@@ -89,15 +89,170 @@ function scanForTrns(buf: Buffer): boolean {
89
89
  return false;
90
90
  }
91
91
 
92
- export function readPngInfo(path: string): PngInfo {
93
- const buf = readFileSync(path);
94
- if (buf.length < 26) throw new Error(`not a PNG (too short): ${path}`);
95
- for (let i = 0; i < SIGNATURE.length; i++) {
96
- if (buf[i] !== SIGNATURE[i]) throw new Error(`not a PNG (bad signature): ${path}`);
92
+ function hexBytes(bytes: Uint8Array): string {
93
+ return [...bytes].map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
94
+ }
95
+
96
+ /** The signature every PNG begins with, as the messages below print it. */
97
+ const PNG_SIGNATURE_HEX = hexBytes(Uint8Array.from(SIGNATURE));
98
+
99
+ function startsWith(buf: Uint8Array, at: number, bytes: ReadonlyArray<number>): boolean {
100
+ if (buf.length < at + bytes.length) return false;
101
+ for (let i = 0; i < bytes.length; i++) if (buf[at + i] !== bytes[i]) return false;
102
+ return true;
103
+ }
104
+
105
+ const ascii = (text: string): number[] => [...text].map((c) => c.charCodeAt(0));
106
+
107
+ /**
108
+ * The image formats a page file is found to be in instead, by their own
109
+ * signatures. Each entry is the format's fixed leading bytes and nothing
110
+ * inferred from them — rigc decodes none of these, so the one honest thing to
111
+ * say about such a file is what its first bytes are.
112
+ *
113
+ * ⭐ Named at all because a file's NAME is not evidence (issue #732): a
114
+ * production pack shipped WebP pages called `*.png`, and "bad signature" sent
115
+ * the author to look for corruption in a well-formed image of another format.
116
+ */
117
+ const OTHER_FORMATS: ReadonlyArray<{ name: string; matches: (buf: Uint8Array) => boolean }> = [
118
+ // A RIFF container with the form type at byte 8. The first chunk's FourCC
119
+ // (`VP8 `, `VP8L`, `VP8X`) is printed beside it by `whatItIs`, never read.
120
+ { name: 'WebP', matches: (buf) => startsWith(buf, 0, ascii('RIFF')) && startsWith(buf, 8, ascii('WEBP')) },
121
+ { name: 'JPEG', matches: (buf) => startsWith(buf, 0, [0xff, 0xd8, 0xff]) },
122
+ { name: 'GIF', matches: (buf) => startsWith(buf, 0, ascii('GIF87a')) || startsWith(buf, 0, ascii('GIF89a')) },
123
+ { name: 'KTX', matches: (buf) => startsWith(buf, 0, [0xab, ...ascii('KTX 11'), 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]) },
124
+ { name: 'KTX2', matches: (buf) => startsWith(buf, 0, [0xab, ...ascii('KTX 20'), 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]) },
125
+ ];
126
+
127
+ /** What a file that does not begin with the PNG signature is, as a phrase. */
128
+ function whatItIs(buf: Uint8Array): string {
129
+ const found = OTHER_FORMATS.find((format) => format.matches(buf));
130
+ if (found === undefined) {
131
+ return (
132
+ 'in no image format rigc recognises by its signature (it names ' +
133
+ `${OTHER_FORMATS.map((format) => format.name).join(', ')} when it meets them)`
134
+ );
135
+ }
136
+ if (found.name === 'WebP' && buf.length >= 16) {
137
+ const chunk = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
138
+ return `a WebP image (a RIFF/WEBP container whose first chunk is ${JSON.stringify(chunk)})`;
97
139
  }
98
- if (buf.toString('latin1', 12, 16) !== 'IHDR') {
99
- throw new Error(`PNG does not start with IHDR: ${path}`);
140
+ return `a ${found.name} image`;
141
+ }
142
+
143
+ /**
144
+ * Why the bytes at `path` are not a PNG rigc can read, as one sentence — or
145
+ * `null` when they are.
146
+ *
147
+ * 🔒 **The one reader of a page file's identity** (issue #732). Every reader of
148
+ * a page goes through it: `readPngInfo` and `readPngHeader` below (the size
149
+ * `A06` judges, the alpha `A19` judges on a one-part page, the sizes the
150
+ * compiler takes from a loose part or a pack's page) and `readPlate` in
151
+ * [`tools/plate.ts`](../tools/plate.ts) (the renderer, `A19`'s scan of a shared
152
+ * page, the region lift and the packer). Before it, one WebP page reached the
153
+ * gate as `A06`'s `threw: not a PNG (bad signature)` and `A19`'s `threw: cannot
154
+ * decode PNG …: unexpected end of file`, and reached `build --atlas-in` as a
155
+ * stack trace — three sentences about one file, and none said what it was.
156
+ *
157
+ * It says three things and decodes nothing: what the file is instead (by the
158
+ * signature it does carry, with its first bytes in hex either way), whether a
159
+ * file that begins as a PNG ends before its chunks do, and whether IHDR comes
160
+ * first. The chunk walk reads only the eight-byte chunk headers, so it costs a
161
+ * skip through the file and no inflate.
162
+ *
163
+ * ⚠️ **"Truncated" is judged by the chunk walk, not by a length floor.** The
164
+ * floor that stood here (`buf.length < 26`, "too short") never said truncated,
165
+ * and a PNG cut anywhere after its IHDR passed it: measured on a packed page
166
+ * cut to 60 bytes, the default profile built it green and wrote a skeleton
167
+ * whose page no reader could decode. A PNG ends at its IEND chunk, so a file
168
+ * that runs out first is truncated wherever it runs out.
169
+ */
170
+ export function pngProblem(buf: Uint8Array, path: string): string | null {
171
+ if (buf.length === 0) return `${path} is empty (0 bytes), and a PNG's first 8 bytes are ${PNG_SIGNATURE_HEX}`;
172
+ if (!startsWith(buf, 0, SIGNATURE.slice(0, Math.min(buf.length, SIGNATURE.length)))) {
173
+ const shown = buf.subarray(0, 12);
174
+ const byName = /\.png$/i.test(path) ? '; its name ends in .png, and it is the bytes that decide what a file is' : '';
175
+ return (
176
+ `${path} is ${whatItIs(buf)}, not a PNG: its first ${shown.length} byte(s) are ${hexBytes(shown)}, where a ` +
177
+ `PNG's first 8 are ${PNG_SIGNATURE_HEX}${byName}. rigc reads PNG and nothing else — its page-size and alpha ` +
178
+ 'readers, its renderer and its region lift all decode PNG, and it links no decoder for any other format — ' +
179
+ 'so nothing in this file was measured. Re-export it as PNG'
180
+ );
100
181
  }
182
+ const truncated = (where: string): string =>
183
+ `${path} is a truncated PNG: it is ${buf.length} byte(s) long and ${where}, so no reader can decode it. ` +
184
+ 'Re-export or re-copy the file whole';
185
+ if (buf.length < SIGNATURE.length) return truncated(`ends ${buf.length} byte(s) into the 8-byte PNG signature`);
186
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
187
+ let at = SIGNATURE.length;
188
+ let previous = 'the signature';
189
+ for (;;) {
190
+ if (at + 8 > buf.length) {
191
+ return truncated(
192
+ at === buf.length
193
+ ? `ends after ${previous} with no IEND chunk`
194
+ : `ends ${buf.length - at} byte(s) into the header of the chunk after ${previous}`,
195
+ );
196
+ }
197
+ const length = view.getUint32(at);
198
+ const type = String.fromCharCode(buf[at + 4], buf[at + 5], buf[at + 6], buf[at + 7]);
199
+ if (at === SIGNATURE.length && type !== 'IHDR') {
200
+ return `${path} is a PNG whose first chunk is ${JSON.stringify(type)} rather than the IHDR the format requires first`;
201
+ }
202
+ if (type === 'IHDR' && length !== 13) {
203
+ return `${path} is a PNG whose IHDR chunk declares ${length} byte(s) of data, where the format's is 13`;
204
+ }
205
+ const next = at + 12 + length; // 4 length + 4 type + body + 4 CRC
206
+ if (next > buf.length) {
207
+ return truncated(
208
+ `its ${type} chunk at byte ${at} declares ${length} byte(s) of data, which with its CRC runs to byte ${next}`,
209
+ );
210
+ }
211
+ if (type === 'IEND') return null;
212
+ previous = `its ${type} chunk at byte ${at}`;
213
+ at = next;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * A file that is not a PNG rigc can read, thrown by the readers that return a
219
+ * value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s,
220
+ * whole; a caller that owns a named failure — the gate, a `CompileError` —
221
+ * reads the sentence instead of catching the throw (`readPngHeader`).
222
+ */
223
+ export class NotAPngError extends Error {
224
+ readonly path: string;
225
+ constructor(path: string, message: string) {
226
+ super(message);
227
+ this.name = 'NotAPngError';
228
+ this.path = path;
229
+ }
230
+ }
231
+
232
+ /** `pngProblem` as a refusal, for the readers that return a value or throw. */
233
+ export function assertPng(buf: Uint8Array, path: string): void {
234
+ const problem = pngProblem(buf, path);
235
+ if (problem !== null) throw new NotAPngError(path, problem);
236
+ }
237
+
238
+ /**
239
+ * A page's header, or the sentence that says why it has none — for a caller
240
+ * whose failures are named rather than thrown.
241
+ */
242
+ export function readPngHeader(path: string): { info: PngInfo; problem: null } | { info: null; problem: string } {
243
+ const buf = readFileSync(path);
244
+ const problem = pngProblem(buf, path);
245
+ return problem === null ? { info: headerOf(buf), problem: null } : { info: null, problem };
246
+ }
247
+
248
+ export function readPngInfo(path: string): PngInfo {
249
+ const buf = readFileSync(path);
250
+ assertPng(buf, path);
251
+ return headerOf(buf);
252
+ }
253
+
254
+ /** The IHDR fields of a file `pngProblem` has already accepted. */
255
+ function headerOf(buf: Buffer): PngInfo {
101
256
  const colourType = buf.readUInt8(25);
102
257
  const hasAlpha = COLOUR_TYPE_HAS_ALPHA[colourType] ?? false;
103
258
  // A file with an alpha channel cannot also carry tRNS, so the scan is skipped
package/src/rig.ts CHANGED
@@ -1934,8 +1934,35 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1934
1934
  // consumer's process, so they are refused here where the message can name the
1935
1935
  // rig spec. What the parser does NOT check is the pairing with `skin: true`,
1936
1936
  // and that half is silent in both directions (see `RigSkinEntry`).
1937
- const skinBoneUse = new Map<string, string>();
1938
- const skinConstraintUse = new Map<string, string>();
1937
+ //
1938
+ // ⭐ **Sets rather than "which skin owns this", because a name may be in
1939
+ // several lists.** Until issue #725 these were `name -> the skin that claimed
1940
+ // it first`, and a second skin naming the same bone or constraint was refused
1941
+ // with *"a bone belongs to one skin"*. The rule was never the parser's and the
1942
+ // comment above it said so; what it rested on was that "which skin am I for"
1943
+ // has no answer for a name in two lists. It has one, and the runtime gives it:
1944
+ // `Skeleton.updateCache` activates the bones of the skin being WORN, so a bone
1945
+ // two mutually exclusive variants both list is active under either — measured
1946
+ // on a hand-forged file the refusal used to prevent, `bone.active` true under
1947
+ // each of the two skins, false under a third that lists nothing and false with
1948
+ // no skin set. `Skin.addSkin` deduplicates by object identity, so even a
1949
+ // consumer combining both variants gets the bone once. The format is a
1950
+ // per-skin SET and rigc now says the same thing.
1951
+ //
1952
+ // ⚠️ **The worn skin, and only the worn skin.** `updateCache` reads
1953
+ // `this.skin` and never `SkeletonData.defaultSkin` — the default-skin fallback
1954
+ // is `getAttachment`'s and covers art alone — so a `skin: true` bone that only
1955
+ // the `default` skin lists is measured INACTIVE under every other skin and
1956
+ // with no skin set. That is why nothing here is a union, and it is the one
1957
+ // shape an author is most likely to write expecting "always on".
1958
+ //
1959
+ // What the rule was really guarding — a second list that was meant to name a
1960
+ // different bone — is not derivable from the file, so it is not refused. Every
1961
+ // refusal that IS derivable stays: a name the rig does not declare, a name
1962
+ // declared under another constraint kind, and both halves of the `skin: true`
1963
+ // switch, each of which the rig suite now measures on a shared member.
1964
+ const skinBoneUse = new Set<string>();
1965
+ const skinConstraintUse = new Set<string>();
1939
1966
  if (spec.skins !== undefined) {
1940
1967
  if (!isObj(spec.skins)) throw new CompileError(`${where}: "skins" is an object keyed by skin name`);
1941
1968
  const collision = RIG_SKIN_KEYS.find((key) => slotNames.has(key));
@@ -1956,17 +1983,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1956
1983
  if (!seen.has(bone)) {
1957
1984
  throw new CompileError(`${at} activates bone "${bone}", which this rig does not declare`);
1958
1985
  }
1959
- const previous = skinBoneUse.get(bone);
1960
- if (previous !== undefined && previous !== skinName) {
1961
- // Not a parser rule — a rig-spec one. `updateCache` activates the union
1962
- // of the current skin's bones, so a bone in two skins is a bone whose
1963
- // "which skin am I for" question has no answer, and the second list is
1964
- // usually a copy-paste that was meant to be a different bone.
1965
- throw new CompileError(
1966
- `${at} activates bone "${bone}", which skin "${previous}" already activates; a bone belongs to one skin`,
1967
- );
1968
- }
1969
- skinBoneUse.set(bone, skinName);
1986
+ skinBoneUse.add(bone);
1970
1987
  const declared = spec.bones.find((b) => b.name === bone);
1971
1988
  if (declared?.skin !== true) {
1972
1989
  throw new CompileError(
@@ -1994,13 +2011,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1994
2011
  'a skin looks its constraints up by name AND type, so this one is a miss and the loader throws',
1995
2012
  );
1996
2013
  }
1997
- const previous = skinConstraintUse.get(constraintAt(type, name));
1998
- if (previous !== undefined && previous !== skinName) {
1999
- throw new CompileError(
2000
- `${at} activates ${type} constraint "${name}", which skin "${previous}" already activates; a constraint belongs to one skin`,
2001
- );
2002
- }
2003
- skinConstraintUse.set(constraintAt(type, name), skinName);
2014
+ skinConstraintUse.add(constraintAt(type, name));
2004
2015
  if (!facts.skinRequired) {
2005
2016
  throw new CompileError(
2006
2017
  `${at} activates ${type} constraint "${name}", but that constraint does not declare \`"skin": true\`. ` +
@@ -2016,7 +2027,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2016
2027
  // there is. Outside the block above on purpose — a rig with no `skins` at all
2017
2028
  // is the strongest case of it. A listed bone activates its ancestors too
2018
2029
  // (`Skeleton.ts:198-205`), so a parent reachable only that way is not dead.
2019
- const activated = new Set(skinBoneUse.keys());
2030
+ const activated = new Set(skinBoneUse);
2020
2031
  const parentOf = new Map(spec.bones.map((b) => [b.name, b.parent]));
2021
2032
  for (const bone of [...activated]) {
2022
2033
  for (let cursor = parentOf.get(bone); cursor; cursor = parentOf.get(cursor)) activated.add(cursor);