@forgeax/engine-rhi 0.1.2

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 (83) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +234 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/async-form.test-d.d.ts +2 -0
  5. package/dist/__tests__/async-form.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/binding-resource.test-d.d.ts +2 -0
  7. package/dist/__tests__/binding-resource.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/buffer-mapping.test-d.d.ts +2 -0
  9. package/dist/__tests__/buffer-mapping.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/canvasContext.test-d.d.ts +2 -0
  11. package/dist/__tests__/canvasContext.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/caps-three-way.unit.test.d.ts +2 -0
  13. package/dist/__tests__/caps-three-way.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/caps.test-d.d.ts +2 -0
  15. package/dist/__tests__/caps.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/command-encoder-spec-alignment.test-d.d.ts +2 -0
  17. package/dist/__tests__/command-encoder-spec-alignment.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/createComputePipeline.test-d.d.ts +2 -0
  19. package/dist/__tests__/createComputePipeline.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/createQuerySet.test-d.d.ts +2 -0
  21. package/dist/__tests__/createQuerySet.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/createTextureView.test-d.d.ts +2 -0
  23. package/dist/__tests__/createTextureView.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/descriptor-mirror.test-d.d.ts +2 -0
  25. package/dist/__tests__/descriptor-mirror.test-d.d.ts.map +1 -0
  26. package/dist/__tests__/draw-validation-errors.test.d.ts +2 -0
  27. package/dist/__tests__/draw-validation-errors.test.d.ts.map +1 -0
  28. package/dist/__tests__/errors-instancing.unit.test.d.ts +2 -0
  29. package/dist/__tests__/errors-instancing.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/errors.test-d.d.ts +2 -0
  31. package/dist/__tests__/errors.test-d.d.ts.map +1 -0
  32. package/dist/__tests__/limit-exceeded-detail.test-d.d.ts +2 -0
  33. package/dist/__tests__/limit-exceeded-detail.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/mapped-buffer.test-d.d.ts +2 -0
  35. package/dist/__tests__/mapped-buffer.test-d.d.ts.map +1 -0
  36. package/dist/__tests__/opaque-handle.test-d.d.ts +2 -0
  37. package/dist/__tests__/opaque-handle.test-d.d.ts.map +1 -0
  38. package/dist/__tests__/owner-out-of-range-role.test.d.ts +2 -0
  39. package/dist/__tests__/owner-out-of-range-role.test.d.ts.map +1 -0
  40. package/dist/__tests__/queue-writes.test-d.d.ts +2 -0
  41. package/dist/__tests__/queue-writes.test-d.d.ts.map +1 -0
  42. package/dist/__tests__/queue-writes.unit.test.d.ts +2 -0
  43. package/dist/__tests__/queue-writes.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/render-pass-encoder-spec-alignment.test-d.d.ts +2 -0
  45. package/dist/__tests__/render-pass-encoder-spec-alignment.test-d.d.ts.map +1 -0
  46. package/dist/__tests__/requestAdapter.test-d.d.ts +2 -0
  47. package/dist/__tests__/requestAdapter.test-d.d.ts.map +1 -0
  48. package/dist/errors.d.ts +315 -0
  49. package/dist/errors.d.ts.map +1 -0
  50. package/dist/errors.mjs +56 -0
  51. package/dist/errors.mjs.map +1 -0
  52. package/dist/index.d.ts +1749 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.mjs +256 -0
  55. package/dist/index.mjs.map +1 -0
  56. package/dist/queue-writes.d.ts +20 -0
  57. package/dist/queue-writes.d.ts.map +1 -0
  58. package/package.json +61 -0
  59. package/src/__tests__/async-form.test-d.ts +40 -0
  60. package/src/__tests__/binding-resource.test-d.ts +66 -0
  61. package/src/__tests__/buffer-mapping.test-d.ts +84 -0
  62. package/src/__tests__/canvasContext.test-d.ts +106 -0
  63. package/src/__tests__/caps-three-way.unit.test.ts +144 -0
  64. package/src/__tests__/caps.test-d.ts +83 -0
  65. package/src/__tests__/command-encoder-spec-alignment.test-d.ts +117 -0
  66. package/src/__tests__/createComputePipeline.test-d.ts +149 -0
  67. package/src/__tests__/createQuerySet.test-d.ts +81 -0
  68. package/src/__tests__/createTextureView.test-d.ts +127 -0
  69. package/src/__tests__/descriptor-mirror.test-d.ts +267 -0
  70. package/src/__tests__/draw-validation-errors.test.ts +155 -0
  71. package/src/__tests__/errors-instancing.unit.test.ts +167 -0
  72. package/src/__tests__/errors.test-d.ts +199 -0
  73. package/src/__tests__/limit-exceeded-detail.test-d.ts +33 -0
  74. package/src/__tests__/mapped-buffer.test-d.ts +65 -0
  75. package/src/__tests__/opaque-handle.test-d.ts +121 -0
  76. package/src/__tests__/owner-out-of-range-role.test.ts +84 -0
  77. package/src/__tests__/queue-writes.test-d.ts +107 -0
  78. package/src/__tests__/queue-writes.unit.test.ts +30 -0
  79. package/src/__tests__/render-pass-encoder-spec-alignment.test-d.ts +202 -0
  80. package/src/__tests__/requestAdapter.test-d.ts +123 -0
  81. package/src/errors.ts +504 -0
  82. package/src/index.ts +2398 -0
  83. package/src/queue-writes.ts +62 -0
package/src/errors.ts ADDED
@@ -0,0 +1,504 @@
1
+ // @forgeax/engine-rhi/src/errors - RhiError + closed RhiErrorCode union + Result<T, E>.
2
+ //
3
+ // Shape:
4
+ // - RhiErrorCode = closed union 23 members (charter P3: closed-union
5
+ // exhaustive switch needs no default fallback; tsc strict mode guards
6
+ // completeness). Extended from 6 to 10 in feat-20260508-rhi-surface-completion
7
+ // w7 (D-S3): added 'command-encoder-finished' / 'render-pass-not-ended' /
8
+ // 'queue-submit-failed' / 'queue-write-buffer-out-of-bounds'.
9
+ // Extended from 10 to 14 in feat-20260509-ecs-render-bridge-mvp w6 (D-S7):
10
+ // added 'render-system-no-camera' / 'render-system-multi-camera' /
11
+ // 'render-system-multi-light' / 'asset-not-registered'.
12
+ // Extended from 14 to 17 in feat-20260511-rhi-spec-realign-aggressive w6
13
+ // (D-P4 + R-02 §2.1 W3C spec 22.2 subtypes): added 'device-lost' / 'oom' /
14
+ // 'internal-error' so the onError fan-out can disambiguate spec error
15
+ // subclasses without falling back to the bucket 'webgpu-runtime-error'.
16
+ // Extended from 17 to 18 in feat-20260511-asset-system-v1 w4
17
+ // (D-P2 + requirements §9 row 8 + AC-04 + AC-21): added
18
+ // 'hierarchy-broken' for `propagateTransforms` stale ChildOf ref fail-fast
19
+ // (ChildOf component references a destroyed entity); same
20
+ // render-system / schedule semantic domain as
21
+ // 'render-system-multi-camera' / 'render-system-no-camera'. Minor add-only
22
+ // per AGENTS.md evolution contract (no reorder / rename / deprecate).
23
+ // Extended from 18 to 19 in feat-20260612-rhi-destroy-renderer-dispose-gpu-
24
+ // lifecycle M1 (D-6 + D-7 + AC-02 / AC-03): added 'destroy-after-destroy'
25
+ // for second `destroyBuffer` / `destroyTexture` on the same handle. The
26
+ // shim layer (rhi-webgpu + rhi-wgpu) tracks per-handle `destroyed: boolean`
27
+ // in WeakMap-backed meta and fail-fasts the second call rather than
28
+ // forwarding it to the underlying GPU (research F-1 wgpu wasm `destroy()`
29
+ // is idempotent void; F-8 WebGPU spec is also idempotent void; D-7 prefers
30
+ // fail-fast over silent idempotency because double-destroy is almost always
31
+ // a lifecycle bug). Minor add-only per AGENTS.md evolution contract.
32
+ // Extended from 19 to 20 in feat-20260619-wasm-fault-isolation M3 w7:
33
+ // added 'rhi-descriptor-invalid' for `createRenderPipeline` (and other
34
+ // create* entries) descriptor parse failures surfaced through the wgpu-wasm
35
+ // backend (Rust `#[wasm_bindgen(catch)]` Err). The prefix-based
36
+ // classification (D-1 / D-2) routes wasm exceptions with the stable marker
37
+ // `[wgpu-wasm] failed to parse` to this code; exceptions without the prefix
38
+ // remain in 'webgpu-runtime-error'. Semantics: descriptor parse failure =
39
+ // caller bug (malformed descriptor data passed from TS), distinct from
40
+ // 'webgpu-runtime-error' = runtime condition (valid descriptor rejected by
41
+ // wgpu backend). Minor add-only per AGENTS.md evolution contract.
42
+ // Extended from 20 to 21 in feat-20260622-chunk-gpu-instancing-sprite-
43
+ // tilemap M2 w10 (D-2 + AC-05 + research N-1): added
44
+ // 'instancing-exceeds-uniform-cap' for the WebGL2 uniform-fallback path
45
+ // when a record-stage fold bucket carries more than 128 instances
46
+ // (128 = MAX_UNIFORM_INSTANCES; 128 * 64B = 8192B comfortably fits the
47
+ // WebGL2 minimum 16384B UBO size, leaving headroom for the per-frame
48
+ // material UBO slice — research N-1 implements the locked value). The
49
+ // record-stage dispatch site fires the error AND falls the offending
50
+ // bucket back to per-entity drawIndexed (the same exit the mode-gate
51
+ // bypass uses — plan-strategy D-9 "shared fallback exit"). Semantics:
52
+ // distinct from 'limit-exceeded' (byte-cap against
53
+ // maxStorageBufferBindingSize) — this code targets the per-bucket
54
+ // instance-count cap, which is a backend-capability ceiling rather than
55
+ // an allocation-size ceiling. AI users branch on .code first then read
56
+ // detail.requested / .limit / .scope through property access (charter
57
+ // P3 + plan-strategy 8.3 actionable hint). Minor add-only
58
+ // per AGENTS.md evolution contract.
59
+ // Extended from 21 to 23 in feat-20260708-composited-multi-world-rendering
60
+ // M3 (D-5): added 'render-system-empty-worlds' + 'render-system-owner-out-of-
61
+ // range' for draw(worlds, { cameraOwner, resourceOwner }) entry validation. The
62
+ // owner-out-of-range path exposes .detail = RhiOwnerOutOfRangeDetail
63
+ // ({ role, owner, worldCount } after feat-20260709-editor-world-partition
64
+ // M1 / w7: role ∈ {'camera','resource'} names which of the two split draw
65
+ // owners is out of range); empty-worlds carries no .detail. The pure
66
+ // validateDrawArgs(worldCount, { cameraOwner, resourceOwner })
67
+ // helper (World-free primitives) emits both and is consumed by the runtime
68
+ // createRenderer draw entry (the codes' SSOT stays in rhi). Checks run
69
+ // empty-worlds -> cameraOwner -> resourceOwner; the first out-of-range owner
70
+ // wins (role='camera' when both offend). Add-only (no new code, 0 net Δ per
71
+ // D-3) per AGENTS.md evolution contract.
72
+ // - RhiError class has readonly .code / .expected / .hint three-field surface
73
+ // (AGENTS.md "Errors are structured" / D-5); the 'shader-compile-failed' path
74
+ // exposes .detail = RhiShaderCompileDetail (compilerMessages array);
75
+ // the 'asset-not-registered' path exposes .detail = RhiAssetNotRegisteredDetail
76
+ // ({ assetHandle: number }, D-S6); the 'webgpu-runtime-error' path optionally
77
+ // exposes .detail = RhiWebgpuRuntimeDetail ({ error: RhiError | fallback }, D-S8) for
78
+ // RenderSystem internal exception fan-out; the 'limit-exceeded' path
79
+ // exposes .detail = LimitExceededDetail ({ maxStorageBufferBindingSize,
80
+ // requestedBytes }, feat-20260513-instanced-mesh M5 reshape from legacy
81
+ // { renderableCount, limit }); the other 15 paths leave
82
+ // .detail = undefined per charter proposition 4 baseline.
83
+ // - Result<T, E> = binary tag union ('ok' / 'err'), per AGENTS.md "Errors are
84
+ // structured" convention.
85
+ //
86
+ // Related: requirements AC AC-10 + MVP-1.7 + AC-RSC-07 + hard-constraint 8 +
87
+ // AI User Affordances; plan-strategy 2 S-6 (types/rhi single source) +
88
+ // 7.3 error-info table; plan-decisions OQ-P2 (forward all 6 fields of
89
+ // GPUCompilationMessage); D-S3 (4 command/queue members) + D-S6 / D-S7
90
+ // / D-S8 (4 RenderSystem / AssetRegistry members + .detail structure).
91
+
92
+ /// <reference types="@webgpu/types" />
93
+
94
+ import { err, ok, type Result } from '@forgeax/engine-types';
95
+
96
+ /**
97
+ * Closed RhiErrorCode union. `switch` exhaustive checks need no default
98
+ * fallback - tsc strict mode guards union completeness (charter proposition 4
99
+ * + proposition 3: machine-readable union > prose).
100
+ *
101
+ *
102
+ * The executable union below is the machine-readable source of truth for
103
+ * the 23 members. The package README owns the AI-facing trigger and recovery
104
+ * table; keeping that projection in one documentation owner avoids a second
105
+ * member ledger drifting from the union.
106
+ *
107
+ * @example AI-user exhaustive switch on the 4 command/queue members (no default fallback)
108
+ * ```ts
109
+ * import type { RhiError, RhiErrorCode } from '@forgeax/engine-rhi';
110
+ *
111
+ * function recover(code: RhiErrorCode): string {
112
+ * switch (code) {
113
+ * // ... 6 baseline members elided ...
114
+ * case 'command-encoder-finished': return 'recreate encoder via device.createCommandEncoder()';
115
+ * case 'render-pass-not-ended': return 'call pass.end() before next beginRenderPass()';
116
+ * case 'queue-submit-failed': return 'audit buffer/pipeline lifetimes before submit';
117
+ * case 'queue-write-buffer-out-of-bounds': return 'realign offset and re-check buffer.size';
118
+ * default: return 'baseline path';
119
+ * }
120
+ * }
121
+ * ```
122
+ */
123
+ export type RhiErrorCode =
124
+ | 'adapter-unavailable'
125
+ | 'feature-not-enabled'
126
+ | 'limit-exceeded'
127
+ | 'shader-compile-failed'
128
+ | 'rhi-not-available'
129
+ | 'webgpu-runtime-error'
130
+ | 'command-encoder-finished'
131
+ | 'render-pass-not-ended'
132
+ | 'queue-submit-failed'
133
+ | 'queue-write-buffer-out-of-bounds'
134
+ | 'render-system-no-camera'
135
+ | 'render-system-multi-camera'
136
+ | 'render-system-multi-light'
137
+ | 'asset-not-registered'
138
+ | 'device-lost'
139
+ | 'oom'
140
+ | 'internal-error'
141
+ | 'hierarchy-broken'
142
+ | 'destroy-after-destroy'
143
+ | 'rhi-descriptor-invalid'
144
+ | 'instancing-exceeds-uniform-cap'
145
+ | 'render-system-empty-worlds'
146
+ | 'render-system-owner-out-of-range';
147
+
148
+ /**
149
+ * Detail structure exclusive to the `shader-compile-failed` path.
150
+ *
151
+ * `compilerMessages` directly forwards the 6 standardized fields of
152
+ * `GPUCompilationMessage` from `@webgpu/types` v0.1.69 (`message` / `type` /
153
+ * `lineNum` / `linePos` / `offset` / `length`); research F-3 finding;
154
+ * plan-decisions OQ-P2 locks full-field forwarding.
155
+ *
156
+ * @see {@link GPUCompilationMessage}
157
+ */
158
+ export interface RhiShaderCompileDetail {
159
+ readonly compilerMessages: readonly GPUCompilationMessage[];
160
+ }
161
+
162
+ /**
163
+ * Detail structure exclusive to the `asset-not-registered` path (D-S6).
164
+ *
165
+ * `assetHandle` carries the offending u32 handle the caller passed via
166
+ * `MeshFilter.assetHandle`; AI users access it through property access
167
+ * (`err.detail.assetHandle`) rather than parsing the message string
168
+ * (charter proposition 4 + F-3 contract surface).
169
+ */
170
+ export interface RhiAssetNotRegisteredDetail {
171
+ readonly assetHandle: number;
172
+ }
173
+
174
+ /**
175
+ * Detail structure exclusive to the `webgpu-runtime-error` path (D-S8).
176
+ *
177
+ * `error` carries the underlying exception object so AI users can inspect the
178
+ * root cause (`.code` / `.expected` / `.hint` for `RhiError` paths, or
179
+ * `.code` + `.message` for non-RhiError falls) without parsing the
180
+ * RhiError.message field. Optional: the K-9 silent-skip fan-out root path
181
+ * may emit `webgpu-runtime-error` without `.detail` when the underlying
182
+ * exception is unavailable.
183
+ *
184
+ * feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M4 / T-M4-02:
185
+ * `error` field type widened from `string` to `RhiError | { code: string;
186
+ * message: string }` so downstream `switch (err.code)` handlers can narrow
187
+ * the inner error (`.code` / `.expected` / `.hint`) without an `as` cast.
188
+ */
189
+ export interface RhiWebgpuRuntimeDetail {
190
+ readonly error: RhiError | { code: string; message: string; name?: string };
191
+ }
192
+
193
+ /**
194
+ * Detail structure exclusive to the `limit-exceeded` path.
195
+ *
196
+ * `maxStorageBufferBindingSize` carries the device-reported storage cap
197
+ * (`device.limits.maxStorageBufferBindingSize`); `requestedBytes`
198
+ * carries the byte count the caller attempted to allocate. AI users
199
+ * access these through typed property access (`err.detail.maxStorageBufferBindingSize`
200
+ * / `err.detail.requestedBytes`) rather than parsing the message string
201
+ * — charter proposition 4 structured-error consumption path; `err.hint`
202
+ * is for human eyeballs only.
203
+ *
204
+ * Single live emit point: the RenderSystem record stage per-entity
205
+ * instance buffer upload path
206
+ * (`packages/runtime/src/render-system-record.ts`). The 18-member
207
+ * `RhiErrorCode` union is unchanged (`'limit-exceeded'` discriminant
208
+ * preserved); evolution major rename + replace of the discriminated
209
+ * `detail` shape per AGENTS.md Change stance + plan-strategy D-3.
210
+ *
211
+ * Migration history:
212
+ * - feat-20260513-instanced-mesh M5: detail reshape from
213
+ * `{ renderableCount, limit }` to `{ maxStorageBufferBindingSize,
214
+ * requestedBytes }`. Emit point at the time was
215
+ * `AssetRegistry.createInstancedBuffer`.
216
+ * - feat-20260514-ecs-children-instances-managed-buffer-array M3 / w15:
217
+ * `AssetRegistry.createInstancedBuffer` deleted alongside the
218
+ * `InstancedBufferAsset` POD; emit point migrated to the record
219
+ * stage upload path (`requestedBytes` now equals
220
+ * `Instances.transforms.byteLength` per Instances-bearing entity).
221
+ */
222
+ export interface LimitExceededDetail {
223
+ readonly maxStorageBufferBindingSize: number;
224
+ readonly requestedBytes: number;
225
+ }
226
+
227
+ /**
228
+ * Detail structure exclusive to the `'render-system-multi-light'` path
229
+ * (feat-20260519-light-casters-point-spot-pbr M3 / w20 + plan-strategy
230
+ * section 8 (3) (b)).
231
+ *
232
+ * Emitted by the RenderSystem record stage when first-slice cap exceedance
233
+ * is detected: `type` discriminates the offending bucket
234
+ * (`'directional'` for N>1 / `'point'` or `'spot'` for N>4); `got`
235
+ * carries the observed entity count so AI users can branch via property
236
+ * access (`err.detail.type === 'point' && err.detail.got > 4`) rather
237
+ * than parsing the message string (charter proposition 4 + F-3 contract
238
+ * surface).
239
+ *
240
+ * Single live emit point: the RenderSystem record stage three-bucket
241
+ * fail-fast (`packages/runtime/src/render-system-record.ts`). Minor
242
+ * additive evolution per AGENTS.md error model evolution contract.
243
+ */
244
+ export interface RhiMultiLightDetail {
245
+ readonly type: 'directional' | 'point' | 'spot';
246
+ readonly got: number;
247
+ }
248
+
249
+ /**
250
+ * Detail structure exclusive to the `'instancing-exceeds-uniform-cap'` path
251
+ * (feat-20260622-chunk-gpu-instancing-sprite-tilemap M2 / w10 +
252
+ * plan-strategy 2 D-2 + research N-1).
253
+ *
254
+ * Emitted by the record-stage fold dispatch loop
255
+ * (`packages/runtime/src/render-system-record.ts`) when
256
+ * `caps.storageBuffer === false` AND a fold bucket carries more than
257
+ * `limit` instances. The engine fires the error AND falls the offending
258
+ * bucket back to per-entity drawIndexed via the same exit the mode-gate
259
+ * bypass uses (plan-strategy D-9 "shared fallback exit"), so the frame
260
+ * is still visually correct (no identity-collapse / black screen) and
261
+ * the cap event surfaces structurally for AI users to observe.
262
+ *
263
+ * Fields:
264
+ * - `requested` — the offending bucket's instance count
265
+ * (`FoldBucket.bucketSize`); always strictly greater than `limit` at
266
+ * emit time (the cap-check helper guards `requested > limit`).
267
+ * - `limit` — the literal 128. The value is locked at the type level
268
+ * because the cap is structurally tied to the WebGL2 minimum 16384 B
269
+ * UBO size (128 * 64 B mat4 stride = 8192 B leaves headroom for the
270
+ * per-frame material UBO slice — research N-1). A future cap change
271
+ * would be a major evolution, not a runtime knob.
272
+ * - `scope` — closed `'sprite' | 'tilemap-chunk'` discriminator that
273
+ * pinpoints the dispatch site (sprite-pass entry that came directly
274
+ * from a user-spawned Sprite vs one derived by
275
+ * `tilemap-chunk-extract-system`). AI users branch on `.scope` to
276
+ * decide whether to shrink the sprite batch size or the tilemap
277
+ * chunk size.
278
+ *
279
+ * AI-user consumption (charter P3 + plan-strategy 8.3):
280
+ * ```ts
281
+ * if (err.code === 'instancing-exceeds-uniform-cap') {
282
+ * const d = err.detail as RhiInstancingExceedsUniformCapDetail;
283
+ * if (d.scope === 'sprite') shrinkSpriteBatchSize(d.requested);
284
+ * else if (d.scope === 'tilemap-chunk') shrinkTileChunkSize(d.requested);
285
+ * }
286
+ * ```
287
+ * — never parse `err.message`. The discriminated `detail` field is the
288
+ * surface; the human-readable `err.message` is for logs only.
289
+ */
290
+ export interface RhiInstancingExceedsUniformCapDetail {
291
+ readonly requested: number;
292
+ readonly limit: 128;
293
+ readonly scope: 'sprite' | 'tilemap-chunk';
294
+ }
295
+
296
+ /**
297
+ * Detail structure exclusive to the `'render-system-owner-out-of-range'` path
298
+ * (feat-20260708-composited-multi-world-rendering M3 / D-5).
299
+ *
300
+ * Emitted by `renderer.draw(worlds, { cameraOwner, resourceOwner })` when one
301
+ * owner index is not valid for `worlds` (`owner < 0` or `owner >= worlds.length`).
302
+ * An out-of-range index cannot resolve, so the frame is skipped before extract.
303
+ *
304
+ * Fields:
305
+ * - `role` — WHICH of the two draw-owner indices was out of range
306
+ * (feat-20260709-editor-world-partition M1 / w7). `draw(worlds, {
307
+ * cameraOwner, resourceOwner })` carries two independent indices; `role`
308
+ * tells the AI user whether the camera-source index (`'camera'`) or the
309
+ * singleton-resource index (`'resource'`) is the offender, so the fix is
310
+ * unambiguous from the text channel (no new error code — D-3 keeps 0 net
311
+ * new codes; the discriminator lives in `.detail`). When both indices are
312
+ * out of range the first offender is reported: `cameraOwner` is validated
313
+ * before `resourceOwner`, so `role === 'camera'`.
314
+ * - `owner` — the offending index the caller passed (the `role` index's
315
+ * value).
316
+ * - `worldCount` — `worlds.length` at call time (the valid range is
317
+ * `0 .. worldCount - 1`).
318
+ *
319
+ * AI users branch via property access (`err.detail.role` / `err.detail.owner` /
320
+ * `err.detail.worldCount`) after narrowing on `.code`, rather than parsing the
321
+ * message string (charter P3 structured-failure surface).
322
+ *
323
+ * The sibling `'render-system-empty-worlds'` path carries no `.detail` — an
324
+ * empty array is fully described by `.code`, and the entry check short-circuits
325
+ * to that code before the owner-range check runs (the two codes are
326
+ * non-exclusive).
327
+ */
328
+ export interface RhiOwnerOutOfRangeDetail {
329
+ readonly role: 'camera' | 'resource';
330
+ readonly owner: number;
331
+ readonly worldCount: number;
332
+ }
333
+
334
+ /**
335
+ * Tagged union of `.detail` shapes carried by structured errors.
336
+ *
337
+ * Entries:
338
+ * - `RhiShaderCompileDetail` (carries `compilerMessages`) - emitted on the
339
+ * `'shader-compile-failed'` path.
340
+ * - `RhiAssetNotRegisteredDetail` (carries `assetHandle`) - emitted on the
341
+ * `'asset-not-registered'` path (D-S6).
342
+ * - `RhiWebgpuRuntimeDetail` (carries `error: RhiError | { code, message }`) - optionally emitted
343
+ * on the `'webgpu-runtime-error'` path when a captured `Error.message` is
344
+ * available (D-S8).
345
+ * - `LimitExceededDetail` (carries `maxStorageBufferBindingSize` +
346
+ * `requestedBytes`) - emitted on the `'limit-exceeded'` path when
347
+ * the RenderSystem record stage's per-entity Instances upload
348
+ * exceeds `device.limits.maxStorageBufferBindingSize`
349
+ * (feat-20260514-ecs-children-instances-managed-buffer-array M3 / w15;
350
+ * emit point migrated from the deleted
351
+ * `AssetRegistry.createInstancedBuffer` factory).
352
+ *
353
+ * The other 15 paths leave `.detail = undefined` (charter proposition 4
354
+ * baseline).
355
+ */
356
+ export type RhiErrorDetail =
357
+ | RhiShaderCompileDetail
358
+ | RhiAssetNotRegisteredDetail
359
+ | RhiWebgpuRuntimeDetail
360
+ | LimitExceededDetail
361
+ | RhiMultiLightDetail
362
+ | RhiInstancingExceedsUniformCapDetail
363
+ | RhiOwnerOutOfRangeDetail;
364
+
365
+ /**
366
+ * Structured RHI error.
367
+ *
368
+ * Three readonly fields aligned with AGENTS.md "Errors are structured":
369
+ * - `.code` - closed union member (L1 key signal).
370
+ * - `.expected` - expected-state description (L2 detail).
371
+ * - `.hint` - actionable recovery guidance (L2 detail; charter proposition 3:
372
+ * machine-readable hint > prose).
373
+ *
374
+ * `.detail` is populated on four paths:
375
+ * - `code === 'shader-compile-failed'` -> `RhiShaderCompileDetail`
376
+ * - `code === 'asset-not-registered'` -> `RhiAssetNotRegisteredDetail`
377
+ * - `code === 'webgpu-runtime-error'` -> `RhiWebgpuRuntimeDetail` (optional)
378
+ * - `code === 'limit-exceeded'` -> `LimitExceededDetail`
379
+ * (feat-20260513-instanced-mesh M5 reshape; carries
380
+ * `maxStorageBufferBindingSize` + `requestedBytes`)
381
+ *
382
+ * The other 15 paths leave `.detail = undefined` (charter proposition 4
383
+ * baseline).
384
+ *
385
+ * Note: `RhiErrorDetail` is currently a flat tagged union without a
386
+ * `code` discriminant field on each variant; AI users perform typed
387
+ * narrowing via outer `switch (err.code)` then a one-time `as` cast on
388
+ * `err.detail` per the documented variant. Full discriminated-union
389
+ * refactor (each variant carrying its own `code` literal field) is left
390
+ * to `feat-future-rhi-error-detail-discriminant` spinoff.
391
+ */
392
+ export class RhiError extends Error {
393
+ readonly code: RhiErrorCode;
394
+ readonly expected: string;
395
+ readonly hint: string;
396
+ readonly detail: RhiErrorDetail | undefined;
397
+
398
+ constructor(args: {
399
+ code: RhiErrorCode;
400
+ expected: string;
401
+ hint: string;
402
+ detail?: RhiErrorDetail | undefined;
403
+ }) {
404
+ super(`[RhiError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
405
+ this.name = 'RhiError';
406
+ this.code = args.code;
407
+ this.expected = args.expected;
408
+ this.hint = args.hint;
409
+ this.detail = args.detail;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * The two split draw-owner indices carried by
415
+ * `draw(worlds, { cameraOwner, resourceOwner })`
416
+ * (feat-20260709-editor-world-partition M1 / w6). `cameraOwner` selects the
417
+ * world whose cameras are surfaced; `resourceOwner` selects the world whose
418
+ * skylight / skybox / postProcessParams are surfaced. Declared here (World-free
419
+ * primitives) so the validator and the `RhiOwnerOutOfRangeDetail.role`
420
+ * discriminator live in one SSOT package (architecture-principles §1).
421
+ */
422
+ export interface DrawOwnerSplit {
423
+ readonly cameraOwner: number;
424
+ readonly resourceOwner: number;
425
+ }
426
+
427
+ /**
428
+ * Validate `renderer.draw(worlds, { cameraOwner, resourceOwner })` arguments at
429
+ * the draw entry (feat-20260708 M3 / D-5, extended by
430
+ * feat-20260709-editor-world-partition M1 / w6-w7).
431
+ *
432
+ * The validator takes primitives (`worldCount = worlds.length`, plus the owner
433
+ * index/indices) — no `World`, no math — so it lives in `@forgeax/engine-rhi`
434
+ * alongside the `RhiErrorCode` members it emits (architecture-principles §1
435
+ * SSOT). The runtime `createRenderer` draw entry calls it before any extract; a
436
+ * non-`ok` result skips the frame with a structured error (charter P3), never a
437
+ * silent no-op.
438
+ *
439
+ * Checks, in order (D-5 + w6):
440
+ * 1. `worldCount === 0` -> `'render-system-empty-worlds'` (no `.detail`).
441
+ * 2. `cameraOwner` is not a valid index -> `'render-system-owner-out-of-range'`
442
+ * with `.detail = { role: 'camera', owner: cameraOwner, worldCount }`.
443
+ * 3. `resourceOwner` is not a valid index -> `'render-system-owner-out-of-range'`
444
+ * with `.detail = { role: 'resource', owner: resourceOwner, worldCount }`.
445
+ * The `Number.isInteger` guard rejects a `NaN` / fractional / undefined-coerced
446
+ * index a JS caller could pass despite the compile-time requirement. When both
447
+ * indices are out of range the FIRST offender wins: `cameraOwner` is checked
448
+ * before `resourceOwner`, so `role === 'camera'` (D-3 / w3 contract).
449
+ *
450
+ * The empty-worlds guard short-circuits before either owner-range check.
451
+ */
452
+ export function validateDrawArgs(
453
+ worldCount: number,
454
+ owner: DrawOwnerSplit,
455
+ ): Result<void, RhiError> {
456
+ if (worldCount === 0) {
457
+ return err(
458
+ new RhiError({
459
+ code: 'render-system-empty-worlds',
460
+ expected: 'worlds array has at least one world',
461
+ hint: 'pass at least one world: draw([world], { cameraOwner: 0, resourceOwner: 0 })',
462
+ }),
463
+ );
464
+ }
465
+ const { cameraOwner, resourceOwner } = owner;
466
+ const outOfRange = (index: number): boolean =>
467
+ !Number.isInteger(index) || index < 0 || index >= worldCount;
468
+ // cameraOwner is validated first: it is the first offender when both indices
469
+ // are out of range (w3 contract). role names which index the AI user fixes.
470
+ if (outOfRange(cameraOwner)) {
471
+ return err(
472
+ new RhiError({
473
+ code: 'render-system-owner-out-of-range',
474
+ expected: 'cameraOwner is an index into worlds (0 <= cameraOwner < worlds.length)',
475
+ hint: 'cameraOwner must be in 0..worlds.length-1; the cameraOwner world supplies the surfaced cameras',
476
+ detail: { role: 'camera', owner: cameraOwner, worldCount },
477
+ }),
478
+ );
479
+ }
480
+ if (outOfRange(resourceOwner)) {
481
+ return err(
482
+ new RhiError({
483
+ code: 'render-system-owner-out-of-range',
484
+ expected: 'resourceOwner is an index into worlds (0 <= resourceOwner < worlds.length)',
485
+ hint: 'resourceOwner must be in 0..worlds.length-1; the resourceOwner world supplies skylight/skybox/postProcess',
486
+ detail: { role: 'resource', owner: resourceOwner, worldCount },
487
+ }),
488
+ );
489
+ }
490
+ return ok(undefined);
491
+ }
492
+
493
+ // Result<T, E> + ok / err live in `@forgeax/engine-types` (tweak-20260612-result-
494
+ // into-types). They were duplicated here ("byte-for-byte aligned" by prose) and
495
+ // in packages/ecs/src/result.ts; SSOT consolidated upstream. The barrel here
496
+ // re-exports them so existing `import { err, ok, Result, ResultOk, ResultErr }
497
+ // from '@forgeax/engine-rhi'` consumers stay unchanged.
498
+ export {
499
+ err,
500
+ ok,
501
+ type Result,
502
+ type ResultErr,
503
+ type ResultOk,
504
+ } from '@forgeax/engine-types';