@forgeax/engine-assets-runtime 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/README.md +453 -175
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/aabb.unit.test.d.ts +2 -0
  4. package/dist/__tests__/aabb.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/artifact-integrity.unit.test.d.ts +2 -0
  6. package/dist/__tests__/artifact-integrity.unit.test.d.ts.map +1 -0
  7. package/dist/__tests__/artifact-io.unit.test.d.ts +2 -0
  8. package/dist/__tests__/artifact-io.unit.test.d.ts.map +1 -0
  9. package/dist/__tests__/asset-evidence-sdk.integration.test.d.ts +2 -0
  10. package/dist/__tests__/asset-evidence-sdk.integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/asset-evidence.unit.test.d.ts +2 -0
  12. package/dist/__tests__/asset-evidence.unit.test.d.ts.map +1 -0
  13. package/dist/__tests__/asset-kind.unit.test.d.ts +2 -0
  14. package/dist/__tests__/asset-kind.unit.test.d.ts.map +1 -0
  15. package/dist/__tests__/asset-registry-catalog-reconcile.integration.test.d.ts +2 -0
  16. package/dist/__tests__/asset-registry-catalog-reconcile.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/asset-registry-catalog-reconcile.unit.test.d.ts +2 -0
  18. package/dist/__tests__/asset-registry-catalog-reconcile.unit.test.d.ts.map +1 -0
  19. package/dist/__tests__/asset-registry-catalog.unit.test.d.ts +2 -0
  20. package/dist/__tests__/asset-registry-catalog.unit.test.d.ts.map +1 -0
  21. package/dist/__tests__/asset-registry.pack-cache.test.d.ts +2 -0
  22. package/dist/__tests__/asset-registry.pack-cache.test.d.ts.map +1 -0
  23. package/dist/__tests__/asset-registry.unit.test.d.ts +2 -0
  24. package/dist/__tests__/asset-registry.unit.test.d.ts.map +1 -0
  25. package/dist/__tests__/asset-runtime-error-code-owner.test-d.d.ts +2 -0
  26. package/dist/__tests__/asset-runtime-error-code-owner.test-d.d.ts.map +1 -0
  27. package/dist/__tests__/catalog-replica.unit.test.d.ts +2 -0
  28. package/dist/__tests__/catalog-replica.unit.test.d.ts.map +1 -0
  29. package/dist/__tests__/catalog-source-v2.unit.test.d.ts +2 -0
  30. package/dist/__tests__/catalog-source-v2.unit.test.d.ts.map +1 -0
  31. package/dist/__tests__/catalog-source.unit.test.d.ts +2 -0
  32. package/dist/__tests__/catalog-source.unit.test.d.ts.map +1 -0
  33. package/dist/__tests__/catalog-subscription-order.unit.test.d.ts +2 -0
  34. package/dist/__tests__/catalog-subscription-order.unit.test.d.ts.map +1 -0
  35. package/dist/__tests__/catalog-subscription.unit.test.d.ts +2 -0
  36. package/dist/__tests__/catalog-subscription.unit.test.d.ts.map +1 -0
  37. package/dist/__tests__/decode-image-bytes-pass-through.test.d.ts +2 -0
  38. package/dist/__tests__/decode-image-bytes-pass-through.test.d.ts.map +1 -0
  39. package/dist/__tests__/decode-image-bytes.browser.test.d.ts +2 -0
  40. package/dist/__tests__/decode-image-bytes.browser.test.d.ts.map +1 -0
  41. package/dist/__tests__/decode-image-bytes.test-d.d.ts +2 -0
  42. package/dist/__tests__/decode-image-bytes.test-d.d.ts.map +1 -0
  43. package/dist/__tests__/decode-image-bytes.test.d.ts +2 -0
  44. package/dist/__tests__/decode-image-bytes.test.d.ts.map +1 -0
  45. package/dist/__tests__/dynamic-texture-store.unit.test.d.ts +2 -0
  46. package/dist/__tests__/dynamic-texture-store.unit.test.d.ts.map +1 -0
  47. package/dist/__tests__/errors-asset.unit.test.d.ts +2 -0
  48. package/dist/__tests__/errors-asset.unit.test.d.ts.map +1 -0
  49. package/dist/__tests__/inline-pack-loaders.unit.test.d.ts +2 -0
  50. package/dist/__tests__/inline-pack-loaders.unit.test.d.ts.map +1 -0
  51. package/dist/__tests__/instantiate-context.unit.test.d.ts +2 -0
  52. package/dist/__tests__/instantiate-context.unit.test.d.ts.map +1 -0
  53. package/dist/__tests__/instantiate-publication-fence.unit.test.d.ts +2 -0
  54. package/dist/__tests__/instantiate-publication-fence.unit.test.d.ts.map +1 -0
  55. package/dist/__tests__/instantiate.unit.test.d.ts +2 -0
  56. package/dist/__tests__/instantiate.unit.test.d.ts.map +1 -0
  57. package/dist/__tests__/loader-input-contract.unit.test.d.ts +2 -0
  58. package/dist/__tests__/loader-input-contract.unit.test.d.ts.map +1 -0
  59. package/dist/__tests__/loader-registry.unit.test.d.ts +2 -0
  60. package/dist/__tests__/loader-registry.unit.test.d.ts.map +1 -0
  61. package/dist/__tests__/material-dependency-invalidation.unit.test.d.ts +2 -0
  62. package/dist/__tests__/material-dependency-invalidation.unit.test.d.ts.map +1 -0
  63. package/dist/__tests__/material-generation-cache.unit.test.d.ts +2 -0
  64. package/dist/__tests__/material-generation-cache.unit.test.d.ts.map +1 -0
  65. package/dist/__tests__/material-load-consumer.test-d.d.ts +2 -0
  66. package/dist/__tests__/material-load-consumer.test-d.d.ts.map +1 -0
  67. package/dist/__tests__/material-ready.integration.test.d.ts +2 -0
  68. package/dist/__tests__/material-ready.integration.test.d.ts.map +1 -0
  69. package/dist/__tests__/material-stale-generation.unit.test.d.ts +2 -0
  70. package/dist/__tests__/material-stale-generation.unit.test.d.ts.map +1 -0
  71. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
  72. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
  73. package/dist/__tests__/mesh-bin.unit.test.d.ts +2 -0
  74. package/dist/__tests__/mesh-bin.unit.test.d.ts.map +1 -0
  75. package/dist/__tests__/mipmap-generator.unit.test.d.ts +2 -0
  76. package/dist/__tests__/mipmap-generator.unit.test.d.ts.map +1 -0
  77. package/dist/__tests__/pack-artifact-transcode-model-owner.test.d.ts +2 -0
  78. package/dist/__tests__/pack-artifact-transcode-model-owner.test.d.ts.map +1 -0
  79. package/dist/__tests__/pack-basis-load.integration.test.d.ts +2 -0
  80. package/dist/__tests__/pack-basis-load.integration.test.d.ts.map +1 -0
  81. package/dist/__tests__/payload-validate.unit.test.d.ts +2 -0
  82. package/dist/__tests__/payload-validate.unit.test.d.ts.map +1 -0
  83. package/dist/__tests__/plugin.test.d.ts +2 -0
  84. package/dist/__tests__/plugin.test.d.ts.map +1 -0
  85. package/dist/__tests__/registry-concurrency.bench.test.d.ts +2 -0
  86. package/dist/__tests__/registry-concurrency.bench.test.d.ts.map +1 -0
  87. package/dist/__tests__/registry-failure-retry.integration.test.d.ts +2 -0
  88. package/dist/__tests__/registry-failure-retry.integration.test.d.ts.map +1 -0
  89. package/dist/__tests__/registry-load-graph.integration.test.d.ts +2 -0
  90. package/dist/__tests__/registry-load-graph.integration.test.d.ts.map +1 -0
  91. package/dist/__tests__/registry-load-state.unit.test.d.ts +2 -0
  92. package/dist/__tests__/registry-load-state.unit.test.d.ts.map +1 -0
  93. package/dist/__tests__/registry-package-cache.unit.test.d.ts +2 -0
  94. package/dist/__tests__/registry-package-cache.unit.test.d.ts.map +1 -0
  95. package/dist/__tests__/registry-public-ready.unit.test.d.ts +2 -0
  96. package/dist/__tests__/registry-public-ready.unit.test.d.ts.map +1 -0
  97. package/dist/__tests__/resolve-asset-handle.unit.test.d.ts +2 -0
  98. package/dist/__tests__/resolve-asset-handle.unit.test.d.ts.map +1 -0
  99. package/dist/__tests__/scene-handle-fields.unit.test.d.ts +2 -0
  100. package/dist/__tests__/scene-handle-fields.unit.test.d.ts.map +1 -0
  101. package/dist/__tests__/scene-payload.unit.test.d.ts +2 -0
  102. package/dist/__tests__/scene-payload.unit.test.d.ts.map +1 -0
  103. package/dist/__tests__/scene-publication-fence.test.d.ts +2 -0
  104. package/dist/__tests__/scene-publication-fence.test.d.ts.map +1 -0
  105. package/dist/__tests__/scriptable-pack-public-surface.test-d.d.ts +2 -0
  106. package/dist/__tests__/scriptable-pack-public-surface.test-d.d.ts.map +1 -0
  107. package/dist/__tests__/static-rig.integration.test.d.ts +2 -0
  108. package/dist/__tests__/static-rig.integration.test.d.ts.map +1 -0
  109. package/dist/__tests__/validate-material.unit.test.d.ts +2 -0
  110. package/dist/__tests__/validate-material.unit.test.d.ts.map +1 -0
  111. package/dist/__tests__/video-pack-loader.integration.test.d.ts +2 -0
  112. package/dist/__tests__/video-pack-loader.integration.test.d.ts.map +1 -0
  113. package/dist/aabb.d.ts +3 -0
  114. package/dist/aabb.d.ts.map +1 -0
  115. package/dist/asset-registry.d.ts +554 -0
  116. package/dist/asset-registry.d.ts.map +1 -0
  117. package/dist/builtin-asset-registry.d.ts +22 -0
  118. package/dist/builtin-asset-registry.d.ts.map +1 -0
  119. package/dist/catalog-source.d.ts +2 -1
  120. package/dist/catalog-source.d.ts.map +1 -1
  121. package/dist/decode-image-bytes.d.ts +57 -0
  122. package/dist/decode-image-bytes.d.ts.map +1 -0
  123. package/dist/dynamic-texture-store.d.ts +89 -0
  124. package/dist/dynamic-texture-store.d.ts.map +1 -0
  125. package/dist/errors/asset.d.ts +201 -0
  126. package/dist/errors/asset.d.ts.map +1 -0
  127. package/dist/handles.d.ts +143 -0
  128. package/dist/handles.d.ts.map +1 -0
  129. package/dist/image-error.d.ts +6 -0
  130. package/dist/image-error.d.ts.map +1 -0
  131. package/dist/index.d.ts +32 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.mjs +7233 -114
  134. package/dist/index.mjs.map +1 -1
  135. package/dist/internal.d.ts +1 -1
  136. package/dist/internal.d.ts.map +1 -1
  137. package/dist/loader-registry.d.ts +63 -0
  138. package/dist/loader-registry.d.ts.map +1 -0
  139. package/dist/loaders/inline-pack.d.ts +72 -0
  140. package/dist/loaders/inline-pack.d.ts.map +1 -0
  141. package/dist/loaders/mesh-bin.d.ts +21 -0
  142. package/dist/loaders/mesh-bin.d.ts.map +1 -0
  143. package/dist/loaders/pack-artifact.d.ts +20 -0
  144. package/dist/loaders/pack-artifact.d.ts.map +1 -0
  145. package/dist/material/dependency-graph.d.ts +9 -0
  146. package/dist/material/dependency-graph.d.ts.map +1 -0
  147. package/dist/material/generation-cache.d.ts +24 -0
  148. package/dist/material/generation-cache.d.ts.map +1 -0
  149. package/dist/material/inspection.d.ts +24 -0
  150. package/dist/material/inspection.d.ts.map +1 -0
  151. package/dist/material/loader.d.ts +59 -0
  152. package/dist/material/loader.d.ts.map +1 -0
  153. package/dist/material/runtime-shader.d.ts +14 -0
  154. package/dist/material/runtime-shader.d.ts.map +1 -0
  155. package/dist/mesh-bin.d.ts +3 -0
  156. package/dist/mesh-bin.d.ts.map +1 -0
  157. package/dist/mipmap-generator.d.ts +132 -0
  158. package/dist/mipmap-generator.d.ts.map +1 -0
  159. package/dist/payload-validate.d.ts +60 -0
  160. package/dist/payload-validate.d.ts.map +1 -0
  161. package/dist/plugin.d.ts +16 -0
  162. package/dist/plugin.d.ts.map +1 -0
  163. package/dist/registry/artifact-io.d.ts +16 -0
  164. package/dist/registry/artifact-io.d.ts.map +1 -0
  165. package/dist/registry/asset-evidence.d.ts +13 -0
  166. package/dist/registry/asset-evidence.d.ts.map +1 -0
  167. package/dist/registry/catalog-state.d.ts +47 -0
  168. package/dist/registry/catalog-state.d.ts.map +1 -0
  169. package/dist/registry/catalog.d.ts +48 -0
  170. package/dist/registry/catalog.d.ts.map +1 -0
  171. package/dist/registry/instantiate.d.ts +153 -0
  172. package/dist/registry/instantiate.d.ts.map +1 -0
  173. package/dist/registry/load-by-guid.d.ts +247 -0
  174. package/dist/registry/load-by-guid.d.ts.map +1 -0
  175. package/dist/registry/load-state.d.ts +26 -0
  176. package/dist/registry/load-state.d.ts.map +1 -0
  177. package/dist/registry/runtime-projection.d.ts +16 -0
  178. package/dist/registry/runtime-projection.d.ts.map +1 -0
  179. package/dist/registry/scene-publication-fence.d.ts +38 -0
  180. package/dist/registry/scene-publication-fence.d.ts.map +1 -0
  181. package/dist/registry/validate-material.d.ts +77 -0
  182. package/dist/registry/validate-material.d.ts.map +1 -0
  183. package/dist/resolve-asset-handle.d.ts +43 -0
  184. package/dist/resolve-asset-handle.d.ts.map +1 -0
  185. package/dist/resolve-tileset-runtime.d.ts +23 -0
  186. package/dist/resolve-tileset-runtime.d.ts.map +1 -0
  187. package/dist/scene-handle-fields.d.ts +88 -0
  188. package/dist/scene-handle-fields.d.ts.map +1 -0
  189. package/dist/scene-payload.d.ts +15 -0
  190. package/dist/scene-payload.d.ts.map +1 -0
  191. package/dist/wire-default-loaders.d.ts +29 -0
  192. package/dist/wire-default-loaders.d.ts.map +1 -0
  193. package/package.json +22 -9
  194. package/src/__tests__/aabb.unit.test.ts +97 -0
  195. package/src/__tests__/artifact-integrity.unit.test.ts +99 -0
  196. package/src/__tests__/artifact-io.unit.test.ts +127 -0
  197. package/src/__tests__/asset-evidence-sdk.integration.test.ts +32 -0
  198. package/src/__tests__/asset-evidence.unit.test.ts +32 -0
  199. package/src/__tests__/{asset-kind.test.ts → asset-kind.unit.test.ts} +1 -1
  200. package/src/__tests__/asset-registry-catalog-reconcile.integration.test.ts +74 -0
  201. package/src/__tests__/asset-registry-catalog-reconcile.unit.test.ts +89 -0
  202. package/src/__tests__/asset-registry-catalog.unit.test.ts +170 -0
  203. package/src/__tests__/asset-registry.pack-cache.test.ts +96 -0
  204. package/src/__tests__/asset-registry.unit.test.ts +511 -0
  205. package/src/__tests__/asset-runtime-error-code-owner.test-d.ts +63 -0
  206. package/src/__tests__/catalog-replica.unit.test.ts +196 -0
  207. package/src/__tests__/catalog-source-v2.unit.test.ts +158 -0
  208. package/src/__tests__/catalog-source.unit.test.ts +126 -0
  209. package/src/__tests__/catalog-subscription-order.unit.test.ts +65 -0
  210. package/src/__tests__/catalog-subscription.unit.test.ts +28 -0
  211. package/src/__tests__/decode-image-bytes-pass-through.test.ts +34 -0
  212. package/src/__tests__/decode-image-bytes.browser.test.ts +102 -0
  213. package/src/__tests__/decode-image-bytes.test-d.ts +108 -0
  214. package/src/__tests__/decode-image-bytes.test.ts +90 -0
  215. package/src/__tests__/decode-image-mime-owner.test.ts +2 -5
  216. package/src/__tests__/dynamic-texture-store.unit.test.ts +115 -0
  217. package/src/__tests__/errors-asset.unit.test.ts +92 -0
  218. package/src/__tests__/inline-pack-loaders.unit.test.ts +548 -0
  219. package/src/__tests__/instantiate-context.unit.test.ts +65 -0
  220. package/src/__tests__/instantiate-publication-fence.unit.test.ts +158 -0
  221. package/src/__tests__/instantiate.unit.test.ts +684 -0
  222. package/src/__tests__/loader-input-contract.unit.test.ts +89 -0
  223. package/src/__tests__/loader-registry.unit.test.ts +101 -0
  224. package/src/__tests__/material-dependency-invalidation.unit.test.ts +14 -0
  225. package/src/__tests__/material-generation-cache.unit.test.ts +46 -0
  226. package/src/__tests__/material-load-consumer.test-d.ts +42 -0
  227. package/src/__tests__/material-ready.integration.test.ts +508 -0
  228. package/src/__tests__/material-stale-generation.unit.test.ts +147 -0
  229. package/src/__tests__/mesh-bin-morph-v3.test.ts +22 -0
  230. package/src/__tests__/mesh-bin.unit.test.ts +152 -0
  231. package/src/__tests__/mipmap-generator.unit.test.ts +202 -0
  232. package/src/__tests__/pack-artifact-transcode-model-owner.test.ts +32 -0
  233. package/src/__tests__/pack-basis-load.integration.test.ts +168 -0
  234. package/src/__tests__/payload-validate.unit.test.ts +285 -0
  235. package/src/__tests__/plugin.test.ts +55 -0
  236. package/src/__tests__/registry-concurrency.bench.test.ts +40 -0
  237. package/src/__tests__/registry-failure-retry.integration.test.ts +69 -0
  238. package/src/__tests__/registry-load-graph.integration.test.ts +246 -0
  239. package/src/__tests__/registry-load-state.unit.test.ts +35 -0
  240. package/src/__tests__/registry-package-cache.unit.test.ts +53 -0
  241. package/src/__tests__/registry-public-ready.unit.test.ts +38 -0
  242. package/src/__tests__/resolve-asset-handle.unit.test.ts +238 -0
  243. package/src/__tests__/scene-handle-fields.unit.test.ts +129 -0
  244. package/src/__tests__/scene-payload.unit.test.ts +151 -0
  245. package/src/__tests__/scene-publication-fence.test.ts +129 -0
  246. package/src/__tests__/scriptable-pack-public-surface.test-d.ts +27 -0
  247. package/src/__tests__/static-rig.integration.test.ts +8 -0
  248. package/src/__tests__/validate-material.unit.test.ts +359 -0
  249. package/src/__tests__/video-pack-loader.integration.test.ts +83 -0
  250. package/src/aabb.ts +107 -0
  251. package/src/asset-registry.ts +2045 -0
  252. package/src/builtin-asset-registry.ts +164 -0
  253. package/src/catalog-source.ts +14 -70
  254. package/src/decode-image-bytes.ts +126 -0
  255. package/src/dynamic-texture-store.ts +234 -0
  256. package/src/errors/asset.ts +347 -0
  257. package/src/handles.ts +186 -0
  258. package/src/image-error.ts +45 -0
  259. package/src/index.ts +183 -16
  260. package/src/internal.ts +1 -0
  261. package/src/loader-registry.ts +157 -0
  262. package/src/loaders/inline-pack.ts +837 -0
  263. package/src/loaders/mesh-bin.ts +304 -0
  264. package/src/loaders/pack-artifact.ts +665 -0
  265. package/src/material/dependency-graph.ts +46 -0
  266. package/src/material/generation-cache.ts +177 -0
  267. package/src/material/inspection.ts +46 -0
  268. package/src/material/loader.ts +326 -0
  269. package/src/material/runtime-shader.ts +133 -0
  270. package/src/mesh-bin.ts +5 -0
  271. package/src/mipmap-generator.ts +450 -0
  272. package/src/payload-validate.ts +573 -0
  273. package/src/plugin.ts +48 -0
  274. package/src/registry/artifact-io.ts +259 -0
  275. package/src/registry/asset-evidence.ts +51 -0
  276. package/src/registry/catalog-state.ts +409 -0
  277. package/src/registry/catalog.ts +369 -0
  278. package/src/registry/instantiate.ts +1056 -0
  279. package/src/registry/load-by-guid.ts +1657 -0
  280. package/src/registry/load-state.ts +115 -0
  281. package/src/registry/runtime-projection.ts +63 -0
  282. package/src/registry/scene-publication-fence.ts +256 -0
  283. package/src/registry/validate-material.ts +385 -0
  284. package/src/resolve-asset-handle.ts +147 -0
  285. package/src/resolve-tileset-runtime.ts +72 -0
  286. package/src/scene-handle-fields.ts +221 -0
  287. package/src/scene-payload.ts +207 -0
  288. package/src/wire-default-loaders.ts +79 -0
  289. package/dist/__tests__/asset-graph-red.integration.test.d.ts +0 -2
  290. package/dist/__tests__/asset-graph-red.integration.test.d.ts.map +0 -1
  291. package/dist/__tests__/asset-kind.test.d.ts +0 -2
  292. package/dist/__tests__/asset-kind.test.d.ts.map +0 -1
  293. package/dist/__tests__/asset-registry-core.integration.test.d.ts +0 -2
  294. package/dist/__tests__/asset-registry-core.integration.test.d.ts.map +0 -1
  295. package/dist/__tests__/asset-registry-public-api.test-d.d.ts +0 -2
  296. package/dist/__tests__/asset-registry-public-api.test-d.d.ts.map +0 -1
  297. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts +0 -2
  298. package/dist/__tests__/asset-runtime-core-lifecycle.integration.test.d.ts.map +0 -1
  299. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts +0 -2
  300. package/dist/__tests__/asset-runtime-snapshot.unit.test.d.ts.map +0 -1
  301. package/dist/__tests__/catalog-session-red.unit.test.d.ts +0 -2
  302. package/dist/__tests__/catalog-session-red.unit.test.d.ts.map +0 -1
  303. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts +0 -2
  304. package/dist/__tests__/registry-lifecycle-red.integration.test.d.ts.map +0 -1
  305. package/src/__tests__/asset-graph-red.integration.test.ts +0 -113
  306. package/src/__tests__/asset-registry-core.integration.test.ts +0 -161
  307. package/src/__tests__/asset-registry-public-api.test-d.ts +0 -31
  308. package/src/__tests__/asset-runtime-core-lifecycle.integration.test.ts +0 -79
  309. package/src/__tests__/asset-runtime-snapshot.unit.test.ts +0 -23
  310. package/src/__tests__/catalog-session-red.unit.test.ts +0 -276
  311. package/src/__tests__/registry-lifecycle-red.integration.test.ts +0 -80
@@ -0,0 +1,1657 @@
1
+ // @forgeax/engine-assets-runtime -- load-by-guid + pack-fetch collaboration module
2
+ // (feat-20260705-runtime-tier2-decomposition M1 / w7, D-4). Free functions
3
+ // taking the AssetRegistry instance as first param; logic byte-preserved from the
4
+ // class body (this. -> registry.). This is the largest method cluster (loadByGuid
5
+ // + the DDC / pack-index / pack-file fetch + parse pipeline).
6
+
7
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
8
+ import { err, ok, type Result, type RhiError } from '@forgeax/engine-rhi';
9
+ import { isEngineMaterial } from '@forgeax/engine-shader';
10
+ import {
11
+ type ArtifactDescriptor,
12
+ ASSET_ERROR_HINTS,
13
+ type Asset,
14
+ type AssetCompression,
15
+ AssetError,
16
+ type AssetErrorCode,
17
+ type AssetErrorDetail,
18
+ type AssetRef,
19
+ type CatalogSubject,
20
+ type ImageError,
21
+ type ImageMetadata,
22
+ type LoadContext,
23
+ type LoaderAsyncResult,
24
+ type MaterialAsset,
25
+ type MeshAsset,
26
+ type ParseErrorDetail,
27
+ } from '@forgeax/engine-types';
28
+ import type { AssetRegistry, ParsedPackFile } from '../asset-registry';
29
+ import {
30
+ createMaterialLoader,
31
+ type MaterialLoadError,
32
+ type MaterialLoadRequest,
33
+ type MaterialPublication,
34
+ type MaterialReady,
35
+ } from '../material/loader';
36
+ import { readArtifact } from './artifact-io';
37
+ import { fetchPackIndex, resolveCatalogAssetUrl } from './catalog';
38
+ import { buildBreadcrumbHint, buildSceneChildContext } from './instantiate';
39
+ import { resolveRuntimeProjection } from './runtime-projection';
40
+
41
+ /**
42
+ * Load an asset and all its transitively referenced sub-assets by GUID;
43
+ * returns `ok(handle)` only when the asset and every sub-asset are in the
44
+ * registry.
45
+ *
46
+ * **Post-condition:** `ok(payload)` is returned ONLY when the asset AND every
47
+ * transitively referenced sub-asset (per the asset envelope's `refs[]`) are
48
+ * present in this registry. The implementation walks `envelope.refs` and
49
+ * recursively calls `loadByGuid` on each ref before cataloguing the top-level
50
+ * asset. The resolved value is the PAYLOAD `T`
51
+ * (D-17), never a handle -- mint a column handle with
52
+ * `world.allocSharedRef('Kind', payload)` when one is needed (e.g. before
53
+ * `instantiate`).
54
+ *
55
+ * Two paths:
56
+ * - **Dev / fallback** (no `configurePackIndex` call): synchronous catalogue
57
+ * lookup wrapped in `Promise.resolve`. Returns `Err(asset-not-found)` if not
58
+ * catalogued.
59
+ * - **Prod** (after `configurePackIndex(url)`): fetches `pack-index.json`
60
+ * on the first call (cached as a `Map<guid, {packageUrl, kind}>`), then
61
+ * fetches the individual resource URL and parses the asset payload, then
62
+ * catalogues it (GUID -> payload) and returns the payload.
63
+ *
64
+ * Error union: `AssetError | PackError | ImageError | RhiError` (closed -- no
65
+ * new codes were introduced by the recursive walk; every code is pre-existing).
66
+ *
67
+ * An in-flight `Map` (D-5) deduplicates concurrent calls for the same GUID and
68
+ * prevents stack overflow on cycles (A->B->A).
69
+ *
70
+ * **Breaking-change classification:** this is a semantic strengthening, not a
71
+ * shape change. Sub-assets catalogued by a prior `catalog(guid, payload)` /
72
+ * `loadByGuid` call are protected by the catalogue fast-path: the recursive
73
+ * walk hits cache on every node and incurs zero additional fetch.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const res = await engine.assets.loadByGuid<SceneAsset>(sceneGuid);
78
+ * if (!res.ok) {
79
+ * switch (res.error.code) {
80
+ * case 'asset-not-found':
81
+ * // top GUID or any sub-asset GUID is missing from the catalog
82
+ * break;
83
+ * case 'asset-fetch-failed':
84
+ * // network / CORS
85
+ * break;
86
+ * case 'asset-parse-failed':
87
+ * // payload malformed
88
+ * break;
89
+ * // ... AssetErrorCode | PackErrorCode | ImageErrorCode | RhiErrorCode exhaustive
90
+ * }
91
+ * return;
92
+ * }
93
+ * ```
94
+ */
95
+ export async function loadByGuid<T = Asset>(
96
+ registry: AssetRegistry,
97
+ guid: AssetGuid,
98
+ parentContext?: {
99
+ sceneEntityId?: number;
100
+ componentField?: string;
101
+ },
102
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
103
+ return loadByGuidInternal(registry, guid, parentContext, new Set());
104
+ }
105
+
106
+ function cookedRecordFromPayload(payload: Record<string, unknown>): unknown {
107
+ if (payload.schemaVersion === 'material-cook/3') return payload;
108
+ if (payload.cooked !== null && typeof payload.cooked === 'object') return payload.cooked;
109
+ if (payload.record !== null && typeof payload.record === 'object') return payload.record;
110
+ return undefined;
111
+ }
112
+
113
+ function materialArtifactKey(
114
+ record: Record<string, unknown> | undefined,
115
+ descriptors: Readonly<Record<string, ArtifactDescriptor>>,
116
+ ): string | undefined {
117
+ const artifact = record?.artifact;
118
+ const path =
119
+ artifact !== null && typeof artifact === 'object' && 'path' in artifact
120
+ ? (artifact as { readonly path?: unknown }).path
121
+ : undefined;
122
+ if (typeof path === 'string') {
123
+ const match = Object.entries(descriptors).find(([, descriptor]) => descriptor.path === path);
124
+ if (match !== undefined) return match[0];
125
+ }
126
+ const shaderArtifacts = Object.entries(descriptors).filter(
127
+ ([, descriptor]) => descriptor.mediaType === 'text/wgsl',
128
+ );
129
+ return shaderArtifacts.length === 1 ? shaderArtifacts[0]?.[0] : undefined;
130
+ }
131
+
132
+ function materialRecordPath(descriptor: ArtifactDescriptor): string {
133
+ return `${descriptor.path}.record.json`;
134
+ }
135
+
136
+ function inlineMaterialArtifact(
137
+ record: Record<string, unknown> | undefined,
138
+ ): MaterialPublication['artifact'] | undefined {
139
+ const artifact = record?.artifact;
140
+ if (artifact === null || typeof artifact !== 'object' || Array.isArray(artifact)) {
141
+ return undefined;
142
+ }
143
+ const bytes = (artifact as { readonly bytes?: unknown }).bytes;
144
+ const normalizedBytes =
145
+ bytes instanceof Uint8Array
146
+ ? new Uint8Array(bytes)
147
+ : Array.isArray(bytes) &&
148
+ bytes.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)
149
+ ? Uint8Array.from(bytes)
150
+ : undefined;
151
+ if (normalizedBytes === undefined) return undefined;
152
+ const digest = (artifact as { readonly digest?: unknown }).digest;
153
+ return {
154
+ bytes: normalizedBytes,
155
+ ...(typeof digest === 'string' ? { digest } : {}),
156
+ };
157
+ }
158
+
159
+ async function loadMaterialPublicationByGuid(
160
+ registry: AssetRegistry,
161
+ request: MaterialLoadRequest,
162
+ ): Promise<MaterialPublication | undefined> {
163
+ const parsedGuid = AssetGuid.parse(request.guid);
164
+ if (!parsedGuid.ok) return undefined;
165
+ const entry = await resolveCatalogEntry(registry, request.guid.toLowerCase());
166
+ if (entry === undefined) return undefined;
167
+ let pack = registry.packFileCache.get(entry.packageUrl);
168
+ if (pack === undefined) {
169
+ const fetched = await fetchAndCachePackFile(
170
+ registry,
171
+ entry.packageUrl,
172
+ request.guid.toLowerCase(),
173
+ );
174
+ if (!fetched.ok) return undefined;
175
+ pack = registry.packFileCache.get(entry.packageUrl);
176
+ }
177
+ const asset = pack?.assets.find(
178
+ (candidate) => candidate.guid.toLowerCase() === request.guid.toLowerCase(),
179
+ );
180
+ if (asset === undefined || asset.kind !== 'material') return undefined;
181
+ const record = cookedRecordFromPayload(asset.payload);
182
+ const recordObject =
183
+ record !== undefined && typeof record === 'object' && record !== null
184
+ ? (record as Record<string, unknown>)
185
+ : undefined;
186
+ const artifactKey = materialArtifactKey(recordObject, asset.artifacts ?? {});
187
+ if (artifactKey === undefined) {
188
+ const inlineArtifact = inlineMaterialArtifact(recordObject);
189
+ if (inlineArtifact !== undefined) {
190
+ return {
191
+ guid: request.guid,
192
+ record,
193
+ artifact: inlineArtifact,
194
+ };
195
+ }
196
+ return {
197
+ guid: request.guid,
198
+ record,
199
+ artifactError: {
200
+ code: 'asset-artifact-missing',
201
+ expected: 'a published material artifact descriptor',
202
+ },
203
+ };
204
+ }
205
+ const descriptor = asset.artifacts?.[artifactKey];
206
+ if (descriptor === undefined) {
207
+ return {
208
+ guid: request.guid,
209
+ record,
210
+ artifactError: {
211
+ code: 'asset-artifact-missing',
212
+ expected: `artifact descriptor '${artifactKey}'`,
213
+ },
214
+ };
215
+ }
216
+ let publicationRecord = record;
217
+ if (publicationRecord === undefined) {
218
+ const recordArtifact = await readArtifact({
219
+ packageUrl: entry.packageUrl,
220
+ guid: request.guid,
221
+ artifactKey: `${artifactKey}.record`,
222
+ descriptor: {
223
+ path: materialRecordPath(descriptor),
224
+ mediaType: 'application/json',
225
+ },
226
+ });
227
+ if (!recordArtifact.ok) return undefined;
228
+ try {
229
+ publicationRecord = JSON.parse(new TextDecoder().decode(recordArtifact.value));
230
+ } catch {
231
+ publicationRecord = null;
232
+ }
233
+ }
234
+ const artifactCacheKey = `${entry.packageUrl}\0${request.guid.toLowerCase()}\0${artifactKey}`;
235
+ const artifact = await registry.artifactCache.read(artifactCacheKey, () =>
236
+ readArtifact({
237
+ packageUrl: entry.packageUrl,
238
+ guid: request.guid,
239
+ artifactKey,
240
+ descriptor,
241
+ }),
242
+ );
243
+ if (!artifact.ok) {
244
+ if (artifact.error.code === 'asset-artifact-missing') {
245
+ return {
246
+ guid: request.guid,
247
+ record: publicationRecord,
248
+ artifactError: {
249
+ code: artifact.error.code,
250
+ expected: artifact.error.expected,
251
+ actual: artifact.error.detail.observed,
252
+ },
253
+ };
254
+ }
255
+ if (artifact.error.code === 'asset-artifact-integrity-mismatch') {
256
+ return {
257
+ guid: request.guid,
258
+ record: publicationRecord,
259
+ artifactError: {
260
+ code: artifact.error.code,
261
+ expected: artifact.error.expected,
262
+ actual: artifact.error.detail.observed,
263
+ },
264
+ };
265
+ }
266
+ return {
267
+ guid: request.guid,
268
+ record: publicationRecord,
269
+ artifactError: {
270
+ code: 'asset-artifact-missing',
271
+ expected: artifact.error.expected,
272
+ actual: artifact.error.detail.observed,
273
+ },
274
+ };
275
+ }
276
+ return {
277
+ guid: request.guid,
278
+ record: publicationRecord,
279
+ artifact: {
280
+ bytes: artifact.value,
281
+ },
282
+ };
283
+ }
284
+
285
+ export async function loadMaterialReadyByGuid(
286
+ registry: AssetRegistry,
287
+ request: MaterialLoadRequest,
288
+ ): Promise<MaterialReady | MaterialLoadError> {
289
+ const publication = await loadMaterialPublicationByGuid(registry, request);
290
+ return loadMaterialReadyPublication(registry, request, publication);
291
+ }
292
+
293
+ function publicationSpecializationKey(publication: MaterialPublication | undefined): string {
294
+ const record = publication?.record;
295
+ if (record === null || typeof record !== 'object') return '';
296
+ const specializationKey = (record as { readonly specializationKey?: unknown }).specializationKey;
297
+ return typeof specializationKey === 'string' ? specializationKey : '';
298
+ }
299
+
300
+ async function loadMaterialReadyPublication(
301
+ registry: AssetRegistry,
302
+ request: MaterialLoadRequest,
303
+ publication: MaterialPublication | undefined,
304
+ ): Promise<MaterialReady | MaterialLoadError> {
305
+ const loader = createMaterialLoader({
306
+ loadPublication: async () => publication,
307
+ loadReference: async (guid) => {
308
+ const parsed = AssetGuid.parse(guid);
309
+ if (!parsed.ok) return true;
310
+ const result = await loadByGuid(registry, parsed.value);
311
+ return result.ok;
312
+ },
313
+ });
314
+ const readiness = await loader.load(request);
315
+ registry.recordMaterialReadiness(request.guid, readiness);
316
+ return readiness;
317
+ }
318
+
319
+ async function loadByGuidInternal<T = Asset>(
320
+ registry: AssetRegistry,
321
+ guid: AssetGuid,
322
+ parentContext:
323
+ | {
324
+ sceneEntityId?: number;
325
+ componentField?: string;
326
+ }
327
+ | undefined,
328
+ ancestry: ReadonlySet<string>,
329
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
330
+ const guidKey = AssetGuid.format(guid).toLowerCase();
331
+
332
+ // feat-20260614 M8 (D-17): the registry catalogues GUID -> payload and
333
+ // returns the PAYLOAD (never a handle). Fast path: already catalogued
334
+ // (covers dev catalog() + prod cached repeat calls).
335
+ const existing = registry.assetCatalog.get(guidKey);
336
+ if (existing !== undefined) {
337
+ const state = registry.loadState.get(guidKey);
338
+ if (state?.status === 'provisional') {
339
+ // A provisional value is only a legal bridge for a true SCC back-edge.
340
+ // A sibling or unrelated concurrent load must await its owning promise;
341
+ // otherwise the parent reaches promoteReady while the dependency is
342
+ // still provisional and reports a spurious public-readiness failure.
343
+ if (ancestry.has(guidKey)) {
344
+ const provisional = registry.loadState.getProvisional<T>(guidKey);
345
+ if (provisional !== undefined) return ok(provisional);
346
+ } else {
347
+ const inFlight = registry.inFlight.get(guidKey);
348
+ if (inFlight !== undefined) {
349
+ return inFlight as Promise<Result<T, AssetError | ImageError | RhiError>>;
350
+ }
351
+ }
352
+ return err(
353
+ new AssetError({
354
+ code: 'asset-parse-failed',
355
+ expected: `GUID ${guidKey} to be promoted to Ready before public load`,
356
+ hint: 'wait for the active load to finish or retry after the referenced assets are ready',
357
+ }),
358
+ );
359
+ }
360
+ if (state?.status === 'ready' || (state === undefined && registry.packIndexUrl === undefined)) {
361
+ const ready = registry.loadState.getReady<T>(guidKey);
362
+ if (ready !== undefined) return ok(ready);
363
+ if (state === undefined) return ok(existing.payload as T);
364
+ }
365
+ }
366
+
367
+ // In-flight dedup (D-5 / B-10): if another call is already loading this
368
+ // GUID, return that same Promise — covers (a) concurrent same-GUID calls
369
+ // and (b) cycle A→B→A termination (B reaches A's in-flight entry).
370
+ const inFlightPromise = registry.inFlight.get(guidKey);
371
+ if (inFlightPromise !== undefined) {
372
+ return inFlightPromise as Promise<Result<T, AssetError | ImageError | RhiError>>;
373
+ }
374
+
375
+ // Prod fetch path: only enabled when packIndexUrl is configured.
376
+ if (registry.packIndexUrl !== undefined && typeof globalThis.fetch === 'function') {
377
+ // F22: capture generation snapshot at Promise creation time so the
378
+ // resolve path can detect whether invalidate/invalidateAll was called
379
+ // while the fetch was in flight.
380
+ const genAtStart = registry.generations.get(guidKey) ?? 0;
381
+ const globalGenAtStart = registry.globalGeneration;
382
+
383
+ const promise = (async () => {
384
+ const result = await loadByGuidProd<T>(registry, guid, guidKey, parentContext, ancestry);
385
+ // F22: if the generation counters changed since the Promise was
386
+ // created, discard the result -- the asset was invalidated.
387
+ if (
388
+ genAtStart !== (registry.generations.get(guidKey) ?? 0) ||
389
+ globalGenAtStart !== registry.globalGeneration
390
+ ) {
391
+ registry.assetCatalog.delete(guidKey);
392
+ registry.loadState.remove(guidKey);
393
+ return err(
394
+ new AssetError({
395
+ code: 'asset-invalidated',
396
+ expected: `GUID ${guidKey} was invalidated during load`,
397
+ hint: ASSET_ERROR_HINTS['asset-invalidated'],
398
+ }),
399
+ ) as Result<T, AssetError | ImageError | RhiError>;
400
+ }
401
+ return result;
402
+ })();
403
+ registry.inFlight.set(guidKey, promise);
404
+ try {
405
+ return await promise;
406
+ } finally {
407
+ if (registry.inFlight.get(guidKey) === promise) registry.inFlight.delete(guidKey);
408
+ }
409
+ }
410
+
411
+ // Dev / fallback: synchronous catalogue miss (no network).
412
+ return Promise.resolve(
413
+ err(
414
+ new AssetError({
415
+ code: 'asset-not-found',
416
+ expected: `GUID ${guidKey} catalogued in AssetRegistry`,
417
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
418
+ }),
419
+ ),
420
+ );
421
+ }
422
+
423
+ /**
424
+ * feat-20260603-asset-import-loader-injection M1 / w6: load an
425
+ * upstream-branch kind (texture / font) straight from its catalog entry
426
+ * through the injected async loader, then register the produced POD. Replaces
427
+ * the bespoke `loadTextureFromEntry` / `loadFontFromEntry` methods; the decode
428
+ * / glyph-parse logic moved verbatim into the loader bodies (D-2 — loader is
429
+ * pure of `registerWithGuid`, which stays here).
430
+ */
431
+ export async function loadFromUpstreamEntry<T = Asset>(
432
+ registry: AssetRegistry,
433
+ guidKey: string,
434
+ entry: {
435
+ packageUrl: string;
436
+ kind: string;
437
+ name?: string;
438
+ metadata?: ImageMetadata | undefined;
439
+ },
440
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
441
+ const loader = registry.loaders.get(entry.kind);
442
+ if (loader === undefined) {
443
+ return err(
444
+ new AssetError({
445
+ code: 'loader-not-registered',
446
+ expected: `a loader registered for kind '${entry.kind}'`,
447
+ hint: ASSET_ERROR_HINTS['loader-not-registered'],
448
+ detail: { kind: entry.kind, registeredKinds: registry.loaders.registeredKinds() },
449
+ }),
450
+ );
451
+ }
452
+ const out = loader.load({ ...entry, guidKey }, undefined, makeLoadContext(registry));
453
+ // Upstream-branch loaders are async (Promise<LoaderAsyncResult>).
454
+ const result = (await out) as LoaderAsyncResult;
455
+ if (!result.ok) {
456
+ return err(result.error as AssetError | ImageError | RhiError);
457
+ }
458
+ const guid = AssetGuid.parse(guidKey);
459
+ if (!guid.ok) {
460
+ return err(
461
+ new AssetError({
462
+ code: 'asset-parse-failed',
463
+ expected: `valid GUID ${guidKey}`,
464
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
465
+ }),
466
+ );
467
+ }
468
+ return registry.catalog(guid.value, result.value) as Result<
469
+ T,
470
+ AssetError | ImageError | RhiError
471
+ >;
472
+ }
473
+
474
+ /**
475
+ * Internal: prod fetch path for `loadByGuid`.
476
+ * Fetches pack-index.json (cached), then fetches the pack file, parses the
477
+ * asset payload, and registers it.
478
+ */
479
+ export async function loadByGuidProd<T = Asset>(
480
+ registry: AssetRegistry,
481
+ guid: AssetGuid,
482
+ guidKey: string,
483
+ parentContext?: {
484
+ sceneEntityId?: number;
485
+ componentField?: string;
486
+ },
487
+ ancestry: ReadonlySet<string> = new Set(),
488
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
489
+ // feat-20260603-asset-import-loader-injection M4 / w31 (AC-19 lazy iron law):
490
+ // wrap the DDC fetch + load path so a DDC miss can be routed through the
491
+ // injected ImportTransport (studio form) or fail-fast with
492
+ // `asset-not-imported` (shipped form, AC-22). The load path after a
493
+ // successful DDC resolve is identical in both forms -- zero branches on
494
+ // `registry.importTransport` (AC-23 key invariant).
495
+ //
496
+ // A DDC miss is: (a) the GUID is absent from the catalog, OR (b) the
497
+ // `.pack.json` fetch returns `asset-not-found` / `asset-fetch-failed`.
498
+ // In case (a) the transport is probed first (the pack-index may have been
499
+ // built before the asset was imported); in case (b) the transport is the
500
+ // only fallback (the pack file is genuinely missing).
501
+
502
+ const entry = await resolveCatalogEntry(registry, guidKey);
503
+ if (entry !== undefined) {
504
+ const projection = resolveRuntimeProjection(guidKey, entry);
505
+ if (!projection.ok) return projection;
506
+ if (projection.value?.lifecycle !== undefined && projection.value.lifecycle !== 'current') {
507
+ return transportOrFail<T>(registry, guid, guidKey, 'asset-not-imported');
508
+ }
509
+ // Catalog hit: try the DDC load path.
510
+ const result = await ddcLoad<T>(registry, guid, guidKey, entry, parentContext, ancestry);
511
+ if (result.ok) return result;
512
+ // DDC miss: only route through transport when the error indicates a
513
+ // missing pack file (not a parse / validation failure inside the pack) or
514
+ // an unimported texture source (feat-20260604 M2 / D-1: import-on-demand).
515
+ // `texture-source-not-imported` is an AssetError, so it passes the
516
+ // `instanceof AssetError` guard naturally. `image-decode-failed` is an
517
+ // ImageError (a genuinely corrupt imported .bin) -- it fails the guard and
518
+ // is therefore never transport-eligible (Risk-1), so a real decode
519
+ // failure is never silently lazy-imported.
520
+ const ddcError = result.error;
521
+ const transportEligible =
522
+ ddcError instanceof AssetError &&
523
+ (ddcError.code === 'asset-not-found' ||
524
+ ddcError.code === 'asset-fetch-failed' ||
525
+ ddcError.code === 'texture-source-not-imported' ||
526
+ // perf-20260706: the raw-container fail-fast (mesh/material/scene whose
527
+ // packageUrl is still a .glb/.gltf/.fbx) surfaces source-not-imported;
528
+ // it is transport-eligible so the import runs once and rewrites the row
529
+ // to .bin/.pack.json (the shipped form, with no transport, fails fast).
530
+ // Distinct from the generic asset-not-imported, which must stay
531
+ // NON-eligible so the parent-missing breadcrumb is never masked.
532
+ ddcError.code === 'source-not-imported');
533
+ if (transportEligible) {
534
+ return transportOrFail<T>(registry, guid, guidKey, ddcError.code);
535
+ }
536
+ return result;
537
+ }
538
+
539
+ // Catalog miss: the GUID is not in the pack-index. In the studio form the
540
+ // import transport can lazily create the missing DDC.
541
+ return transportOrFail<T>(registry, guid, guidKey, 'asset-not-found');
542
+ }
543
+
544
+ /**
545
+ * Resolve the catalog entry for a GUID, lazily fetching the pack-index on
546
+ * first call. Returns `undefined` when the GUID is absent from the catalog.
547
+ */
548
+ export async function resolveCatalogEntry(
549
+ registry: AssetRegistry,
550
+ guidKey: string,
551
+ ): Promise<
552
+ | {
553
+ packageUrl: string;
554
+ kind: string;
555
+ name?: string;
556
+ metadata?: ImageMetadata | undefined;
557
+ compression?: AssetCompression;
558
+ }
559
+ | undefined
560
+ > {
561
+ const key = guidKey.toLowerCase();
562
+ // Re-fetch the pack-index when it has never been fetched (=== undefined) OR
563
+ // when the cached Map lacks this GUID. The miss case covers invalidate(guid)
564
+ // round-2 M-A, which deletes the per-GUID index entry (targeted, bystanders
565
+ // survive) without nuking the whole Map to undefined: the next loadByGuid
566
+ // must re-consult the source so the GUID re-resolves and its freshly-cleared
567
+ // body cache re-fetches. A genuinely absent GUID re-fetches once then still
568
+ // misses, falling through to the transport / asset-not-found path as before.
569
+ if (registry.packIndexCache === undefined || !registry.packIndexCache.has(key)) {
570
+ const catalogResult = await fetchPackIndex(registry);
571
+ if (!catalogResult.ok) {
572
+ // Keep packIndexCache === undefined so next resolveCatalogEntry re-enters
573
+ // the fetch path instead of short-circuiting on an empty (polluted) cache.
574
+ if (registry.packIndexCache === undefined) return undefined;
575
+ } else {
576
+ registry.packIndexCache = catalogResult.value;
577
+ registerPackagesFromIndex(registry, registry.packIndexCache);
578
+ }
579
+ }
580
+ return registry.packIndexCache?.get(key);
581
+ }
582
+
583
+ /**
584
+ * feat-20260618 M3 (D-2): once the pack-index is parsed, group every row by
585
+ * its `packageUrl` and register each package fully -- all of its GUIDs and
586
+ * their entry display names in one `registerPackage` call. Registering the
587
+ * whole package at once (rather than one GUID per load) means the package
588
+ * cardinality is known up front, so `resolveName` returns the basename for a
589
+ * genuinely single-asset package and the entry name for a multi-asset one,
590
+ * with no incremental 1->N promotion needed on the prod path. The name travels
591
+ * entry -> Package, never through the payload (Risk-3 JSON-roundtrip safety),
592
+ * and covers both the sync (parseAssetPayload) and async (texture/font) loads.
593
+ */
594
+ export function registerPackagesFromIndex(
595
+ registry: AssetRegistry,
596
+ catalog: Map<
597
+ string,
598
+ {
599
+ packageUrl: string;
600
+ name?: string;
601
+ sourcePath?: string;
602
+ subject?: CatalogSubject;
603
+ }
604
+ >,
605
+ ): void {
606
+ const byPath = new Map<string, { guids: string[]; names: Map<string, string> }>();
607
+ for (const [guidKey, entry] of catalog) {
608
+ // Imported outputs are published under generated DDC/package URLs, but
609
+ // their stable package identity is the authored source. Keeping that
610
+ // source path here preserves `sky.hdr` (and equivalent source names) for
611
+ // single-asset lazy imports; authored internal packages retain their
612
+ // package URL semantics.
613
+ const packagePath =
614
+ entry.subject === 'imported-output' && entry.sourcePath !== undefined
615
+ ? entry.sourcePath
616
+ : entry.packageUrl;
617
+ let group = byPath.get(packagePath);
618
+ if (group === undefined) {
619
+ group = { guids: [], names: new Map() };
620
+ byPath.set(packagePath, group);
621
+ }
622
+ group.guids.push(guidKey);
623
+ if (entry.name !== undefined) group.names.set(guidKey, entry.name);
624
+ }
625
+ for (const [path, group] of byPath) {
626
+ registry._registerPackage(path, group.guids, group.names);
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Load an asset through the DDC (catalog entry -> fetch pack -> loader.load
632
+ * -> register). Returns `Err(asset-not-found)` or `Err(asset-fetch-failed)`
633
+ * on DDC miss (the caller then decides whether to route through the
634
+ * import transport).
635
+ *
636
+ * This path is IDENTICAL in studio and shipped forms -- the only difference
637
+ * between the two is whether `registry.importTransport` exists when the caller
638
+ * falls back to `transportOrFail` (AC-23 key invariant).
639
+ */
640
+ export async function ddcLoad<T = Asset>(
641
+ registry: AssetRegistry,
642
+ guid: AssetGuid,
643
+ guidKey: string,
644
+ entry: {
645
+ packageUrl: string;
646
+ kind: string;
647
+ name?: string;
648
+ metadata?: ImageMetadata | undefined;
649
+ compression?: AssetCompression;
650
+ },
651
+ parentContext?: {
652
+ sceneEntityId?: number;
653
+ componentField?: string;
654
+ },
655
+ ancestry: ReadonlySet<string> = new Set(),
656
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
657
+ if (typeof entry.packageUrl !== 'string' || entry.packageUrl.length === 0) {
658
+ return err(
659
+ new AssetError({
660
+ code: 'asset-not-imported',
661
+ expected: `catalog entry for GUID ${guidKey} to contain a packageUrl locator`,
662
+ hint: ASSET_ERROR_HINTS['asset-not-imported'],
663
+ }),
664
+ );
665
+ }
666
+ const packResult = await loadPackV2Asset(registry, guidKey, entry.packageUrl);
667
+ if (packResult === undefined) {
668
+ return err(
669
+ new AssetError({
670
+ code: 'asset-parse-failed',
671
+ expected: `Pack v2 package for GUID ${guidKey}`,
672
+ hint: 'legacy top-level asset payloads are not accepted',
673
+ }),
674
+ );
675
+ }
676
+ if (!packResult.ok) {
677
+ return packResult as Result<T, AssetError>;
678
+ }
679
+
680
+ const asset = packResult.value.asset;
681
+ // feat-20260622 M4 / w12: project the pack-entry refs[] (GUID strings) into
682
+ // AssetRef[] for the envelope. The on-disk pack.json refs[] carries only
683
+ // GUID strings (sourceField / sceneEntityId are stripped at the
684
+ // serialization boundary, w7 D-10), so prod-loaded edges have no per-entity
685
+ // metadata — the scene breadcrumb fallback (buildSceneChildContext) still
686
+ // walks the payload for entity/field detail. Dev-server register paths that
687
+ // carry rich AssetRef[] keep their edge metadata end-to-end.
688
+ const packRefs: readonly AssetRef[] = packResult.value.refs.map((g) => ({ guid: g }));
689
+
690
+ // feat-20260622 M5 / w17 (D-8, R5): the former material parent preload
691
+ // "Path B" (an independent early-return that loaded the parent BEFORE the
692
+ // unified for-loop and carried the precise breadcrumb hint `loading parent
693
+ // material X for child Y`) is folded into the unified envelope.refs
694
+ // for-loop. The parent GUID already rides on the material envelope.refs
695
+ // (gltf-importer w5 writes it; the on-disk pack refs[] carries it as a
696
+ // GUID string -> packRefs above projects it), so the unified for-loop
697
+ // recurses on it like any other edge. Here we only resolve the parent
698
+ // GUID -> AssetGuid and stamp `parent` onto the asset payload (the
699
+ // renderer-facing field read by walkMaterialPassesOverSharedRefs); the
700
+ // parent EDGE load + the `loading parent material X for child Y` breadcrumb
701
+ // + the not-a-material guard all move into the for-loop's
702
+ // sourceField.fieldName==='parent' / parent-edge branch below. No early
703
+ // return: the material registers (register-before-recurse) and its parent
704
+ // edge loads through the same unified path as texture / scene edges.
705
+ let assetToRegister: Asset = asset;
706
+ let parentGuidKey: string | undefined;
707
+ if (
708
+ asset.kind === 'material' &&
709
+ 'parentGuid' in (asset as unknown as Record<string, unknown>) &&
710
+ typeof (asset as unknown as Record<string, unknown>).parentGuid === 'string'
711
+ ) {
712
+ const parentGuidStr = (asset as unknown as MaterialAsset & { parentGuid: string }).parentGuid;
713
+ const parentGuid = AssetGuid.parse(parentGuidStr);
714
+ if (!parentGuid.ok) {
715
+ return err(
716
+ new AssetError({
717
+ code: 'asset-parse-failed',
718
+ expected: `valid parent GUID for child ${guidKey}`,
719
+ hint: `parent GUID '${parentGuidStr}' is not a valid UUID format`,
720
+ }),
721
+ );
722
+ }
723
+ parentGuidKey = parentGuidStr.toLowerCase();
724
+ const matAsset = asset as unknown as MaterialAsset & { parentGuid?: string };
725
+ const passes = matAsset.passes;
726
+ const values = matAsset.values;
727
+ assetToRegister = {
728
+ kind: 'material',
729
+ ...(passes !== undefined ? { passes } : {}),
730
+ ...(values !== undefined ? { values } : {}),
731
+ parent: parentGuid.value,
732
+ };
733
+ }
734
+
735
+ registry.loadState.begin(
736
+ guidKey,
737
+ packRefs.map((ref) => ref.guid),
738
+ );
739
+
740
+ // The provisional record lets cycle back-edges terminate internally while
741
+ // lookup()/inspect() remain limited to the ready domain.
742
+ // tweak-20260609 M1: catalogue the asset BEFORE recursing into its
743
+ // sub-assets. This way, when a cycle (A→B→A) reaches back to A during
744
+ // B's recursion, A is already catalogued (fast-path hit) and the inFlight
745
+ // Promise for A can be fulfilled. The inFlight entry in `loadByGuid` is
746
+ // the second line of defense — it catches concurrent same-GUID calls
747
+ // before the asset is catalogued.
748
+ const registerResult = registerParsedAsset<T>(registry, guid, assetToRegister, guidKey, packRefs);
749
+ if (!registerResult.ok) {
750
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, registerResult.error);
751
+ return registerResult;
752
+ }
753
+ const registeredPayload = registerResult.value;
754
+ registry.loadState.resolveAsset(guidKey, registeredPayload);
755
+
756
+ // feat-20260622 M4 / w12 (D-5): the recursion source is the just-catalogued
757
+ // envelope's refs[]. The for-loop is kind-agnostic
758
+ // — every AssetRef carries the GUID to recurse on; scene/material/skin all
759
+ // flow through this one loop. Each edge optionally carries sourceField /
760
+ // sceneEntityId; when present the childContext is built straight from the
761
+ // edge, otherwise the scene branch falls back to walking the payload
762
+ // (buildSceneChildContext) so the prod-path breadcrumb keeps its entity /
763
+ // field detail (on-disk refs[] are GUID-string-only, w7 D-10).
764
+ const envelope = registry.assetCatalog.get(guidKey);
765
+ const refs: readonly AssetRef[] = envelope?.refs ?? [];
766
+ if (refs.length > 0) {
767
+ const subResults = await Promise.all(
768
+ refs.map((ref) => {
769
+ const refGuidKey = ref.guid.toLowerCase();
770
+ const parsedRef = AssetGuid.parse(ref.guid);
771
+ if (!parsedRef.ok) {
772
+ return Promise.resolve({
773
+ guidKey: refGuidKey,
774
+ result: err(
775
+ new AssetError({
776
+ code: 'asset-parse-failed',
777
+ expected: `valid sub-asset GUID referenced by ${asset.kind} ${guidKey}`,
778
+ hint: `refs[] entry '${ref.guid}' is not a valid UUID format`,
779
+ }),
780
+ ) as Result<Asset, AssetError | ImageError | RhiError>,
781
+ childContext: undefined as
782
+ | {
783
+ sceneEntityId?: number;
784
+ componentField?: string;
785
+ sourceField?: {
786
+ componentName?: string;
787
+ fieldName: string;
788
+ arrayIndex?: number;
789
+ };
790
+ }
791
+ | undefined,
792
+ isParentEdge: false,
793
+ edge: ref,
794
+ });
795
+ }
796
+ let childContext:
797
+ | {
798
+ sceneEntityId?: number;
799
+ componentField?: string;
800
+ sourceField?: {
801
+ componentName?: string;
802
+ fieldName: string;
803
+ arrayIndex?: number;
804
+ };
805
+ }
806
+ | undefined;
807
+ if (ref.sceneEntityId !== undefined || ref.sourceField !== undefined) {
808
+ childContext = {};
809
+ if (ref.sceneEntityId !== undefined) childContext.sceneEntityId = ref.sceneEntityId;
810
+ if (ref.sourceField?.fieldName !== undefined) {
811
+ childContext.componentField =
812
+ (ref.sourceField.componentName !== undefined
813
+ ? `${ref.sourceField.componentName}.`
814
+ : '') +
815
+ ref.sourceField.fieldName +
816
+ (ref.sourceField.arrayIndex !== undefined ? `[${ref.sourceField.arrayIndex}]` : '');
817
+ childContext.sourceField = ref.sourceField;
818
+ }
819
+ } else if (asset.kind === 'scene') {
820
+ childContext = buildSceneChildContext(registry, asset, refGuidKey, guidKey);
821
+ }
822
+ // feat-20260622 M5 / w17 (D-8): the material parent edge. Identify it
823
+ // by either the rich dev-path marker (sourceField.fieldName==='parent')
824
+ // or the prod-path GUID match against the resolved parent GUID
825
+ // (on-disk refs[] strip sourceField, w7 D-10, so the GUID is the only
826
+ // signal). The parent edge carries the distinct `loading parent
827
+ // material X for child Y` breadcrumb (AC-10) instead of the generic
828
+ // buildBreadcrumbHint form, and is guarded to be a material.
829
+ const isParentEdge =
830
+ asset.kind === 'material' &&
831
+ (ref.sourceField?.fieldName === 'parent' ||
832
+ (parentGuidKey !== undefined && refGuidKey === parentGuidKey));
833
+ const childAncestry = new Set(ancestry);
834
+ childAncestry.add(guidKey);
835
+ return loadByGuidInternal(
836
+ registry,
837
+ parsedRef.value,
838
+ childContext ?? parentContext,
839
+ childAncestry,
840
+ ).then((r) => ({
841
+ guidKey: refGuidKey,
842
+ result: r,
843
+ childContext,
844
+ isParentEdge,
845
+ edge: ref,
846
+ }));
847
+ }),
848
+ );
849
+
850
+ // If any sub-asset load failed, propagate the first error enriched with
851
+ // parent breadcrumb.
852
+ for (const {
853
+ guidKey: subGuidKey,
854
+ result: subResult,
855
+ childContext: subChildContext,
856
+ isParentEdge,
857
+ edge: subEdge,
858
+ } of subResults) {
859
+ // feat-20260622 M5 / w17: parent-edge breadcrumb migration (former Path
860
+ // B). On load failure, carry the distinct `loading parent material X for
861
+ // child Y: <subErr.hint>` form (AC-10 downstream literal assertion) and
862
+ // propagate the parent's own error code verbatim.
863
+ if (isParentEdge && !subResult.ok) {
864
+ const subErr = subResult.error;
865
+ const code: AssetErrorCode =
866
+ subErr instanceof AssetError ? subErr.code : 'asset-parse-failed';
867
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, subErr);
868
+ return err(
869
+ new AssetError({
870
+ code,
871
+ expected: subErr.expected,
872
+ hint: `loading parent material ${subGuidKey} for child ${guidKey}: ${
873
+ subErr.hint ?? ''
874
+ }`,
875
+ ...(subErr instanceof AssetError && subErr.detail !== undefined
876
+ ? { detail: subErr.detail as Readonly<AssetErrorDetail> }
877
+ : {}),
878
+ }),
879
+ );
880
+ }
881
+ // feat-20260622 M5 / w17: parent edge loaded but is not a material —
882
+ // same guard the former Path B carried, with the matching breadcrumb.
883
+ if (isParentEdge && subResult.ok && subResult.value?.kind !== 'material') {
884
+ const error = new AssetError({
885
+ code: 'asset-parse-failed',
886
+ expected: `parent GUID ${subGuidKey} to reference a MaterialAsset`,
887
+ hint: `loading parent material ${subGuidKey} for child ${guidKey}: referenced asset is ${subResult.value?.kind ?? 'unknown'}, not 'material'`,
888
+ });
889
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, error);
890
+ return err(error);
891
+ }
892
+ if (!subResult.ok) {
893
+ const subErr = subResult.error;
894
+ const breadcrumb = buildBreadcrumbHint(
895
+ guidKey,
896
+ asset.kind,
897
+ subGuidKey,
898
+ subChildContext ?? parentContext,
899
+ );
900
+ const code: AssetErrorCode =
901
+ subErr instanceof AssetError ? subErr.code : 'asset-fetch-failed';
902
+ // feat-20260622 verify r1: deliver the breadcrumb provenance in
903
+ // structured form so AI users locate the broken edge by property
904
+ // access (charter P3), not by parsing the hint. Preserve the sub
905
+ // error's own detail when it carries one (more specific); otherwise
906
+ // expose the edge provenance (entity / source field).
907
+ // Prefer the rich dev-path edge provenance; on the prod path the
908
+ // on-disk edge is GUID-only (sourceField stripped, w7 D-10), so fall
909
+ // back to the entity-walk-recovered provenance carried on the
910
+ // childContext (verify r1).
911
+ const provEntityId = subEdge?.sceneEntityId ?? subChildContext?.sceneEntityId;
912
+ const provSourceField = subEdge?.sourceField ?? subChildContext?.sourceField;
913
+ const breadcrumbDetail: Readonly<AssetErrorDetail> = {
914
+ referencedByGuid: guidKey,
915
+ referencedByKind: asset.kind,
916
+ subAssetGuid: subGuidKey,
917
+ ...(provEntityId !== undefined ? { sceneEntityId: provEntityId } : {}),
918
+ ...(provSourceField !== undefined ? { sourceField: provSourceField } : {}),
919
+ };
920
+ const detail: Readonly<AssetErrorDetail> =
921
+ subErr instanceof AssetError && subErr.detail !== undefined
922
+ ? subErr.detail
923
+ : breadcrumbDetail;
924
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, subErr);
925
+ return err(
926
+ new AssetError({
927
+ code,
928
+ expected: subErr.expected,
929
+ hint: `${breadcrumb} / ${subErr.hint ?? ''}`,
930
+ detail,
931
+ }),
932
+ );
933
+ }
934
+ }
935
+ }
936
+
937
+ // A Mesh default is a typed dependency, not merely a loadable GUID. This
938
+ // check deliberately lives outside the `refs.length > 0` branch: a producer
939
+ // that puts a default GUID in the payload but omits the matching refs[] edge
940
+ // must fail closed instead of publishing a Mesh whose dependency closure is
941
+ // incomplete.
942
+ if (asset.kind === 'mesh') {
943
+ const directRefGuids = new Set(refs.map((ref) => ref.guid.toLowerCase()));
944
+ const slots = (asset as MeshAsset).materialSlots;
945
+ for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
946
+ const slot = slots[slotIndex];
947
+ if (slot?.defaultMaterial === undefined) continue;
948
+ const defaultMaterialGuid = AssetGuid.format(slot.defaultMaterial).toLowerCase();
949
+ const loaded = directRefGuids.has(defaultMaterialGuid)
950
+ ? registry.assetCatalog.get(defaultMaterialGuid)?.payload
951
+ : undefined;
952
+ if (loaded?.kind === 'material') continue;
953
+ const actualKind = directRefGuids.has(defaultMaterialGuid)
954
+ ? (loaded?.kind ?? 'missing')
955
+ : 'missing-ref-edge';
956
+ const error = new AssetError({
957
+ code: 'asset-parse-failed',
958
+ expected: `mesh ${guidKey} materialSlots[${slotIndex}] (${slot.slotName}) default ${defaultMaterialGuid} to reference a MaterialAsset`,
959
+ hint: `recook mesh ${guidKey}; slot ${slotIndex} '${slot.slotName}' resolves to ${actualKind}, not 'material'`,
960
+ detail: {
961
+ meshAssetGuid: guidKey,
962
+ slotIndex,
963
+ slotName: slot.slotName,
964
+ defaultMaterialGuid,
965
+ actualKind,
966
+ },
967
+ });
968
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, error);
969
+ return err(error);
970
+ }
971
+ }
972
+
973
+ registry.loadState.promoteReady(guidKey);
974
+ if (registry.loadState.getReady(guidKey) === undefined) {
975
+ const error = new AssetError({
976
+ code: 'asset-parse-failed',
977
+ expected: `GUID ${guidKey} and all referenced assets to be public-ready`,
978
+ hint: 'retry after every referenced GUID has loaded successfully',
979
+ });
980
+ purgeFailedLoad(registry, guidKey, entry.packageUrl, error);
981
+ return err(error);
982
+ }
983
+ const registeredAsset = registeredPayload as Asset;
984
+ if (registeredAsset.kind === 'material' && !isEngineMaterial(registeredAsset)) {
985
+ const publication = await loadMaterialPublicationByGuid(registry, {
986
+ guid: guidKey,
987
+ specializationKey: '',
988
+ });
989
+ await loadMaterialReadyPublication(
990
+ registry,
991
+ {
992
+ guid: guidKey,
993
+ specializationKey: publicationSpecializationKey(publication),
994
+ },
995
+ publication,
996
+ );
997
+ }
998
+ return ok(registeredPayload as T);
999
+ }
1000
+
1001
+ async function loadPackV2Asset(
1002
+ registry: AssetRegistry,
1003
+ guidKey: string,
1004
+ packageUrl: string,
1005
+ ): Promise<
1006
+ | Result<
1007
+ {
1008
+ asset: Asset;
1009
+ refs: readonly string[];
1010
+ },
1011
+ AssetError
1012
+ >
1013
+ | undefined
1014
+ > {
1015
+ const cached = registry.packFileCache.get(packageUrl);
1016
+ if (cached === undefined) {
1017
+ const inFlight = registry.packFileInFlight.get(packageUrl);
1018
+ if (inFlight !== undefined) await inFlight.catch(() => undefined);
1019
+ const fetched =
1020
+ registry.packFileCache.get(packageUrl) === undefined
1021
+ ? await fetchAndCachePackFile(registry, packageUrl, guidKey)
1022
+ : undefined;
1023
+ if (registry.packFileCache.get(packageUrl) === undefined) {
1024
+ if (fetched === undefined) {
1025
+ return err(
1026
+ new AssetError({
1027
+ code: 'asset-fetch-failed',
1028
+ expected: `pack file ${packageUrl} to be cached after its shared fetch`,
1029
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1030
+ }),
1031
+ );
1032
+ }
1033
+ return fetched as unknown as Result<{ asset: Asset; refs: readonly string[] }, AssetError>;
1034
+ }
1035
+ }
1036
+ const pack = registry.packFileCache.get(packageUrl);
1037
+ if (pack?.schemaVersion !== '2.0.0') return undefined;
1038
+ const asset = pack.assets.find((candidate) => candidate.guid.toLowerCase() === guidKey);
1039
+ if (asset === undefined) {
1040
+ return err(
1041
+ new AssetError({
1042
+ code: 'asset-not-found',
1043
+ expected: `GUID ${guidKey} present in Pack v2 package ${packageUrl}`,
1044
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
1045
+ }),
1046
+ );
1047
+ }
1048
+ const artifacts: Record<string, { descriptor: ArtifactDescriptor; bytes: Uint8Array }> = {};
1049
+ for (const [artifactKey, descriptor] of Object.entries(asset.artifacts ?? {})) {
1050
+ const cacheKey = `${packageUrl}\0${guidKey}\0${artifactKey}`;
1051
+ const artifact = await registry.artifactCache.read(cacheKey, () =>
1052
+ readArtifact({ packageUrl, guid: guidKey, artifactKey, descriptor }),
1053
+ );
1054
+ if (!artifact.ok)
1055
+ return artifact as unknown as Result<{ asset: Asset; refs: readonly string[] }, AssetError>;
1056
+ artifacts[artifactKey] = { descriptor, bytes: artifact.value };
1057
+ }
1058
+ const loaded = await registry.loaders.loadPack(
1059
+ {
1060
+ guid: guidKey,
1061
+ kind: asset.kind,
1062
+ payload: asset.payload,
1063
+ refs: asset.refs ?? [],
1064
+ artifacts,
1065
+ },
1066
+ makeLoadContext(registry),
1067
+ );
1068
+ if (!loaded.ok) return err(loaded.error as AssetError);
1069
+ if (loaded.value === undefined || typeof loaded.value !== 'object') {
1070
+ return err(
1071
+ new AssetError({
1072
+ code: 'asset-parse-failed',
1073
+ expected: `loader '${asset.kind}' to return an asset payload`,
1074
+ hint: 'register a loader that accepts the Pack v2 asset-local input',
1075
+ }),
1076
+ );
1077
+ }
1078
+ return ok({ asset: loaded.value as Asset, refs: asset.refs ?? [] });
1079
+ }
1080
+
1081
+ function purgeFailedLoad(
1082
+ registry: AssetRegistry,
1083
+ guidKey: string,
1084
+ packageUrl: string,
1085
+ error: unknown,
1086
+ ): void {
1087
+ const doomed = registry.loadState.fail(guidKey, error);
1088
+ for (const key of doomed) registry.assetCatalog.delete(key);
1089
+ registry.packFileCache.delete(packageUrl);
1090
+ }
1091
+
1092
+ /**
1093
+ * M4 transport fallback: try the injected {@link ImportTransport} to lazily
1094
+ * import a missing DDC, then re-enter the DDC load path. When no transport
1095
+ * is wired (shipped form), fail fast with `asset-not-imported` (AC-22).
1096
+ */
1097
+ export async function transportOrFail<T = Asset>(
1098
+ registry: AssetRegistry,
1099
+ guid: AssetGuid,
1100
+ guidKey: string,
1101
+ _missReason: AssetErrorCode,
1102
+ ): Promise<Result<T, AssetError | ImageError | RhiError>> {
1103
+ if (registry.importTransport === undefined) {
1104
+ // shipped form: no transport wired -> fail fast, never degrade to
1105
+ // runtime import (AC-22, charter P3 explicit failure).
1106
+ return err(
1107
+ new AssetError({
1108
+ code: 'asset-not-imported',
1109
+ expected: `GUID ${guidKey} to have been pre-imported at build time or to have an ImportTransport wired`,
1110
+ hint: ASSET_ERROR_HINTS['asset-not-imported'],
1111
+ }),
1112
+ );
1113
+ }
1114
+
1115
+ // studio form: request the transport to import this GUID on-the-fly.
1116
+ // After a successful transport call the DDC is available; re-enter the
1117
+ // catalog + DDC load path (the transport writes the DDC but does NOT
1118
+ // register the asset — that's the Loader's job).
1119
+ const transportResult = await registry.importTransport.fetchPack(
1120
+ guidKey,
1121
+ registry.runtimeBinding,
1122
+ );
1123
+ if (!transportResult.ok) {
1124
+ return err(
1125
+ new AssetError({
1126
+ code: 'asset-not-imported',
1127
+ expected: `import transport to fetch pack for GUID ${guidKey}`,
1128
+ hint: ASSET_ERROR_HINTS['asset-not-imported'],
1129
+ }),
1130
+ );
1131
+ }
1132
+
1133
+ // Patch ONLY the freshly imported rows into the catalog cache (per-asset
1134
+ // incremental, the four-verb redesign 2026-06-06) instead of nuking the
1135
+ // cache and re-fetching the whole pack-index. The transport returns the one
1136
+ // imported entry (+ sub-asset siblings); each becomes / overwrites a cache
1137
+ // row. This keeps 122 concurrent texture imports O(N) instead of O(N^2)
1138
+ // whole-catalog re-fetches and never resets a sibling's imported row.
1139
+ const importedEntries = 'entries' in transportResult ? transportResult.entries : undefined;
1140
+ if (importedEntries !== undefined && importedEntries.length > 0) {
1141
+ // F20: serialise packIndexCache writes through a per-cache Promise queue.
1142
+ // The "check -> new Map -> set" block is not atomic across concurrent
1143
+ // transportOrFail calls; chaining through the queue ensures each patch
1144
+ // completes before the next starts, preventing new-Map overwrite races.
1145
+ registry.packIndexCachePatchQueue = registry.packIndexCachePatchQueue.then(() => {
1146
+ if (registry.packIndexCache === undefined) registry.packIndexCache = new Map();
1147
+ for (const e of importedEntries) {
1148
+ if (typeof e.packageUrl !== 'string' || e.packageUrl.length === 0) continue;
1149
+ registry.packIndexCache.set(e.guid.toLowerCase(), {
1150
+ packageUrl: resolveCatalogAssetUrl(registry, e.packageUrl),
1151
+ kind: e.kind,
1152
+ // Carry the transport's derived display name into the cache row.
1153
+ // buildCatalog already resolves it (deriveAssetName: basename of the
1154
+ // source for single-/no-storedName sub-assets), so a freshly imported
1155
+ // GLB's 1000+ sub-assets show as "<file>.glb" in the Content Browser
1156
+ // instead of blank. Dropping it here made listCatalog fall back to
1157
+ // `entry.name ?? ''` — the whole-index re-read path (else branch) kept
1158
+ // names, so only the incremental patch path was blank.
1159
+ ...(e.name !== undefined ? { name: e.name } : {}),
1160
+ // Carry refs on the incremental patch path too, else an asset
1161
+ // imported via POST /__import shows missing dependency edges until
1162
+ // the next full pack-index refresh (feat: listCatalog refs).
1163
+ ...(e.refs !== undefined ? { refs: e.refs } : {}),
1164
+ ...(e.packageId !== undefined ? { packageId: e.packageId } : {}),
1165
+ ...(e.provenance !== undefined ? { provenance: e.provenance } : {}),
1166
+ ...(e.revision !== undefined ? { revision: e.revision } : {}),
1167
+ ...(e.authoring !== undefined ? { authoring: e.authoring } : {}),
1168
+ ...(e.sourceKey !== undefined ? { sourceKey: e.sourceKey } : {}),
1169
+ ...(e.sourceIndex !== undefined ? { sourceIndex: e.sourceIndex } : {}),
1170
+ ...(e.relations !== undefined ? { relations: e.relations } : {}),
1171
+ ...(e.diagnostics !== undefined ? { diagnostics: e.diagnostics } : {}),
1172
+ // Carry sourcePath on the incremental patch path too (same red-line
1173
+ // as refs above): an asset imported via POST /__import would
1174
+ // otherwise expose no source-file path in listCatalog until the next
1175
+ // full pack-index refresh, breaking editor CRUD sidecar lookup for
1176
+ // freshly imported assets. `sourcePath` is a required PackIndexEntry
1177
+ // field, so it is always present on the transport row.
1178
+ ...(e.sourcePath !== undefined ? { sourcePath: e.sourcePath } : {}),
1179
+ });
1180
+ }
1181
+ });
1182
+ await registry.packIndexCachePatchQueue;
1183
+ } else {
1184
+ // No inline rows -- fall back to a full pack-index re-read so the freshly
1185
+ // imported DDC entry is visible (legacy / non-row-returning transports).
1186
+ registry.packIndexCache = undefined;
1187
+ }
1188
+ const entry = await resolveCatalogEntry(registry, guidKey);
1189
+ if (entry === undefined) {
1190
+ return err(
1191
+ new AssetError({
1192
+ code: 'asset-not-imported',
1193
+ expected: `import transport to produce a catalog entry for GUID ${guidKey}`,
1194
+ hint: ASSET_ERROR_HINTS['asset-not-imported'],
1195
+ }),
1196
+ );
1197
+ }
1198
+
1199
+ // Re-enter the DDC load path (identical to the catalog-hit path).
1200
+ return ddcLoad<T>(registry, guid, guidKey, entry);
1201
+ }
1202
+
1203
+ /**
1204
+ * Register a parsed asset POD (the synchronous tail of the DDC load path:
1205
+ * `registerWithGuid`). Material parent preload is handled asynchronously
1206
+ * inside `ddcLoad` before calling this method; the registered asset is
1207
+ * always fully resolved by the time it reaches here.
1208
+ *
1209
+ * Extracted from the old `loadByGuidProd` body so `ddcLoad` and
1210
+ * `transportOrFail` share an identical load path (AC-23 key invariant).
1211
+ */
1212
+ export function registerParsedAsset<T = Asset>(
1213
+ registry: AssetRegistry,
1214
+ guid: AssetGuid,
1215
+ asset: Asset,
1216
+ _guidKey: string,
1217
+ refs?: readonly AssetRef[],
1218
+ ): Result<T, AssetError | ImageError | RhiError> {
1219
+ // feat-20260614 M8 (D-17): catalogue the parsed payload under its GUID and
1220
+ // return the PAYLOAD. `catalog` validates mesh stride + material passes and
1221
+ // returns Result.err on failure (no throw), so the loadByGuid surface stays
1222
+ // a consistent Result (charter P4 consistent abstraction).
1223
+ //
1224
+ // feat-20260622 M4 / w12 (D-9): the pack-entry refs[] ride onto the
1225
+ // catalogued envelope here so the recursive core can read envelope.refs as
1226
+ // its single recursion source.
1227
+ return registry.catalog<T>(guid, asset as T, refs) as Result<
1228
+ T,
1229
+ AssetError | ImageError | RhiError
1230
+ >;
1231
+ }
1232
+
1233
+ /**
1234
+ * Fetch a .pack.json file, find the asset entry matching guidKey, and
1235
+ * reconstruct the Asset from its payload.
1236
+ */
1237
+ /**
1238
+ * bug-20260610: fetch one pack file and return the raw asset entry without
1239
+ * parsing. Used by `loadByGuidProd` for material kinds so the caller can
1240
+ * recursively preload `refs[]` (texture sub-assets) BEFORE the synchronous
1241
+ * materialLoader runs and rewrites values handle fields to their refs[]
1242
+ * GUID strings (feat-20260614 M8 / D-19: GUID verbatim, no handle minting).
1243
+ */
1244
+ export async function fetchPackEntry(
1245
+ _registry: AssetRegistry,
1246
+ packageUrl: string,
1247
+ guidKey: string,
1248
+ ): Promise<
1249
+ Result<{ kind: string; payload: Record<string, unknown>; refs?: string[] }, AssetError>
1250
+ > {
1251
+ let raw: unknown;
1252
+ try {
1253
+ const res = await globalThis.fetch(packageUrl);
1254
+ if (!res.ok) {
1255
+ return err(
1256
+ new AssetError({
1257
+ code: 'asset-fetch-failed',
1258
+ expected: `fetch(${packageUrl}) to return ok`,
1259
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1260
+ }),
1261
+ );
1262
+ }
1263
+ raw = (await res.json()) as unknown;
1264
+ } catch {
1265
+ return err(
1266
+ new AssetError({
1267
+ code: 'asset-fetch-failed',
1268
+ expected: `fetch(${packageUrl}) to succeed`,
1269
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1270
+ }),
1271
+ );
1272
+ }
1273
+ const packFile = raw as {
1274
+ assets?: Array<{
1275
+ guid: string;
1276
+ kind: string;
1277
+ payload: Record<string, unknown>;
1278
+ refs?: string[];
1279
+ }>;
1280
+ };
1281
+ const assetEntry = (packFile.assets ?? []).find(
1282
+ (a) => a.guid.toLowerCase() === guidKey.toLowerCase(),
1283
+ );
1284
+ if (assetEntry === undefined) {
1285
+ return err(
1286
+ new AssetError({
1287
+ code: 'asset-not-found',
1288
+ expected: `GUID ${guidKey} present in pack file ${packageUrl}`,
1289
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
1290
+ }),
1291
+ );
1292
+ }
1293
+ return ok({
1294
+ kind: assetEntry.kind,
1295
+ payload: assetEntry.payload,
1296
+ ...(assetEntry.refs !== undefined ? { refs: assetEntry.refs } : {}),
1297
+ });
1298
+ }
1299
+
1300
+ /**
1301
+ * Fetch one pack file, locate the requested asset entry, and either parse it
1302
+ * inline or expose the entry to the caller (for kinds that need to preload
1303
+ * `refs[]` BEFORE running the loader — currently 'material', whose
1304
+ * values handle fields are rewritten to their refs[] GUID strings
1305
+ * (feat-20260614 M8 / D-19: GUID verbatim, no handle minting at load time)).
1306
+ *
1307
+ * bug-20260610 Fix B (M3 / D-4): the fetch+parse result is cached per
1308
+ * `packageUrl` in `packFileCache`; concurrent calls for the same URL share
1309
+ * a single in-flight promise via `packFileInFlight`. Only the raw parsed
1310
+ * body is cached — `parseAssetPayload` still runs per-call (CON-2).
1311
+ */
1312
+ export async function fetchPackFile(
1313
+ registry: AssetRegistry,
1314
+ packageUrl: string,
1315
+ guidKey: string,
1316
+ _kind: string,
1317
+ ): Promise<Result<{ asset: Asset; refs: readonly string[] }, AssetError>> {
1318
+ // ── cache hit ───────────────────────────────────────────────────────
1319
+ const cached = registry.packFileCache.get(packageUrl);
1320
+ if (cached !== undefined) {
1321
+ const assetEntry = cached.assets.find((a) => a.guid.toLowerCase() === guidKey.toLowerCase());
1322
+ if (assetEntry === undefined) {
1323
+ return err(
1324
+ new AssetError({
1325
+ code: 'asset-not-found',
1326
+ expected: `GUID ${guidKey} present in pack file ${packageUrl}`,
1327
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
1328
+ }),
1329
+ );
1330
+ }
1331
+ return parseAndReturnAsset(registry, assetEntry);
1332
+ }
1333
+
1334
+ // ── in-flight dedup ─────────────────────────────────────────────────
1335
+ const inFlight = registry.packFileInFlight.get(packageUrl);
1336
+ if (inFlight !== undefined) {
1337
+ try {
1338
+ const packFile = await inFlight;
1339
+ const assetEntry = packFile.assets.find(
1340
+ (a) => a.guid.toLowerCase() === guidKey.toLowerCase(),
1341
+ );
1342
+ if (assetEntry === undefined) {
1343
+ return err(
1344
+ new AssetError({
1345
+ code: 'asset-not-found',
1346
+ expected: `GUID ${guidKey} present in pack file ${packageUrl}`,
1347
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
1348
+ }),
1349
+ );
1350
+ }
1351
+ return parseAndReturnAsset(registry, assetEntry);
1352
+ } catch {
1353
+ // In-flight promise rejected (network failure) — fall through to
1354
+ // re-fetch. The in-flight entry was already cleaned by the
1355
+ // catch block in the original miss path.
1356
+ }
1357
+ }
1358
+
1359
+ // ── miss: fetch + parse + cache ─────────────────────────────────────
1360
+ return fetchAndCachePackFile(registry, packageUrl, guidKey);
1361
+ }
1362
+
1363
+ /**
1364
+ * Parse the asset payload from a pack-file entry and return the result.
1365
+ * Extracted so cache-hit and in-flight-dedup paths share the same
1366
+ * parseAssetPayload + error-wrapping logic.
1367
+ */
1368
+ export function parseAndReturnAsset(
1369
+ registry: AssetRegistry,
1370
+ assetEntry: {
1371
+ kind: string;
1372
+ payload: Record<string, unknown>;
1373
+ refs?: string[];
1374
+ },
1375
+ ): Result<{ asset: Asset; refs: readonly string[] }, AssetError> {
1376
+ const parsed = parseAssetPayload(registry, assetEntry.kind, assetEntry.payload, assetEntry.refs);
1377
+ // F21: the scene loader returns its structured ParseErrorDetail inline via
1378
+ // the LoaderOutput `{ ok: false, error }` arm, surfaced here through
1379
+ // parseAssetPayload's return value -- no shared instance slot.
1380
+ if (parsed !== undefined && typeof parsed === 'object' && 'ok' in parsed) {
1381
+ const e = (parsed as { readonly ok: false; readonly error: ParseErrorDetail }).error;
1382
+ return err(
1383
+ new AssetError({
1384
+ code: 'asset-parse-failed',
1385
+ expected: `refs index ${e.index} within [0, ${e.refsLength})`,
1386
+ detail: {
1387
+ localId: e.localId,
1388
+ component: e.component,
1389
+ field: e.field,
1390
+ index: e.index,
1391
+ refsLength: e.refsLength,
1392
+ },
1393
+ hint:
1394
+ `at node localId=${e.localId}, component=${e.component}, ` +
1395
+ `field=${e.field}: index ${e.index} is out of bounds ` +
1396
+ `(refs has ${e.refsLength} entries)`,
1397
+ }),
1398
+ );
1399
+ }
1400
+ if (parsed === undefined) {
1401
+ return err(
1402
+ new AssetError({
1403
+ code: 'asset-parse-failed',
1404
+ expected: `parseable asset payload for kind ${assetEntry.kind}`,
1405
+ hint: ASSET_ERROR_HINTS['asset-parse-failed'],
1406
+ }),
1407
+ );
1408
+ }
1409
+ // feat-20260622 M4 / w12: surface the pack-entry refs[] (GUID-string
1410
+ // projection) alongside the parsed payload so ddcLoad can store them on
1411
+ // the catalogued envelope. The recursive core then reads envelope.refs
1412
+ // as the single recursion source (D-5), never re-deriving them from
1413
+ // the payload.
1414
+ return ok({ asset: parsed as Asset, refs: assetEntry.refs ?? [] });
1415
+ }
1416
+
1417
+ /**
1418
+ * Fetch a pack file from the network, parse the JSON body, store the
1419
+ * result in the cache, and return the requested asset entry.
1420
+ *
1421
+ * Registers the in-flight promise in `packFileInFlight` so concurrent
1422
+ * callers share a single fetch. On success the body moves to
1423
+ * `packFileCache`; on failure the in-flight entry is removed so
1424
+ * subsequent retries re-fetch (D-7).
1425
+ */
1426
+ export async function fetchAndCachePackFile(
1427
+ registry: AssetRegistry,
1428
+ packageUrl: string,
1429
+ guidKey: string,
1430
+ ): Promise<Result<{ asset: Asset; refs: readonly string[] }, AssetError>> {
1431
+ const fetchPromise = (async (): Promise<ParsedPackFile> => {
1432
+ let raw: unknown;
1433
+ try {
1434
+ const res = await globalThis.fetch(packageUrl);
1435
+ if (!res.ok) {
1436
+ throw new AssetError({
1437
+ code: 'asset-fetch-failed',
1438
+ expected: `fetch(${packageUrl}) to return ok`,
1439
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1440
+ });
1441
+ }
1442
+ raw = (await res.json()) as unknown;
1443
+ } catch (e) {
1444
+ if (e instanceof AssetError) throw e;
1445
+ throw new AssetError({
1446
+ code: 'asset-fetch-failed',
1447
+ expected: `fetch(${packageUrl}) to succeed`,
1448
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1449
+ });
1450
+ }
1451
+ // Shape guard: the dev-server / preview / 404 fallback can return
1452
+ // index.html or an unrelated JSON body that satisfies res.ok but lacks
1453
+ // the ParsedPackFile contract. Without this guard the downstream
1454
+ // `packFile.assets.find` raises TypeError outside any AssetError
1455
+ // branch, escapes as a process-level Unhandled Rejection, and drives
1456
+ // vitest browser-project exit=1 even when every onerror-gate test
1457
+ // assertion passes (feat-20260611 step-implement F-4).
1458
+ if (
1459
+ raw === null ||
1460
+ typeof raw !== 'object' ||
1461
+ !Array.isArray((raw as { assets?: unknown }).assets)
1462
+ ) {
1463
+ throw new AssetError({
1464
+ code: 'asset-fetch-failed',
1465
+ expected: `pack-file body at ${packageUrl} to be { assets: [...] }`,
1466
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1467
+ });
1468
+ }
1469
+ return raw as ParsedPackFile;
1470
+ })();
1471
+
1472
+ registry.packFileInFlight.set(packageUrl, fetchPromise);
1473
+
1474
+ try {
1475
+ const packFile = await fetchPromise;
1476
+ registry.packFileCache.set(packageUrl, packFile);
1477
+ registry.packFileInFlight.delete(packageUrl);
1478
+
1479
+ const assetEntry = packFile.assets.find((a) => a.guid.toLowerCase() === guidKey.toLowerCase());
1480
+ if (assetEntry === undefined) {
1481
+ return err(
1482
+ new AssetError({
1483
+ code: 'asset-not-found',
1484
+ expected: `GUID ${guidKey} present in pack file ${packageUrl}`,
1485
+ hint: ASSET_ERROR_HINTS['asset-not-found'],
1486
+ }),
1487
+ );
1488
+ }
1489
+ return parseAndReturnAsset(registry, assetEntry);
1490
+ } catch (e) {
1491
+ registry.packFileInFlight.delete(packageUrl);
1492
+ if (e instanceof AssetError) {
1493
+ return err(e);
1494
+ }
1495
+ throw e;
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Reconstruct a typed `Asset` from a raw payload object.
1501
+ *
1502
+ * @param kind The asset kind discriminant (matches the pack entry or
1503
+ * dev-register dispatch).
1504
+ * @param payload The serialised asset payload (keys mirror the asset
1505
+ * interface field names).
1506
+ * @param refs Pack-file refs array for Handle fields — when a field
1507
+ * value is `number` it resolves to `refs[N]` (glTF-style index).
1508
+ * Optional to preserve compatibility with callers outside the pack
1509
+ * ingestion path (e.g., direct `registerWithGuid`).
1510
+ */
1511
+ export function parseAssetPayload(
1512
+ registry: AssetRegistry,
1513
+ kind: string,
1514
+ payload: Record<string, unknown>,
1515
+ refs?: string[],
1516
+ ):
1517
+ | Asset
1518
+ | Record<string, unknown>
1519
+ | undefined
1520
+ | { readonly ok: false; readonly error: ParseErrorDetail } {
1521
+ // feat-20260603-asset-import-loader-injection M1 / w4: dispatch on
1522
+ // `kind` through the injected LoaderRegistry instead of a hardcoded
1523
+ // `if (kind === ...)` chain (D-1 / AC-01). The seven inline pack-payload
1524
+ // loaders parse synchronously; texture / font live on the upstream
1525
+ // loadByGuidProd branch (w6) and are never reached here.
1526
+ // feat-20260623 M2 / w5: unknown kinds pass through the raw payload so
1527
+ // host-registered loaders can parse their own kind. The engine does not
1528
+ // parse payloads it cannot match; parse responsibility is explicit on the
1529
+ // missing loader (charter P3).
1530
+ const loader = registry.loaders.get(kind);
1531
+ if (loader === undefined) return { ...payload, kind };
1532
+ const out = loader.load(payload, refs, makeLoadContext(registry));
1533
+ // The inline pack-payload loaders are synchronous (`Asset | undefined`);
1534
+ // the async texture / font loaders are dispatched from loadByGuidProd, not
1535
+ // here. A Promise here would mean a misregistered loader -> treat as a
1536
+ // parse miss rather than leaking a thenable into the sync return.
1537
+ if (out !== undefined && typeof (out as { then?: unknown }).then === 'function') {
1538
+ return undefined;
1539
+ }
1540
+ // F21: the scene loader returns { ok: false, error: ParseErrorDetail } for
1541
+ // structured parse errors. Pass the error arm straight through the return
1542
+ // value so the caller constructs a precise AssetError -- no instance slot.
1543
+ if (out !== undefined && out !== null && typeof out === 'object' && 'ok' in out) {
1544
+ return out as { readonly ok: false; readonly error: ParseErrorDetail };
1545
+ }
1546
+ return out as Asset | undefined;
1547
+ }
1548
+
1549
+ /**
1550
+ * Build the {@link LoadContext} passed to a loader's `load`.
1551
+ * `fetchBinary` / `resolveRef` / `device` are wired for the async texture /
1552
+ * font loaders (w6).
1553
+ */
1554
+ export function makeLoadContext(registry: AssetRegistry): LoadContext {
1555
+ return {
1556
+ /**
1557
+ * feat-20260706 M3 / w19: fetchBinary signature extended per D-2.
1558
+ * `opts?.compression` triggers the single decompression gate (AC-02).
1559
+ * 'zstd' → lazy-init codec decompressZstd · 'none' / undefined → pass-through.
1560
+ * On decompression failure, the codec error is nested in asset-fetch-failed
1561
+ * detail (D-8: runtime error union NOT extended).
1562
+ */
1563
+ fetchBinary: async (url: string, opts?: { readonly compression?: AssetCompression }) => {
1564
+ try {
1565
+ const res = await globalThis.fetch(url);
1566
+ if (!res.ok) {
1567
+ return {
1568
+ ok: false as const,
1569
+ error: new AssetError({
1570
+ code: 'asset-fetch-failed',
1571
+ expected: `fetch(${url}) to return ok`,
1572
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1573
+ }),
1574
+ };
1575
+ }
1576
+ const buf = await res.arrayBuffer();
1577
+ let bytes: Uint8Array = new Uint8Array(buf);
1578
+
1579
+ // --- Decompression gate (AC-02: single gate inside fetchBinary) ---
1580
+ if (opts?.compression === 'zstd') {
1581
+ const { decompressZstd } = await import('@forgeax/engine-codec');
1582
+ const decRes = await decompressZstd(bytes);
1583
+ if (!decRes.ok) {
1584
+ return {
1585
+ ok: false as const,
1586
+ error: new AssetError({
1587
+ code: 'asset-parse-failed',
1588
+ expected: `zstd decompression for ${url}`,
1589
+ hint: `[${decRes.error.code}] ${decRes.error.hint}`,
1590
+ detail: { sourcePath: url },
1591
+ }),
1592
+ };
1593
+ }
1594
+ bytes = new Uint8Array(
1595
+ decRes.value.buffer,
1596
+ decRes.value.byteOffset,
1597
+ decRes.value.byteLength,
1598
+ );
1599
+ }
1600
+ // compression === 'none' / undefined → E1 pass-through
1601
+
1602
+ return { ok: true as const, value: bytes };
1603
+ } catch {
1604
+ return {
1605
+ ok: false as const,
1606
+ error: new AssetError({
1607
+ code: 'asset-fetch-failed',
1608
+ expected: `fetch(${url}) to succeed`,
1609
+ hint: ASSET_ERROR_HINTS['asset-fetch-failed'],
1610
+ }),
1611
+ };
1612
+ }
1613
+ },
1614
+ resolveRef: async (guid: string) => {
1615
+ const parsed = AssetGuid.parse(guid);
1616
+ if (!parsed.ok) {
1617
+ return { ok: false as const, error: parsed.error };
1618
+ }
1619
+ const r = await loadByGuid(registry, parsed.value);
1620
+ if (!r.ok) return { ok: false as const, error: r.error };
1621
+ // feat-20260614 M8 (D-19): resolveRef ensures the sub-asset is
1622
+ // catalogued (recursive load). The numeric value is vestigial -- the
1623
+ // registry mints no handles; callers store the GUID, not this number.
1624
+ return { ok: true as const, value: 0 };
1625
+ },
1626
+ // feat-20260613-material-paramschema-driven-binding M4 / w22 (D-5 graceful):
1627
+ // expose the registered shader's derive(paramSchema).textureFieldNames to
1628
+ // the materialLoader so it can decide which values fields carry
1629
+ // refs[] indices without a hardcoded texture-field allowlist Set
1630
+ // (AC-03). Returns `undefined` when the shader is not registered (cross-
1631
+ // worktree shader-late-register, plan R-4) — the loader then falls back
1632
+ // to a graceful "try every int paramValue" walk.
1633
+ getMaterialShaderTextureFieldNames: (shaderId: string) => {
1634
+ const lookup = registry.shaderRegistry.findMaterialArtifact(shaderId);
1635
+ if (!lookup.ok) return undefined;
1636
+ return lookup.value.paramSchemaProjection.derivedInterface.textureFieldNames;
1637
+ },
1638
+ transcodeCaps: registry.transcodeCaps,
1639
+ device: undefined,
1640
+ };
1641
+ }
1642
+
1643
+ /**
1644
+ * Return a runtime snapshot of every catalogued asset. Each entry exposes
1645
+ * `{ guid, kind, name }` where `kind` is the asset discriminant string
1646
+ * from `payload.kind`. feat-20260614 M8 (D-15): the registry holds no
1647
+ * handles -- entries are keyed by GUID (the catalogue key).
1648
+ *
1649
+ * AI-user narrowing flow (AC-11 + plan-strategy §7.4):
1650
+ * ```ts
1651
+ * for (const e of registry.inspect().assets) {
1652
+ * if (e.kind === 'texture') {
1653
+ * // re-query via registry.lookup(e.guid) to get the typed Asset value.
1654
+ * }
1655
+ * }
1656
+ * ```
1657
+ */