spine-rigc 0.31.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.
package/src/compile.ts CHANGED
@@ -63,6 +63,7 @@ import {
63
63
  type RigMeshBinding,
64
64
  type RigPathAttachment,
65
65
  type RigRegionAttachment,
66
+ type RigSequence,
66
67
  type RigSkinParts,
67
68
  type RigSpec,
68
69
  type RigVertexGeometry,
@@ -132,6 +133,7 @@ import type {
132
133
  MotionEventKey,
133
134
  MotionIkTrack,
134
135
  MotionMemberValues,
136
+ MotionSequenceTrack,
135
137
  MotionSpec,
136
138
  MotionTrack,
137
139
  MotionTransformTrack,
@@ -148,6 +150,7 @@ import type {
148
150
  SpineMeshAttachment,
149
151
  SpinePathAttachment,
150
152
  SpineRegionAttachment,
153
+ SpineSequence,
151
154
  SpineSkeletonJson,
152
155
  SpineSlot,
153
156
  SpineTimelineKey,
@@ -314,8 +317,14 @@ export function editorNamesInOrder(names: readonly string[], collection: 'animat
314
317
  return [...names].sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
315
318
  }
316
319
 
317
- /** The skin the editor keeps at index 0 whatever its name sorts as. */
318
- const DEFAULT_SKIN = 'default';
320
+ /**
321
+ * The skin the editor keeps at index 0 whatever its name sorts as.
322
+ *
323
+ * Exported for `ingest`, which has to know that a contested placeholder the
324
+ * default skin fills is refused rather than named (`refuseDefaultSkinContest`),
325
+ * and has no business spelling the name a second time (issue #746).
326
+ */
327
+ export const DEFAULT_SKIN = 'default';
319
328
 
320
329
  /**
321
330
  * The order the emitted `skins` array is written in: **`default` first, then the
@@ -1871,8 +1880,17 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1871
1880
  /** The pre-packed atlas `--atlas-in` named, parsed once, or null. */
1872
1881
  const atlasIn = opts.atlasInPath === undefined ? null : readAtlasIn(resolve(opts.atlasInPath));
1873
1882
 
1874
- const addImage = (relPath: string, baseDir: string, isBase: boolean): CompiledImage => {
1875
- const region = basename(relPath, '.png');
1883
+ const addImage = (
1884
+ relPath: string,
1885
+ baseDir: string,
1886
+ isBase: boolean,
1887
+ region: string = basename(relPath, '.png'),
1888
+ /**
1889
+ * The directory this part counts as named in, for `skeletonImagesPath`, or
1890
+ * `null` for none. Default: the file's own directory, as it always was.
1891
+ */
1892
+ partDir: string | null = dirname(resolve(baseDir, relPath)),
1893
+ ): CompiledImage => {
1876
1894
  if (seenRegions.has(region)) {
1877
1895
  throw new CompileError(`duplicate region name "${region}" (${relPath})`);
1878
1896
  }
@@ -1883,7 +1901,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1883
1901
  : resolveFromAtlas(relPath, region, isBase, outDir, atlasIn);
1884
1902
  seenRegions.add(region);
1885
1903
  regionSource.set(region, loosePath);
1886
- partDirs.add(dirname(loosePath));
1904
+ if (partDir !== null) partDirs.add(partDir);
1887
1905
  images.push(img);
1888
1906
  return img;
1889
1907
  };
@@ -1929,6 +1947,63 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
1929
1947
  );
1930
1948
  };
1931
1949
 
1950
+ /**
1951
+ * Measure and atlas every frame of one attachment's `sequence` — by NAME, the
1952
+ * way the loader will ask for them (issue #729).
1953
+ *
1954
+ * Frame `i` is the region `sequenceFrameRegion(stem, …, i)`; on the loose
1955
+ * route its pixels are the PNG of that name under the images directory, and
1956
+ * under `--atlas-in` they are the pack's region of that name. A frame that is
1957
+ * not there is refused with its number and the name looked for: the loader's
1958
+ * own miss is `Region not found in atlas: <name> (attachment: <name>)`, which
1959
+ * says neither that the region was frame 3 of a series nor which series.
1960
+ *
1961
+ * ⚠️ The region is the frame's FULL name, not a basename — an editor names a
1962
+ * series in an images subfolder `fx/flame_0001`, and that string is what the
1963
+ * loader looks up. A frame another attachment already atlased from the same
1964
+ * file is the same region on purpose, as `addSkinImage` allows for one PNG.
1965
+ */
1966
+ const addSequenceFrames = (stem: string, seq: RigSequence, where: string): void => {
1967
+ for (let i = 0; i < seq.count; i++) {
1968
+ const region = sequenceFrameRegion(stem, seq, i);
1969
+ const relPath = `${region}.png`;
1970
+ const wanted = resolve(imagesDir, relPath);
1971
+ const frame = `frame ${i} of ${seq.count} (number ${(seq.start ?? 1) + i})`;
1972
+ if (atlasIn === null && !existsSync(wanted)) {
1973
+ throw new CompileError(
1974
+ `${where}: sequence ${frame} is the region "${region}", and there is no PNG for it at ${wanted}. A ` +
1975
+ `sequence's frames are the regions "<stem><start + i>", zero-padded to "digits" — here stem ` +
1976
+ `${JSON.stringify(stem)}, start ${seq.start ?? 1}, digits ${seq.digits ?? 0} — and the compiler draws ` +
1977
+ 'no frame in place of another. Add the file, or state the "count" the series really has.',
1978
+ );
1979
+ }
1980
+ if (atlasIn !== null && !atlasIn.byName.has(region)) {
1981
+ const near = nearMisses(region, atlasIn.byName.keys());
1982
+ throw new CompileError(
1983
+ `${where}: sequence ${frame} is the region "${region}", which the atlas at ${atlasIn.path} does not have. ` +
1984
+ (near.length ? `Did you mean ${near.map((n) => JSON.stringify(n)).join(', ')}? ` : '') +
1985
+ `A sequence's frames are the regions "<stem><start + i>", zero-padded to "digits" — here stem ` +
1986
+ `${JSON.stringify(stem)}, start ${seq.start ?? 1}, digits ${seq.digits ?? 0} — and the compiler draws ` +
1987
+ 'no frame in place of another.',
1988
+ );
1989
+ }
1990
+ const already = regionSource.get(region);
1991
+ if (already === wanted) continue;
1992
+ if (already !== undefined) {
1993
+ throw new CompileError(
1994
+ `${where}: sequence ${frame} is the region "${region}", and the art already atlased under that name is ` +
1995
+ `${already}, not ${wanted}. One region name is one set of pixels, so this frame would draw the other ` +
1996
+ "file's. Rename one of them.",
1997
+ );
1998
+ }
1999
+ // The directory the frame NAMES are relative to — the images root, not
2000
+ // the frame's own subfolder, because the region name carries the folder —
2001
+ // and only on the loose route: under `--atlas-in` the spec names no file
2002
+ // for a frame, where an `image` is still a file the spec names.
2003
+ addImage(relPath, imagesDir, false, region, atlasIn === null ? imagesDir : null);
2004
+ }
2005
+ };
2006
+
1932
2007
  // A manifest may name a part the cut does not carry. A formation can declare
1933
2008
  // more slots than any one cut fills, and a cut that shares a sprite with the
1934
2009
  // scene around it has no plate of its own to point at — the manifest then
@@ -2116,6 +2191,19 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2116
2191
  if (typeof image === 'string') {
2117
2192
  addSkinImage(image, `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`);
2118
2193
  }
2194
+ // `parseRigSpec` has refused a sequence on any kind but these three and
2195
+ // proved its shape, so what is left is whether its frames exist. The stem
2196
+ // is the region `path` the attachment resolves through, which with no
2197
+ // `path` stated is the placeholder (`nameSkinAttachment` pins exactly that
2198
+ // where a placeholder is contested).
2199
+ const sequence = (att as RigRegionAttachment).sequence;
2200
+ if (sequence !== undefined) {
2201
+ addSequenceFrames(
2202
+ (att as RigRegionAttachment).path ?? placeholder,
2203
+ sequence,
2204
+ `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`,
2205
+ );
2206
+ }
2119
2207
  }
2120
2208
  rigAttachmentNames.set(slotName, names);
2121
2209
  }
@@ -2847,6 +2935,43 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2847
2935
  ((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
2848
2936
  }
2849
2937
 
2938
+ // -- sequence timelines: the other attachment timeline (issue #729) -----
2939
+ // Same triple, same table: `readAnimation` reads both names out of one
2940
+ // attachment map, and one attachment may carry both. `deform` is written
2941
+ // first where both exist, which is the order the parser tests them in.
2942
+ const sequenceTracks: MotionSequenceTrack[] = anim.sequence ?? [];
2943
+ for (const track of sequenceTracks) {
2944
+ const skinName = track.skin ?? 'default';
2945
+ const at = `animation "${animName}" sequence ${skinName}/${String(track.slot)}/${String(track.attachment)}`;
2946
+ const table = skinTables.get(skinName);
2947
+ if (!table) {
2948
+ throw new CompileError(
2949
+ `${at}: this rig emits no skin called "${skinName}" (it emits: ${[...skinTables.keys()].join(', ')})`,
2950
+ );
2951
+ }
2952
+ const perSlot = table[track.slot];
2953
+ if (!perSlot) {
2954
+ throw new CompileError(
2955
+ `${at}: skin "${skinName}" gives slot "${String(track.slot)}" no attachments` +
2956
+ (slotNames.has(track.slot) ? '' : ', and this rig does not declare that slot at all'),
2957
+ );
2958
+ }
2959
+ const attachment = perSlot[track.attachment];
2960
+ if (!attachment) {
2961
+ throw new CompileError(
2962
+ `${at}: slot "${track.slot}" in skin "${skinName}" has no attachment "${String(track.attachment)}" ` +
2963
+ `(it has: ${Object.keys(perSlot).join(', ')})`,
2964
+ );
2965
+ }
2966
+ const keys = compileSequenceTrack(track, anim.duration, attachment, at);
2967
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
2968
+ const slot = ((deformTimelines[skinName] ??= {})[track.slot] ??= {});
2969
+ if (slot[track.attachment]?.sequence) {
2970
+ throw new CompileError(`${at}: two sequence timelines on one attachment; merge them into one`);
2971
+ }
2972
+ slot[track.attachment] = { ...(slot[track.attachment] ?? {}), sequence: keys };
2973
+ }
2974
+
2850
2975
  const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
2851
2976
  if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
2852
2977
 
@@ -3149,8 +3274,14 @@ function skinAttachmentName(skinName: string, placeholder: string): string {
3149
3274
  * By the time either caller runs, a contested placeholder the **default** skin
3150
3275
  * fills has already been refused — see `refuseDefaultSkinContest` — so every
3151
3276
  * entry this composes for is a named skin's.
3277
+ *
3278
+ * 🔒 A third caller reads it from outside: `ingest` compares the name a source
3279
+ * states against the one this returns, and reports the rename where the two
3280
+ * differ (issue #746). It calls this rather than `skinAttachmentName` so that
3281
+ * WHETHER a name is composed is read off the same line as WHAT it is — the
3282
+ * separator and the contest test are each stated once, here.
3152
3283
  */
3153
- function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3284
+ export function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3154
3285
  return contested ? skinAttachmentName(skinName, placeholder) : null;
3155
3286
  }
3156
3287
 
@@ -3956,12 +4087,120 @@ function attachmentPath(att: { path?: string; image?: string }, placeholder: str
3956
4087
  return region === placeholder ? undefined : region;
3957
4088
  }
3958
4089
 
4090
+ /**
4091
+ * The atlas region frame `i` of a sequence resolves to — `Sequence.getPath`
4092
+ * (`Sequence.js:124-132`) transcribed: the stem, then `start + i` left-padded
4093
+ * with zeros to `digits`. `start` and `digits` take the parser's own defaults
4094
+ * (`readSequence`: 1 and 0), which are the format's, not a guess.
4095
+ *
4096
+ * ⚠️ A second transcription of the rule exists in `validate.ts`
4097
+ * (`attachmentRegionLookups`, the walk `A08` joins the atlas with), and that is
4098
+ * deliberate: this module links no runtime, and the two are held to the loader
4099
+ * independently — the one by `A08`, both by `A00`'s round trip, which asks the
4100
+ * runtime's own `getPath` for every frame.
4101
+ */
4102
+ function sequenceFrameRegion(stem: string, seq: RigSequence, i: number): string {
4103
+ const frame = String((seq.start ?? 1) + i);
4104
+ return `${stem}${'0'.repeat(Math.max(0, (seq.digits ?? 0) - frame.length))}${frame}`;
4105
+ }
4106
+
4107
+ /** The emitted `sequence` block: the four fields exactly as the spec stated them. */
4108
+ function emitSequence(seq: RigSequence): SpineSequence {
4109
+ const out: SpineSequence = { count: seq.count };
4110
+ if (seq.start !== undefined) out.start = seq.start;
4111
+ if (seq.digits !== undefined) out.digits = seq.digits;
4112
+ if (seq.setup !== undefined) out.setup = seq.setup;
4113
+ return out;
4114
+ }
4115
+
4116
+ /**
4117
+ * What a sequence attachment's frames measure, for the `width`/`height` a spec
4118
+ * leaves out — or the refusal when they do not agree.
4119
+ *
4120
+ * An attachment has ONE size and every frame is mapped into it
4121
+ * (`RegionAttachment.computeUVs(regions[i], … width, height …)` for each `i` in
4122
+ * `Sequence.update`), so an omitted size is derivable only when every frame
4123
+ * measures the same: that is the one number the frames state. Frames of
4124
+ * different sizes state several, and picking one — the first, the setup frame,
4125
+ * the largest — would be the compiler choosing a value the spec did not.
4126
+ * Under `--atlas-in` a stated size that disagrees with a frame is refused as it
4127
+ * is for a single region, for the same reason: the pack's rectangle is fixed.
4128
+ */
4129
+ function sequenceFrameSize(
4130
+ frames: readonly CompiledImage[],
4131
+ stated: { width?: number; height?: number },
4132
+ where: string,
4133
+ ): { width?: number; height?: number } {
4134
+ const out: { width?: number; height?: number } = {};
4135
+ for (const field of ['width', 'height'] as const) {
4136
+ const sizes = [...new Set(frames.map((img) => img[field]))];
4137
+ if (stated[field] !== undefined) {
4138
+ const packed = frames.find((img) => img.atlas !== undefined && img[field] !== stated[field]);
4139
+ if (packed !== undefined) {
4140
+ throw new CompileError(
4141
+ `${where}: the spec says ${field} ${stated[field]} and sequence frame "${packed.region}" of the imported ` +
4142
+ `atlas is ${packed[field]}; a packed frame's rectangle is fixed, so the two would produce a quad the ` +
4143
+ 'pack cannot fill',
4144
+ );
4145
+ }
4146
+ out[field] = stated[field];
4147
+ } else if (sizes.length === 1) {
4148
+ out[field] = sizes[0];
4149
+ } else {
4150
+ throw new CompileError(
4151
+ `${where}: the ${frames.length} frames of this sequence measure ${sizes.join(', ')} in ${field} ` +
4152
+ `(${frames.map((img) => `"${img.region}" ${img[field]}`).join(', ')}), and an attachment has one ` +
4153
+ `${field} every frame is drawn into. State "${field}" — the frames do not agree on one`,
4154
+ );
4155
+ }
4156
+ }
4157
+ return out;
4158
+ }
4159
+
4160
+ /** Every frame of `att`'s sequence, already atlased by the gather pass. */
4161
+ function sequenceFrames(att: { path?: string; sequence?: RigSequence }, placeholder: string, where: string, ctx: AttachmentContext): CompiledImage[] {
4162
+ const seq = att.sequence!;
4163
+ const stem = att.path ?? placeholder;
4164
+ const frames: CompiledImage[] = [];
4165
+ for (let i = 0; i < seq.count; i++) {
4166
+ // By the frame's FULL region name — `atlasedImage` takes a basename, and a
4167
+ // series in an images subfolder is named `fx/flame_0001`.
4168
+ const region = sequenceFrameRegion(stem, seq, i);
4169
+ const img = ctx.images.find((im) => im.region === region);
4170
+ if (img === undefined) {
4171
+ // Unreachable while the gather pass atlases every frame of every sequence
4172
+ // or refuses the missing one by name; stated so that a gather that stops
4173
+ // doing so is a sentence rather than a quad with no region.
4174
+ throw new CompileError(
4175
+ `${where}: sequence frame ${i} is the region "${region}", which was never added to the atlas — every frame ` +
4176
+ 'of a sequence is atlased or refused by name before any attachment is built, so reaching this means rigc ' +
4177
+ 'skipped one',
4178
+ );
4179
+ }
4180
+ frames.push(img);
4181
+ }
4182
+ return frames;
4183
+ }
4184
+
3959
4185
  function buildRigRegion(
3960
4186
  att: RigRegionAttachment,
3961
4187
  placeholder: string,
3962
4188
  where: string,
3963
4189
  ctx: AttachmentContext,
3964
4190
  ): SpineRegionAttachment {
4191
+ if (att.sequence !== undefined) {
4192
+ const size = sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4193
+ const out: SpineRegionAttachment = { width: r6(size.width!), height: r6(size.height!) };
4194
+ if (att.path !== undefined) out.path = att.path;
4195
+ if (att.x !== undefined) out.x = r6(att.x);
4196
+ if (att.y !== undefined) out.y = r6(att.y);
4197
+ if (att.rotation !== undefined) out.rotation = r6(att.rotation);
4198
+ if (att.scaleX !== undefined) out.scaleX = r6(att.scaleX);
4199
+ if (att.scaleY !== undefined) out.scaleY = r6(att.scaleY);
4200
+ if (att.color !== undefined) out.color = att.color;
4201
+ out.sequence = emitSequence(att.sequence);
4202
+ return out;
4203
+ }
3965
4204
  const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
3966
4205
  // ⭐ An IMPORTED region's size is not a default the spec may override. On the
3967
4206
  // loose path `att.width` and the PNG's width are two legitimate numbers — "draw
@@ -4232,8 +4471,9 @@ function buildRigMesh(
4232
4471
  // spec stated, and the editor shows whatever is written here as the image's
4233
4472
  // dimensions (it showed 32x32, its missing-image placeholder, for a 0x0 mesh).
4234
4473
  const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
4235
- const width = att.width ?? img?.width;
4236
- const height = att.height ?? img?.height;
4474
+ const seqSize = att.sequence === undefined ? null : sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4475
+ const width = seqSize !== null ? seqSize.width : (att.width ?? img?.width);
4476
+ const height = seqSize !== null ? seqSize.height : (att.height ?? img?.height);
4237
4477
  if (width === undefined || height === undefined) {
4238
4478
  throw new CompileError(
4239
4479
  `${where}: a mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
@@ -4252,6 +4492,7 @@ function buildRigMesh(
4252
4492
  const path = attachmentPath(att, placeholder);
4253
4493
  if (path !== undefined) out.path = path;
4254
4494
  if (att.color !== undefined) out.color = att.color;
4495
+ if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
4255
4496
  // Register it as `authored`: geometry rigc did not build and whose topology it
4256
4497
  // therefore gets to assume nothing about. The generator-topology assertions
4257
4498
  // read this and skip rather than measuring a ring that was never a ring.
@@ -4348,8 +4589,9 @@ function buildRigLinkedMesh(
4348
4589
  // The art side is a mesh's, unchanged: a link draws its OWN region, which is
4349
4590
  // the reason the type exists — one triangulation, one outfit's pixels each.
4350
4591
  const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
4351
- const width = att.width ?? img?.width;
4352
- const height = att.height ?? img?.height;
4592
+ const seqSize = att.sequence === undefined ? null : sequenceFrameSize(sequenceFrames(att, placeholder, where, ctx), att, where);
4593
+ const width = seqSize !== null ? seqSize.width : (att.width ?? img?.width);
4594
+ const height = seqSize !== null ? seqSize.height : (att.height ?? img?.height);
4353
4595
  if (width === undefined || height === undefined) {
4354
4596
  throw new CompileError(
4355
4597
  `${where}: a linked mesh needs width and height — give them, or give an "image" and rigc will measure the PNG`,
@@ -4365,6 +4607,7 @@ function buildRigLinkedMesh(
4365
4607
  if (att.skin !== undefined && att.skin !== 'default') out.skin = att.skin;
4366
4608
  if (att.timelines === false) out.timelines = false;
4367
4609
  if (att.color !== undefined) out.color = att.color;
4610
+ if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
4368
4611
  // 🚫 NOT registered in `ctx.meshes`, and that is a decision rather than an
4369
4612
  // omission. `meshKinds` is keyed by SLOT and the commonest linked mesh shares
4370
4613
  // its source's slot from another skin, so an entry here would overwrite the
@@ -6821,6 +7064,81 @@ function deformGeometryOf(
6821
7064
  * nothing guessed. What survives is the one case the identity does not cover: a
6822
7065
  * vertex whose weights do not close at 1.
6823
7066
  */
7067
+ /**
7068
+ * One sequence timeline — `animations.<a>.attachments.<skin>.<slot>.<attachment>.sequence`
7069
+ * — keyed on an attachment that carries a numbered series (issue #729).
7070
+ *
7071
+ * The key's own shape (`mode` among the seven, a whole `index`, a delay the
7072
+ * parser will not divide by zero) is `parseMotionSpec`'s; what is refused here
7073
+ * needs the emitted attachment:
7074
+ *
7075
+ * - an attachment with no `sequence` block. The parser gives every region and
7076
+ * mesh a one-region series (`readSequence(null)` is `new Sequence(1,
7077
+ * false)`), so the timeline loads and every mode shows that one region —
7078
+ * measured: a `loop` key on a plain region showed it at every time.
7079
+ * - an `index` at or past the series' `count`. `Sequence.resolveIndex` clamps
7080
+ * it to the last frame (measured: `hold` at index 5 of 4 showed frame 4).
7081
+ * - a linked mesh that plays its source's timelines (`timelines` absent or
7082
+ * true). Its `timelineAttachment` is the SOURCE (`SkeletonJson.js:437-448`),
7083
+ * and `SequenceTimeline.applyToSlot` returns unless the slot's attachment's
7084
+ * `timelineAttachment` is the one the timeline was built for — so a key
7085
+ * aimed at the link itself is applied to nothing.
7086
+ *
7087
+ * Fields are emitted exactly as stated: an omitted `mode` is the parser's
7088
+ * `"hold"`, an omitted `index` its 0, an omitted `delay` the previous key's —
7089
+ * none of which the compiler writes for the spec.
7090
+ */
7091
+ function compileSequenceTrack(
7092
+ track: MotionSequenceTrack,
7093
+ duration: number,
7094
+ attachment: SpineAttachment,
7095
+ where: string,
7096
+ ): SpineTimelineKey[] {
7097
+ const type = (attachment as { type?: string }).type ?? 'region';
7098
+ const series = (attachment as { sequence?: SpineSequence }).sequence;
7099
+ if (series === undefined) {
7100
+ throw new CompileError(
7101
+ `${where}: attachment "${track.attachment}" carries no "sequence" block, so there is no series to step. The ` +
7102
+ 'parser gives every region and mesh a series of ONE region (`readSequence(null)` is `new Sequence(1, ' +
7103
+ 'false)`), so this timeline would load and show that region under every mode at every time. Give the ' +
7104
+ 'attachment a "sequence" in the rig spec, or remove the track.',
7105
+ );
7106
+ }
7107
+ if (type === 'linkedmesh' && (attachment as SpineLinkedMeshAttachment).timelines !== false) {
7108
+ const source = (attachment as SpineLinkedMeshAttachment).source;
7109
+ throw new CompileError(
7110
+ `${where}: attachment "${track.attachment}" is a linked mesh that plays its source's timelines ("timelines" ` +
7111
+ `is not false), so its \`timelineAttachment\` is "${source}" (\`SkeletonJson.js:437-448\`) and ` +
7112
+ '`SequenceTimeline` applies only where the slot\'s attachment\'s `timelineAttachment` is the one it was ' +
7113
+ `built for — a key aimed at the link is applied to nothing. Key "${source}" instead (the link steps its own ` +
7114
+ 'series by it), or set "timelines": false on the link.',
7115
+ );
7116
+ }
7117
+ if (track.keys.length === 0) throw new CompileError(`${where}: no keys`);
7118
+ const out: SpineTimelineKey[] = [];
7119
+ for (let i = 0; i < track.keys.length; i++) {
7120
+ const key = track.keys[i];
7121
+ const time = keyTime(key.t);
7122
+ if (i > 0 && time <= (out[i - 1].time as number)) {
7123
+ throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
7124
+ }
7125
+ checkKeyTime(where, time, key.t, duration);
7126
+ if (key.index !== undefined && key.index >= series.count) {
7127
+ throw new CompileError(
7128
+ `${where} (t=${key.t}): index ${key.index} is past the end of a ${series.count}-frame series (frames 0 to ` +
7129
+ `${series.count - 1}). \`Sequence.resolveIndex\` clamps it to the last frame, so the key would start on a ` +
7130
+ 'frame it does not name. `index` is 0-based.',
7131
+ );
7132
+ }
7133
+ const entry: SpineTimelineKey = { time };
7134
+ if (key.mode !== undefined) entry.mode = key.mode;
7135
+ if (key.index !== undefined) entry.index = key.index;
7136
+ if (key.delay !== undefined) entry.delay = r6(key.delay);
7137
+ out.push(entry);
7138
+ }
7139
+ return out;
7140
+ }
7141
+
6824
7142
  function compileDeformTrack(
6825
7143
  track: MotionDeformTrack,
6826
7144
  motion: MotionSpec,
@@ -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