@forgeax/engine-rhi-debug 0.0.0-dev.8d955ade1c79

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 (237) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +178 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/bootstrap-integration.test.d.ts +2 -0
  5. package/dist/__tests__/bootstrap-integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/consumer-inventory.unit.test.d.ts +2 -0
  7. package/dist/__tests__/consumer-inventory.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts +15 -0
  9. package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts.map +1 -0
  10. package/dist/__tests__/coverage-invariant.test-d.d.ts +2 -0
  11. package/dist/__tests__/coverage-invariant.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/coverage-invariant.unit.test.d.ts +2 -0
  13. package/dist/__tests__/coverage-invariant.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/e2e.browser.test.d.ts +2 -0
  15. package/dist/__tests__/e2e.browser.test.d.ts.map +1 -0
  16. package/dist/__tests__/error-code-narrowing.test-d.d.ts +2 -0
  17. package/dist/__tests__/error-code-narrowing.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/errors-v7.test-d.d.ts +2 -0
  19. package/dist/__tests__/errors-v7.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/errors-v7.unit.test.d.ts +2 -0
  21. package/dist/__tests__/errors-v7.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  23. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/event-semantics-coverage.unit.test.d.ts +2 -0
  25. package/dist/__tests__/event-semantics-coverage.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/format-info.unit.test.d.ts +2 -0
  27. package/dist/__tests__/format-info.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/frame-model-parity.test-d.d.ts +2 -0
  29. package/dist/__tests__/frame-model-parity.test-d.d.ts.map +1 -0
  30. package/dist/__tests__/frame-model-parity.unit.test.d.ts +2 -0
  31. package/dist/__tests__/frame-model-parity.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/guard-gates.test.d.ts +2 -0
  33. package/dist/__tests__/guard-gates.test.d.ts.map +1 -0
  34. package/dist/__tests__/public-surface.integration.test.d.ts +2 -0
  35. package/dist/__tests__/public-surface.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts +2 -0
  37. package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/readback-format-matrix.dawn.test.d.ts +2 -0
  39. package/dist/__tests__/readback-format-matrix.dawn.test.d.ts.map +1 -0
  40. package/dist/__tests__/readback-format-matrix.unit.test.d.ts +2 -0
  41. package/dist/__tests__/readback-format-matrix.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
  43. package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
  44. package/dist/__tests__/recorder-bootstrap.unit.test.d.ts +2 -0
  45. package/dist/__tests__/recorder-bootstrap.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/recorder-budget.unit.test.d.ts +2 -0
  47. package/dist/__tests__/recorder-budget.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/recorder-fail-fast.unit.test.d.ts +2 -0
  49. package/dist/__tests__/recorder-fail-fast.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/recorder-lifecycle.unit.test.d.ts +2 -0
  51. package/dist/__tests__/recorder-lifecycle.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/recorder-push-event.unit.test.d.ts +2 -0
  53. package/dist/__tests__/recorder-push-event.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/recorder-session.integration.test.d.ts +2 -0
  55. package/dist/__tests__/recorder-session.integration.test.d.ts.map +1 -0
  56. package/dist/__tests__/recorder-session.unit.test.d.ts +2 -0
  57. package/dist/__tests__/recorder-session.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts +2 -0
  59. package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts.map +1 -0
  60. package/dist/__tests__/replay-fail-fast.unit.test.d.ts +2 -0
  61. package/dist/__tests__/replay-fail-fast.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/replay-session.dawn.test.d.ts +2 -0
  63. package/dist/__tests__/replay-session.dawn.test.d.ts.map +1 -0
  64. package/dist/__tests__/replay-session.test-d.d.ts +2 -0
  65. package/dist/__tests__/replay-session.test-d.d.ts.map +1 -0
  66. package/dist/__tests__/replay-session.unit.test.d.ts +2 -0
  67. package/dist/__tests__/replay-session.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts +2 -0
  69. package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/resource-lifecycle.unit.test.d.ts +2 -0
  71. package/dist/__tests__/resource-lifecycle.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts +2 -0
  73. package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts.map +1 -0
  74. package/dist/__tests__/tape-index.unit.test.d.ts +2 -0
  75. package/dist/__tests__/tape-index.unit.test.d.ts.map +1 -0
  76. package/dist/__tests__/tape-v7-invalid.unit.test.d.ts +2 -0
  77. package/dist/__tests__/tape-v7-invalid.unit.test.d.ts.map +1 -0
  78. package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts +2 -0
  79. package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts.map +1 -0
  80. package/dist/__tests__/tape-v7-version.unit.test.d.ts +2 -0
  81. package/dist/__tests__/tape-v7-version.unit.test.d.ts.map +1 -0
  82. package/dist/__tests__/tree-shake.unit.test.d.ts +2 -0
  83. package/dist/__tests__/tree-shake.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/types.test.d.ts +2 -0
  85. package/dist/__tests__/types.test.d.ts.map +1 -0
  86. package/dist/browser.d.ts +45 -0
  87. package/dist/browser.d.ts.map +1 -0
  88. package/dist/browser.mjs +83 -0
  89. package/dist/browser.mjs.map +1 -0
  90. package/dist/errors.d.ts +85 -0
  91. package/dist/errors.d.ts.map +1 -0
  92. package/dist/frame-model.d.ts +158 -0
  93. package/dist/frame-model.d.ts.map +1 -0
  94. package/dist/index.d.ts +17 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.mjs +10415 -0
  97. package/dist/index.mjs.map +1 -0
  98. package/dist/protocol/codec.d.ts +6 -0
  99. package/dist/protocol/codec.d.ts.map +1 -0
  100. package/dist/protocol/event-semantics.d.ts +15 -0
  101. package/dist/protocol/event-semantics.d.ts.map +1 -0
  102. package/dist/protocol/tape-index.d.ts +30 -0
  103. package/dist/protocol/tape-index.d.ts.map +1 -0
  104. package/dist/protocol/types.d.ts +44 -0
  105. package/dist/protocol/types.d.ts.map +1 -0
  106. package/dist/protocol/validation.d.ts +7 -0
  107. package/dist/protocol/validation.d.ts.map +1 -0
  108. package/dist/readback.d.ts +156 -0
  109. package/dist/readback.d.ts.map +1 -0
  110. package/dist/recorder/assemble.d.ts +11 -0
  111. package/dist/recorder/assemble.d.ts.map +1 -0
  112. package/dist/recorder/closure.d.ts +39 -0
  113. package/dist/recorder/closure.d.ts.map +1 -0
  114. package/dist/recorder/core.d.ts +296 -0
  115. package/dist/recorder/core.d.ts.map +1 -0
  116. package/dist/recorder/device.d.ts +4 -0
  117. package/dist/recorder/device.d.ts.map +1 -0
  118. package/dist/recorder/encoder.d.ts +5 -0
  119. package/dist/recorder/encoder.d.ts.map +1 -0
  120. package/dist/recorder/lifecycle.d.ts +20 -0
  121. package/dist/recorder/lifecycle.d.ts.map +1 -0
  122. package/dist/recorder/pass.d.ts +6 -0
  123. package/dist/recorder/pass.d.ts.map +1 -0
  124. package/dist/recorder/proxy.d.ts +19 -0
  125. package/dist/recorder/proxy.d.ts.map +1 -0
  126. package/dist/recorder/queue.d.ts +4 -0
  127. package/dist/recorder/queue.d.ts.map +1 -0
  128. package/dist/recorder/resource-registry.d.ts +15 -0
  129. package/dist/recorder/resource-registry.d.ts.map +1 -0
  130. package/dist/recorder/session.d.ts +25 -0
  131. package/dist/recorder/session.d.ts.map +1 -0
  132. package/dist/recorder/shader.d.ts +3 -0
  133. package/dist/recorder/shader.d.ts.map +1 -0
  134. package/dist/recorder/snapshot.d.ts +10 -0
  135. package/dist/recorder/snapshot.d.ts.map +1 -0
  136. package/dist/recorder/wrap.d.ts +4 -0
  137. package/dist/recorder/wrap.d.ts.map +1 -0
  138. package/dist/recorder.d.ts +7 -0
  139. package/dist/recorder.d.ts.map +1 -0
  140. package/dist/replay/device-request.d.ts +8 -0
  141. package/dist/replay/device-request.d.ts.map +1 -0
  142. package/dist/replay/execute-support.d.ts +33 -0
  143. package/dist/replay/execute-support.d.ts.map +1 -0
  144. package/dist/replay/execute.d.ts +18 -0
  145. package/dist/replay/execute.d.ts.map +1 -0
  146. package/dist/replay/readback.d.ts +32 -0
  147. package/dist/replay/readback.d.ts.map +1 -0
  148. package/dist/replay/resources.d.ts +57 -0
  149. package/dist/replay/resources.d.ts.map +1 -0
  150. package/dist/replay/session.d.ts +49 -0
  151. package/dist/replay/session.d.ts.map +1 -0
  152. package/dist/replay/texture-format.d.ts +21 -0
  153. package/dist/replay/texture-format.d.ts.map +1 -0
  154. package/dist/texel-decode.d.ts +35 -0
  155. package/dist/texel-decode.d.ts.map +1 -0
  156. package/dist/texel-layout.d.ts +76 -0
  157. package/dist/texel-layout.d.ts.map +1 -0
  158. package/dist/types.d.ts +722 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/package.json +72 -0
  161. package/src/__tests__/bootstrap-integration.test.ts +268 -0
  162. package/src/__tests__/consumer-inventory.unit.test.ts +154 -0
  163. package/src/__tests__/copy-src-usage-validity.dawn.test.ts +117 -0
  164. package/src/__tests__/coverage-invariant.test-d.ts +115 -0
  165. package/src/__tests__/coverage-invariant.unit.test.ts +45 -0
  166. package/src/__tests__/e2e.browser.test.ts +239 -0
  167. package/src/__tests__/error-code-narrowing.test-d.ts +32 -0
  168. package/src/__tests__/errors-v7.test-d.ts +19 -0
  169. package/src/__tests__/errors-v7.unit.test.ts +15 -0
  170. package/src/__tests__/errors.unit.test.ts +83 -0
  171. package/src/__tests__/event-semantics-coverage.unit.test.ts +22 -0
  172. package/src/__tests__/format-info.unit.test.ts +85 -0
  173. package/src/__tests__/frame-model-parity.test-d.ts +20 -0
  174. package/src/__tests__/frame-model-parity.unit.test.ts +285 -0
  175. package/src/__tests__/guard-gates.test.ts +317 -0
  176. package/src/__tests__/public-surface.integration.test.ts +38 -0
  177. package/src/__tests__/readback-fault-cleanup.unit.test.ts +66 -0
  178. package/src/__tests__/readback-format-matrix.dawn.test.ts +378 -0
  179. package/src/__tests__/readback-format-matrix.unit.test.ts +120 -0
  180. package/src/__tests__/readback-matrix-fixture.ts +20 -0
  181. package/src/__tests__/recorder-bootstrap.unit.test.ts +302 -0
  182. package/src/__tests__/recorder-budget.unit.test.ts +42 -0
  183. package/src/__tests__/recorder-fail-fast.unit.test.ts +434 -0
  184. package/src/__tests__/recorder-lifecycle.unit.test.ts +42 -0
  185. package/src/__tests__/recorder-push-event.unit.test.ts +573 -0
  186. package/src/__tests__/recorder-session.integration.test.ts +32 -0
  187. package/src/__tests__/recorder-session.unit.test.ts +63 -0
  188. package/src/__tests__/recorder-steady-frame.dawn.test.ts +52 -0
  189. package/src/__tests__/replay-fail-fast.unit.test.ts +155 -0
  190. package/src/__tests__/replay-session.dawn.test.ts +220 -0
  191. package/src/__tests__/replay-session.test-d.ts +27 -0
  192. package/src/__tests__/replay-session.unit.test.ts +171 -0
  193. package/src/__tests__/resolve-texture-descriptor.unit.test.ts +144 -0
  194. package/src/__tests__/resource-lifecycle.unit.test.ts +114 -0
  195. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +178 -0
  196. package/src/__tests__/tape-index.unit.test.ts +56 -0
  197. package/src/__tests__/tape-v7-invalid.unit.test.ts +45 -0
  198. package/src/__tests__/tape-v7-roundtrip.unit.test.ts +66 -0
  199. package/src/__tests__/tape-v7-version.unit.test.ts +13 -0
  200. package/src/__tests__/tree-shake.unit.test.ts +151 -0
  201. package/src/__tests__/types.test.ts +257 -0
  202. package/src/browser.ts +146 -0
  203. package/src/errors.ts +173 -0
  204. package/src/frame-model.ts +753 -0
  205. package/src/index.ts +92 -0
  206. package/src/pako.d.ts +8 -0
  207. package/src/protocol/codec.ts +220 -0
  208. package/src/protocol/event-semantics.ts +180 -0
  209. package/src/protocol/tape-index.ts +120 -0
  210. package/src/protocol/types.ts +60 -0
  211. package/src/protocol/validation.ts +173 -0
  212. package/src/readback.ts +804 -0
  213. package/src/recorder/assemble.ts +192 -0
  214. package/src/recorder/closure.ts +408 -0
  215. package/src/recorder/core.ts +587 -0
  216. package/src/recorder/device.ts +474 -0
  217. package/src/recorder/encoder.ts +251 -0
  218. package/src/recorder/lifecycle.ts +847 -0
  219. package/src/recorder/pass.ts +241 -0
  220. package/src/recorder/proxy.ts +59 -0
  221. package/src/recorder/queue.ts +109 -0
  222. package/src/recorder/resource-registry.ts +53 -0
  223. package/src/recorder/session.ts +152 -0
  224. package/src/recorder/shader.ts +47 -0
  225. package/src/recorder/snapshot.ts +38 -0
  226. package/src/recorder/wrap.ts +157 -0
  227. package/src/recorder.ts +15 -0
  228. package/src/replay/device-request.ts +38 -0
  229. package/src/replay/execute-support.ts +243 -0
  230. package/src/replay/execute.ts +870 -0
  231. package/src/replay/readback.ts +671 -0
  232. package/src/replay/resources.ts +154 -0
  233. package/src/replay/session.ts +509 -0
  234. package/src/replay/texture-format.ts +99 -0
  235. package/src/texel-decode.ts +289 -0
  236. package/src/texel-layout.ts +324 -0
  237. package/src/types.ts +888 -0
@@ -0,0 +1,847 @@
1
+ // @forgeax/engine-rhi-debug/src/recorder/lifecycle -- capture state and snapshot lifecycle.
2
+
3
+ /// <reference types="@webgpu/types" />
4
+
5
+ import type { Result, RhiDevice } from '@forgeax/engine-rhi';
6
+ import { err as makeErr, ok as makeOk } from '@forgeax/engine-types';
7
+ import { createRhiDebugError, type RhiDebugError } from '../errors';
8
+ import {
9
+ readbackBufferBytes,
10
+ readbackBufferBytesBatch,
11
+ readbackTexturePixels,
12
+ readbackTexturePixelsBatch,
13
+ } from '../readback';
14
+ import { computeTextureLayout, projectTextureExtent } from '../texel-layout';
15
+ import type { HandleId, RhiCallEvent, Tape } from '../types';
16
+ import {
17
+ _collectFrameReferencedHandleIds,
18
+ _computeClosure,
19
+ _getCreateEventReferencedHandleIds,
20
+ _topoSortClosure,
21
+ } from './closure';
22
+ import {
23
+ isDepthOrStencilFormat,
24
+ isMappableBuffer,
25
+ isSnapshottableColorTexture,
26
+ pushSnapshotEvent,
27
+ type RecorderInternal,
28
+ RecorderState,
29
+ reconcileSwapchainViewFormats,
30
+ SNAPSHOT_RESOURCE_BATCH_SIZE,
31
+ SNAPSHOT_TIMEOUT_MS,
32
+ snapshotStageOf,
33
+ snapshotTimeoutDetail,
34
+ storeBlob,
35
+ TAPE_FORMAT_VERSION,
36
+ } from './core';
37
+
38
+ export function createRecorderLifecycle(s: RecorderInternal) {
39
+ function arm(frames: number): Result<void, RhiDebugError> {
40
+ if (
41
+ s.state === RecorderState.Armed ||
42
+ s.state === RecorderState.Snapshotting ||
43
+ s.state === RecorderState.Recording ||
44
+ s.state === RecorderState.Finalizing
45
+ ) {
46
+ return makeErr(
47
+ createRhiDebugError('capture-busy', {
48
+ stage: 'capture',
49
+ cause: 'arm() called while the recorder is already capturing',
50
+ }),
51
+ );
52
+ }
53
+ if (s.state === RecorderState.Error) {
54
+ return makeErr(
55
+ createRhiDebugError('capture-unavailable', {
56
+ stage: 'capture',
57
+ cause: 'the recorder is in an error state; dispose the failed capture before re-arming',
58
+ }),
59
+ );
60
+ }
61
+
62
+ s.state = RecorderState.Armed;
63
+ // A previous bounded snapshot may still be unwinding after its timeout.
64
+ // Its generation fence prevents stale cleanup from touching this capture;
65
+ // reset the suppression latch here so the new capture can record normally.
66
+ s._skipRecord = false;
67
+ s.snapshotGeneration += 1;
68
+ s.requestedFrames = frames;
69
+ s.recordedFrames = 0;
70
+ s.events = [];
71
+ s.blobPool = new Map();
72
+ s.snapshotSeededHandles.clear();
73
+ s.snapshotProgress = undefined;
74
+ s.frameIdx = 0;
75
+ s.bootstrap = true;
76
+ s.valid = true;
77
+ return makeOk(undefined);
78
+ }
79
+
80
+ function onFrameEnd(): void {
81
+ if (s.state === RecorderState.Idle) {
82
+ s.frameIdx++;
83
+ s.bootstrap = false;
84
+ return;
85
+ }
86
+
87
+ // Snapshotting = the async frame-header snapshot loop is mid-flight. Its
88
+ // readbacks await between resources, so the host rAF loop CAN fire
89
+ // onFrameEnd while the loop is still pushing initialData events. If we let
90
+ // that tick advance the state machine (Recording -> frameMark -> Finalizing
91
+ // -> Idle), the still-running snapshot loop's later pushEvent() calls hit
92
+ // the Idle gate and are silently dropped -- the exact race that lost every
93
+ // texture initialData (material default textures all-zero -> black cube).
94
+ // Ignore the tick entirely: snapshotAllLiveResources() sets Recording when
95
+ // it completes, and the NEXT onFrameEnd records the real frame.
96
+ if (s.state === RecorderState.Snapshotting) {
97
+ s.bootstrap = false;
98
+ return;
99
+ }
100
+
101
+ // Armed at frame end -> recording. This is the fallback for hosts that
102
+ // never call snapshotAllLiveResources() (the snapshot loop is opt-in at the
103
+ // seam); they record straight from Armed with no frame-header snapshot.
104
+ if (s.state === RecorderState.Armed) {
105
+ s.state = RecorderState.Recording;
106
+ s.bootstrap = false;
107
+ }
108
+
109
+ if (s.state === RecorderState.Recording) {
110
+ // Emit frameMark at end of this frame
111
+ s.events.push({ kind: 'frameMark', frameIdx: s.frameIdx });
112
+ s.recordedFrames++;
113
+ s.frameIdx++;
114
+
115
+ if (s.recordedFrames >= s.requestedFrames) {
116
+ s.state = RecorderState.Finalizing;
117
+ if (s.onFrameEndUnsubscribe) {
118
+ s.onFrameEndUnsubscribe();
119
+ s.onFrameEndUnsubscribe = undefined;
120
+ }
121
+ s.state = RecorderState.Idle;
122
+ }
123
+ return;
124
+ }
125
+
126
+ // finalizing or error: no-op
127
+ s.frameIdx++;
128
+ }
129
+
130
+ function getTape(): Tape | RhiDebugError | undefined {
131
+ if (s.events.length === 0) return undefined;
132
+
133
+ reconcileSwapchainViewFormats(s);
134
+
135
+ // Pre-scan s.events for create* declarations: handleIds whose
136
+ // create event is already carried by the frame events (transient
137
+ // per-frame resources like swapchain textures, command encoders).
138
+ // These handles do NOT need bootstrap prefixing -- they were born
139
+ // during the recorded frame and their create event is in s.events.
140
+ // Collect handleIds that are directly declared by create* events in s.events.
141
+ // Only include the handleId field of the create event itself — NOT backward-refs
142
+ // (layoutHandleId, resourceHandleIds, etc.) from _getCreateEventReferencedHandleIds.
143
+ //
144
+ // Backward-refs from in-frame create events often point to persistent resources
145
+ // (buffers, textures, pipelines) that were created before arm(). Including them
146
+ // in inFrameHandleIds would exclude those resources from bootstrap prefixing,
147
+ // causing tapes to be non-self-contained (missing create* events for early handles).
148
+ //
149
+ // Swapchain textures that have no createTexture event are handled elsewhere:
150
+ // createTextureView (line 1237) detects missing bootstrap entries and constructs
151
+ // faithful createTexture events at capture time, so they are already in both
152
+ // bootstrapCreates and s.events.
153
+ const inFrameHandleIds = new Set<HandleId>();
154
+ for (const e of s.events) {
155
+ // create* events declare handleId (or resultHandleId for createTextureView).
156
+ if (
157
+ e.kind.startsWith('create') &&
158
+ 'handleId' in e &&
159
+ typeof (e as { handleId: unknown }).handleId === 'string'
160
+ ) {
161
+ inFrameHandleIds.add((e as { handleId: HandleId }).handleId);
162
+ }
163
+ // createTextureView declares the result via resultHandleId.
164
+ if (
165
+ e.kind === 'createTextureView' &&
166
+ 'resultHandleId' in e &&
167
+ typeof (e as { resultHandleId: unknown }).resultHandleId === 'string'
168
+ ) {
169
+ inFrameHandleIds.add((e as { resultHandleId: HandleId }).resultHandleId);
170
+ }
171
+ // createCommandEncoder declares the cmd via cmdHandleId.
172
+ if (
173
+ e.kind === 'createCommandEncoder' &&
174
+ 'cmdHandleId' in e &&
175
+ typeof (e as { cmdHandleId: unknown }).cmdHandleId === 'string'
176
+ ) {
177
+ inFrameHandleIds.add((e as { cmdHandleId: HandleId }).cmdHandleId);
178
+ }
179
+ // beginRenderPass / beginComputePass declare passHandleId.
180
+ if (
181
+ (e.kind === 'beginRenderPass' || e.kind === 'beginComputePass') &&
182
+ 'passHandleId' in e &&
183
+ typeof (e as { passHandleId: unknown }).passHandleId === 'string'
184
+ ) {
185
+ inFrameHandleIds.add((e as { passHandleId: HandleId }).passHandleId);
186
+ }
187
+ }
188
+
189
+ // Collect frame-referenced handleIds from per-frame events.
190
+ const allFrameHandleIds = _collectFrameReferencedHandleIds(s.events);
191
+
192
+ // Exclude handles whose create event is already in s.events:
193
+ // only compute bootstrap closure for handles that need prefixing.
194
+ const prefixSeedIds = new Set<HandleId>();
195
+ for (const hId of allFrameHandleIds) {
196
+ if (!inFrameHandleIds.has(hId)) {
197
+ prefixSeedIds.add(hId);
198
+ }
199
+ }
200
+
201
+ // Transitive closure from bootstrapCreates
202
+ const { closure, missing } = _computeClosure(
203
+ prefixSeedIds,
204
+ s.bootstrapCreates,
205
+ inFrameHandleIds,
206
+ );
207
+
208
+ if (missing !== null) {
209
+ // Missing create — return error (hint refined in w9)
210
+ const referencingEventIndex = s.events.findIndex((event) => {
211
+ try {
212
+ return JSON.stringify(event).includes(missing);
213
+ } catch {
214
+ return false;
215
+ }
216
+ });
217
+ const referencingEventKind =
218
+ referencingEventIndex >= 0 ? s.events[referencingEventIndex]?.kind : undefined;
219
+ const referencingCreate = [...s.bootstrapCreates.entries()].find(([, event]) =>
220
+ _getCreateEventReferencedHandleIds(event).includes(missing),
221
+ );
222
+ return createRhiDebugError('tape-invalid', {
223
+ stage: 'validate',
224
+ cause: `handleId '${missing}' has no create event in bootstrap table; referenced by event ${referencingEventIndex} (${referencingEventKind ?? 'unknown'}) and bootstrap ${referencingCreate?.[0] ?? 'unknown'} (${referencingCreate?.[1].kind ?? 'unknown'})`,
225
+ handleId: missing,
226
+ eventIndex: referencingEventIndex,
227
+ });
228
+ }
229
+
230
+ // Topological sort: dependencies (leaf resources) before dependents
231
+ const prefixEvents: RhiCallEvent[] = _topoSortClosure(closure, s.bootstrapCreates);
232
+
233
+ // dedup: only prefix create events not already in s.events.
234
+ const dedupedPrefx = prefixEvents.filter((e) => {
235
+ if ('handleId' in e && typeof (e as { handleId: unknown }).handleId === 'string') {
236
+ return !inFrameHandleIds.has((e as { handleId: HandleId }).handleId);
237
+ }
238
+ return true;
239
+ });
240
+
241
+ return {
242
+ formatVersion: TAPE_FORMAT_VERSION,
243
+ rhiCapsRecorded: s.recordedCaps ?? {
244
+ canvasFormat: 'bgra8unorm' as GPUTextureFormat,
245
+ rgba16floatRenderable: false,
246
+ float32Filterable: false,
247
+ textureCompressionBc: false,
248
+ textureCompressionEtc2: false,
249
+ textureCompressionAstc: false,
250
+ storageBuffer: false,
251
+ timestampQuery: false,
252
+ },
253
+ events: [...dedupedPrefx, ...s.events],
254
+ blobPool: s.blobPool,
255
+ };
256
+ }
257
+
258
+ function getState(): string {
259
+ return s.state;
260
+ }
261
+ function getEvents(): readonly RhiCallEvent[] {
262
+ return s.events;
263
+ }
264
+ function getBlobPool(): ReadonlyMap<string, ArrayBuffer> {
265
+ return s.blobPool;
266
+ }
267
+
268
+ function transitionToError(): void {
269
+ if (
270
+ s.state === RecorderState.Recording ||
271
+ s.state === RecorderState.Armed ||
272
+ s.state === RecorderState.Snapshotting
273
+ ) {
274
+ s.state = RecorderState.Error;
275
+ s.snapshotGeneration += 1;
276
+ s._skipRecord = false;
277
+ s.valid = false;
278
+ if (s.onFrameEndUnsubscribe) {
279
+ s.onFrameEndUnsubscribe();
280
+ s.onFrameEndUnsubscribe = undefined;
281
+ }
282
+ }
283
+ }
284
+
285
+ function disposeError(): void {
286
+ if (s.state === RecorderState.Error) {
287
+ s.state = RecorderState.Idle;
288
+ s.snapshotGeneration += 1;
289
+ s._skipRecord = false;
290
+ s.events = [];
291
+ s.blobPool = new Map();
292
+ s.valid = true;
293
+ s.snapshotProgress = undefined;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Snapshot a resource's GPU bytes into the tape as an initialData event.
299
+ *
300
+ * Reads the resource shape from the descriptor registry, copies the bytes
301
+ * back from the GPU via readbackBufferBytes (buffer) / readbackTexturePixels
302
+ * (texture), stores them in the blobPool (djb2 hash-dedup), and pushes an
303
+ * `initialData` event into the stream. The snapshot's own copy/submit are
304
+ * wrapped in `_skipRecord = true` so they never leak into the tape event
305
+ * stream (D-8 isolation).
306
+ *
307
+ * Async because the GPU readback chain (copyToBuffer -> submit ->
308
+ * onSubmittedWorkDone -> mapAsync) is inherently asynchronous; the M1 stub
309
+ * locked a sync signature, but no caller existed yet — the frame-header loop
310
+ * added here is the first consumer (Change stance: optimal > compatible).
311
+ *
312
+ * Returns Result with {handleId, dataHash} on success, or
313
+ * capture-snapshot-failed (with structured snapshot detail) on a
314
+ * failure, so AI users can switch-exhaustive narrow the code (D-3).
315
+ */
316
+ async function snapshotResource(
317
+ handleId: HandleId,
318
+ snapshotGeneration?: number,
319
+ ): Promise<Result<{ handleId: HandleId; dataHash: string }, RhiDebugError>> {
320
+ type SnapshotResult = Result<{ handleId: HandleId; dataHash: string }, RhiDebugError>;
321
+ const fail = (
322
+ stage: 'copy' | 'map' | 'store',
323
+ _expected: string,
324
+ hint: string,
325
+ ): SnapshotResult =>
326
+ makeErr(
327
+ createRhiDebugError('capture-snapshot-failed', {
328
+ stage: 'snapshot',
329
+ cause: `${stage}: ${hint}`,
330
+ handleId,
331
+ }),
332
+ );
333
+
334
+ const entry = s.descriptorTable.get(handleId);
335
+ if (entry === undefined) {
336
+ return fail(
337
+ 'copy',
338
+ 'handleId present in descriptor registry',
339
+ `no live resource registered for handleId '${handleId}'; it may have been destroyed or never created through the recorder proxy`,
340
+ );
341
+ }
342
+
343
+ const device = s.capturedDevice;
344
+ if (device === undefined) {
345
+ return fail(
346
+ 'copy',
347
+ 'a captured RhiDevice to drive GPU readback',
348
+ 'no device has been acquired through the recorder proxy yet; drive requestAdapter().requestDevice() before snapshotting',
349
+ );
350
+ }
351
+
352
+ // Resolve the unwrapped real device — readback issues copy/submit/mapAsync
353
+ // through it. The proxy device would re-record those calls were it not for
354
+ // the _skipRecord guard below; using the real device sidesteps the proxy
355
+ // entirely for the readback staging buffer.
356
+ const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
357
+ const snapshotIsActive = () =>
358
+ snapshotGeneration === undefined ||
359
+ (s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration);
360
+ const cancelled = () =>
361
+ makeErr(
362
+ createRhiDebugError('capture-snapshot-failed', {
363
+ stage: 'snapshot',
364
+ cause:
365
+ 'snapshot was cancelled after a timeout or recorder error; discard this capture and retry',
366
+ handleId,
367
+ }),
368
+ );
369
+
370
+ let bytes: ArrayBuffer;
371
+ const prevSkip = s._skipRecord;
372
+ s._skipRecord = true;
373
+ try {
374
+ if (entry.kind === 'buffer') {
375
+ const size = typeof entry.size === 'number' ? entry.size : 0;
376
+ const res = await readbackBufferBytes(realDevice, entry.resource, size);
377
+ if (!res.ok) return fail(snapshotStageOf(res.error), res.error.expected, res.error.hint);
378
+ if (!snapshotIsActive()) return cancelled();
379
+ bytes = res.value;
380
+ } else {
381
+ const { width, height, layerCount } = projectTextureExtent(entry.size);
382
+ const layout = computeTextureLayout(
383
+ entry.format,
384
+ width,
385
+ height,
386
+ layerCount,
387
+ entry.mipLevelCount ?? 1,
388
+ );
389
+ if (layout === undefined) {
390
+ // Should not happen: the snapshot loop's isSnapshottableColorTexture
391
+ // gate already excludes formats with no texel size. Fail fast rather
392
+ // than emit a corrupt seed.
393
+ return fail(
394
+ 'copy',
395
+ 'a snapshottable color format with a known texel size',
396
+ `format '${entry.format}' has no byte layout; the snapshot gate should have skipped it`,
397
+ );
398
+ }
399
+ try {
400
+ // Read every (layer, mip) subresource and concatenate tight into one
401
+ // blob in the canonical order computeTextureLayout defines; the seed
402
+ // side walks the same layout to writeTexture each slice back.
403
+ const blob = new Uint8Array(layout.totalBytes);
404
+ for (const slice of layout.slices) {
405
+ if (!snapshotIsActive()) return cancelled();
406
+ const sub = await readbackTexturePixels(
407
+ realDevice,
408
+ entry.resource,
409
+ slice.width,
410
+ slice.height,
411
+ {
412
+ bytesPerBlock: layout.bytesPerBlock,
413
+ blockWidth: layout.blockWidth,
414
+ blockHeight: layout.blockHeight,
415
+ mipLevel: slice.mip,
416
+ baseArrayLayer: slice.layer,
417
+ },
418
+ );
419
+ if (!snapshotIsActive()) return cancelled();
420
+ blob.set(sub.subarray(0, slice.byteLength), slice.byteOffset);
421
+ }
422
+ bytes = blob.buffer.slice(
423
+ blob.byteOffset,
424
+ blob.byteOffset + blob.byteLength,
425
+ ) as ArrayBuffer;
426
+ } catch (e) {
427
+ return fail(
428
+ 'copy',
429
+ 'texture GPU byte readback to succeed',
430
+ `readbackTexturePixels failed: ${String(e)}`,
431
+ );
432
+ }
433
+ }
434
+ } finally {
435
+ // A retry may begin before a timed-out readback promise settles. Do not
436
+ // let the stale generation restore its old suppression bit over the new
437
+ // capture's active snapshot; only the generation that acquired it may
438
+ // release it.
439
+ if (snapshotGeneration === undefined || s.snapshotGeneration === snapshotGeneration) {
440
+ s._skipRecord = prevSkip;
441
+ }
442
+ }
443
+
444
+ if (!snapshotIsActive()) return cancelled();
445
+
446
+ // storeBlob: djb2 hash-dedup into the unified blobPool (D-1, no separate
447
+ // init-data pool). Reuses the same tag space as writeBuffer/writeTexture.
448
+ let dataHash: string;
449
+ try {
450
+ dataHash = storeBlob(s, bytes);
451
+ } catch (e) {
452
+ return fail(
453
+ 'store',
454
+ 'storeBlob to hash + insert the snapshot bytes',
455
+ `storeBlob failed: ${String(e)}`,
456
+ );
457
+ }
458
+
459
+ pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
460
+ s.snapshotSeededHandles.add(handleId);
461
+ return makeOk({ handleId, dataHash });
462
+ }
463
+
464
+ /**
465
+ * Frame-header snapshot loop (D-5, C-3, C-4): with the recorder in the
466
+ * Snapshotting middle state, await all submitted work, then snapshot every
467
+ * live resource in the descriptor registry (full-table dump, no size
468
+ * threshold / allowlist trimming — that for-loop is the single Phase 2
469
+ * policy seam, OOS-4 / OOS-5). On full success the recorder advances to
470
+ * Recording; a single snapshot failure aborts with its Result so the
471
+ * caller fails fast (architecture §5) rather than recording a partial seed.
472
+ */
473
+ async function snapshotAllLiveResources(
474
+ timeoutMs = SNAPSHOT_TIMEOUT_MS,
475
+ ): Promise<Result<void, RhiDebugError>> {
476
+ if (s.state !== RecorderState.Armed && s.state !== RecorderState.Snapshotting) {
477
+ return makeErr(
478
+ createRhiDebugError('capture-unavailable', {
479
+ stage: 'capture',
480
+ cause: `snapshotAllLiveResources called while recorder is in '${s.state}' state; arm() before the frame-header snapshot`,
481
+ }),
482
+ );
483
+ }
484
+ const snapshotGeneration = s.snapshotGeneration;
485
+ s.state = RecorderState.Snapshotting;
486
+
487
+ const timeoutError = () =>
488
+ createRhiDebugError('capture-timeout', snapshotTimeoutDetail(s.snapshotProgress, timeoutMs));
489
+ let timer: ReturnType<typeof setTimeout> | undefined;
490
+ const timeoutResult = new Promise<Result<void, RhiDebugError>>((resolve) => {
491
+ timer = setTimeout(() => {
492
+ // The caller may have timed out first, disposed the failed capture,
493
+ // and already started a new generation. A late timer from that stale
494
+ // snapshot must not poison the retry that now owns the recorder.
495
+ if (s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration) {
496
+ transitionToError();
497
+ }
498
+ resolve(makeErr(timeoutError()));
499
+ }, timeoutMs);
500
+ });
501
+
502
+ try {
503
+ const result = await Promise.race([
504
+ runSnapshotAllLiveResources(snapshotGeneration),
505
+ timeoutResult,
506
+ ]);
507
+ if (
508
+ !result.ok &&
509
+ s.state === RecorderState.Snapshotting &&
510
+ s.snapshotGeneration === snapshotGeneration
511
+ ) {
512
+ transitionToError();
513
+ }
514
+ return result;
515
+ } catch (error) {
516
+ if (s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration) {
517
+ transitionToError();
518
+ }
519
+ throw error;
520
+ } finally {
521
+ if (timer !== undefined) clearTimeout(timer);
522
+ }
523
+ }
524
+
525
+ async function runSnapshotAllLiveResources(
526
+ snapshotGeneration: number,
527
+ ): Promise<Result<void, RhiDebugError>> {
528
+ s.snapshotProgress = {
529
+ startedAt: Date.now(),
530
+ stage: 'queue-drain',
531
+ totalResources: s.descriptorTable.size,
532
+ completedResources: 0,
533
+ skippedResources: 0,
534
+ currentHandleId: null,
535
+ currentKind: null,
536
+ currentSizeBytes: null,
537
+ };
538
+
539
+ // C-3 conservative timing: drain queued work so snapshots read frame-outside
540
+ // / historical content, never a half-written in-frame value (A-2).
541
+ const device = s.capturedDevice;
542
+ const realDevice =
543
+ device === undefined
544
+ ? undefined
545
+ : ((device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device);
546
+ if (realDevice !== undefined) {
547
+ const prevSkip = s._skipRecord;
548
+ s._skipRecord = true;
549
+ try {
550
+ await realDevice.queue.onSubmittedWorkDone();
551
+ } finally {
552
+ if (s.snapshotGeneration === snapshotGeneration) s._skipRecord = prevSkip;
553
+ }
554
+ if (s.snapshotProgress !== undefined) {
555
+ s.snapshotProgress = { ...s.snapshotProgress, stage: 'resource-readback' };
556
+ }
557
+ if (s.state !== RecorderState.Snapshotting || s.snapshotGeneration !== snapshotGeneration) {
558
+ return makeErr(
559
+ createRhiDebugError('capture-snapshot-failed', {
560
+ stage: 'snapshot',
561
+ cause: 'snapshot was cancelled while queued GPU work was draining',
562
+ }),
563
+ );
564
+ }
565
+ }
566
+
567
+ const liveEntries = [...s.descriptorTable.entries()];
568
+ const candidates = liveEntries.filter(([handleId, entry]) => {
569
+ // Mappable buffers are staging scratch, not seedable authored resources.
570
+ if (entry.kind === 'buffer' && isMappableBuffer(entry.usage)) {
571
+ if (s.snapshotProgress !== undefined) {
572
+ s.snapshotProgress = {
573
+ ...s.snapshotProgress,
574
+ skippedResources: s.snapshotProgress.skippedResources + 1,
575
+ };
576
+ }
577
+ return false;
578
+ }
579
+ // Depth/stencil and multisample textures cannot be faithfully re-seeded.
580
+ if (
581
+ entry.kind === 'texture' &&
582
+ (isDepthOrStencilFormat(entry.format) ||
583
+ !isSnapshottableColorTexture(entry.format, entry.size, entry.sampleCount))
584
+ ) {
585
+ if (s.snapshotProgress !== undefined) {
586
+ s.snapshotProgress = {
587
+ ...s.snapshotProgress,
588
+ skippedResources: s.snapshotProgress.skippedResources + 1,
589
+ };
590
+ }
591
+ return false;
592
+ }
593
+ // Preserve the existing transient-texture policy: a texture destroyed
594
+ // before its turn is skipped, while a resource already admitted to a
595
+ // batch retains its seed even if it is released during that batch.
596
+ if (entry.kind === 'texture' && !s.descriptorTable.has(handleId)) {
597
+ if (s.snapshotProgress !== undefined) {
598
+ s.snapshotProgress = {
599
+ ...s.snapshotProgress,
600
+ skippedResources: s.snapshotProgress.skippedResources + 1,
601
+ };
602
+ }
603
+ return false;
604
+ }
605
+ return true;
606
+ });
607
+
608
+ const snapshotIsActive = () =>
609
+ s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration;
610
+ const cancelledResult = () =>
611
+ makeErr(
612
+ createRhiDebugError('capture-snapshot-failed', {
613
+ stage: 'snapshot',
614
+ cause: 'snapshot was cancelled while live resources were being seeded',
615
+ }),
616
+ );
617
+
618
+ // No captured device is an existing error path for snapshotResource. Keep
619
+ // it serial and deterministic rather than manufacturing a batch surface.
620
+ if (realDevice === undefined) {
621
+ for (const [handleId, entry] of candidates) {
622
+ if (s.snapshotProgress !== undefined) {
623
+ s.snapshotProgress = {
624
+ ...s.snapshotProgress,
625
+ stage: 'resource-readback',
626
+ currentHandleId: handleId,
627
+ currentKind: entry.kind,
628
+ currentSizeBytes:
629
+ entry.kind === 'buffer' && typeof entry.size === 'number' ? entry.size : null,
630
+ };
631
+ }
632
+ const result = await snapshotResource(handleId, snapshotGeneration);
633
+ if (!result.ok) return result;
634
+ if (s.snapshotProgress !== undefined) {
635
+ s.snapshotProgress = {
636
+ ...s.snapshotProgress,
637
+ completedResources: s.snapshotProgress.completedResources + 1,
638
+ currentHandleId: null,
639
+ currentKind: null,
640
+ currentSizeBytes: null,
641
+ };
642
+ }
643
+ }
644
+ } else {
645
+ let offset = 0;
646
+ while (offset < candidates.length) {
647
+ const first = candidates[offset];
648
+ if (first === undefined) break;
649
+ const kind = first[1].kind;
650
+ const batchEntries: typeof candidates = [];
651
+ while (
652
+ offset < candidates.length &&
653
+ batchEntries.length < SNAPSHOT_RESOURCE_BATCH_SIZE &&
654
+ candidates[offset]?.[1].kind === kind
655
+ ) {
656
+ const candidate = candidates[offset];
657
+ if (candidate !== undefined) batchEntries.push(candidate);
658
+ offset += 1;
659
+ }
660
+
661
+ if (s.snapshotProgress !== undefined) {
662
+ s.snapshotProgress = {
663
+ ...s.snapshotProgress,
664
+ stage: 'resource-readback',
665
+ currentHandleId: batchEntries[0]?.[0] ?? null,
666
+ currentKind: kind,
667
+ currentSizeBytes:
668
+ kind === 'buffer' && typeof batchEntries[0]?.[1].size === 'number'
669
+ ? batchEntries[0][1].size
670
+ : null,
671
+ };
672
+ }
673
+
674
+ if (kind === 'buffer') {
675
+ const batch = await readbackBufferBytesBatch(
676
+ realDevice,
677
+ batchEntries.map(([handleId, entry]) => ({
678
+ handleId,
679
+ buffer: entry.resource,
680
+ size: typeof entry.size === 'number' ? entry.size : 0,
681
+ })),
682
+ {
683
+ onResourceStart: (handleId) => {
684
+ const entry = s.descriptorTable.get(handleId);
685
+ if (s.snapshotProgress !== undefined && entry !== undefined) {
686
+ s.snapshotProgress = {
687
+ ...s.snapshotProgress,
688
+ currentHandleId: handleId,
689
+ currentKind: 'buffer',
690
+ currentSizeBytes: typeof entry.size === 'number' ? entry.size : null,
691
+ };
692
+ }
693
+ },
694
+ onResourceComplete: () => {
695
+ if (s.snapshotProgress !== undefined) {
696
+ s.snapshotProgress = {
697
+ ...s.snapshotProgress,
698
+ completedResources: s.snapshotProgress.completedResources + 1,
699
+ currentHandleId: null,
700
+ currentKind: null,
701
+ currentSizeBytes: null,
702
+ };
703
+ }
704
+ },
705
+ isCancelled: () => !snapshotIsActive(),
706
+ },
707
+ );
708
+ if (!batch.ok) return batch;
709
+ if (!snapshotIsActive()) return cancelledResult();
710
+ for (const [handleId] of batchEntries) {
711
+ const bytes = batch.value.get(handleId);
712
+ if (bytes === undefined) {
713
+ return makeErr(
714
+ createRhiDebugError('capture-snapshot-failed', {
715
+ stage: 'snapshot',
716
+ cause: 'the batched GPU readback returned no bytes for a live buffer',
717
+ handleId,
718
+ resourceKind: 'buffer',
719
+ }),
720
+ );
721
+ }
722
+ const dataHash = storeBlob(s, bytes);
723
+ pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
724
+ s.snapshotSeededHandles.add(handleId);
725
+ }
726
+ } else {
727
+ const requests = batchEntries.map(([handleId, entry]) => {
728
+ const { width, height, layerCount } = projectTextureExtent(entry.size);
729
+ const layout = computeTextureLayout(
730
+ entry.format,
731
+ width,
732
+ height,
733
+ layerCount,
734
+ entry.mipLevelCount ?? 1,
735
+ );
736
+ if (layout === undefined) {
737
+ throw new Error(`texture '${handleId}' has no snapshottable byte layout`);
738
+ }
739
+ return {
740
+ handleId,
741
+ texture: entry.resource,
742
+ bytesPerBlock: layout.bytesPerBlock,
743
+ blockWidth: layout.blockWidth,
744
+ blockHeight: layout.blockHeight,
745
+ totalBytes: layout.totalBytes,
746
+ slices: layout.slices,
747
+ };
748
+ });
749
+ const batch = await readbackTexturePixelsBatch(realDevice, requests, {
750
+ onResourceStart: (handleId) => {
751
+ if (s.snapshotProgress !== undefined) {
752
+ s.snapshotProgress = {
753
+ ...s.snapshotProgress,
754
+ currentHandleId: handleId,
755
+ currentKind: 'texture',
756
+ currentSizeBytes: null,
757
+ };
758
+ }
759
+ },
760
+ isCancelled: () => !snapshotIsActive(),
761
+ });
762
+ if (!batch.ok) return batch;
763
+ if (!snapshotIsActive()) return cancelledResult();
764
+ for (const [handleId] of batchEntries) {
765
+ // A transient texture can be released while its batch is waiting
766
+ // on the GPU. The copy was intentionally isolated and cleaned up,
767
+ // but the released handle must not become a seed for the next
768
+ // frame. Buffers retain the historical batch behavior above.
769
+ if (!s.descriptorTable.has(handleId)) {
770
+ if (s.snapshotProgress !== undefined) {
771
+ s.snapshotProgress = {
772
+ ...s.snapshotProgress,
773
+ skippedResources: s.snapshotProgress.skippedResources + 1,
774
+ currentHandleId: null,
775
+ currentKind: null,
776
+ currentSizeBytes: null,
777
+ };
778
+ }
779
+ continue;
780
+ }
781
+ const bytes = batch.value.get(handleId);
782
+ if (bytes === undefined) {
783
+ return makeErr(
784
+ createRhiDebugError('capture-snapshot-failed', {
785
+ stage: 'snapshot',
786
+ cause: 'the batched GPU readback returned no bytes for a live texture',
787
+ handleId,
788
+ resourceKind: 'texture',
789
+ }),
790
+ );
791
+ }
792
+ const dataHash = storeBlob(s, bytes);
793
+ pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
794
+ s.snapshotSeededHandles.add(handleId);
795
+ if (s.snapshotProgress !== undefined) {
796
+ s.snapshotProgress = {
797
+ ...s.snapshotProgress,
798
+ completedResources: s.snapshotProgress.completedResources + 1,
799
+ currentHandleId: null,
800
+ currentKind: null,
801
+ currentSizeBytes: null,
802
+ };
803
+ }
804
+ }
805
+ }
806
+ if (!snapshotIsActive()) return cancelledResult();
807
+ }
808
+ }
809
+
810
+ if (s.snapshotProgress !== undefined) {
811
+ s.snapshotProgress = {
812
+ ...s.snapshotProgress,
813
+ currentHandleId: null,
814
+ currentKind: null,
815
+ currentSizeBytes: null,
816
+ };
817
+ }
818
+
819
+ if (s.state !== RecorderState.Snapshotting || s.snapshotGeneration !== snapshotGeneration) {
820
+ return makeErr(
821
+ createRhiDebugError('capture-snapshot-failed', {
822
+ stage: 'snapshot',
823
+ cause: 'snapshot was cancelled before the full live-resource table was seeded',
824
+ }),
825
+ );
826
+ }
827
+ s.state = RecorderState.Recording;
828
+ return makeOk(undefined);
829
+ }
830
+
831
+ // --------------------------------------------------
832
+ // proxy construction
833
+ // --------------------------------------------------
834
+
835
+ return {
836
+ arm,
837
+ onFrameEnd,
838
+ getTape,
839
+ getState,
840
+ getEvents,
841
+ getBlobPool,
842
+ transitionToError,
843
+ disposeError,
844
+ snapshotResource,
845
+ snapshotAllLiveResources,
846
+ };
847
+ }