@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
@@ -0,0 +1,553 @@
1
+ /// <reference types="@webgpu/types" />
2
+
3
+ import type { Buffer, MappedBuffer, RhiDevice } from '@forgeax/engine-rhi';
4
+ import { buildFrameModel } from '../frame-model';
5
+ import { decodeTape } from '../protocol/codec';
6
+ import type { Tape } from '../protocol/types';
7
+ import type { CreateShaderModuleFn, EncodedTape } from '../recorder';
8
+ import { openReplay } from '../replay/session';
9
+
10
+ export type QuerySetFixtureRunner = 'browser' | 'dawn';
11
+
12
+ export interface QuerySetFixtureHost {
13
+ readonly runner: QuerySetFixtureRunner;
14
+ readonly device: RhiDevice;
15
+ readonly createShaderModule: CreateShaderModuleFn;
16
+ readonly replayCreateShaderModule: CreateShaderModuleFn;
17
+ readonly finishCapture: () => Promise<EncodedTape>;
18
+ readonly createFreshDevice: () => Promise<RhiDevice>;
19
+ }
20
+
21
+ export interface QuerySetErrorReceipt {
22
+ readonly operation: string;
23
+ readonly code: string;
24
+ readonly expected: string;
25
+ readonly hint: string;
26
+ readonly detail: unknown;
27
+ }
28
+
29
+ export interface QuerySetReplayFixtureEvidence {
30
+ readonly runner: QuerySetFixtureRunner;
31
+ readonly status: 'available' | 'unavailable' | 'failed';
32
+ readonly tapeFormatVersion: number | null;
33
+ readonly querySetHandleId: string | null;
34
+ readonly resolveDestinationHandleId: string | null;
35
+ readonly resolveDestinationOffset: number | null;
36
+ readonly originalQueryValues: readonly string[];
37
+ readonly originalResultHalfWords: readonly number[];
38
+ readonly freshQueryValues: readonly string[];
39
+ readonly originalColorBytes: readonly number[];
40
+ readonly freshColorHalfWords: readonly number[];
41
+ readonly eventKinds: readonly string[];
42
+ readonly errorReceipts: readonly QuerySetErrorReceipt[];
43
+ readonly deviceLost: { readonly reason: string; readonly message: string } | null;
44
+ }
45
+
46
+ const COLOR_WIDTH = 32;
47
+ const COLOR_HEIGHT = 32;
48
+ const QUERY_RESOLVE_OFFSET = 256;
49
+ const QUERY_BYTES = 8;
50
+ const QUERY_COUNT = 2;
51
+ const QUERY_RESOLVE_BUFFER_SIZE = QUERY_RESOLVE_OFFSET + QUERY_BYTES * QUERY_COUNT;
52
+ const READBACK_SIZE = 256;
53
+ const BUFFER_USAGE_MAP_READ_COPY_DST = 0x9;
54
+ const BUFFER_USAGE_QUERY_RESOLVE_COPY_SRC_STORAGE = 0x284;
55
+ const BUFFER_USAGE_COPY_DST_STORAGE = 0x88;
56
+ const TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC = 0x11;
57
+ const GPU_MAP_MODE_READ = 0x1;
58
+
59
+ const DRAW_VERTEX_SHADER = /* wgsl */ `
60
+ @vertex
61
+ fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
62
+ var positions = array<vec2<f32>, 3>(
63
+ vec2<f32>(-0.8, -0.8),
64
+ vec2<f32>(0.8, -0.8),
65
+ vec2<f32>(0.0, 0.8),
66
+ );
67
+ return vec4<f32>(positions[vertexIndex], 0.0, 1.0);
68
+ }`;
69
+
70
+ const DRAW_FRAGMENT_SHADER = /* wgsl */ `
71
+ @fragment
72
+ fn main() -> @location(0) vec4<f32> {
73
+ return vec4<f32>(0.0, 0.8, 0.2, 1.0);
74
+ }`;
75
+
76
+ const QUERY_RESULT_FRAGMENT_SHADER = /* wgsl */ `
77
+ @group(0) @binding(0) var<storage, read> queryValues: array<u32>;
78
+
79
+ @fragment
80
+ fn main(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
81
+ let queryIndex = min(u32(position.x), 1u);
82
+ let covered = select(0.0, 1.0, queryValues[queryIndex * 2u] > 0u);
83
+ return vec4<f32>(covered, 0.0, 0.0, 1.0);
84
+ }`;
85
+
86
+ const RESULT_VERTEX_SHADER = /* wgsl */ `
87
+ @vertex
88
+ fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
89
+ var positions = array<vec2<f32>, 3>(
90
+ vec2<f32>(-1.0, -1.0),
91
+ vec2<f32>(3.0, -1.0),
92
+ vec2<f32>(-1.0, 3.0),
93
+ );
94
+ return vec4<f32>(positions[vertexIndex], 0.0, 1.0);
95
+ }`;
96
+
97
+ class FixtureFailure extends Error {
98
+ readonly receipt: QuerySetErrorReceipt;
99
+
100
+ constructor(receipt: QuerySetErrorReceipt) {
101
+ super(`${receipt.operation}: ${receipt.code}`);
102
+ this.name = 'FixtureFailure';
103
+ this.receipt = receipt;
104
+ }
105
+ }
106
+
107
+ function asReceipt(operation: string, error: unknown): QuerySetErrorReceipt {
108
+ const candidate = error as {
109
+ readonly code?: unknown;
110
+ readonly expected?: unknown;
111
+ readonly hint?: unknown;
112
+ readonly detail?: unknown;
113
+ };
114
+ return {
115
+ operation,
116
+ code: typeof candidate.code === 'string' ? candidate.code : 'unknown-error',
117
+ expected:
118
+ typeof candidate.expected === 'string' ? candidate.expected : 'successful RHI operation',
119
+ hint: typeof candidate.hint === 'string' ? candidate.hint : String(error),
120
+ detail: candidate.detail ?? null,
121
+ };
122
+ }
123
+
124
+ function unwrap<T>(
125
+ operation: string,
126
+ result:
127
+ | { readonly ok: true; readonly value: T }
128
+ | { readonly ok: false; readonly error: unknown },
129
+ ): T {
130
+ if (!result.ok) throw new FixtureFailure(asReceipt(operation, result.error));
131
+ return result.value;
132
+ }
133
+
134
+ function call(operation: string, action: () => void): void {
135
+ try {
136
+ action();
137
+ } catch (error) {
138
+ throw new FixtureFailure(asReceipt(operation, error));
139
+ }
140
+ }
141
+
142
+ async function shader(
143
+ createShaderModule: CreateShaderModuleFn,
144
+ device: RhiDevice,
145
+ operation: string,
146
+ code: string,
147
+ ) {
148
+ return unwrap(operation, await createShaderModule(device, { code }));
149
+ }
150
+
151
+ function readBytes(mapped: MappedBuffer, operation: string, size: number): Uint8Array {
152
+ const range = unwrap(`${operation}.getMappedRange`, mapped.getMappedRange(0, size));
153
+ return new Uint8Array(range.slice(0));
154
+ }
155
+
156
+ async function readMappedBuffer(buffer: Buffer, operation: string, size: number) {
157
+ const mapped = unwrap(`${operation}.mapAsync`, await buffer.mapAsync(GPU_MAP_MODE_READ, 0, size));
158
+ const bytes = readBytes(mapped, operation, size);
159
+ call(`${operation}.unmap`, () => mapped.unmap());
160
+ return bytes;
161
+ }
162
+
163
+ function u64Values(bytes: Uint8Array): readonly string[] {
164
+ const values: string[] = [];
165
+ for (let offset = 0; offset + QUERY_BYTES <= bytes.byteLength; offset += QUERY_BYTES) {
166
+ const low = new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true);
167
+ const high = new DataView(bytes.buffer, bytes.byteOffset + offset + 4, 4).getUint32(0, true);
168
+ values.push(((BigInt(high) << 32n) | BigInt(low)).toString());
169
+ }
170
+ return values;
171
+ }
172
+
173
+ function tapeEventKinds(tape: Tape): readonly string[] {
174
+ return [
175
+ ...tape.bootstrap.map((resource) => {
176
+ const create = resource.create as { readonly kind?: unknown };
177
+ return typeof create.kind === 'string' ? create.kind : 'bootstrap';
178
+ }),
179
+ ...tape.events.map((event) => event.kind),
180
+ ];
181
+ }
182
+
183
+ function eventHandle(tape: Tape, kind: string, field: string): string | null {
184
+ const all = [
185
+ ...tape.bootstrap.map((resource) => resource.create as Record<string, unknown>),
186
+ ...tape.events,
187
+ ];
188
+ const event = all.find((candidate) => candidate.kind === kind) as
189
+ | Record<string, unknown>
190
+ | undefined;
191
+ return typeof event?.[field] === 'string' ? event[field] : null;
192
+ }
193
+
194
+ function eventNumber(tape: Tape, kind: string, field: string): number | null {
195
+ const event = tape.events.find((candidate) => candidate.kind === kind) as
196
+ | Record<string, unknown>
197
+ | undefined;
198
+ return typeof event?.[field] === 'number' ? event[field] : null;
199
+ }
200
+
201
+ function freshHalfWords(bytes: Uint8Array): readonly number[] {
202
+ const words = new Uint16Array(bytes.buffer, bytes.byteOffset, Math.min(8, bytes.byteLength / 2));
203
+ return Array.from(words);
204
+ }
205
+
206
+ async function executeFrame(
207
+ device: RhiDevice,
208
+ createShaderModule: CreateShaderModuleFn,
209
+ ): Promise<{
210
+ readonly queryValues: readonly string[];
211
+ readonly colorBytes: Uint8Array;
212
+ readonly resultHalfWords: readonly number[];
213
+ }> {
214
+ const vertex = await shader(
215
+ createShaderModule,
216
+ device,
217
+ 'create.vertexShader',
218
+ DRAW_VERTEX_SHADER,
219
+ );
220
+ const fragment = await shader(
221
+ createShaderModule,
222
+ device,
223
+ 'create.fragmentShader',
224
+ DRAW_FRAGMENT_SHADER,
225
+ );
226
+ const queryFragment = await shader(
227
+ createShaderModule,
228
+ device,
229
+ 'create.queryResultFragmentShader',
230
+ QUERY_RESULT_FRAGMENT_SHADER,
231
+ );
232
+ const resultVertex = await shader(
233
+ createShaderModule,
234
+ device,
235
+ 'create.resultVertexShader',
236
+ RESULT_VERTEX_SHADER,
237
+ );
238
+ const drawLayout = unwrap(
239
+ 'create.drawBindGroupLayout',
240
+ device.createBindGroupLayout({ entries: [] }),
241
+ );
242
+ const queryLayout = unwrap(
243
+ 'create.queryBindGroupLayout',
244
+ device.createBindGroupLayout({
245
+ entries: [{ binding: 0, visibility: 0x2, buffer: { type: 'read-only-storage' } }],
246
+ }),
247
+ );
248
+ const drawPipelineLayout = unwrap(
249
+ 'create.drawPipelineLayout',
250
+ device.createPipelineLayout({ bindGroupLayouts: [drawLayout] }),
251
+ );
252
+ const queryPipelineLayout = unwrap(
253
+ 'create.queryPipelineLayout',
254
+ device.createPipelineLayout({ bindGroupLayouts: [queryLayout] }),
255
+ );
256
+ const drawPipeline = unwrap(
257
+ 'create.drawPipeline',
258
+ device.createRenderPipeline({
259
+ layout: drawPipelineLayout,
260
+ vertex: { module: vertex, entryPoint: 'main', buffers: [] },
261
+ fragment: { module: fragment, entryPoint: 'main', targets: [{ format: 'rgba8unorm' }] },
262
+ primitive: { topology: 'triangle-list' },
263
+ } as never),
264
+ );
265
+ const queryPipeline = unwrap(
266
+ 'create.queryPipeline',
267
+ device.createRenderPipeline({
268
+ layout: queryPipelineLayout,
269
+ vertex: { module: resultVertex, entryPoint: 'main', buffers: [] },
270
+ fragment: { module: queryFragment, entryPoint: 'main', targets: [{ format: 'rgba16float' }] },
271
+ primitive: { topology: 'triangle-list' },
272
+ } as never),
273
+ );
274
+ const colorTexture = unwrap(
275
+ 'create.colorTexture',
276
+ device.createTexture({
277
+ size: { width: COLOR_WIDTH, height: COLOR_HEIGHT, depthOrArrayLayers: 1 },
278
+ format: 'rgba8unorm',
279
+ usage: TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC,
280
+ }),
281
+ );
282
+ const colorView = unwrap('create.colorView', device.createTextureView(colorTexture, {}));
283
+ const queryTexture = unwrap(
284
+ 'create.queryTexture',
285
+ device.createTexture({
286
+ size: { width: QUERY_COUNT, height: 1, depthOrArrayLayers: 1 },
287
+ format: 'rgba16float',
288
+ usage: TEXTURE_USAGE_RENDER_ATTACHMENT_COPY_SRC,
289
+ }),
290
+ );
291
+ const queryView = unwrap('create.queryView', device.createTextureView(queryTexture, {}));
292
+ const querySet = unwrap(
293
+ 'create.querySet',
294
+ device.createQuerySet({ type: 'occlusion', count: QUERY_COUNT }),
295
+ );
296
+ const queryResolveBuffer = unwrap(
297
+ 'create.queryResolveBuffer',
298
+ device.createBuffer({
299
+ size: QUERY_RESOLVE_BUFFER_SIZE,
300
+ usage: BUFFER_USAGE_QUERY_RESOLVE_COPY_SRC_STORAGE,
301
+ mappedAtCreation: false,
302
+ }),
303
+ );
304
+ const queryStagingBuffer = unwrap(
305
+ 'create.queryStagingBuffer',
306
+ device.createBuffer({
307
+ size: READBACK_SIZE,
308
+ usage: BUFFER_USAGE_MAP_READ_COPY_DST,
309
+ mappedAtCreation: false,
310
+ }),
311
+ );
312
+ const colorReadbackBuffer = unwrap(
313
+ 'create.colorReadbackBuffer',
314
+ device.createBuffer({
315
+ size: READBACK_SIZE * COLOR_HEIGHT,
316
+ usage: BUFFER_USAGE_MAP_READ_COPY_DST,
317
+ mappedAtCreation: false,
318
+ }),
319
+ );
320
+ const queryGpuResultBuffer = unwrap(
321
+ 'create.queryGpuResultBuffer',
322
+ device.createBuffer({
323
+ size: QUERY_BYTES * QUERY_COUNT,
324
+ usage: BUFFER_USAGE_COPY_DST_STORAGE,
325
+ mappedAtCreation: false,
326
+ }),
327
+ );
328
+ const resultReadbackBuffer = unwrap(
329
+ 'create.resultReadbackBuffer',
330
+ device.createBuffer({
331
+ size: READBACK_SIZE,
332
+ usage: BUFFER_USAGE_MAP_READ_COPY_DST,
333
+ mappedAtCreation: false,
334
+ }),
335
+ );
336
+ const queryBindGroup = unwrap(
337
+ 'create.queryBindGroup',
338
+ device.createBindGroup({
339
+ layout: queryLayout,
340
+ entries: [
341
+ {
342
+ binding: 0,
343
+ resource: {
344
+ kind: 'buffer',
345
+ value: {
346
+ buffer: queryGpuResultBuffer,
347
+ size: QUERY_BYTES * QUERY_COUNT,
348
+ },
349
+ },
350
+ },
351
+ ],
352
+ } as never),
353
+ );
354
+ const encoder = unwrap('create.commandEncoder', device.createCommandEncoder({}));
355
+ const pass = encoder.beginRenderPass({
356
+ colorAttachments: [
357
+ {
358
+ view: colorView,
359
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
360
+ loadOp: 'clear',
361
+ storeOp: 'store',
362
+ },
363
+ ],
364
+ occlusionQuerySet: querySet,
365
+ } as never);
366
+ call('pass.setPipeline', () => pass.setPipeline(drawPipeline));
367
+ call('pass.beginOcclusionQuery.0', () =>
368
+ unwrap('pass.beginOcclusionQuery.0', pass.beginOcclusionQuery(0)),
369
+ );
370
+ call('pass.draw.0', () => pass.draw(3, 1, 0, 0));
371
+ call('pass.endOcclusionQuery.0', () =>
372
+ unwrap('pass.endOcclusionQuery.0', pass.endOcclusionQuery()),
373
+ );
374
+ call('pass.setScissorRect.zero', () => pass.setScissorRect(0, 0, 0, 0));
375
+ call('pass.beginOcclusionQuery.1', () =>
376
+ unwrap('pass.beginOcclusionQuery.1', pass.beginOcclusionQuery(1)),
377
+ );
378
+ call('pass.draw.1', () => pass.draw(3, 1, 0, 0));
379
+ call('pass.endOcclusionQuery.1', () =>
380
+ unwrap('pass.endOcclusionQuery.1', pass.endOcclusionQuery()),
381
+ );
382
+ call('pass.end', () => pass.end());
383
+ call('encoder.resolveQuerySet', () =>
384
+ unwrap(
385
+ 'encoder.resolveQuerySet',
386
+ encoder.resolveQuerySet(querySet, 0, QUERY_COUNT, queryResolveBuffer, QUERY_RESOLVE_OFFSET),
387
+ ),
388
+ );
389
+ call('encoder.copyQueryResolveToStaging', () =>
390
+ encoder.copyBufferToBuffer(
391
+ queryResolveBuffer,
392
+ QUERY_RESOLVE_OFFSET,
393
+ queryStagingBuffer,
394
+ 0,
395
+ QUERY_BYTES * QUERY_COUNT,
396
+ ),
397
+ );
398
+ call('encoder.copyColorToReadback', () =>
399
+ encoder.copyTextureToBuffer(
400
+ { texture: colorTexture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } } as never,
401
+ {
402
+ buffer: colorReadbackBuffer,
403
+ offset: 0,
404
+ bytesPerRow: READBACK_SIZE,
405
+ rowsPerImage: COLOR_HEIGHT,
406
+ } as never,
407
+ { width: COLOR_WIDTH, height: COLOR_HEIGHT, depthOrArrayLayers: 1 },
408
+ ),
409
+ );
410
+ const firstCommand = unwrap('encoder.finish.first', encoder.finish());
411
+ unwrap('queue.submit.first', device.queue.submit([firstCommand]));
412
+ await device.queue.onSubmittedWorkDone();
413
+
414
+ const resultEncoder = unwrap('create.resultCommandEncoder', device.createCommandEncoder({}));
415
+ call('resultEncoder.copyQueryResolveToGpuResult', () =>
416
+ resultEncoder.copyBufferToBuffer(
417
+ queryResolveBuffer,
418
+ QUERY_RESOLVE_OFFSET,
419
+ queryGpuResultBuffer,
420
+ 0,
421
+ QUERY_BYTES * QUERY_COUNT,
422
+ ),
423
+ );
424
+ const resultPass = resultEncoder.beginRenderPass({
425
+ colorAttachments: [
426
+ {
427
+ view: queryView,
428
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
429
+ loadOp: 'clear',
430
+ storeOp: 'store',
431
+ },
432
+ ],
433
+ } as never);
434
+ call('resultPass.setPipeline', () => resultPass.setPipeline(queryPipeline));
435
+ call('resultPass.setBindGroup', () => resultPass.setBindGroup(0, queryBindGroup, []));
436
+ call('resultPass.draw', () => resultPass.draw(3, 1, 0, 0));
437
+ call('resultPass.end', () => resultPass.end());
438
+ call('resultEncoder.copyResultToReadback', () =>
439
+ resultEncoder.copyTextureToBuffer(
440
+ { texture: queryTexture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } } as never,
441
+ {
442
+ buffer: resultReadbackBuffer,
443
+ offset: 0,
444
+ bytesPerRow: READBACK_SIZE,
445
+ rowsPerImage: 1,
446
+ } as never,
447
+ { width: QUERY_COUNT, height: 1, depthOrArrayLayers: 1 },
448
+ ),
449
+ );
450
+ const resultCommand = unwrap('resultEncoder.finish', resultEncoder.finish());
451
+ unwrap('queue.submit.result', device.queue.submit([resultCommand]));
452
+ await device.queue.onSubmittedWorkDone();
453
+ const queryBytes = await readMappedBuffer(
454
+ queryStagingBuffer,
455
+ 'queryStaging',
456
+ QUERY_BYTES * QUERY_COUNT,
457
+ );
458
+ const colorBytes = await readMappedBuffer(
459
+ colorReadbackBuffer,
460
+ 'colorReadback',
461
+ READBACK_SIZE * COLOR_HEIGHT,
462
+ );
463
+ const resultBytes = await readMappedBuffer(resultReadbackBuffer, 'resultReadback', READBACK_SIZE);
464
+ for (const [operation, resource, destroy] of [
465
+ ['destroy.querySet', querySet, device.destroyQuerySet.bind(device)],
466
+ ['destroy.colorTexture', colorTexture, device.destroyTexture.bind(device)],
467
+ ['destroy.queryTexture', queryTexture, device.destroyTexture.bind(device)],
468
+ ['destroy.queryResolveBuffer', queryResolveBuffer, device.destroyBuffer.bind(device)],
469
+ ['destroy.queryStagingBuffer', queryStagingBuffer, device.destroyBuffer.bind(device)],
470
+ ['destroy.queryGpuResultBuffer', queryGpuResultBuffer, device.destroyBuffer.bind(device)],
471
+ ['destroy.colorReadbackBuffer', colorReadbackBuffer, device.destroyBuffer.bind(device)],
472
+ ['destroy.resultReadbackBuffer', resultReadbackBuffer, device.destroyBuffer.bind(device)],
473
+ ] as const) {
474
+ call(operation, () => unwrap(operation, destroy(resource as never)));
475
+ }
476
+ return {
477
+ queryValues: u64Values(queryBytes),
478
+ colorBytes,
479
+ resultHalfWords: freshHalfWords(resultBytes),
480
+ };
481
+ }
482
+
483
+ export async function runQuerySetReplayFixture(
484
+ host: QuerySetFixtureHost,
485
+ ): Promise<QuerySetReplayFixtureEvidence> {
486
+ let deviceLost: { reason: string; message: string } | null = null;
487
+ void host.device.lost.then((loss) => {
488
+ deviceLost = loss;
489
+ });
490
+ try {
491
+ const original = await executeFrame(host.device, host.createShaderModule);
492
+ const encoded = await host.finishCapture();
493
+ const decoded = decodeTape(encoded.bytes);
494
+ if (!decoded.ok) throw new FixtureFailure(asReceipt('decodeTape', decoded.error));
495
+ const tape = decoded.value;
496
+ const freshDevice = await host.createFreshDevice();
497
+ const replay = unwrap(
498
+ 'openReplay',
499
+ await openReplay(tape, {
500
+ device: freshDevice,
501
+ createShaderModule: host.replayCreateShaderModule,
502
+ }),
503
+ );
504
+ const model = buildFrameModel(tape);
505
+ const queryWork = model.works.at(-1);
506
+ if (queryWork === undefined) {
507
+ throw new FixtureFailure(
508
+ asReceipt('buildFrameModel', new Error('query result draw work is missing')),
509
+ );
510
+ }
511
+ const inspection = unwrap(
512
+ 'inspectWork.queryResult',
513
+ await replay.inspectWork(queryWork.workIndex, ['pixels']),
514
+ );
515
+ const freshBytes = inspection.attachment?.bytes ?? new Uint8Array();
516
+ const freshWords = freshHalfWords(freshBytes);
517
+ await replay.dispose();
518
+ return {
519
+ runner: host.runner,
520
+ status: 'available',
521
+ tapeFormatVersion: tape.header.formatVersion,
522
+ querySetHandleId: eventHandle(tape, 'createQuerySet', 'handleId'),
523
+ resolveDestinationHandleId: eventHandle(tape, 'resolveQuerySet', 'destinationHandleId'),
524
+ resolveDestinationOffset: eventNumber(tape, 'resolveQuerySet', 'destinationOffset'),
525
+ originalQueryValues: original.queryValues,
526
+ originalResultHalfWords: original.resultHalfWords,
527
+ freshQueryValues: [freshWords[0] === 15360 ? '1' : '0', freshWords[4] === 15360 ? '1' : '0'],
528
+ originalColorBytes: Array.from(original.colorBytes),
529
+ freshColorHalfWords: freshWords,
530
+ eventKinds: tapeEventKinds(tape),
531
+ errorReceipts: [],
532
+ deviceLost,
533
+ };
534
+ } catch (error) {
535
+ const receipt = error instanceof FixtureFailure ? error.receipt : asReceipt('fixture', error);
536
+ return {
537
+ runner: host.runner,
538
+ status: 'unavailable',
539
+ tapeFormatVersion: null,
540
+ querySetHandleId: null,
541
+ resolveDestinationHandleId: null,
542
+ resolveDestinationOffset: null,
543
+ originalQueryValues: [],
544
+ originalResultHalfWords: [],
545
+ freshQueryValues: [],
546
+ originalColorBytes: [],
547
+ freshColorHalfWords: [],
548
+ eventKinds: [],
549
+ errorReceipts: [receipt],
550
+ deviceLost,
551
+ };
552
+ }
553
+ }
@@ -136,7 +136,7 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
136
136
  });
137
137
  });
138
138
 
139
- it('reads a known buffer range through the replay owner', async () => {
139
+ it.each([0x0c, 0x84, 0x184])('reads seeded buffer bytes for captured usage %i', async (usage) => {
140
140
  const pack = await loadDawn();
141
141
  const adapter = await pack.rhi.requestAdapter();
142
142
  expect(adapter.ok).toBe(true);
@@ -154,7 +154,7 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
154
154
  create: {
155
155
  kind: 'createBuffer',
156
156
  handleId: 'buffer:known',
157
- desc: { size: 8, usage: 0x0c },
157
+ desc: { size: 8, usage },
158
158
  },
159
159
  initialData: [{ hash: 'buffer-known', byteOffset: 0, byteLength: 8 }],
160
160
  },
@@ -231,6 +231,13 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
231
231
  expect(arrayResult.value.width).toBe(1);
232
232
  expect(arrayResult.value.height).toBe(1);
233
233
  expect([...arrayResult.value.bytes]).toEqual([96, 97, 98, 99]);
234
+ // Omitting a request means local mip/layer zero, not texture mip/layer zero.
235
+ const defaultResult = await replay.value.readResource('view:array');
236
+ expect(defaultResult.ok).toBe(true);
237
+ if (!defaultResult.ok) throw new Error(defaultResult.error.hint);
238
+ expect(defaultResult.value.width).toBe(1);
239
+ expect(defaultResult.value.height).toBe(1);
240
+ expect([...defaultResult.value.bytes]).toEqual([...arrayResult.value.bytes]);
234
241
  const cubeResult = await replay.value.readResource('view:cube', {
235
242
  mipLevel: 0,
236
243
  arrayLayer: 5,
@@ -0,0 +1,67 @@
1
+ import type {
2
+ CanvasConfiguration,
3
+ RhiCanvasContext,
4
+ RhiDevice,
5
+ RhiInstance,
6
+ } from '@forgeax/engine-rhi';
7
+ import { ok } from '@forgeax/engine-types';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import { createRecorderProxy, type RecordableBackend } from '../recorder/proxy';
10
+
11
+ function backend(acquireCanvasContext: ReturnType<typeof vi.fn>): RecordableBackend {
12
+ return {
13
+ rhi: {
14
+ requestAdapter: vi.fn(),
15
+ acquireCanvasContext,
16
+ } as unknown as RhiInstance & RecordableBackend['rhi'],
17
+ createShaderModule: vi.fn(),
18
+ };
19
+ }
20
+
21
+ describe('recorder canvas context device identity', () => {
22
+ it('unwraps the recorder device before forwarding canvas configure', () => {
23
+ const configure = vi.fn(() => ok(undefined));
24
+ const context = {
25
+ configure,
26
+ unconfigure: vi.fn(),
27
+ getConfiguration: vi.fn(),
28
+ getCurrentTexture: vi.fn(),
29
+ } as unknown as RhiCanvasContext;
30
+ const acquireCanvasContext = vi.fn(() => ok(context));
31
+ const originalDevice = { caps: {} } as unknown as RhiDevice;
32
+ const recorderDevice = { _realDevice: originalDevice } as unknown as RhiDevice;
33
+ const proxy = createRecorderProxy(backend(acquireCanvasContext));
34
+
35
+ const acquired = proxy.backend.rhi.acquireCanvasContext?.({} as HTMLCanvasElement);
36
+ expect(acquired?.ok).toBe(true);
37
+ if (!acquired?.ok) return;
38
+
39
+ const configuration = {
40
+ device: recorderDevice,
41
+ format: 'bgra8unorm',
42
+ } as CanvasConfiguration;
43
+ expect(acquired.value.configure(configuration)).toEqual(ok(undefined));
44
+ expect(acquireCanvasContext).toHaveBeenCalledOnce();
45
+ expect(configure).toHaveBeenCalledWith({
46
+ ...configuration,
47
+ device: originalDevice,
48
+ });
49
+ });
50
+
51
+ it('preserves a native device when no recorder identity is present', () => {
52
+ const configure = vi.fn(() => ok(undefined));
53
+ const context = {
54
+ configure,
55
+ unconfigure: vi.fn(),
56
+ getConfiguration: vi.fn(),
57
+ getCurrentTexture: vi.fn(),
58
+ } as unknown as RhiCanvasContext;
59
+ const proxy = createRecorderProxy(backend(vi.fn(() => ok(context))));
60
+ const nativeDevice = { caps: {} } as unknown as RhiDevice;
61
+ const acquired = proxy.backend.rhi.acquireCanvasContext?.({} as HTMLCanvasElement);
62
+ if (!acquired?.ok) throw new Error('canvas context acquisition failed');
63
+
64
+ acquired.value.configure({ device: nativeDevice, format: 'bgra8unorm' } as CanvasConfiguration);
65
+ expect(configure).toHaveBeenCalledWith({ device: nativeDevice, format: 'bgra8unorm' });
66
+ });
67
+ });