@forgeax/engine-rhi-debug 0.1.4 → 0.1.7

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 (48) hide show
  1. package/README.md +39 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
  4. package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
  5. package/dist/frame-model.d.ts +84 -52
  6. package/dist/frame-model.d.ts.map +1 -1
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +4651 -46
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/recorder/device.d.ts.map +1 -1
  12. package/dist/recorder/encoder.d.ts.map +1 -1
  13. package/dist/replay/device-request.d.ts +8 -0
  14. package/dist/replay/device-request.d.ts.map +1 -0
  15. package/dist/replay/readback.d.ts +7 -0
  16. package/dist/replay/readback.d.ts.map +1 -1
  17. package/dist/replay/session.d.ts +17 -0
  18. package/dist/replay/session.d.ts.map +1 -1
  19. package/dist/texel-decode.d.ts +5 -4
  20. package/dist/texel-decode.d.ts.map +1 -1
  21. package/dist/types.d.ts +24 -6
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +7 -7
  24. package/src/__tests__/consumer-inventory.unit.test.ts +49 -1
  25. package/src/__tests__/e2e.browser.test.ts +6 -1
  26. package/src/__tests__/frame-model-parity.test-d.ts +13 -1
  27. package/src/__tests__/frame-model-parity.unit.test.ts +262 -15
  28. package/src/__tests__/guard-gates.test.ts +176 -1
  29. package/src/__tests__/public-surface.integration.test.ts +8 -0
  30. package/src/__tests__/readback-format-matrix.dawn.test.ts +226 -3
  31. package/src/__tests__/readback-format-matrix.unit.test.ts +25 -1
  32. package/src/__tests__/readback-matrix-fixture.ts +20 -0
  33. package/src/__tests__/replay-fail-fast.unit.test.ts +12 -0
  34. package/src/__tests__/replay-session.dawn.test.ts +3 -0
  35. package/src/__tests__/replay-session.test-d.ts +7 -1
  36. package/src/__tests__/replay-session.unit.test.ts +16 -0
  37. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +2 -0
  38. package/src/__tests__/tape-index.unit.test.ts +3 -0
  39. package/src/__tests__/tree-shake.unit.test.ts +24 -0
  40. package/src/frame-model.ts +381 -83
  41. package/src/index.ts +17 -10
  42. package/src/recorder/device.ts +20 -2
  43. package/src/recorder/encoder.ts +22 -5
  44. package/src/replay/device-request.ts +38 -0
  45. package/src/replay/readback.ts +106 -18
  46. package/src/replay/session.ts +47 -2
  47. package/src/texel-decode.ts +37 -3
  48. package/src/types.ts +30 -6
@@ -2,37 +2,284 @@ import { describe, expect, it } from 'vitest';
2
2
  import { buildFrameModel } from '../frame-model';
3
3
  import type { Tape } from '../protocol/types';
4
4
 
5
- describe('FrameModel parity', () => {
6
- it('is JSON-safe and derives work entries from the same index', () => {
5
+ function makeTape(): Tape {
6
+ return {
7
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 15, blobCount: 0 },
8
+ bootstrap: [
9
+ {
10
+ handleId: 'buffer-1',
11
+ kind: 'buffer',
12
+ create: { kind: 'createBuffer', handleId: 'buffer-1', desc: { size: 64, usage: 4 } },
13
+ initialData: [],
14
+ },
15
+ {
16
+ handleId: 'shader:1',
17
+ kind: 'shader-module',
18
+ create: {
19
+ kind: 'createShaderModule',
20
+ handleId: 'shader:1',
21
+ wgslCode: '@vertex fn vs_main() -> @builtin(position) vec4f { return vec4f(0.0); }',
22
+ },
23
+ initialData: [],
24
+ },
25
+ ],
26
+ events: [
27
+ {
28
+ kind: 'beginRenderPass',
29
+ passHandleId: 'pass:render',
30
+ cmdHandleId: 'encoder:1',
31
+ desc: { colorAttachments: [] },
32
+ colorAttachmentViewHandleIds: [],
33
+ },
34
+ { kind: 'pushDebugGroup', cmdHandleId: 'encoder:1', groupLabel: 'render group' },
35
+ { kind: 'passPushDebugGroup', passHandleId: 'pass:render', groupLabel: 'nested group' },
36
+ { kind: 'insertDebugMarker', cmdHandleId: 'encoder:1', markerLabel: 'before draws' },
37
+ {
38
+ kind: 'draw',
39
+ passHandleId: 'pass:render',
40
+ vertexCount: 3,
41
+ instanceCount: 1,
42
+ firstVertex: 0,
43
+ firstInstance: 0,
44
+ },
45
+ {
46
+ kind: 'drawIndexed',
47
+ passHandleId: 'pass:render',
48
+ indexCount: 3,
49
+ instanceCount: 1,
50
+ firstIndex: 0,
51
+ baseVertex: 0,
52
+ firstInstance: 0,
53
+ },
54
+ {
55
+ kind: 'drawIndirect',
56
+ passHandleId: 'pass:render',
57
+ indirectBufferHandleId: 'buffer-1',
58
+ indirectOffset: 0,
59
+ },
60
+ {
61
+ kind: 'drawIndexedIndirect',
62
+ passHandleId: 'pass:render',
63
+ indirectBufferHandleId: 'buffer-1',
64
+ indirectOffset: 16,
65
+ },
66
+ { kind: 'passPopDebugGroup', passHandleId: 'pass:render' },
67
+ { kind: 'popDebugGroup', cmdHandleId: 'encoder:1' },
68
+ { kind: 'endRenderPass', passHandleId: 'pass:render' },
69
+ {
70
+ kind: 'beginComputePass',
71
+ passHandleId: 'pass:compute',
72
+ cmdHandleId: 'encoder:1',
73
+ desc: {},
74
+ },
75
+ { kind: 'dispatchWorkgroups', passHandleId: 'pass:compute', x: 2, y: 3, z: 1 },
76
+ {
77
+ kind: 'dispatchWorkgroupsIndirect',
78
+ passHandleId: 'pass:compute',
79
+ indirectBufferHandleId: 'buffer-1',
80
+ indirectOffset: 32,
81
+ },
82
+ { kind: 'endComputePass', passHandleId: 'pass:compute' },
83
+ ],
84
+ blobs: [],
85
+ };
86
+ }
87
+
88
+ describe('FrameModel canonical projection', () => {
89
+ it('exposes one JSON-safe projection with six globally indexed work kinds', () => {
90
+ const model = buildFrameModel(makeTape());
91
+ const roundTrip = JSON.parse(JSON.stringify(model)) as typeof model;
92
+
93
+ expect(roundTrip).toEqual(model);
94
+ expect(Object.keys(model)).toEqual([
95
+ 'commands',
96
+ 'passes',
97
+ 'resources',
98
+ 'resourceLifecycle',
99
+ 'works',
100
+ ]);
101
+ expect(
102
+ model.works.map((work) => [work.workIndex, work.eventIndex, work.passIndex, work.kind]),
103
+ ).toEqual([
104
+ [0, 4, 0, 'draw'],
105
+ [1, 5, 0, 'drawIndexed'],
106
+ [2, 6, 0, 'drawIndirect'],
107
+ [3, 7, 0, 'drawIndexedIndirect'],
108
+ [4, 12, 1, 'dispatchWorkgroups'],
109
+ [5, 13, 1, 'dispatchWorkgroupsIndirect'],
110
+ ]);
111
+ expect(model.commands[1]).toMatchObject({
112
+ eventIndex: 1,
113
+ passIndex: 0,
114
+ group: ['render group'],
115
+ });
116
+ expect(model.commands[3]).toMatchObject({ eventIndex: 3, marker: 'before draws' });
117
+ expect(model.passes.map((pass) => pass.workIndices)).toEqual([
118
+ [0, 1, 2, 3],
119
+ [4, 5],
120
+ ]);
121
+ expect(model.resources).toEqual(
122
+ expect.arrayContaining([
123
+ expect.objectContaining({ resourceId: 'buffer-1', kind: 'buffer' }),
124
+ expect.objectContaining({ resourceId: 'shader:1', kind: 'shader-module' }),
125
+ ]),
126
+ );
127
+ expect(model.works.every((work) => work.eventIndex >= 0 && work.passIndex >= 0)).toBe(true);
128
+ });
129
+
130
+ it('fails closed instead of exposing empty legacy projections or Map values', () => {
131
+ const model = buildFrameModel(makeTape()) as unknown as Record<string, unknown>;
132
+ for (const field of ['tree', 'draws', 'meta']) {
133
+ expect(model, `legacy field ${field} must be absent`).not.toHaveProperty(field);
134
+ }
135
+ expect(model).not.toHaveProperty('totalDraws');
136
+ expect(model.resources).toBeInstanceOf(Array);
137
+ expect(JSON.stringify(model)).not.toContain('ReadonlyMap');
138
+ });
139
+
140
+ it('resolves pipeline, shader, and bind-group facts from the bootstrap closure', () => {
7
141
  const tape: Tape = {
8
- header: { formatVersion: 7, rhiCaps: {}, eventCount: 2, blobCount: 0 },
9
- bootstrap: [],
142
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 5, blobCount: 0 },
143
+ bootstrap: [
144
+ {
145
+ handleId: 'texture:sample',
146
+ kind: 'texture',
147
+ create: {
148
+ kind: 'createTexture',
149
+ handleId: 'texture:sample',
150
+ desc: { size: [2, 2, 1], format: 'rgba8unorm', usage: 4 },
151
+ },
152
+ initialData: [],
153
+ },
154
+ {
155
+ handleId: 'view:sample',
156
+ kind: 'texture-view',
157
+ create: {
158
+ kind: 'createTextureView',
159
+ sourceHandleId: 'texture:sample',
160
+ resultHandleId: 'view:sample',
161
+ desc: {},
162
+ },
163
+ initialData: [],
164
+ },
165
+ {
166
+ handleId: 'shader:vertex',
167
+ kind: 'shader-module',
168
+ create: {
169
+ kind: 'createShaderModule',
170
+ handleId: 'shader:vertex',
171
+ wgslCode: '@vertex fn main() -> @builtin(position) vec4f { return vec4f(); }',
172
+ },
173
+ initialData: [],
174
+ },
175
+ {
176
+ handleId: 'shader:fragment',
177
+ kind: 'shader-module',
178
+ create: {
179
+ kind: 'createShaderModule',
180
+ handleId: 'shader:fragment',
181
+ wgslCode: '@fragment fn main() -> @location(0) vec4f { return vec4f(1.0); }',
182
+ },
183
+ initialData: [],
184
+ },
185
+ {
186
+ handleId: 'bgl:main',
187
+ kind: 'binding',
188
+ create: {
189
+ kind: 'createBindGroupLayout',
190
+ handleId: 'bgl:main',
191
+ desc: { entries: [] },
192
+ },
193
+ initialData: [],
194
+ },
195
+ {
196
+ handleId: 'bindGroup:main',
197
+ kind: 'binding',
198
+ create: {
199
+ kind: 'createBindGroup',
200
+ handleId: 'bindGroup:main',
201
+ layoutHandleId: 'bgl:main',
202
+ entries: [{ binding: 3, resourceKind: 'textureView' }],
203
+ resourceHandleIds: ['view:sample'],
204
+ },
205
+ initialData: [],
206
+ },
207
+ {
208
+ handleId: 'pipelineLayout:main',
209
+ kind: 'binding',
210
+ create: {
211
+ kind: 'createPipelineLayout',
212
+ handleId: 'pipelineLayout:main',
213
+ bglHandleIds: ['bgl:main'],
214
+ },
215
+ initialData: [],
216
+ },
217
+ {
218
+ handleId: 'pipeline:main',
219
+ kind: 'pipeline',
220
+ create: {
221
+ kind: 'createRenderPipeline',
222
+ handleId: 'pipeline:main',
223
+ desc: {
224
+ vertex: { entryPoint: 'main', buffers: [] },
225
+ fragment: { entryPoint: 'main', targets: [{ format: 'rgba8unorm' }] },
226
+ },
227
+ layoutHandleId: 'pipelineLayout:main',
228
+ vertexShaderModuleHandleId: 'shader:vertex',
229
+ fragmentShaderModuleHandleId: 'shader:fragment',
230
+ },
231
+ initialData: [],
232
+ },
233
+ ],
10
234
  events: [
11
235
  {
12
236
  kind: 'beginRenderPass',
13
- passHandleId: 'pass:1',
14
- cmdHandleId: 'cmd:1',
237
+ passHandleId: 'pass:render',
238
+ cmdHandleId: 'encoder:1',
15
239
  desc: { colorAttachments: [] },
16
- colorAttachmentViewHandleIds: [],
240
+ colorAttachmentViewHandleIds: ['view:sample'],
241
+ },
242
+ {
243
+ kind: 'setPipeline',
244
+ passHandleId: 'pass:render',
245
+ pipelineHandleId: 'pipeline:main',
246
+ },
247
+ {
248
+ kind: 'setBindGroup',
249
+ passHandleId: 'pass:render',
250
+ index: 0,
251
+ bindGroupHandleId: 'bindGroup:main',
17
252
  },
18
253
  {
19
254
  kind: 'draw',
20
- passHandleId: 'pass:1',
255
+ passHandleId: 'pass:render',
21
256
  vertexCount: 3,
22
257
  instanceCount: 1,
23
258
  firstVertex: 0,
24
259
  firstInstance: 0,
25
260
  },
261
+ { kind: 'endRenderPass', passHandleId: 'pass:render' },
26
262
  ],
27
263
  blobs: [],
28
264
  };
29
- const model = buildFrameModel(tape);
30
- expect(model.works[0]).toMatchObject({
31
- workIndex: 0,
32
- eventIndex: 1,
33
- passIndex: 0,
34
- kind: 'draw',
265
+
266
+ const work = buildFrameModel(tape).works[0];
267
+ expect(work?.pipeline).toMatchObject({
268
+ status: 'available',
269
+ pipelineHandleId: 'pipeline:main',
35
270
  });
36
- expect(JSON.stringify(model)).not.toContain('Map');
271
+ expect(work?.pipeline.shaders.map((shader) => shader.moduleHandleId)).toEqual([
272
+ 'shader:vertex',
273
+ 'shader:fragment',
274
+ ]);
275
+ expect(work?.bindings).toEqual([
276
+ expect.objectContaining({
277
+ groupIndex: 0,
278
+ binding: 3,
279
+ bindGroupId: 'bindGroup:main',
280
+ resourceId: 'view:sample',
281
+ resourceKind: 'textureView',
282
+ }),
283
+ ]);
37
284
  });
38
285
  });
@@ -9,7 +9,9 @@
9
9
  //
10
10
  // t10; requirements AC-07/AC-08/AC-09; plan-strategy §2 D-8.
11
11
 
12
- import { readdirSync, readFileSync } from 'node:fs';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
13
15
  import path from 'node:path';
14
16
  import { fileURLToPath } from 'node:url';
15
17
  import { describe, expect, it } from 'vitest';
@@ -142,3 +144,176 @@ describe('AC-08 partial: import.meta.hot in rhiDebugFlag guard', () => {
142
144
  }
143
145
  });
144
146
  });
147
+
148
+ describe('RHI-debug smoke roster gate', () => {
149
+ it('resolves every declared hello and learn-render smoke at 300 frames', () => {
150
+ const rosterPath = path.resolve(ENGINE_ROOT, 'scripts', 'rhi-debug-smoke-roster.mjs');
151
+ const result = spawnSync(
152
+ process.execPath,
153
+ [
154
+ rosterPath,
155
+ '--apps-root',
156
+ 'apps/hello',
157
+ '--learn-root',
158
+ 'apps/learn-render',
159
+ '--frames',
160
+ '300',
161
+ ],
162
+ { cwd: ENGINE_ROOT, encoding: 'utf8' },
163
+ );
164
+ expect(result.status).toBe(1);
165
+ const roster = JSON.parse(result.stdout) as {
166
+ status: string;
167
+ frameCount: number;
168
+ execution: { status: string; mode: string };
169
+ entries: readonly {
170
+ frames: number;
171
+ command: string;
172
+ invocation: string;
173
+ tokens: readonly string[];
174
+ }[];
175
+ unavailable: readonly { reason: string }[];
176
+ };
177
+ expect(roster.status).toBe('unavailable');
178
+ expect(roster.execution).toMatchObject({ status: 'not-executed', mode: 'declaration-only' });
179
+ expect(roster.frameCount).toBe(300);
180
+ expect(roster.entries.length).toBeGreaterThan(0);
181
+ expect(
182
+ roster.entries.every(
183
+ (entry) =>
184
+ entry.frames === 300 &&
185
+ entry.command === entry.invocation &&
186
+ entry.tokens.join(' ') === entry.invocation &&
187
+ entry.tokens[0] === 'pnpm' &&
188
+ entry.tokens[1] === '--filter',
189
+ ),
190
+ ).toBe(true);
191
+ expect(roster.entries.some((entry) => entry.invocation.includes('format-tier1 build'))).toBe(
192
+ true,
193
+ );
194
+ expect(roster.unavailable.length).toBeGreaterThan(0);
195
+ expect(roster.unavailable.every((item) => item.reason.length > 0)).toBe(true);
196
+ });
197
+
198
+ it('executes every declared command in a temporary workspace and fails closed', {
199
+ timeout: 30_000,
200
+ }, () => {
201
+ const fixture = mkdtempSync(path.join(tmpdir(), 'forgeax-rhi-debug-roster-'));
202
+ const appRoot = path.join(fixture, 'apps', 'hello');
203
+ const packageRoot = path.join(appRoot, 'fake-smoke');
204
+ const learnRoot = path.join(fixture, 'apps', 'learn-render');
205
+ const learnPackageRoot = path.join(learnRoot, 'fake-learn-smoke');
206
+ mkdirSync(packageRoot, { recursive: true });
207
+ mkdirSync(learnPackageRoot, { recursive: true });
208
+ writeFileSync(path.join(fixture, 'pnpm-workspace.yaml'), 'packages:\n - apps/**\n');
209
+ const manifestPath = path.join(packageRoot, 'package.json');
210
+ writeFileSync(
211
+ manifestPath,
212
+ JSON.stringify({
213
+ name: '@fake/smoke',
214
+ private: true,
215
+ scripts: { 'smoke:browser': 'node -e "console.log(\'fake browser smoke\')"' },
216
+ forgeax: { smokeInvocation: 'pnpm --filter @fake/smoke smoke:browser' },
217
+ }),
218
+ );
219
+ writeFileSync(
220
+ path.join(learnPackageRoot, 'package.json'),
221
+ JSON.stringify({
222
+ name: '@fake/learn-smoke',
223
+ private: true,
224
+ scripts: { 'smoke:browser': 'node -e "console.log(\'fake learn browser smoke\')"' },
225
+ forgeax: { smokeInvocation: 'pnpm --filter @fake/learn-smoke smoke:browser' },
226
+ }),
227
+ );
228
+ const rosterPath = path.resolve(ENGINE_ROOT, 'scripts', 'rhi-debug-smoke-roster.mjs');
229
+ try {
230
+ const passed = spawnSync(
231
+ process.execPath,
232
+ [
233
+ rosterPath,
234
+ '--apps-root',
235
+ appRoot,
236
+ '--learn-root',
237
+ learnRoot,
238
+ '--cwd',
239
+ fixture,
240
+ '--frames',
241
+ '300',
242
+ '--execute',
243
+ ],
244
+ { cwd: ENGINE_ROOT, encoding: 'utf8' },
245
+ );
246
+ expect(passed.status).toBe(0);
247
+ const passedRoster = JSON.parse(passed.stdout) as {
248
+ status: string;
249
+ execution: { status: string; mode: string; passedCount: number; failedCount: number };
250
+ entries: readonly {
251
+ status: string;
252
+ returnCode: number | null;
253
+ stdout: string;
254
+ invocation: string;
255
+ tokens: readonly string[];
256
+ backend: string;
257
+ frames: number;
258
+ }[];
259
+ };
260
+ expect(passedRoster.status).toBe('passed');
261
+ expect(passedRoster.execution).toMatchObject({
262
+ status: 'passed',
263
+ mode: 'execute',
264
+ passedCount: 2,
265
+ failedCount: 0,
266
+ });
267
+ const passedEntry = passedRoster.entries.find(
268
+ (entry) => entry.invocation === 'pnpm --filter @fake/smoke smoke:browser',
269
+ );
270
+ expect(passedEntry).toMatchObject({
271
+ status: 'passed',
272
+ returnCode: 0,
273
+ invocation: 'pnpm --filter @fake/smoke smoke:browser',
274
+ tokens: ['pnpm', '--filter', '@fake/smoke', 'smoke:browser'],
275
+ backend: 'browser',
276
+ frames: 300,
277
+ });
278
+ expect(passedEntry?.stdout).toContain('fake browser smoke');
279
+
280
+ writeFileSync(
281
+ manifestPath,
282
+ JSON.stringify({
283
+ name: '@fake/smoke',
284
+ private: true,
285
+ scripts: {
286
+ 'smoke:browser': 'node -e "console.error(\'fake failure\'); process.exit(7)"',
287
+ },
288
+ forgeax: { smokeInvocation: 'pnpm --filter @fake/smoke smoke:browser' },
289
+ }),
290
+ );
291
+ const failed = spawnSync(
292
+ process.execPath,
293
+ [
294
+ rosterPath,
295
+ '--apps-root',
296
+ appRoot,
297
+ '--learn-root',
298
+ learnRoot,
299
+ '--cwd',
300
+ fixture,
301
+ '--execute',
302
+ ],
303
+ { cwd: ENGINE_ROOT, encoding: 'utf8' },
304
+ );
305
+ expect(failed.status).toBe(1);
306
+ const failedRoster = JSON.parse(failed.stdout) as {
307
+ status: string;
308
+ execution: { status: string; failedCount: number };
309
+ entries: readonly { status: string; returnCode: number | null; stderr: string }[];
310
+ };
311
+ expect(failedRoster.status).toBe('failed');
312
+ expect(failedRoster.execution).toMatchObject({ status: 'failed', failedCount: 1 });
313
+ expect(failedRoster.entries[0]).toMatchObject({ status: 'failed', returnCode: 7 });
314
+ expect(failedRoster.entries[0]?.stderr).toContain('fake failure');
315
+ } finally {
316
+ rmSync(fixture, { recursive: true, force: true });
317
+ }
318
+ });
319
+ });
@@ -11,6 +11,7 @@ describe('v7 public surface', () => {
11
11
  expect(root.encodeTape).toBeTypeOf('function');
12
12
  expect(root.buildTapeIndex).toBeTypeOf('function');
13
13
  expect(root.buildFrameModel).toBeTypeOf('function');
14
+ expect(root.buildResourceLifecycle).toBeTypeOf('function');
14
15
  expect(root.createRhiDebugError).toBeTypeOf('function');
15
16
  });
16
17
 
@@ -27,4 +28,11 @@ describe('v7 public surface', () => {
27
28
  expect(manifest.exports['./cli']).toBeUndefined();
28
29
  expect(Object.keys(manifest.exports).sort()).toEqual(['.', './browser', './package.json']);
29
30
  });
31
+
32
+ it('keeps the producer contract as the only model surface', () => {
33
+ expect(root).not.toHaveProperty('buildViewerModel');
34
+ expect(root).not.toHaveProperty('buildViewModel');
35
+ expect(root).not.toHaveProperty('captureAndUpload');
36
+ expect(root).not.toHaveProperty('uploadTape');
37
+ });
30
38
  });