@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,804 @@
1
+ // @forgeax/engine-rhi-debug/src/readback — shared GPU texture→host readback utilities.
2
+ //
3
+ // Extracted from inspector.ts (round 1 fix-up 34be40d6, I-7) for reuse by
4
+ // replayer.readbackRt() (m5b-1) and e2e.dawn.test.ts (m5b-3).
5
+ //
6
+ // Related: plan-strategy §5.3.1; m5b-1 / m5b-3.
7
+
8
+ /// <reference types="@webgpu/types" />
9
+
10
+ import type {
11
+ Buffer,
12
+ MappedBuffer,
13
+ RhiCommandEncoder,
14
+ RhiDevice,
15
+ RhiQueue,
16
+ } from '@forgeax/engine-rhi';
17
+ import type { Result } from '@forgeax/engine-types';
18
+ import { err, ok } from '@forgeax/engine-types';
19
+ import { createRhiDebugError, type RhiDebugError } from './errors';
20
+ import type { RhiCallEvent } from './types';
21
+
22
+ // GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ = 8 | 1 = 9.
23
+ const COPY_DST_MAP_READ = 9;
24
+ const TEXTURE_READBACK_USAGE = COPY_DST_MAP_READ;
25
+
26
+ // ============================================================================
27
+ // resolveTextureDescriptor — tape handle -> source texture descriptor (SSOT)
28
+ // ============================================================================
29
+
30
+ /** Resolved descriptor for a texture (or texture-view) handle from the tape. */
31
+ export interface ResolvedTextureDescriptor {
32
+ /** The source GPUTexture handleId (copyTextureToBuffer needs a texture, not a view). */
33
+ readonly handleId: string;
34
+ readonly width: number;
35
+ readonly height: number;
36
+ readonly format: string;
37
+ /** The view's dimension ('2d' | 'cube' | '2d-array' | '3d' | ...); '2d' when no view event. */
38
+ readonly dimension: string;
39
+ /** The source texture's depthOrArrayLayers (slice count); 1 for a plain 2D texture. */
40
+ readonly arrayLayers: number;
41
+ }
42
+
43
+ /**
44
+ * Walk the tape events to resolve a view-or-texture handleId to its source
45
+ * GPUTexture descriptor (handleId, real dimensions, format, view dimension).
46
+ *
47
+ * The single source of truth for "tape handle -> texture descriptor": both the
48
+ * color-attachment RT path (resolveAttachmentSize / readbackDrawRt) and the
49
+ * viewer's depth + bound-texture preview paths resolve handles this way —
50
+ * createTextureView.resultHandleId -> sourceHandleId -> createTexture, falling
51
+ * back to the id itself when it is a direct texture handle (no view event).
52
+ *
53
+ * Size is read from the raw createTexture event. Returns null when no
54
+ * createTexture event declares the resolved handle.
55
+ */
56
+ export function resolveTextureDescriptor(
57
+ events: readonly RhiCallEvent[],
58
+ viewOrTextureHandleId: string,
59
+ ): ResolvedTextureDescriptor | null {
60
+ // Step 1: resolve texture view -> source texture handleId + capture view dimension.
61
+ let sourceTextureHandleId: string | undefined;
62
+ let viewDimension: string | undefined;
63
+ for (const ev of events) {
64
+ if (ev.kind === 'createTextureView' && ev.resultHandleId === viewOrTextureHandleId) {
65
+ sourceTextureHandleId = ev.sourceHandleId;
66
+ viewDimension = ev.desc.dimension;
67
+ break;
68
+ }
69
+ }
70
+ // Some handles are texture handles directly (no view event).
71
+ const targetHandleId = sourceTextureHandleId ?? viewOrTextureHandleId;
72
+
73
+ // Step 2: find the createTexture event for the resolved texture handleId.
74
+ for (const ev of events) {
75
+ if (ev.kind === 'createTexture' && ev.handleId === targetHandleId) {
76
+ const sz = ev.desc.size;
77
+ let width: number;
78
+ let height: number;
79
+ let arrayLayers: number;
80
+ // GPUExtent3DStrict: { width, height?, depthOrArrayLayers? } or [w, h?, d?]
81
+ if (Array.isArray(sz)) {
82
+ width = typeof sz[0] === 'number' ? sz[0] : 512;
83
+ height = typeof sz[1] === 'number' ? sz[1] : width;
84
+ arrayLayers = typeof sz[2] === 'number' ? sz[2] : 1;
85
+ } else {
86
+ const obj = sz as { width: number; height?: number; depthOrArrayLayers?: number };
87
+ width = typeof obj.width === 'number' ? obj.width : 512;
88
+ height = typeof obj.height === 'number' ? obj.height : width;
89
+ arrayLayers = typeof obj.depthOrArrayLayers === 'number' ? obj.depthOrArrayLayers : 1;
90
+ }
91
+ return {
92
+ handleId: targetHandleId,
93
+ width,
94
+ height,
95
+ format: ev.desc.format,
96
+ // View dimension wins; else the texture's own dimension; else '2d'.
97
+ dimension: viewDimension ?? ev.desc.dimension ?? '2d',
98
+ arrayLayers,
99
+ };
100
+ }
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ // ============================================================================
107
+ // resolveAttachmentSize — walk tape events to find texture dimensions
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Walk the tape events to find the real texture dimensions for a given
112
+ * color attachment view/target handleId. Avoids hard-coding 512×512.
113
+ *
114
+ * Thin wrapper over {@link resolveTextureDescriptor}; returns
115
+ * { width: 512, height: 512 } as a conservative fallback when no createTexture
116
+ * event is found (should not happen for a real frame).
117
+ */
118
+ export function resolveAttachmentSize(
119
+ events: readonly RhiCallEvent[],
120
+ attachmentViewHandleId: string,
121
+ ): { readonly width: number; readonly height: number } {
122
+ const desc = resolveTextureDescriptor(events, attachmentViewHandleId);
123
+ if (desc === null) return { width: 512, height: 512 };
124
+ return { width: desc.width, height: desc.height };
125
+ }
126
+
127
+ // ============================================================================
128
+ // readbackTexturePixels — copyTextureToBuffer + mapAsync + getMappedRange
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Read back raw RGBA8 pixels from a GPU texture into a host-side Uint8Array.
133
+ *
134
+ * Steps:
135
+ * 1. Create a staging buffer (COPY_DST | MAP_READ) sized to aligned rows.
136
+ * 2. Create a command encoder + copyTextureToBuffer.
137
+ * 3. Finish + submit + await onSubmittedWorkDone.
138
+ * 4. mapAsync(READ) + getMappedRange() → new Uint8Array(slice).
139
+ * 5. Unmap + destroy staging buffer.
140
+ *
141
+ * The returned Uint8Array has length = texWidth * texHeight * 4 (tight;
142
+ * alignment padding is stripped). The buffer alignment is WebGPU 256-byte
143
+ * row requirement.
144
+ *
145
+ * @param device - The RHI device that owns the texture.
146
+ * @param texture - The texture to read back (opaque branded handle at the boundary).
147
+ * @param texWidth - Texture width in pixels.
148
+ * @param texHeight - Texture height in pixels.
149
+ */
150
+ export async function readbackTexturePixels(
151
+ device: RhiDevice,
152
+ texture: unknown,
153
+ texWidth: number,
154
+ texHeight: number,
155
+ opts?: {
156
+ /** Bytes in one uncompressed texel; retained for depth/color callers. */
157
+ bytesPerTexel?: number;
158
+ /** Compressed-format footprint; defaults to bytesPerTexel with a 1x1 block. */
159
+ bytesPerBlock?: number;
160
+ blockWidth?: number;
161
+ blockHeight?: number;
162
+ mipLevel?: number;
163
+ baseArrayLayer?: number;
164
+ aspect?: 'all' | 'depth-only' | 'stencil-only';
165
+ },
166
+ ): Promise<Uint8Array> {
167
+ const bytesPerBlock = opts?.bytesPerBlock ?? opts?.bytesPerTexel ?? 4;
168
+ const blockWidth = opts?.blockWidth ?? 1;
169
+ const blockHeight = opts?.blockHeight ?? 1;
170
+ const blockCountX = Math.ceil(texWidth / blockWidth);
171
+ const blockCountY = Math.ceil(texHeight / blockHeight);
172
+ const copyWidth = blockCountX * blockWidth;
173
+ const copyHeight = blockCountY * blockHeight;
174
+ const mipLevel = opts?.mipLevel ?? 0;
175
+ const baseArrayLayer = opts?.baseArrayLayer ?? 0;
176
+ const aspect = opts?.aspect;
177
+ const rowBytes = blockCountX * bytesPerBlock;
178
+ const alignedRowBytes = Math.ceil(rowBytes / 256) * 256; // WebGPU alignment
179
+ const bufferSize = alignedRowBytes * blockCountY;
180
+
181
+ const readbackBufferResult = device.createBuffer({
182
+ size: bufferSize,
183
+ usage: COPY_DST_MAP_READ,
184
+ });
185
+ if (!readbackBufferResult.ok) {
186
+ throw new Error(`createBuffer for readback failed: ${readbackBufferResult.error.code}`);
187
+ }
188
+ const readbackBuffer = readbackBufferResult.value;
189
+
190
+ const encoderResult = device.createCommandEncoder({});
191
+ if (!encoderResult.ok) {
192
+ device.destroyBuffer(readbackBuffer);
193
+ throw new Error(`createCommandEncoder for readback failed: ${encoderResult.error.code}`);
194
+ }
195
+ const encoder = encoderResult.value;
196
+
197
+ try {
198
+ encoder.copyTextureToBuffer(
199
+ {
200
+ texture,
201
+ mipLevel,
202
+ origin: { x: 0, y: 0, z: baseArrayLayer },
203
+ // aspect selects depth vs stencil plane on combined depth-stencil
204
+ // textures. stencil-only IS copyable on depth24plus-stencil8 (the
205
+ // depth plane is not). Omitted -> backend default ('all').
206
+ ...(aspect !== undefined ? { aspect } : {}),
207
+ } as unknown as never,
208
+ {
209
+ buffer: readbackBuffer,
210
+ offset: 0,
211
+ bytesPerRow: alignedRowBytes,
212
+ rowsPerImage: blockCountY,
213
+ } as unknown as never,
214
+ { width: copyWidth, height: copyHeight, depthOrArrayLayers: 1 },
215
+ );
216
+ } catch {
217
+ device.destroyBuffer(readbackBuffer);
218
+ throw new Error('copyTextureToBuffer failed');
219
+ }
220
+
221
+ const finishResult = encoder.finish();
222
+ if (!finishResult.ok) {
223
+ device.destroyBuffer(readbackBuffer);
224
+ throw new Error(`encoder.finish failed: ${finishResult.error.code}`);
225
+ }
226
+
227
+ const queue: RhiQueue = device.queue;
228
+ queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
229
+ await queue.onSubmittedWorkDone();
230
+
231
+ // RHI Buffer.mapAsync / MappedBuffer.getMappedRange return Result wrappers, not
232
+ // the raw spec void / ArrayBuffer. The previous `as unknown as { ... }` casts
233
+ // hid that: mapAsync was called with mode=2 (which is GPUMapMode.WRITE, not
234
+ // READ=0x1) and getMappedRange's Result object was fed straight into
235
+ // `new Uint8Array(...)`, yielding a zero-length array — every RT readback came
236
+ // back all-zero (transparent black), which the e2e delta check missed because
237
+ // baseline and replay were equally empty (empty-vs-empty trap).
238
+ const buffer = readbackBuffer as unknown as Buffer;
239
+ // GPUMapMode.READ = 0x1
240
+ const mapResult = await buffer.mapAsync(0x1);
241
+ if (!mapResult.ok) {
242
+ device.destroyBuffer(readbackBuffer);
243
+ throw new Error(`mapAsync(READ) failed: ${mapResult.error.code}`);
244
+ }
245
+ const mapped: MappedBuffer = mapResult.value;
246
+
247
+ const rangeResult = mapped.getMappedRange();
248
+ if (!rangeResult.ok) {
249
+ mapped.unmap();
250
+ device.destroyBuffer(readbackBuffer);
251
+ throw new Error(`getMappedRange failed: ${rangeResult.error.code}`);
252
+ }
253
+ const fullPixels = new Uint8Array(rangeResult.value);
254
+
255
+ // Extract tight pixels (strip alignment padding)
256
+ const tightPixels = new Uint8Array(blockCountX * blockCountY * bytesPerBlock);
257
+ for (let y = 0; y < blockCountY; y++) {
258
+ const srcOffset = y * alignedRowBytes;
259
+ const dstOffset = y * rowBytes;
260
+ for (let x = 0; x < rowBytes; x++) {
261
+ tightPixels[dstOffset + x] = fullPixels[srcOffset + x] ?? 0;
262
+ }
263
+ }
264
+
265
+ // Cleanup
266
+ mapped.unmap();
267
+ device.destroyBuffer(readbackBuffer);
268
+
269
+ return tightPixels;
270
+ }
271
+
272
+ // ============================================================================
273
+ // readbackBufferBytes — copyBufferToBuffer + mapAsync + getMappedRange (D-7)
274
+ // ============================================================================
275
+
276
+ /**
277
+ * Read back the raw bytes of a GPU buffer into a host-side ArrayBuffer.
278
+ *
279
+ * Sibling of readbackTexturePixels under the single "GPU byte readback"
280
+ * responsibility unit (plan-strategy D-7) — snapshotResource calls this to
281
+ * capture a buffer's initial GPU bytes at frame-header time.
282
+ *
283
+ * Steps:
284
+ * 1. Create a staging buffer (COPY_DST | MAP_READ) sized to `size`.
285
+ * 2. Create a command encoder + copyBufferToBuffer(src, 0, staging, 0, size).
286
+ * 3. Finish + submit + await onSubmittedWorkDone.
287
+ * 4. mapAsync(READ=0x1) + getMappedRange() -> sliced ArrayBuffer copy.
288
+ * 5. Unmap + destroy staging buffer.
289
+ *
290
+ * Returns Ok(ArrayBuffer) (a detached copy independent of the mapped range)
291
+ * or Err(readback-failed) with `.detail.phase` narrowing
292
+ * the failure point (copy / map). The buffer is passed opaque (`unknown`)
293
+ * because RHI handles are branded; the caller resolved it from the descriptor
294
+ * registry. The caller (snapshotResource) holds the handleId and maps this
295
+ * readback error to the capture-snapshot-failed boundary.
296
+ *
297
+ * Reuses the M0-fixed mapAsync(0x1) + Result-unwrap pattern from
298
+ * readbackTexturePixels (never the all-zero mode=2 bug).
299
+ *
300
+ * @param device - The RHI device that owns the buffer.
301
+ * @param buffer - The source buffer (opaque branded handle) to read back.
302
+ * @param size - Number of bytes to read back (the buffer's recorded size).
303
+ */
304
+ export async function readbackBufferBytes(
305
+ device: RhiDevice,
306
+ buffer: unknown,
307
+ size: number,
308
+ ): Promise<Result<ArrayBuffer, RhiDebugError>> {
309
+ const fail = (phase: 'copy' | 'map', cause: string): Result<ArrayBuffer, RhiDebugError> =>
310
+ err(createRhiDebugError('readback-failed', { stage: 'readback', phase, cause }));
311
+
312
+ const readbackBufferResult = device.createBuffer({ size, usage: COPY_DST_MAP_READ });
313
+ if (!readbackBufferResult.ok) {
314
+ return fail('copy', `staging buffer creation failed: ${readbackBufferResult.error.code}`);
315
+ }
316
+ const readbackBuffer = readbackBufferResult.value;
317
+
318
+ const encoderResult = device.createCommandEncoder({});
319
+ if (!encoderResult.ok) {
320
+ device.destroyBuffer(readbackBuffer);
321
+ return fail('copy', `command encoder creation failed: ${encoderResult.error.code}`);
322
+ }
323
+ const encoder = encoderResult.value;
324
+
325
+ try {
326
+ encoder.copyBufferToBuffer(buffer as Buffer, 0, readbackBuffer, 0, size);
327
+ } catch (e) {
328
+ device.destroyBuffer(readbackBuffer);
329
+ return fail('copy', `copyBufferToBuffer failed: ${String(e)}`);
330
+ }
331
+
332
+ const finishResult = encoder.finish();
333
+ if (!finishResult.ok) {
334
+ device.destroyBuffer(readbackBuffer);
335
+ return fail('copy', `encoder.finish failed: ${finishResult.error.code}`);
336
+ }
337
+
338
+ const queue: RhiQueue = device.queue;
339
+ queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
340
+ await queue.onSubmittedWorkDone();
341
+
342
+ const stagingBuffer = readbackBuffer as unknown as Buffer;
343
+ // GPUMapMode.READ = 0x1
344
+ const mapResult = await stagingBuffer.mapAsync(0x1);
345
+ if (!mapResult.ok) {
346
+ device.destroyBuffer(readbackBuffer);
347
+ return fail('map', `mapAsync(READ) failed: ${mapResult.error.code}`);
348
+ }
349
+ const mapped: MappedBuffer = mapResult.value;
350
+
351
+ const rangeResult = mapped.getMappedRange();
352
+ if (!rangeResult.ok) {
353
+ mapped.unmap();
354
+ device.destroyBuffer(readbackBuffer);
355
+ return fail('map', `getMappedRange failed: ${rangeResult.error.code}`);
356
+ }
357
+
358
+ // Copy the mapped bytes into a standalone ArrayBuffer before unmap — the
359
+ // mapped range is invalidated on unmap.
360
+ const bytes = new Uint8Array(rangeResult.value).slice();
361
+
362
+ mapped.unmap();
363
+ device.destroyBuffer(readbackBuffer);
364
+
365
+ return ok(bytes.buffer as ArrayBuffer);
366
+ }
367
+
368
+ /** A load-time buffer that can be read back as part of one GPU submission. */
369
+ export interface BufferReadbackBatchRequest {
370
+ readonly handleId: string;
371
+ readonly buffer: unknown;
372
+ readonly size: number;
373
+ }
374
+
375
+ export interface BufferReadbackBatchCallbacks {
376
+ readonly onResourceStart?: (handleId: string) => void;
377
+ readonly onResourceComplete?: (handleId: string) => void;
378
+ /** Return true when the owning snapshot generation has been invalidated. */
379
+ readonly isCancelled?: () => boolean;
380
+ }
381
+
382
+ async function raceCancellation<T>(
383
+ work: Promise<T>,
384
+ isCancelled: (() => boolean) | undefined,
385
+ ): Promise<{ readonly cancelled: true } | { readonly cancelled: false; readonly value: T }> {
386
+ if (isCancelled === undefined) return { cancelled: false, value: await work };
387
+ if (isCancelled()) return { cancelled: true };
388
+
389
+ let timer: ReturnType<typeof setInterval> | undefined;
390
+ const cancelled = new Promise<{ readonly cancelled: true }>((resolve) => {
391
+ timer = setInterval(() => {
392
+ if (isCancelled()) resolve({ cancelled: true });
393
+ }, 1);
394
+ });
395
+ try {
396
+ return await Promise.race([
397
+ work.then((value) => ({ cancelled: false as const, value })),
398
+ cancelled,
399
+ ]);
400
+ } finally {
401
+ if (timer !== undefined) clearInterval(timer);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Read back multiple buffers with one command submission and one queue drain.
407
+ *
408
+ * Prism City exposed the cost of the old one-buffer helper: every resource
409
+ * submitted and awaited independently, so thousands of small buffers spent
410
+ * most of capture time in synchronization rather than byte transfer. The
411
+ * batch keeps each staging buffer isolated but submits all copies together;
412
+ * mapping remains per-resource so a timeout can still identify the current
413
+ * handle and the caller can preserve the original initialData event order.
414
+ */
415
+ export async function readbackBufferBytesBatch(
416
+ device: RhiDevice,
417
+ requests: readonly BufferReadbackBatchRequest[],
418
+ callbacks: BufferReadbackBatchCallbacks = {},
419
+ ): Promise<Result<ReadonlyMap<string, ArrayBuffer>, RhiDebugError>> {
420
+ if (requests.length === 0) return ok(new Map());
421
+ const firstRequest = requests[0];
422
+ if (firstRequest === undefined) return ok(new Map());
423
+
424
+ const fail = (handleId: string, phase: 'copy' | 'map', cause: string) =>
425
+ err(
426
+ createRhiDebugError('readback-failed', {
427
+ stage: 'readback',
428
+ phase,
429
+ cause: `${handleId}: ${cause}`,
430
+ }),
431
+ );
432
+ const staging: Array<{ readonly request: BufferReadbackBatchRequest; readonly buffer: Buffer }> =
433
+ [];
434
+ const mapped = new Map<Buffer, MappedBuffer>();
435
+ const cleaned = new Set<Buffer>();
436
+ const cleanup = () => {
437
+ for (const mappedBuffer of mapped.values()) mappedBuffer.unmap();
438
+ for (const item of staging) {
439
+ if (!cleaned.has(item.buffer)) {
440
+ device.destroyBuffer(item.buffer);
441
+ cleaned.add(item.buffer);
442
+ }
443
+ }
444
+ };
445
+
446
+ let encoder: RhiCommandEncoder;
447
+ try {
448
+ const encoderResult = device.createCommandEncoder({});
449
+ if (!encoderResult.ok)
450
+ return fail(
451
+ firstRequest.handleId,
452
+ 'copy',
453
+ `command encoder creation failed: ${encoderResult.error.code}`,
454
+ );
455
+ encoder = encoderResult.value;
456
+ for (const request of requests) {
457
+ const stagingResult = device.createBuffer({ size: request.size, usage: COPY_DST_MAP_READ });
458
+ if (!stagingResult.ok) {
459
+ cleanup();
460
+ return fail(
461
+ request.handleId,
462
+ 'copy',
463
+ `staging buffer creation failed: ${stagingResult.error.code}`,
464
+ );
465
+ }
466
+ const stagingBuffer = stagingResult.value;
467
+ staging.push({ request, buffer: stagingBuffer });
468
+ try {
469
+ encoder.copyBufferToBuffer(request.buffer as Buffer, 0, stagingBuffer, 0, request.size);
470
+ } catch (error) {
471
+ cleanup();
472
+ return fail(request.handleId, 'copy', `copyBufferToBuffer failed: ${String(error)}`);
473
+ }
474
+ }
475
+ const finishResult = encoder.finish();
476
+ if (!finishResult.ok) {
477
+ cleanup();
478
+ return fail(
479
+ firstRequest.handleId,
480
+ 'copy',
481
+ `encoder.finish failed: ${finishResult.error.code}`,
482
+ );
483
+ }
484
+ device.queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
485
+ await device.queue.onSubmittedWorkDone();
486
+
487
+ // Start every mapAsync together. The GPU work has already been submitted
488
+ // and drained; awaiting each map before starting the next one recreates a
489
+ // per-resource synchronization wall even after batching the copies.
490
+ const mapResultsPromise = Promise.all(
491
+ staging.map(async (item) => {
492
+ callbacks.onResourceStart?.(item.request.handleId);
493
+ try {
494
+ const result = await item.buffer.mapAsync(0x1);
495
+ if (result.ok) {
496
+ if (callbacks.isCancelled?.()) result.value.unmap();
497
+ else mapped.set(item.buffer, result.value);
498
+ }
499
+ return { item, result };
500
+ } catch (error) {
501
+ return { item, error };
502
+ }
503
+ }),
504
+ );
505
+ const mapResults = await raceCancellation(mapResultsPromise, callbacks.isCancelled);
506
+ if (mapResults.cancelled) {
507
+ cleanup();
508
+ return fail(
509
+ firstRequest.handleId,
510
+ 'map',
511
+ 'buffer batch readback cancelled after the snapshot generation was invalidated',
512
+ );
513
+ }
514
+ const result = new Map<string, ArrayBuffer>();
515
+ for (const mappedResult of mapResults.value) {
516
+ if ('error' in mappedResult) {
517
+ cleanup();
518
+ return fail(
519
+ mappedResult.item.request.handleId,
520
+ 'map',
521
+ `mapAsync(READ) failed: ${String(mappedResult.error)}`,
522
+ );
523
+ }
524
+ if (!mappedResult.result.ok) {
525
+ cleanup();
526
+ return fail(
527
+ mappedResult.item.request.handleId,
528
+ 'map',
529
+ `mapAsync(READ) failed: ${mappedResult.result.error.code}`,
530
+ );
531
+ }
532
+ const mappedBuffer = mappedResult.result.value;
533
+ const item = mappedResult.item;
534
+ const rangeResult = mappedBuffer.getMappedRange();
535
+ if (!rangeResult.ok) {
536
+ cleanup();
537
+ return fail(
538
+ item.request.handleId,
539
+ 'map',
540
+ `getMappedRange failed: ${rangeResult.error.code}`,
541
+ );
542
+ }
543
+ result.set(
544
+ item.request.handleId,
545
+ new Uint8Array(rangeResult.value).slice().buffer as ArrayBuffer,
546
+ );
547
+ mappedBuffer.unmap();
548
+ mapped.delete(item.buffer);
549
+ device.destroyBuffer(item.buffer);
550
+ cleaned.add(item.buffer);
551
+ callbacks.onResourceComplete?.(item.request.handleId);
552
+ }
553
+ return ok(result);
554
+ } catch (error) {
555
+ cleanup();
556
+ return fail(firstRequest.handleId, 'map', `buffer batch readback failed: ${String(error)}`);
557
+ }
558
+ }
559
+
560
+ /** One (layer, mip) copy contributing to a complete texture snapshot blob. */
561
+ export interface TextureReadbackBatchSlice {
562
+ readonly layer: number;
563
+ readonly mip: number;
564
+ readonly width: number;
565
+ readonly height: number;
566
+ readonly byteOffset: number;
567
+ readonly byteLength: number;
568
+ }
569
+
570
+ /** A complete texture whose subresources are copied in one bounded batch. */
571
+ export interface TextureReadbackBatchRequest {
572
+ readonly handleId: string;
573
+ readonly texture: unknown;
574
+ readonly bytesPerBlock: number;
575
+ readonly blockWidth: number;
576
+ readonly blockHeight: number;
577
+ readonly totalBytes: number;
578
+ readonly slices: readonly TextureReadbackBatchSlice[];
579
+ }
580
+
581
+ export interface TextureReadbackBatchCallbacks {
582
+ readonly onResourceStart?: (handleId: string) => void;
583
+ readonly onResourceComplete?: (handleId: string) => void;
584
+ /** Return true when the owning snapshot generation has been invalidated. */
585
+ readonly isCancelled?: () => boolean;
586
+ }
587
+
588
+ /**
589
+ * Read complete texture snapshots with one submission and drain per bounded
590
+ * resource batch. Each subresource retains its own staging buffer, so mip and
591
+ * array-layer bytes are copied without padding or ordering loss.
592
+ */
593
+ export async function readbackTexturePixelsBatch(
594
+ device: RhiDevice,
595
+ requests: readonly TextureReadbackBatchRequest[],
596
+ callbacks: TextureReadbackBatchCallbacks = {},
597
+ ): Promise<Result<ReadonlyMap<string, ArrayBuffer>, RhiDebugError>> {
598
+ if (requests.length === 0) return ok(new Map());
599
+ const firstRequest = requests[0];
600
+ if (firstRequest === undefined) return ok(new Map());
601
+
602
+ const fail = (handleId: string, phase: 'copy' | 'map', cause: string) =>
603
+ err(
604
+ createRhiDebugError('readback-failed', {
605
+ stage: 'readback',
606
+ phase,
607
+ cause: `${handleId}: ${cause}`,
608
+ }),
609
+ );
610
+ const staging: Array<{
611
+ readonly request: TextureReadbackBatchRequest;
612
+ readonly slice: TextureReadbackBatchSlice;
613
+ readonly buffer: Buffer;
614
+ readonly bytesPerBlock: number;
615
+ readonly blockWidth: number;
616
+ readonly blockHeight: number;
617
+ }> = [];
618
+ const mapped = new Map<Buffer, MappedBuffer>();
619
+ const cleaned = new Set<Buffer>();
620
+ const cleanup = () => {
621
+ for (const mappedBuffer of mapped.values()) mappedBuffer.unmap();
622
+ for (const item of staging) {
623
+ if (!cleaned.has(item.buffer)) {
624
+ device.destroyBuffer(item.buffer);
625
+ cleaned.add(item.buffer);
626
+ }
627
+ }
628
+ };
629
+
630
+ let encoder: RhiCommandEncoder;
631
+ try {
632
+ const encoderResult = device.createCommandEncoder({});
633
+ if (!encoderResult.ok)
634
+ return fail(
635
+ firstRequest.handleId,
636
+ 'copy',
637
+ `command encoder creation failed: ${encoderResult.error.code}`,
638
+ );
639
+ encoder = encoderResult.value;
640
+ for (const request of requests) {
641
+ for (const slice of request.slices) {
642
+ const blockCountX = Math.ceil(slice.width / request.blockWidth);
643
+ const blockCountY = Math.ceil(slice.height / request.blockHeight);
644
+ const rowBytes = blockCountX * request.bytesPerBlock;
645
+ const alignedRowBytes = Math.ceil(rowBytes / 256) * 256;
646
+ const stagingResult = device.createBuffer({
647
+ size: alignedRowBytes * blockCountY,
648
+ usage: TEXTURE_READBACK_USAGE,
649
+ });
650
+ if (!stagingResult.ok) {
651
+ cleanup();
652
+ return fail(
653
+ request.handleId,
654
+ 'copy',
655
+ `staging buffer creation failed: ${stagingResult.error.code}`,
656
+ );
657
+ }
658
+ const stagingBuffer = stagingResult.value;
659
+ staging.push({
660
+ request,
661
+ slice,
662
+ buffer: stagingBuffer,
663
+ bytesPerBlock: request.bytesPerBlock,
664
+ blockWidth: request.blockWidth,
665
+ blockHeight: request.blockHeight,
666
+ });
667
+ try {
668
+ encoder.copyTextureToBuffer(
669
+ {
670
+ texture: request.texture,
671
+ mipLevel: slice.mip,
672
+ origin: { x: 0, y: 0, z: slice.layer },
673
+ } as unknown as never,
674
+ {
675
+ buffer: stagingBuffer,
676
+ offset: 0,
677
+ bytesPerRow: alignedRowBytes,
678
+ rowsPerImage: blockCountY,
679
+ } as unknown as never,
680
+ {
681
+ width: blockCountX * request.blockWidth,
682
+ height: blockCountY * request.blockHeight,
683
+ depthOrArrayLayers: 1,
684
+ },
685
+ );
686
+ } catch (error) {
687
+ cleanup();
688
+ return fail(request.handleId, 'copy', `copyTextureToBuffer failed: ${String(error)}`);
689
+ }
690
+ }
691
+ }
692
+ const finishResult = encoder.finish();
693
+ if (!finishResult.ok) {
694
+ cleanup();
695
+ return fail(
696
+ firstRequest.handleId,
697
+ 'copy',
698
+ `encoder.finish failed: ${finishResult.error.code}`,
699
+ );
700
+ }
701
+ device.queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
702
+ const drain = raceCancellation(device.queue.onSubmittedWorkDone(), callbacks.isCancelled);
703
+ const drainResult = await drain;
704
+ if (drainResult.cancelled) {
705
+ cleanup();
706
+ return fail(
707
+ firstRequest.handleId,
708
+ 'map',
709
+ 'texture batch readback cancelled after the snapshot generation was invalidated',
710
+ );
711
+ }
712
+
713
+ for (const request of requests) callbacks.onResourceStart?.(request.handleId);
714
+ const mapResultsPromise = Promise.all(
715
+ staging.map(async (item) => {
716
+ try {
717
+ const result = await item.buffer.mapAsync(0x1);
718
+ if (result.ok) {
719
+ if (callbacks.isCancelled?.()) result.value.unmap();
720
+ else mapped.set(item.buffer, result.value);
721
+ }
722
+ return { item, result };
723
+ } catch (error) {
724
+ return { item, error };
725
+ }
726
+ }),
727
+ );
728
+ const mapResults = await raceCancellation(mapResultsPromise, callbacks.isCancelled);
729
+ if (mapResults.cancelled) {
730
+ cleanup();
731
+ return fail(
732
+ firstRequest.handleId,
733
+ 'map',
734
+ 'texture batch readback cancelled after the snapshot generation was invalidated',
735
+ );
736
+ }
737
+
738
+ const bytesByHandle = new Map<string, Uint8Array>();
739
+ for (const request of requests)
740
+ bytesByHandle.set(request.handleId, new Uint8Array(request.totalBytes));
741
+ for (const mappedResult of mapResults.value) {
742
+ if ('error' in mappedResult) {
743
+ cleanup();
744
+ return fail(
745
+ mappedResult.item.request.handleId,
746
+ 'map',
747
+ `mapAsync(READ) failed: ${String(mappedResult.error)}`,
748
+ );
749
+ }
750
+ if (!mappedResult.result.ok) {
751
+ cleanup();
752
+ return fail(
753
+ mappedResult.item.request.handleId,
754
+ 'map',
755
+ `mapAsync(READ) failed: ${mappedResult.result.error.code}`,
756
+ );
757
+ }
758
+ const item = mappedResult.item;
759
+ const mappedBuffer = mappedResult.result.value;
760
+ const rangeResult = mappedBuffer.getMappedRange();
761
+ if (!rangeResult.ok) {
762
+ cleanup();
763
+ return fail(
764
+ item.request.handleId,
765
+ 'map',
766
+ `getMappedRange failed: ${rangeResult.error.code}`,
767
+ );
768
+ }
769
+ const fullBytes = new Uint8Array(rangeResult.value);
770
+ const blockCountX = Math.ceil(item.slice.width / item.blockWidth);
771
+ const blockCountY = Math.ceil(item.slice.height / item.blockHeight);
772
+ const rowBytes = blockCountX * item.bytesPerBlock;
773
+ const alignedRowBytes = Math.ceil(rowBytes / 256) * 256;
774
+ const output = bytesByHandle.get(item.request.handleId);
775
+ if (output === undefined) {
776
+ cleanup();
777
+ return fail(item.request.handleId, 'map', 'texture batch returned an unknown handle');
778
+ }
779
+ for (let y = 0; y < blockCountY; y++) {
780
+ const srcOffset = y * alignedRowBytes;
781
+ const dstOffset = item.slice.byteOffset + y * rowBytes;
782
+ for (let x = 0; x < rowBytes; x++) output[dstOffset + x] = fullBytes[srcOffset + x] ?? 0;
783
+ }
784
+ mappedBuffer.unmap();
785
+ mapped.delete(item.buffer);
786
+ device.destroyBuffer(item.buffer);
787
+ cleaned.add(item.buffer);
788
+ }
789
+ const result = new Map<string, ArrayBuffer>();
790
+ for (const request of requests) {
791
+ const bytes = bytesByHandle.get(request.handleId);
792
+ if (bytes === undefined) {
793
+ cleanup();
794
+ return fail(request.handleId, 'map', 'texture batch returned no bytes for a live texture');
795
+ }
796
+ result.set(request.handleId, bytes.buffer as ArrayBuffer);
797
+ callbacks.onResourceComplete?.(request.handleId);
798
+ }
799
+ return ok(result);
800
+ } catch (error) {
801
+ cleanup();
802
+ return fail(firstRequest.handleId, 'map', `texture batch readback failed: ${String(error)}`);
803
+ }
804
+ }