@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.
@@ -5,13 +5,15 @@ import { describe, expect, it } from 'vitest';
5
5
  import {
6
6
  type CookedMaterialRecord,
7
7
  createMaterialArtifactDigest,
8
+ createMaterialProgramSetDigest,
9
+ type MaterialCookProgram,
8
10
  projectCookedMaterialRecord,
9
11
  serializeCookedMaterialRecord,
10
12
  validateCookedMaterialRecord,
11
13
  } from '../evidence/material-cook.js';
12
14
 
13
15
  const record: CookedMaterialRecord = {
14
- schemaVersion: 'material-cook/3',
16
+ schemaVersion: 'material-cook/4',
15
17
  guid: 'mat-root',
16
18
  authored: {
17
19
  kind: 'material',
@@ -25,14 +27,34 @@ const record: CookedMaterialRecord = {
25
27
  values: { roughness: 0.5 },
26
28
  },
27
29
  refs: { parent: [], textures: [], samplers: [], modules: ['core/pbr'] },
28
- artifact: {
29
- mediaType: 'text/wgsl',
30
- path: 'materials/mat-root/forward.wgsl',
31
- digest: 'sha256:artifact',
32
- bytes: new TextEncoder().encode('shader'),
33
- },
30
+ programs: [
31
+ {
32
+ specializationKey: 'sha256:program',
33
+ selections: [
34
+ {
35
+ pass: 'forward',
36
+ context: {
37
+ backend: 'webgpu',
38
+ capability: 'storage-buffer',
39
+ pipeline: 'forward',
40
+ geometry: 'mesh',
41
+ pass: 'forward',
42
+ profile: 'forgeax-material-wgsl-v1',
43
+ toolchain: 'naga-oil',
44
+ instrumentation: 'none',
45
+ },
46
+ },
47
+ ],
48
+ artifact: {
49
+ mediaType: 'text/wgsl',
50
+ path: 'materials/mat-root/forward.wgsl',
51
+ digest: 'sha256:artifact',
52
+ bytes: new TextEncoder().encode('shader'),
53
+ },
54
+ },
55
+ ],
34
56
  receipt: {
35
- schemaVersion: 'material-cook/3',
57
+ schemaVersion: 'material-cook/4',
36
58
  sourceClosure: ['materials/mat-root.material.json'],
37
59
  profile: 'webgpu/v1',
38
60
  compilerVersion: 'compiler/1',
@@ -60,8 +82,21 @@ const record: CookedMaterialRecord = {
60
82
  },
61
83
  };
62
84
 
85
+ function seal(value: CookedMaterialRecord): CookedMaterialRecord {
86
+ return {
87
+ ...value,
88
+ receipt: {
89
+ ...value.receipt,
90
+ identity: {
91
+ ...value.receipt.identity,
92
+ artifactDigest: createMaterialProgramSetDigest(value.programs, value.resolved.passes),
93
+ },
94
+ },
95
+ };
96
+ }
97
+
63
98
  describe('cooked material schema', () => {
64
- it('requires material-cook/3 identity and derived interface association', () => {
99
+ it('requires material-cook/4 identity and derived interface association', () => {
65
100
  const schema = JSON.parse(
66
101
  readFileSync(
67
102
  fileURLToPath(new URL('../schema/material-cook.schema.json', import.meta.url)),
@@ -74,14 +109,14 @@ describe('cooked material schema', () => {
74
109
  >;
75
110
  required: string[];
76
111
  };
77
- expect(schema.properties.schemaVersion?.const).toBe('material-cook/3');
112
+ expect(schema.properties.schemaVersion?.const).toBe('material-cook/4');
78
113
  expect(schema.properties.receipt?.required).toEqual(
79
114
  expect.arrayContaining(['identity', 'derivedInterface']),
80
115
  );
81
116
  expect(schema.properties.receipt?.properties?.derivedInterface).toBeDefined();
82
117
  });
83
118
 
84
- it('defines the v3 identity, provenance, and generation contract', () => {
119
+ it('defines the v4 identity, provenance, and generation contract', () => {
85
120
  const schema = JSON.parse(
86
121
  readFileSync(
87
122
  fileURLToPath(new URL('../schema/material-cook.schema.json', import.meta.url)),
@@ -93,7 +128,7 @@ describe('cooked material schema', () => {
93
128
  { const?: string; required?: string[]; properties?: Record<string, unknown> }
94
129
  >;
95
130
  };
96
- expect(schema.properties.schemaVersion?.const).toBe('material-cook/3');
131
+ expect(schema.properties.schemaVersion?.const).toBe('material-cook/4');
97
132
  expect(schema.properties.receipt?.properties?.identity).toMatchObject({
98
133
  type: 'object',
99
134
  required: expect.arrayContaining([
@@ -119,25 +154,141 @@ describe('cooked material schema', () => {
119
154
  });
120
155
 
121
156
  it('serializes a stable cooked DTO and derives an artifact digest', () => {
122
- const first = serializeCookedMaterialRecord(record);
123
- const second = serializeCookedMaterialRecord({ ...record });
157
+ const first = serializeCookedMaterialRecord(seal(record));
158
+ const second = serializeCookedMaterialRecord(seal({ ...record }));
124
159
 
125
160
  expect(first).toBe(second);
126
- expect(createMaterialArtifactDigest(record.artifact.bytes)).toBe(
161
+ expect(createMaterialArtifactDigest(new TextEncoder().encode('shader'))).toBe(
127
162
  'sha256:e137e75a5e0e7a623ca39de480667a55b43e0eedec58767634ebaef07c33383a',
128
163
  );
129
- expect(validateCookedMaterialRecord(JSON.parse(first))).toEqual({ ok: true, value: record });
164
+ expect(validateCookedMaterialRecord(JSON.parse(first))).toEqual({
165
+ ok: true,
166
+ value: seal(record),
167
+ });
130
168
  });
131
169
 
132
170
  it('projects cooked data without exposing an authored GUID entry', () => {
133
171
  const projection = projectCookedMaterialRecord(record);
134
172
 
135
173
  expect(projection.resolved.values).toEqual({ roughness: 0.5 });
136
- expect(projection.artifact.digest).toBe('sha256:artifact');
174
+ expect(projection.programs[0]?.artifact.digest).toBe('sha256:artifact');
137
175
  expect(projection).not.toHaveProperty('guid');
138
176
  expect(projection).not.toHaveProperty('authored');
139
177
  });
140
178
 
179
+ it('admits different programs with equal entry names and shared programs with multiple selections', () => {
180
+ const primary = record.programs[0] as MaterialCookProgram;
181
+ const passes = [
182
+ ...record.resolved.passes,
183
+ {
184
+ name: 'second',
185
+ program: { module: 'other', vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
186
+ },
187
+ ];
188
+ const selection = {
189
+ ...primary.selections[0],
190
+ pass: 'second',
191
+ } as MaterialCookProgram['selections'][number];
192
+ const distinct = seal({
193
+ ...record,
194
+ resolved: { ...record.resolved, passes },
195
+ programs: [
196
+ primary,
197
+ {
198
+ ...primary,
199
+ specializationKey: 'second',
200
+ artifact: {
201
+ ...primary.artifact,
202
+ path: 'second.wgsl',
203
+ digest: 'sha256:second',
204
+ bytes: new TextEncoder().encode('other'),
205
+ },
206
+ selections: [selection],
207
+ },
208
+ ],
209
+ });
210
+ expect(validateCookedMaterialRecord(distinct)).toMatchObject({ ok: true });
211
+ const shared = seal({
212
+ ...distinct,
213
+ programs: [{ ...primary, selections: [...primary.selections, selection] }],
214
+ });
215
+ expect(validateCookedMaterialRecord(shared)).toMatchObject({ ok: true });
216
+ });
217
+
218
+ it('rejects ambiguous selections, unpublished passes and unknown context axes', () => {
219
+ const primary = record.programs[0] as MaterialCookProgram;
220
+ expect(
221
+ validateCookedMaterialRecord({
222
+ ...record,
223
+ programs: [primary, { ...primary, specializationKey: 'duplicate' }],
224
+ }),
225
+ ).toMatchObject({
226
+ ok: false,
227
+ error: { detail: { actual: 'ambiguous Pass/context selection' } },
228
+ });
229
+ expect(
230
+ validateCookedMaterialRecord({
231
+ ...record,
232
+ resolved: {
233
+ ...record.resolved,
234
+ passes: [...record.resolved.passes, { name: 'missing', program: { module: 'other' } }],
235
+ },
236
+ }),
237
+ ).toMatchObject({ ok: false, error: { detail: { actual: 'unpublished Pass' } } });
238
+ expect(
239
+ validateCookedMaterialRecord({
240
+ ...record,
241
+ programs: [
242
+ {
243
+ ...primary,
244
+ selections: [
245
+ { pass: 'forward', context: { ...primary.selections[0]?.context, arbitrary: true } },
246
+ ],
247
+ },
248
+ ],
249
+ }),
250
+ ).toMatchObject({
251
+ ok: false,
252
+ error: { detail: { field: 'programs[0].selections[0].context.arbitrary' } },
253
+ });
254
+ });
255
+
256
+ it('rejects stale manifests after entry or context changes and rejects legacy publication fields', () => {
257
+ const sealed = seal(record);
258
+ expect(
259
+ validateCookedMaterialRecord({
260
+ ...sealed,
261
+ resolved: {
262
+ ...sealed.resolved,
263
+ passes: [{ name: 'forward', program: { module: 'core/pbr', fragmentEntry: 'fs_other' } }],
264
+ },
265
+ }),
266
+ ).toMatchObject({ ok: false, error: { detail: { field: 'receipt.identity.artifactDigest' } } });
267
+ const primary = record.programs[0] as MaterialCookProgram;
268
+ expect(
269
+ validateCookedMaterialRecord({
270
+ ...sealed,
271
+ programs: [
272
+ {
273
+ ...primary,
274
+ selections: [
275
+ {
276
+ pass: 'forward',
277
+ context: { ...primary.selections[0]?.context, geometry: 'skinned' },
278
+ },
279
+ ],
280
+ },
281
+ ],
282
+ }),
283
+ ).toMatchObject({ ok: false, error: { detail: { field: 'receipt.identity.artifactDigest' } } });
284
+ expect(
285
+ validateCookedMaterialRecord({ ...sealed, schemaVersion: 'material-cook/3' }),
286
+ ).toMatchObject({ ok: false });
287
+ expect(validateCookedMaterialRecord({ ...sealed, artifact: primary.artifact })).toMatchObject({
288
+ ok: false,
289
+ });
290
+ });
291
+
141
292
  it('rejects missing fields and unsupported schema versions', () => {
142
293
  expect(validateCookedMaterialRecord({ ...record, refs: undefined })).toMatchObject({
143
294
  ok: false,
@@ -154,7 +305,7 @@ describe('cooked material schema', () => {
154
305
  it('rejects a Standard record whose receipt layer plan is stale', () => {
155
306
  const standardPass = { name: 'forward', program: { module: 'forgeax::default-standard-pbr' } };
156
307
  const standardPlan = deriveStandardLayerPlan(record.resolved.parameters, [standardPass]);
157
- const standardRecord = {
308
+ const standardRecord = seal({
158
309
  ...record,
159
310
  resolved: { ...record.resolved, passes: [standardPass] },
160
311
  receipt: {
@@ -164,7 +315,7 @@ describe('cooked material schema', () => {
164
315
  layerPlanIdentity: standardPlan.identity,
165
316
  },
166
317
  },
167
- };
318
+ });
168
319
  expect(validateCookedMaterialRecord(standardRecord)).toMatchObject({ ok: true });
169
320
  expect(
170
321
  validateCookedMaterialRecord({
@@ -185,7 +336,7 @@ describe('cooked material schema', () => {
185
336
  it('rejects a Standard record whose receipt omits the layer plan identity', () => {
186
337
  const standardPass = { name: 'forward', program: { module: 'forgeax::default-standard-pbr' } };
187
338
  const standardPlan = deriveStandardLayerPlan(record.resolved.parameters, [standardPass]);
188
- const standardRecord = {
339
+ const standardRecord = seal({
189
340
  ...record,
190
341
  resolved: { ...record.resolved, passes: [standardPass] },
191
342
  receipt: {
@@ -195,7 +346,7 @@ describe('cooked material schema', () => {
195
346
  layerPlanIdentity: standardPlan.identity,
196
347
  },
197
348
  },
198
- };
349
+ });
199
350
  expect(
200
351
  validateCookedMaterialRecord({
201
352
  ...standardRecord,
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { Materials } from '@forgeax/engine-render';
6
6
  import { describe, expect, it } from 'vitest';
7
+ import { validateCookedMaterialRecord } from '../material-cook.js';
7
8
  import { NativeCookerRegistry } from '../native-cooker-registry.js';
8
9
 
9
10
  const compilerModule = pathToFileURL(
@@ -39,15 +40,17 @@ describe('Surface Pack publication', () => {
39
40
  source: material,
40
41
  sourcePath,
41
42
  });
42
- const cooked = draft.payload.cooked as {
43
- readonly receipt: { readonly sourceClosure: readonly string[] };
44
- readonly artifact: { readonly path: string; readonly bytes: Uint8Array };
45
- };
43
+ const cooked = validateCookedMaterialRecord(draft.payload.cooked).unwrap();
46
44
  expect(cooked.receipt.sourceClosure).toContain(sourcePath);
47
- expect(cooked.artifact.path).toContain(GUID);
48
- const artifact = draft.artifacts[cooked.artifact.path];
49
- expect(artifact?.bytes.byteLength).toBeGreaterThan(0);
50
- expect(Object.keys(draft.artifacts)).toContain(cooked.artifact.path);
45
+ expect(cooked.programs.length).toBeGreaterThan(0);
46
+ for (const program of cooked.programs) {
47
+ const artifact = draft.artifacts[program.artifact.path];
48
+ expect(artifact?.bytes.byteLength).toBeGreaterThan(0);
49
+ expect(artifact?.bytes).toEqual(new Uint8Array(program.artifact.bytes));
50
+ }
51
+ expect(
52
+ cooked.resolved.passes?.find((pass) => pass.name === 'shadow-caster')?.program,
53
+ ).toMatchObject({ module: 'forgeax::default-shadow-caster', fragmentEntry: 'fs_shadow' });
51
54
  } finally {
52
55
  await rm(root, { recursive: true, force: true });
53
56
  }
@@ -154,6 +157,6 @@ describe('Surface Pack publication', () => {
154
157
  guid: GUID,
155
158
  source: material,
156
159
  }),
157
- ).rejects.toThrow('material-physical-contract-invalid');
160
+ ).rejects.toMatchObject({ code: 'material-physical-contract-invalid' });
158
161
  });
159
162
  });
@@ -9,12 +9,12 @@ const customOptions = {
9
9
  };
10
10
 
11
11
  describe('custom Surface MaterialAsset wire projection', () => {
12
- it('uses Standard module and the existing Surface slot wire', () => {
12
+ it('uses Standard modules plus the default shadow caster and the existing Surface slot wire', () => {
13
13
  const material = Materials.standard(customOptions as never);
14
14
  expect(material.passes?.map((pass) => pass.program.module)).toEqual([
15
15
  'forgeax_material::standard',
16
16
  'forgeax_material::standard',
17
- 'forgeax_material::standard',
17
+ 'forgeax::default-shadow-caster',
18
18
  ]);
19
19
  expect(
20
20
  material.passes?.every(
package/src/build.ts CHANGED
@@ -57,11 +57,11 @@ export {
57
57
  createMaterialArtifactDigest,
58
58
  type MaterialCookArtifact,
59
59
  type MaterialCookIdentityExpectation,
60
+ type MaterialCookProgram,
61
+ type MaterialCookProgramContext,
60
62
  type MaterialCookReceipt,
61
63
  type MaterialCookRecordError,
62
64
  type MaterialCookRefs,
63
- type MaterialCookVariant,
64
- type MaterialCookVariantContext,
65
65
  projectCookedMaterialRecord,
66
66
  serializeCookedMaterialRecord,
67
67
  serializeMaterialCookReceipt,