@forgeax/engine-runtime 0.1.23 → 0.1.24

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 (368) hide show
  1. package/README.md +15 -6
  2. package/dist/__tests__/advance-animation-player.cases.unit.test.d.ts +2 -0
  3. package/dist/__tests__/advance-animation-player.cases.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/asset-registry-aabb.cases.unit.test.d.ts +2 -0
  5. package/dist/__tests__/asset-registry-aabb.cases.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/asset-registry-bootstrap.cases.unit.test.d.ts +2 -0
  7. package/dist/__tests__/asset-registry-bootstrap.cases.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/asset-registry-builtin-nineslice.cases.unit.test.d.ts +2 -0
  9. package/dist/__tests__/asset-registry-builtin-nineslice.cases.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.d.ts +2 -0
  11. package/dist/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/asset-registry-guid.cases.unit.test.d.ts +2 -0
  13. package/dist/__tests__/asset-registry-guid.cases.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/asset-registry-material-validate.cases.unit.test.d.ts +2 -0
  15. package/dist/__tests__/asset-registry-material-validate.cases.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.d.ts +2 -0
  17. package/dist/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/asset-registry-scene.cases.unit.test.d.ts +2 -0
  19. package/dist/__tests__/asset-registry-scene.cases.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.d.ts +2 -0
  21. package/dist/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/asset-renderer-assembly.cases.unit.test.d.ts +2 -0
  23. package/dist/__tests__/asset-renderer-assembly.cases.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/asset.cases.unit.test.d.ts +2 -0
  25. package/dist/__tests__/asset.cases.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/bloom-gating.cases.unit.test.d.ts +2 -0
  27. package/dist/__tests__/bloom-gating.cases.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/builtin-guid-ssot.cases.unit.test.d.ts +2 -0
  29. package/dist/__tests__/builtin-guid-ssot.cases.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/builtin-pack.cases.unit.test.d.ts +2 -0
  31. package/dist/__tests__/builtin-pack.cases.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/camera-antialias.cases.unit.test.d.ts +2 -0
  33. package/dist/__tests__/camera-antialias.cases.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/camera-bloom.cases.unit.test.d.ts +2 -0
  35. package/dist/__tests__/camera-bloom.cases.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/camera-clear-schema.cases.unit.test.d.ts +2 -0
  37. package/dist/__tests__/camera-clear-schema.cases.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/camera-ortho.cases.unit.test.d.ts +2 -0
  39. package/dist/__tests__/camera-ortho.cases.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/cluster-binner.cases.unit.test.d.ts +2 -0
  41. package/dist/__tests__/cluster-binner.cases.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.d.ts +2 -0
  43. package/dist/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/create-renderer-uniform-fallback.cases.unit.test.d.ts +2 -0
  45. package/dist/__tests__/create-renderer-uniform-fallback.cases.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/cubemap-upload.cases.unit.test.d.ts +2 -0
  47. package/dist/__tests__/cubemap-upload.cases.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/dev-import-transport.cases.unit.test.d.ts +2 -0
  49. package/dist/__tests__/dev-import-transport.cases.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/engine-metrics.cases.unit.test.d.ts +2 -0
  51. package/dist/__tests__/engine-metrics.cases.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/equirect-projection-error.cases.unit.test.d.ts +2 -0
  53. package/dist/__tests__/equirect-projection-error.cases.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/font-asset-load.cases.unit.test.d.ts +2 -0
  55. package/dist/__tests__/font-asset-load.cases.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/fxaa-intermediate-texture.cases.unit.test.d.ts +2 -0
  57. package/dist/__tests__/fxaa-intermediate-texture.cases.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/fxaa-pipeline.cases.unit.test.d.ts +2 -0
  59. package/dist/__tests__/fxaa-pipeline.cases.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/gpu-residency.cases.unit.test.d.ts +2 -0
  61. package/dist/__tests__/gpu-residency.cases.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/gpu-resource-release.cases.unit.test.d.ts +2 -0
  63. package/dist/__tests__/gpu-resource-release.cases.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/handle-quad.cases.unit.test.d.ts +2 -0
  65. package/dist/__tests__/handle-quad.cases.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/hdrp-bgl-slots.cases.unit.test.d.ts +2 -0
  67. package/dist/__tests__/hdrp-bgl-slots.cases.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/hdrp-grid-invalid.cases.unit.test.d.ts +2 -0
  69. package/dist/__tests__/hdrp-grid-invalid.cases.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/hdrp-index-list-overflow-once.cases.unit.test.d.ts +2 -0
  71. package/dist/__tests__/hdrp-index-list-overflow-once.cases.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/hdrp-light-budget.cases.unit.test.d.ts +2 -0
  73. package/dist/__tests__/hdrp-light-budget.cases.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/hdrp-pipeline-asset-config.cases.unit.test.d.ts +2 -0
  75. package/dist/__tests__/hdrp-pipeline-asset-config.cases.unit.test.d.ts.map +1 -0
  76. package/dist/__tests__/helpers/renderer-device-loss-dawn.d.ts +25 -0
  77. package/dist/__tests__/helpers/renderer-device-loss-dawn.d.ts.map +1 -0
  78. package/dist/__tests__/helpers/standard-material-manifest.d.ts +0 -1
  79. package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -1
  80. package/dist/__tests__/ibl-pipeline-cache.cases.unit.test.d.ts +2 -0
  81. package/dist/__tests__/ibl-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  82. package/dist/__tests__/lazy-catalog.cases.unit.test.d.ts +2 -0
  83. package/dist/__tests__/lazy-catalog.cases.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/load-by-guid-hdr.cases.unit.test.d.ts +2 -0
  85. package/dist/__tests__/load-by-guid-hdr.cases.unit.test.d.ts.map +1 -0
  86. package/dist/__tests__/load-by-guid-prod-material-parent.cases.unit.test.d.ts +2 -0
  87. package/dist/__tests__/load-by-guid-prod-material-parent.cases.unit.test.d.ts.map +1 -0
  88. package/dist/__tests__/load-by-guid-prod.cases.unit.test.d.ts +2 -0
  89. package/dist/__tests__/load-by-guid-prod.cases.unit.test.d.ts.map +1 -0
  90. package/dist/__tests__/loader-registry.cases.unit.test.d.ts +2 -0
  91. package/dist/__tests__/loader-registry.cases.unit.test.d.ts.map +1 -0
  92. package/dist/__tests__/m7-hdrp-demo-shape.cases.unit.test.d.ts +2 -0
  93. package/dist/__tests__/m7-hdrp-demo-shape.cases.unit.test.d.ts.map +1 -0
  94. package/dist/__tests__/mipmap-formula.cases.unit.test.d.ts +2 -0
  95. package/dist/__tests__/mipmap-formula.cases.unit.test.d.ts.map +1 -0
  96. package/dist/__tests__/mipmap-pipeline-cache.cases.unit.test.d.ts +2 -0
  97. package/dist/__tests__/mipmap-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  98. package/dist/__tests__/parse-asset-payload-material.cases.unit.test.d.ts +2 -0
  99. package/dist/__tests__/parse-asset-payload-material.cases.unit.test.d.ts.map +1 -0
  100. package/dist/__tests__/parse-asset-payload-texture.cases.unit.test.d.ts +2 -0
  101. package/dist/__tests__/parse-asset-payload-texture.cases.unit.test.d.ts.map +1 -0
  102. package/dist/__tests__/parse-scene-payload-refs.cases.unit.test.d.ts +2 -0
  103. package/dist/__tests__/parse-scene-payload-refs.cases.unit.test.d.ts.map +1 -0
  104. package/dist/__tests__/pass-selector.cases.unit.test.d.ts +2 -0
  105. package/dist/__tests__/pass-selector.cases.unit.test.d.ts.map +1 -0
  106. package/dist/__tests__/pipeline-builder.cases.unit.test.d.ts +2 -0
  107. package/dist/__tests__/pipeline-builder.cases.unit.test.d.ts.map +1 -0
  108. package/dist/__tests__/pipeline-cache-key-topology.cases.unit.test.d.ts +2 -0
  109. package/dist/__tests__/pipeline-cache-key-topology.cases.unit.test.d.ts.map +1 -0
  110. package/dist/__tests__/pipeline-vertex-stride-branch.cases.unit.test.d.ts +2 -0
  111. package/dist/__tests__/pipeline-vertex-stride-branch.cases.unit.test.d.ts.map +1 -0
  112. package/dist/__tests__/pipeline.cases.unit.test.d.ts +2 -0
  113. package/dist/__tests__/pipeline.cases.unit.test.d.ts.map +1 -0
  114. package/dist/__tests__/post-process-register.cases.unit.test.d.ts +2 -0
  115. package/dist/__tests__/post-process-register.cases.unit.test.d.ts.map +1 -0
  116. package/dist/__tests__/propagate-transforms.cases.unit.test.d.ts +2 -0
  117. package/dist/__tests__/propagate-transforms.cases.unit.test.d.ts.map +1 -0
  118. package/dist/__tests__/record-all-topology.cases.unit.test.d.ts +2 -0
  119. package/dist/__tests__/record-all-topology.cases.unit.test.d.ts.map +1 -0
  120. package/dist/__tests__/record-draw-branch.cases.unit.test.d.ts +2 -0
  121. package/dist/__tests__/record-draw-branch.cases.unit.test.d.ts.map +1 -0
  122. package/dist/__tests__/record-strip-index-format.cases.unit.test.d.ts +2 -0
  123. package/dist/__tests__/record-strip-index-format.cases.unit.test.d.ts.map +1 -0
  124. package/dist/__tests__/register-with-guid-rgba16float.cases.unit.test.d.ts +2 -0
  125. package/dist/__tests__/register-with-guid-rgba16float.cases.unit.test.d.ts.map +1 -0
  126. package/dist/__tests__/render-data.cases.unit.test.d.ts +2 -0
  127. package/dist/__tests__/render-data.cases.unit.test.d.ts.map +1 -0
  128. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  129. package/dist/__tests__/render-query-regression.cases.unit.test.d.ts +2 -0
  130. package/dist/__tests__/render-query-regression.cases.unit.test.d.ts.map +1 -0
  131. package/dist/__tests__/render-system-record-submesh.cases.unit.test.d.ts +2 -0
  132. package/dist/__tests__/render-system-record-submesh.cases.unit.test.d.ts.map +1 -0
  133. package/dist/__tests__/render-system-record-warn-once.cases.unit.test.d.ts +2 -0
  134. package/dist/__tests__/render-system-record-warn-once.cases.unit.test.d.ts.map +1 -0
  135. package/dist/__tests__/renderer-device-loss-multi-world.integration.test.d.ts +2 -0
  136. package/dist/__tests__/renderer-device-loss-multi-world.integration.test.d.ts.map +1 -0
  137. package/dist/__tests__/renderer-device-loss-provider-feasibility.dawn.test.d.ts +2 -0
  138. package/dist/__tests__/renderer-device-loss-provider-feasibility.dawn.test.d.ts.map +1 -0
  139. package/dist/__tests__/renderer-device-loss-recovery.dawn.test.d.ts +2 -0
  140. package/dist/__tests__/renderer-device-loss-recovery.dawn.test.d.ts.map +1 -0
  141. package/dist/__tests__/renderer-draw-world.cases.unit.test.d.ts +2 -0
  142. package/dist/__tests__/renderer-draw-world.cases.unit.test.d.ts.map +1 -0
  143. package/dist/__tests__/renderer-recovery-consumer.integration.test.d.ts +2 -0
  144. package/dist/__tests__/renderer-recovery-consumer.integration.test.d.ts.map +1 -0
  145. package/dist/__tests__/renderstate-pipeline-cache.cases.unit.test.d.ts +2 -0
  146. package/dist/__tests__/renderstate-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  147. package/dist/__tests__/resolve-scene-guids.cases.unit.test.d.ts +2 -0
  148. package/dist/__tests__/resolve-scene-guids.cases.unit.test.d.ts.map +1 -0
  149. package/dist/__tests__/shadow-skip-non-triangle.cases.unit.test.d.ts +2 -0
  150. package/dist/__tests__/shadow-skip-non-triangle.cases.unit.test.d.ts.map +1 -0
  151. package/dist/__tests__/skin-errors-kebab-case.cases.unit.test.d.ts +2 -0
  152. package/dist/__tests__/skin-errors-kebab-case.cases.unit.test.d.ts.map +1 -0
  153. package/dist/__tests__/skin-instances-coexist.cases.unit.test.d.ts +2 -0
  154. package/dist/__tests__/skin-instances-coexist.cases.unit.test.d.ts.map +1 -0
  155. package/dist/__tests__/skin-m2-extract-happy.cases.unit.test.d.ts +2 -0
  156. package/dist/__tests__/skin-m2-extract-happy.cases.unit.test.d.ts.map +1 -0
  157. package/dist/__tests__/skin-m2-extract-joint-error.cases.unit.test.d.ts +2 -0
  158. package/dist/__tests__/skin-m2-extract-joint-error.cases.unit.test.d.ts.map +1 -0
  159. package/dist/__tests__/skin-m2-extract-null-assets.cases.unit.test.d.ts +2 -0
  160. package/dist/__tests__/skin-m2-extract-null-assets.cases.unit.test.d.ts.map +1 -0
  161. package/dist/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.d.ts +2 -0
  162. package/dist/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.d.ts.map +1 -0
  163. package/dist/__tests__/skin-m3-record-cache.cases.unit.test.d.ts +2 -0
  164. package/dist/__tests__/skin-m3-record-cache.cases.unit.test.d.ts.map +1 -0
  165. package/dist/__tests__/skin-palette-extract.cases.unit.test.d.ts +2 -0
  166. package/dist/__tests__/skin-palette-extract.cases.unit.test.d.ts.map +1 -0
  167. package/dist/__tests__/skin-pipeline-routing.cases.unit.test.d.ts +2 -0
  168. package/dist/__tests__/skin-pipeline-routing.cases.unit.test.d.ts.map +1 -0
  169. package/dist/__tests__/skin.cases.unit.test.d.ts +2 -0
  170. package/dist/__tests__/skin.cases.unit.test.d.ts.map +1 -0
  171. package/dist/__tests__/skybox-extract.cases.unit.test.d.ts +2 -0
  172. package/dist/__tests__/skybox-extract.cases.unit.test.d.ts.map +1 -0
  173. package/dist/__tests__/skybox-shader-compile.cases.unit.test.d.ts +2 -0
  174. package/dist/__tests__/skybox-shader-compile.cases.unit.test.d.ts.map +1 -0
  175. package/dist/__tests__/skylight-bind-group.cases.unit.test.d.ts +2 -0
  176. package/dist/__tests__/skylight-bind-group.cases.unit.test.d.ts.map +1 -0
  177. package/dist/__tests__/skylight-component.cases.unit.test.d.ts +2 -0
  178. package/dist/__tests__/skylight-component.cases.unit.test.d.ts.map +1 -0
  179. package/dist/__tests__/skylight-fallback-path.cases.unit.test.d.ts +2 -0
  180. package/dist/__tests__/skylight-fallback-path.cases.unit.test.d.ts.map +1 -0
  181. package/dist/__tests__/skylight-pipeline-layout.cases.unit.test.d.ts +2 -0
  182. package/dist/__tests__/skylight-pipeline-layout.cases.unit.test.d.ts.map +1 -0
  183. package/dist/__tests__/skylight-solid-color-extract.cases.unit.test.d.ts +2 -0
  184. package/dist/__tests__/skylight-solid-color-extract.cases.unit.test.d.ts.map +1 -0
  185. package/dist/__tests__/sprite-animation-tick-boundary.cases.unit.test.d.ts +2 -0
  186. package/dist/__tests__/sprite-animation-tick-boundary.cases.unit.test.d.ts.map +1 -0
  187. package/dist/__tests__/sprite-animation-tick-clamp.cases.unit.test.d.ts +2 -0
  188. package/dist/__tests__/sprite-animation-tick-clamp.cases.unit.test.d.ts.map +1 -0
  189. package/dist/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.d.ts +2 -0
  190. package/dist/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.d.ts.map +1 -0
  191. package/dist/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.d.ts +2 -0
  192. package/dist/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.d.ts.map +1 -0
  193. package/dist/__tests__/sprite-animation-tick-loop.cases.unit.test.d.ts +2 -0
  194. package/dist/__tests__/sprite-animation-tick-loop.cases.unit.test.d.ts.map +1 -0
  195. package/dist/__tests__/sprite-animation-tick-override-probe.cases.unit.test.d.ts +2 -0
  196. package/dist/__tests__/sprite-animation-tick-override-probe.cases.unit.test.d.ts.map +1 -0
  197. package/dist/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.d.ts +2 -0
  198. package/dist/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.d.ts.map +1 -0
  199. package/dist/__tests__/storage-buffer-caps.cases.unit.test.d.ts +2 -0
  200. package/dist/__tests__/storage-buffer-caps.cases.unit.test.d.ts.map +1 -0
  201. package/dist/__tests__/systems.cases.unit.test.d.ts +2 -0
  202. package/dist/__tests__/systems.cases.unit.test.d.ts.map +1 -0
  203. package/dist/__tests__/tonemap-hdr-target.cases.unit.test.d.ts +2 -0
  204. package/dist/__tests__/tonemap-hdr-target.cases.unit.test.d.ts.map +1 -0
  205. package/dist/__tests__/tonemap-pipeline-split.cases.unit.test.d.ts +2 -0
  206. package/dist/__tests__/tonemap-pipeline-split.cases.unit.test.d.ts.map +1 -0
  207. package/dist/__tests__/tonemap.cases.unit.test.d.ts +2 -0
  208. package/dist/__tests__/tonemap.cases.unit.test.d.ts.map +1 -0
  209. package/dist/__tests__/transparent-sort-config-get.cases.unit.test.d.ts +2 -0
  210. package/dist/__tests__/transparent-sort-config-get.cases.unit.test.d.ts.map +1 -0
  211. package/dist/__tests__/transparent-sort-config-set.cases.unit.test.d.ts +2 -0
  212. package/dist/__tests__/transparent-sort-config-set.cases.unit.test.d.ts.map +1 -0
  213. package/dist/__tests__/transparent-sort.cases.unit.test.d.ts +2 -0
  214. package/dist/__tests__/transparent-sort.cases.unit.test.d.ts.map +1 -0
  215. package/dist/__tests__/upload-texture-consistency.cases.unit.test.d.ts +2 -0
  216. package/dist/__tests__/upload-texture-consistency.cases.unit.test.d.ts.map +1 -0
  217. package/dist/__tests__/verify-revisions.cases.unit.test.d.ts +2 -0
  218. package/dist/__tests__/verify-revisions.cases.unit.test.d.ts.map +1 -0
  219. package/dist/__tests__/wire-default-loaders.cases.unit.test.d.ts +2 -0
  220. package/dist/__tests__/wire-default-loaders.cases.unit.test.d.ts.map +1 -0
  221. package/dist/__tests__/zero-camera-clear-fallback.cases.unit.test.d.ts +2 -0
  222. package/dist/__tests__/zero-camera-clear-fallback.cases.unit.test.d.ts.map +1 -0
  223. package/dist/dev-import-transport.d.ts.map +1 -1
  224. package/dist/index.mjs +1 -1
  225. package/dist/index.mjs.map +1 -1
  226. package/package.json +30 -30
  227. package/src/__tests__/advance-animation-player.cases.unit.test.ts +1666 -0
  228. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +676 -0
  229. package/src/__tests__/asset-registry-bootstrap.cases.unit.test.ts +472 -0
  230. package/src/__tests__/asset-registry-builtin-nineslice.cases.unit.test.ts +481 -0
  231. package/src/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.ts +568 -0
  232. package/src/__tests__/asset-registry-guid.cases.unit.test.ts +500 -0
  233. package/src/__tests__/asset-registry-material-validate.cases.unit.test.ts +756 -0
  234. package/src/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.ts +591 -0
  235. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +501 -0
  236. package/src/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.ts +536 -0
  237. package/src/__tests__/asset-renderer-assembly.cases.unit.test.ts +496 -0
  238. package/src/__tests__/asset.cases.unit.test.ts +439 -0
  239. package/src/__tests__/bind-group-cache-keying.test.ts +6 -2
  240. package/src/__tests__/bloom-gating.cases.unit.test.ts +428 -0
  241. package/src/__tests__/builtin-guid-ssot.cases.unit.test.ts +472 -0
  242. package/src/__tests__/builtin-pack.cases.unit.test.ts +639 -0
  243. package/src/__tests__/builtin-shader-register-e2e.test.ts +1 -1
  244. package/src/__tests__/camera-antialias.cases.unit.test.ts +372 -0
  245. package/src/__tests__/camera-bloom.cases.unit.test.ts +396 -0
  246. package/src/__tests__/camera-clear-schema.cases.unit.test.ts +391 -0
  247. package/src/__tests__/camera-ortho.cases.unit.test.ts +484 -0
  248. package/src/__tests__/cluster-binner.cases.unit.test.ts +817 -0
  249. package/src/__tests__/collect-scene-mount-collapse.test.ts +5 -1
  250. package/src/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.ts +345 -0
  251. package/src/__tests__/create-renderer-uniform-fallback.cases.unit.test.ts +445 -0
  252. package/src/__tests__/cubemap-upload.cases.unit.test.ts +593 -0
  253. package/src/__tests__/dawn/ibl-precompute-readback.dawn.test.ts +184 -17
  254. package/src/__tests__/dawn/material-alpha.dawn.test.ts +17 -4
  255. package/src/__tests__/derive-bgl-integration.test.ts +8 -8
  256. package/src/__tests__/dev-import-transport.cases.unit.test.ts +503 -0
  257. package/src/__tests__/engine-metrics.cases.unit.test.ts +371 -0
  258. package/src/__tests__/equirect-projection-error.cases.unit.test.ts +416 -0
  259. package/src/__tests__/font-asset-load.cases.unit.test.ts +510 -0
  260. package/src/__tests__/fxaa-intermediate-texture.cases.unit.test.ts +690 -0
  261. package/src/__tests__/fxaa-pipeline.cases.unit.test.ts +546 -0
  262. package/src/__tests__/gpu-residency.cases.unit.test.ts +479 -0
  263. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +929 -0
  264. package/src/__tests__/handle-quad.cases.unit.test.ts +491 -0
  265. package/src/__tests__/hdrp-bgl-slots.cases.unit.test.ts +451 -0
  266. package/src/__tests__/hdrp-grid-invalid.cases.unit.test.ts +408 -0
  267. package/src/__tests__/hdrp-index-list-overflow-once.cases.unit.test.ts +405 -0
  268. package/src/__tests__/hdrp-light-budget.cases.unit.test.ts +483 -0
  269. package/src/__tests__/hdrp-pipeline-asset-config.cases.unit.test.ts +441 -0
  270. package/src/__tests__/helpers/renderer-device-loss-dawn.ts +69 -0
  271. package/src/__tests__/helpers/standard-material-manifest.ts +11 -15
  272. package/src/__tests__/ibl-pipeline-cache.cases.unit.test.ts +415 -0
  273. package/src/__tests__/lazy-catalog.cases.unit.test.ts +848 -0
  274. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +6 -5
  275. package/src/__tests__/load-by-guid-hdr.cases.unit.test.ts +582 -0
  276. package/src/__tests__/load-by-guid-prod-material-parent.cases.unit.test.ts +1514 -0
  277. package/src/__tests__/load-by-guid-prod.cases.unit.test.ts +557 -0
  278. package/src/__tests__/loader-registry.cases.unit.test.ts +767 -0
  279. package/src/__tests__/m7-hdrp-demo-shape.cases.unit.test.ts +361 -0
  280. package/src/__tests__/material-texture-uv-scale.unit.test.ts +2 -2
  281. package/src/__tests__/materials.unit.test.ts +29 -10
  282. package/src/__tests__/mipmap-formula.cases.unit.test.ts +434 -0
  283. package/src/__tests__/mipmap-pipeline-cache.cases.unit.test.ts +490 -0
  284. package/src/__tests__/parse-asset-payload-material.cases.unit.test.ts +565 -0
  285. package/src/__tests__/parse-asset-payload-texture.cases.unit.test.ts +720 -0
  286. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +767 -0
  287. package/src/__tests__/pass-selector.cases.unit.test.ts +376 -0
  288. package/src/__tests__/pbr-pipeline.unit.test.ts +23 -5
  289. package/src/__tests__/pipeline-builder.cases.unit.test.ts +624 -0
  290. package/src/__tests__/pipeline-cache-key-topology.cases.unit.test.ts +807 -0
  291. package/src/__tests__/pipeline-vertex-stride-branch.cases.unit.test.ts +379 -0
  292. package/src/__tests__/pipeline.cases.unit.test.ts +370 -0
  293. package/src/__tests__/post-process-register.cases.unit.test.ts +405 -0
  294. package/src/__tests__/propagate-transforms.cases.unit.test.ts +502 -0
  295. package/src/__tests__/propagate-transforms.dawn.test.ts +19 -2
  296. package/src/__tests__/record-all-topology.cases.unit.test.ts +608 -0
  297. package/src/__tests__/record-draw-branch.cases.unit.test.ts +621 -0
  298. package/src/__tests__/record-strip-index-format.cases.unit.test.ts +419 -0
  299. package/src/__tests__/register-with-guid-rgba16float.cases.unit.test.ts +469 -0
  300. package/src/__tests__/render-data.cases.unit.test.ts +553 -0
  301. package/src/__tests__/render-error-exhaustive.test-d.ts +1 -0
  302. package/src/__tests__/render-query-regression.cases.unit.test.ts +531 -0
  303. package/src/__tests__/render-system-record-submesh.cases.unit.test.ts +740 -0
  304. package/src/__tests__/render-system-record-warn-once.cases.unit.test.ts +418 -0
  305. package/src/__tests__/render-system-record.test.ts +15 -33
  306. package/src/__tests__/renderer-device-loss-multi-world.integration.test.ts +103 -0
  307. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +124 -0
  308. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +50 -0
  309. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +514 -0
  310. package/src/__tests__/renderer-recovery-consumer.integration.test.ts +96 -0
  311. package/src/__tests__/renderstate-pipeline-cache.cases.unit.test.ts +631 -0
  312. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +1123 -0
  313. package/src/__tests__/shadow-caster-empty-schema.test.ts +1 -1
  314. package/src/__tests__/shadow-fields-observable.dawn.test.ts +2 -2
  315. package/src/__tests__/shadow-m1.dawn.test.ts +1 -1
  316. package/src/__tests__/shadow-skip-non-triangle.cases.unit.test.ts +681 -0
  317. package/src/__tests__/skin-errors-kebab-case.cases.unit.test.ts +475 -0
  318. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +5 -4
  319. package/src/__tests__/skin-instances-coexist.cases.unit.test.ts +375 -0
  320. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +592 -0
  321. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +619 -0
  322. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +569 -0
  323. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +585 -0
  324. package/src/__tests__/skin-m3-record-cache.cases.unit.test.ts +457 -0
  325. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +635 -0
  326. package/src/__tests__/skin-pipeline-routing.cases.unit.test.ts +411 -0
  327. package/src/__tests__/skin.cases.unit.test.ts +421 -0
  328. package/src/__tests__/skybox-extract.cases.unit.test.ts +511 -0
  329. package/src/__tests__/skybox-shader-compile.cases.unit.test.ts +437 -0
  330. package/src/__tests__/skylight-bind-group.cases.unit.test.ts +642 -0
  331. package/src/__tests__/skylight-component.cases.unit.test.ts +386 -0
  332. package/src/__tests__/skylight-fallback-path.cases.unit.test.ts +566 -0
  333. package/src/__tests__/skylight-lazy-projection.test.ts +35 -20
  334. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +506 -0
  335. package/src/__tests__/skylight-solid-color-extract.cases.unit.test.ts +399 -0
  336. package/src/__tests__/sprite-animation-tick-boundary.cases.unit.test.ts +521 -0
  337. package/src/__tests__/sprite-animation-tick-clamp.cases.unit.test.ts +453 -0
  338. package/src/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.ts +447 -0
  339. package/src/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.ts +395 -0
  340. package/src/__tests__/sprite-animation-tick-loop.cases.unit.test.ts +494 -0
  341. package/src/__tests__/sprite-animation-tick-override-probe.cases.unit.test.ts +439 -0
  342. package/src/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.ts +466 -0
  343. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +5 -5
  344. package/src/__tests__/sprite-lit-pipeline-cache-key.test.ts +1 -1
  345. package/src/__tests__/ssao-pipelines.test.ts +1 -1
  346. package/src/__tests__/storage-buffer-caps.cases.unit.test.ts +373 -0
  347. package/src/__tests__/systems.cases.unit.test.ts +425 -0
  348. package/src/__tests__/tilemap-chunk-extract.overhang-bounds.test.ts +1 -1
  349. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +670 -0
  350. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +867 -0
  351. package/src/__tests__/tonemap.cases.unit.test.ts +446 -0
  352. package/src/__tests__/transparent-sort-config-get.cases.unit.test.ts +395 -0
  353. package/src/__tests__/transparent-sort-config-set.cases.unit.test.ts +424 -0
  354. package/src/__tests__/transparent-sort.cases.unit.test.ts +684 -0
  355. package/src/__tests__/upload-texture-consistency.cases.unit.test.ts +478 -0
  356. package/src/__tests__/verify-revisions.cases.unit.test.ts +619 -0
  357. package/src/__tests__/wire-default-loaders.cases.unit.test.ts +461 -0
  358. package/src/__tests__/zero-camera-clear-fallback.cases.unit.test.ts +374 -0
  359. package/src/dev-import-transport.ts +2 -1
  360. package/dist/__tests__/asset.unit.test.d.ts +0 -2
  361. package/dist/__tests__/asset.unit.test.d.ts.map +0 -1
  362. package/dist/__tests__/pipeline.unit.test.d.ts +0 -2
  363. package/dist/__tests__/pipeline.unit.test.d.ts.map +0 -1
  364. package/dist/__tests__/systems.unit.test.d.ts +0 -2
  365. package/dist/__tests__/systems.unit.test.d.ts.map +0 -1
  366. package/src/__tests__/asset.unit.test.ts +0 -7113
  367. package/src/__tests__/pipeline.unit.test.ts +0 -5279
  368. package/src/__tests__/systems.unit.test.ts +0 -9061
@@ -0,0 +1,1666 @@
1
+ // @ts-nocheck — merged file: indexed-access checks cascade across noUncheckedIndexedAccess for blocks originally outside src/ rootDir
2
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
3
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: block-scope isolation between merged source files (consolidation paradigm)
4
+ //
5
+ // Source files (N=44):
6
+ // - packages/runtime/__tests__/antialias-from-f32.test.ts
7
+ // - packages/runtime/src/__tests__/bloom-gating.test.ts
8
+ // - packages/runtime/src/__tests__/camera-antialias.test.ts
9
+ // - packages/runtime/src/__tests__/camera-bloom.test.ts
10
+ // - packages/runtime/src/__tests__/camera-clear-schema.test.ts
11
+ // - packages/runtime/src/__tests__/camera-ortho.test.ts
12
+ // - packages/runtime/src/__tests__/fxaa-intermediate-texture.test.ts
13
+ // - packages/runtime/src/__tests__/fxaa-pipeline.test.ts
14
+ // - packages/runtime/src/__tests__/ibl-pipeline-cache.test.ts
15
+ // - packages/runtime/src/__tests__/ibl-runtime-probe.test.ts
16
+ // - packages/runtime/src/__tests__/render-system-record-warn-once.test.ts
17
+ // - packages/runtime/src/__tests__/shadow-skip-non-triangle.test.ts
18
+ // - packages/runtime/src/__tests__/skin-errors-kebab-case.test.ts
19
+ // - packages/runtime/src/__tests__/skybox-error.test.ts
20
+ // - packages/runtime/src/__tests__/skybox-shader-compile.test.ts
21
+ // - packages/runtime/src/__tests__/skylight-bind-group.test.ts
22
+ // - packages/runtime/src/__tests__/skylight-component.test.ts
23
+ // - packages/runtime/src/__tests__/skylight-fallback-path.test.ts
24
+ // - packages/runtime/src/__tests__/skylight-pipeline-layout.test.ts
25
+ // - packages/runtime/src/__tests__/tonemap-hdr-target.test.ts
26
+ // - packages/runtime/src/__tests__/tonemap-pipeline-split.test.ts
27
+ // - packages/runtime/src/__tests__/zero-camera-clear-fallback.test.ts
28
+ // - packages/runtime/src/components/__tests__/skin.test.ts
29
+ // - packages/runtime/src/systems/__tests__/advance-animation-player.test.ts
30
+ // - packages/runtime/src/systems/__tests__/graph-skybox.test.ts
31
+ // - packages/runtime/src/systems/__tests__/propagate-transforms.test.ts
32
+ // - packages/runtime/src/systems/__tests__/skin-cap-gate.test.ts
33
+ // - packages/runtime/src/systems/__tests__/skin-instances-coexist.test.ts
34
+ // - packages/runtime/src/systems/__tests__/skin-palette-extract.test.ts
35
+ // - packages/runtime/src/systems/__tests__/skin-pipeline-routing.test.ts
36
+ // - packages/runtime/src/systems/__tests__/skybox-extract.test.ts
37
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-boundary.test.ts
38
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-clamp.test.ts
39
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-frame-duration-negative.test.ts
40
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-frame-duration-zero.test.ts
41
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-loop.test.ts
42
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-override-probe.test.ts
43
+ // - packages/runtime/src/systems/__tests__/sprite-animation-tick-regions-mismatch.test.ts
44
+ // - packages/runtime/src/systems/__tests__/tonemap.test.ts
45
+ // - packages/runtime/src/systems/__tests__/transparent-sort-config-get.test.ts
46
+ // - packages/runtime/src/systems/__tests__/transparent-sort-config-set.test.ts
47
+ // - packages/runtime/src/systems/__tests__/transparent-sort.test.ts
48
+ // - packages/runtime/src/__tests__/render-system-multi-material.test.ts
49
+ // - packages/runtime/src/__tests__/render-system-record-submesh.test.ts
50
+ //
51
+ // Paradigm: each block-scope wraps a source file. ancestorTitles[0] is the
52
+ // source-preserved inner describe (NOT the source filename for these 3 files
53
+ // — recovery path: vitest report ancestorTitles -> grep this file -> upstream
54
+ // `// ─── from <name>.test.ts ───` block separator -> source filename).
55
+ // Top-level imports merged + deduped.
56
+
57
+ import { readFileSync } from 'node:fs';
58
+ import { resolve } from 'node:path';
59
+ import { fileURLToPath } from 'node:url';
60
+ import { AnimationPlayer } from '@forgeax/engine-animation';
61
+ import type { AssetRuntimeErrorCode } from '@forgeax/engine-assets-runtime';
62
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
63
+ import type { EntityHandle, World as WorldType } from '@forgeax/engine-ecs';
64
+ import { ENTITY_NULL_RAW, World } from '@forgeax/engine-ecs';
65
+ import { SpriteAnimationInvalidError } from '@forgeax/engine-ecs/projection';
66
+ import { mat4, vec3 } from '@forgeax/engine-math';
67
+ import type { RenderErrorCode, Renderer as RendererType } from '@forgeax/engine-render';
68
+ import {
69
+ ANTIALIAS_FXAA,
70
+ ANTIALIAS_NONE,
71
+ BLOOM_DISABLED,
72
+ BLOOM_ENABLED,
73
+ CAMERA_PROJECTION_ORTHOGRAPHIC,
74
+ CAMERA_PROJECTION_PERSPECTIVE,
75
+ Camera,
76
+ MeshFilter,
77
+ MeshRenderer,
78
+ SkyboxBackground,
79
+ Skylight,
80
+ } from '@forgeax/engine-render';
81
+ import type { BindGroupEntry, Buffer, Sampler, Texture, TextureView } from '@forgeax/engine-rhi';
82
+ import { ok as rhiOk } from '@forgeax/engine-rhi';
83
+ import { ChildOf, Name, propagateTransforms, Transform } from '@forgeax/engine-scene';
84
+ import { TONEMAP_LUMINANCE_EPSILON } from '@forgeax/engine-shader';
85
+ import type { SkinErrorCode } from '@forgeax/engine-skinning';
86
+ import {
87
+ Skin,
88
+ SkinInstancesCoexistForbiddenError,
89
+ SkinJointCountExceededError,
90
+ SkinJointDespawnedError,
91
+ SkinJointPathUnresolvedError,
92
+ } from '@forgeax/engine-skinning';
93
+ import type {
94
+ Handle,
95
+ MaterialAsset,
96
+ MeshAsset,
97
+ SkeletonAsset,
98
+ TextureFormat,
99
+ } from '@forgeax/engine-types';
100
+ import { toShared } from '@forgeax/engine-types';
101
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
102
+ import { worldInternal } from '../../../ecs/src/world-internal';
103
+ import {
104
+ SPRITE_PLAYBACK_MODE_CLAMP,
105
+ SPRITE_PLAYBACK_MODE_LOOP,
106
+ SpriteAnimation,
107
+ SpriteRegionOverride,
108
+ } from '../../../render/src/components';
109
+ import {
110
+ type CameraProjection,
111
+ cameraProjectionFromF32,
112
+ } from '../../../render/src/components/camera';
113
+ import {
114
+ assembleMaterialWithSkylightEntries,
115
+ createSkylightFallback,
116
+ mergeSkylightIntoMaterialBgl,
117
+ } from '../../../render/src/ibl/skylight-bind-group';
118
+ import { buildPbrPipelineLayouts, buildUnlitMaterialBgl } from '../../../render/src/pbr-pipeline';
119
+ import { INSTANCE_STORAGE_STRIDE_FLOATS } from '../../../render/src/record/mesh-ssbo';
120
+ import { selectSwapChainFormat } from '../../../render/src/render-system';
121
+ import { createSkinPaletteAllocator } from '../../../render/src/systems/skin-palette-allocator';
122
+ import type { TransparentEntry } from '../../../render/src/systems/transparent-sort-config';
123
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
124
+ import { drawWithOwners } from './renderer-test-utils';
125
+
126
+ function componentFieldType(component: { fields: Record<string, { type: string }> }, name: string) {
127
+ return component.fields[name]?.type;
128
+ }
129
+
130
+ function componentFieldDefault(
131
+ component: { fields: Record<string, { default?: unknown }> },
132
+ name: string,
133
+ ) {
134
+ return component.fields[name]?.default;
135
+ }
136
+
137
+ function componentSchemaTypes(component: { fields: Record<string, { type: string }> }) {
138
+ return Object.fromEntries(
139
+ Object.entries(component.fields).map(([name, field]) => [name, field.type]),
140
+ );
141
+ }
142
+
143
+ afterEach(() => vi.restoreAllMocks());
144
+
145
+ type RendererErrorObservation = {
146
+ readonly code: string;
147
+ readonly detail?: unknown;
148
+ readonly hint?: string;
149
+ };
150
+
151
+ function unwrapRendererError(value: unknown): RendererErrorObservation {
152
+ let current = value as RendererErrorObservation;
153
+ while (
154
+ current.detail !== undefined &&
155
+ typeof current.detail === 'object' &&
156
+ current.detail !== null
157
+ ) {
158
+ const cause = (current.detail as { cause?: unknown }).cause;
159
+ if (
160
+ cause === undefined ||
161
+ typeof cause !== 'object' ||
162
+ cause === null ||
163
+ typeof (cause as { code?: unknown }).code !== 'string'
164
+ ) {
165
+ break;
166
+ }
167
+ current = cause as RendererErrorObservation;
168
+ }
169
+ return current;
170
+ }
171
+
172
+ function subscribeRendererErrors(
173
+ renderer: RendererType,
174
+ listener: (error: RendererErrorObservation) => void,
175
+ ): () => void {
176
+ return renderer.subscribe((event) => {
177
+ if (event.kind === 'error') listener(unwrapRendererError(event.error));
178
+ });
179
+ }
180
+
181
+ function drawPublished(renderer: RendererType, world: WorldType) {
182
+ const attached = renderer.attach(world);
183
+ if (!attached.ok) throw attached.error;
184
+ world.update().unwrap();
185
+ return drawWithOwners(renderer, world);
186
+ }
187
+
188
+ // feat-20260704-runtime-tier1-decomposition M2 / w12: reconstitute the
189
+ // eliminated top-level RuntimeErrorCode aggregate union (D-3) as a test-local
190
+ // alias so the exhaustive-switch bodies below stay byte-identical (AC-09).
191
+ type RuntimeLayerErrorCode = RenderErrorCode | AssetRuntimeErrorCode | SkinErrorCode;
192
+
193
+ import {
194
+ AnimationTargetId,
195
+ bindAnimationTargets,
196
+ advanceAnimationPlayer as canonicalAdvanceAnimationPlayer,
197
+ } from '@forgeax/engine-animation';
198
+ import { deriveAnimationTargetId } from '@forgeax/engine-animation/target-id';
199
+ import { DeviceScope } from '../../../render/src/device/device-scope';
200
+ import { GpuBuffer } from '../../../render/src/gpu-resource';
201
+ import { getOrCreateIblCache, hasIblCache } from '../../../render/src/ibl/IblPipelineCache';
202
+ import {
203
+ disposeInstanceBuffers,
204
+ type InstanceBufferCacheEntry,
205
+ } from '../../../render/src/instance-buffer-cache';
206
+ import { standardPipeline as urpPipeline } from '../../../render/src/pipeline/standard-pipeline';
207
+ import { ZERO_CAMERA_CLEAR_FALLBACK } from '../../../render/src/record/frame-snapshot';
208
+ import {
209
+ warnMultiLightDirectional,
210
+ warnMultiLightPoint,
211
+ warnMultiLightSpot,
212
+ } from '../../../render/src/record/helpers';
213
+ import type { CameraSnapshot } from '../../../render/src/render-contract';
214
+ import {
215
+ type ExtractedLights,
216
+ extractFrame,
217
+ extractFrames,
218
+ prepareExtractContext,
219
+ } from '../../../render/src/render-system-extract';
220
+ import {
221
+ getTransparentSortConfig,
222
+ setTransparentSortConfig,
223
+ TRANSPARENT_SORT_CONFIG_KEY,
224
+ TRANSPARENT_SORT_MODE_LAYER_Y,
225
+ TRANSPARENT_SORT_MODE_LAYER_YZ,
226
+ TRANSPARENT_SORT_MODE_LAYER_Z,
227
+ } from '../../../render/src/systems/transparent-sort-config';
228
+ import { spriteAnimationTickSystem } from '../systems/sprite-animation-tick';
229
+ import { REC709_LUMA_WEIGHTS, tonemapReinhardLuminance } from '../systems/tonemap';
230
+ import { transparentSortEntries } from '../systems/transparent-sort';
231
+ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
232
+
233
+ void [
234
+ ANTIALIAS_FXAA,
235
+ ANTIALIAS_NONE,
236
+ AnimationPlayer,
237
+ AnimationTargetId,
238
+ AssetRegistry,
239
+ BLOOM_DISABLED,
240
+ BLOOM_ENABLED,
241
+ CAMERA_PROJECTION_ORTHOGRAPHIC,
242
+ CAMERA_PROJECTION_PERSPECTIVE,
243
+ Camera,
244
+ ChildOf,
245
+ DeviceScope,
246
+ ENTITY_NULL_RAW,
247
+ GpuBuffer,
248
+ INSTANCE_STORAGE_STRIDE_FLOATS,
249
+ MeshFilter,
250
+ MeshRenderer,
251
+ Name,
252
+ REC709_LUMA_WEIGHTS,
253
+ SPRITE_PLAYBACK_MODE_CLAMP,
254
+ SPRITE_PLAYBACK_MODE_LOOP,
255
+ Skin,
256
+ SkinInstancesCoexistForbiddenError,
257
+ SkinJointCountExceededError,
258
+ SkinJointDespawnedError,
259
+ SkinJointPathUnresolvedError,
260
+ SkyboxBackground,
261
+ Skylight,
262
+ SpriteAnimation,
263
+ SpriteAnimationInvalidError,
264
+ SpriteRegionOverride,
265
+ TONEMAP_LUMINANCE_EPSILON,
266
+ TRANSPARENT_SORT_CONFIG_KEY,
267
+ TRANSPARENT_SORT_MODE_LAYER_Y,
268
+ TRANSPARENT_SORT_MODE_LAYER_YZ,
269
+ TRANSPARENT_SORT_MODE_LAYER_Z,
270
+ Transform,
271
+ World,
272
+ ZERO_CAMERA_CLEAR_FALLBACK,
273
+ afterEach,
274
+ assembleMaterialWithSkylightEntries,
275
+ beforeAll,
276
+ beforeEach,
277
+ bindAnimationTargets,
278
+ buildPbrPipelineLayouts,
279
+ buildUnlitMaterialBgl,
280
+ cameraProjectionFromF32,
281
+ canonicalAdvanceAnimationPlayer,
282
+ componentFieldDefault,
283
+ componentFieldType,
284
+ componentSchemaTypes,
285
+ createSkinPaletteAllocator,
286
+ createSkylightFallback,
287
+ deriveAnimationTargetId,
288
+ describe,
289
+ disposeInstanceBuffers,
290
+ drawPublished,
291
+ drawWithOwners,
292
+ expect,
293
+ extractFrame,
294
+ extractFrames,
295
+ fileURLToPath,
296
+ getOrCreateIblCache,
297
+ getTransparentSortConfig,
298
+ hasIblCache,
299
+ it,
300
+ makeMockShaderRegistry,
301
+ mat4,
302
+ mergeSkylightIntoMaterialBgl,
303
+ prepareExtractContext,
304
+ propagateTransforms,
305
+ readFileSync,
306
+ resolve,
307
+ rhiOk,
308
+ selectSwapChainFormat,
309
+ setTransparentSortConfig,
310
+ spriteAnimationTickSystem,
311
+ standardMaterialShaderVariants,
312
+ subscribeRendererErrors,
313
+ toShared,
314
+ tonemapReinhardLuminance,
315
+ transparentSortEntries,
316
+ unwrapRendererError,
317
+ urpPipeline,
318
+ vec3,
319
+ vi,
320
+ warnMultiLightDirectional,
321
+ warnMultiLightPoint,
322
+ warnMultiLightSpot,
323
+ worldInternal,
324
+ ];
325
+ type __MergedKeep =
326
+ | AssetRuntimeErrorCode
327
+ | BindGroupEntry
328
+ | Buffer
329
+ | CameraProjection
330
+ | CameraSnapshot
331
+ | EntityHandle
332
+ | ExtractedLights
333
+ | Handle
334
+ | InstanceBufferCacheEntry
335
+ | MaterialAsset
336
+ | MeshAsset
337
+ | RenderErrorCode
338
+ | RendererErrorObservation
339
+ | RendererType
340
+ | RuntimeLayerErrorCode
341
+ | Sampler
342
+ | SkeletonAsset
343
+ | SkinErrorCode
344
+ | Texture
345
+ | TextureFormat
346
+ | TextureView
347
+ | TransparentEntry
348
+ | WorldType;
349
+
350
+ {
351
+ // --- from advance-animation-player.test.ts ---
352
+
353
+ const defaultTransform = {
354
+ pos: [0, 0, 0],
355
+ quat: [0, 0, 0, 1],
356
+ scale: [1, 1, 1],
357
+ };
358
+
359
+ function makeSampler(
360
+ input: number[],
361
+ output: number[],
362
+ interpolation: 'LINEAR' | 'STEP' = 'LINEAR',
363
+ ): AnimationSampler {
364
+ return {
365
+ input: new Float32Array(input),
366
+ output: new Float32Array(output),
367
+ interpolation,
368
+ };
369
+ }
370
+
371
+ function makeClip(duration: number, channels: AnimationClip['channels']): AnimationClip {
372
+ return {
373
+ kind: 'animation-clip',
374
+ duration,
375
+ channels,
376
+ };
377
+ }
378
+
379
+ type TestResolver = { clips: Map<number, AnimationClip>; handles?: Map<number, number> };
380
+
381
+ function makeResolver(clips: Map<number, AnimationClip>): TestResolver {
382
+ return { clips };
383
+ }
384
+
385
+ function advanceAnimationPlayer(world: World, resolver: TestResolver, dt: number): void {
386
+ const handles = resolver.handles ?? new Map<number, number>();
387
+ resolver.handles = handles;
388
+ for (const [raw, clip] of resolver.clips) {
389
+ if (!handles.has(raw)) handles.set(raw, world.allocSharedRef('AnimationClip', clip));
390
+ }
391
+ const query = world.query({ with: [AnimationPlayer] }).unwrap();
392
+ const allocated = new Set(handles.values());
393
+ const players = Array.from(query, (row) => row.entity);
394
+ for (const entity of players) {
395
+ const player = world.get(entity, AnimationPlayer);
396
+ if (!player.ok) continue;
397
+ const clips = Array.from(player.value.clips, (handle) => {
398
+ const mapped = handles.get(handle);
399
+ return mapped ?? (allocated.has(handle) ? handle : 0);
400
+ });
401
+ world.set(entity, AnimationPlayer, { clips });
402
+ const skin = world.get(entity, Skin);
403
+ if (!skin.ok) continue;
404
+ const targets: EntityHandle[] = [];
405
+ for (const raw of skin.value.joints) {
406
+ if (raw === ENTITY_NULL_RAW) continue;
407
+ const target = raw as EntityHandle;
408
+ const name = world.get(target, Name);
409
+ if (!name.ok) continue;
410
+ if (!world.get(target, ChildOf).ok) {
411
+ world.addComponent(target, { component: ChildOf, data: { parent: entity } });
412
+ }
413
+ if (!world.get(target, AnimationTargetId).ok) {
414
+ world.addComponent(target, {
415
+ component: AnimationTargetId,
416
+ data: { value: deriveAnimationTargetId([name.value.value]) },
417
+ });
418
+ }
419
+ targets.push(target);
420
+ }
421
+ bindAnimationTargets(world, entity, targets);
422
+ }
423
+ canonicalAdvanceAnimationPlayer(world, dt);
424
+ }
425
+
426
+ // M2 / w3: spawn data migrated to SoA inline arrays. Single-clip legacy
427
+ // path: clips[0] = handle, weights[0] = 1, speeds[0] = speed, times[0] = 0;
428
+ // slots 1..3 stay zero. `world.set({ time: t })` becomes a partial column
429
+ // write `world.set({ times: new Float32Array([t,0,0,0]) })`. Reads of
430
+ // `.time` route through `.times[0]`. Old expectations preserved (time
431
+ // advance / looping modulo / paused skip) — schema cut only.
432
+ function spawnLegacySinglePlayer(
433
+ world: World,
434
+ clipId: number,
435
+ overrides: { speed?: number; paused?: boolean; looping?: boolean } = {},
436
+ ): EntityHandle {
437
+ const speed = overrides.speed ?? 1;
438
+ const paused = overrides.paused ?? false;
439
+ const looping = overrides.looping ?? true;
440
+ return world
441
+ .spawn({
442
+ component: AnimationPlayer,
443
+ data: {
444
+ clips: [
445
+ toShared<'AnimationClip'>(clipId),
446
+ 0 as Handle<'AnimationClip', 'shared'>,
447
+ 0 as Handle<'AnimationClip', 'shared'>,
448
+ 0 as Handle<'AnimationClip', 'shared'>,
449
+ ],
450
+ times: new Float32Array([0, 0, 0, 0]),
451
+ weights: new Float32Array([1, 0, 0, 0]),
452
+ speeds: new Float32Array([speed, 1, 1, 1]),
453
+ paused,
454
+ looping,
455
+ },
456
+ })
457
+ .unwrap();
458
+ }
459
+
460
+ function readLegacyTime(world: World, e: EntityHandle): number {
461
+ const ap = world.get(e, AnimationPlayer).unwrap() as unknown as { times: Float32Array };
462
+ return ap.times[0] ?? 0;
463
+ }
464
+
465
+ function writeLegacyTime(world: World, e: EntityHandle, t: number): void {
466
+ world.set(e, AnimationPlayer, { times: new Float32Array([t, 0, 0, 0]) });
467
+ }
468
+
469
+ describe('T-17 — advanceAnimationPlayer time advance (AC-17 / AC-18)', () => {
470
+ it('advances time by speed * dt each tick (paused=false, looping=true)', () => {
471
+ const world = new World();
472
+ const clip = makeClip(10, []);
473
+ const resolver = makeResolver(new Map([[1, clip]]));
474
+
475
+ const e = spawnLegacySinglePlayer(world, 1, { speed: 2 });
476
+ writeLegacyTime(world, e, 0);
477
+
478
+ advanceAnimationPlayer(world, resolver, 0.5);
479
+ expect(readLegacyTime(world, e)).toBe(1.0);
480
+
481
+ advanceAnimationPlayer(world, resolver, 0.5);
482
+ expect(readLegacyTime(world, e)).toBe(2.0);
483
+ });
484
+
485
+ it('looping=true: time wraps around with modulo', () => {
486
+ const world = new World();
487
+ const clip = makeClip(3, []);
488
+ const resolver = makeResolver(new Map([[1, clip]]));
489
+
490
+ const e = spawnLegacySinglePlayer(world, 1, { speed: 2, looping: true });
491
+ writeLegacyTime(world, e, 2);
492
+
493
+ advanceAnimationPlayer(world, resolver, 1.0); // 2 + 2*1 = 4, mod 3 = 1
494
+ expect(readLegacyTime(world, e)).toBeCloseTo(1.0, 5);
495
+ });
496
+
497
+ it('looping=false: stops at duration', () => {
498
+ const world = new World();
499
+ const clip = makeClip(3, []);
500
+ const resolver = makeResolver(new Map([[1, clip]]));
501
+
502
+ const e = spawnLegacySinglePlayer(world, 1, { speed: 2, looping: false });
503
+ writeLegacyTime(world, e, 2);
504
+
505
+ advanceAnimationPlayer(world, resolver, 1.0); // 2 + 2*1 = 4, clamp to 3
506
+ expect(readLegacyTime(world, e)).toBe(3.0);
507
+ });
508
+
509
+ it('paused=true: time does not change', () => {
510
+ const world = new World();
511
+ const clip = makeClip(10, []);
512
+ const resolver = makeResolver(new Map([[1, clip]]));
513
+
514
+ const e = spawnLegacySinglePlayer(world, 1, { speed: 2, paused: true });
515
+ writeLegacyTime(world, e, 5);
516
+
517
+ advanceAnimationPlayer(world, resolver, 0.5);
518
+ expect(readLegacyTime(world, e)).toBe(5.0);
519
+ });
520
+ });
521
+
522
+ describe('T-17 — advanceAnimationPlayer joint sampling no-crash (AC-17)', () => {
523
+ it('LINEAR sampling with Skin+Transform: system runs without crash', () => {
524
+ const world = new World();
525
+ const sampler = makeSampler([0, 2], [0, 0, 0, 2, 4, 6], 'LINEAR');
526
+ const clip = makeClip(2, [
527
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler },
528
+ ]);
529
+ const resolver = makeResolver(new Map([[1, clip]]));
530
+
531
+ const jointE = world.spawn({ component: Transform, data: defaultTransform }).unwrap();
532
+
533
+ world.spawn(
534
+ {
535
+ component: AnimationPlayer,
536
+ data: {
537
+ clips: [
538
+ toShared<'AnimationClip'>(1),
539
+ 0 as Handle<'AnimationClip', 'shared'>,
540
+ 0 as Handle<'AnimationClip', 'shared'>,
541
+ 0 as Handle<'AnimationClip', 'shared'>,
542
+ ],
543
+ times: new Float32Array([0, 0, 0, 0]),
544
+ weights: new Float32Array([1, 0, 0, 0]),
545
+ speeds: new Float32Array([1, 1, 1, 1]),
546
+ },
547
+ },
548
+ {
549
+ component: Skin,
550
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
551
+ },
552
+ { component: Transform, data: defaultTransform },
553
+ );
554
+
555
+ expect(() => advanceAnimationPlayer(world, resolver, 1.0)).not.toThrow();
556
+ // Time should have advanced.
557
+ const tf = world.get(jointE, Transform).unwrap();
558
+ expect(tf).toBeDefined();
559
+ });
560
+
561
+ it('LINEAR rotation sampling with Skin+Transform: system runs without crash', () => {
562
+ const world = new World();
563
+ const halfSqrt2 = Math.sqrt(2) / 2;
564
+ const sampler = makeSampler([0, 2], [0, 0, 0, 1, 0, halfSqrt2, 0, halfSqrt2], 'LINEAR');
565
+ const clip = makeClip(2, [
566
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'rotation', sampler },
567
+ ]);
568
+ const resolver = makeResolver(new Map([[1, clip]]));
569
+
570
+ const jointE = world.spawn({ component: Transform, data: defaultTransform }).unwrap();
571
+
572
+ world.spawn(
573
+ {
574
+ component: AnimationPlayer,
575
+ data: {
576
+ clips: [
577
+ toShared<'AnimationClip'>(1),
578
+ 0 as Handle<'AnimationClip', 'shared'>,
579
+ 0 as Handle<'AnimationClip', 'shared'>,
580
+ 0 as Handle<'AnimationClip', 'shared'>,
581
+ ],
582
+ times: new Float32Array([0, 0, 0, 0]),
583
+ weights: new Float32Array([1, 0, 0, 0]),
584
+ speeds: new Float32Array([1, 1, 1, 1]),
585
+ },
586
+ },
587
+ {
588
+ component: Skin,
589
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
590
+ },
591
+ { component: Transform, data: defaultTransform },
592
+ );
593
+
594
+ expect(() => advanceAnimationPlayer(world, resolver, 1.0)).not.toThrow();
595
+ });
596
+
597
+ it('STEP interpolation with Skin+Transform: system runs without crash', () => {
598
+ const world = new World();
599
+ const sampler = makeSampler([0, 2], [0, 0, 0, 5, 5, 5], 'STEP');
600
+ const clip = makeClip(2, [
601
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler },
602
+ ]);
603
+ const resolver = makeResolver(new Map([[1, clip]]));
604
+
605
+ const jointE = world.spawn({ component: Transform, data: defaultTransform }).unwrap();
606
+
607
+ world.spawn(
608
+ {
609
+ component: AnimationPlayer,
610
+ data: {
611
+ clips: [
612
+ toShared<'AnimationClip'>(1),
613
+ 0 as Handle<'AnimationClip', 'shared'>,
614
+ 0 as Handle<'AnimationClip', 'shared'>,
615
+ 0 as Handle<'AnimationClip', 'shared'>,
616
+ ],
617
+ times: new Float32Array([0, 0, 0, 0]),
618
+ weights: new Float32Array([1, 0, 0, 0]),
619
+ speeds: new Float32Array([1, 1, 1, 1]),
620
+ },
621
+ },
622
+ {
623
+ component: Skin,
624
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
625
+ },
626
+ { component: Transform, data: defaultTransform },
627
+ );
628
+
629
+ expect(() => advanceAnimationPlayer(world, resolver, 1.5)).not.toThrow();
630
+ });
631
+ });
632
+
633
+ describe('T-17 — edge cases', () => {
634
+ it('entity without Skin/Transform is skipped gracefully', () => {
635
+ const world = new World();
636
+ const clip = makeClip(10, []);
637
+ const resolver = makeResolver(new Map([[1, clip]]));
638
+
639
+ spawnLegacySinglePlayer(world, 1);
640
+
641
+ expect(() => advanceAnimationPlayer(world, resolver, 1 / 60)).not.toThrow();
642
+ });
643
+
644
+ it('entity with unresolved clip handle is skipped', () => {
645
+ const world = new World();
646
+ const resolver = makeResolver(new Map());
647
+
648
+ const e = spawnLegacySinglePlayer(world, 999);
649
+ writeLegacyTime(world, e, 0);
650
+
651
+ expect(() => advanceAnimationPlayer(world, resolver, 1 / 60)).not.toThrow();
652
+ expect(readLegacyTime(world, e)).toBe(0);
653
+ });
654
+
655
+ it('zero duration clip does not crash', () => {
656
+ const world = new World();
657
+ const clip = makeClip(0, []);
658
+ const resolver = makeResolver(new Map([[1, clip]]));
659
+
660
+ const e = spawnLegacySinglePlayer(world, 1, { speed: 2 });
661
+ writeLegacyTime(world, e, 0);
662
+
663
+ expect(() => advanceAnimationPlayer(world, resolver, 1.0)).not.toThrow();
664
+ });
665
+ });
666
+
667
+ describe('T-17 — registerAdvanceAnimationPlayer schedule order (D-9)', () => {
668
+ it('constant ADVANCE_ANIMATION_PLAYER_SYSTEM name matches', async () => {
669
+ const { ADVANCE_ANIMATION_PLAYER_SYSTEM } = await import('@forgeax/engine-animation');
670
+ expect(ADVANCE_ANIMATION_PLAYER_SYSTEM).toBe('advanceAnimationPlayer');
671
+ });
672
+ });
673
+
674
+ // ──────────────────────────────────────────────────────────────────────────
675
+ // M2 / w6 — dev-mode warn throttle (plan-strategy D-2 / AC-05(d)).
676
+ //
677
+ // Three it blocks per the M2 mission brief:
678
+ // (a) 60 frames + same triple => warn fires exactly once
679
+ // (b) two distinct channelKeys (different chIdx) => 2 warns
680
+ // (c) same channelKey, two distinct reasons => 2 warns (key shape includes reason)
681
+ //
682
+ // Each `it` resets via `_resetAnimationWarnsForTests(world)` so the WeakMap
683
+ // bag is fresh; vi.spyOn(console, 'warn') captures the fan-out and the
684
+ // mock is restored at the test's end.
685
+ //
686
+ // Test scaffolding mirrors the T-17 helpers above (spawnLegacySinglePlayer
687
+ // is single-clip; warn-pass tests need direct multi-slot setup or
688
+ // unmapped target clips, so they use the SoA literal form inline).
689
+ // ──────────────────────────────────────────────────────────────────────────
690
+
691
+ describe('M2 / w6 — advanceAnimationPlayer dev-mode warn throttle (D-2)', () => {
692
+ it('60 consecutive frames with same (entity, channel, reason) emit warn exactly once', async () => {
693
+ const { _resetAnimationWarnsForTests } = await import('@forgeax/engine-animation');
694
+ const world = new World();
695
+ // Channel target ID has no matching bound target ID
696
+ // — every frame triggers channel-target-missing on (entity, clip:1, ch:0).
697
+ const sampler = makeSampler([0, 1], [0, 0, 0, 1, 1, 1], 'LINEAR');
698
+ const clip = makeClip(1, [
699
+ {
700
+ targetId: deriveAnimationTargetId(['mismatched-leaf']),
701
+ property: 'translation',
702
+ sampler,
703
+ },
704
+ ]);
705
+ const resolver = makeResolver(new Map([[1, clip]]));
706
+
707
+ const jointE = world
708
+ .spawn(
709
+ { component: Transform, data: defaultTransform },
710
+ { component: Name, data: { value: 'real-joint' } },
711
+ )
712
+ .unwrap();
713
+ world.spawn(
714
+ {
715
+ component: AnimationPlayer,
716
+ data: {
717
+ clips: [
718
+ toShared<'AnimationClip'>(1),
719
+ 0 as Handle<'AnimationClip', 'shared'>,
720
+ 0 as Handle<'AnimationClip', 'shared'>,
721
+ 0 as Handle<'AnimationClip', 'shared'>,
722
+ ],
723
+ times: new Float32Array([0, 0, 0, 0]),
724
+ weights: new Float32Array([1, 0, 0, 0]),
725
+ speeds: new Float32Array([1, 1, 1, 1]),
726
+ },
727
+ },
728
+ {
729
+ component: Skin,
730
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
731
+ },
732
+ { component: Transform, data: defaultTransform },
733
+ );
734
+
735
+ _resetAnimationWarnsForTests(world);
736
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
737
+ try {
738
+ for (let i = 0; i < 60; i++) {
739
+ advanceAnimationPlayer(world, resolver, 1 / 60);
740
+ }
741
+ const targetMissingCalls = warnSpy.mock.calls.filter(
742
+ (c) => (c[0] as { code?: string }).code === 'animation-target-missing',
743
+ );
744
+ expect(targetMissingCalls.length).toBe(1);
745
+ } finally {
746
+ warnSpy.mockRestore();
747
+ }
748
+ });
749
+
750
+ it('two distinct channel indices on same entity each emit one warn (3 channels => 3 warns)', async () => {
751
+ const { _resetAnimationWarnsForTests } = await import('@forgeax/engine-animation');
752
+ const world = new World();
753
+ // Three channels each targeting an unmapped ID — chIdx differs, so
754
+ // the (entity, clip, chIdx, reason) key splits into 3 distinct entries
755
+ // and the throttle should NOT collapse them.
756
+ const sampler = makeSampler([0, 1], [0, 0, 0, 1, 1, 1], 'LINEAR');
757
+ const clip = makeClip(1, [
758
+ { targetId: deriveAnimationTargetId(['leaf-A']), property: 'translation', sampler },
759
+ { targetId: deriveAnimationTargetId(['leaf-B']), property: 'translation', sampler },
760
+ { targetId: deriveAnimationTargetId(['leaf-C']), property: 'translation', sampler },
761
+ ]);
762
+ const resolver = makeResolver(new Map([[1, clip]]));
763
+
764
+ const jointE = world
765
+ .spawn(
766
+ { component: Transform, data: defaultTransform },
767
+ { component: Name, data: { value: 'real-joint' } },
768
+ )
769
+ .unwrap();
770
+ world.spawn(
771
+ {
772
+ component: AnimationPlayer,
773
+ data: {
774
+ clips: [
775
+ toShared<'AnimationClip'>(1),
776
+ 0 as Handle<'AnimationClip', 'shared'>,
777
+ 0 as Handle<'AnimationClip', 'shared'>,
778
+ 0 as Handle<'AnimationClip', 'shared'>,
779
+ ],
780
+ times: new Float32Array([0, 0, 0, 0]),
781
+ weights: new Float32Array([1, 0, 0, 0]),
782
+ speeds: new Float32Array([1, 1, 1, 1]),
783
+ },
784
+ },
785
+ {
786
+ component: Skin,
787
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
788
+ },
789
+ { component: Transform, data: defaultTransform },
790
+ );
791
+
792
+ _resetAnimationWarnsForTests(world);
793
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
794
+ try {
795
+ for (let i = 0; i < 5; i++) advanceAnimationPlayer(world, resolver, 1 / 60);
796
+ const targetMissingCalls = warnSpy.mock.calls.filter(
797
+ (c) => (c[0] as { code?: string }).code === 'animation-target-missing',
798
+ );
799
+ expect(targetMissingCalls.length).toBe(3);
800
+ const chIdxValues = targetMissingCalls.map(
801
+ (c) => (c[0] as { detail: { channel: number } }).detail.channel,
802
+ );
803
+ expect(new Set(chIdxValues)).toEqual(new Set([0, 1, 2]));
804
+ } finally {
805
+ warnSpy.mockRestore();
806
+ }
807
+ });
808
+
809
+ it('two distinct reasons on same channel emit two warns (key includes reason)', async () => {
810
+ const { _resetAnimationWarnsForTests } = await import('@forgeax/engine-animation');
811
+ const world = new World();
812
+ // Slot 0: clip 1 has channel 0 = translation on 'real-joint' (resolves)
813
+ // and channel 1 = translation on 'unknown-joint' (target-missing).
814
+ // Slot 1: clip 2 has only channel 0 = rotation on 'real-joint'.
815
+ // After tick:
816
+ // - clip1.ch1 fires channel-target-missing (entity, 1, 1, target-missing).
817
+ // - The (real-joint, rotation) tuple is covered by slot1 but missing
818
+ // on slot0; slot1.ch0 fires channel-missing-on-some-slot
819
+ // (entity, 2, 0, missing-on-some-slot). The (real-joint, translation)
820
+ // tuple is covered by slot0 but missing on slot1; slot0.ch0 fires
821
+ // channel-missing-on-some-slot (entity, 1, 0, missing-on-some-slot).
822
+ // So we expect: target-missing=1, missing-on-some-slot=2.
823
+ const sampT = makeSampler([0, 1], [0, 0, 0, 1, 1, 1], 'LINEAR');
824
+ const sampR = makeSampler([0, 1], [0, 0, 0, 1, 0, 0, 0, 1], 'LINEAR');
825
+ const clip1 = makeClip(1, [
826
+ {
827
+ targetId: deriveAnimationTargetId(['real-joint']),
828
+ property: 'translation',
829
+ sampler: sampT,
830
+ },
831
+ {
832
+ targetId: deriveAnimationTargetId(['unknown-joint']),
833
+ property: 'translation',
834
+ sampler: sampT,
835
+ },
836
+ ]);
837
+ const clip2 = makeClip(1, [
838
+ { targetId: deriveAnimationTargetId(['real-joint']), property: 'rotation', sampler: sampR },
839
+ ]);
840
+ const resolver = makeResolver(
841
+ new Map([
842
+ [1, clip1],
843
+ [2, clip2],
844
+ ]),
845
+ );
846
+
847
+ const jointE = world
848
+ .spawn(
849
+ { component: Transform, data: defaultTransform },
850
+ { component: Name, data: { value: 'real-joint' } },
851
+ )
852
+ .unwrap();
853
+ world.spawn(
854
+ {
855
+ component: AnimationPlayer,
856
+ data: {
857
+ clips: [
858
+ toShared<'AnimationClip'>(1),
859
+ toShared<'AnimationClip'>(2),
860
+ 0 as Handle<'AnimationClip', 'shared'>,
861
+ 0 as Handle<'AnimationClip', 'shared'>,
862
+ ],
863
+ times: new Float32Array([0, 0, 0, 0]),
864
+ weights: new Float32Array([0.5, 0.5, 0, 0]),
865
+ speeds: new Float32Array([1, 1, 1, 1]),
866
+ },
867
+ },
868
+ {
869
+ component: Skin,
870
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
871
+ },
872
+ { component: Transform, data: defaultTransform },
873
+ );
874
+
875
+ _resetAnimationWarnsForTests(world);
876
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
877
+ try {
878
+ for (let i = 0; i < 5; i++) advanceAnimationPlayer(world, resolver, 1 / 60);
879
+ const targetCalls = warnSpy.mock.calls.filter(
880
+ (c) => (c[0] as { code?: string }).code === 'animation-target-missing',
881
+ );
882
+ const missingCalls = warnSpy.mock.calls.filter(
883
+ (c) => (c[0] as { code?: string }).code === 'animation-channel-missing',
884
+ );
885
+ expect(targetCalls.length).toBe(1);
886
+ // Both translation-on-slot1 and rotation-on-slot0 are missing — two
887
+ // distinct (clip, chIdx, missing-on-some-slot) keys.
888
+ expect(missingCalls.length).toBe(2);
889
+ } finally {
890
+ warnSpy.mockRestore();
891
+ }
892
+ });
893
+ });
894
+
895
+ // ──────────────────────────────────────────────────────────────────────────
896
+ // M3 / w8 — N-way weight blend matrix (plan-strategy §7 / AC-03/04/05).
897
+ //
898
+ // Ten it blocks covering normalized blend math, invalid-skip, per-channel
899
+ // normalize, duration-modulo, negative-weight clamp, paused time-stasis,
900
+ // resolver miss, and target-missing skips. All it blocks use the shared
901
+ // makeSampler / makeClip / makeResolver helpers from the enclosing block.
902
+ // ──────────────────────────────────────────────────────────────────────────
903
+
904
+ describe('M3 N-way weight blend matrix', () => {
905
+ it('single slot weights=[1,0,0,0] equals hard-cut (pos = clipA pos)', () => {
906
+ const world = new World();
907
+ const sampT = makeSampler([0, 1], [1, 2, 3, 10, 20, 30], 'LINEAR');
908
+ const clipA = makeClip(1, [
909
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
910
+ ]);
911
+ const resolver = makeResolver(new Map([[1, clipA]]));
912
+
913
+ const jointE = world
914
+ .spawn(
915
+ { component: Transform, data: defaultTransform },
916
+ { component: Name, data: { value: 'joint0' } },
917
+ )
918
+ .unwrap();
919
+ world.spawn(
920
+ {
921
+ component: AnimationPlayer,
922
+ data: {
923
+ clips: [
924
+ toShared<'AnimationClip'>(1),
925
+ toShared<'AnimationClip'>(2),
926
+ 0 as Handle<'AnimationClip', 'shared'>,
927
+ 0 as Handle<'AnimationClip', 'shared'>,
928
+ ],
929
+ times: new Float32Array([0, 0, 0, 0]),
930
+ weights: new Float32Array([1, 0, 0, 0]),
931
+ speeds: new Float32Array([1, 1, 1, 1]),
932
+ },
933
+ },
934
+ {
935
+ component: Skin,
936
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
937
+ },
938
+ { component: Transform, data: defaultTransform },
939
+ );
940
+
941
+ advanceAnimationPlayer(world, resolver, 0.5); // time=0.5 => lerp midway
942
+ const tf = world.get(jointE, Transform).unwrap();
943
+ expect(tf.pos[0]).toBeCloseTo(5.5); // lerp(1,10,0.5) = 5.5
944
+ expect(tf.pos[1]).toBeCloseTo(11); // lerp(2,20,0.5) = 11
945
+ expect(tf.pos[2]).toBeCloseTo(16.5); // lerp(3,30,0.5) = 16.5
946
+ });
947
+
948
+ it('two-slot weights=[0.5,0.5,0,0] yields midpoint pose', () => {
949
+ const world = new World();
950
+ const sampT1 = makeSampler([0, 1], [0, 0, 0, 4, 0, 0], 'LINEAR'); // pos (2,0,0) at t=0.5
951
+ const sampT2 = makeSampler([0, 1], [0, 0, 0, 0, 6, 0], 'LINEAR'); // pos (0,3,0) at t=0.5
952
+ const clipA = makeClip(1, [
953
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT1 },
954
+ ]);
955
+ const clipB = makeClip(1, [
956
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT2 },
957
+ ]);
958
+ const resolver = makeResolver(
959
+ new Map([
960
+ [1, clipA],
961
+ [2, clipB],
962
+ ]),
963
+ );
964
+
965
+ const jointE = world
966
+ .spawn(
967
+ { component: Transform, data: defaultTransform },
968
+ { component: Name, data: { value: 'joint0' } },
969
+ )
970
+ .unwrap();
971
+ world.spawn(
972
+ {
973
+ component: AnimationPlayer,
974
+ data: {
975
+ clips: [
976
+ toShared<'AnimationClip'>(1),
977
+ toShared<'AnimationClip'>(2),
978
+ 0 as Handle<'AnimationClip', 'shared'>,
979
+ 0 as Handle<'AnimationClip', 'shared'>,
980
+ ],
981
+ times: new Float32Array([0, 0, 0, 0]),
982
+ weights: new Float32Array([0.5, 0.5, 0, 0]),
983
+ speeds: new Float32Array([1, 1, 1, 1]),
984
+ },
985
+ },
986
+ {
987
+ component: Skin,
988
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
989
+ },
990
+ { component: Transform, data: defaultTransform },
991
+ );
992
+
993
+ advanceAnimationPlayer(world, resolver, 0.5); // both at t=0.5
994
+ const tf = world.get(jointE, Transform).unwrap();
995
+ // clipA pos=(2,0,0), clipB pos=(0,3,0), blended=(1,1.5,0) with equal weights
996
+ expect(tf.pos[0]).toBeCloseTo(1);
997
+ expect(tf.pos[1]).toBeCloseTo(1.5);
998
+ expect(tf.pos[2]).toBeCloseTo(0);
999
+ });
1000
+
1001
+ it('three-slot weights=[1/3,1/3,1/3,0] equal-weight blend', () => {
1002
+ const world = new World();
1003
+ const sampT1 = makeSampler([0, 1], [0, 0, 0, 3, 0, 0], 'LINEAR');
1004
+ const sampT2 = makeSampler([0, 1], [0, 0, 0, 0, 3, 0], 'LINEAR');
1005
+ const sampT3 = makeSampler([0, 1], [0, 0, 0, 0, 0, 3], 'LINEAR');
1006
+ const clipA = makeClip(1, [
1007
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT1 },
1008
+ ]);
1009
+ const clipB = makeClip(1, [
1010
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT2 },
1011
+ ]);
1012
+ const clipC = makeClip(1, [
1013
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT3 },
1014
+ ]);
1015
+ const resolver = makeResolver(
1016
+ new Map([
1017
+ [1, clipA],
1018
+ [2, clipB],
1019
+ [3, clipC],
1020
+ ]),
1021
+ );
1022
+
1023
+ const jointE = world
1024
+ .spawn(
1025
+ { component: Transform, data: defaultTransform },
1026
+ { component: Name, data: { value: 'joint0' } },
1027
+ )
1028
+ .unwrap();
1029
+ world.spawn(
1030
+ {
1031
+ component: AnimationPlayer,
1032
+ data: {
1033
+ clips: [
1034
+ toShared<'AnimationClip'>(1),
1035
+ toShared<'AnimationClip'>(2),
1036
+ toShared<'AnimationClip'>(3),
1037
+ 0 as Handle<'AnimationClip', 'shared'>,
1038
+ ],
1039
+ times: new Float32Array([0, 0, 0, 0]),
1040
+ weights: new Float32Array([1 / 3, 1 / 3, 1 / 3, 0]),
1041
+ speeds: new Float32Array([1, 1, 1, 1]),
1042
+ },
1043
+ },
1044
+ {
1045
+ component: Skin,
1046
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1047
+ },
1048
+ { component: Transform, data: defaultTransform },
1049
+ );
1050
+
1051
+ advanceAnimationPlayer(world, resolver, 0.5); // all at t=0.5
1052
+ const tf = world.get(jointE, Transform).unwrap();
1053
+ // Each clip at t=0.5: (1.5,0,0), (0,1.5,0), (0,0,1.5) => avg = (0.5,0.5,0.5)
1054
+ expect(tf.pos[0]).toBeCloseTo(0.5);
1055
+ expect(tf.pos[1]).toBeCloseTo(0.5);
1056
+ expect(tf.pos[2]).toBeCloseTo(0.5);
1057
+ });
1058
+
1059
+ it('un-normalized weights [0.6,0.6,0,0] normalize to [0.5,0.5,0,0]', () => {
1060
+ const world = new World();
1061
+ const sampT1 = makeSampler([0, 1], [0, 0, 0, 10, 0, 0], 'LINEAR');
1062
+ const sampT2 = makeSampler([0, 1], [0, 0, 0, 0, 10, 0], 'LINEAR');
1063
+ const clipA = makeClip(1, [
1064
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT1 },
1065
+ ]);
1066
+ const clipB = makeClip(1, [
1067
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT2 },
1068
+ ]);
1069
+ const resolver = makeResolver(
1070
+ new Map([
1071
+ [1, clipA],
1072
+ [2, clipB],
1073
+ ]),
1074
+ );
1075
+
1076
+ const jointE = world
1077
+ .spawn(
1078
+ { component: Transform, data: defaultTransform },
1079
+ { component: Name, data: { value: 'joint0' } },
1080
+ )
1081
+ .unwrap();
1082
+ world.spawn(
1083
+ {
1084
+ component: AnimationPlayer,
1085
+ data: {
1086
+ clips: [
1087
+ toShared<'AnimationClip'>(1),
1088
+ toShared<'AnimationClip'>(2),
1089
+ 0 as Handle<'AnimationClip', 'shared'>,
1090
+ 0 as Handle<'AnimationClip', 'shared'>,
1091
+ ],
1092
+ times: new Float32Array([0, 0, 0, 0]),
1093
+ weights: new Float32Array([0.6, 0.6, 0, 0]),
1094
+ speeds: new Float32Array([1, 1, 1, 1]),
1095
+ },
1096
+ },
1097
+ {
1098
+ component: Skin,
1099
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1100
+ },
1101
+ { component: Transform, data: defaultTransform },
1102
+ );
1103
+
1104
+ advanceAnimationPlayer(world, resolver, 0.5);
1105
+ const tf = world.get(jointE, Transform).unwrap();
1106
+ // Normalized blend: sumW=1.2, each w/Σw=0.5. pos = (5*0.5 + 0*0.5, 0*0.5 + 5*0.5) = (2.5, 2.5)
1107
+ expect(tf.pos[0]).toBeCloseTo(2.5);
1108
+ expect(tf.pos[1]).toBeCloseTo(2.5);
1109
+ // Weights column unchanged (weightsView reflects the original 0.6 values)
1110
+ });
1111
+
1112
+ it('weights[i] < 0 clamped to 0 and NOT written back', () => {
1113
+ const world = new World();
1114
+ const sampT = makeSampler([0, 1], [0, 0, 0, 4, 4, 4], 'LINEAR');
1115
+ const clipA = makeClip(1, [
1116
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1117
+ ]);
1118
+ const resolver = makeResolver(new Map([[1, clipA]]));
1119
+
1120
+ const jointE = world
1121
+ .spawn(
1122
+ { component: Transform, data: defaultTransform },
1123
+ { component: Name, data: { value: 'joint0' } },
1124
+ )
1125
+ .unwrap();
1126
+ const animE = world
1127
+ .spawn(
1128
+ {
1129
+ component: AnimationPlayer,
1130
+ data: {
1131
+ clips: [
1132
+ toShared<'AnimationClip'>(1),
1133
+ 0 as Handle<'AnimationClip', 'shared'>,
1134
+ 0 as Handle<'AnimationClip', 'shared'>,
1135
+ 0 as Handle<'AnimationClip', 'shared'>,
1136
+ ],
1137
+ times: new Float32Array([0, 0, 0, 0]),
1138
+ weights: new Float32Array([-0.5, 0, 0, 0]),
1139
+ speeds: new Float32Array([1, 0, 0, 0]),
1140
+ },
1141
+ },
1142
+ {
1143
+ component: Skin,
1144
+ data: {
1145
+ skeleton: toShared<'SkeletonAsset'>(100),
1146
+ joints: new Uint32Array([jointE]),
1147
+ },
1148
+ },
1149
+ { component: Transform, data: defaultTransform },
1150
+ )
1151
+ .unwrap();
1152
+
1153
+ // Negative weight clamped -> w=0, slot skipped (no accumulator writes).
1154
+ // Transform should remain at its default (position = 0).
1155
+ advanceAnimationPlayer(world, resolver, 0.5);
1156
+ const tf = world.get(jointE, Transform).unwrap();
1157
+ expect(tf.pos[0]).toBe(0);
1158
+ expect(tf.pos[1]).toBe(0);
1159
+ expect(tf.pos[2]).toBe(0);
1160
+
1161
+ // Read-back: weights[0] is still -0.5 (not written back per D-7).
1162
+ const apRes = world.get(animE, AnimationPlayer).unwrap() as unknown as {
1163
+ weights: Float32Array;
1164
+ };
1165
+ expect(apRes.weights[0]).toBe(-0.5);
1166
+ });
1167
+
1168
+ it('paused=true does not advance times but still blends by current times', () => {
1169
+ const world = new World();
1170
+ const sampT1 = makeSampler([0, 1], [0, 0, 0, 10, 0, 0], 'LINEAR');
1171
+ const sampT2 = makeSampler([0, 1], [0, 0, 0, 0, 10, 0], 'LINEAR');
1172
+ const clipA = makeClip(1, [
1173
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT1 },
1174
+ ]);
1175
+ const clipB = makeClip(1, [
1176
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT2 },
1177
+ ]);
1178
+ const resolver = makeResolver(
1179
+ new Map([
1180
+ [1, clipA],
1181
+ [2, clipB],
1182
+ ]),
1183
+ );
1184
+
1185
+ const jointE = world
1186
+ .spawn(
1187
+ { component: Transform, data: defaultTransform },
1188
+ { component: Name, data: { value: 'joint0' } },
1189
+ )
1190
+ .unwrap();
1191
+ const animE = world
1192
+ .spawn(
1193
+ {
1194
+ component: AnimationPlayer,
1195
+ data: {
1196
+ clips: [
1197
+ toShared<'AnimationClip'>(1),
1198
+ toShared<'AnimationClip'>(2),
1199
+ 0 as Handle<'AnimationClip', 'shared'>,
1200
+ 0 as Handle<'AnimationClip', 'shared'>,
1201
+ ],
1202
+ times: new Float32Array([0.5, 0.2, 0, 0]),
1203
+ weights: new Float32Array([0.5, 0.5, 0, 0]),
1204
+ speeds: new Float32Array([1, 1, 1, 1]),
1205
+ paused: true,
1206
+ },
1207
+ },
1208
+ {
1209
+ component: Skin,
1210
+ data: {
1211
+ skeleton: toShared<'SkeletonAsset'>(100),
1212
+ joints: new Uint32Array([jointE]),
1213
+ },
1214
+ },
1215
+ { component: Transform, data: defaultTransform },
1216
+ )
1217
+ .unwrap();
1218
+
1219
+ advanceAnimationPlayer(world, resolver, 0.5);
1220
+ const tf = world.get(jointE, Transform).unwrap();
1221
+ // clipA at t=0.5: pos (5,0,0); clipB at t=0.2: pos (0,2,0)
1222
+ // blended: (2.5, 1.0, 0)
1223
+ expect(tf.pos[0]).toBeCloseTo(2.5);
1224
+ expect(tf.pos[1]).toBeCloseTo(1.0);
1225
+
1226
+ // Times unchanged by paused gate
1227
+ const apRes = world.get(animE, AnimationPlayer).unwrap() as unknown as {
1228
+ times: Float32Array;
1229
+ };
1230
+ expect(apRes.times[0]).toBeCloseTo(0.5);
1231
+ expect(apRes.times[1]).toBeCloseTo(0.2);
1232
+ });
1233
+
1234
+ it('resolver cache miss skips slot without warning', () => {
1235
+ const world = new World();
1236
+ const sampT = makeSampler([0, 1], [0, 0, 0, 10, 10, 10], 'LINEAR');
1237
+ const clipA = makeClip(1, [
1238
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1239
+ ]);
1240
+ const resolver = makeResolver(new Map([[1, clipA]]));
1241
+ // clip handle 2 is not in resolver — resolver returns undefined, slot skipped.
1242
+
1243
+ const jointE = world
1244
+ .spawn(
1245
+ { component: Transform, data: defaultTransform },
1246
+ { component: Name, data: { value: 'joint0' } },
1247
+ )
1248
+ .unwrap();
1249
+ world.spawn(
1250
+ {
1251
+ component: AnimationPlayer,
1252
+ data: {
1253
+ clips: [
1254
+ toShared<'AnimationClip'>(1),
1255
+ toShared<'AnimationClip'>(2),
1256
+ 0 as Handle<'AnimationClip', 'shared'>,
1257
+ 0 as Handle<'AnimationClip', 'shared'>,
1258
+ ],
1259
+ times: new Float32Array([0, 0, 0, 0]),
1260
+ weights: new Float32Array([1, 0.5, 0, 0]),
1261
+ speeds: new Float32Array([1, 1, 1, 1]),
1262
+ },
1263
+ },
1264
+ {
1265
+ component: Skin,
1266
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1267
+ },
1268
+ { component: Transform, data: defaultTransform },
1269
+ );
1270
+
1271
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
1272
+ try {
1273
+ advanceAnimationPlayer(world, resolver, 0.5);
1274
+ // clip 1 at t=0.5 with weight=1: pos=(5,5,5). Slot 2 skipped silently.
1275
+ const tf = world.get(jointE, Transform).unwrap();
1276
+ expect(tf.pos[0]).toBeCloseTo(5);
1277
+ expect(tf.pos[1]).toBeCloseTo(5);
1278
+ expect(tf.pos[2]).toBeCloseTo(5);
1279
+ // No warn emitted for resolver miss (AC-04: invalid handle / miss = silent skip).
1280
+ expect(warnSpy).not.toHaveBeenCalled();
1281
+ } finally {
1282
+ warnSpy.mockRestore();
1283
+ }
1284
+ });
1285
+
1286
+ it('clips[i]=0 invalid handle skips slot silently', () => {
1287
+ const world = new World();
1288
+ const sampT = makeSampler([0, 1], [3, 0, 0, 3, 0, 0], 'LINEAR');
1289
+ const clipA = makeClip(1, [
1290
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1291
+ ]);
1292
+ const resolver = makeResolver(new Map([[1, clipA]]));
1293
+
1294
+ const jointE = world
1295
+ .spawn(
1296
+ { component: Transform, data: defaultTransform },
1297
+ { component: Name, data: { value: 'joint0' } },
1298
+ )
1299
+ .unwrap();
1300
+ world.spawn(
1301
+ {
1302
+ component: AnimationPlayer,
1303
+ data: {
1304
+ clips: [
1305
+ toShared<'AnimationClip'>(1),
1306
+ 0 as Handle<'AnimationClip', 'shared'>,
1307
+ 0 as Handle<'AnimationClip', 'shared'>,
1308
+ 0 as Handle<'AnimationClip', 'shared'>,
1309
+ ],
1310
+ times: new Float32Array([0, 0, 0, 0]),
1311
+ weights: new Float32Array([1, 0, 0, 0]),
1312
+ speeds: new Float32Array([1, 1, 1, 1]),
1313
+ },
1314
+ },
1315
+ {
1316
+ component: Skin,
1317
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1318
+ },
1319
+ { component: Transform, data: defaultTransform },
1320
+ );
1321
+
1322
+ advanceAnimationPlayer(world, resolver, 0);
1323
+ const tf = world.get(jointE, Transform).unwrap();
1324
+ // clipA at t=0 always gives pos=(3,0,0), regardless of other slots being 0.
1325
+ expect(tf.pos[0]).toBeCloseTo(3);
1326
+ });
1327
+
1328
+ it('different durations each modulo independently without warning', () => {
1329
+ const world = new World();
1330
+ const sampTShort = makeSampler([0, 0.5], [0, 0, 0, 0.5, 0, 0], 'LINEAR');
1331
+ const sampTLong = makeSampler([0, 2], [0, 0, 0, 0, 2, 0], 'LINEAR');
1332
+ const clipShort = makeClip(0.5, [
1333
+ {
1334
+ targetId: deriveAnimationTargetId(['joint0']),
1335
+ property: 'translation',
1336
+ sampler: sampTShort,
1337
+ },
1338
+ ]);
1339
+ const clipLong = makeClip(2, [
1340
+ {
1341
+ targetId: deriveAnimationTargetId(['joint0']),
1342
+ property: 'translation',
1343
+ sampler: sampTLong,
1344
+ },
1345
+ ]);
1346
+ const resolver = makeResolver(
1347
+ new Map([
1348
+ [1, clipShort],
1349
+ [2, clipLong],
1350
+ ]),
1351
+ );
1352
+
1353
+ const jointE = world
1354
+ .spawn(
1355
+ { component: Transform, data: defaultTransform },
1356
+ { component: Name, data: { value: 'joint0' } },
1357
+ )
1358
+ .unwrap();
1359
+ world.spawn(
1360
+ {
1361
+ component: AnimationPlayer,
1362
+ data: {
1363
+ clips: [
1364
+ toShared<'AnimationClip'>(1),
1365
+ toShared<'AnimationClip'>(2),
1366
+ 0 as Handle<'AnimationClip', 'shared'>,
1367
+ 0 as Handle<'AnimationClip', 'shared'>,
1368
+ ],
1369
+ times: new Float32Array([0, 0, 0, 0]),
1370
+ weights: new Float32Array([0.5, 0.5, 0, 0]),
1371
+ speeds: new Float32Array([1, 1, 1, 1]),
1372
+ },
1373
+ },
1374
+ {
1375
+ component: Skin,
1376
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1377
+ },
1378
+ { component: Transform, data: defaultTransform },
1379
+ );
1380
+
1381
+ // Advance by 1.25 seconds.
1382
+ // clipShort (duration=0.5, looping): t = 0 + 1.25 = 1.25 -> 1.25 % 0.5 = 0.25 => pos=(0.25,0,0)
1383
+ // clipLong (duration=2, looping): t = 0 + 1.25 = 1.25 -> 1.25 % 2 = 1.25 => pos=(0,1.25,0)
1384
+ advanceAnimationPlayer(world, resolver, 1.25);
1385
+ const tf = world.get(jointE, Transform).unwrap();
1386
+ // 0.5*(0.25,0,0) + 0.5*(0,1.25,0) => (0.125, 0.625, 0)
1387
+ expect(tf.pos[0]).toBeCloseTo(0.125);
1388
+ expect(tf.pos[1]).toBeCloseTo(0.625);
1389
+ });
1390
+
1391
+ it('targetId mismatch skips channel (no crash)', () => {
1392
+ const world = new World();
1393
+ const sampT = makeSampler([0, 1], [0, 0, 0, 4, 4, 4], 'LINEAR');
1394
+ const clip = makeClip(1, [
1395
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1396
+ {
1397
+ targetId: deriveAnimationTargetId(['nonexistent-joint']),
1398
+ property: 'translation',
1399
+ sampler: sampT,
1400
+ },
1401
+ ]);
1402
+ const resolver = makeResolver(new Map([[1, clip]]));
1403
+
1404
+ const jointE = world
1405
+ .spawn(
1406
+ { component: Transform, data: defaultTransform },
1407
+ { component: Name, data: { value: 'joint0' } },
1408
+ )
1409
+ .unwrap();
1410
+ world.spawn(
1411
+ {
1412
+ component: AnimationPlayer,
1413
+ data: {
1414
+ clips: [
1415
+ toShared<'AnimationClip'>(1),
1416
+ 0 as Handle<'AnimationClip', 'shared'>,
1417
+ 0 as Handle<'AnimationClip', 'shared'>,
1418
+ 0 as Handle<'AnimationClip', 'shared'>,
1419
+ ],
1420
+ times: new Float32Array([0, 0, 0, 0]),
1421
+ weights: new Float32Array([1, 0, 0, 0]),
1422
+ speeds: new Float32Array([1, 1, 1, 1]),
1423
+ },
1424
+ },
1425
+ {
1426
+ component: Skin,
1427
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1428
+ },
1429
+ { component: Transform, data: defaultTransform },
1430
+ );
1431
+
1432
+ // Should not crash — the nonexistent-joint channel is skipped.
1433
+ expect(() => advanceAnimationPlayer(world, resolver, 0.5)).not.toThrow();
1434
+ // The joint0 channel still applies.
1435
+ const tf = world.get(jointE, Transform).unwrap();
1436
+ expect(tf.pos[0]).toBeCloseTo(2); // lerp(0,4,0.5) = 2
1437
+ });
1438
+
1439
+ it('two-slot quat nlerp with sign fix (opposite hemispheres)', () => {
1440
+ const world = new World();
1441
+ // Clip 1: quat rotates 90 degrees around y (0, 0.707, 0, 0.707)
1442
+ // Clip 2: quat rotates -90 degrees around y (0, -0.707, 0, 0.707)
1443
+ // Dot = 0*0 + 0.707*(-0.707) + 0*0 + 0.707*0.707 = -0.5 + 0.5 = 0.
1444
+ // Sign fix will negate one for short-arc nlerp.
1445
+ const sampR1 = makeSampler(
1446
+ [0, 1],
1447
+ [0, Math.SQRT1_2, 0, Math.SQRT1_2, 0, Math.SQRT1_2, 0, Math.SQRT1_2],
1448
+ 'LINEAR',
1449
+ );
1450
+ const sampR2 = makeSampler(
1451
+ [0, 1],
1452
+ [0, -Math.SQRT1_2, 0, Math.SQRT1_2, 0, -Math.SQRT1_2, 0, Math.SQRT1_2],
1453
+ 'LINEAR',
1454
+ );
1455
+ const clipA = makeClip(1, [
1456
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'rotation', sampler: sampR1 },
1457
+ ]);
1458
+ const clipB = makeClip(1, [
1459
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'rotation', sampler: sampR2 },
1460
+ ]);
1461
+ const resolver = makeResolver(
1462
+ new Map([
1463
+ [1, clipA],
1464
+ [2, clipB],
1465
+ ]),
1466
+ );
1467
+
1468
+ const jointE = world
1469
+ .spawn(
1470
+ { component: Transform, data: defaultTransform },
1471
+ { component: Name, data: { value: 'joint0' } },
1472
+ )
1473
+ .unwrap();
1474
+ world.spawn(
1475
+ {
1476
+ component: AnimationPlayer,
1477
+ data: {
1478
+ clips: [
1479
+ toShared<'AnimationClip'>(1),
1480
+ toShared<'AnimationClip'>(2),
1481
+ 0 as Handle<'AnimationClip', 'shared'>,
1482
+ 0 as Handle<'AnimationClip', 'shared'>,
1483
+ ],
1484
+ times: new Float32Array([0.5, 0.5, 0, 0]),
1485
+ weights: new Float32Array([0.5, 0.5, 0, 0]),
1486
+ speeds: new Float32Array([1, 1, 1, 1]),
1487
+ },
1488
+ },
1489
+ {
1490
+ component: Skin,
1491
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1492
+ },
1493
+ { component: Transform, data: defaultTransform },
1494
+ );
1495
+
1496
+ expect(() => advanceAnimationPlayer(world, resolver, 0.5)).not.toThrow();
1497
+ const tf = world.get(jointE, Transform).unwrap();
1498
+ // nlerp of equal-weight opposite y-rotations should give identity-ish result.
1499
+ // quatX/quatZ should stay near 0, quatW stays near 1.
1500
+ expect(Math.abs(tf.quat[1])).toBeLessThan(0.01);
1501
+ expect(tf.quat[3]).toBeGreaterThan(0.99);
1502
+ });
1503
+
1504
+ it('speed<0 reverses time (looping wraparound)', () => {
1505
+ const world = new World();
1506
+ const sampT = makeSampler([0, 2], [0, 0, 0, 2, 0, 0], 'LINEAR');
1507
+ const clip = makeClip(2, [
1508
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1509
+ ]);
1510
+ const resolver = makeResolver(new Map([[1, clip]]));
1511
+
1512
+ const jointE = world
1513
+ .spawn(
1514
+ { component: Transform, data: defaultTransform },
1515
+ { component: Name, data: { value: 'joint0' } },
1516
+ )
1517
+ .unwrap();
1518
+ world.spawn(
1519
+ {
1520
+ component: AnimationPlayer,
1521
+ data: {
1522
+ clips: [
1523
+ toShared<'AnimationClip'>(1),
1524
+ 0 as Handle<'AnimationClip', 'shared'>,
1525
+ 0 as Handle<'AnimationClip', 'shared'>,
1526
+ 0 as Handle<'AnimationClip', 'shared'>,
1527
+ ],
1528
+ times: new Float32Array([0.5, 0, 0, 0]),
1529
+ weights: new Float32Array([1, 0, 0, 0]),
1530
+ speeds: new Float32Array([-2, 1, 1, 1]),
1531
+ },
1532
+ },
1533
+ {
1534
+ component: Skin,
1535
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array([jointE]) },
1536
+ },
1537
+ { component: Transform, data: defaultTransform },
1538
+ );
1539
+
1540
+ advanceAnimationPlayer(world, resolver, 0.5); // t = 0.5 + (-2)*0.5 = -0.5 => -0.5 % 2 = -0.5 => -0.5+2=1.5
1541
+ const tf = world.get(jointE, Transform).unwrap();
1542
+ expect(tf.pos[0]).toBeCloseTo(1.5); // lerp(0,2,0.75) = 1.5
1543
+ });
1544
+
1545
+ it('entity with Skin but empty joints is skipped', () => {
1546
+ const world = new World();
1547
+ const sampT = makeSampler([0, 1], [0, 0, 0, 4, 4, 4], 'LINEAR');
1548
+ const clip = makeClip(1, [
1549
+ { targetId: deriveAnimationTargetId(['joint0']), property: 'translation', sampler: sampT },
1550
+ ]);
1551
+ const resolver = makeResolver(new Map([[1, clip]]));
1552
+
1553
+ world.spawn(
1554
+ {
1555
+ component: AnimationPlayer,
1556
+ data: {
1557
+ clips: [
1558
+ toShared<'AnimationClip'>(1),
1559
+ 0 as Handle<'AnimationClip', 'shared'>,
1560
+ 0 as Handle<'AnimationClip', 'shared'>,
1561
+ 0 as Handle<'AnimationClip', 'shared'>,
1562
+ ],
1563
+ times: new Float32Array([0, 0, 0, 0]),
1564
+ weights: new Float32Array([1, 0, 0, 0]),
1565
+ speeds: new Float32Array([1, 1, 1, 1]),
1566
+ },
1567
+ },
1568
+ {
1569
+ component: Skin,
1570
+ data: { skeleton: toShared<'SkeletonAsset'>(100), joints: new Uint32Array(0) },
1571
+ },
1572
+ { component: Transform, data: defaultTransform },
1573
+ );
1574
+
1575
+ expect(() => advanceAnimationPlayer(world, resolver, 0.5)).not.toThrow();
1576
+ });
1577
+ });
1578
+
1579
+ // ──────────────────────────────────────────────────────────────────────────
1580
+ // M3 / w9 — sample writes counting (per-joint single world.set, not
1581
+ // per-channel triple set). Spies on World.prototype.set to count calls
1582
+ // matching Transform writes on joint entities.
1583
+ // ──────────────────────────────────────────────────────────────────────────
1584
+
1585
+ describe('M3 per-joint sample writes count assertion', () => {
1586
+ it('world.set callCount = joint count (not joints x channels)', () => {
1587
+ const world = new World();
1588
+ // 2 joints + 3 channels (translation/rotation/scale) per clip
1589
+ // = 2 clips x 2 joints x 3 channels = 12 channels total.
1590
+ // If per-channel set were kept, callCount would be 6 (2 joints x 3 channels).
1591
+ // With per-joint accumulator: callCount = 2 (one per joint).
1592
+ const sampT = makeSampler([0, 1], [0, 0, 0, 3, 0, 0], 'LINEAR');
1593
+ const sampR = makeSampler([0, 1], [0, 0, 0, 1, 0, 0, 0, 1], 'LINEAR');
1594
+ const sampS = makeSampler([0, 1], [1, 1, 1, 2, 2, 2], 'LINEAR');
1595
+ const clipA = makeClip(1, [
1596
+ { targetId: deriveAnimationTargetId(['jointA']), property: 'translation', sampler: sampT },
1597
+ { targetId: deriveAnimationTargetId(['jointA']), property: 'rotation', sampler: sampR },
1598
+ { targetId: deriveAnimationTargetId(['jointA']), property: 'scale', sampler: sampS },
1599
+ { targetId: deriveAnimationTargetId(['jointB']), property: 'translation', sampler: sampT },
1600
+ { targetId: deriveAnimationTargetId(['jointB']), property: 'rotation', sampler: sampR },
1601
+ { targetId: deriveAnimationTargetId(['jointB']), property: 'scale', sampler: sampS },
1602
+ ]);
1603
+ const resolver = makeResolver(new Map([[1, clipA]]));
1604
+
1605
+ const jointA = world
1606
+ .spawn(
1607
+ { component: Transform, data: defaultTransform },
1608
+ { component: Name, data: { value: 'jointA' } },
1609
+ )
1610
+ .unwrap();
1611
+ const jointB = world
1612
+ .spawn(
1613
+ { component: Transform, data: defaultTransform },
1614
+ { component: Name, data: { value: 'jointB' } },
1615
+ )
1616
+ .unwrap();
1617
+
1618
+ world.spawn(
1619
+ {
1620
+ component: AnimationPlayer,
1621
+ data: {
1622
+ clips: [
1623
+ toShared<'AnimationClip'>(1),
1624
+ 0 as Handle<'AnimationClip', 'shared'>,
1625
+ 0 as Handle<'AnimationClip', 'shared'>,
1626
+ 0 as Handle<'AnimationClip', 'shared'>,
1627
+ ],
1628
+ times: new Float32Array([0, 0, 0, 0]),
1629
+ weights: new Float32Array([1, 0, 0, 0]),
1630
+ speeds: new Float32Array([1, 1, 1, 1]),
1631
+ },
1632
+ },
1633
+ {
1634
+ component: Skin,
1635
+ data: {
1636
+ skeleton: toShared<'SkeletonAsset'>(100),
1637
+ joints: new Uint32Array([jointA, jointB]),
1638
+ },
1639
+ },
1640
+ { component: Transform, data: defaultTransform },
1641
+ );
1642
+
1643
+ // Spy on world.set — count calls whose first arg is jointA or jointB.
1644
+ const jointHandles = new Set<number>([
1645
+ jointA as unknown as number,
1646
+ jointB as unknown as number,
1647
+ ]);
1648
+ let setCount = 0;
1649
+ const origSet = world.set.bind(world);
1650
+ world.set = (...args: unknown[]) => {
1651
+ const entity = args[0] as unknown as number;
1652
+ if (jointHandles.has(entity)) setCount++;
1653
+ return origSet(...args) as ReturnType<typeof world.set>;
1654
+ };
1655
+
1656
+ try {
1657
+ advanceAnimationPlayer(world, resolver, 0.5);
1658
+ // 2 joints, each receives exactly 1 set(Transform, partial).
1659
+ // NOT 6 (2 joints x 3 channels).
1660
+ expect(setCount).toBe(2);
1661
+ } finally {
1662
+ world.set = origSet;
1663
+ }
1664
+ });
1665
+ });
1666
+ }