@forgeax/engine-vfx 0.1.2

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 (108) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +214 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
  5. package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
  7. package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
  9. package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
  11. package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
  13. package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
  17. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
  21. package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
  23. package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
  25. package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/instance.unit.test.d.ts +2 -0
  27. package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
  29. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
  31. package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/player-type.test-d.d.ts +2 -0
  33. package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
  35. package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
  37. package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
  40. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  41. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  42. package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
  43. package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
  45. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
  46. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
  47. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
  49. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
  50. package/dist/assets/particle-effect-decoder.d.ts +3 -0
  51. package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
  52. package/dist/authoring-descriptor.d.ts +60 -0
  53. package/dist/authoring-descriptor.d.ts.map +1 -0
  54. package/dist/code-source.d.ts +140 -0
  55. package/dist/code-source.d.ts.map +1 -0
  56. package/dist/data-interface.d.ts +53 -0
  57. package/dist/data-interface.d.ts.map +1 -0
  58. package/dist/effect-contract.d.ts +57 -0
  59. package/dist/effect-contract.d.ts.map +1 -0
  60. package/dist/gpu-loader.d.ts +33 -0
  61. package/dist/gpu-loader.d.ts.map +1 -0
  62. package/dist/gpu-program.d.ts +80 -0
  63. package/dist/gpu-program.d.ts.map +1 -0
  64. package/dist/gpu-runtime.d.ts +174 -0
  65. package/dist/gpu-runtime.d.ts.map +1 -0
  66. package/dist/index.d.ts +21 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +1986 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/instance.d.ts +86 -0
  71. package/dist/instance.d.ts.map +1 -0
  72. package/dist/player.d.ts +17 -0
  73. package/dist/player.d.ts.map +1 -0
  74. package/package.json +66 -0
  75. package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
  76. package/src/__tests__/channel-input.unit.test.ts +65 -0
  77. package/src/__tests__/channel-inspect.unit.test.ts +65 -0
  78. package/src/__tests__/code-source-v2.unit.test.ts +121 -0
  79. package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
  80. package/src/__tests__/effect-contract.unit.test.ts +62 -0
  81. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
  82. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
  83. package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
  84. package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
  85. package/src/__tests__/instance-public-api.unit.test.ts +27 -0
  86. package/src/__tests__/instance.unit.test.ts +107 -0
  87. package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
  88. package/src/__tests__/player-reflection.unit.test.ts +91 -0
  89. package/src/__tests__/player-type.test-d.ts +54 -0
  90. package/src/__tests__/renderer-source.unit.test.ts +58 -0
  91. package/src/__tests__/replay-canonical.unit.test.ts +68 -0
  92. package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
  93. package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
  94. package/src/__tests__/stage-source.unit.test.ts +64 -0
  95. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
  96. package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
  97. package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
  98. package/src/assets/particle-effect-decoder.ts +34 -0
  99. package/src/authoring-descriptor.ts +355 -0
  100. package/src/code-source.ts +827 -0
  101. package/src/data-interface.ts +181 -0
  102. package/src/effect-contract.ts +220 -0
  103. package/src/gpu-loader.ts +279 -0
  104. package/src/gpu-program.ts +101 -0
  105. package/src/gpu-runtime.ts +807 -0
  106. package/src/index.ts +110 -0
  107. package/src/instance.ts +379 -0
  108. package/src/player.ts +21 -0
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@forgeax/engine-vfx",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Runtime-safe code-first GPU VFX source, asset loading, player intent, and fixed-tick GPU work contracts.",
9
+ "keywords": [
10
+ "vfx",
11
+ "gpu-particles",
12
+ "particle-effect-player",
13
+ "wgsl",
14
+ "fixed-update"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.mjs"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "main": "./dist/index.mjs",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "dependencies": {
32
+ "@forgeax/engine-assets-runtime": "0.1.2",
33
+ "@forgeax/engine-ecs": "0.1.2",
34
+ "@forgeax/engine-plugin": "0.1.2",
35
+ "@forgeax/engine-types": "0.1.2"
36
+ },
37
+ "forgeax": {
38
+ "metrics": {
39
+ "bundle-size": {
40
+ "enabled": true,
41
+ "path": "dist/index.mjs",
42
+ "compression": "gzip"
43
+ },
44
+ "fps": {
45
+ "enabled": false,
46
+ "reason": "runtime contract package; no frame loop or canvas"
47
+ },
48
+ "bench": {
49
+ "enabled": false,
50
+ "reason": "GPU performance requires an adapter-qualified renderer benchmark, not the runtime contract package"
51
+ },
52
+ "gate": {
53
+ "enabled": false,
54
+ "reason": "public contract covered by package tests and workspace gates"
55
+ },
56
+ "spike-report": {
57
+ "enabled": false,
58
+ "reason": "not a spike package"
59
+ }
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "test": "vitest run"
65
+ }
66
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { describeVfxGpuEffect, isVfxGpuEffectAsset } from '../authoring-descriptor.js';
3
+ import type { VfxGpuEffectAsset } from '../gpu-program.js';
4
+ import { VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT } from '../gpu-program.js';
5
+
6
+ const effect: VfxGpuEffectAsset = {
7
+ guid: 'effect-guid',
8
+ kind: 'particle-effect',
9
+ schemaVersion: 2,
10
+ programFingerprint: 'sha256:effect',
11
+ emitters: [{ id: 'sparks', capacity: 128 }],
12
+ program: {
13
+ format: 'forgeax-vfx-program-2',
14
+ fingerprint: 'sha256:effect',
15
+ emitters: [
16
+ {
17
+ id: 'sparks',
18
+ module: 'sparks.vfx.wgsl',
19
+ capacity: 128,
20
+ backend: { required: 'gpu' },
21
+ space: 'world',
22
+ schedule: { rate: 8, bursts: [{ time: 0, count: 4 }], loopDuration: 2 },
23
+ bounds: { kind: 'sphere', center: [0, 1, 0], radius: 4 },
24
+ renderers: [
25
+ { kind: 'billboard', material: 'material-guid', sorting: 'back-to-front' },
26
+ { kind: 'beam', material: 'beam-material-guid', endpointField: 'velocity', capacity: 32 },
27
+ ],
28
+ channels: [{ id: 'impact', payload: 'impact', capacity: 8, overflow: 'drop-newest' }],
29
+ events: [
30
+ {
31
+ id: 'impact-event',
32
+ channel: 'impact',
33
+ subEmitter: 'sparks',
34
+ fanOut: 1,
35
+ recursionDepth: 1,
36
+ },
37
+ ],
38
+ simulationWhenCulled: 'continue',
39
+ wgsl: 'cooked',
40
+ reflection: {
41
+ hooks: ['vfx_spawn', 'vfx_update'],
42
+ imports: ['game::noise'],
43
+ resources: ['particles'],
44
+ entryPoints: ['forgeax_vfx_spawn_main'],
45
+ bindings: [],
46
+ layout: {
47
+ version: 1,
48
+ parameters: {
49
+ name: 'VfxParameters',
50
+ fields: [
51
+ {
52
+ name: 'strength',
53
+ type: 'f32',
54
+ offset: 0,
55
+ size: 4,
56
+ alignment: 4,
57
+ defaultValue: 1,
58
+ },
59
+ ],
60
+ size: 16,
61
+ alignment: 16,
62
+ },
63
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
64
+ fingerprint: 'sha256:layout',
65
+ },
66
+ stages: [
67
+ {
68
+ id: 'turbulence',
69
+ entry: 'vfx_turbulence',
70
+ entryPoint: 'forgeax_vfx_stage_turbulence',
71
+ domain: 'particle',
72
+ resources: [{ name: 'particles', access: 'read-write' }],
73
+ dependsOn: [],
74
+ iterationBudget: 2,
75
+ },
76
+ ],
77
+ },
78
+ },
79
+ ],
80
+ },
81
+ };
82
+
83
+ describe('VFX authoring descriptor', () => {
84
+ it('keeps the complete program format and asset-local artifact key canonical', () => {
85
+ expect(VFX_GPU_PROGRAM_FORMAT).toBe('forgeax-vfx-program-2');
86
+ expect(VFX_GPU_PROGRAM_ARTIFACT_KEY).toBe('particle-effect/program.json');
87
+ expect(effect.program.format).toBe(VFX_GPU_PROGRAM_FORMAT);
88
+ expect(effect.programFingerprint).toMatch(/^sha256:/);
89
+ });
90
+
91
+ it('owns the cooked asset type guard at the producer boundary', () => {
92
+ expect(isVfxGpuEffectAsset(effect)).toBe(true);
93
+ expect(
94
+ isVfxGpuEffectAsset({ ...effect, program: { ...effect.program, format: 'legacy' } }),
95
+ ).toBe(false);
96
+ expect(isVfxGpuEffectAsset({ kind: 'particle-effect', schemaVersion: 2 })).toBe(false);
97
+ });
98
+ it('projects one producer-owned system tree, timeline, dependencies, and capability truth', () => {
99
+ const descriptor = describeVfxGpuEffect(effect);
100
+
101
+ expect(descriptor).toMatchObject({
102
+ version: 1,
103
+ assetGuid: 'effect-guid',
104
+ artifactFingerprint: 'sha256:effect',
105
+ timeline: [{ emitterId: 'sparks', rate: 8, loopDuration: 2 }],
106
+ });
107
+ expect(descriptor.emitters[0]).toMatchObject({
108
+ id: 'emitter:sparks',
109
+ role: 'emitter',
110
+ label: 'sparks',
111
+ module: 'sparks.vfx.wgsl',
112
+ });
113
+ expect(descriptor.emitters[0]?.children.map((node) => node.role)).toEqual([
114
+ 'program',
115
+ 'parameters',
116
+ 'stage',
117
+ 'channel',
118
+ 'event',
119
+ 'renderer',
120
+ 'renderer',
121
+ ]);
122
+ expect(descriptor.dependencies).toEqual(
123
+ expect.arrayContaining([
124
+ expect.objectContaining({ kind: 'module', identity: 'sparks.vfx.wgsl' }),
125
+ expect.objectContaining({ kind: 'asset', identity: 'material-guid' }),
126
+ expect.objectContaining({ kind: 'asset', identity: 'beam-material-guid' }),
127
+ expect.objectContaining({ kind: 'module', identity: 'game::noise' }),
128
+ ]),
129
+ );
130
+ expect(descriptor.capabilities).toEqual(
131
+ expect.arrayContaining([
132
+ expect.objectContaining({ id: 'runtime-parameters', state: 'partial' }),
133
+ expect.objectContaining({ id: 'deterministic-replay', state: 'executable' }),
134
+ ]),
135
+ );
136
+ expect(JSON.stringify(descriptor)).not.toContain('wgsl":"cooked');
137
+ });
138
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxEffectContract } from '../effect-contract.js';
3
+ import { ParticleEffectInstance, type VfxChannelInput } from '../instance.js';
4
+
5
+ const reflection = {
6
+ version: 1,
7
+ parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
8
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
9
+ fingerprint: 'sha256:channel-input',
10
+ } as const;
11
+
12
+ function instance() {
13
+ return new ParticleEffectInstance(createVfxEffectContract(reflection));
14
+ }
15
+
16
+ function input(sequence: number, strength: number): VfxChannelInput {
17
+ return {
18
+ channel: 'impact',
19
+ payload: { position: [sequence, 1, 0], strength },
20
+ sequence,
21
+ };
22
+ }
23
+
24
+ describe('VFX channel input', () => {
25
+ it('commits typed inputs once at the fixed-tick boundary in sequence order', () => {
26
+ const effect = instance();
27
+
28
+ expect(effect.submit(input(2, 0.4)).ok).toBe(true);
29
+ expect(effect.submit(input(1, 0.9)).ok).toBe(true);
30
+ const committed = effect.commit({ seed: 3, tick: 8 });
31
+
32
+ expect(committed.ok).toBe(true);
33
+ if (!committed.ok) return;
34
+ expect(committed.value.channelInputs).toEqual([input(1, 0.9), input(2, 0.4)]);
35
+ expect(committed.value.channelInputs[0]?.tick).toBe(8);
36
+ });
37
+
38
+ it('drops new input at capacity without changing another instance', () => {
39
+ const first = instance();
40
+ const second = instance();
41
+ first.setChannelCapacity('impact', 1);
42
+
43
+ expect(first.submit(input(1, 0.5)).ok).toBe(true);
44
+ const overflow = first.submit(input(2, 0.6));
45
+ expect(overflow.ok).toBe(false);
46
+ if (!overflow.ok) expect(overflow.error.code).toBe('vfx-channel-overflow');
47
+ expect(second.submit(input(1, 0.7)).ok).toBe(true);
48
+ const firstCommit = first.commit({ seed: 1, tick: 1 });
49
+ const secondCommit = second.commit({ seed: 2, tick: 1 });
50
+ expect(firstCommit.ok && firstCommit.value).toMatchObject({ droppedCount: 1 });
51
+ expect(secondCommit.ok && secondCommit.value).toMatchObject({ droppedCount: 0 });
52
+ });
53
+
54
+ it('clears pending inputs on despawn/reset and preserves replay ordering', () => {
55
+ const effect = instance();
56
+ effect.submit(input(4, 1));
57
+ const committed = effect.commit({ seed: 4, tick: 3 });
58
+ expect(committed.ok).toBe(true);
59
+ if (!committed.ok) return;
60
+ const replay = instance().replay(committed.value.replayInput);
61
+ expect(replay.ok).toBe(true);
62
+ if (!replay.ok) return;
63
+ expect(replay.value.channelInputs).toEqual(committed.value.channelInputs);
64
+ });
65
+ });
@@ -0,0 +1,65 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { VfxGpuRuntime } from '../gpu-runtime.js';
4
+
5
+ const player = 7 as EntityHandle;
6
+
7
+ describe('VFX channel inspect counters', () => {
8
+ it('keeps bounded event counters observable without GPU readback', () => {
9
+ const runtime = new VfxGpuRuntime();
10
+ runtime.markEventDispatched(player, {
11
+ queued: 4,
12
+ produced: 4,
13
+ consumed: 0,
14
+ dropped: 2,
15
+ overflow: 1,
16
+ fanOut: 2,
17
+ recursionDepth: 1,
18
+ lastSequence: 9,
19
+ });
20
+
21
+ expect(runtime.eventCounters(player)).toEqual({
22
+ queued: 0,
23
+ produced: 4,
24
+ consumed: 4,
25
+ dropped: 2,
26
+ overflow: 1,
27
+ fanOut: 2,
28
+ recursionDepth: 1,
29
+ lastSequence: 9,
30
+ });
31
+ });
32
+
33
+ it('merges a consumer dispatch into the producer snapshot', () => {
34
+ const runtime = new VfxGpuRuntime();
35
+ runtime.markEventDispatched(player, {
36
+ queued: 1,
37
+ produced: 1,
38
+ consumed: 0,
39
+ dropped: 0,
40
+ overflow: 0,
41
+ fanOut: 2,
42
+ recursionDepth: 1,
43
+ lastSequence: 3,
44
+ });
45
+ runtime.markEventDispatched(player, {
46
+ queued: 1,
47
+ produced: 0,
48
+ consumed: 2,
49
+ dropped: 0,
50
+ overflow: 0,
51
+ fanOut: 2,
52
+ recursionDepth: 1,
53
+ lastSequence: 3,
54
+ });
55
+
56
+ expect(runtime.eventCounters(player)).toMatchObject({
57
+ queued: 0,
58
+ produced: 1,
59
+ consumed: 2,
60
+ fanOut: 2,
61
+ recursionDepth: 1,
62
+ lastSequence: 3,
63
+ });
64
+ });
65
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseParticleEffectSourceV2, parseVfxStageDeclarations } from '../code-source.js';
3
+
4
+ const source = {
5
+ schemaVersion: 2,
6
+ emitters: [
7
+ {
8
+ id: 'sparks',
9
+ capacity: 1024,
10
+ backend: { required: 'gpu' as const },
11
+ space: 'world',
12
+ schedule: { rate: 32, bursts: [{ time: 0, count: 16 }], loopDuration: 2 },
13
+ bounds: { kind: 'aabb', min: [-10, -10, -10], max: [10, 10, 10] },
14
+ program: { module: 'sparks.vfx.wgsl' },
15
+ renderers: [{ kind: 'billboard', material: 'material-guid' }],
16
+ },
17
+ ],
18
+ };
19
+
20
+ describe('ParticleCodeEffectSource v2', () => {
21
+ it('accepts typed bounded channels and same-effect visual events', () => {
22
+ const result = parseParticleEffectSourceV2({
23
+ ...source,
24
+ emitters: [
25
+ {
26
+ ...source.emitters[0],
27
+ channels: [{ id: 'impact', capacity: 2, overflow: 'drop-newest' }],
28
+ events: [
29
+ {
30
+ id: 'impact-event',
31
+ channel: 'impact',
32
+ subEmitter: 'sparks',
33
+ fanOut: 1,
34
+ recursionDepth: 1,
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ });
40
+ expect(result.ok).toBe(true);
41
+ });
42
+
43
+ it('accepts the code-first source without an operator stack', () => {
44
+ const result = parseParticleEffectSourceV2(source);
45
+ expect(result.ok).toBe(true);
46
+ if (result.ok) {
47
+ expect(result.value.emitters[0]?.program.module).toBe('sparks.vfx.wgsl');
48
+ }
49
+ });
50
+
51
+ it('keeps stage declarations in authored WGSL rather than the source manifest', () => {
52
+ const stage = parseVfxStageDeclarations(
53
+ '// #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write dependsOn=update iterationBudget=4',
54
+ );
55
+ expect(stage.ok).toBe(true);
56
+ });
57
+
58
+ it('rejects v1 with an executable recook hint', () => {
59
+ const result = parseParticleEffectSourceV2({ schemaVersion: 1, emitters: [] });
60
+ expect(result.ok).toBe(false);
61
+ if (!result.ok) {
62
+ expect(result.error.code).toBe('vfx-source-version-unsupported');
63
+ expect(result.error.hint).toContain('WGSL');
64
+ expect(result.error.hint).toContain('recook');
65
+ }
66
+ });
67
+
68
+ it('requires one explicit GPU backend policy and rejects derived fields', () => {
69
+ const backend = structuredClone(source) as unknown as {
70
+ emitters: Array<Record<string, unknown>>;
71
+ };
72
+ const emitter = backend.emitters[0];
73
+ expect(emitter).toBeDefined();
74
+ if (emitter === undefined) return;
75
+ emitter.backend = { required: 'cpu' };
76
+ expect(parseParticleEffectSourceV2(backend).ok).toBe(false);
77
+ const derived = structuredClone(source) as typeof source & { backendPlan: string };
78
+ derived.backendPlan = 'gpu';
79
+ expect(parseParticleEffectSourceV2(derived).ok).toBe(false);
80
+ });
81
+
82
+ it.each([
83
+ ['bounds', { bounds: { kind: 'sphere', center: [0, 0, 0], radius: 1, dynamic: true } }],
84
+ ['schedule', { schedule: { rate: 1, legacyRate: 2 } }],
85
+ ['burst', { schedule: { rate: 1, bursts: [{ time: 0, count: 1, once: true }] } }],
86
+ ['program', { program: { module: 'sparks.vfx.wgsl', fallback: 'cpu' } }],
87
+ ['parameters', { parameters: { speed: 1 } }],
88
+ ['custom data', { customData: { value: 1 } }],
89
+ ])('rejects unknown nested %s fields', (_name, override) => {
90
+ const emitter = { ...source.emitters[0], ...override };
91
+ expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
92
+ });
93
+
94
+ it('keeps Data Interface requirements out of the source-side roster', () => {
95
+ const emitter = {
96
+ ...source.emitters[0],
97
+ dataInterfaces: [{ token: 'vfx:camera' }],
98
+ };
99
+ expect(parseParticleEffectSourceV2({ ...source, emitters: [emitter] }).ok).toBe(false);
100
+ });
101
+
102
+ it('accepts an explicit mesh submesh and rejects ambiguous indices', () => {
103
+ const mesh = structuredClone(source) as unknown as {
104
+ schemaVersion: number;
105
+ emitters: Array<Record<string, unknown>>;
106
+ };
107
+ const emitter = mesh.emitters[0];
108
+ expect(emitter).toBeDefined();
109
+ if (emitter === undefined) return;
110
+ const renderers: Array<Record<string, unknown>> = [
111
+ { kind: 'mesh', material: 'material-guid', mesh: 'mesh-guid', submesh: 2 },
112
+ ];
113
+ emitter.renderers = renderers;
114
+ expect(parseParticleEffectSourceV2(mesh).ok).toBe(true);
115
+ const renderer = renderers[0];
116
+ expect(renderer).toBeDefined();
117
+ if (renderer === undefined) return;
118
+ renderer.submesh = -1;
119
+ expect(parseParticleEffectSourceV2(mesh).ok).toBe(false);
120
+ });
121
+ });
@@ -0,0 +1,92 @@
1
+ import { ok, type Result } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ resolveVfxDataInterfaces,
5
+ type VfxDataInterfaceError,
6
+ type VfxDataInterfaceProvider,
7
+ type VfxDataInterfaceRequirement,
8
+ type VfxDataInterfaceResource,
9
+ } from '../index.js';
10
+
11
+ const camera: VfxDataInterfaceRequirement = {
12
+ token: 'vfx:camera',
13
+ kind: 'camera',
14
+ binding: 8,
15
+ bindingType: 'uniform',
16
+ lifetime: 'generation',
17
+ };
18
+
19
+ function provider(
20
+ resource: Omit<VfxDataInterfaceResource, 'generation'> & { readonly generation?: number },
21
+ ): VfxDataInterfaceProvider {
22
+ return {
23
+ id: `${resource.token}-provider`,
24
+ token: resource.token,
25
+ kind: resource.kind,
26
+ bindingType: resource.bindingType,
27
+ provide: (generation) =>
28
+ ok({
29
+ ...resource,
30
+ generation: resource.generation ?? generation,
31
+ }),
32
+ };
33
+ }
34
+
35
+ describe('VFX Data Interface contract', () => {
36
+ it.each([
37
+ ['missing', [], 'vfx-data-interface-missing'],
38
+ [
39
+ 'wrong type',
40
+ [provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'sampled-depth' })],
41
+ 'vfx-data-interface-wrong-type',
42
+ ],
43
+ [
44
+ 'stale generation',
45
+ [provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform', generation: 2 })],
46
+ 'vfx-data-interface-stale',
47
+ ],
48
+ ])('%s is a structured failure', (_name, providers, code) => {
49
+ const result = resolveVfxDataInterfaces([camera], providers as VfxDataInterfaceProvider[], 3);
50
+ expect(result.ok).toBe(false);
51
+ if (!result.ok) {
52
+ expect(result.error.code).toBe(code);
53
+ if (code === 'vfx-data-interface-stale') {
54
+ expect(result.error.expected).toContain('generation 3');
55
+ } else {
56
+ expect(result.error.expected).toContain('camera');
57
+ }
58
+ expect(result.error.hint.length).toBeGreaterThan(0);
59
+ expect(result.error.detail).toMatchObject({ token: 'vfx:camera' });
60
+ }
61
+ });
62
+
63
+ it('returns generation-scoped readiness for a valid provider', () => {
64
+ const result = resolveVfxDataInterfaces(
65
+ [camera],
66
+ [provider({ token: 'vfx:camera', kind: 'camera', bindingType: 'uniform' })],
67
+ 7,
68
+ );
69
+ expect(result).toMatchObject({ ok: true, value: { generation: 7, readiness: 'ready' } });
70
+ });
71
+
72
+ it('keeps provider failures exhaustive without parsing messages', () => {
73
+ const result: Result<unknown, VfxDataInterfaceError> = resolveVfxDataInterfaces(
74
+ [camera],
75
+ [],
76
+ 1,
77
+ );
78
+ if (!result.ok) {
79
+ switch (result.error.code) {
80
+ case 'vfx-data-interface-missing':
81
+ case 'vfx-data-interface-wrong-type':
82
+ case 'vfx-data-interface-stale':
83
+ case 'vfx-data-interface-duplicate':
84
+ break;
85
+ default: {
86
+ const exhaustive: never = result.error.code;
87
+ expect(exhaustive).toBeUndefined();
88
+ }
89
+ }
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,62 @@
1
+ import type { ParticleEffectAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createVfxEffectContract } from '../effect-contract.js';
4
+
5
+ const reflection = {
6
+ version: 1,
7
+ parameters: {
8
+ name: 'VfxParameters',
9
+ fields: [
10
+ { name: 'direction', type: 'vec3<f32>', offset: 0, size: 12, alignment: 16 },
11
+ { name: 'speed', type: 'f32', offset: 16, size: 4, alignment: 4 },
12
+ ],
13
+ size: 32,
14
+ alignment: 16,
15
+ },
16
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
17
+ fingerprint: 'sha256:0000000000000000000000000000000000000000000000000000000000000000',
18
+ } as const;
19
+
20
+ describe('VfxEffectContract', () => {
21
+ it('models the executable program on the ordinary particle asset', () => {
22
+ const asset: ParticleEffectAsset = {
23
+ kind: 'particle-effect',
24
+ schemaVersion: 2,
25
+ programFingerprint: 'sha256:program',
26
+ emitters: [{ id: 'sparks', capacity: 8 }],
27
+ program: {
28
+ format: 'forgeax-vfx-program-2',
29
+ fingerprint: 'sha256:program',
30
+ emitters: [],
31
+ },
32
+ };
33
+ expect(asset.program.fingerprint).toBe(asset.programFingerprint);
34
+ });
35
+
36
+ it('derives defaults and packed values from the same reflection result', () => {
37
+ const contract = createVfxEffectContract(reflection);
38
+ const values = contract.createValues({ speed: 2.5, direction: [0, 1, 0] });
39
+ expect(values.ok).toBe(true);
40
+ if (!values.ok) return;
41
+ expect(values.value.speed).toBe(2.5);
42
+ expect(values.value.direction).toEqual([0, 1, 0]);
43
+ expect(contract.pack(values.value).ok).toBe(true);
44
+ expect(contract.fingerprint).toBe(reflection.fingerprint);
45
+ });
46
+
47
+ it('rejects unknown names, wrong dimensions, and wrong scalar kinds without assertions', () => {
48
+ const contract = createVfxEffectContract(reflection);
49
+
50
+ const unknown = contract.createValues({ missing: 1 });
51
+ expect(unknown.ok).toBe(false);
52
+ if (!unknown.ok) expect(unknown.error.code).toBe('vfx-value-unknown-field');
53
+
54
+ const wrongDimension = contract.createValues({ direction: [0, 1] });
55
+ expect(wrongDimension.ok).toBe(false);
56
+ if (!wrongDimension.ok) expect(wrongDimension.error.code).toBe('vfx-value-type-mismatch');
57
+
58
+ const wrongScalar = contract.validateValues(JSON.parse('{"speed":"fast"}'));
59
+ expect(wrongScalar.ok).toBe(false);
60
+ if (!wrongScalar.ok) expect(wrongScalar.error.code).toBe('vfx-value-type-mismatch');
61
+ });
62
+ });
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, expectTypeOf, it } from 'vitest';
3
+ import type { ParticleEmitterSourceV2 } from '../code-source.js';
4
+ import type { VfxGpuEmitterProgram } from '../gpu-program.js';
5
+ import type {
6
+ VfxGpuEmitterProgram as PublicVfxGpuEmitterProgram,
7
+ VfxGpuEffectAsset,
8
+ VfxGpuProgram,
9
+ } from '../index.js';
10
+
11
+ type SourceSimulationWhenCulled = ParticleEmitterSourceV2['simulationWhenCulled'];
12
+ type CookedSimulationWhenCulled = VfxGpuEmitterProgram['simulationWhenCulled'];
13
+ type RequiredSimulationWhenCulled = Pick<VfxGpuEmitterProgram, 'simulationWhenCulled'>;
14
+ type PublicProgramEmitter = VfxGpuProgram['emitters'][number];
15
+ type PublicAssetEmitter = VfxGpuEffectAsset['program']['emitters'][number];
16
+
17
+ const gpuProgramSource = readFileSync(new URL('../gpu-program.ts', import.meta.url), 'utf8');
18
+
19
+ describe('VFX GPU program simulation culling owner', () => {
20
+ it('derives the cooked policy from the authored source while keeping it required', () => {
21
+ expectTypeOf<CookedSimulationWhenCulled>().toEqualTypeOf<
22
+ NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>
23
+ >();
24
+ expectTypeOf<SourceSimulationWhenCulled>().toEqualTypeOf<
25
+ NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']> | undefined
26
+ >();
27
+ expectTypeOf<RequiredSimulationWhenCulled>().toEqualTypeOf<{
28
+ readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;
29
+ }>();
30
+ });
31
+
32
+ it('keeps exact policy membership across public projections', () => {
33
+ expectTypeOf<CookedSimulationWhenCulled>().toEqualTypeOf<
34
+ 'continue' | 'pause' | 'restart-on-visible'
35
+ >();
36
+ expectTypeOf<PublicProgramEmitter>().toEqualTypeOf<VfxGpuEmitterProgram>();
37
+ expectTypeOf<PublicAssetEmitter>().toEqualTypeOf<VfxGpuEmitterProgram>();
38
+ expectTypeOf<PublicVfxGpuEmitterProgram>().toEqualTypeOf<VfxGpuEmitterProgram>();
39
+
40
+ const acceptsSimulationWhenCulled = (
41
+ value: CookedSimulationWhenCulled,
42
+ ): CookedSimulationWhenCulled => value;
43
+ expect(acceptsSimulationWhenCulled('continue')).toBe('continue');
44
+ expect(acceptsSimulationWhenCulled('pause')).toBe('pause');
45
+ expect(acceptsSimulationWhenCulled('restart-on-visible')).toBe('restart-on-visible');
46
+ // @ts-expect-error VfxGpuEmitterProgram excludes additional culling policies.
47
+ acceptsSimulationWhenCulled('reset');
48
+ });
49
+
50
+ it('keeps the source-derived owner explicit in the declaration', () => {
51
+ expect(gpuProgramSource).toContain(
52
+ "readonly simulationWhenCulled: NonNullable<ParticleEmitterSourceV2['simulationWhenCulled']>;",
53
+ );
54
+ expect(gpuProgramSource).not.toContain(
55
+ "readonly simulationWhenCulled: 'continue' | 'pause' | 'restart-on-visible';",
56
+ );
57
+ });
58
+ });