@forgeax/engine-pack 0.1.28 → 0.1.30

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.
Files changed (63) hide show
  1. package/README.md +33 -5
  2. package/dist/build.mjs +99 -60
  3. package/dist/build.mjs.map +1 -1
  4. package/dist/catalog-projection.d.ts +3 -3
  5. package/dist/cli-asset.mjs +22 -13
  6. package/dist/cli-asset.mjs.map +1 -1
  7. package/dist/evidence/material-cook.d.ts +7 -1
  8. package/dist/evidence/material-cook.d.ts.map +1 -1
  9. package/dist/index.mjs +61 -5
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/inventory/declaration.d.ts +1 -1
  12. package/dist/inventory/declaration.d.ts.map +1 -1
  13. package/dist/inventory/sync.d.ts.map +1 -1
  14. package/dist/material-cook.mjs +61 -5
  15. package/dist/material-cook.mjs.map +1 -1
  16. package/dist/native-cooker-registry.d.ts +1 -0
  17. package/dist/native-cooker-registry.d.ts.map +1 -1
  18. package/dist/native-cooker.mjs.map +1 -1
  19. package/dist/pack-authoring-node.mjs +22 -13
  20. package/dist/pack-authoring-node.mjs.map +1 -1
  21. package/dist/pack-authoring.d.ts +8 -2
  22. package/dist/pack-authoring.d.ts.map +1 -1
  23. package/dist/pack-authoring.mjs.map +1 -1
  24. package/dist/runtime-publication.d.ts +2 -0
  25. package/dist/runtime-publication.d.ts.map +1 -1
  26. package/dist/runtime.mjs.map +1 -1
  27. package/dist/scanner.mjs +22 -13
  28. package/dist/scanner.mjs.map +1 -1
  29. package/dist/schema-compiled.d.ts.map +1 -1
  30. package/dist/schema.mjs +40 -62
  31. package/dist/schema.mjs.map +1 -1
  32. package/dist/scriptable-pack-node.d.ts.map +1 -1
  33. package/dist/scriptable-pack-node.mjs +12 -4
  34. package/dist/scriptable-pack-node.mjs.map +1 -1
  35. package/dist/scriptable-pack-worker.mjs +12 -5
  36. package/dist/scriptable-pack-worker.mjs.map +1 -1
  37. package/dist/scriptable-pack.d.ts +2 -1
  38. package/dist/scriptable-pack.d.ts.map +1 -1
  39. package/dist/scriptable-pack.mjs.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/inventory-schema.test.ts +10 -10
  42. package/src/__tests__/material-cook-schema.unit.test.ts +92 -1
  43. package/src/__tests__/pack-authoring.unit.test.ts +2 -4
  44. package/src/__tests__/pack.unit.test.ts +86 -124
  45. package/src/__tests__/runtime-publication.unit.test.ts +36 -0
  46. package/src/__tests__/scanner-instance-cycle.test.ts +92 -0
  47. package/src/__tests__/scanner-inventory.contract.test.ts +2 -2
  48. package/src/__tests__/scriptable-pack-cli.integration.test.ts +35 -1
  49. package/src/__tests__/scriptable-pack-diagnostic.integration.test.ts +45 -0
  50. package/src/evidence/material-cook.ts +79 -5
  51. package/src/inventory/binding.ts +3 -3
  52. package/src/inventory/declaration.ts +10 -48
  53. package/src/inventory/sync.ts +3 -2
  54. package/src/native-cooker-registry.ts +1 -0
  55. package/src/pack-authoring.ts +11 -2
  56. package/src/runtime-publication.ts +7 -3
  57. package/src/scanner.ts +28 -28
  58. package/src/schema/material-cook.schema.json +107 -0
  59. package/src/schema-compiled.ts +40 -79
  60. package/src/scriptable-pack-node.ts +16 -4
  61. package/src/scriptable-pack-worker.ts +23 -5
  62. package/src/scriptable-pack.ts +2 -1
  63. package/src/__tests__/scanner-mount-cycle.test.ts +0 -232
@@ -0,0 +1,45 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { expect, it } from 'vitest';
5
+ import { loadScriptablePack } from '../scriptable-pack-node.js';
6
+
7
+ it('keeps the source line and column for a malformed author module', async () => {
8
+ const root = await mkdtemp(join(tmpdir(), 'pack-source-diagnostic-'));
9
+ const source = join(root, 'broken.pack.ts');
10
+ try {
11
+ await writeFile(source, 'export default {\n build: () => { const = 1; }\n};\n');
12
+ const loaded = await loadScriptablePack(source);
13
+ expect(loaded.ok).toBe(false);
14
+ if (loaded.ok) return;
15
+ expect(JSON.stringify(loaded.error)).toContain(`${source}:2:`);
16
+ } finally {
17
+ await rm(root, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it.each([
22
+ 0, 17,
23
+ ])('propagates worker exit %i during a build instead of waiting for timeout', async (code) => {
24
+ const root = await mkdtemp(join(tmpdir(), 'pack-worker-exit-'));
25
+ const source = join(root, 'exit.pack.ts');
26
+ try {
27
+ await writeFile(
28
+ source,
29
+ `export default { schemaVersion: '2.0.0', packageId: new Uint8Array([121,171,30,178,252,222,65,195,145,119,233,61,213,193,142,245]), build() { process.exit(${code}); } };`,
30
+ );
31
+ const loaded = await loadScriptablePack(source, { buildTimeoutMs: 1000 });
32
+ expect(loaded.ok).toBe(true);
33
+ if (!loaded.ok) return;
34
+ await expect(
35
+ loaded.value.build({
36
+ packageId: loaded.value.packageId,
37
+ readByGuid: async () => {
38
+ throw new Error('unexpected read');
39
+ },
40
+ }),
41
+ ).rejects.toThrow(`worker exited with code ${code}`);
42
+ } finally {
43
+ await rm(root, { recursive: true, force: true });
44
+ }
45
+ });
@@ -2,12 +2,20 @@ import type {
2
2
  MaterialAsset,
3
3
  MaterialParameter,
4
4
  MaterialPass,
5
+ MaterialProgramAbi,
6
+ MaterialProgramAddress,
5
7
  MaterialTextureReference,
6
8
  MaterialTextureValue,
7
9
  MaterialValue,
8
10
  Result,
9
11
  } from '@forgeax/engine-types';
10
- import { deriveStandardLayerPlan, err, MATERIAL_TEXTURE_SLOTS, ok } from '@forgeax/engine-types';
12
+ import {
13
+ deriveStandardLayerPlan,
14
+ err,
15
+ isMaterialProgramAbi,
16
+ MATERIAL_TEXTURE_SLOTS,
17
+ ok,
18
+ } from '@forgeax/engine-types';
11
19
  import { sha256 } from '@noble/hashes/sha2.js';
12
20
  import { bytesToHex } from '@noble/hashes/utils.js';
13
21
 
@@ -44,6 +52,12 @@ export interface MaterialCookProgram {
44
52
  readonly selections: readonly {
45
53
  readonly pass: string;
46
54
  readonly context: MaterialCookProgramContext;
55
+ /** Renderer submission address selected by this publication. */
56
+ readonly address?: MaterialProgramAddress;
57
+ /** Actual entry point paired with the selected address. */
58
+ readonly entry?: string;
59
+ /** Producer-reflected ABI facts for the selected artifact/entry. */
60
+ readonly abi?: MaterialProgramAbi;
47
61
  }[];
48
62
  }
49
63
 
@@ -483,6 +497,9 @@ export function validateCookedMaterialRecord(
483
497
  const programs: MaterialCookProgram[] = [];
484
498
  const programKeys = new Set<string>();
485
499
  const selections = new Set<string>();
500
+ const submissionSelections = new Map<string, Set<MaterialProgramAddress>>();
501
+ let modernPublication = false;
502
+ let legacySelectionField: string | undefined;
486
503
  const selectedPasses = new Set<string>();
487
504
  for (const [index, entry] of candidate.programs.entries()) {
488
505
  const field = `programs[${index}]`;
@@ -511,9 +528,11 @@ export function validateCookedMaterialRecord(
511
528
  if (!Array.isArray(program.selections) || program.selections.length === 0)
512
529
  return invalid(`${field}.selections`);
513
530
  const programSelections: MaterialCookProgram['selections'][number][] = [];
514
- for (const [selectionIndex, selection] of program.selections.entries()) {
531
+ for (const [selectionIndex, rawSelection] of program.selections.entries()) {
515
532
  const selectionField = `${field}.selections[${selectionIndex}]`;
516
- if (selection === null || typeof selection !== 'object' || !passNames.has(selection.pass))
533
+ if (rawSelection === null || typeof rawSelection !== 'object') return invalid(selectionField);
534
+ const selection = rawSelection as Record<string, unknown>;
535
+ if (typeof selection.pass !== 'string' || !passNames.has(selection.pass))
517
536
  return invalid(selectionField);
518
537
  const context = validateMaterialCookProgramContext(selection.context);
519
538
  if (!context.ok)
@@ -521,11 +540,55 @@ export function validateCookedMaterialRecord(
521
540
  `${selectionField}.${context.error.detail.field}`,
522
541
  context.error.detail.actual,
523
542
  );
524
- const key = JSON.stringify([selection.pass, materialProgramContextKey(context.value)]);
543
+ const address = selection.address === undefined ? 'direct' : selection.address;
544
+ if (address !== 'direct' && address !== 'scene-index')
545
+ return invalid(`${selectionField}.address`, selection.address);
546
+ const hasAddressFacts =
547
+ selection.address !== undefined ||
548
+ selection.entry !== undefined ||
549
+ selection.abi !== undefined;
550
+ modernPublication ||= hasAddressFacts;
551
+ if (!hasAddressFacts) legacySelectionField ??= selectionField;
552
+ const rawEntry = selection.entry;
553
+ const entry = typeof rawEntry === 'string' ? rawEntry : undefined;
554
+ if (hasAddressFacts && selection.address === undefined)
555
+ return invalid(
556
+ `${selectionField}.address`,
557
+ 'modern ABI selections require an explicit address',
558
+ );
559
+ if (hasAddressFacts && (entry === undefined || entry.length === 0))
560
+ return invalid(`${selectionField}.entry`, rawEntry);
561
+ if (hasAddressFacts && !isMaterialProgramAbi(selection.abi))
562
+ return invalid(`${selectionField}.abi`, selection.abi);
563
+ if (hasAddressFacts) {
564
+ const abi = selection.abi as MaterialProgramAbi;
565
+ const expectedEntry = address === 'direct' ? abi.directEntry : abi.sceneIndexEntry;
566
+ if (entry !== expectedEntry)
567
+ return invalid(`${selectionField}.entry`, 'entry does not match published ABI');
568
+ const submissionKey = JSON.stringify([
569
+ selection.pass,
570
+ materialProgramContextKey(context.value),
571
+ ]);
572
+ const addresses =
573
+ submissionSelections.get(submissionKey) ?? new Set<MaterialProgramAddress>();
574
+ addresses.add(address);
575
+ submissionSelections.set(submissionKey, addresses);
576
+ }
577
+ const key = JSON.stringify([
578
+ selection.pass,
579
+ materialProgramContextKey(context.value),
580
+ address,
581
+ ]);
525
582
  if (selections.has(key)) return invalid(selectionField, 'ambiguous Pass/context selection');
526
583
  selections.add(key);
527
584
  selectedPasses.add(selection.pass);
528
- programSelections.push({ pass: selection.pass, context: context.value });
585
+ programSelections.push({
586
+ pass: selection.pass,
587
+ context: context.value,
588
+ ...(selection.address === undefined ? {} : { address }),
589
+ ...(entry === undefined ? {} : { entry }),
590
+ ...(selection.abi === undefined ? {} : { abi: selection.abi as MaterialProgramAbi }),
591
+ });
529
592
  }
530
593
  programs.push({
531
594
  specializationKey: program.specializationKey,
@@ -533,6 +596,17 @@ export function validateCookedMaterialRecord(
533
596
  selections: programSelections,
534
597
  });
535
598
  }
599
+ if (modernPublication && legacySelectionField !== undefined) {
600
+ return invalid(
601
+ legacySelectionField,
602
+ 'modern material publications require address, entry, and ABI facts on every selection',
603
+ );
604
+ }
605
+ for (const [selectionKey, addresses] of submissionSelections) {
606
+ if (addresses.size !== 2) {
607
+ return invalid('programs.selections', `incomplete submission address pair: ${selectionKey}`);
608
+ }
609
+ }
536
610
  if ([...passNames].some((pass) => !selectedPasses.has(pass)))
537
611
  return invalid('programs.selections', 'unpublished Pass');
538
612
  const manifestDigest = createMaterialProgramSetDigest(programs, passes);
@@ -15,11 +15,11 @@ export function projectAssetRefs(inventory: AuthorInventory): readonly AssetRef[
15
15
  /** Derive instance-relative scene refs from the same validated author rows. */
16
16
  export function projectSceneEntityRefs(inventory: AuthorInventory): readonly SceneEntityRef[] {
17
17
  return inventory.declarations.flatMap((row) =>
18
- row.sceneBindings === undefined
18
+ row.sceneEntityKeys === undefined
19
19
  ? []
20
- : row.sceneBindings.map((bindingKey) => ({
20
+ : row.sceneEntityKeys.map((address) => ({
21
21
  sceneSourceKey: row.sourceKey,
22
- bindingKey,
22
+ address,
23
23
  })),
24
24
  );
25
25
  }
@@ -6,7 +6,7 @@ export interface AuthorInventoryRow {
6
6
  readonly kind: string;
7
7
  readonly payload: Readonly<Record<string, unknown>>;
8
8
  readonly refs: readonly string[];
9
- readonly sceneBindings?: readonly string[];
9
+ readonly sceneEntityKeys?: readonly string[];
10
10
  }
11
11
 
12
12
  export interface AuthorInventory {
@@ -102,54 +102,16 @@ export function validateAuthorInventory(value: unknown): Result<AuthorInventory,
102
102
  { guid, sourceKey },
103
103
  );
104
104
  }
105
- if (row.kind === 'scene') {
106
- const payload = row.payload;
107
- const payloadRecord =
108
- typeof payload === 'object' && payload !== null
109
- ? (payload as Record<string, unknown>)
110
- : undefined;
111
- const entities =
112
- payloadRecord !== undefined && Array.isArray(payloadRecord.entities)
113
- ? payloadRecord.entities
114
- : [];
115
- const bindings = new Set<string>();
116
- for (const entity of entities) {
117
- const bindingKey =
118
- typeof entity === 'object' && entity !== null && typeof entity.bindingKey === 'string'
119
- ? entity.bindingKey
120
- : undefined;
121
- if (bindingKey !== undefined && bindingKey.length === 0) {
122
- return failure(
123
- 'inventory-scene-binding-missing',
124
- 'each declared scene bindingKey is non-empty',
125
- 'remove the empty bindingKey or declare a stable key in the scene producer',
126
- { sourceKey },
127
- );
128
- }
129
- if (bindingKey === undefined) continue;
130
- if (bindings.has(bindingKey)) {
131
- return failure(
132
- 'inventory-scene-binding-duplicate',
133
- 'bindingKey values are unique within one scene',
134
- 'rename the duplicate bindingKey in the scene producer',
135
- { sourceKey },
136
- );
137
- }
138
- bindings.add(bindingKey);
139
- }
140
- }
141
105
  guids.add(normalizedGuid);
142
106
  sourceKeys.add(sourceKey);
143
- const sceneBindings =
144
- row.kind === 'scene' && typeof row.payload === 'object' && row.payload !== null
145
- ? (row.payload as { readonly entities?: readonly unknown[] }).entities?.flatMap(
146
- (entity) => {
147
- if (typeof entity !== 'object' || entity === null) return [];
148
- const key = (entity as { readonly bindingKey?: unknown }).bindingKey;
149
- return typeof key === 'string' && key.length > 0 ? [key] : [];
150
- },
151
- )
152
- : undefined;
107
+ const sceneEntityKeys = (() => {
108
+ if (row.kind !== 'scene' || typeof row.payload !== 'object' || row.payload === null)
109
+ return undefined;
110
+ const entities = (row.payload as { readonly entities?: unknown }).entities;
111
+ if (entities === null || typeof entities !== 'object' || Array.isArray(entities))
112
+ return undefined;
113
+ return Object.keys(entities);
114
+ })();
153
115
  declarations.push({
154
116
  guid,
155
117
  sourceKey,
@@ -161,7 +123,7 @@ export function validateAuthorInventory(value: unknown): Result<AuthorInventory,
161
123
  refs: Array.isArray(row.refs)
162
124
  ? row.refs.filter((ref): ref is string => typeof ref === 'string')
163
125
  : [],
164
- ...(sceneBindings === undefined ? {} : { sceneBindings }),
126
+ ...(sceneEntityKeys === undefined ? {} : { sceneEntityKeys }),
165
127
  });
166
128
  }
167
129
  return ok({ declarations });
@@ -5,7 +5,8 @@ export type InventoryDigest = string;
5
5
  export function inventoryDigest(inventory: AuthorInventory): InventoryDigest {
6
6
  return inventory.declarations
7
7
  .map(
8
- (row) => `${row.guid}\0${row.sourceKey}\0${row.kind}\0${row.sceneBindings?.join('\0') ?? ''}`,
8
+ (row) =>
9
+ `${row.guid}\0${row.sourceKey}\0${row.kind}\0${row.sceneEntityKeys?.join('\0') ?? ''}`,
9
10
  )
10
11
  .sort()
11
12
  .join('\n');
@@ -16,7 +17,7 @@ export function syncAuthorInventory(inventory: AuthorInventory): AuthorInventory
16
17
  declarations: inventory.declarations.map((row) => ({
17
18
  ...row,
18
19
  refs: [...row.refs],
19
- ...(row.sceneBindings === undefined ? {} : { sceneBindings: [...row.sceneBindings] }),
20
+ ...(row.sceneEntityKeys === undefined ? {} : { sceneEntityKeys: [...row.sceneEntityKeys] }),
20
21
  })),
21
22
  };
22
23
  }
@@ -20,6 +20,7 @@ export interface NativeCookDraft<P = unknown> {
20
20
  readonly refs: readonly string[];
21
21
  readonly artifacts: Readonly<Record<string, NativeCookArtifact>>;
22
22
  readonly inputFingerprint: string;
23
+ readonly sourceDependencies?: readonly string[];
23
24
  }
24
25
 
25
26
  export interface NativeCooker<P = unknown, I = unknown> {
@@ -569,7 +569,9 @@ function validateParameterDefinitions(
569
569
  }
570
570
 
571
571
  export interface PackBuildReadContext {
572
- readByGuid<TAsset extends Asset = Asset>(guid: AssetGuid): Promise<Result<TAsset, unknown>>;
572
+ readByGuid<TAsset extends Asset | PackCookSource = Asset>(
573
+ guid: AssetGuid,
574
+ ): Promise<Result<TAsset, unknown>>;
573
575
  }
574
576
 
575
577
  export interface PackBuildContextWithoutParameters extends PackBuildReadContext {
@@ -588,7 +590,14 @@ export type PackBuildContext<TParameters extends readonly PackParameterDefinitio
588
590
  ? PackBuildContextWithParameters<TParameters>
589
591
  : PackBuildContextWithoutParameters;
590
592
 
591
- export type PackOutputMap = Readonly<Record<string, Asset>>;
593
+ /** Build-only custom source. The host derives identity and invokes the kind's NativeCooker. */
594
+ export interface PackCookSource {
595
+ readonly kind: string;
596
+ readonly execution: 'cooked';
597
+ readonly source: unknown;
598
+ }
599
+
600
+ export type PackOutputMap = Readonly<Record<string, Asset | PackCookSource>>;
592
601
  export type PackBuildResult<TError = unknown> =
593
602
  | Result<PackOutputMap, TError>
594
603
  | Promise<Result<PackOutputMap, TError>>;
@@ -48,6 +48,8 @@ export interface RuntimePackPublicationInput {
48
48
  readonly digest?: string;
49
49
  readonly generation?: number;
50
50
  readonly outputs?: readonly AssetPublicationOutput[];
51
+ /** Author/producer keys by normalized GUID when deriving output rows. */
52
+ readonly sourceKeys?: ReadonlyMap<string, string>;
51
53
  readonly externalEvidence?: readonly AssetPublicationExternalEvidence[];
52
54
  }
53
55
 
@@ -88,10 +90,10 @@ function normalizedAssets(pack: RuntimePackInput): readonly RuntimePackAsset[] {
88
90
  });
89
91
  }
90
92
 
91
- function outputFor(asset: RuntimePackAsset): AssetPublicationOutput {
93
+ function outputFor(asset: RuntimePackAsset, sourceKey?: string): AssetPublicationOutput {
92
94
  return {
93
95
  guid: asset.guid.toLowerCase(),
94
- sourceKey: asset.guid.toLowerCase(),
96
+ sourceKey: sourceKey ?? asset.guid.toLowerCase(),
95
97
  kind: asset.kind,
96
98
  digest: digest({
97
99
  guid: asset.guid.toLowerCase(),
@@ -145,7 +147,9 @@ export function createRuntimePackPublication(
145
147
  ),
146
148
  };
147
149
  const valueDigest = input.digest ?? digest(semantic);
148
- const outputs = input.outputs ?? assets.map(outputFor);
150
+ const outputs =
151
+ input.outputs ??
152
+ assets.map((asset) => outputFor(asset, input.sourceKeys?.get(asset.guid.toLowerCase())));
149
153
  const outputDigest = outputSetDigest(outputs);
150
154
  const generation =
151
155
  input.generation ?? publicationGeneration(input.sourceRevision, valueDigest, outputDigest);
package/src/scanner.ts CHANGED
@@ -254,27 +254,35 @@ function makePackError(
254
254
  }
255
255
 
256
256
  /**
257
- * For scene assets with `payload.mounts[]`, return the lowercased GUID
258
- * each `mount.source` integer resolves to via `asset.refs[]`. Returns an
259
- * empty iterable for non-scene assets, scene assets without mounts, or
260
- * mounts with malformed `source` (out-of-range integer / non-integer) —
261
- * those are caught by ajv schema validation upstream. The yielded GUIDs
262
- * feed scanner step-6's mount-asset cycle DFS (D-1, R10).
257
+ * For keyed scene assets, return the lowercased GUID each nested instance
258
+ * source resolves to via `asset.refs[]` (or directly carries). The yielded GUIDs feed
259
+ * scanner step-6's scene dependency cycle DFS.
263
260
  */
264
- function* extractMountSourceGuids(asset: {
261
+ function* extractInstanceSourceGuids(asset: {
265
262
  kind?: unknown;
266
263
  payload?: unknown;
267
264
  refs: readonly string[];
268
265
  }): Generator<string> {
269
266
  if (asset.kind !== 'scene') return;
270
- const payload = asset.payload as { mounts?: unknown } | undefined;
271
- if (!payload || !Array.isArray(payload.mounts)) return;
272
- for (const rawMount of payload.mounts) {
273
- const mount = rawMount as { source?: unknown };
274
- const idx = mount.source;
275
- if (typeof idx !== 'number' || !Number.isInteger(idx)) continue;
276
- if (idx < 0 || idx >= asset.refs.length) continue;
277
- const resolved = asset.refs[idx];
267
+ const payload = asset.payload as { entities?: unknown } | undefined;
268
+ if (
269
+ !payload ||
270
+ payload.entities === null ||
271
+ typeof payload.entities !== 'object' ||
272
+ Array.isArray(payload.entities)
273
+ )
274
+ return;
275
+ for (const rawEntity of Object.values(payload.entities as Record<string, unknown>)) {
276
+ if (rawEntity === null || typeof rawEntity !== 'object' || Array.isArray(rawEntity)) continue;
277
+ const instance = (rawEntity as { instance?: unknown }).instance;
278
+ if (instance === null || typeof instance !== 'object' || Array.isArray(instance)) continue;
279
+ const source = (instance as { source?: unknown }).source;
280
+ const resolved =
281
+ typeof source === 'number' && Number.isInteger(source)
282
+ ? asset.refs[source]
283
+ : typeof source === 'string'
284
+ ? source
285
+ : undefined;
278
286
  if (typeof resolved !== 'string') continue;
279
287
  yield resolved.toLowerCase();
280
288
  }
@@ -450,7 +458,7 @@ async function scanValidated(
450
458
  guidToPath.set(normalizedGuid, packPath);
451
459
  packRefs.set(normalizedGuid, [
452
460
  ...asset.refs.map((ref) => ref.toLowerCase()),
453
- ...extractMountSourceGuids(asset),
461
+ ...extractInstanceSourceGuids(asset),
454
462
  ]);
455
463
  }
456
464
  }
@@ -563,20 +571,12 @@ async function scanValidated(
563
571
  }
564
572
  guidToPath.set(normalizedGuid, packPath);
565
573
 
566
- // feat-20260608-scene-nesting-ecs-fication M1 / w14 (D-1):
567
- // mount-payload-extract for scene assets, redundantly inject the
568
- // mount.source -> resolved GUID edge into the cycle graph alongside
569
- // asset.refs[]. By the .pack.json convention mount.source is an
570
- // integer index into the same asset.refs[], so the resolved GUID is
571
- // already present in `existingRefs`; this defensive pass guarantees
572
- // that any author-supplied mounts[] references participate in the
573
- // cycle DFS even if the schema-emitter forgot to mirror them into
574
- // refs[]. The `kind: 'mount-asset'` tag on the resulting
575
- // pack-cyclic-reference detail is set by the cycle producer below
576
- // (R10).
574
+ // Keyed scene instances are explicit dependency edges. Keep the
575
+ // producer source in the cycle graph alongside ordinary asset refs so
576
+ // recursive SceneAsset declarations fail before publication.
577
577
  packRefs.set(normalizedGuid, [
578
578
  ...asset.refs.map((ref) => ref.toLowerCase()),
579
- ...extractMountSourceGuids(asset),
579
+ ...extractInstanceSourceGuids(asset),
580
580
  ]);
581
581
  }
582
582
  capture?.declarations.set(packPath, {
@@ -273,6 +273,113 @@
273
273
  "type": "string",
274
274
  "minLength": 1
275
275
  },
276
+ "address": {
277
+ "enum": ["direct", "scene-index"]
278
+ },
279
+ "entry": {
280
+ "type": "string",
281
+ "minLength": 1
282
+ },
283
+ "abi": {
284
+ "type": "object",
285
+ "required": [
286
+ "directEntry",
287
+ "sceneIndexEntry",
288
+ "materialRow",
289
+ "resourceSlots",
290
+ "uvSets",
291
+ "vertexInputs",
292
+ "alphaMask",
293
+ "reflection",
294
+ "receiptIdentity",
295
+ "generation"
296
+ ],
297
+ "properties": {
298
+ "directEntry": { "type": "string", "minLength": 1 },
299
+ "sceneIndexEntry": { "type": "string", "minLength": 1 },
300
+ "materialRow": {
301
+ "type": "object",
302
+ "required": ["byteLength", "fields"],
303
+ "properties": {
304
+ "byteLength": { "type": "integer", "minimum": 16, "multipleOf": 16 },
305
+ "fields": { "type": "array", "items": { "type": "string" } }
306
+ },
307
+ "additionalProperties": false
308
+ },
309
+ "resourceSlots": {
310
+ "type": "array",
311
+ "items": {
312
+ "type": "object",
313
+ "required": ["name", "parameter", "kind", "group", "binding"],
314
+ "properties": {
315
+ "name": { "type": "string", "minLength": 1 },
316
+ "parameter": { "type": "string", "minLength": 1 },
317
+ "kind": { "enum": ["sampler", "texture", "storage-buffer"] },
318
+ "group": { "type": "integer", "minimum": 0 },
319
+ "binding": { "type": "integer", "minimum": 0 }
320
+ },
321
+ "additionalProperties": false
322
+ }
323
+ },
324
+ "uvSets": {
325
+ "type": "array",
326
+ "items": {
327
+ "type": "object",
328
+ "required": ["parameter", "set"],
329
+ "properties": {
330
+ "parameter": { "type": "string", "minLength": 1 },
331
+ "set": { "type": "integer", "minimum": 0 }
332
+ },
333
+ "additionalProperties": false
334
+ }
335
+ },
336
+ "vertexInputs": {
337
+ "type": "array",
338
+ "items": {
339
+ "type": "object",
340
+ "required": ["semantic", "location", "format"],
341
+ "properties": {
342
+ "semantic": { "type": "string", "minLength": 1 },
343
+ "location": { "type": "integer", "minimum": 0 },
344
+ "format": { "type": "string", "minLength": 1 }
345
+ },
346
+ "additionalProperties": false
347
+ }
348
+ },
349
+ "alphaMask": {
350
+ "type": "object",
351
+ "required": ["cutoff", "source"],
352
+ "properties": {
353
+ "cutoff": { "type": "string" },
354
+ "source": { "type": "string" }
355
+ },
356
+ "additionalProperties": false
357
+ },
358
+ "skinPaletteAddress": {
359
+ "type": "object",
360
+ "required": ["group", "binding", "stride"],
361
+ "properties": {
362
+ "group": { "type": "integer", "minimum": 0 },
363
+ "binding": { "type": "integer", "minimum": 0 },
364
+ "stride": { "type": "integer", "minimum": 1 }
365
+ },
366
+ "additionalProperties": false
367
+ },
368
+ "reflection": {
369
+ "type": "object",
370
+ "required": ["layoutIdentity", "resourceSlots", "vertexInputs"],
371
+ "properties": {
372
+ "layoutIdentity": { "type": "string", "minLength": 1 },
373
+ "resourceSlots": { "type": "array" },
374
+ "vertexInputs": { "type": "array" }
375
+ },
376
+ "additionalProperties": false
377
+ },
378
+ "receiptIdentity": { "type": "string", "minLength": 1 },
379
+ "generation": { "type": "integer", "minimum": 1 }
380
+ },
381
+ "additionalProperties": false
382
+ },
276
383
  "context": {
277
384
  "type": "object",
278
385
  "required": [