@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,117 +2,415 @@
2
2
 
3
3
  /// <reference types="@webgpu/types" />
4
4
 
5
- import { isWorkEvent } from './protocol/event-semantics';
6
- import type { TapePassEntry, TapeResourceEntry } from './protocol/tape-index';
5
+ import {
6
+ EVENT_SEMANTICS,
7
+ type EventCategory,
8
+ isWorkEvent,
9
+ resourceKindForEvent,
10
+ } from './protocol/event-semantics';
11
+ import type { TapeWorkEntry } from './protocol/tape-index';
7
12
  import { buildTapeIndex } from './protocol/tape-index';
8
13
  import type { Tape as V7Tape } from './protocol/types';
9
14
  import { computeTextureLayout } from './texel-layout';
10
- import type {
11
- CreateDescriptor,
12
- DrawPipelineState,
13
- HandleId,
14
- InspectBindingEntry,
15
- InspectDrawCall,
16
- RhiCallEvent,
17
- } from './types';
18
-
19
- export type { CreateDescriptor, DrawPipelineState } from './types';
20
-
21
- export interface PassDrawItem {
22
- readonly workIndex: number;
23
- readonly eventKind:
24
- | 'draw'
25
- | 'drawIndexed'
26
- | 'dispatchWorkgroups'
27
- | 'dispatchWorkgroupsIndirect'
28
- | 'drawIndirect'
29
- | 'drawIndexedIndirect';
15
+ import type { HandleId, RhiCallEvent } from './types';
16
+
17
+ type JsonPrimitive = string | number | boolean | null;
18
+ export type JsonValue =
19
+ | JsonPrimitive
20
+ | readonly JsonValue[]
21
+ | { readonly [key: string]: JsonValue };
22
+
23
+ export interface CommandEntry {
24
+ readonly eventIndex: number;
25
+ readonly passIndex: number;
26
+ readonly kind: string;
27
+ readonly category: EventCategory;
28
+ readonly isWork: boolean;
29
+ readonly params: JsonValue;
30
+ readonly group: readonly string[];
31
+ readonly marker?: string;
30
32
  }
31
33
 
32
- export interface PassNode {
34
+ export interface FramePass {
35
+ readonly passIndex: number;
33
36
  readonly kind: 'render' | 'compute';
34
- readonly passIdx: number;
35
- readonly draws: readonly PassDrawItem[];
37
+ readonly beginEventIndex: number;
38
+ readonly endEventIndex: number | null;
39
+ readonly workIndices: readonly number[];
40
+ readonly commandIndices: readonly number[];
41
+ colorAttachmentViewHandleIds: readonly string[];
42
+ depthStencilViewHandleId: string | null;
36
43
  }
37
44
 
38
- export interface DrawEntry {
39
- readonly frameIdx: number;
40
- readonly passIdx: number;
41
- readonly bindings: readonly InspectBindingEntry[];
42
- readonly drawCall: InspectDrawCall;
43
- readonly colorAttachmentHandleId: string | undefined;
44
- readonly pipelineState: DrawPipelineState;
45
- readonly vertexBuffers: ReadonlyMap<
46
- number,
47
- { readonly handleId: HandleId; readonly offset: number; readonly size: number }
48
- >;
49
- readonly indexBuffer?: { handleId: HandleId; format: GPUIndexFormat; offset: number } | undefined;
50
- readonly depthStencil: DrawDepthStencil;
45
+ export interface ResourceConsumer {
46
+ readonly eventIndex: number;
47
+ readonly workIndex: number | null;
48
+ readonly access: 'read' | 'write';
51
49
  }
52
50
 
53
- export interface DrawDepthStencil {
54
- readonly depthStencilViewHandleId: HandleId | undefined;
55
- readonly depthStencilAttachment: GPURenderPassDepthStencilAttachment | undefined;
51
+ export interface ResourceEntry {
52
+ readonly resourceId: string;
53
+ readonly kind: string;
54
+ readonly origin: 'bootstrap' | 'frame';
55
+ readonly createEventIndex: number | null;
56
+ readonly destroyEventIndex: number | null;
57
+ readonly descriptor: JsonValue | null;
58
+ consumers: readonly ResourceConsumer[];
59
+ readonly lifecycle: {
60
+ readonly state: 'live' | 'destroyed' | 'unavailable';
61
+ readonly byteEstimate: ResourceByteEstimate | null;
62
+ };
56
63
  }
57
64
 
58
- export interface CommandEntry {
59
- readonly passIdx: number;
60
- readonly eventIdx: number;
61
- readonly isDraw: boolean;
62
- readonly kind: string;
63
- readonly groupLabel: string | undefined;
64
- readonly markerLabel: string | undefined;
65
+ export interface WorkBinding {
66
+ readonly groupIndex: number;
67
+ readonly binding: number;
68
+ readonly bindGroupId: string;
69
+ readonly resourceId: string | null;
70
+ readonly resourceKind: string | null;
71
+ readonly bufferOffset: number | null;
72
+ readonly bufferSize: number | null;
65
73
  }
66
74
 
67
- export interface FrameModelMeta {
68
- readonly totalDraws: number;
69
- readonly totalPasses: number;
70
- readonly hasCompute: boolean;
75
+ export interface WorkPipeline {
76
+ readonly status: 'available' | 'unavailable';
77
+ readonly pipelineHandleId?: string;
78
+ readonly kind?: 'render' | 'compute';
79
+ readonly descriptor?: JsonValue;
80
+ readonly shaders: readonly {
81
+ readonly stage: 'vertex' | 'fragment' | 'compute';
82
+ readonly moduleHandleId: string;
83
+ readonly entryPoint: string | null;
84
+ readonly source: string | null;
85
+ }[];
86
+ readonly reason?: 'pipeline-not-bound' | 'descriptor-unavailable';
71
87
  }
72
88
 
73
- export interface WorkEntry {
74
- readonly workIndex: number;
75
- readonly eventIndex: number;
76
- readonly passIndex: number;
77
- readonly kind: string;
89
+ export interface WorkEntry extends TapeWorkEntry {
90
+ readonly commandIndex: number;
91
+ readonly drawCall: JsonValue;
92
+ readonly pipeline: WorkPipeline;
93
+ readonly bindings: readonly WorkBinding[];
94
+ readonly vertexBuffers: readonly {
95
+ readonly slot: number;
96
+ readonly bufferHandleId: string;
97
+ readonly offset: number;
98
+ readonly size: number | null;
99
+ }[];
100
+ readonly indexBuffer: {
101
+ readonly bufferHandleId: string;
102
+ readonly format: string;
103
+ readonly offset: number;
104
+ readonly size: number | null;
105
+ } | null;
106
+ readonly attachments: {
107
+ readonly colorViewHandleIds: readonly string[];
108
+ readonly depthStencilViewHandleId: string | null;
109
+ } | null;
78
110
  }
79
111
 
80
112
  export interface FrameModel {
81
- readonly tree: readonly PassNode[];
82
- readonly draws: readonly DrawEntry[];
83
- readonly meta: FrameModelMeta;
84
113
  readonly commands: readonly CommandEntry[];
85
- readonly resources: ReadonlyMap<HandleId, CreateDescriptor>;
114
+ readonly passes: readonly FramePass[];
115
+ readonly resources: readonly ResourceEntry[];
86
116
  readonly resourceLifecycle: ResourceLifecycleSummary;
87
117
  readonly works: readonly WorkEntry[];
88
- readonly passes: readonly TapePassEntry[];
89
- readonly resourceEntries: readonly TapeResourceEntry[];
118
+ }
119
+
120
+ function jsonValue(value: unknown): JsonValue {
121
+ if (
122
+ value === null ||
123
+ typeof value === 'string' ||
124
+ typeof value === 'number' ||
125
+ typeof value === 'boolean'
126
+ )
127
+ return value;
128
+ if (value === undefined) return null;
129
+ if (Array.isArray(value)) return value.map(jsonValue);
130
+ if (ArrayBuffer.isView(value))
131
+ return Array.from(value as unknown as ArrayLike<unknown>, jsonValue);
132
+ if (typeof value === 'object') {
133
+ const output: Record<string, JsonValue> = {};
134
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
135
+ if (child !== undefined) output[key] = jsonValue(child);
136
+ }
137
+ return output;
138
+ }
139
+ return String(value);
140
+ }
141
+
142
+ function eventDescriptor(event: RhiCallEvent): JsonValue {
143
+ return jsonValue(event);
144
+ }
145
+
146
+ function workPipeline(
147
+ pipelineId: string | undefined,
148
+ pipelineEvents: ReadonlyMap<string, RhiCallEvent>,
149
+ shaderEvents: ReadonlyMap<string, RhiCallEvent>,
150
+ ): WorkPipeline {
151
+ if (pipelineId === undefined)
152
+ return { status: 'unavailable', shaders: [], reason: 'pipeline-not-bound' };
153
+ const event = pipelineEvents.get(pipelineId);
154
+ if (event === undefined)
155
+ return { status: 'unavailable', shaders: [], reason: 'descriptor-unavailable' };
156
+ const shaders: WorkPipeline['shaders'][number][] = [];
157
+ const shaderRefs =
158
+ event.kind === 'createRenderPipeline'
159
+ ? [
160
+ ['vertex', event.vertexShaderModuleHandleId, event.desc.vertex?.entryPoint],
161
+ ['fragment', event.fragmentShaderModuleHandleId, event.desc.fragment?.entryPoint],
162
+ ]
163
+ : event.kind === 'createComputePipeline'
164
+ ? [['compute', event.computeShaderModuleHandleId, event.desc.compute.entryPoint]]
165
+ : [];
166
+ for (const [stage, moduleId, entryPoint] of shaderRefs) {
167
+ if (typeof moduleId !== 'string') continue;
168
+ const shader = shaderEvents.get(moduleId);
169
+ shaders.push({
170
+ stage: stage as 'vertex' | 'fragment' | 'compute',
171
+ moduleHandleId: moduleId,
172
+ entryPoint: typeof entryPoint === 'string' ? entryPoint : null,
173
+ source: shader?.kind === 'createShaderModule' ? shader.wgslCode : null,
174
+ });
175
+ }
176
+ return {
177
+ status: 'available',
178
+ pipelineHandleId: pipelineId,
179
+ kind: event.kind === 'createRenderPipeline' ? 'render' : 'compute',
180
+ descriptor: eventDescriptor(event),
181
+ shaders,
182
+ };
90
183
  }
91
184
 
92
185
  export function buildFrameModel(tape: V7Tape): FrameModel {
93
186
  const index = buildTapeIndex(tape);
94
- const works = index.works;
95
- return {
96
- tree: [],
97
- draws: [],
98
- meta: {
99
- totalDraws: works.length,
100
- totalPasses: index.passes.length,
101
- hasCompute: works.some((work) => work.kind.startsWith('dispatch')),
102
- },
103
- commands: tape.events.map((event, eventIndex) => ({
104
- passIdx: index.passIndexByEvent[eventIndex] ?? -1,
105
- eventIdx: eventIndex,
106
- isDraw: isWorkEvent(event.kind),
187
+ const events = tape.events;
188
+ const passCommandIndices = index.passes.map(() => [] as number[]);
189
+ const commands: CommandEntry[] = [];
190
+ const groupPath: string[] = [];
191
+ const currentPassByEvent = index.passIndexByEvent;
192
+ const passAttachmentByIndex = new Map<number, FramePass>();
193
+
194
+ for (const pass of index.passes) {
195
+ passAttachmentByIndex.set(pass.passIndex, {
196
+ passIndex: pass.passIndex,
197
+ kind: pass.kind,
198
+ beginEventIndex: pass.beginEventIndex,
199
+ endEventIndex: pass.endEventIndex ?? null,
200
+ workIndices: pass.workIndices,
201
+ commandIndices: [],
202
+ colorAttachmentViewHandleIds: [],
203
+ depthStencilViewHandleId: null,
204
+ });
205
+ const begin = events[pass.beginEventIndex];
206
+ if (begin?.kind === 'beginRenderPass') {
207
+ const attachment = passAttachmentByIndex.get(pass.passIndex);
208
+ if (attachment !== undefined) {
209
+ attachment.colorAttachmentViewHandleIds = begin.colorAttachmentViewHandleIds.filter(
210
+ (id): id is string => typeof id === 'string',
211
+ );
212
+ attachment.depthStencilViewHandleId = begin.depthStencilViewHandleId ?? null;
213
+ }
214
+ }
215
+ }
216
+
217
+ for (const [eventIndex, event] of events.entries()) {
218
+ const passIndex = currentPassByEvent[eventIndex] ?? -1;
219
+ const isPush = event.kind === 'pushDebugGroup' || event.kind === 'passPushDebugGroup';
220
+ const isPop = event.kind === 'popDebugGroup' || event.kind === 'passPopDebugGroup';
221
+ const group = isPush ? [...groupPath, event.groupLabel] : [...groupPath];
222
+ const marker =
223
+ event.kind === 'insertDebugMarker' || event.kind === 'passInsertDebugMarker'
224
+ ? event.markerLabel
225
+ : undefined;
226
+ const commandIndex = commands.length;
227
+ commands.push({
228
+ eventIndex,
229
+ passIndex,
107
230
  kind: event.kind,
108
- groupLabel: undefined,
109
- markerLabel: undefined,
110
- })),
111
- resources: new Map(),
112
- resourceLifecycle: buildResourceLifecycle(tape.events),
231
+ category: EVENT_SEMANTICS[event.kind].category,
232
+ isWork: isWorkEvent(event.kind),
233
+ params: eventDescriptor(event),
234
+ group,
235
+ ...(marker === undefined ? {} : { marker }),
236
+ });
237
+ if (passIndex >= 0) passCommandIndices[passIndex]?.push(commandIndex);
238
+ if (isPush) groupPath.push(event.groupLabel);
239
+ if (isPop) groupPath.pop();
240
+ }
241
+
242
+ const pipelineEvents = new Map<string, RhiCallEvent>();
243
+ const shaderEvents = new Map<string, RhiCallEvent>();
244
+ const bindGroups = new Map<string, Extract<RhiCallEvent, { kind: 'createBindGroup' }>>();
245
+ const resourceRecords = new Map<string, ResourceEntry>();
246
+ const workByEvent = new Map<number, number>();
247
+ for (const work of index.works) workByEvent.set(work.eventIndex, work.workIndex);
248
+
249
+ for (const bootstrap of tape.bootstrap) {
250
+ // Strict v7 decoding validates each bootstrap create record against the same
251
+ // RhiCallEvent union before FrameModel construction.
252
+ const create = bootstrap.create as unknown as RhiCallEvent;
253
+ if (create.kind === 'createRenderPipeline' || create.kind === 'createComputePipeline')
254
+ pipelineEvents.set(create.handleId, create);
255
+ if (create.kind === 'createShaderModule') shaderEvents.set(create.handleId, create);
256
+ if (create.kind === 'createBindGroup') bindGroups.set(create.handleId, create);
257
+ resourceRecords.set(bootstrap.handleId, {
258
+ resourceId: bootstrap.handleId,
259
+ kind: bootstrap.kind,
260
+ origin: 'bootstrap',
261
+ createEventIndex: null,
262
+ destroyEventIndex: null,
263
+ descriptor: jsonValue(bootstrap.create),
264
+ consumers: [],
265
+ lifecycle: { state: 'unavailable', byteEstimate: null },
266
+ });
267
+ }
268
+ for (const [eventIndex, event] of events.entries()) {
269
+ if (event.kind === 'createRenderPipeline' || event.kind === 'createComputePipeline')
270
+ pipelineEvents.set(event.handleId, event);
271
+ if (event.kind === 'createShaderModule') shaderEvents.set(event.handleId, event);
272
+ if (event.kind === 'createBindGroup') bindGroups.set(event.handleId, event);
273
+ const kind = resourceKindForEvent(event.kind);
274
+ const handleId =
275
+ kind === undefined
276
+ ? undefined
277
+ : event.kind === 'createTextureView'
278
+ ? event.resultHandleId
279
+ : event.kind === 'createCommandEncoder'
280
+ ? event.cmdHandleId
281
+ : 'handleId' in event
282
+ ? event.handleId
283
+ : undefined;
284
+ if (kind !== undefined && handleId !== undefined) {
285
+ resourceRecords.set(handleId, {
286
+ resourceId: handleId,
287
+ kind,
288
+ origin: 'frame',
289
+ createEventIndex: eventIndex,
290
+ destroyEventIndex: null,
291
+ descriptor: eventDescriptor(event),
292
+ consumers: [],
293
+ lifecycle: { state: 'unavailable', byteEstimate: null },
294
+ });
295
+ }
296
+ }
297
+
298
+ const lifecycle = buildResourceLifecycle(events);
299
+ for (const record of lifecycle.resources) {
300
+ const resource = resourceRecords.get(record.handleId);
301
+ if (resource === undefined) continue;
302
+ resourceRecords.set(record.handleId, {
303
+ ...resource,
304
+ destroyEventIndex: record.destroyedEventIndex ?? null,
305
+ lifecycle: { state: record.state, byteEstimate: record.byteEstimate },
306
+ });
307
+ }
308
+ for (const [eventIndex, event] of events.entries()) {
309
+ const workIndex = workByEvent.get(eventIndex) ?? null;
310
+ for (const resourceId of EVENT_SEMANTICS[event.kind].read(event)) {
311
+ const resource = resourceRecords.get(resourceId);
312
+ if (resource !== undefined)
313
+ resource.consumers = [...resource.consumers, { eventIndex, workIndex, access: 'read' }];
314
+ }
315
+ for (const resourceId of EVENT_SEMANTICS[event.kind].written(event)) {
316
+ const resource = resourceRecords.get(resourceId);
317
+ if (resource !== undefined)
318
+ resource.consumers = [...resource.consumers, { eventIndex, workIndex, access: 'write' }];
319
+ }
320
+ }
321
+
322
+ const works: WorkEntry[] = [];
323
+ const currentPipeline = new Map<string, string>();
324
+ const currentVertexBuffers = new Map<
325
+ string,
326
+ Map<number, { bufferHandleId: string; offset: number; size: number | null }>
327
+ >();
328
+ const currentIndexBuffers = new Map<string, WorkEntry['indexBuffer']>();
329
+ const currentBindGroups = new Map<string, Map<number, string>>();
330
+ const workByEventEntry = new Map(index.works.map((work) => [work.eventIndex, work]));
331
+ for (const [eventIndex, event] of events.entries()) {
332
+ if (event === undefined) continue;
333
+ const passHandleId = 'passHandleId' in event ? event.passHandleId : '';
334
+ if (event.kind === 'setPipeline' || event.kind === 'setComputePipeline')
335
+ currentPipeline.set(passHandleId, event.pipelineHandleId);
336
+ if (event.kind === 'setVertexBuffer') {
337
+ const buffers = currentVertexBuffers.get(passHandleId) ?? new Map();
338
+ buffers.set(event.slot, {
339
+ bufferHandleId: event.bufferHandleId,
340
+ offset: event.offset ?? 0,
341
+ size: event.size ?? null,
342
+ });
343
+ currentVertexBuffers.set(passHandleId, buffers);
344
+ }
345
+ if (event.kind === 'setIndexBuffer')
346
+ currentIndexBuffers.set(passHandleId, {
347
+ bufferHandleId: event.bufferHandleId,
348
+ format: event.format,
349
+ offset: event.offset ?? 0,
350
+ size: event.size ?? null,
351
+ });
352
+ if (event.kind === 'setBindGroup') {
353
+ const groups = currentBindGroups.get(passHandleId) ?? new Map();
354
+ groups.set(event.index, event.bindGroupHandleId);
355
+ currentBindGroups.set(passHandleId, groups);
356
+ }
357
+ if (!isWorkEvent(event.kind)) continue;
358
+ const work = workByEventEntry.get(eventIndex);
359
+ if (work === undefined) continue;
360
+ const pipelineId = currentPipeline.get(passHandleId);
361
+ const groups = currentBindGroups.get(passHandleId) ?? new Map();
362
+ const bindings: WorkBinding[] = [];
363
+ for (const [groupIndex, bindGroupId] of groups) {
364
+ const bindGroup = bindGroups.get(bindGroupId);
365
+ for (const [entryIndex, resourceId] of bindGroup?.resourceHandleIds.entries() ?? []) {
366
+ const entry = bindGroup?.entries[entryIndex];
367
+ bindings.push({
368
+ groupIndex,
369
+ binding: entry?.binding ?? entryIndex,
370
+ bindGroupId,
371
+ resourceId: resourceId ?? null,
372
+ resourceKind: entry?.resourceKind ?? null,
373
+ bufferOffset: entry?.bufferOffset ?? null,
374
+ bufferSize: entry?.bufferSize ?? null,
375
+ });
376
+ }
377
+ }
378
+ const attachment = passAttachmentByIndex.get(work.passIndex);
379
+ works.push({
380
+ ...work,
381
+ commandIndex: commands.findIndex((command) => command.eventIndex === work.eventIndex),
382
+ drawCall: eventDescriptor(event),
383
+ pipeline: workPipeline(pipelineId, pipelineEvents, shaderEvents),
384
+ bindings,
385
+ vertexBuffers: [...(currentVertexBuffers.get(passHandleId)?.entries() ?? [])].map(
386
+ ([slot, buffer]) => ({ slot, ...buffer }),
387
+ ),
388
+ indexBuffer: currentIndexBuffers.get(passHandleId) ?? null,
389
+ attachments:
390
+ attachment === undefined
391
+ ? null
392
+ : {
393
+ colorViewHandleIds: attachment.colorAttachmentViewHandleIds,
394
+ depthStencilViewHandleId: attachment.depthStencilViewHandleId,
395
+ },
396
+ });
397
+ }
398
+
399
+ const passes = index.passes.map((pass) => ({
400
+ ...pass,
401
+ endEventIndex: pass.endEventIndex ?? null,
402
+ commandIndices: passCommandIndices[pass.passIndex] ?? [],
403
+ colorAttachmentViewHandleIds:
404
+ passAttachmentByIndex.get(pass.passIndex)?.colorAttachmentViewHandleIds ?? [],
405
+ depthStencilViewHandleId:
406
+ passAttachmentByIndex.get(pass.passIndex)?.depthStencilViewHandleId ?? null,
407
+ }));
408
+ return {
409
+ commands,
410
+ passes,
411
+ resources: [...resourceRecords.values()],
412
+ resourceLifecycle: lifecycle,
113
413
  works,
114
- passes: index.passes,
115
- resourceEntries: index.resources,
116
414
  };
117
415
  }
118
416
 
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // @forgeax/engine-rhi-debug/src/index.ts -- v7 public contract.
2
2
  //
3
- // The public path is one capture artifact, one FrameModel workIndex, and one
4
- // fresh replay session. Consumers should use structured Result errors and keep
3
+ // AI cold-start path: one .rhitape ArtifactRef, one FrameModel workIndex, and one
4
+ // fresh ReplaySession. Consumers should use structured Result errors and keep
5
5
  // browser, Node, and backend ownership at their host boundaries.
6
6
 
7
7
  export {
@@ -13,17 +13,23 @@ export {
13
13
  } from './errors';
14
14
  export type {
15
15
  CommandEntry,
16
- CreateDescriptor,
17
- DrawDepthStencil,
18
- DrawEntry,
19
- DrawPipelineState,
20
16
  FrameModel,
21
- FrameModelMeta,
22
- PassDrawItem,
23
- PassNode,
17
+ FramePass,
18
+ JsonValue,
19
+ ResourceConsumer,
20
+ ResourceEntry,
21
+ WorkBinding,
24
22
  WorkEntry,
23
+ WorkPipeline,
24
+ } from './frame-model';
25
+ export {
26
+ buildFrameModel,
27
+ buildResourceLifecycle,
28
+ type ResourceByteEstimate,
29
+ type ResourceKind,
30
+ type ResourceLifecycleEntry,
31
+ type ResourceLifecycleSummary,
25
32
  } from './frame-model';
26
- export { buildFrameModel } from './frame-model';
27
33
  export { decodeTape, encodeTape } from './protocol/codec';
28
34
  export type { EventCategory, EventSemantics } from './protocol/event-semantics';
29
35
  export {
@@ -66,6 +72,7 @@ export {
66
72
  type RecorderBackend,
67
73
  type RecorderOptions,
68
74
  } from './recorder/session';
75
+ export { replayDeviceRequest } from './replay/device-request';
69
76
  export {
70
77
  type InspectField,
71
78
  openReplay,
@@ -303,15 +303,33 @@ export function createDeviceProxy(s: RecorderInternal, realDevice: RhiDevice): R
303
303
  'shaderModule',
304
304
  );
305
305
  }
306
+ const { module: _vertexModule, constants: vertexConstants, ...vertexFields } = desc.vertex;
307
+ const recordedVertex =
308
+ vertexConstants === undefined
309
+ ? vertexFields
310
+ : { ...vertexFields, constants: vertexConstants };
311
+ const recordedFragment =
312
+ desc.fragment === undefined
313
+ ? undefined
314
+ : (() => {
315
+ const {
316
+ module: _fragmentModule,
317
+ constants: fragmentConstants,
318
+ ...fragmentFields
319
+ } = desc.fragment;
320
+ return fragmentConstants === undefined
321
+ ? fragmentFields
322
+ : { ...fragmentFields, constants: fragmentConstants };
323
+ })();
306
324
  const event: RhiCallEvent = {
307
325
  kind: 'createRenderPipeline',
308
326
  handleId: '' as HandleId,
309
327
  desc: {
310
- vertex: desc.vertex,
328
+ vertex: recordedVertex,
311
329
  primitive: desc.primitive,
312
330
  depthStencil: desc.depthStencil,
313
331
  multisample: desc.multisample,
314
- fragment: desc.fragment,
332
+ ...(recordedFragment === undefined ? {} : { fragment: recordedFragment }),
315
333
  },
316
334
  layoutHandleId: layoutId,
317
335
  vertexShaderModuleHandleId,
@@ -1,6 +1,11 @@
1
1
  // @forgeax/engine-rhi-debug/src/recorder/encoder -- command encoder proxy owner.
2
2
 
3
- import type { Buffer, ComputePassDescriptor, RhiCommandEncoder } from '@forgeax/engine-rhi';
3
+ import type {
4
+ Buffer,
5
+ ComputePassDescriptor,
6
+ RenderPassDescriptor,
7
+ RhiCommandEncoder,
8
+ } from '@forgeax/engine-rhi';
4
9
  import type { HandleId } from '../types';
5
10
  import type { RecorderInternal } from './core';
6
11
  import { allocHandleId, getHandleId, pushEvent } from './core';
@@ -12,7 +17,7 @@ export function createCommandEncoderProxy(
12
17
  cmdHId: HandleId,
13
18
  ): RhiCommandEncoder {
14
19
  return {
15
- beginRenderPass(desc: GPURenderPassDescriptor) {
20
+ beginRenderPass(desc: RenderPassDescriptor) {
16
21
  const passHId = allocHandleId('renderPass');
17
22
  // I-2 fix-up (round 1, dawn smoke): walk colorAttachments +
18
23
  // depthStencilAttachment to extract the textureView handleIds
@@ -26,11 +31,11 @@ export function createCommandEncoderProxy(
26
31
  colorAttachmentViewHandleIds.push(undefined);
27
32
  colorAttachmentResolveTargetHandleIds.push(undefined);
28
33
  } else {
29
- const view = (att as GPURenderPassColorAttachment).view;
34
+ const view = att.view;
30
35
  const id =
31
36
  view !== undefined && view !== null ? s.handleMap.get(view as object) : undefined;
32
37
  colorAttachmentViewHandleIds.push(id);
33
- const resolveTarget = (att as GPURenderPassColorAttachment).resolveTarget;
38
+ const resolveTarget = att.resolveTarget;
34
39
  const resolveTargetId =
35
40
  resolveTarget !== undefined && resolveTarget !== null
36
41
  ? s.handleMap.get(resolveTarget as object)
@@ -49,7 +54,19 @@ export function createCommandEncoderProxy(
49
54
  kind: 'beginRenderPass',
50
55
  cmdHandleId: cmdHId,
51
56
  passHandleId: passHId,
52
- desc: desc as Omit<GPURenderPassDescriptor, 'label'>,
57
+ desc: {
58
+ colorAttachments: Array.from(desc.colorAttachments).map((attachment) =>
59
+ attachment === null || attachment === undefined ? attachment : { ...attachment },
60
+ ),
61
+ ...(desc.depthStencilAttachment === undefined
62
+ ? {}
63
+ : { depthStencilAttachment: { ...desc.depthStencilAttachment } }),
64
+ ...(desc.occlusionQuerySet === undefined
65
+ ? {}
66
+ : { occlusionQuerySet: desc.occlusionQuerySet }),
67
+ ...(desc.timestampWrites === undefined ? {} : { timestampWrites: desc.timestampWrites }),
68
+ ...(desc.maxDrawCount === undefined ? {} : { maxDrawCount: desc.maxDrawCount }),
69
+ },
53
70
  colorAttachmentViewHandleIds,
54
71
  colorAttachmentResolveTargetHandleIds,
55
72
  depthStencilViewHandleId,
@@ -0,0 +1,38 @@
1
+ import type { Tape } from '../protocol/types';
2
+
3
+ const RECORDED_CAPABILITY_FEATURES = [
4
+ ['timestampQuery', 'timestamp-query'],
5
+ ['textureCompressionBc', 'texture-compression-bc'],
6
+ ['textureCompressionEtc2', 'texture-compression-etc2'],
7
+ ['textureCompressionAstc', 'texture-compression-astc'],
8
+ ['firstInstanceIndirect', 'indirect-first-instance'],
9
+ ['float32Filterable', 'float32-filterable'],
10
+ ['rg11b10ufloatRenderable', 'rg11b10ufloat-renderable'],
11
+ ] as const satisfies readonly (readonly [string, GPUFeatureName])[];
12
+
13
+ /**
14
+ * Build the strongest fresh WebGPU device the current adapter can provide for
15
+ * a recorded tape. Optional features must be enabled, not merely advertised,
16
+ * and replay receives concrete adapter limits instead of lower device defaults.
17
+ */
18
+ export function replayDeviceRequest(
19
+ tape: Tape,
20
+ adapterFeatures: ReadonlySet<GPUFeatureName>,
21
+ adapterLimits: Readonly<Record<string, number>>,
22
+ ): GPUDeviceDescriptor {
23
+ const requiredFeatures = RECORDED_CAPABILITY_FEATURES.filter(
24
+ ([capability, feature]) =>
25
+ tape.header.rhiCaps[capability] === true && adapterFeatures.has(feature),
26
+ ).map(([, feature]) => feature);
27
+ const limitEntries = Object.entries(adapterLimits).filter(
28
+ ([, value]) => Number.isFinite(value) && value >= 0,
29
+ );
30
+ const request: GPUDeviceDescriptor = {};
31
+ if (requiredFeatures.length > 0) request.requiredFeatures = requiredFeatures;
32
+ if (limitEntries.length > 0) {
33
+ request.requiredLimits = Object.fromEntries(limitEntries) as NonNullable<
34
+ GPUDeviceDescriptor['requiredLimits']
35
+ >;
36
+ }
37
+ return request;
38
+ }