@forgeax/engine-pack 0.1.25 → 0.1.26

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.
@@ -25,17 +25,26 @@ export interface MaterialCookArtifact {
25
25
  readonly bytes: Uint8Array;
26
26
  }
27
27
 
28
- /** Backend capability projection for one immutable cooked material artifact. */
29
- export interface MaterialCookVariantContext {
28
+ /** Domain-owned inputs used to compile and select a material program. */
29
+ export interface MaterialCookProgramContext {
30
30
  readonly backend: 'webgpu' | 'webgl2' | 'wgpu-native';
31
31
  readonly capability: 'storage-buffer' | 'uniform-fallback';
32
+ readonly pipeline: 'forward' | 'deferred';
33
+ readonly geometry: 'mesh' | 'skinned' | 'sprite';
34
+ readonly pass: 'forward' | 'shadow' | 'depth';
35
+ readonly profile: 'forgeax-material-wgsl-v1';
36
+ readonly toolchain: 'naga-oil';
37
+ readonly instrumentation: 'none' | 'validation';
32
38
  }
33
39
 
34
- /** Additional backend artifact published with one material generation. */
35
- export interface MaterialCookVariant {
36
- readonly context: MaterialCookVariantContext;
40
+ /** Programs are derived artifacts, never independently authored assets. */
41
+ export interface MaterialCookProgram {
37
42
  readonly specializationKey: string;
38
43
  readonly artifact: MaterialCookArtifact;
44
+ readonly selections: readonly {
45
+ readonly pass: string;
46
+ readonly context: MaterialCookProgramContext;
47
+ }[];
39
48
  }
40
49
 
41
50
  export interface MaterialCookWasmProvenance {
@@ -64,7 +73,7 @@ export interface MaterialCookIdentity {
64
73
  export type MaterialCookIdentityInput = Omit<MaterialCookIdentity, 'cookIdentity'>;
65
74
 
66
75
  export interface MaterialCookReceipt {
67
- readonly schemaVersion: 'material-cook/3';
76
+ readonly schemaVersion: 'material-cook/4';
68
77
  readonly sourceClosure: readonly string[];
69
78
  readonly profile: string;
70
79
  readonly compilerVersion: string;
@@ -82,16 +91,14 @@ export interface MaterialParameterContract {
82
91
  }
83
92
 
84
93
  export interface CookedMaterialRecord {
85
- readonly schemaVersion: 'material-cook/3';
94
+ readonly schemaVersion: 'material-cook/4';
86
95
  readonly guid: string;
87
96
  readonly authored?: MaterialAsset;
88
97
  readonly materialGuid?: string;
89
98
  readonly publicationGeneration?: number;
90
99
  readonly specializationKey?: string;
91
- /** Compiler context used by the primary `artifact` entry. */
92
- readonly variantContext?: MaterialCookVariantContext;
93
- /** Additional immutable artifacts for other backend capability lanes. */
94
- readonly variants?: readonly MaterialCookVariant[];
100
+ readonly programs: readonly MaterialCookProgram[];
101
+ /** Digest of the complete program/Pass/context manifest. */
95
102
  readonly artifactDigest?: string;
96
103
  readonly sourceClosure?: readonly string[];
97
104
  readonly parameterContract?: MaterialParameterContract;
@@ -101,7 +108,6 @@ export interface CookedMaterialRecord {
101
108
  readonly values: Readonly<Record<string, MaterialValue | null>>;
102
109
  };
103
110
  readonly refs: MaterialCookRefs;
104
- readonly artifact: MaterialCookArtifact;
105
111
  readonly receipt: MaterialCookReceipt;
106
112
  }
107
113
 
@@ -127,7 +133,6 @@ const STANDARD_ROOT_MODULES = new Set([
127
133
  'forgeax::pbr-skin',
128
134
  'forgeax_material::standard',
129
135
  'forgeax_material::pbr-skin',
130
- 'forgeax::default-shadow-caster',
131
136
  ]);
132
137
 
133
138
  /** Shared ownership predicate for the built-in Standard material root. */
@@ -266,7 +271,7 @@ export function serializeMaterialCookReceipt(receipt: MaterialCookReceipt): stri
266
271
  function invalid(field: string, actual?: unknown): Result<never, MaterialCookRecordError> {
267
272
  return err({
268
273
  code: 'material-cook-record-invalid',
269
- expected: 'a complete material-cook/3 record with layered identity and provenance',
274
+ expected: 'a complete material-cook/4 record with layered identity and provenance',
270
275
  hint: 're-cook the material and publish its record, artifact, references, and receipt together',
271
276
  detail: {
272
277
  field,
@@ -337,7 +342,7 @@ export function validateMaterialCookReceipt(
337
342
  ): Result<MaterialCookReceipt, MaterialCookRecordError> {
338
343
  if (value === null || typeof value !== 'object') return invalid('receipt');
339
344
  const candidate = value as Record<string, unknown>;
340
- if (candidate.schemaVersion !== 'material-cook/3')
345
+ if (candidate.schemaVersion !== 'material-cook/4')
341
346
  return invalid('receipt.schemaVersion', candidate.schemaVersion);
342
347
  const identityResult = validateIdentity(candidate.identity);
343
348
  if (!identityResult.ok) return identityResult;
@@ -388,7 +393,7 @@ export function validateMaterialCookReceipt(
388
393
  return invalid('receipt.identity.cookIdentity', identity.cookIdentity);
389
394
  }
390
395
  return ok({
391
- schemaVersion: 'material-cook/3',
396
+ schemaVersion: 'material-cook/4',
392
397
  sourceClosure: candidate.sourceClosure as string[],
393
398
  profile: candidate.profile as string,
394
399
  compilerVersion: candidate.compilerVersion as string,
@@ -407,7 +412,7 @@ export function validateCookedMaterialRecord(
407
412
  ): Result<CookedMaterialRecord, MaterialCookRecordError> {
408
413
  if (value === null || typeof value !== 'object') return invalid('record');
409
414
  const candidate = value as Record<string, unknown>;
410
- if (candidate.schemaVersion !== 'material-cook/3')
415
+ if (candidate.schemaVersion !== 'material-cook/4')
411
416
  return invalid('schemaVersion', candidate.schemaVersion);
412
417
  if (typeof candidate.guid !== 'string' || !candidate.guid) return invalid('guid');
413
418
  if (candidate.materialGuid !== undefined && typeof candidate.materialGuid !== 'string')
@@ -419,49 +424,8 @@ export function validateCookedMaterialRecord(
419
424
  return invalid('publicationGeneration', candidate.publicationGeneration);
420
425
  if (candidate.specializationKey !== undefined && typeof candidate.specializationKey !== 'string')
421
426
  return invalid('specializationKey');
422
- if (candidate.variantContext !== undefined) {
423
- if (candidate.variantContext === null || typeof candidate.variantContext !== 'object')
424
- return invalid('variantContext');
425
- const context = candidate.variantContext as Record<string, unknown>;
426
- if (
427
- (context.backend !== 'webgpu' &&
428
- context.backend !== 'webgl2' &&
429
- context.backend !== 'wgpu-native') ||
430
- (context.capability !== 'storage-buffer' && context.capability !== 'uniform-fallback')
431
- )
432
- return invalid('variantContext', candidate.variantContext);
433
- }
434
- if (candidate.variants !== undefined) {
435
- if (!Array.isArray(candidate.variants)) return invalid('variants');
436
- for (const [index, value] of candidate.variants.entries()) {
437
- if (value === null || typeof value !== 'object') return invalid(`variants[${index}]`);
438
- const variant = value as Record<string, unknown>;
439
- const context = variant.context;
440
- if (context === null || typeof context !== 'object')
441
- return invalid(`variants[${index}].context`);
442
- const contextRecord = context as Record<string, unknown>;
443
- if (
444
- (contextRecord.backend !== 'webgpu' &&
445
- contextRecord.backend !== 'webgl2' &&
446
- contextRecord.backend !== 'wgpu-native') ||
447
- (contextRecord.capability !== 'storage-buffer' &&
448
- contextRecord.capability !== 'uniform-fallback')
449
- )
450
- return invalid(`variants[${index}].context`, context);
451
- if (typeof variant.specializationKey !== 'string' || variant.specializationKey.length === 0)
452
- return invalid(`variants[${index}].specializationKey`);
453
- if (variant.artifact === null || typeof variant.artifact !== 'object')
454
- return invalid(`variants[${index}].artifact`);
455
- const variantArtifact = variant.artifact as Record<string, unknown>;
456
- if (
457
- typeof variantArtifact.mediaType !== 'string' ||
458
- typeof variantArtifact.path !== 'string' ||
459
- typeof variantArtifact.digest !== 'string' ||
460
- normalizeArtifactBytes(variantArtifact.bytes) === undefined
461
- )
462
- return invalid(`variants[${index}].artifact`);
463
- }
464
- }
427
+ if ('artifact' in candidate || 'variants' in candidate || 'variantContext' in candidate)
428
+ return invalid('programs', 'legacy single-artifact publication');
465
429
  if (candidate.artifactDigest !== undefined && typeof candidate.artifactDigest !== 'string')
466
430
  return invalid('artifactDigest');
467
431
  if (
@@ -486,21 +450,8 @@ export function validateCookedMaterialRecord(
486
450
  if (candidate.resolved === null || typeof candidate.resolved !== 'object')
487
451
  return invalid('resolved');
488
452
  if (candidate.refs === null || typeof candidate.refs !== 'object') return invalid('refs');
489
- if (candidate.artifact === null || typeof candidate.artifact !== 'object')
490
- return invalid('artifact');
491
453
  if (candidate.receipt === null || typeof candidate.receipt !== 'object')
492
454
  return invalid('receipt');
493
- const artifact = candidate.artifact as Record<string, unknown>;
494
- if (
495
- typeof artifact.digest !== 'string' ||
496
- (!Array.isArray(artifact.bytes) && !ArrayBuffer.isView(artifact.bytes))
497
- ) {
498
- return invalid('artifact');
499
- }
500
- const receiptResult = validateMaterialCookReceipt(candidate.receipt, {
501
- artifactDigest: artifact.digest as string,
502
- });
503
- if (!receiptResult.ok) return receiptResult;
504
455
  const resolved = candidate.resolved as Record<string, unknown>;
505
456
  if (!Array.isArray(resolved.passes)) return invalid('resolved.passes', resolved.passes);
506
457
  if (!Array.isArray(resolved.parameters))
@@ -511,6 +462,84 @@ export function validateCookedMaterialRecord(
511
462
  Array.isArray(resolved.values)
512
463
  )
513
464
  return invalid('resolved.values', resolved.values);
465
+ const passes = resolved.passes as MaterialPass[];
466
+ const passNames = new Set<string>();
467
+ for (const [index, pass] of passes.entries()) {
468
+ if (
469
+ pass === null ||
470
+ typeof pass !== 'object' ||
471
+ typeof pass.name !== 'string' ||
472
+ !pass.name ||
473
+ passNames.has(pass.name) ||
474
+ pass.program === null ||
475
+ typeof pass.program !== 'object' ||
476
+ typeof pass.program.module !== 'string'
477
+ )
478
+ return invalid(`resolved.passes[${index}]`);
479
+ passNames.add(pass.name);
480
+ }
481
+ if (!Array.isArray(candidate.programs) || candidate.programs.length === 0)
482
+ return invalid('programs');
483
+ const programs: MaterialCookProgram[] = [];
484
+ const programKeys = new Set<string>();
485
+ const selections = new Set<string>();
486
+ const selectedPasses = new Set<string>();
487
+ for (const [index, entry] of candidate.programs.entries()) {
488
+ const field = `programs[${index}]`;
489
+ if (entry === null || typeof entry !== 'object') return invalid(field);
490
+ const program = entry as Record<string, unknown>;
491
+ if (
492
+ typeof program.specializationKey !== 'string' ||
493
+ !program.specializationKey ||
494
+ programKeys.has(program.specializationKey)
495
+ )
496
+ return invalid(`${field}.specializationKey`);
497
+ programKeys.add(program.specializationKey);
498
+ if (program.artifact === null || typeof program.artifact !== 'object')
499
+ return invalid(`${field}.artifact`);
500
+ const artifact = program.artifact as Record<string, unknown>;
501
+ const bytes = normalizeArtifactBytes(artifact.bytes);
502
+ if (
503
+ artifact.mediaType !== 'text/wgsl' ||
504
+ typeof artifact.path !== 'string' ||
505
+ !artifact.path ||
506
+ typeof artifact.digest !== 'string' ||
507
+ !artifact.digest ||
508
+ bytes === undefined
509
+ )
510
+ return invalid(`${field}.artifact`);
511
+ if (!Array.isArray(program.selections) || program.selections.length === 0)
512
+ return invalid(`${field}.selections`);
513
+ const programSelections: MaterialCookProgram['selections'][number][] = [];
514
+ for (const [selectionIndex, selection] of program.selections.entries()) {
515
+ const selectionField = `${field}.selections[${selectionIndex}]`;
516
+ if (selection === null || typeof selection !== 'object' || !passNames.has(selection.pass))
517
+ return invalid(selectionField);
518
+ const context = validateMaterialCookProgramContext(selection.context);
519
+ if (!context.ok)
520
+ return invalid(
521
+ `${selectionField}.${context.error.detail.field}`,
522
+ context.error.detail.actual,
523
+ );
524
+ const key = JSON.stringify([selection.pass, materialProgramContextKey(context.value)]);
525
+ if (selections.has(key)) return invalid(selectionField, 'ambiguous Pass/context selection');
526
+ selections.add(key);
527
+ selectedPasses.add(selection.pass);
528
+ programSelections.push({ pass: selection.pass, context: context.value });
529
+ }
530
+ programs.push({
531
+ specializationKey: program.specializationKey,
532
+ artifact: { mediaType: 'text/wgsl', path: artifact.path, digest: artifact.digest, bytes },
533
+ selections: programSelections,
534
+ });
535
+ }
536
+ if ([...passNames].some((pass) => !selectedPasses.has(pass)))
537
+ return invalid('programs.selections', 'unpublished Pass');
538
+ const manifestDigest = createMaterialProgramSetDigest(programs, passes);
539
+ const receiptResult = validateMaterialCookReceipt(candidate.receipt, {
540
+ artifactDigest: manifestDigest,
541
+ });
542
+ if (!receiptResult.ok) return receiptResult;
514
543
  let expectedLayerPlanIdentity: string | undefined;
515
544
  try {
516
545
  expectedLayerPlanIdentity = materialLayerPlanIdentity({
@@ -528,37 +557,18 @@ export function validateCookedMaterialRecord(
528
557
  receiptResult.value.derivedInterface.layerPlanIdentity,
529
558
  );
530
559
  }
531
- if (candidate.artifactDigest !== undefined && candidate.artifactDigest !== artifact.digest)
560
+ if (candidate.artifactDigest !== undefined && candidate.artifactDigest !== manifestDigest)
532
561
  return invalid('artifactDigest', candidate.artifactDigest);
533
562
  if (
534
563
  candidate.publicationGeneration !== undefined &&
535
564
  receiptResult.value.identity.cookGeneration !== candidate.publicationGeneration
536
565
  )
537
566
  return invalid('receipt.identity.cookGeneration', receiptResult.value.identity.cookGeneration);
538
- const normalized = {
567
+ return ok({
539
568
  ...candidate,
540
- artifact: {
541
- ...artifact,
542
- bytes: normalizeArtifactBytes(artifact.bytes) as Uint8Array,
543
- },
544
- ...(Array.isArray(candidate.variants)
545
- ? {
546
- variants: candidate.variants.map((value) => {
547
- const variant = value as Record<string, unknown>;
548
- const variantArtifact = variant.artifact as Record<string, unknown>;
549
- return {
550
- ...variant,
551
- artifact: {
552
- ...variantArtifact,
553
- bytes: normalizeArtifactBytes(variantArtifact.bytes) as Uint8Array,
554
- },
555
- };
556
- }),
557
- }
558
- : {}),
569
+ programs,
559
570
  receipt: receiptResult.value,
560
- } as unknown as CookedMaterialRecord;
561
- return ok(normalized);
571
+ } as unknown as CookedMaterialRecord);
562
572
  }
563
573
 
564
574
  export function projectCookedMaterialRecord(
@@ -567,10 +577,65 @@ export function projectCookedMaterialRecord(
567
577
  return {
568
578
  resolved: record.resolved,
569
579
  refs: record.refs,
570
- artifact: record.artifact,
571
- ...(record.variantContext === undefined ? {} : { variantContext: record.variantContext }),
572
- ...(record.variants === undefined ? {} : { variants: record.variants }),
580
+ programs: record.programs,
573
581
  receipt: record.receipt,
574
582
  schemaVersion: record.schemaVersion,
575
583
  };
576
584
  }
585
+
586
+ /** Canonical context key shared by publication validation and runtime selection. */
587
+ export function materialProgramContextKey(context: MaterialCookProgramContext): string {
588
+ return JSON.stringify(jsonValue(context));
589
+ }
590
+
591
+ export function validateMaterialCookProgramContext(
592
+ value: unknown,
593
+ ): Result<MaterialCookProgramContext, MaterialCookRecordError> {
594
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
595
+ return invalid('context', value);
596
+ const context = value as Record<string, unknown>;
597
+ const fields = {
598
+ backend: ['webgpu', 'webgl2', 'wgpu-native'],
599
+ capability: ['storage-buffer', 'uniform-fallback'],
600
+ pipeline: ['forward', 'deferred'],
601
+ geometry: ['mesh', 'skinned', 'sprite'],
602
+ pass: ['forward', 'shadow', 'depth'],
603
+ profile: ['forgeax-material-wgsl-v1'],
604
+ toolchain: ['naga-oil'],
605
+ instrumentation: ['none', 'validation'],
606
+ };
607
+ for (const field of Object.keys(context)) {
608
+ if (!(field in fields)) return invalid(`context.${field}`, context[field]);
609
+ }
610
+ for (const [field, allowed] of Object.entries(fields)) {
611
+ if (!allowed.includes(context[field] as string))
612
+ return invalid(`context.${field}`, context[field]);
613
+ }
614
+ return ok({ ...context } as unknown as MaterialCookProgramContext);
615
+ }
616
+
617
+ /** Covers every selection and artifact while excluding transport copies of the bytes. */
618
+ export function createMaterialProgramSetDigest(
619
+ programs: readonly MaterialCookProgram[],
620
+ passes: readonly MaterialPass[],
621
+ ): string {
622
+ const manifest = {
623
+ passes,
624
+ programs: programs
625
+ .map((program) => ({
626
+ specializationKey: program.specializationKey,
627
+ artifact: {
628
+ path: program.artifact.path,
629
+ mediaType: program.artifact.mediaType,
630
+ digest: program.artifact.digest,
631
+ },
632
+ selections: [...program.selections].sort((a, b) =>
633
+ JSON.stringify(jsonValue(a)).localeCompare(JSON.stringify(jsonValue(b))),
634
+ ),
635
+ }))
636
+ .sort((a, b) => a.specializationKey.localeCompare(b.specializationKey)),
637
+ };
638
+ return createMaterialArtifactDigest(
639
+ new TextEncoder().encode(JSON.stringify(jsonValue(manifest))),
640
+ );
641
+ }
package/src/index.ts CHANGED
@@ -7,23 +7,26 @@ export {
7
7
  collectMaterialCookRefs,
8
8
  createMaterialArtifactDigest,
9
9
  createMaterialCookIdentity,
10
+ createMaterialProgramSetDigest,
10
11
  isStandardMaterialRecord,
11
12
  isStandardRootModule,
12
13
  type MaterialCookArtifact,
13
14
  type MaterialCookIdentity,
14
15
  type MaterialCookIdentityExpectation,
15
16
  type MaterialCookIdentityInput,
17
+ type MaterialCookProgram,
18
+ type MaterialCookProgramContext,
16
19
  type MaterialCookReceipt,
17
20
  type MaterialCookRecordError,
18
21
  type MaterialCookRefs,
19
- type MaterialCookVariant,
20
- type MaterialCookVariantContext,
21
22
  type MaterialCookWasmProvenance,
22
23
  materialLayerPlanIdentity,
24
+ materialProgramContextKey,
23
25
  projectCookedMaterialRecord,
24
26
  serializeCookedMaterialRecord,
25
27
  serializeMaterialCookReceipt,
26
28
  validateCookedMaterialRecord,
29
+ validateMaterialCookProgramContext,
27
30
  validateMaterialCookReceipt,
28
31
  } from './evidence/material-cook.js';
29
32
  export { buildOfflineAssetEvidence, packageVerification } from './evidence/offline-evidence.js';
@@ -3,16 +3,19 @@ export {
3
3
  collectMaterialCookRefs,
4
4
  createMaterialArtifactDigest,
5
5
  createMaterialCookIdentity,
6
+ createMaterialProgramSetDigest,
6
7
  type MaterialCookArtifact,
8
+ type MaterialCookProgram,
9
+ type MaterialCookProgramContext,
7
10
  type MaterialCookReceipt,
8
11
  type MaterialCookRecordError,
9
12
  type MaterialCookRefs,
10
- type MaterialCookVariant,
11
- type MaterialCookVariantContext,
12
13
  type MaterialCookWasmProvenance,
13
14
  materialLayerPlanIdentity,
15
+ materialProgramContextKey,
14
16
  projectCookedMaterialRecord,
15
17
  serializeCookedMaterialRecord,
16
18
  serializeMaterialCookReceipt,
17
19
  validateCookedMaterialRecord,
20
+ validateMaterialCookProgramContext,
18
21
  } from './evidence/material-cook.js';