@forgeax/engine-rhi-debug 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 (75) hide show
  1. package/README.md +32 -7
  2. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts +2 -0
  3. package/dist/__tests__/query-set-lifecycle.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/query-set-replay-fixture.d.ts +39 -0
  5. package/dist/__tests__/query-set-replay-fixture.d.ts.map +1 -0
  6. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts +2 -0
  7. package/dist/__tests__/recorder-canvas-context.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/timestamp-query-capture.dawn.test.d.ts +2 -0
  9. package/dist/__tests__/timestamp-query-capture.dawn.test.d.ts.map +1 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.mjs +527 -58
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/protocol/event-semantics.d.ts +1 -1
  15. package/dist/protocol/event-semantics.d.ts.map +1 -1
  16. package/dist/protocol/types.d.ts +1 -1
  17. package/dist/protocol/types.d.ts.map +1 -1
  18. package/dist/recorder/closure.d.ts.map +1 -1
  19. package/dist/recorder/core.d.ts +10 -1
  20. package/dist/recorder/core.d.ts.map +1 -1
  21. package/dist/recorder/device.d.ts.map +1 -1
  22. package/dist/recorder/encoder.d.ts.map +1 -1
  23. package/dist/recorder/pass.d.ts.map +1 -1
  24. package/dist/recorder/pipeline.d.ts +5 -0
  25. package/dist/recorder/pipeline.d.ts.map +1 -0
  26. package/dist/recorder/proxy.d.ts +3 -1
  27. package/dist/recorder/proxy.d.ts.map +1 -1
  28. package/dist/recorder/session.d.ts +1 -1
  29. package/dist/recorder/session.d.ts.map +1 -1
  30. package/dist/recorder/shader.d.ts +3 -1
  31. package/dist/recorder/shader.d.ts.map +1 -1
  32. package/dist/recorder.d.ts +2 -2
  33. package/dist/recorder.d.ts.map +1 -1
  34. package/dist/replay/execute.d.ts.map +1 -1
  35. package/dist/replay/resources.d.ts +4 -1
  36. package/dist/replay/resources.d.ts.map +1 -1
  37. package/dist/replay/session.d.ts +7 -0
  38. package/dist/replay/session.d.ts.map +1 -1
  39. package/dist/types.d.ts +45 -6
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +6 -6
  42. package/src/__tests__/coverage-invariant.test-d.ts +4 -5
  43. package/src/__tests__/coverage-invariant.unit.test.ts +6 -17
  44. package/src/__tests__/e2e.browser.test.ts +50 -0
  45. package/src/__tests__/guard-gates.test.ts +1 -1
  46. package/src/__tests__/query-set-lifecycle.unit.test.ts +265 -0
  47. package/src/__tests__/query-set-replay-fixture.ts +553 -0
  48. package/src/__tests__/readback-format-matrix.dawn.test.ts +9 -2
  49. package/src/__tests__/recorder-canvas-context.unit.test.ts +67 -0
  50. package/src/__tests__/recorder-session.integration.test.ts +55 -0
  51. package/src/__tests__/recorder-session.unit.test.ts +18 -1
  52. package/src/__tests__/replay-session.dawn.test.ts +171 -0
  53. package/src/__tests__/replay-session.test-d.ts +1 -0
  54. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +61 -0
  55. package/src/__tests__/timestamp-query-capture.dawn.test.ts +61 -0
  56. package/src/index.ts +1 -0
  57. package/src/protocol/event-semantics.ts +21 -2
  58. package/src/protocol/types.ts +1 -0
  59. package/src/protocol/validation.ts +36 -0
  60. package/src/recorder/assemble.ts +8 -1
  61. package/src/recorder/closure.ts +21 -1
  62. package/src/recorder/core.ts +11 -0
  63. package/src/recorder/device.ts +33 -6
  64. package/src/recorder/encoder.ts +60 -6
  65. package/src/recorder/pass.ts +7 -2
  66. package/src/recorder/pipeline.ts +31 -0
  67. package/src/recorder/proxy.ts +58 -2
  68. package/src/recorder/session.ts +1 -1
  69. package/src/recorder/shader.ts +29 -22
  70. package/src/recorder.ts +6 -2
  71. package/src/replay/execute.ts +143 -1
  72. package/src/replay/readback.ts +15 -3
  73. package/src/replay/resources.ts +6 -0
  74. package/src/replay/session.ts +241 -27
  75. package/src/types.ts +61 -14
@@ -135,17 +135,22 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
135
135
  cmdHandleId: HandleId;
136
136
  colorAttachmentViewHandleIds: readonly (HandleId | undefined)[];
137
137
  depthStencilViewHandleId?: HandleId;
138
+ occlusionQuerySetHandleId?: HandleId;
139
+ timestampQuerySetHandleId?: HandleId;
138
140
  };
139
141
  refs.add(we.cmdHandleId);
140
142
  for (const vhId of we.colorAttachmentViewHandleIds) {
141
143
  if (vhId !== undefined) refs.add(vhId);
142
144
  }
143
145
  if (we.depthStencilViewHandleId !== undefined) refs.add(we.depthStencilViewHandleId);
146
+ if (we.occlusionQuerySetHandleId !== undefined) refs.add(we.occlusionQuerySetHandleId);
147
+ if (we.timestampQuerySetHandleId !== undefined) refs.add(we.timestampQuerySetHandleId);
144
148
  break;
145
149
  }
146
150
  case 'beginComputePass': {
147
- const we = e as { cmdHandleId: HandleId };
151
+ const we = e as { cmdHandleId: HandleId; timestampQuerySetHandleId?: HandleId };
148
152
  refs.add(we.cmdHandleId);
153
+ if (we.timestampQuerySetHandleId !== undefined) refs.add(we.timestampQuerySetHandleId);
149
154
  break;
150
155
  }
151
156
  case 'finish': {
@@ -204,6 +209,7 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
204
209
  break;
205
210
  }
206
211
  case 'createBindGroup':
212
+ case 'getBindGroupLayout':
207
213
  case 'createPipelineLayout':
208
214
  case 'createRenderPipeline':
209
215
  case 'createComputePipeline':
@@ -223,17 +229,28 @@ export function _collectFrameReferencedHandleIds(events: readonly RhiCallEvent[]
223
229
  }
224
230
  case 'destroyBuffer':
225
231
  case 'destroyTexture':
232
+ case 'destroyQuerySet':
226
233
  refs.add(e.handleId);
227
234
  break;
228
235
  case 'frameMark':
229
236
  case 'createBuffer':
230
237
  case 'createTexture':
238
+ case 'createQuerySet':
231
239
  case 'createSampler':
232
240
  case 'createBindGroupLayout':
233
241
  case 'createShaderModule':
234
242
  case 'createCommandEncoder':
235
243
  // Leaf declaration events — no backward references to collect.
236
244
  break;
245
+ case 'resolveQuerySet':
246
+ refs.add(e.cmdHandleId);
247
+ refs.add(e.querySetHandleId);
248
+ refs.add(e.destinationHandleId);
249
+ break;
250
+ case 'beginOcclusionQuery':
251
+ case 'endOcclusionQuery':
252
+ refs.add(e.passHandleId);
253
+ break;
237
254
  default: {
238
255
  // Exhaustiveness guard: if a new RhiCallEvent member is added to the
239
256
  // union without a corresponding handle-collection case, tsc fails here.
@@ -292,8 +309,11 @@ export function _getCreateEventReferencedHandleIds(event: RhiCallEvent): HandleI
292
309
  const e = event as { sourceHandleId: HandleId };
293
310
  return [e.sourceHandleId];
294
311
  }
312
+ case 'getBindGroupLayout':
313
+ return [(event as { pipelineHandleId: HandleId }).pipelineHandleId];
295
314
  case 'createBuffer':
296
315
  case 'createTexture':
316
+ case 'createQuerySet':
297
317
  case 'createSampler':
298
318
  case 'createBindGroupLayout':
299
319
  case 'createShaderModule':
@@ -5,6 +5,7 @@
5
5
  import type {
6
6
  Result,
7
7
  RhiDevice,
8
+ RhiError,
8
9
  RhiInstance,
9
10
  ShaderModule,
10
11
  TextureView,
@@ -557,6 +558,16 @@ export type CreateShaderModuleFn = (
557
558
  desc: { code: string; label?: string | undefined },
558
559
  ) => Promise<Result<ShaderModule, import('@forgeax/engine-rhi').RhiError>>;
559
560
 
561
+ /**
562
+ * Synchronous shader-module factory used by the render path's immediate
563
+ * adapter. Keep it in the recordable backend contract so attaching RHI-debug
564
+ * does not silently remove the renderer's first-use fast path.
565
+ */
566
+ export type CreateShaderModuleImmediateFn = (
567
+ device: RhiDevice,
568
+ desc: { code: string; label?: string | undefined },
569
+ ) => Result<ShaderModule, RhiError>;
570
+
560
571
  export type { RecorderInternal, SnapshotProgress };
561
572
  export {
562
573
  addSwapchainViewFormat,
@@ -40,6 +40,7 @@ import {
40
40
  TEXTURE_USAGE_COPY_SRC,
41
41
  } from './core';
42
42
  import { createCommandEncoderProxy } from './encoder';
43
+ import { wrapPipeline } from './pipeline';
43
44
  import { createQueueProxy } from './queue';
44
45
 
45
46
  export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): RhiDevice {
@@ -338,9 +339,9 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
338
339
  vertexShaderModuleHandleId,
339
340
  fragmentShaderModuleHandleId,
340
341
  };
341
- registerHandle(s, res.value as object, 'renderPipeline', event);
342
+ const pipelineId = registerHandle(s, res.value as object, 'renderPipeline', event);
342
343
  pushEvent(s, event);
343
- return res;
344
+ return makeOk(wrapPipeline(s, res.value, pipelineId));
344
345
  },
345
346
 
346
347
  createComputePipeline(desc: ComputePipelineDescriptor) {
@@ -364,13 +365,26 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
364
365
  layoutHandleId: layoutId,
365
366
  computeShaderModuleHandleId,
366
367
  };
367
- registerHandle(s, res.value as object, 'computePipeline', event);
368
+ const pipelineId = registerHandle(s, res.value as object, 'computePipeline', event);
368
369
  pushEvent(s, event);
369
- return res;
370
+ return makeOk(wrapPipeline(s, res.value, pipelineId));
370
371
  },
371
372
 
372
373
  createQuerySet(desc: QuerySetDescriptor) {
373
- return realDevice.createQuerySet(desc);
374
+ const res = realDevice.createQuerySet(desc);
375
+ if (!res.ok) return res;
376
+ const event: RhiCallEvent = {
377
+ kind: 'createQuerySet',
378
+ handleId: '' as HandleId,
379
+ desc: {
380
+ ...(desc.label === undefined ? {} : { label: desc.label }),
381
+ type: desc.type ?? 'occlusion',
382
+ count: desc.count ?? 0,
383
+ },
384
+ };
385
+ registerHandle(s, res.value as object, 'querySet', event);
386
+ pushEvent(s, event);
387
+ return res;
374
388
  },
375
389
 
376
390
  destroyBuffer(buf: Buffer) {
@@ -419,7 +433,20 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
419
433
  },
420
434
 
421
435
  destroyQuerySet(querySet: QuerySet) {
422
- return realDevice.destroyQuerySet(querySet);
436
+ const hId = s.handleMap.get(querySet as object);
437
+ const res = realDevice.destroyQuerySet(querySet);
438
+ if (!res.ok) return res;
439
+ if (hId !== undefined) {
440
+ if (
441
+ !retainsCaptureBootstrap(s) &&
442
+ !s.snapshotSeededHandles.has(hId) &&
443
+ !hasBootstrapDependency(s, hId)
444
+ ) {
445
+ s.bootstrapCreates.delete(hId);
446
+ }
447
+ pushEvent(s, { kind: 'destroyQuerySet', handleId: hId });
448
+ }
449
+ return res;
423
450
  },
424
451
 
425
452
  destroyTexture(tex: Texture) {
@@ -54,6 +54,10 @@ export function createCommandEncoderProxy(
54
54
  kind: 'beginRenderPass',
55
55
  cmdHandleId: cmdHId,
56
56
  passHandleId: passHId,
57
+ timestampQuerySetHandleId:
58
+ desc?.timestampWrites === undefined
59
+ ? undefined
60
+ : getHandleId(s, desc.timestampWrites.querySet as object, 'querySet'),
57
61
  desc: {
58
62
  colorAttachments: Array.from(desc.colorAttachments).map((attachment) =>
59
63
  attachment === null || attachment === undefined ? attachment : { ...attachment },
@@ -61,15 +65,23 @@ export function createCommandEncoderProxy(
61
65
  ...(desc.depthStencilAttachment === undefined
62
66
  ? {}
63
67
  : { depthStencilAttachment: { ...desc.depthStencilAttachment } }),
64
- ...(desc.occlusionQuerySet === undefined
68
+ ...(desc.timestampWrites === undefined
65
69
  ? {}
66
- : { occlusionQuerySet: desc.occlusionQuerySet }),
67
- ...(desc.timestampWrites === undefined ? {} : { timestampWrites: desc.timestampWrites }),
70
+ : { timestampWrites: recordTimestampWrites(desc.timestampWrites) }),
68
71
  ...(desc.maxDrawCount === undefined ? {} : { maxDrawCount: desc.maxDrawCount }),
69
72
  },
70
73
  colorAttachmentViewHandleIds,
71
74
  colorAttachmentResolveTargetHandleIds,
72
75
  depthStencilViewHandleId,
76
+ ...(desc.occlusionQuerySet === undefined
77
+ ? {}
78
+ : {
79
+ occlusionQuerySetHandleId: getHandleId(
80
+ s,
81
+ desc.occlusionQuerySet as object,
82
+ 'querySet',
83
+ ),
84
+ }),
73
85
  });
74
86
  const realPass = realEnc.beginRenderPass(desc);
75
87
  return createRenderPassProxy(s, realPass, passHId);
@@ -80,7 +92,17 @@ export function createCommandEncoderProxy(
80
92
  kind: 'beginComputePass',
81
93
  cmdHandleId: cmdHId,
82
94
  passHandleId: passHId,
83
- desc: desc as Partial<GPUComputePassDescriptor> | undefined,
95
+ timestampQuerySetHandleId:
96
+ desc?.timestampWrites === undefined
97
+ ? undefined
98
+ : getHandleId(s, desc.timestampWrites.querySet as object, 'querySet'),
99
+ desc:
100
+ desc === undefined
101
+ ? undefined
102
+ : {
103
+ ...(desc.label === undefined ? {} : { label: desc.label }),
104
+ timestampWrites: recordTimestampWrites(desc.timestampWrites),
105
+ },
84
106
  });
85
107
  const realPass = realEnc.beginComputePass(desc);
86
108
  return createComputePassProxy(s, realPass, passHId);
@@ -91,7 +113,14 @@ export function createCommandEncoderProxy(
91
113
  kind: 'beginComputePass',
92
114
  cmdHandleId: cmdHId,
93
115
  passHandleId: passHId,
94
- desc: desc as unknown as Partial<GPUComputePassDescriptor>,
116
+ timestampQuerySetHandleId:
117
+ desc?.timestampWrites === undefined
118
+ ? undefined
119
+ : getHandleId(s, desc.timestampWrites.querySet as object, 'querySet'),
120
+ desc: {
121
+ ...(desc.label === undefined ? {} : { label: desc.label }),
122
+ timestampWrites: recordTimestampWrites(desc.timestampWrites),
123
+ },
95
124
  });
96
125
  realEnc.encodeEmptyComputePass(desc);
97
126
  pushEvent(s, { kind: 'endComputePass', passHandleId: passHId });
@@ -220,13 +249,25 @@ export function createCommandEncoderProxy(
220
249
  realEnc.clearBuffer(buffer, offset, size);
221
250
  },
222
251
  resolveQuerySet(querySet, firstQuery, queryCount, destination, destinationOffset) {
223
- return realEnc.resolveQuerySet(
252
+ const result = realEnc.resolveQuerySet(
224
253
  querySet,
225
254
  firstQuery,
226
255
  queryCount,
227
256
  destination,
228
257
  destinationOffset,
229
258
  );
259
+ if (result.ok) {
260
+ pushEvent(s, {
261
+ kind: 'resolveQuerySet',
262
+ cmdHandleId: cmdHId,
263
+ querySetHandleId: getHandleId(s, querySet as object, 'querySet'),
264
+ firstQuery,
265
+ queryCount,
266
+ destinationHandleId: getHandleId(s, destination as object, 'buffer'),
267
+ destinationOffset,
268
+ });
269
+ }
270
+ return result;
230
271
  },
231
272
  pushDebugGroup(groupLabel) {
232
273
  pushEvent(s, { kind: 'pushDebugGroup', cmdHandleId: cmdHId, groupLabel });
@@ -257,3 +298,16 @@ export function createCommandEncoderProxy(
257
298
  },
258
299
  };
259
300
  }
301
+
302
+ function recordTimestampWrites(writes: RenderPassDescriptor['timestampWrites']) {
303
+ return writes === undefined
304
+ ? undefined
305
+ : {
306
+ ...(writes.beginningOfPassWriteIndex === undefined
307
+ ? {}
308
+ : { beginningOfPassWriteIndex: writes.beginningOfPassWriteIndex }),
309
+ ...(writes.endOfPassWriteIndex === undefined
310
+ ? {}
311
+ : { endOfPassWriteIndex: writes.endOfPassWriteIndex }),
312
+ };
313
+ }
@@ -177,10 +177,15 @@ export function createRenderPassProxy(
177
177
  return realPass.executeBundles(bundles);
178
178
  },
179
179
  beginOcclusionQuery(queryIndex) {
180
- return realPass.beginOcclusionQuery(queryIndex);
180
+ const result = realPass.beginOcclusionQuery(queryIndex);
181
+ if (result.ok)
182
+ pushEvent(s, { kind: 'beginOcclusionQuery', passHandleId: passHId, queryIndex });
183
+ return result;
181
184
  },
182
185
  endOcclusionQuery() {
183
- return realPass.endOcclusionQuery();
186
+ const result = realPass.endOcclusionQuery();
187
+ if (result.ok) pushEvent(s, { kind: 'endOcclusionQuery', passHandleId: passHId });
188
+ return result;
184
189
  },
185
190
  end() {
186
191
  pushEvent(s, { kind: 'endRenderPass', passHandleId: passHId });
@@ -0,0 +1,31 @@
1
+ import type { ComputePipeline, RenderPipeline, RhiRenderPipelineOps } from '@forgeax/engine-rhi';
2
+ import type { HandleId } from '../types';
3
+ import { pushEvent, type RecorderInternal, registerHandle } from './core';
4
+
5
+ export function wrapPipeline<T extends RenderPipeline | ComputePipeline>(
6
+ state: RecorderInternal,
7
+ pipeline: T,
8
+ pipelineHandleId: HandleId,
9
+ ): T {
10
+ // Preserve native pipeline identity: renderer-owned command paths may pass
11
+ // the opaque handle directly to WebGPU, which rejects a JavaScript Proxy.
12
+ const operation = (pipeline as T & Partial<RhiRenderPipelineOps>).getBindGroupLayout;
13
+ if (operation === undefined) return pipeline;
14
+ const getLayout = operation.bind(pipeline);
15
+ Object.defineProperty(pipeline, 'getBindGroupLayout', {
16
+ configurable: true,
17
+ value: (index: number) => {
18
+ const layout = getLayout(index);
19
+ const event = {
20
+ kind: 'getBindGroupLayout' as const,
21
+ handleId: '' as HandleId,
22
+ pipelineHandleId,
23
+ index,
24
+ };
25
+ registerHandle(state, layout, 'bindGroupLayout', event);
26
+ pushEvent(state, event);
27
+ return layout;
28
+ },
29
+ });
30
+ return pipeline;
31
+ }
@@ -3,6 +3,7 @@ import { err, ok, type Result } from '@forgeax/engine-types';
3
3
  import { createRhiDebugError, type RhiDebugError } from '../errors';
4
4
  import {
5
5
  type CreateShaderModuleFn,
6
+ type CreateShaderModuleImmediateFn,
6
7
  type DebugRhiInstance,
7
8
  wrap,
8
9
  wrapCreateShaderModule,
@@ -15,6 +16,8 @@ export interface RecordableBackend {
15
16
  ) => Result<RhiCanvasContext, RhiError>;
16
17
  };
17
18
  readonly createShaderModule: CreateShaderModuleFn;
19
+ /** Optional synchronous factory retained for render's first-use path. */
20
+ readonly createShaderModuleImmediate?: CreateShaderModuleImmediateFn;
18
21
  }
19
22
 
20
23
  export interface RecorderBackend extends RecordableBackend {
@@ -29,21 +32,74 @@ export interface RecorderProxy {
29
32
  export function createRecorderProxy(backend: RecordableBackend): RecorderProxy {
30
33
  const recorder = wrap(backend.rhi);
31
34
  const wrappedCreateShaderModule = wrapCreateShaderModule(backend.createShaderModule, recorder);
35
+ const originalCreateShaderModuleImmediate = backend.createShaderModuleImmediate;
36
+ const wrappedCreateShaderModuleImmediate =
37
+ originalCreateShaderModuleImmediate === undefined
38
+ ? undefined
39
+ : (device: RhiDevice, desc: { code: string; label?: string | undefined }) => {
40
+ const realDevice =
41
+ (device as RhiDevice & { readonly _realDevice?: RhiDevice })._realDevice ?? device;
42
+ const result = originalCreateShaderModuleImmediate(realDevice, desc);
43
+ if (!result.ok) return result;
44
+ const hId = recorder.pushExternalCreateEvent(result.value, 'shaderModule', {
45
+ kind: 'createShaderModule',
46
+ handleId: '' as import('../types').HandleId,
47
+ wgslCode: desc.code,
48
+ });
49
+ recorder.registerShaderModule(result.value, hId);
50
+ return result;
51
+ };
32
52
  const acquireCanvasContext = backend.rhi.acquireCanvasContext;
33
53
  // Runtime's explicit-RHI seam receives the wrapped singleton as one value.
34
54
  // Keep the standalone shader factory enumerable on that value so the
35
55
  // renderer can preserve the complete typed backend pack after capture
36
56
  // attachment; the public RhiInstance contract remains unchanged.
37
- const rhi: RecordableBackend['rhi'] & { readonly createShaderModule: CreateShaderModuleFn } = {
57
+ const rhi: RecordableBackend['rhi'] & {
58
+ readonly createShaderModule: CreateShaderModuleFn;
59
+ readonly createShaderModuleImmediate?: CreateShaderModuleImmediateFn;
60
+ } = {
38
61
  requestAdapter: recorder.requestAdapter.bind(recorder),
39
62
  createShaderModule: wrappedCreateShaderModule,
63
+ ...(wrappedCreateShaderModuleImmediate === undefined
64
+ ? {}
65
+ : { createShaderModuleImmediate: wrappedCreateShaderModuleImmediate }),
40
66
  ...(acquireCanvasContext === undefined
41
67
  ? {}
42
- : { acquireCanvasContext: acquireCanvasContext.bind(backend.rhi) }),
68
+ : {
69
+ acquireCanvasContext(canvas) {
70
+ const result = acquireCanvasContext.call(backend.rhi, canvas);
71
+ if (!result.ok) return result;
72
+ const context = result.value;
73
+ // rhi-webgpu's canvas shim translates the forgeax RhiDevice to the
74
+ // native GPUDevice through its own WeakMap. The recorder device is
75
+ // an intentional proxy, so passing it directly makes configure()
76
+ // reject with a foreign-device error before the first frame. Keep
77
+ // the context owned by the selected backend, but unwrap only the
78
+ // device argument at this boundary; all resource calls continue to
79
+ // flow through the recorder device and remain capturable.
80
+ return ok({
81
+ ...context,
82
+ configure(configuration) {
83
+ const device = (
84
+ configuration.device as RhiDevice & {
85
+ readonly _realDevice?: RhiDevice;
86
+ }
87
+ )._realDevice;
88
+ return context.configure({
89
+ ...configuration,
90
+ ...(device === undefined ? {} : { device }),
91
+ });
92
+ },
93
+ });
94
+ },
95
+ }),
43
96
  };
44
97
  const wrappedBackend: RecorderBackend = {
45
98
  rhi,
46
99
  createShaderModule: wrappedCreateShaderModule,
100
+ ...(wrappedCreateShaderModuleImmediate === undefined
101
+ ? {}
102
+ : { createShaderModuleImmediate: wrappedCreateShaderModuleImmediate }),
47
103
  unwrapDeviceForSurface(device) {
48
104
  const raw = (device as RhiDevice & { readonly _realDevice?: RhiDevice })._realDevice;
49
105
  if (raw !== undefined) return ok(raw);
@@ -147,6 +147,6 @@ export function attachRecorder(
147
147
  return ok(attachment);
148
148
  }
149
149
 
150
- export type { CreateShaderModuleFn } from '../recorder';
150
+ export type { CreateShaderModuleFn, CreateShaderModuleImmediateFn } from '../recorder';
151
151
  export type { EncodedTape } from './assemble';
152
152
  export type { RecordableBackend, RecorderBackend } from './proxy';
@@ -2,7 +2,24 @@
2
2
 
3
3
  import type { Result, RhiDevice, ShaderModule } from '@forgeax/engine-rhi';
4
4
  import type { HandleId } from '../types';
5
- import type { CreateShaderModuleFn, DebugRhiInstance } from './core';
5
+ import type { CreateShaderModuleFn, CreateShaderModuleImmediateFn, DebugRhiInstance } from './core';
6
+
7
+ function recordShaderModule(
8
+ debugInst: DebugRhiInstance,
9
+ result: Result<ShaderModule, import('@forgeax/engine-rhi').RhiError>,
10
+ desc: { code: string; label?: string | undefined },
11
+ ): Result<ShaderModule, import('@forgeax/engine-rhi').RhiError> {
12
+ if (!result.ok) return result;
13
+ const hId = debugInst.pushExternalCreateEvent(result.value, 'shaderModule', {
14
+ kind: 'createShaderModule',
15
+ handleId: '' as HandleId,
16
+ wgslCode: desc.code,
17
+ });
18
+ // Register the shader module handle in the recorder's handleMap so
19
+ // downstream pipeline events can resolve the handleId during replay.
20
+ debugInst.registerShaderModule(result.value, hId);
21
+ return result;
22
+ }
6
23
 
7
24
  export function wrapCreateShaderModule(
8
25
  originalFn: CreateShaderModuleFn,
@@ -21,27 +38,17 @@ export function wrapCreateShaderModule(
21
38
  // proxyDevice exposes for exactly this purpose.
22
39
  const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
23
40
  const result = await originalFn(realDevice, desc);
24
- if (!result.ok) return result;
25
-
26
- // I-12 (round 1 implement-review) fix: route the createShaderModule
27
- // event through the same `pushExternalEvent` helper that wraps the
28
- // internal `pushEvent` (and therefore the `_skipRecord` + state-machine
29
- // guard). The previous `(events as RhiCallEvent[]).push(...)` cast
30
- // bypassed those guards — recorder-internal RHI calls during shader
31
- // construction would have self-polluted the tape.
32
- const hId = debugInst.pushExternalCreateEvent(result.value, 'shaderModule', {
33
- kind: 'createShaderModule',
34
- handleId: '' as HandleId,
35
- wgslCode: desc.code,
36
- });
37
- // Register the shader module handle in the recorder's handleMap so
38
- // downstream pipeline events (createRenderPipeline / createComputePipeline)
39
- // can resolve the handleId via getHandleId. Required for cross-device
40
- // replay: the tape's pipeline desc carries live GPU module objects which
41
- // are device-bound, so the replayer must swap them with re-created shader
42
- // modules from the handleMap using the handleId.
43
- debugInst.registerShaderModule(result.value, hId);
41
+ return recordShaderModule(debugInst, result, desc);
42
+ };
43
+ }
44
44
 
45
- return result;
45
+ /** Wrap the synchronous render-path factory while preserving tape ownership. */
46
+ export function wrapCreateShaderModuleImmediate(
47
+ originalFn: CreateShaderModuleImmediateFn,
48
+ debugInst: DebugRhiInstance,
49
+ ): CreateShaderModuleImmediateFn {
50
+ return (device, desc) => {
51
+ const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
52
+ return recordShaderModule(debugInst, originalFn(realDevice, desc), desc);
46
53
  };
47
54
  }
package/src/recorder.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // @forgeax/engine-rhi-debug/src/recorder -- split recorder public seam.
2
2
 
3
- export type { CreateShaderModuleFn, DebugRhiInstance } from './recorder/core';
3
+ export type {
4
+ CreateShaderModuleFn,
5
+ CreateShaderModuleImmediateFn,
6
+ DebugRhiInstance,
7
+ } from './recorder/core';
4
8
  export { PER_EVENT_OVERHEAD, TAPE_FORMAT_VERSION } from './recorder/core';
5
9
  export type {
6
10
  CaptureFrameOptions,
@@ -11,5 +15,5 @@ export type {
11
15
  RecorderOptions,
12
16
  } from './recorder/session';
13
17
  export { attachRecorder } from './recorder/session';
14
- export { wrapCreateShaderModule } from './recorder/shader';
18
+ export { wrapCreateShaderModule, wrapCreateShaderModuleImmediate } from './recorder/shader';
15
19
  export { wrap } from './recorder/wrap';