@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
@@ -10,6 +10,7 @@ import type {
10
10
  RhiDevice,
11
11
  RhiError,
12
12
  RhiQueue,
13
+ RhiRenderPipelineOps,
13
14
  Texture,
14
15
  } from '@forgeax/engine-rhi';
15
16
  import { ok, type Result } from '@forgeax/engine-types';
@@ -70,10 +71,20 @@ export async function executeEvent(
70
71
  kind: 'texture',
71
72
  },
72
73
  );
74
+ case 'createQuerySet':
75
+ return createResource(
76
+ context,
77
+ eventIndex,
78
+ event,
79
+ context.device.createQuerySet(event.desc),
80
+ { kind: 'query-set' },
81
+ );
73
82
  case 'destroyBuffer':
74
83
  return destroyResource(context, eventIndex, event, 'buffer');
75
84
  case 'destroyTexture':
76
85
  return destroyResource(context, eventIndex, event, 'texture');
86
+ case 'destroyQuerySet':
87
+ return destroyQuerySet(context, event, eventIndex);
77
88
  case 'createTextureView': {
78
89
  const texture = requireResource<Texture>(
79
90
  context,
@@ -110,6 +121,28 @@ export async function executeEvent(
110
121
  context.device.createBindGroupLayout(event.desc as never),
111
122
  { kind: 'binding', role: 'bind-group-layout' },
112
123
  );
124
+ case 'getBindGroupLayout': {
125
+ const pipeline = requireReplayResource(
126
+ context,
127
+ event.pipelineHandleId,
128
+ 'pipeline',
129
+ eventIndex,
130
+ event.kind,
131
+ );
132
+ if (!pipeline.ok) return pipeline;
133
+ return createResource(
134
+ context,
135
+ eventIndex,
136
+ event,
137
+ ok(
138
+ (
139
+ pipeline.value.value as import('@forgeax/engine-rhi').RenderPipeline &
140
+ RhiRenderPipelineOps
141
+ ).getBindGroupLayout(event.index),
142
+ ),
143
+ { kind: 'binding', role: 'bind-group-layout' },
144
+ );
145
+ }
113
146
  case 'createBindGroup':
114
147
  return createBindGroup(context, event, eventIndex);
115
148
  case 'createPipelineLayout':
@@ -141,6 +174,22 @@ export async function executeEvent(
141
174
  return beginRenderPass(context, event, eventIndex);
142
175
  case 'beginComputePass':
143
176
  return beginComputePass(context, event, eventIndex);
177
+ case 'resolveQuerySet':
178
+ return resolveQuerySet(context, event, eventIndex);
179
+ case 'beginOcclusionQuery':
180
+ return passCall(context, event, eventIndex, 'render-pass', (pass) => {
181
+ const result = pass.beginOcclusionQuery(event.queryIndex);
182
+ return result.ok
183
+ ? ok(undefined)
184
+ : eventFailure(eventIndex, event, 'encode', result.error);
185
+ });
186
+ case 'endOcclusionQuery':
187
+ return passCall(context, event, eventIndex, 'render-pass', (pass) => {
188
+ const result = pass.endOcclusionQuery();
189
+ return result.ok
190
+ ? ok(undefined)
191
+ : eventFailure(eventIndex, event, 'encode', result.error);
192
+ });
144
193
  case 'copyBufferToBuffer':
145
194
  return copyBufferToBuffer(context, event, eventIndex);
146
195
  case 'copyBufferToTexture':
@@ -386,6 +435,60 @@ function destroyResource(
386
435
  return ok(undefined);
387
436
  }
388
437
 
438
+ function destroyQuerySet(
439
+ context: ReplayExecutionContext,
440
+ event: Extract<RhiCallEvent, { kind: 'destroyQuerySet' }>,
441
+ eventIndex: number,
442
+ ): Result<void, RhiDebugError> {
443
+ const entry = context.table.get(event.handleId);
444
+ if (entry?.resource.kind !== 'query-set')
445
+ return missingResource(eventIndex, event, event.handleId, 'query-set');
446
+ const result = context.device.destroyQuerySet(entry.resource.value);
447
+ if (!result.ok) return eventFailure(eventIndex, event, 'create', result.error);
448
+ context.table.delete(event.handleId);
449
+ return ok(undefined);
450
+ }
451
+
452
+ function resolveQuerySet(
453
+ context: ReplayExecutionContext,
454
+ event: Extract<RhiCallEvent, { kind: 'resolveQuerySet' }>,
455
+ eventIndex: number,
456
+ ): Result<void, RhiDebugError> {
457
+ const encoder = requireResource<RhiCommandEncoder>(
458
+ context,
459
+ event.cmdHandleId,
460
+ 'encoder',
461
+ eventIndex,
462
+ event.kind,
463
+ 'command',
464
+ );
465
+ const querySet = requireResource<import('@forgeax/engine-rhi').QuerySet>(
466
+ context,
467
+ event.querySetHandleId,
468
+ 'query-set',
469
+ eventIndex,
470
+ event.kind,
471
+ );
472
+ const destination = requireResource<Buffer>(
473
+ context,
474
+ event.destinationHandleId,
475
+ 'buffer',
476
+ eventIndex,
477
+ event.kind,
478
+ );
479
+ if (!encoder.ok) return encoder;
480
+ if (!querySet.ok) return querySet;
481
+ if (!destination.ok) return destination;
482
+ const result = encoder.value.resolveQuerySet(
483
+ querySet.value,
484
+ event.firstQuery,
485
+ event.queryCount,
486
+ destination.value,
487
+ event.destinationOffset,
488
+ );
489
+ return result.ok ? ok(undefined) : eventFailure(eventIndex, event, 'encode', result.error);
490
+ }
491
+
389
492
  async function createShaderModule(
390
493
  context: ReplayExecutionContext,
391
494
  event: Extract<RhiCallEvent, { kind: 'createShaderModule' }>,
@@ -673,8 +776,18 @@ function beginRenderPass(
673
776
  event.depthStencilViewHandleId === undefined
674
777
  ? undefined
675
778
  : context.table.get(event.depthStencilViewHandleId)?.resource;
779
+ const querySet =
780
+ event.occlusionQuerySetHandleId === undefined
781
+ ? undefined
782
+ : context.table.get(event.occlusionQuerySetHandleId)?.resource;
783
+ if (event.occlusionQuerySetHandleId !== undefined && querySet?.kind !== 'query-set') {
784
+ return missingResource(eventIndex, event, event.occlusionQuerySetHandleId, 'query-set');
785
+ }
786
+ const timestamp = replayTimestampWrites(context, event, eventIndex);
787
+ if (!timestamp.ok) return timestamp;
676
788
  const descriptor = {
677
789
  ...event.desc,
790
+ ...(timestamp.value === undefined ? {} : { timestampWrites: timestamp.value }),
678
791
  colorAttachments: colors,
679
792
  ...(depth?.kind === 'texture-view'
680
793
  ? {
@@ -684,6 +797,7 @@ function beginRenderPass(
684
797
  } as RenderPassDepthStencilAttachment,
685
798
  }
686
799
  : {}),
800
+ ...(querySet?.kind === 'query-set' ? { occlusionQuerySet: querySet.value } : {}),
687
801
  };
688
802
  const pass = encoder.value.beginRenderPass(descriptor as never);
689
803
  return context.table.set(
@@ -693,6 +807,29 @@ function beginRenderPass(
693
807
  );
694
808
  }
695
809
 
810
+ function replayTimestampWrites(
811
+ context: ReplayExecutionContext,
812
+ event: Extract<RhiCallEvent, { kind: 'beginRenderPass' | 'beginComputePass' }>,
813
+ eventIndex: number,
814
+ ): Result<import('@forgeax/engine-rhi').RenderPassTimestampWrites | undefined, RhiDebugError> {
815
+ if (event.desc?.timestampWrites === undefined) return ok(undefined);
816
+ const handle = event.timestampQuerySetHandleId;
817
+ if (handle === undefined)
818
+ return missingResource(eventIndex, event, 'timestamp-query-set', 'query-set');
819
+ const query = requireResource<import('@forgeax/engine-rhi').QuerySet>(
820
+ context,
821
+ handle,
822
+ 'query-set',
823
+ eventIndex,
824
+ event.kind,
825
+ );
826
+ if (!query.ok) return query;
827
+ return ok({
828
+ ...event.desc.timestampWrites,
829
+ querySet: query.value,
830
+ });
831
+ }
832
+
696
833
  function beginComputePass(
697
834
  context: ReplayExecutionContext,
698
835
  event: Extract<RhiCallEvent, { kind: 'beginComputePass' }>,
@@ -707,7 +844,12 @@ function beginComputePass(
707
844
  'command',
708
845
  );
709
846
  if (!encoder.ok) return encoder;
710
- const pass = encoder.value.beginComputePass(JSON.parse(JSON.stringify(event.desc)));
847
+ const timestamp = replayTimestampWrites(context, event, eventIndex);
848
+ if (!timestamp.ok) return timestamp;
849
+ const pass = encoder.value.beginComputePass({
850
+ ...(event.desc?.label === undefined ? {} : { label: event.desc.label }),
851
+ ...(timestamp.value === undefined ? {} : { timestampWrites: timestamp.value }),
852
+ });
711
853
  return context.table.set(
712
854
  event.passHandleId,
713
855
  { kind: 'encoder', role: 'compute-pass', value: pass },
@@ -100,7 +100,7 @@ function resolveTextureViewSubresource(
100
100
  source: ResourceTableEntry,
101
101
  requested: ReplayReadbackRequest | undefined,
102
102
  ): Result<ReplayReadbackRequest | undefined, RhiDebugError> {
103
- if (requested === undefined || isBufferRange(requested)) return ok(requested);
103
+ if (isBufferRange(requested)) return ok(requested);
104
104
  const sourceDescriptor = recordField(source.descriptor, 'desc');
105
105
  const sourceSize = textureSize(sourceDescriptor?.size);
106
106
  const sourceMipCount = numberField(sourceDescriptor, 'mipLevelCount') ?? 1;
@@ -112,8 +112,8 @@ function resolveTextureViewSubresource(
112
112
  const arrayLayerCount =
113
113
  numberField(viewDescriptor, 'arrayLayerCount') ??
114
114
  sourceSize.depthOrArrayLayers - baseArrayLayer;
115
- const localMipLevel = requested.mipLevel ?? 0;
116
- const localArrayLayer = requested.arrayLayer ?? 0;
115
+ const localMipLevel = requested?.mipLevel ?? 0;
116
+ const localArrayLayer = requested?.arrayLayer ?? 0;
117
117
  if (
118
118
  !validIndex(baseMipLevel, sourceMipCount + 1) ||
119
119
  !validIndex(baseArrayLayer, sourceSize.depthOrArrayLayers + 1) ||
@@ -128,6 +128,11 @@ function resolveTextureViewSubresource(
128
128
  ...requested,
129
129
  mipLevel: baseMipLevel + localMipLevel,
130
130
  arrayLayer: baseArrayLayer + localArrayLayer,
131
+ aspect:
132
+ requested?.aspect ??
133
+ (viewDescriptor?.aspect === 'depth-only' || viewDescriptor?.aspect === 'stencil-only'
134
+ ? viewDescriptor.aspect
135
+ : 'all'),
131
136
  });
132
137
  }
133
138
 
@@ -168,6 +173,13 @@ async function readTexture(
168
173
  const format = stringField(descriptor, 'format');
169
174
  const dimension = stringField(descriptor, 'dimension') ?? '2d';
170
175
  if (format === undefined) return readbackFailure(`texture ${resourceId} has no recorded format`);
176
+ if ((numberField(descriptor, 'sampleCount') ?? 1) > 1) {
177
+ return readbackUnsupported(
178
+ resourceId,
179
+ format,
180
+ 'multisampled textures require a single-sample resolve target before readback',
181
+ );
182
+ }
171
183
  const plan = getTextureReadbackPlan({ format, dimension });
172
184
  if (!plan.supported) {
173
185
  return err(
@@ -5,6 +5,7 @@ import type {
5
5
  CommandBuffer,
6
6
  ComputePipeline,
7
7
  PipelineLayout,
8
+ QuerySet,
8
9
  RenderPipeline,
9
10
  RhiCommandEncoder,
10
11
  RhiComputePassEncoder,
@@ -24,6 +25,7 @@ export type ReplayResource = {
24
25
  } & (
25
26
  | { readonly kind: 'buffer'; readonly value: Buffer }
26
27
  | { readonly kind: 'texture'; readonly value: Texture }
28
+ | { readonly kind: 'query-set'; readonly value: QuerySet }
27
29
  | { readonly kind: 'texture-view'; readonly value: TextureView }
28
30
  | { readonly kind: 'sampler'; readonly value: Sampler }
29
31
  | { readonly kind: 'shader-module'; readonly value: ShaderModule }
@@ -132,6 +134,10 @@ export class ResourceTable {
132
134
  const result = this.device.destroyTexture(entry.resource.value);
133
135
  if (!result.ok && firstFailure === undefined)
134
136
  firstFailure = resourceDisposeFailure(entry.resourceId, result.error.code);
137
+ } else if (entry.resource.kind === 'query-set') {
138
+ const result = this.device.destroyQuerySet(entry.resource.value);
139
+ if (!result.ok && firstFailure === undefined)
140
+ firstFailure = resourceDisposeFailure(entry.resourceId, result.error.code);
135
141
  }
136
142
  }
137
143
  return firstFailure ?? ok(undefined);
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ MappedBuffer,
2
3
  RhiCommandEncoder,
3
4
  RhiComputePassEncoder,
4
5
  RhiDevice,
@@ -69,6 +70,18 @@ export interface ReplaySession {
69
70
  subresource?: ReadbackSubresource,
70
71
  signal?: AbortSignal,
71
72
  ): Promise<Result<ReplayReadbackResult, RhiDebugError>>;
73
+ /**
74
+ * Replay the captured stream through one indexed work item, then read the
75
+ * resource from that exact post-work state. Unlike readResource(), this is
76
+ * not a bootstrap-only read and therefore can inspect compute/storage
77
+ * outputs produced by the selected work without creating a second tape.
78
+ */
79
+ readResourceAtWork(
80
+ resourceId: string,
81
+ workIndex: number,
82
+ subresource?: ReadbackSubresource,
83
+ signal?: AbortSignal,
84
+ ): Promise<Result<ReplayReadbackResult, RhiDebugError>>;
72
85
  dispose(): Promise<Result<void, RhiDebugError>>;
73
86
  }
74
87
 
@@ -204,6 +217,42 @@ export async function openReplay(
204
217
  backend.createShaderModule,
205
218
  );
206
219
  },
220
+ async readResourceAtWork(resourceId, workIndex, subresource, signal) {
221
+ if (disposed) return positionError(workIndex, index.works.length);
222
+ if (signal?.aborted) {
223
+ return err(
224
+ createRhiDebugError('readback-failed', {
225
+ stage: 'readback',
226
+ cause: 'readback was aborted',
227
+ }),
228
+ );
229
+ }
230
+ const work = index.works[workIndex];
231
+ if (work === undefined || model.works[workIndex] === undefined) {
232
+ return positionError(workIndex, index.works.length);
233
+ }
234
+ const cleared = await reset();
235
+ if (!cleared.ok) return cleared;
236
+ const bootstrapped = await prepare();
237
+ if (!bootstrapped.ok) return bootstrapped;
238
+ const replayed = await replayThroughWork(context, index, work, signal);
239
+ if (!replayed.ok) return replayed;
240
+ const read = await readReplayResource(
241
+ backend.device,
242
+ table,
243
+ resourceId,
244
+ subresource,
245
+ backend.createShaderModule,
246
+ );
247
+ if (!read.ok) return read;
248
+ return ok({
249
+ ...read.value,
250
+ provenance: {
251
+ ...read.value.provenance,
252
+ selectedWorkIndex: work.workIndex,
253
+ },
254
+ });
255
+ },
207
256
  async dispose() {
208
257
  if (disposed) return ok(undefined);
209
258
  const result = table.dispose();
@@ -328,7 +377,14 @@ async function executeBootstrapResource(
328
377
  bootstrapIndex: number,
329
378
  ): Promise<Result<void, RhiDebugError>> {
330
379
  const event: RhiCallEvent = JSON.parse(JSON.stringify(resource.create));
331
- const result = await executeEvent(context, event, -bootstrapIndex - 1);
380
+ // Captured GPU-written buffers need not allow COPY_DST. Restore their
381
+ // initial bytes through creation mapping, which is legal for every usage
382
+ // (including MAP_WRITE, where adding COPY_DST would itself be invalid).
383
+ const replayEvent =
384
+ event.kind === 'createBuffer' && resource.initialData.length > 0
385
+ ? { ...event, desc: { ...event.desc, mappedAtCreation: true } }
386
+ : event;
387
+ const result = await executeEvent(context, replayEvent, -bootstrapIndex - 1);
332
388
  return result;
333
389
  }
334
390
 
@@ -351,25 +407,50 @@ async function seedBootstrapResource(
351
407
  'bootstrap initialData requires a buffer or color texture resource',
352
408
  );
353
409
  }
354
- for (const slice of resource.initialData) {
355
- const blob = context.tape.blobs.find((candidate) => candidate.hash === slice.hash);
356
- if (blob === undefined) {
357
- return eventFailure(-bootstrapIndex - 1, event, 'lookup', `blob ${slice.hash} is missing`);
358
- }
359
- const end = slice.byteOffset + slice.byteLength;
360
- if (slice.byteOffset < 0 || slice.byteLength < 0 || end > blob.bytes.byteLength) {
361
- return eventFailure(
362
- -bootstrapIndex - 1,
363
- event,
364
- 'lookup',
365
- `blob ${slice.hash} does not contain initialData slice [${slice.byteOffset}, ${end})`,
366
- );
367
- }
368
- const bytes = blob.bytes.slice(slice.byteOffset, slice.byteOffset + slice.byteLength);
369
- const written = context.queue.writeBuffer(entry.resource.value, 0, bytes);
370
- if (!written.ok) {
371
- return eventFailure(-bootstrapIndex - 1, event, 'write', written.error);
410
+ // executeBootstrapResource created this buffer mapped specifically for
411
+ // restoration. It must be unmapped before any recorded work can consume it.
412
+ const mapped = entry.resource.value as MappedBuffer;
413
+ // RhiNull deliberately exposes opaque structural handles rather than a
414
+ // CPU-visible mapping implementation. Its replay bytes have no observable
415
+ // storage, so there is nothing to seed; real backends always provide the
416
+ // MappedBuffer methods below and still take the strict mapping path.
417
+ if (
418
+ typeof (mapped as Partial<MappedBuffer>).getMappedRange !== 'function' ||
419
+ typeof (mapped as Partial<MappedBuffer>).unmap !== 'function'
420
+ ) {
421
+ if (context.device.caps.backendKind === 'null') return ok(undefined);
422
+ return eventFailure(
423
+ -bootstrapIndex - 1,
424
+ event,
425
+ 'write',
426
+ 'bootstrap buffer mapping is unavailable on the replay backend',
427
+ );
428
+ }
429
+ try {
430
+ const range = mapped.getMappedRange();
431
+ if (!range.ok) return eventFailure(-bootstrapIndex - 1, event, 'write', range.error);
432
+ const target = new Uint8Array(range.value);
433
+ for (const slice of resource.initialData) {
434
+ const blob = context.tape.blobs.find((candidate) => candidate.hash === slice.hash);
435
+ if (blob === undefined) {
436
+ return eventFailure(-bootstrapIndex - 1, event, 'lookup', `blob ${slice.hash} is missing`);
437
+ }
438
+ const end = slice.byteOffset + slice.byteLength;
439
+ if (slice.byteOffset < 0 || slice.byteLength < 0 || end > blob.bytes.byteLength) {
440
+ return eventFailure(
441
+ -bootstrapIndex - 1,
442
+ event,
443
+ 'lookup',
444
+ `blob ${slice.hash} does not contain initialData slice [${slice.byteOffset}, ${end})`,
445
+ );
446
+ }
447
+ if (slice.byteLength > target.byteLength) {
448
+ return eventFailure(-bootstrapIndex - 1, event, 'write', 'initialData exceeds buffer size');
449
+ }
450
+ target.set(blob.bytes.subarray(slice.byteOffset, end));
372
451
  }
452
+ } finally {
453
+ mapped.unmap();
373
454
  }
374
455
  return ok(undefined);
375
456
  }
@@ -489,12 +570,8 @@ async function finalizeWorkPass(
489
570
  `pass ${begin.passHandleId} is not open`,
490
571
  );
491
572
  }
492
- try {
493
- (entry.resource.value as RhiRenderPassEncoder | RhiComputePassEncoder).end();
494
- } catch (cause) {
495
- return eventFailure(pass.endEventIndex ?? work.eventIndex, begin, 'encode', cause);
496
- }
497
- context.table.delete(begin.passHandleId);
573
+ const closed = closeReplayPass(context, entry, begin, pass, work.eventIndex);
574
+ if (!closed.ok) return closed;
498
575
  const finishEventIndex = findNextEvent(
499
576
  context.tape.events,
500
577
  pass.beginEventIndex,
@@ -510,6 +587,13 @@ async function finalizeWorkPass(
510
587
  `encoder ${begin.cmdHandleId} is not available`,
511
588
  );
512
589
  }
590
+ const closure = await replayCommandClosure(
591
+ context,
592
+ begin.cmdHandleId,
593
+ (pass.endEventIndex ?? work.eventIndex) + 1,
594
+ finishEventIndex ?? context.tape.events.length,
595
+ );
596
+ if (!closure.ok) return closure;
513
597
  const finished = (encoder.resource.value as RhiCommandEncoder).finish();
514
598
  const finishEvent = context.tape.events[finishEventIndex ?? work.eventIndex] ?? begin;
515
599
  if (!finished.ok)
@@ -538,6 +622,131 @@ async function finalizeWorkPass(
538
622
  return ok(undefined);
539
623
  }
540
624
 
625
+ function closeReplayPass(
626
+ context: ReplayExecutionContext,
627
+ entry: NonNullable<ReturnType<ReplayExecutionContext['table']['get']>>,
628
+ begin: Extract<RhiCallEvent, { kind: 'beginRenderPass' | 'beginComputePass' }>,
629
+ pass: TapeIndex['passes'][number],
630
+ selectedEventIndex: number,
631
+ ): Result<void, RhiDebugError> {
632
+ const activeQuery = activeOcclusionQuery(
633
+ context.tape.events,
634
+ begin.kind === 'beginRenderPass' ? begin.passHandleId : undefined,
635
+ pass.beginEventIndex,
636
+ selectedEventIndex,
637
+ );
638
+ if (!activeQuery.ok) return activeQuery;
639
+ try {
640
+ if (activeQuery.value !== undefined) {
641
+ if (entry.resource.role !== 'render-pass') {
642
+ return eventFailure(
643
+ activeQuery.value.eventIndex,
644
+ activeQuery.value.event,
645
+ 'lookup',
646
+ 'an active occlusion query belongs to a non-render pass',
647
+ );
648
+ }
649
+ const endedQuery = (entry.resource.value as RhiRenderPassEncoder).endOcclusionQuery();
650
+ if (!endedQuery.ok) {
651
+ return eventFailure(
652
+ activeQuery.value.eventIndex,
653
+ activeQuery.value.event,
654
+ 'encode',
655
+ endedQuery.error,
656
+ );
657
+ }
658
+ }
659
+ (entry.resource.value as RhiRenderPassEncoder | RhiComputePassEncoder).end();
660
+ } catch (cause) {
661
+ return eventFailure(pass.endEventIndex ?? selectedEventIndex, begin, 'encode', cause);
662
+ }
663
+ context.table.delete(begin.passHandleId);
664
+ return ok(undefined);
665
+ }
666
+
667
+ async function replayCommandClosure(
668
+ context: ReplayExecutionContext,
669
+ cmdHandleId: string,
670
+ start: number,
671
+ end: number,
672
+ ): Promise<Result<void, RhiDebugError>> {
673
+ for (let eventIndex = start; eventIndex < end; eventIndex++) {
674
+ const event = context.tape.events[eventIndex];
675
+ if (event === undefined || !isCommandClosureEvent(event)) continue;
676
+ if (event.cmdHandleId !== cmdHandleId) continue;
677
+ const result = await executeEvent(context, event, eventIndex);
678
+ if (!result.ok) return result;
679
+ }
680
+ return ok(undefined);
681
+ }
682
+
683
+ type ActiveOcclusionQuery = Extract<RhiCallEvent, { kind: 'beginOcclusionQuery' }>;
684
+
685
+ function activeOcclusionQuery(
686
+ events: readonly RhiCallEvent[],
687
+ passHandleId: string | undefined,
688
+ beginEventIndex: number,
689
+ selectedEventIndex: number,
690
+ ): Result<
691
+ { readonly eventIndex: number; readonly event: ActiveOcclusionQuery } | undefined,
692
+ RhiDebugError
693
+ > {
694
+ if (passHandleId === undefined) return ok(undefined);
695
+ let active: { readonly eventIndex: number; readonly event: ActiveOcclusionQuery } | undefined;
696
+ for (let eventIndex = beginEventIndex + 1; eventIndex <= selectedEventIndex; eventIndex++) {
697
+ const event = events[eventIndex];
698
+ if (event === undefined || !('passHandleId' in event) || event.passHandleId !== passHandleId)
699
+ continue;
700
+ if (event.kind === 'beginOcclusionQuery') {
701
+ if (active !== undefined) {
702
+ return eventFailure(
703
+ eventIndex,
704
+ event,
705
+ 'encode',
706
+ 'an occlusion query is already active for this render pass',
707
+ );
708
+ }
709
+ active = { eventIndex, event };
710
+ } else if (event.kind === 'endOcclusionQuery') {
711
+ if (active === undefined) {
712
+ return eventFailure(
713
+ eventIndex,
714
+ event,
715
+ 'encode',
716
+ 'an occlusion query ended without a matching begin',
717
+ );
718
+ }
719
+ active = undefined;
720
+ }
721
+ }
722
+ return ok(active);
723
+ }
724
+
725
+ function isCommandClosureEvent(event: RhiCallEvent): event is Extract<
726
+ RhiCallEvent,
727
+ {
728
+ kind:
729
+ | 'resolveQuerySet'
730
+ | 'copyBufferToBuffer'
731
+ | 'copyBufferToTexture'
732
+ | 'copyTextureToBuffer'
733
+ | 'copyTextureToTexture'
734
+ | 'clearBuffer';
735
+ }
736
+ > {
737
+ switch (event.kind) {
738
+ case 'resolveQuerySet':
739
+ case 'copyBufferToBuffer':
740
+ case 'copyBufferToTexture':
741
+ case 'copyTextureToBuffer':
742
+ case 'copyTextureToTexture':
743
+ case 'clearBuffer':
744
+ return true;
745
+ default:
746
+ return false;
747
+ }
748
+ }
749
+
541
750
  function findNextEvent(
542
751
  events: readonly RhiCallEvent[],
543
752
  start: number,
@@ -578,9 +787,14 @@ async function readWorkAttachment(
578
787
  }),
579
788
  );
580
789
  }
581
- const viewId = begin.colorAttachmentViewHandleIds.find(
582
- (candidate): candidate is string => candidate !== undefined,
790
+ const attachmentIndex = begin.colorAttachmentViewHandleIds.findIndex(
791
+ (candidate) => candidate !== undefined,
583
792
  );
793
+ // finalizeWorkPass has ended the pass and resolved the selected work.
794
+ // Keep the resolve target paired with its color slot, including sparse MRTs.
795
+ const viewId =
796
+ begin.colorAttachmentResolveTargetHandleIds?.[attachmentIndex] ??
797
+ begin.colorAttachmentViewHandleIds[attachmentIndex];
584
798
  if (viewId === undefined) {
585
799
  return err(
586
800
  createRhiDebugError('readback-unsupported', {