spine-rigc 0.30.0 → 0.32.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.
@@ -94,6 +94,7 @@ import {
94
94
  type Attachment,
95
95
  AtlasAttachmentLoader,
96
96
  type Bone,
97
+ type CurveTimeline,
97
98
  DeformTimeline,
98
99
  FromProperty,
99
100
  FromRotate,
@@ -1937,7 +1938,7 @@ function curveLegs(timeline: DeformTimeline, frame: number): { kind: DeformSpanC
1937
1938
  }
1938
1939
 
1939
1940
  /** Points `CurveTimeline.setBezier` stores per curve — `BEZIER_SIZE / 2`. */
1940
- const BEZIER_POINTS = 9;
1941
+ export const BEZIER_POINTS = 9;
1941
1942
 
1942
1943
  /**
1943
1944
  * `CurveTimeline.curves`, which is `protected` and is read anyway.
@@ -1956,8 +1957,12 @@ const BEZIER_POINTS = 9;
1956
1957
  *
1957
1958
  * `A05` already gates the emitted curve arrays, and `DW18` is the control that
1958
1959
  * the reading here matches what the runtime does with them.
1960
+ *
1961
+ * Exported for `validate.ts`'s `curveChannelValues` (issue #752), which asks the
1962
+ * same array what values a constraint's `mix` timeline poses between its keys —
1963
+ * so the reach into protected storage stays one reach, in this file.
1959
1964
  */
1960
- function curveStorage(timeline: DeformTimeline): ArrayLike<number> {
1965
+ export function curveStorage(timeline: CurveTimeline): ArrayLike<number> {
1961
1966
  return (timeline as unknown as { curves: ArrayLike<number> }).curves;
1962
1967
  }
1963
1968
 
package/src/ingest.ts CHANGED
@@ -44,7 +44,13 @@
44
44
  * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
45
45
  * spec.
46
46
  */
47
- import { PHYSICS_COMPONENTS, SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
47
+ import {
48
+ composeSkinAttachmentName,
49
+ DEFAULT_SKIN,
50
+ PHYSICS_COMPONENTS,
51
+ SLOT_TRACKS as EMITTED_SLOT_TRACKS,
52
+ SPINE_VERSION,
53
+ } from './compile.ts';
48
54
  import { CompileError } from './errors.ts';
49
55
  import { CHANNELS_BY_KIND } from './timelines.ts';
50
56
  import {
@@ -54,8 +60,8 @@ import {
54
60
  spineGeneration,
55
61
  TOPLEVEL_CONSTRAINT_ARRAYS,
56
62
  } from './generation.ts';
57
- import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
58
- import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
63
+ import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
64
+ import { parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
59
65
  import type { MotionSpec } from './types.ts';
60
66
 
61
67
  /**
@@ -374,6 +380,18 @@ const BONE_TRACKS: Record<string, TrackShape> = {
374
380
  rotate: [['value', 0]],
375
381
  };
376
382
 
383
+ /**
384
+ * The bone timeline whose key holds a NAME rather than numbers — `inherit`,
385
+ * `{ time, inherit }`, stepped by the format (its reader builds no curve). The
386
+ * field and the default `SkeletonJson`'s `inherit` branch reads where a key
387
+ * omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
388
+ * `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
389
+ */
390
+ const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
391
+
392
+ /** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
393
+ const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
394
+
377
395
  /**
378
396
  * The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
379
397
  * `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
@@ -516,7 +534,7 @@ export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((na
516
534
  export const INGEST_VOCABULARY = {
517
535
  attachments: ATTACHMENT_TYPES,
518
536
  constraints: Object.keys(CONSTRAINT_FIELDS),
519
- boneTracks: Object.keys(BONE_TRACKS),
537
+ boneTracks: BONE_TRACK_NAMES,
520
538
  slotTracks: SLOT_TRACKS,
521
539
  path: Object.keys(PATH_TRACKS),
522
540
  physics: Object.keys(PHYSICS_TRACKS),
@@ -694,6 +712,7 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
694
712
  perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
695
713
  where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
696
714
  contested: contested.get(slot)?.has(placeholder) === true,
715
+ skin: nameOf(entry),
697
716
  });
698
717
  }
699
718
  table[slot] = perSlot;
@@ -1152,7 +1171,7 @@ function ingestAttachment(
1152
1171
  boneNames: readonly string[],
1153
1172
  opts: IngestOptions,
1154
1173
  note: Note,
1155
- at: { where: string; contested: boolean },
1174
+ at: { where: string; contested: boolean; skin: string },
1156
1175
  ): JsonObject {
1157
1176
  // `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
1158
1177
  // does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
@@ -1206,10 +1225,28 @@ function ingestAttachment(
1206
1225
  ? att.name
1207
1226
  : undefined;
1208
1227
 
1228
+ /** What became of the atlas region once the name is gone — the same four shapes wherever the name is lost. */
1229
+ const regionFate =
1230
+ keptPath !== undefined
1231
+ ? `That name was also the ATLAS REGION this attachment resolves, because \`path\` defaults to the name and ` +
1232
+ `not to the placeholder (\`SkeletonJson.js:526\`, \`:529\`, \`:560\`) — so the region key is KEPT as ` +
1233
+ `"path": ${JSON.stringify(keptPath)} and the name alone is lost`
1234
+ : att.path !== undefined
1235
+ ? `The region key is the source's own \`path\` ${JSON.stringify(att.path)}, carried unchanged, so the ` +
1236
+ 'name alone is lost'
1237
+ : resolvesRegion
1238
+ ? at.contested
1239
+ ? 'The region is the placeholder\'s either way — the compiler pins a composed name\'s `path` at the ' +
1240
+ 'placeholder — so the name alone is lost'
1241
+ : 'The name is the placeholder itself, so the rebuild resolves the same atlas region and no `path` is ' +
1242
+ 'written for it'
1243
+ : `An attachment of type ${JSON.stringify(type)} resolves no atlas region — the parser builds it from ` +
1244
+ 'the name alone — so there is no region key to keep and the name is the whole loss';
1245
+
1209
1246
  // `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
1210
1247
  // where a placeholder is contested — so the rig spec has no field for it and
1211
- // it is dropped. Where the contest survives the round trip the same name comes
1212
- // back; anywhere else it is a real loss and is reported.
1248
+ // it is dropped. Where one skin fills the placeholder every stated name is a
1249
+ // loss, because the rebuild writes none.
1213
1250
  if (att.name !== undefined && !at.contested) {
1214
1251
  note(
1215
1252
  'lossy',
@@ -1217,18 +1254,43 @@ function ingestAttachment(
1217
1254
  at.where,
1218
1255
  `the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
1219
1256
  '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'),
1257
+ regionFate,
1258
+ );
1259
+ }
1260
+
1261
+ /**
1262
+ * 🚨 Where a placeholder IS contested the name is re-derived, and re-derived is
1263
+ * not the same as kept (issue #746). The rebuild's attachment answers to what
1264
+ * `composeSkinAttachmentName` returns; the source's answers to what it states,
1265
+ * or to its placeholder where it states nothing (`readAttachment`'s
1266
+ * `getValue(map, "name", placeholder)`, `SkeletonJson.js:526`). Only where
1267
+ * those two strings differ is anything renamed, so only there is a line.
1268
+ *
1269
+ * ⚠️ Comparing is the whole rule, and its cheap neighbour was measured and
1270
+ * rejected on #742: firing whenever a contested entry states a name puts a
1271
+ * `LOSS` on every contested round trip of rigc's own emits, which state
1272
+ * exactly the composed name and rebuild byte for byte.
1273
+ *
1274
+ * ⚠️ A contested placeholder the DEFAULT skin fills gets no line here: the
1275
+ * compiler composes nothing for it — `refuseDefaultSkinContest` refuses the
1276
+ * rebuild by name — so a line claiming a composed name would state one no
1277
+ * build will ever emit.
1278
+ */
1279
+ const composed = composeSkinAttachmentName(at.skin, placeholder, at.contested);
1280
+ const sourceName = att.name === undefined ? placeholder : att.name;
1281
+ if (composed !== null && at.skin !== DEFAULT_SKIN && sourceName !== composed) {
1282
+ note(
1283
+ 'lossy',
1284
+ 'ATTACHMENT_NAME',
1285
+ at.where,
1286
+ (att.name === undefined
1287
+ ? `the attachment states no name, so the source names it by its placeholder ${JSON.stringify(placeholder)} ` +
1288
+ '(`SkeletonJson.js:526`)'
1289
+ : `the attachment states name ${JSON.stringify(att.name)}`) +
1290
+ `, and more than one skin fills this placeholder, so rigc composes the name ${JSON.stringify(composed)} ` +
1291
+ `on the rebuild (#541) — the rig spec has no field for a name, so the rebuilt attachment answers to ` +
1292
+ `${JSON.stringify(composed)} where the source's answered to ${JSON.stringify(sourceName)}. ` +
1293
+ regionFate,
1232
1294
  );
1233
1295
  }
1234
1296
 
@@ -1340,18 +1402,67 @@ function ingestAttachment(
1340
1402
  return out;
1341
1403
  }
1342
1404
 
1343
- if (att.sequence !== undefined) {
1344
- note(
1345
- 'blocker',
1346
- 'ATTACHMENT_SEQUENCE',
1347
- at.where,
1348
- 'the attachment carries a `sequence` block (a numbered image series), which the rig spec cannot say; ' +
1349
- 'the rebuild draws the single region this attachment names',
1350
- );
1405
+ // A numbered image series (issue #729). Carried as the file states it —
1406
+ // the four fields `readSequence` reads — wherever the rig spec can say it;
1407
+ // what is left for the blocker is a block the parser reads into a series
1408
+ // that is not the one written, or one on a kind the parser never reads it on.
1409
+ // `null` is the parser's own absent (`getValue(map, "sequence", null)`), so it
1410
+ // is read as no series rather than as a malformed one.
1411
+ if (att.sequence !== undefined && att.sequence !== null) {
1412
+ const refusal = resolvesRegion
1413
+ ? sequenceBlockRefusal(att.sequence)
1414
+ : `the attachment is a ${type}, and \`readAttachment\` reads a \`sequence\` only on a region or a mesh ` +
1415
+ '(`SkeletonJson.js:530`, `:561`) — the parser never read this one, and the rig spec refuses it there by name';
1416
+ if (refusal === null) {
1417
+ const seq = att.sequence as JsonObject;
1418
+ const carried: JsonObject = {};
1419
+ for (const field of ['count', 'start', 'digits', 'setup']) if (seq[field] !== undefined) carried[field] = seq[field];
1420
+ out.sequence = carried;
1421
+ // The frames ARE the art: `<path><number>` regions, which on the loose
1422
+ // route are PNGs of those names. A single `image` would name one region
1423
+ // the series does not have, and the rig spec refuses the pair.
1424
+ delete out.image;
1425
+ } else {
1426
+ note(
1427
+ 'blocker',
1428
+ 'ATTACHMENT_SEQUENCE',
1429
+ at.where,
1430
+ `the attachment's \`sequence\` block is ${JSON.stringify(att.sequence)}: ${refusal}. The rebuild draws the ` +
1431
+ 'single region this attachment names instead of a series',
1432
+ );
1433
+ }
1351
1434
  }
1352
1435
  return out;
1353
1436
  }
1354
1437
 
1438
+ /**
1439
+ * Why a `sequence` block cannot be carried as written, or `null` when it can —
1440
+ * the rig spec's own refusals (`checkRigSequence`), stated about a file.
1441
+ *
1442
+ * Every one of them is a series the parser loads into something other than
1443
+ * what the file says (issue #729): no `count` is 0 regions, a `setup` past the
1444
+ * end is clamped, a fraction names a region like `stem1.5`.
1445
+ */
1446
+ function sequenceBlockRefusal(seq: unknown): string | null {
1447
+ if (typeof seq !== 'object' || seq === null || Array.isArray(seq)) {
1448
+ return 'a sequence is an object of `count`, `start`, `digits` and `setup`';
1449
+ }
1450
+ const block = seq as JsonObject;
1451
+ if (block.count === undefined) {
1452
+ return 'it states no `count`, and `readSequence` reads 0 — the attachment loads holding no region and draws nothing';
1453
+ }
1454
+ for (const [field, min] of [['count', 1], ['start', 0], ['digits', 0], ['setup', 0]] as const) {
1455
+ const value = block[field];
1456
+ if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < min)) {
1457
+ return `\`${field}\` is ${JSON.stringify(value)}, and the rig spec takes a whole number of at least ${min} there`;
1458
+ }
1459
+ }
1460
+ if (typeof block.setup === 'number' && block.setup >= (block.count as number)) {
1461
+ return `\`setup\` ${block.setup} is past the end of a ${String(block.count)}-frame series, and \`Sequence.resolveIndex\` clamps it to the last frame`;
1462
+ }
1463
+ return null;
1464
+ }
1465
+
1355
1466
  /** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
1356
1467
  function ingestAnimation(
1357
1468
  animName: string,
@@ -1448,6 +1559,16 @@ function ingestAnimation(
1448
1559
  * `lossy`, on the argument that the rebuilt skeleton plays identically — is
1449
1560
  * a decision about all three families and is not made here.
1450
1561
  */
1562
+ /**
1563
+ * The physics constraints the rebuild carries — every one the file declares
1564
+ * but those `ingest` omits as inert — which is what an unnamed physics
1565
+ * timeline can reach in it.
1566
+ */
1567
+ const carriedPhysics = arr(root.constraints)
1568
+ .map(obj)
1569
+ .filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
1570
+ /** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
1571
+ const unreached: Array<{ property: string; lastKey: number }> = [];
1451
1572
  const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
1452
1573
  for (const [name, timelines] of objEntries(anim[group])) {
1453
1574
  for (const [property, keys] of arrEntries(timelines)) {
@@ -1471,6 +1592,26 @@ function ingestAnimation(
1471
1592
  note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
1472
1593
  continue;
1473
1594
  }
1595
+ // The empty name is the physics group's timeline that names no
1596
+ // constraint (issue #726), which the motion spec spells `"*"`. It
1597
+ // writes every carried constraint declaring the property global —
1598
+ // `reset` every one — and one that reaches none is a no-op the rebuild
1599
+ // would be refused over by name, so it goes, and is said, instead.
1600
+ if (group === 'physics' && name === '') {
1601
+ const reaches =
1602
+ property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
1603
+ if (!reaches) {
1604
+ let lastKey = 0;
1605
+ for (const raw of keys) {
1606
+ const t = obj(raw).time;
1607
+ if (typeof t === 'number' && t > lastKey) lastKey = t;
1608
+ }
1609
+ unreached.push({ property, lastKey });
1610
+ continue;
1611
+ }
1612
+ valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
1613
+ continue;
1614
+ }
1474
1615
  valueTrack({ [group]: name }, property, keys, shape, where);
1475
1616
  }
1476
1617
  }
@@ -1480,12 +1621,46 @@ function ingestAnimation(
1480
1621
  for (const [property, keys] of arrEntries(timelines)) {
1481
1622
  const shape = BONE_TRACKS[property];
1482
1623
  const where = `animation "${animName}" bone "${bone}" ${property}`;
1624
+ if (property === INHERIT_TRACK.property) {
1625
+ // `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
1626
+ // table's spelling, which is what `build` writes back; a key that omits
1627
+ // it, or spells it with a capital the runtime also folds, is a key the
1628
+ // rebuild states differently and is counted as such. A spelling the
1629
+ // runtime cannot resolve at all is carried as written — the file plays
1630
+ // no mode there, and `build` refuses it by name rather than guessing one.
1631
+ let restated = 0;
1632
+ const out = keys.map((raw) => {
1633
+ const key = obj(raw);
1634
+ const entry: JsonObject = { t: timeOf(key) };
1635
+ const written = key[INHERIT_TRACK.field];
1636
+ const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
1637
+ if (mode !== written && mode !== undefined) restated++;
1638
+ entry.v = mode ?? (written as JsonObject[string]);
1639
+ for (const field of Object.keys(key)) {
1640
+ if (field === 'time' || field === INHERIT_TRACK.field) continue;
1641
+ note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
1642
+ }
1643
+ return entry;
1644
+ });
1645
+ if (restated > 0) {
1646
+ note(
1647
+ 'lossy',
1648
+ 'TIMELINE_KEY_RESTATED',
1649
+ where,
1650
+ `${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
1651
+ `mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
1652
+ 'spelling the editor writes',
1653
+ );
1654
+ }
1655
+ tracks.push({ bone, property, keys: out });
1656
+ continue;
1657
+ }
1483
1658
  if (shape === undefined) {
1484
1659
  note(
1485
1660
  'blocker',
1486
1661
  'BONE_TIMELINE',
1487
1662
  where,
1488
- `timeline "${property}" has no track in the motion spec — a bone track is ${Object.keys(BONE_TRACKS).join(', ')} ` +
1663
+ `timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
1489
1664
  'and nothing else, so the rebuild plays nothing here',
1490
1665
  );
1491
1666
  continue;
@@ -1608,13 +1783,42 @@ function ingestAnimation(
1608
1783
  // Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
1609
1784
  // and whose `offset` is omitted at 0 (the parser's default).
1610
1785
  const deform: JsonObject[] = [];
1786
+ // sequence — the other attachment timeline, inverting `compileSequenceTrack`:
1787
+ // `{time, mode?, index?, delay?}` with each field written only where the file
1788
+ // wrote it, because each has a parser default (`"hold"`, 0, the previous
1789
+ // key's delay) and restating one would be a rebuild saying more than the file.
1790
+ const sequence: JsonObject[] = [];
1611
1791
  for (const [skinName, perSkin] of objEntries(anim.attachments)) {
1612
1792
  for (const [slot, perSlot] of objEntries(perSkin)) {
1613
1793
  for (const [attachment, timelines] of objEntries(perSlot)) {
1614
1794
  for (const [property, keys] of arrEntries(timelines)) {
1615
1795
  const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
1796
+ if (property === 'sequence') {
1797
+ const entry: JsonObject = {
1798
+ slot,
1799
+ attachment,
1800
+ keys: keys.map((raw) => {
1801
+ const key = obj(raw);
1802
+ const out: JsonObject = { t: timeOf(key) };
1803
+ for (const field of ['mode', 'index', 'delay']) if (key[field] !== undefined) out[field] = key[field];
1804
+ return out;
1805
+ }),
1806
+ };
1807
+ if (skinName !== 'default') entry.skin = skinName;
1808
+ sequence.push(entry);
1809
+ continue;
1810
+ }
1616
1811
  if (property !== 'deform') {
1617
- note('blocker', 'ATTACHMENT_TIMELINE', where, `timeline "${property}" (the motion spec carries \`deform\` only)`);
1812
+ // Reached only by a name OUTSIDE the format: `readAnimation` tests
1813
+ // an attachment timeline for "deform" and "sequence" and reads
1814
+ // nothing else (`SkeletonJson.js:1147-1201`), so no player plays it.
1815
+ note(
1816
+ 'blocker',
1817
+ 'ATTACHMENT_TIMELINE',
1818
+ where,
1819
+ `timeline "${property}" is not an attachment timeline the format has — the runtime's reader tests for ` +
1820
+ '"deform" and "sequence" and ignores anything else, and those two are what the motion spec carries',
1821
+ );
1618
1822
  continue;
1619
1823
  }
1620
1824
  const entry: JsonObject = {
@@ -1667,6 +1871,26 @@ function ingestAnimation(
1667
1871
  });
1668
1872
  }
1669
1873
 
1874
+ for (const { property, lastKey } of unreached) {
1875
+ const flag = `${property}Global`;
1876
+ note(
1877
+ 'lossy',
1878
+ 'PHYSICS_GLOBAL_REACHES_NOTHING',
1879
+ `animation "${animName}" physics "" ${property}`,
1880
+ 'names no constraint, so the runtime writes it into every physics constraint ' +
1881
+ (property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
1882
+ ', and the rebuild carries ' +
1883
+ (carriedPhysics.length === 0
1884
+ ? 'no physics constraint'
1885
+ : `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
1886
+ ` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
1887
+ 'The motion spec omits it' +
1888
+ (lastKey > maxT
1889
+ ? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
1890
+ : '; the rebuild differs from the source by exactly this no-op'),
1891
+ );
1892
+ }
1893
+
1670
1894
  for (const group of Object.keys(anim)) {
1671
1895
  if (ANIMATION_GROUPS.includes(group)) continue;
1672
1896
  note(
@@ -1694,6 +1918,7 @@ function ingestAnimation(
1694
1918
  if (ik.length) out.ik = ik;
1695
1919
  if (transform.length) out.transform = transform;
1696
1920
  if (deform.length) out.deform = deform;
1921
+ if (sequence.length) out.sequence = sequence;
1697
1922
  if (drawOrder !== undefined) out.drawOrder = drawOrder;
1698
1923
  if (events !== undefined) out.events = events;
1699
1924
  return out;
package/src/motion.ts CHANGED
@@ -55,10 +55,29 @@
55
55
  */
56
56
  import { CompileError } from './errors.ts';
57
57
  import { refuseUnknownKeys } from './keys.ts';
58
+ import { SEQUENCE_MODES } from './timelines.ts';
58
59
  import type { MotionSpec } from './types.ts';
59
60
 
60
61
  export const MOTION_SPEC_VERSION = 'rigc-motion/1';
61
62
 
63
+ /**
64
+ * A track's `physics` target for the timeline that names NO constraint (issue
65
+ * #726) — the one the runtime applies to every physics constraint whose own data
66
+ * declares the keyed property global (`"strengthGlobal": true` for `strength`,
67
+ * and so on; `reset` resets every physics constraint and asks no flag).
68
+ * `compile` emits it under the empty name, which is the skeleton file's own
69
+ * spelling of it (`SkeletonJson.js:1048-1054`).
70
+ *
71
+ * 🔑 Not the empty string itself, and that is the choice rather than a detail:
72
+ * `""` is the likeliest shape of a value somebody forgot to fill in, and a
73
+ * forgotten target that silently became "every global constraint" is the exact
74
+ * silence this format exists to name. So `"physics": ""` is refused by name and
75
+ * points here, and `"*"` is reserved the other way round: `compile` refuses a
76
+ * physics constraint that is CALLED `"*"`, because a track naming it could not
77
+ * say which of the two it meant.
78
+ */
79
+ export const EVERY_GLOBAL_PHYSICS = '*';
80
+
62
81
  /**
63
82
  * The six fields that pick a track's target family. Listed here as well as in
64
83
  * `compile`'s `resolveTargets` because the two ask different questions of it:
@@ -112,7 +131,7 @@ export const MOTION_KEYS = {
112
131
  'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity',
113
132
  'mix', 'fps', 'limit', 'note',
114
133
  ],
115
- MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'drawOrder', 'events'],
134
+ MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'sequence', 'drawOrder', 'events'],
116
135
  MotionTrack: ['slot', 'group', 'bone', 'physics', 'path', 'slider', 'property', 'lag', 'stagger', 'keys'],
117
136
  MotionKey: ['t', 'v', 'derive', 'ease', 'curve'],
118
137
  MotionIkTrack: ['constraint', 'keys'],
@@ -121,6 +140,8 @@ export const MOTION_KEYS = {
121
140
  MotionTransformKey: ['t', 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY', 'ease', 'curve'],
122
141
  MotionDeformTrack: ['skin', 'slot', 'attachment', 'keys'],
123
142
  MotionDeformKey: ['t', 'offset', 'fromVertex', 'vertices', 'transform', 'ease', 'curve'],
143
+ MotionSequenceTrack: ['skin', 'slot', 'attachment', 'keys'],
144
+ MotionSequenceKey: ['t', 'mode', 'index', 'delay'],
124
145
  MotionDrawOrderKey: ['t', 'offsets'],
125
146
  MotionDrawOrderOffset: ['slot', 'offset'],
126
147
  MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
@@ -292,7 +313,15 @@ function parsePhysics(raw: unknown, where: string): void {
292
313
  const table = needObj(raw, where, 'physics', 'it is a table keyed by constraint name, `{ "<name>": { "bone": … } }`');
293
314
  for (const [name, entry] of Object.entries(table)) {
294
315
  const key = `physics."${name}"`;
295
- const spec = needObj(entry, where, key, 'a physics constraint is an object naming the bone it drives and the components it drives it in');
316
+ if (name === EVERY_GLOBAL_PHYSICS) {
317
+ throw new CompileError(
318
+ `${where}: \`${key}\` names a physics constraint "${EVERY_GLOBAL_PHYSICS}", and that name is reserved: a ` +
319
+ `track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives ` +
320
+ 'every one declaring the keyed property global, so a constraint called ' +
321
+ `"${EVERY_GLOBAL_PHYSICS}" could not be keyed by name. Give it another name`,
322
+ );
323
+ }
324
+ const spec = needObj(entry, where, key,'a physics constraint is an object naming the bone it drives and the components it drives it in');
296
325
  known(spec, 'MotionPhysics', where, key);
297
326
  needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
298
327
  for (const field of PHYSICS_NUMBERS) {
@@ -378,6 +407,17 @@ function parseTracks(raw: unknown, where: string, at: string): void {
378
407
  for (const field of TARGET_FIELDS) {
379
408
  optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
380
409
  }
410
+ if (track.physics === '') {
411
+ refuse(
412
+ where,
413
+ `${key}.physics`,
414
+ track.physics,
415
+ 'the empty name is how a skeleton file spells a physics timeline that names no constraint, and a motion ' +
416
+ `spec spells that "${EVERY_GLOBAL_PHYSICS}" — it drives every physics constraint that declares the keyed ` +
417
+ 'property global (`"strengthGlobal": true` for `strength`). Name one constraint, or write ' +
418
+ `"${EVERY_GLOBAL_PHYSICS}"`,
419
+ );
420
+ }
381
421
  optFinite(track.lag, where, `${key}.lag`, '"lag" is seconds added to every key time of this track, so a finite number — a string is CONCATENATED onto each time and a boolean adds 1');
382
422
  optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
383
423
  const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
@@ -442,6 +482,83 @@ function parseDeform(raw: unknown, where: string, at: string): void {
442
482
  }
443
483
  }
444
484
 
485
+ /**
486
+ * `sequence` — which frame of an attachment's numbered series shows.
487
+ *
488
+ * Everything decidable from the key alone is decided here; what needs the rig
489
+ * (does the attachment carry a `sequence` block, is `index` inside its `count`)
490
+ * is `compile`'s. Each refusal is a key the parser loads without a word and
491
+ * plays as something else — measured on spine-core 4.3.13 (issue #729):
492
+ *
493
+ * - a `mode` outside the seven — `SequenceMode[name]` is `undefined`, the mode
494
+ * bits store 0, and the key plays as `hold`;
495
+ * - an `index` that is not a whole number of at least 0 — it is stored as
496
+ * `index << 4`, so `1.5` shows frame 1 and a negative one indexes before the
497
+ * series;
498
+ * - a `delay` that is not a number of at least 0 — and, under a mode that
499
+ * advances, an EFFECTIVE delay of 0: the parser carries a key's delay from
500
+ * the key before (`lastDelay`, 0 on the first), `(time - keyTime) / 0` is
501
+ * `Infinity` and `Infinity | 0` is 0, so a `loop` at delay 0 shows its
502
+ * first frame for the whole key.
503
+ */
504
+ function parseSequence(raw: unknown, where: string, at: string): void {
505
+ if (raw === undefined) return;
506
+ const entries = needArray(raw, where, `${at}.sequence`, 'it is an array of `{ slot, attachment, keys }` entries — one per skin/slot/attachment triple');
507
+ for (const [i, entry] of entries.entries()) {
508
+ const key = `${at}.sequence[${i}]`;
509
+ const track = needObj(entry, where, key, 'a sequence timeline is an object of `{ skin?, slot, attachment, keys }`');
510
+ known(track, 'MotionSequenceTrack', where, key);
511
+ optString(track.skin, where, `${key}.skin`, 'it names the skin the attachment lives in; absent means "default"');
512
+ needString(track.slot, where, `${key}.slot`, 'a sequence timeline steps one attachment of one slot, named here');
513
+ needString(track.attachment, where, `${key}.attachment`, "it is the attachment's placeholder name inside that skin and slot");
514
+ const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, mode?, index?, delay? }` keys');
515
+ // The delay the PARSER will read at each key: the stated one, else the
516
+ // previous key's, else 0 (`lastDelay` in `readAnimation`).
517
+ let carried = 0;
518
+ for (const [j, k] of keys.entries()) {
519
+ const kat = `${key}.keys[${j}]`;
520
+ const parsed = parseKey(k, where, kat, 'a sequence key is an object of `{ t, mode?, index?, delay? }`', 'MotionSequenceKey');
521
+ if (parsed.mode !== undefined && !(SEQUENCE_MODES as readonly unknown[]).includes(parsed.mode)) {
522
+ refuse(
523
+ where,
524
+ `${kat}.mode`,
525
+ parsed.mode,
526
+ `a sequence mode is one of the ${SEQUENCE_MODES.length} the format has — ${SEQUENCE_MODES.join(', ')} ` +
527
+ '(absent means "hold"). The parser reads `SequenceMode[mode]`, which is undefined for anything else, and ' +
528
+ 'stores mode bits 0: the key would load without a word and play as "hold"',
529
+ );
530
+ }
531
+ const index = parsed.index;
532
+ if (index !== undefined && (typeof index !== 'number' || !Number.isInteger(index) || index < 0)) {
533
+ refuse(
534
+ where,
535
+ `${kat}.index`,
536
+ index,
537
+ 'it is the 0-based frame this key starts on, so a whole number of at least 0 — the runtime stores it as ' +
538
+ '`index << 4`, which truncates a fraction (1.5 showed frame 1) and puts a negative one before the series',
539
+ );
540
+ }
541
+ const delay = parsed.delay;
542
+ if (delay !== undefined && (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0)) {
543
+ refuse(where, `${kat}.delay`, delay, 'it is the seconds each frame shows for, so a finite number of at least 0');
544
+ }
545
+ if (typeof delay === 'number') carried = delay;
546
+ const mode = typeof parsed.mode === 'string' ? parsed.mode : 'hold';
547
+ if (mode !== 'hold' && carried === 0) {
548
+ throw new CompileError(
549
+ `${where}: \`${kat}\` plays "${mode}" at a delay of 0` +
550
+ (delay === undefined
551
+ ? ` — it states none, and the parser carries the previous key's (${j === 0 ? 'there is none, so 0' : '0'})`
552
+ : '') +
553
+ '. The runtime advances the frame by `(time - keyTime) / delay`, which is Infinity at 0, and ' +
554
+ '`Infinity | 0` is 0 — so the key shows its first frame for as long as it lasts, which is "hold" spelt ' +
555
+ `as "${mode}". State the seconds per frame, or write "hold"`,
556
+ );
557
+ }
558
+ }
559
+ }
560
+ }
561
+
445
562
  function parseEvents(raw: unknown, where: string, at: string): void {
446
563
  if (raw === undefined) return;
447
564
  const keys = needArray(raw, where, `${at}.events`, 'it is an array of `{ t, name }` firings — one timeline per animation, naming no target');
@@ -503,6 +620,7 @@ function parseAnimation(raw: unknown, where: string, name: string): void {
503
620
  parseTracks(anim.tracks, where, at);
504
621
  for (const group of CONSTRAINT_GROUPS) parseConstraintTracks(anim[group], where, at, group);
505
622
  parseDeform(anim.deform, where, at);
623
+ parseSequence(anim.sequence, where, at);
506
624
  parseDrawOrder(anim.drawOrder, where, at);
507
625
  parseEvents(anim.events, where, at);
508
626
  }