@woosh/meep-engine 2.168.0 → 2.169.0

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 (295) hide show
  1. package/README.md +2 -2
  2. package/build/bundle-worker-image-decoder.js +1 -1
  3. package/package.json +1 -1
  4. package/src/REVIEW_2026_08_06/adv-core.md +511 -0
  5. package/src/REVIEW_2026_08_06/adv-gfx.md +480 -0
  6. package/src/REVIEW_2026_08_06/adv-view.md +446 -0
  7. package/src/REVIEW_2026_08_06/api-core.md +296 -0
  8. package/src/REVIEW_2026_08_06/api-engine.md +195 -0
  9. package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +507 -0
  10. package/src/REVIEW_2026_08_06/core-binary.md +348 -0
  11. package/src/REVIEW_2026_08_06/core-collection.md +376 -0
  12. package/src/REVIEW_2026_08_06/core-math.md +221 -0
  13. package/src/REVIEW_2026_08_06/core-process.md +266 -0
  14. package/src/REVIEW_2026_08_06/core-science.md +223 -0
  15. package/src/REVIEW_2026_08_06/engine-ai-nav.md +279 -0
  16. package/src/REVIEW_2026_08_06/engine-animation.md +167 -0
  17. package/src/REVIEW_2026_08_06/engine-asset.md +166 -0
  18. package/src/REVIEW_2026_08_06/engine-ecs.md +920 -0
  19. package/src/REVIEW_2026_08_06/engine-input.md +197 -0
  20. package/src/REVIEW_2026_08_06/engine-misc.md +248 -0
  21. package/src/REVIEW_2026_08_06/engine-terrain.md +52 -0
  22. package/src/REVIEW_2026_08_06/generation.md +176 -0
  23. package/src/REVIEW_2026_08_06/geom-2d.md +434 -0
  24. package/src/REVIEW_2026_08_06/geom-3d.md +117 -0
  25. package/src/REVIEW_2026_08_06/gfx-gi.md +143 -0
  26. package/src/REVIEW_2026_08_06/gfx-render.md +740 -0
  27. package/src/REVIEW_2026_08_06/gfx-texture.md +690 -0
  28. package/src/REVIEW_2026_08_06/prior-criticals.md +49 -0
  29. package/src/REVIEW_2026_08_06/prior-highs.md +138 -0
  30. package/src/REVIEW_2026_08_06/test-gaps.md +364 -0
  31. package/src/REVIEW_2026_08_06/view.md +667 -0
  32. package/src/REVIEW_2026_08_06.md +571 -0
  33. package/src/core/binary/BinaryBuffer.d.ts.map +1 -1
  34. package/src/core/binary/BinaryBuffer.js +12 -1
  35. package/src/core/binary/BitImage2.d.ts +5 -1
  36. package/src/core/binary/BitImage2.d.ts.map +1 -1
  37. package/src/core/binary/BitImage2.js +5 -1
  38. package/src/core/bvh8/build/NodeProxy.d.ts.map +1 -1
  39. package/src/core/bvh8/build/NodeProxy.js +314 -308
  40. package/src/core/collection/list/List.d.ts +15 -0
  41. package/src/core/collection/list/List.d.ts.map +1 -1
  42. package/src/core/collection/list/List.js +34 -0
  43. package/src/core/collection/list/SortedListProjection.d.ts.map +1 -1
  44. package/src/core/collection/list/SortedListProjection.js +4 -1
  45. package/src/core/collection/map/BiMap.d.ts +25 -2
  46. package/src/core/collection/map/BiMap.d.ts.map +1 -1
  47. package/src/core/collection/map/BiMap.js +57 -3
  48. package/src/core/debug/matchers/IsAnything.d.ts.map +1 -1
  49. package/src/core/debug/matchers/IsAnything.js +5 -1
  50. package/src/core/events/signal/Signal.d.ts.map +1 -1
  51. package/src/core/events/signal/Signal.js +3 -1
  52. package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +433 -0
  53. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts +31 -0
  54. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts.map +1 -0
  55. package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.js +84 -0
  56. package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts +24 -2
  57. package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts.map +1 -1
  58. package/src/core/geom/2d/polygon/polygon2_signed_area.js +52 -34
  59. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts +28 -0
  60. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts.map +1 -0
  61. package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.js +86 -0
  62. package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts +40 -0
  63. package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts.map +1 -0
  64. package/src/core/geom/2d/polygon/polygon2_triangulate.js +1280 -0
  65. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts +28 -0
  66. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts.map +1 -0
  67. package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.js +77 -0
  68. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts +6 -3
  69. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts.map +1 -1
  70. package/src/core/geom/2d/triangle/tri2_rasterize_conservative.js +26 -13
  71. package/src/core/geom/2d/triangle/tri2_signed_area.d.ts +17 -1
  72. package/src/core/geom/2d/triangle/tri2_signed_area.d.ts.map +1 -1
  73. package/src/core/geom/2d/triangle/tri2_signed_area.js +35 -23
  74. package/src/core/geom/2d/v2_morton_encode.d.ts +20 -0
  75. package/src/core/geom/2d/v2_morton_encode.d.ts.map +1 -0
  76. package/src/core/geom/2d/v2_morton_encode.js +23 -0
  77. package/src/core/geom/3d/atlas/segment/atlas_merge_charts.d.ts.map +1 -1
  78. package/src/core/geom/3d/atlas/segment/atlas_merge_charts.js +4 -1
  79. package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts +27 -0
  80. package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts.map +1 -0
  81. package/src/core/geom/3d/polygon/polygon3_compute_normal.js +60 -0
  82. package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts +34 -0
  83. package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts.map +1 -0
  84. package/src/core/geom/3d/polygon/polygon3_triangulate.js +94 -0
  85. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +8 -2
  86. package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -1
  87. package/src/core/geom/3d/quaternion/quat3_multiply.js +8 -2
  88. package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
  89. package/src/core/geom/3d/shape/ConvexHullShape3D.js +32 -2
  90. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts +23 -6
  91. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts.map +1 -1
  92. package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.js +268 -124
  93. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts +17 -0
  94. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts.map +1 -0
  95. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.js +44 -0
  96. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts +20 -0
  97. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts.map +1 -0
  98. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.js +40 -0
  99. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts +19 -0
  100. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts.map +1 -0
  101. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.js +34 -0
  102. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts +22 -0
  103. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts.map +1 -0
  104. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.js +37 -0
  105. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts +87 -0
  106. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts.map +1 -0
  107. package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.js +599 -0
  108. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts +32 -0
  109. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts.map +1 -1
  110. package/src/core/geom/3d/triangle/tri3_compute_signed_volume.js +66 -7
  111. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts +22 -1
  112. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts.map +1 -1
  113. package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.js +83 -38
  114. package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +554 -0
  115. package/src/core/geom/vec3/v3_cotangent.d.ts +6 -2
  116. package/src/core/geom/vec3/v3_cotangent.d.ts.map +1 -1
  117. package/src/core/geom/vec3/v3_cotangent.js +6 -2
  118. package/src/core/geom/vec3/v3_cross.d.ts +6 -2
  119. package/src/core/geom/vec3/v3_cross.d.ts.map +1 -1
  120. package/src/core/geom/vec3/v3_cross.js +6 -2
  121. package/src/core/geom/vec3/v3_multiply.d.ts +6 -2
  122. package/src/core/geom/vec3/v3_multiply.d.ts.map +1 -1
  123. package/src/core/geom/vec3/v3_multiply.js +6 -2
  124. package/src/core/geom/vec3/v3_subtract.d.ts +6 -2
  125. package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -1
  126. package/src/core/geom/vec3/v3_subtract.js +6 -2
  127. package/src/core/localization/Localization.js +1 -1
  128. package/src/core/math/random/randomSeed.d.ts +13 -0
  129. package/src/core/math/random/randomSeed.d.ts.map +1 -0
  130. package/src/core/math/random/randomSeed.js +16 -0
  131. package/src/core/math/spline/spline3_hermite_apply_transform.d.ts +50 -0
  132. package/src/core/math/spline/spline3_hermite_apply_transform.d.ts.map +1 -0
  133. package/src/core/math/spline/spline3_hermite_apply_transform.js +107 -0
  134. package/src/core/math/spline/spline3_hermite_reverse.d.ts +34 -0
  135. package/src/core/math/spline/spline3_hermite_reverse.d.ts.map +1 -0
  136. package/src/core/math/spline/spline3_hermite_reverse.js +49 -0
  137. package/src/core/math/statistics/hammersley_sequence.d.ts +17 -4
  138. package/src/core/math/statistics/hammersley_sequence.d.ts.map +1 -1
  139. package/src/core/math/statistics/hammersley_sequence.js +25 -16
  140. package/src/core/process/Future.d.ts.map +1 -1
  141. package/src/core/process/Future.js +12 -0
  142. package/src/core/process/PromiseWatcher.d.ts.map +1 -1
  143. package/src/core/process/PromiseWatcher.js +9 -2
  144. package/src/core/process/executor/ConcurrentExecutor.d.ts.map +1 -1
  145. package/src/core/process/executor/ConcurrentExecutor.js +12 -2
  146. package/src/core/process/worker/WorkerProxy.d.ts.map +1 -1
  147. package/src/core/process/worker/WorkerProxy.js +5 -2
  148. package/src/core/process/worker/extractTransferables.d.ts.map +1 -1
  149. package/src/core/process/worker/extractTransferables.js +4 -1
  150. package/src/engine/Engine.d.ts +12 -12
  151. package/src/engine/Engine.d.ts.map +1 -1
  152. package/src/engine/Engine.js +149 -10
  153. package/src/engine/EngineHarness.d.ts.map +1 -1
  154. package/src/engine/EngineHarness.js +4 -1
  155. package/src/engine/asset/loaders/ArrayBufferLoader.d.ts.map +1 -1
  156. package/src/engine/asset/loaders/ArrayBufferLoader.js +7 -0
  157. package/src/engine/ecs/speaker/lines/sets/LineSetDescription.d.ts.map +1 -1
  158. package/src/engine/ecs/speaker/lines/sets/LineSetDescription.js +3 -1
  159. package/src/engine/ecs/terrain/ecs/TerrainSystem.d.ts.map +1 -1
  160. package/src/engine/ecs/terrain/ecs/TerrainSystem.js +4 -1
  161. package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts +29 -0
  162. package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts.map +1 -1
  163. package/src/engine/ecs/terrain/tiles/TerrainTileManager.js +84 -23
  164. package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +446 -0
  165. package/src/engine/graphics/GraphicsEngine.d.ts +56 -7
  166. package/src/engine/graphics/GraphicsEngine.d.ts.map +1 -1
  167. package/src/engine/graphics/GraphicsEngine.js +196 -24
  168. package/src/engine/graphics/context/WebGLContextFailureReason.d.ts +9 -0
  169. package/src/engine/graphics/context/WebGLContextFailureReason.d.ts.map +1 -0
  170. package/src/engine/graphics/context/WebGLContextFailureReason.js +17 -0
  171. package/src/engine/graphics/context/WebGLContextMonitor.d.ts +106 -0
  172. package/src/engine/graphics/context/WebGLContextMonitor.d.ts.map +1 -0
  173. package/src/engine/graphics/context/WebGLContextMonitor.js +341 -0
  174. package/src/engine/graphics/context/WebGLContextState.d.ts +14 -0
  175. package/src/engine/graphics/context/WebGLContextState.d.ts.map +1 -0
  176. package/src/engine/graphics/context/WebGLContextState.js +24 -0
  177. package/src/engine/graphics/context/testWebGLContextLoss.d.ts +2 -0
  178. package/src/engine/graphics/context/testWebGLContextLoss.d.ts.map +1 -0
  179. package/src/engine/graphics/context/testWebGLContextLoss.js +696 -0
  180. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.d.ts.map +1 -1
  181. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.js +68 -32
  182. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts +14 -0
  183. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts.map +1 -0
  184. package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.js +41 -0
  185. package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +1 -1
  186. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.d.ts.map +1 -1
  187. package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js +5 -0
  188. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts +5 -1
  189. package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts.map +1 -1
  190. package/src/engine/graphics/ecs/trail2d/Trail2D.js +15 -0
  191. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts +1 -1
  192. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts.map +1 -1
  193. package/src/engine/graphics/ecs/trail2d/Trail2DSystem.js +2 -1
  194. package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts +4 -1
  195. package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts.map +1 -1
  196. package/src/engine/graphics/ecs/trail3d/Trail3D.js +488 -476
  197. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts +1 -1
  198. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts.map +1 -1
  199. package/src/engine/graphics/ecs/trail3d/Trail3DSystem.js +280 -253
  200. package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.d.ts.map +1 -1
  201. package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.js +3 -2
  202. package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.d.ts.map +1 -1
  203. package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.js +3 -1
  204. package/src/engine/graphics/geometry/computeMeshSurfaceArea.js +1 -1
  205. package/src/engine/graphics/load_and_set_cubemap_v0.d.ts +5 -0
  206. package/src/engine/graphics/load_and_set_cubemap_v0.d.ts.map +1 -1
  207. package/src/engine/graphics/load_and_set_cubemap_v0.js +26 -21
  208. package/src/engine/graphics/particles/particular/group/ParticleGroup.d.ts.map +1 -1
  209. package/src/engine/graphics/particles/particular/group/ParticleGroup.js +19 -8
  210. package/src/engine/graphics/particles/particular/group/optimizeCommandQueue.js +1 -1
  211. package/src/engine/graphics/texture/atlas/AtlasLookupTexture.d.ts.map +1 -1
  212. package/src/engine/graphics/texture/atlas/AtlasLookupTexture.js +10 -3
  213. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
  214. package/src/engine/graphics/texture/atlas/TextureAtlas.js +4 -1
  215. package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.d.ts.map +1 -1
  216. package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.js +11 -0
  217. package/src/engine/graphics/texture/cubemap/load_environment_map.d.ts.map +1 -1
  218. package/src/engine/graphics/texture/cubemap/load_environment_map.js +5 -2
  219. package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.d.ts.map +1 -1
  220. package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.js +26 -18
  221. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts +15 -2
  222. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts.map +1 -1
  223. package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.js +29 -3
  224. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts +11 -1
  225. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts.map +1 -1
  226. package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.js +158 -171
  227. package/src/engine/graphics/texture/sampler/sampler2d_paint.js +1 -1
  228. package/src/engine/graphics/texture/sampler/util/bitSet2Sampler2D.js +2 -2
  229. package/src/engine/input/ecs/util/TopDownCameraControllerHelper.d.ts.map +1 -1
  230. package/src/engine/input/ecs/util/TopDownCameraControllerHelper.js +10 -3
  231. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts +14 -0
  232. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts.map +1 -0
  233. package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.js +38 -0
  234. package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
  235. package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +433 -425
  236. package/src/engine/physics/constraint/solve_constraints.d.ts +4 -1
  237. package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
  238. package/src/engine/physics/constraint/solve_constraints.js +38 -13
  239. package/src/engine/physics/contact/ManifoldStore.d.ts +15 -3
  240. package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
  241. package/src/engine/physics/contact/ManifoldStore.js +15 -3
  242. package/src/engine/physics/ecs/Joint.d.ts +7 -4
  243. package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
  244. package/src/engine/physics/ecs/Joint.js +7 -4
  245. package/src/engine/physics/ecs/PhysicsSystem.d.ts +14 -0
  246. package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
  247. package/src/engine/physics/ecs/PhysicsSystem.js +16 -0
  248. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  249. package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +3 -3
  250. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts +15 -7
  251. package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -1
  252. package/src/engine/physics/narrowphase/convex_convex_manifold.js +33 -10
  253. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +6 -2
  254. package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
  255. package/src/engine/physics/narrowphase/refine_ray_concave.js +6 -2
  256. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +6 -2
  257. package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -1
  258. package/src/engine/physics/narrowphase/refine_ray_hit.js +6 -2
  259. package/src/engine/physics/queries/raycast.d.ts.map +1 -1
  260. package/src/engine/physics/queries/raycast.js +11 -4
  261. package/src/engine/save/storage/IndexedDBStorage.d.ts.map +1 -1
  262. package/src/engine/save/storage/IndexedDBStorage.js +21 -3
  263. package/src/engine/simulation/Ticker.d.ts +12 -0
  264. package/src/engine/simulation/Ticker.d.ts.map +1 -1
  265. package/src/engine/simulation/Ticker.js +18 -0
  266. package/src/engine/sound/simulation/AcousticSimulator.d.ts.map +1 -1
  267. package/src/engine/sound/simulation/AcousticSimulator.js +9 -1
  268. package/src/engine/sound/simulation/core/VolumeField.d.ts +6 -2
  269. package/src/engine/sound/simulation/core/VolumeField.d.ts.map +1 -1
  270. package/src/engine/sound/simulation/core/VolumeField.js +6 -2
  271. package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts +6 -2
  272. package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts.map +1 -1
  273. package/src/engine/sound/simulation/probe/AcousticProbeField.js +6 -2
  274. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts +6 -2
  275. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts.map +1 -1
  276. package/src/engine/sound/simulation/probe/acoustic_probe_transfer.js +6 -2
  277. package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts +3 -1
  278. package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts.map +1 -1
  279. package/src/engine/sound/simulation/probe/bakeProbeReflectors.js +3 -1
  280. package/src/engine/ui/GUIEngine.d.ts.map +1 -1
  281. package/src/engine/ui/GUIEngine.js +8 -1
  282. package/src/generation/theme/TerrainTheme.d.ts.map +1 -1
  283. package/src/generation/theme/TerrainTheme.js +2 -1
  284. package/src/generation/theme/ThemeEngine.d.ts.map +1 -1
  285. package/src/generation/theme/ThemeEngine.js +6 -5
  286. package/src/view/ViewGroup.d.ts.map +1 -1
  287. package/src/view/ViewGroup.js +9 -7
  288. package/src/view/graphics/WebGLContextFailureView.d.ts +19 -0
  289. package/src/view/graphics/WebGLContextFailureView.d.ts.map +1 -0
  290. package/src/view/graphics/WebGLContextFailureView.js +76 -0
  291. package/src/view/minimap/Minimap.d.ts +8 -0
  292. package/src/view/minimap/Minimap.d.ts.map +1 -1
  293. package/src/view/minimap/Minimap.js +16 -3
  294. package/src/view/minimap/dom/MinimapMarkerView.d.ts.map +1 -1
  295. package/src/view/minimap/dom/MinimapMarkerView.js +3 -1
@@ -0,0 +1,920 @@
1
+ # Correctness review — `engine/ecs`
2
+
3
+ Scope: every non-spec source file under `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/`
4
+ (208 source files). Reviewed 2026-08-06.
5
+
6
+ Findings marked **Confirmed** were either reproduced by executing the code or read directly
7
+ off the source with an unambiguous trace. `assert.*` is treated as absent (stripped in prod).
8
+
9
+ ---
10
+
11
+ ## Core (Entity / EntityComponentDataset / EntityManager / System)
12
+
13
+ ### ECS-1 `Entity.readFromDataset` never watches destruction, so a stale `Built` flag corrupts recycled entity ids
14
+ - **Severity**: CRITICAL
15
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/Entity.js:508-525`
16
+ - **Defect**: `build()` registers `#handleEntityDestroyed` on `EventType.EntityRemoved` when the
17
+ `WatchDestruction` flag is set (line 487-489), which is on by default. `readFromDataset` sets
18
+ `EntityFlags.Built` and binds the reference but registers no such listener, so the returned
19
+ `Entity` keeps reporting `isBuilt === true` forever after the underlying entity is removed.
20
+ Since `EntityComponentDataset.createEntity` recycles ids (`entityOccupancy.nextClearBit(0)`),
21
+ every subsequent operation on that `Entity` lands on a *different* entity.
22
+ - **Failure scenario** (reproduced):
23
+ ```js
24
+ const ds = new EntityComponentDataset();
25
+ ds.setComponentTypeMap([A, B]);
26
+ const id = ds.createEntity(); // id = 0
27
+ ds.addComponentToEntity(id, new A());
28
+ const e = Entity.readFromDataset(id, ds);
29
+ ds.removeEntity(id);
30
+ const recycled = ds.createEntity(); // id = 0 again, generation 1
31
+ e.isBuilt // => true (should be false)
32
+ e.add(new B()); // => writes B onto `recycled`, an unrelated entity
33
+ e.destroy(); // => destroys `recycled`
34
+ ```
35
+ Output observed: `recycled entity now has B: true`, `after e.destroy(), recycled entity exists: false`.
36
+ The `EntityReference` already carries the generation needed to detect this (`e.generation === 0`
37
+ vs `ds.getEntityGeneration(recycled) === 1`) but nothing consults it.
38
+ - **Fix**: in `readFromDataset`, mirror `build()` — after setting the `Built` flag, do
39
+ `if (r.getFlag(EntityFlags.WatchDestruction)) dataset.addEntityEventListener(entity, EventType.EntityRemoved, r.#handleEntityDestroyed, r);`
40
+ (needs a small private accessor since `#handleEntityDestroyed` is instance-private).
41
+ - **Confidence**: Confirmed
42
+
43
+ ### ECS-2 `setComponentTypeMap` leaves the dataset permanently corrupted when observer rebuild throws
44
+ - **Severity**: MAJOR
45
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/EntityComponentDataset.js:674-731`
46
+ - **Defect**: `setComponentTypeMap` mutates state in four steps — `updateComponentOccupancy` (716),
47
+ `updateComponentStores` (718), `updateObservers` (720), then `componentTypeMap` / `componentTypeCount`
48
+ (722, 730). `updateObservers` calls `observer.build(map)` for every observer sitting in a *surviving*
49
+ component's store; `EntityObserver.build` **throws** if any of that observer's component types is
50
+ absent from the new map (`EntityObserver.js:102-104`). Because occupancy and the component stores
51
+ have already been re-strided at that point, the throw leaves the dataset with the new stride but the
52
+ old `componentTypeMap` / `componentTypeCount` / `__type_to_index_map`.
53
+ - **Failure scenario** (reproduced):
54
+ ```js
55
+ ds.setComponentTypeMap([A, B]);
56
+ ds.addObserver(new EntityObserver([A, B], onComplete, onBroken), false);
57
+ ds.unregisterComponentType(A); // A has no live instances, so the removal check passes
58
+ ```
59
+ Observed: throws `Component type[0] was not found in the supplied map`, then
60
+ `componentTypeMap = ['A','B']`, `componentTypeCount = 2`, but `components.length = 1`.
61
+ The next `ds.addComponentToEntity(e, new B())` throws
62
+ `TypeError: Cannot read properties of undefined (reading '0')` — `components[1]` no longer exists.
63
+ The dataset cannot be used again.
64
+ - **Fix**: validate first, mutate second. Before touching occupancy/stores, iterate every registered
65
+ observer and check `map` contains all of `observer.componentTypes`; throw there (dataset untouched).
66
+ Observers that watch only removed types should be disconnected explicitly (`observer.dataset = null`)
67
+ rather than silently dropped from the stores.
68
+ - **Confidence**: Confirmed
69
+
70
+ ### ECS-3 `traverseEntitiesCompactedFiltered` uses `-1` as a component index for unregistered classes
71
+ - **Severity**: MAJOR
72
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/EntityComponentDataset.js:1990-2020`
73
+ - **Defect**: `componentIndices[i] = this.computeComponentTypeIndex(klass)` stores `-1` for a class
74
+ that is not registered in this dataset, and nothing filters it out. The loop then does
75
+ `componentOccupancy.get(entityOccupancyAddress + (-1))` — reading the *last* component bit of the
76
+ *previous* entity — and, when that bit happens to be set, `this.components[-1][i]`.
77
+ `this.components[-1]` is `undefined`. (In production `BitSet.get`'s `assert.isNonNegativeInteger`
78
+ is stripped, so it silently returns the neighbouring entity's bit instead of throwing.)
79
+ - **Failure scenario** (reproduced): dataset registered with `[A, B]`; entity 0 has both A and B;
80
+ entity 1 has A. `ds.traverseEntitiesCompactedFiltered([C, A], visitor)` where `C` was never
81
+ registered. With asserts on it throws `bitIndex must be >= 0, instead was -1`. With asserts stripped,
82
+ entity 1 reads bit `2 - 1 = 1` (entity 0's B bit, which is set) and then dereferences
83
+ `this.components[-1][1]` → `TypeError: Cannot read properties of undefined`.
84
+ - **Fix**: skip unregistered classes when building `componentIndices` (build a compacted array of
85
+ valid indices only), the same way `traverseEntities` returns early on `-1`.
86
+ - **Confidence**: Confirmed
87
+
88
+ ### ECS-4 Bare `.sort()` on component indices is lexicographic
89
+ - **Severity**: MINOR
90
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/EntityComponentDataset.js:1996`
91
+ - **Defect**: `componentIndices.sort()` with no comparator sorts numbers as strings. The comment
92
+ says "sort component indices array for faster traversal"; with 10 or more registered component
93
+ types the array is not sorted at all, and the `components` array handed to the visitor comes out
94
+ in a scrambled, size-dependent order.
95
+ - **Failure scenario** (reproduced): 12 registered component types; an entity holds instances of
96
+ type index 2 and type index 10. The visitor receives `[K10, K2]` instead of `[K2, K10]`. Any
97
+ consumer of this "main utility … to facilitate serialization" method that depends on a stable
98
+ component ordering (e.g. a byte-for-byte save comparison or a diff) sees the order flip as soon
99
+ as the dataset crosses 10 component types.
100
+ - **Fix**: `componentIndices.sort((a, b) => a - b);`
101
+ - **Confidence**: Confirmed
102
+
103
+ ### ECS-5 `Entity.getComponentSafe` throws `ReferenceError` — its helper is neither imported nor exported
104
+ - **Severity**: MINOR
105
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/Entity.js:256`
106
+ - **Defect**: The error path calls `computeComponentClassName(klass)`, but `Entity.js` imports only
107
+ `assert`, `Signal`, `EntityFlags`, `EntityReference`, `EventType`. Worse,
108
+ `computeComponentClassName.js` declares the function without any `export` statement at all, so
109
+ there is nothing to import.
110
+ - **Failure scenario** (reproduced): `new Entity().getComponentSafe(SomeClass)` throws
111
+ `ReferenceError: computeComponentClassName is not defined` instead of the documented
112
+ `Error: Component of given class 'SomeClass' not found`. The diagnostic naming the missing
113
+ component — the entire point of the method over `getComponent` — is lost.
114
+ - **Fix**: add `export` to `computeComponentClassName.js` and import it in `Entity.js`.
115
+ - **Confidence**: Confirmed
116
+
117
+ ### ECS-6 Re-`build()`ing an `Entity` whose components drifted orphans the old entity and mis-aims its destruction watcher
118
+ - **Severity**: MINOR
119
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/Entity.js:442-493`
120
+ - **Defect**: `build()` returns early only when `checkExistingComponents` passes. Otherwise it falls
121
+ through and calls `dataset.createEntity()` **without** removing the entity it was previously bound
122
+ to and without unregistering the `EntityRemoved` listener registered on the old id at line 488.
123
+ - **Failure scenario** (reproduced):
124
+ ```js
125
+ const e = new Entity(); e.add(new A());
126
+ const id1 = e.build(ds); // 0
127
+ ds.removeComponentFromEntity(id1, A); // a system strips the component directly
128
+ const id2 = e.build(ds); // 1 -- entity 0 is now orphaned, entityCount = 2
129
+ ds.removeEntity(id1); // fires the stale watcher
130
+ e.isBuilt // => false, although entity 1 is alive
131
+ e.destroy() // => false; entity 1 leaks permanently
132
+ ```
133
+ - **Fix**: when the early-return check fails but `EntityFlags.Built` is set, call `this.destroy()`
134
+ first so the previous entity and its listener are released before rebinding.
135
+ - **Confidence**: Confirmed
136
+
137
+ ### ECS-7 `EntityManager.removeSystem` never clears `system.entityManager`
138
+ - **Severity**: MINOR
139
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/EntityManager.js:590-631`
140
+ - **Defect**: `startSystem` refuses to start a system whose `entityManager` is set to a different
141
+ manager (`EntityManager.js:739-741`). Neither `removeSystem` nor `stopSystem` resets the field.
142
+ - **Failure scenario**: `em1.removeSystem(s)` then `em2.addSystem(s); em2.startup()` →
143
+ `startSystem` throws `System is bound to another EntityManager`, caught at line 763, so
144
+ `errorCallback` fires and `em2.state` becomes `EntityManagerState.Failed`; the whole manager
145
+ never reaches `Running`. Reusing a system instance across manager instances (level reload, test
146
+ fixtures) is impossible.
147
+ - **Fix**: set `system.entityManager = null` in `removeSystem` after `stopSystem` resolves.
148
+ - **Confidence**: Confirmed
149
+
150
+ ### ECS-8 `system_validate_class` duplicate-component backtrace is off by one
151
+ - **Severity**: MINOR
152
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/system_validate_class.js:49`
153
+ - **Defect**: `for (let j = 0; j < i - 1; j++)` never compares `components_used[i]` against
154
+ `components_used[i - 1]`, so adjacent duplicate resource specifications slip through.
155
+ - **Failure scenario**: `components_used = [Spec(Transform, Read), Spec(Transform, Write)]` passes
156
+ validation. `System.getAccessForComponent` (`System.js:110-117`) `break`s at the first match, so
157
+ the `Write` access is silently discarded, and `EntityManager.updateExecutionOrder`'s `scoreSystem`
158
+ scores the system as a reader (`attached_edge_count` instead of `× 2`) — the system is scheduled
159
+ after the systems that should run after it.
160
+ - **Fix**: `for (let j = 0; j < i; j++)`.
161
+ - **Confidence**: Confirmed
162
+
163
+ ### ECS-48 `EntityManager.startup` reports `Running` while a system added during startup is still `STARTING`
164
+ - **Severity**: MINOR
165
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/EntityManager.js:795,814,567-574`
166
+ - **Defect**: `startup()` captures `expectedReadyCount = systems.length` before the start loop, then
167
+ `finalizeStartup()` fires when `readyCount === expectedReadyCount`. A system's own `startup()` may
168
+ call `entityManager.addSystem(...)`, which pushes onto the same `systems` array the `for…of` loop is
169
+ walking, so the loop does start the newcomer — but the target count is stale by one. Separately,
170
+ `addSystem` only auto-starts when `this.state === EntityManagerState.Running`; during
171
+ `EntityManagerState.Starting` it returns `Promise.resolve()` and never calls `startSystem`, so a
172
+ system added after the loop has drained is never started at all.
173
+ - **Failure scenario**: `SGMeshSystem.startup(em)` does
174
+ `if (!em.hasSystem(TransformAttachmentSystem)) await em.addSystem(new TransformAttachmentSystem());`
175
+ (`engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js:94-96`). With N systems registered,
176
+ `expectedReadyCount = N`; once the original N have reported ready, `finalizeStartup()` sets
177
+ `state = Running` and invokes `readyCallback()` while `TransformAttachmentSystem` is still in
178
+ `STARTING`. The caller begins `simulate()`; `updateExecutionOrder` skips non-`RUNNING` systems and its
179
+ observer has not been added to the dataset, so attachments silently do not update for those frames.
180
+ If the same `addSystem` were made from after an `await` (once the start loop has drained) the system
181
+ would never start at all, while its promise still resolves.
182
+ - **Fix**: read `systems.length` live at each `systemReady` (`readyCount === systems.length`) instead of
183
+ a captured count, and treat `EntityManagerState.Starting` like `Running` in `addSystem`'s auto-start
184
+ branch.
185
+ - **Confidence**: Confirmed
186
+
187
+ ---
188
+
189
+ ## Fog of war
190
+
191
+ ### ECS-9 `FogOfWarSystem.shutdown` removes none of the three render-loop subscriptions it made in `startup`
192
+ - **Severity**: MAJOR
193
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/fow/FogOfWarSystem.js:162-219`
194
+ - **Defect**: `startup` registers three anonymous closures on `graphics.on.preRender`,
195
+ `graphics.on.buffersRendered` and `graphics.on.postRender`. `shutdown` only calls
196
+ `this.graphics.layerComposer.remove(this.compositLayer)`. Because the handlers are inline
197
+ functions with no retained reference, they can never be removed even by a caller.
198
+ - **Failure scenario**: `entityManager.shutdown()` (or `removeSystem(fogOfWarSystem)`) removes the
199
+ composit layer, but on the very next frame the `preRender` handler still runs
200
+ `compositerLayer.enable()` on the removed layer and `depthFrameBuffer.referenceCount++`, and
201
+ `buffersRendered` still runs `fogOfWarRenderer.render(renderer, camera, scene, compositerLayer.renderTarget)`
202
+ into a render target that is no longer composited — burning a full-screen pass every frame for the
203
+ lifetime of the `GraphicsEngine`. The closures also pin `entityManager`, `graphics` and the
204
+ `FogOfWarRenderer` (its `ShaderMaterial`, `Mesh` and `Scene` are never disposed). Starting a second
205
+ `FogOfWarSystem` — e.g. a level reload on a shared `GraphicsEngine` — stacks a second set of handlers,
206
+ so each frame renders the fog twice.
207
+ - **Fix**: hoist the three handlers into named fields (or wrap them in `SignalBinding`s) in `startup`,
208
+ `remove` them in `shutdown`, and dispose `fogOfWarRenderer.material` / `fogOfWarRenderer.quad`.
209
+ - **Confidence**: Confirmed
210
+
211
+ ### ECS-10 A revealer on the *wrong* team still reveals fog whenever its radius changes
212
+ - **Severity**: MAJOR
213
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/fow/FogOfWarRevealerSystem.js:52-97`
214
+ - **Defect**: The team check lives only in `attemptToWatch`, which gates the *position* watcher.
215
+ `update()` itself contains no team test, and `radiusWatcher` (line 93-97) is linked
216
+ unconditionally for every linked entity regardless of team.
217
+ - **Failure scenario**: `new FogOfWarRevealerSystem(/* team */ 1)`. An enemy unit with
218
+ `Team = 2`, `Transform`, `FogOfWarRevealer` links: `attemptToWatch(2)` correctly leaves
219
+ `positionWatcher` unlinked, but `radiusWatcher.link()` runs anyway. The enemy then receives a
220
+ vision buff (`revealer.radius.set(8)`) — `revealer.radius.onChanged` fires `update()`, which maps
221
+ the *enemy's* position to the grid and calls `fow.reveal(...)` on the player's fog. The player's
222
+ map is permanently uncovered at the enemy's location.
223
+ - **Fix**: put the team check inside `update()` (`if (team.getValue() !== visibilityTeam) return;`),
224
+ or link/unlink `radiusWatcher` from `attemptToWatch` alongside `positionWatcher`.
225
+ - **Confidence**: Confirmed
226
+
227
+ ### ECS-11 `computeRevealedGridBoundingRectangle` returns an inverted AABB when nothing is revealed
228
+ - **Severity**: MINOR
229
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/fow/FogOfWar.js:532-563`
230
+ - **Defect**: `x0`/`y0` seed to `width - 1` / `height - 1` and `x1`/`y1` to `0`. If no cell differs
231
+ from 255 the loop body never runs and those seeds are written out unchanged, producing
232
+ `x0 > x1` and `y0 > y1`.
233
+ - **Failure scenario**: a freshly resized 64×64 fog (sampler 66×66, all 255) →
234
+ `result.set(65, 65, 0, 0)`. `MinimapFogOfWar.updateFocusArea`
235
+ (`view/minimap/gl/MinimapFogOfWar.js:87`) then does `bounds.grow(2)`, `bounds.move(-0.5, 0.5)`
236
+ and multiplies by the scale, feeding a negative-extent rectangle into `enforceAspectRatio` — the
237
+ minimap focus area is garbage on the first frame of any map before the first reveal.
238
+ - **Fix**: track a `found` flag and, when nothing is revealed, emit an explicitly empty rectangle
239
+ (`result.set(0, 0, 0, 0)`) or leave `result` untouched and return a boolean.
240
+ - **Confidence**: Confirmed
241
+
242
+ ### ECS-12 `getWorldClearance` reads the distance sampler unclamped
243
+ - **Severity**: MINOR
244
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/fow/FogOfWar.js:677-684`
245
+ - **Defect**: Its sibling `computeAABBVisibility` clamps the sample position before
246
+ `distanceSampler.readChannel` (line 628-629); `getWorldClearance` does not.
247
+ `Sampler2D.readChannel` computes `(y * width + x) * itemSize + channel` with no bounds check
248
+ (the `assert`s are stripped), so an x outside `[0, width)` silently indexes into an adjacent row.
249
+ - **Failure scenario**: `FogOfWarVisibilityPredicate.test(x, y, z)` (line 41) is called with an
250
+ object's world position. For a 64×64 fog with `scale = 1`, an object one cell east of the map at
251
+ grid row 20 produces `sampleX = 65`, `sampleY = 21`, giving index `21 * 66 + 65 = 1451` — which is
252
+ cell `(1, 22)`, the *west* border of the row below. The object's visibility is decided by an
253
+ unrelated cell; where that cell is revealed, an off-map object that should be hidden renders.
254
+ - **Fix**: clamp exactly as `computeAABBVisibility` does, using the sampler's own dimensions.
255
+ - **Confidence**: Confirmed
256
+
257
+ ### ECS-13 `computeAABBVisibility` clamps to `size - 1` but the distance sampler is `size + 2` wide
258
+ - **Severity**: MINOR
259
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/fow/FogOfWar.js:628-629`
260
+ - **Defect**: `worldToSamplePosition` emits sampler-space coordinates with the +1 border offset
261
+ baked in, so the real world area occupies sampler columns `[1, size.x]`. The clamp bound is
262
+ `this.size.x - 1`, two cells short of the valid maximum.
263
+ - **Failure scenario**: 64×64 fog, `scale = 1`. `worldToSamplePosition` maps the world extent onto
264
+ sample coordinates `[1, 64]`, but the clamp caps at `63`. An object at the far eastern edge maps to
265
+ `sampleX = 64` and is read at column `63` instead — the outermost column/row of the map takes its
266
+ clearance from the cell one step inward. An unexplored strip along the east/south map edge therefore
267
+ reports the clearance of the explored cell beside it, and objects standing there are drawn through
268
+ the fog.
269
+ - **Fix**: clamp against the sampler, not the logical size: `clamp(sampleX, 0, this.sampler.width - 1)`.
270
+ - **Confidence**: Confirmed
271
+
272
+ ---
273
+
274
+ ## Terrain
275
+
276
+ ### ECS-14 `getTileByWorldPosition2D` reads the vertical axis as the grid row
277
+ - **Severity**: CRITICAL
278
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/tiles/TerrainTileManager.js:461-472`
279
+ - **Defect**: The point is packed as `Float32Array.of(x, 0, y)` — so the top-down Y lands at index
280
+ **2** — inverse-transformed, and then passed as `getRawTileByPosition(v3[0], v3[1])`. `v3[1]` is
281
+ the vertical component. Separately, `getRawTileByPosition` divides by `tileSize`, which is in grid
282
+ units, while `v3` is in object units, so `this.scale` must be divided out first.
283
+ - **Failure scenario**: 64×64 terrain, `gridScale = 1`, tile size 32 → a 2×2 tile grid, identity
284
+ transform. `getTileByWorldPosition2D(40, 40)` → `getRawTileByPosition(40, 0)` →
285
+ `tileX = 1, tileY = floor(0 / 32) = 0` → tile **(1,0)** instead of **(1,1)**. With an identity
286
+ transform the row is *always* 0, so every lookup collapses onto the top row of tiles.
287
+ `obtainTileAtWorldPosition2D` then builds the wrong tile.
288
+ - **Fix**: `return this.getRawTileByPosition(v3[0] / this.scale.x, v3[2] / this.scale.y);`
289
+ - **Confidence**: Confirmed
290
+
291
+ ### ECS-15 `traverseVisibleTiles` sums per-frustum hit counts but always writes at offset 0
292
+ - **Severity**: MAJOR
293
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/ecs/TerrainSystem.js:265-290`
294
+ - **Defect**: `tile_count` is initialised to 0 and passed as the `result_offset` to
295
+ `bvh_query_user_data_intersects_frustum` on every iteration but never advanced, so each frustum
296
+ overwrites the previous frustum's results from index 0. `found_tiles` meanwhile accumulates the
297
+ per-frustum counts, and the consumer loop iterates to that sum.
298
+ - **Failure scenario**: two active `Camera` components (`CameraSystem.getActiveFrustums` yields one
299
+ frustum per active camera). Frustum A returns 6 tiles, frustum B returns 4 → `tiles` holds 6
300
+ entries but `found_tiles = 10`. Indices 6..9 are `undefined` →
301
+ `tileManager.tiles[undefined] === undefined` → the callback dereferences `tile.isBuilt` /
302
+ `tile.gridPosition` → `TypeError` out of `TerrainSystem.update` every frame.
303
+ - **Fix**: `tile_count += bvh_query_user_data_intersects_frustum(tiles, tile_count, ...)` and iterate
304
+ to `tile_count`.
305
+ - **Confidence**: Confirmed
306
+
307
+ ### ECS-16 `TerrainOverlay.paintSampler` passes the overlay instead of its sampler
308
+ - **Severity**: MAJOR
309
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/overlay/TerrainOverlay.js:321`
310
+ - **Defect**: `sampler2d_paint(this, scaled_source, ...)` supplies the `TerrainOverlay` as the
311
+ destination `Sampler2D`. The pixel data lives on `this.sampler`.
312
+ - **Failure scenario**: `sampler2d_paint` computes
313
+ `_w = Math.min(width, source.width - 0, undefined - dx)` → `NaN`, so `for (x = 0; x < NaN; ...)`
314
+ never executes. Nothing is written and no error is raised — `paintImage`/`paintSampler` are silent
315
+ no-ops, while `texture.needsUpdate = true` re-uploads unchanged pixels every call.
316
+ - **Fix**: `sampler2d_paint(this.sampler, scaled_source, 0, 0, dx, dy, dWidth, dHeight);`
317
+ - **Confidence**: Confirmed
318
+
319
+ ### ECS-17 Tile rebuild leaks the previous `BufferGeometry`
320
+ - **Severity**: MAJOR
321
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/tiles/TerrainTile.js:551,586`
322
+ - **Defect**: `build()` assigns `this.geometry = g` / `mesh.geometry = g` without disposing the
323
+ geometry it replaces. The only disposal path, `dispose()`, early-returns on `if (!this.isBuilt) return;`
324
+ — and `TerrainTileManager.rebuildTilesByUV` (`tiles/TerrainTileManager.js:300`) clears `isBuilt`
325
+ while the geometry is still attached, so the old geometry can never be released.
326
+ - **Failure scenario**: terrain height editing calls `rebuildTilesByUV`, clearing `isBuilt` on every
327
+ overlapping tile; `TerrainSystem.update` then re-invokes `tile.build(tileData)`, installing a fresh
328
+ geometry and orphaning the old VBO/IBO. For a 4-tile region at `resolution = 4` with 32×32 tiles
329
+ (16 384 verts, ~590 KB of attributes each) that is ~2.4 MB of VRAM per edit, never reclaimed.
330
+ `destroyTiles()` hits the same early return.
331
+ - **Fix**: dispose the previous geometry at the top of `build()`, and replace the `!isBuilt` early
332
+ return in `dispose()` with a `geometry !== null` check.
333
+ - **Confidence**: Confirmed
334
+
335
+ ### ECS-18 `TerrainTile.stitching` flags are never reset, so re-stitched seams copy instead of average
336
+ - **Severity**: MAJOR
337
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/tiles/TerrainTile.js:99-110` (consumed at `:279`, `:333`)
338
+ - **Defect**: Neither `build()`, `dispose()`, nor `TerrainTileManager.rebuildTilesByUV` clears the
339
+ per-neighbour `stitching` state. `stitchHorizontal`/`stitchVertical` branch on those flags: when one
340
+ side is already marked stitched, the other side's normals are *copied* wholesale rather than averaged.
341
+ - **Failure scenario**: tiles A(0,0) and B(1,0) built and stitched, so `A.stitching.right === true`.
342
+ A height edit clears `isBuilt` on both but leaves the flags. A rebuilds first — `stitchTile` sees B
343
+ unbuilt and skips. B rebuilds — `stitchHorizontal(A, B)` finds the stale
344
+ `left.stitching.right === true` and takes the copy branch, writing A's one-sided edge normals into B.
345
+ The shared edge now carries normals computed from A's triangles only: a visible lighting crease down
346
+ every rebuilt tile boundary after any terrain edit.
347
+ - **Fix**: reset all eight `stitching` fields in `TerrainTile.build()` and `dispose()`, and clear the
348
+ neighbours' matching flags in `rebuildTilesByUV`.
349
+ - **Confidence**: Confirmed
350
+
351
+ ### ECS-19 `ClingToTerrain.fromJSON` defaults `rotationSpeed` to `Infinity`; the field initialiser is `3`
352
+ - **Severity**: MAJOR
353
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/ecs/cling/ClingToTerrain.js:54`
354
+ - **Defect**: The class field is `rotationSpeed = 3`, and `ClingToTerrainSerializationUpgrader_0_1.js:22`
355
+ explicitly writes `3`, calling it "the default value from the ClingToTerrain class". `fromJSON`
356
+ destructures with `Number.POSITIVE_INFINITY` as the default.
357
+ - **Failure scenario**: a blueprint carrying `{"ClingToTerrain": {"normalAlign": true}}` with no
358
+ `rotationSpeed` → `rotationSpeed = Infinity`. In `ClingToTerrainSystem.processRaycastHit:272`
359
+ `Number.isFinite` is false → `angularLimit = Infinity` → `alignToVector` computes
360
+ `t = clamp01(Infinity / angle) = 1` → the entity snaps to the terrain normal instantly every frame
361
+ instead of easing at 3 rad/s. The same entity constructed in code, or loaded from binary v0 through
362
+ the upgrader, eases correctly — the behaviour differs by load path.
363
+ - **Fix**: use `3` as the `fromJSON` destructuring default.
364
+ - **Confidence**: Confirmed
365
+
366
+ ### ECS-20 `Terrain` JSON and binary paths persist disjoint field sets, and disagree on a default
367
+ - **Severity**: MAJOR
368
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/ecs/Terrain.js:930,981`;
369
+ `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/serialization/TerrainSerializationAdapter.js:29,144`
370
+ - **Defect**: Three fields are handled by exactly one of the two paths.
371
+ `gridTransformKind` and `lightMapURL` are written into `extra` by `serialize`/`deserialize` but are
372
+ absent from `toJSON`/`fromJSON`. `overlay.baseTileImage` is written by `toJSON` (as
373
+ `overlayTileImage`) and restored by `fromJSON`, but is absent from the binary adapter. The two paths
374
+ also disagree on the `gridTransformKind` default: `deserialize` falls back to
375
+ `GridTransformKind.Legacy` (`:144`) while the class field initialiser is `GridTransformKind.Direct`
376
+ (`Terrain.js:76`), which `fromJSON` leaves in place.
377
+ - **Failure scenario**: a terrain with `gridTransformKind = Legacy`, `size = (64,64)`, `gridScale = 1`,
378
+ saved via `toJSON` and reloaded. `fromJSON` leaves it at `Direct`, so `buildGridTransform` produces
379
+ `scale = 1, offset = 0.5` instead of `scale = 64/63 ≈ 1.0159, offset = 0`. `mapPointGrid2World(63, 63)`
380
+ returns `(63.5, ·, 63.5)` instead of `(64, ·, 64)` — every grid-anchored entity, marker and combat
381
+ cell is displaced, growing to more than half a cell at the far edge. The same round-trip drops
382
+ `lightMapURL`, so `build()` skips the `aoMap` load and the terrain renders unshadowed. Symmetrically,
383
+ a custom `overlay.baseTileImage` saved to binary reloads as the default `WHITE_PIXEL_DATA_URL`.
384
+ - **Fix**: add `gridTransformKind` and `lightMapURL` to `toJSON`/`fromJSON` (with the same default the
385
+ binary path uses), and write `overlay.baseTileImage` into the binary adapter's `extra` blob.
386
+ - **Confidence**: Confirmed
387
+
388
+ ### ECS-21 `convertHeightMap2NormalMap` returns normals tilted uphill — both gradients unnegated
389
+ - **Severity**: MAJOR
390
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/grid/HeightSampler2NormalSamplerSoft.js:54-56`
391
+ - **Defect**: For a height field `h(x, y)` the surface normal is `normalize(-∂h/∂x, -∂h/∂y, k)`. The
392
+ Sobel gradients `dX`/`dY` are computed with the correct sign but written out unnegated
393
+ (`_x = dX / magnitude`, `_y = dY / magnitude`).
394
+ - **Failure scenario**: a ramp `h(x, y) = x`. At an interior texel `dX = 8`, `dY = 0`,
395
+ `magnitude = sqrt(64.25) ≈ 8.0156` → the function returns `(+0.998, 0, +0.0624)`. The true normal for
396
+ a surface rising in `+x` leans toward `−x`: `(−0.998, 0, +0.0624)`. Every slope gets a normal pointing
397
+ up-slope, so any lighting or slope test built on this map is exactly reversed on both horizontal
398
+ axes. Flat regions are unaffected, which is why it is not obvious.
399
+ - **Fix**: `const _x = -dX / magnitude; const _y = -dY / magnitude;`
400
+ - **Confidence**: Likely (no in-repo consumer — public library surface; the maths is unambiguous)
401
+
402
+ ### ECS-22 `Terrain.heightTexture` receives the `Sampler2D` object as its `height` argument
403
+ - **Severity**: MINOR
404
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/ecs/Terrain.js:145`
405
+ - **Defect**: `new DataTexture(this.samplerHeight.data, this.samplerHeight.width, this.samplerHeight, RedFormat, FloatType)`
406
+ — the third parameter of `DataTexture(data, width, height, …)` gets the sampler instance rather than
407
+ `this.samplerHeight.height`. Compare `graphics/ecs/water/Water.js:138`.
408
+ - **Failure scenario**: immediately after `new Terrain()`, `terrain.heightTexture.image` is
409
+ `{data: Float32Array(1), width: 1, height: <Sampler2D>}`. Any consumer that binds `heightTexture`
410
+ before `build()` runs `updateHeightTexture()` (which repairs `image.width`/`image.height` at
411
+ `:721-722`) uploads with a non-numeric height — `gl.texImage2D` raises `GL_INVALID_VALUE`.
412
+ - **Fix**: pass `this.samplerHeight.height`.
413
+ - **Confidence**: Confirmed
414
+
415
+ ### ECS-23 `heightMap2AOMap` leaks the intermediate denoise source texture
416
+ - **Severity**: MINOR
417
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/grid/HeightMap2AOMap.js:77`
418
+ - **Defect**: `filter_result_to_texture(raw_ao.array, width, height)` creates a `DataTexture` that is
419
+ uploaded by the denoise `ImageFilter` pass and then dropped without `.dispose()`. The sibling
420
+ `pyramid_texture` *is* disposed at `:91`, so the omission is unintentional.
421
+ - **Failure scenario**: `terrain.buildLightMap()` runs per generated map. For a 256×256 terrain at the
422
+ default `quality = 4` the AO resolution is 1024×1024, so each bake orphans a 1024×1024 RGBA8 GPU
423
+ texture (~4 MB VRAM) for the lifetime of the WebGL context. Each new run accumulates another.
424
+ - **Fix**: hold the texture in a local and `.dispose()` it after the `smooth_ao` `ImageFilter` returns.
425
+ - **Confidence**: Confirmed
426
+
427
+ ### ECS-24 `loadVisibleTerrainTiles` does not reset its counters on retry, and can report success early
428
+ - **Severity**: MINOR
429
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/util/loadVisibleTerrainTiles.js:70-84`
430
+ - **Defect**: `handleFailedTile` bumps `attempt` and re-runs `init()`, which recomputes `totalTiles` and
431
+ restarts `start_time`, but `loadedTiles` and `failedTiles` carry over from the aborted attempt. The
432
+ `attemptId !== attempt` guard only suppresses *stale* callbacks; it does not roll back what they
433
+ already counted.
434
+ - **Failure scenario**: 20 visible tiles; 12 resolve, then one rejects
435
+ (`'Original tile was destroyed during build process'`). `init()` re-runs with `totalTiles = 20` and
436
+ `loadedTiles = 12`. The 12 already-built tiles resolve on the next microtask → `loadedTiles = 24 ≥ 20`
437
+ → `cycleFunction` returns `TaskSignal.EndSuccess` while 8 tiles are still mid-build. The loading
438
+ screen closes and the scene shows 8 tiles with no geometry.
439
+ - **Fix**: reset `loadedTiles = 0` and `failedTiles = 0` at the top of `init()`.
440
+ - **Confidence**: Confirmed
441
+
442
+ ### ECS-25 `TerrainSystem.mapPointGrid2World` hardcodes the Legacy transform and drops the offset
443
+ - **Severity**: MINOR
444
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/terrain/ecs/TerrainSystem.js:81-87,186-187`
445
+ - **Defect**: `link()` computes `gridScaleX = g_w * gridScale / (g_w - 1)` unconditionally — the
446
+ `GridTransformKind.Legacy` formula — ignoring `component.gridTransformKind`, and
447
+ `mapPointGrid2World` applies no offset. `Terrain.mapPointGrid2World` (`Terrain.js:555`) uses
448
+ `gridTransform`, whose `Direct` branch (the class default) is `scale = gridScale, offset = gridScale / 2`.
449
+ - **Failure scenario**: 64×64 terrain, `gridScale = 1`, default `Direct` kind.
450
+ `terrain.mapPointGrid2World(0, 0, v3)` → `(0.5, ·, 0.5)`; `terrainSystem.mapPointGrid2World(0, 0, v3)`
451
+ → `(0, ·, 0)`. At `x = 63`: `63.5` vs `64.0`. Two public methods documented to perform the same
452
+ mapping differ by half a cell at the origin and diverge with distance. `gridScaleX/Y` are also
453
+ system-global, so with two `Terrain` entities they hold whichever linked last.
454
+ - **Fix**: delete `TerrainSystem.mapPointGrid2World`/`gridScaleX`/`gridScaleY` and route callers to
455
+ `Terrain.mapPointGrid2World`, or read `terrain.gridTransform` directly.
456
+ - **Confidence**: Confirmed
457
+
458
+ ---
459
+
460
+ ## Animation / dynamic actions / evaluation
461
+
462
+ ### ECS-26 `AnimationOptimizer` writes its results to itself — optimization is a silent no-op
463
+ - **Severity**: MAJOR
464
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/animation/AnimationOptimizer.js:109-114`
465
+ - **Defect**: `optimizeTrack(track)` is a port of three.js `KeyframeTrack.prototype.optimize()`, where
466
+ `this` *is* the track. Here `this` is the `AnimationOptimizer`, so the compacted arrays are assigned
467
+ to `this.times` / `this.values` on the optimizer; the `track` (whose arrays were only *copied* at
468
+ lines 108-109) is never modified, and the method returns `this`.
469
+ - **Failure scenario**: `GLTFAssetLoader.js:426` calls `animationOptimizer.optimize(animation)` on
470
+ every animation of every loaded GLTF, using the module-level singleton. Load a GLTF whose track has
471
+ 1000 keyframes of which 900 are redundant: the track still has 1000 keyframes afterwards. The
472
+ singleton also permanently retains `this.times`/`this.values` — full copies of the last-optimized
473
+ track's typed arrays, megabytes for a skeletal clip — for the module's lifetime.
474
+ - **Fix**: write back to the track (`track.times = ...; track.values = ...;`) and return `track`.
475
+ - **Confidence**: Confirmed
476
+
477
+ ### ECS-27 `AnimationOptimizer`: `time[0]` should be `times[0]`
478
+ - **Severity**: MINOR
479
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/animation/AnimationOptimizer.js:36`
480
+ - **Defect**: `if (time !== timeNext && (i !== 1 || time !== time[0]))` — `time` is a number, so
481
+ `time[0]` is `undefined` and the sub-condition is always true. three.js has `times[0]`.
482
+ - **Failure scenario**: a track with `times = [0, 0, 1, 2]` and distinct values at index 1. three.js
483
+ drops keyframe 1 as a duplicate of the keyframe at t=0; this code keeps it, leaving two keyframes
484
+ scheduled at t=0 in the "optimized" output.
485
+ - **Fix**: `time !== times[0]`.
486
+ - **Confidence**: Confirmed
487
+
488
+ ### ECS-28 `DynamicRuleDescription.equals` calls `.equals` on a primitive string
489
+ - **Severity**: MAJOR
490
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/dynamic_actions/rules/DynamicRuleDescription.js:55`
491
+ - **Defect**: `id` is a primitive string (`UUID.string()` returns `UUID.v4().toString()`, and
492
+ `fromJSON` assigns the JSON string directly), but `equals` does `return this.id.equals(other.id)`.
493
+ `String.prototype.equals` does not exist.
494
+ - **Failure scenario**:
495
+ `DynamicRuleDescription.fromJSON({id:"combat.long-idle.ai", ...}).equals(otherRule)` →
496
+ `TypeError: this.id.equals is not a function`. Any container relying on the `equals` protocol
497
+ (a default-configured `HashMap`/`HashSet` keyed by rules) throws on the first lookup.
498
+ `DynamicActorSystem` only escapes because `__global_last_used_times` supplies a custom
499
+ `keyEqualityFunction`.
500
+ - **Fix**: `return this.id === other.id;`
501
+ - **Confidence**: Confirmed
502
+
503
+ ### ECS-29 Pooled `DynamicActorSystem` context keeps the previous entity's `RuleExecution`
504
+ - **Severity**: MAJOR
505
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/dynamic_actions/DynamicActorSystem.js:43,74-90`
506
+ - **Defect**: `Context` instances are pooled by `AbstractContextSystem`, and the pool's reset callback
507
+ is an empty `// TODO` (`system/AbstractContextSystem.js:37-42`). `Context.link()` resets
508
+ `next_idle_event_time` but never resets `execution`, so a recycled context arrives carrying the
509
+ previous entity's `execution.rule` and `execution.executor`. `Context.unlink()` also never terminates
510
+ the still-running executor.
511
+ - **Failure scenario**: unit A executes a `SpeakLine` rule, whose executor tree is
512
+ `ParallelBehavior([WaitForEventBehavior(FinishedSpeakingLine on A), SendEventBehavior])`. A is killed
513
+ and removed; `removeEntity` deletes A's event listeners, so `FinishedSpeakingLine` can never arrive,
514
+ the parallel never completes, `DieBehavior` never runs, and the executor `Entity` keeps
515
+ `EntityFlags.Built` forever. A's context is released to the pool still pointing at that immortal
516
+ executor. Unit B spawns and pops the same context. On B's first event, `attemptRuleExecution`
517
+ (lines 193-199) sees `execution.executor.getFlag(Built) === true` and tests
518
+ `rule.priority <= execution.rule.priority`. Every rule in
519
+ `app/data/database/dynamic-actions/data.json` uses the default `priority = 0`, so `0 <= 0` →
520
+ `return false`: **B never executes any rule for the rest of the session.** If a higher-priority rule
521
+ ever does get through, `terminateActiveExecution` destroys entity A's executor from B's context.
522
+ - **Fix**: reset execution state at the context boundary — in `Context.link()` set
523
+ `this.execution.rule = null; this.execution.executor = null;`, and in `Context.unlink()` destroy a
524
+ still-built executor before clearing.
525
+ - **Confidence**: Confirmed
526
+
527
+ ### ECS-30 `match()` hands actions a live view of a shared scope stack that it unwinds before the action runs
528
+ - **Severity**: MAJOR
529
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/dynamic_actions/DynamicActorSystem.js:536-551`;
530
+ `.../actions/definition/WriteToBlackboardActionDescription.js:24-35`
531
+ - **Defect**: `match()` passes `this.scope.proxy` — a proxy over the system-wide, mutable `DataScope` —
532
+ as the action `context`, then calls `this.scope.unwind(top)` on line 551, dropping every frame it
533
+ just pushed (blackboards, `{now, entity}`, the event frame, the entity proxy scope).
534
+ `WriteToBlackboardActionDescription` captures that proxy in an `ActionBehavior` closure and only
535
+ calls `expression.evaluate(context)` when the behavior ticks. `BehaviorSystem.link()` only calls
536
+ `behavior.initialize()`; behaviors are first ticked in `BehaviorSystem.update()`, i.e. no earlier
537
+ than the next frame.
538
+ - **Failure scenario**: a rule with `"action": {"type":"Write", "name":"last_bark_time", "value":"now"}`.
539
+ `match()` pushes `{now: 12.5, entity}`, matches, builds the executor, then unwinds. Next frame the
540
+ `ActionBehavior` ticks and evaluates `now` against an empty scope → `undefined`, so
541
+ `blackboard.acquire("last_bark_time", …).set(undefined)` instead of `12.5`. With two actors handling
542
+ events in the same frame the deferred read can also resolve against the *other* actor's frames if
543
+ those are still on the stack.
544
+ - **Fix**: snapshot the scope before handing it to an action — materialise the proxy into a plain
545
+ object in `executeRule` (as `SendRequestActionDescription` already does eagerly for
546
+ `action_context`), or have `WriteToBlackboardActionDescription.execute` evaluate the expression
547
+ eagerly and defer only the blackboard write.
548
+ - **Confidence**: Confirmed
549
+
550
+ ### ECS-31 `PointFitnessGaussianBlur` serializes a `quality` field that is never stored
551
+ - **Severity**: MAJOR
552
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/evaluation/json/serializeToJSON_PointFitnessFunction.js:61`
553
+ - **Defect**: The serializer writes `quality: value.quality`, but `PointFitnessGaussianBlur` never
554
+ stores it — `from()` consumes it only to compute `samples_x/y/z = size * quality` and discards it.
555
+ The deserializer (`deserializeFromJSON_PointFitnessFunction.js:45,51`) destructures `quality` with no
556
+ default and forwards it to `from()`, whose own default `quality = 5` then applies.
557
+ - **Failure scenario**: `PointFitnessGaussianBlur.from(src, 4, 4, 4, 1)` → `samples = 4,4,4`
558
+ (64 source evaluations per point). Serialize → `{size_x:4, size_y:4, size_z:4, quality: undefined}`
559
+ (and `undefined` is dropped entirely by `JSON.stringify`). Deserialize → `quality = 5` →
560
+ `samples = 20,20,20`, i.e. 8000 source evaluations per point and a numerically different blur.
561
+ - **Fix**: store the value in `from()` (`r.quality = quality;`) with a matching class field, or derive
562
+ it on serialize as `value.samples_x / value.size_x`.
563
+ - **Confidence**: Confirmed
564
+
565
+ ### ECS-32 `PointFitnessGaussianBlur` kernel is offset by half a sample on every axis
566
+ - **Severity**: MINOR
567
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/evaluation/complex/PointFitnessGaussianBlur.js:67-69,80,89,99`
568
+ - **Defect**: `half_samples_x = samplesX / 2` with `local_x = ix - half_samples_x` gives taps at
569
+ `-n/2 … n/2 - 1`, which is not centred on the sample point. A centred kernel needs `(samplesX - 1) / 2`.
570
+ - **Failure scenario**: `samples_x = 5, size_x = 10`. Taps land at
571
+ `local_x ∈ {-2.5,-1.5,-0.5,0.5,1.5}` → spatial offsets `{-5,-3,-1,1,3}` instead of
572
+ `{-5,-2.5,0,2.5,5}`. Both the gaussian weights and the sample positions are biased by half a tap, so
573
+ blurring a step edge shifts it by 1 world unit in −x on each axis, and the point being blurred is
574
+ never itself sampled.
575
+ - **Fix**: `const half_samples_x = (samplesX - 1) / 2;` (and likewise for y, z).
576
+ - **Confidence**: Confirmed
577
+
578
+ ### ECS-33 `Animation.flags` is in neither serialization path, so a paused animation resumes after load
579
+ - **Severity**: MINOR
580
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/animation/Animation.js:40,153-158`;
581
+ `.../animation/AnimationSerializationAdapter.js:15-28`
582
+ - **Defect**: `flags` (carrying `Playing` and `MeshSizeCulling`) is written by neither `toJSON` nor
583
+ `serialize`, and the field initialiser unconditionally sets `Playing | MeshSizeCulling`.
584
+ `equals()`/`hash()` also ignore `flags` while including `debtTime`.
585
+ - **Failure scenario**: `animation.isPlaying = false` (a supported setter, covered by
586
+ `Animation.spec.js:58-62`), save via `BinaryBufferSerializer`, reload → `flags` is back to the
587
+ constructor default and the animation plays again. Two `Animation` components differing only in
588
+ `isPlaying` also report `equals() === true` and identical `hash()`.
589
+ - **Fix**: add `flags` to the binary adapter (bumping `version`) and to `toJSON`/`fromJSON`
590
+ (defaulting to `MeshSizeCulling | Playing`), and include it in `equals`/`hash`.
591
+ - **Confidence**: Confirmed
592
+
593
+ ### ECS-34 `AnimationSystem` re-subscribes to clip signals on every mesh change and never unsubscribes
594
+ - **Severity**: MINOR
595
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/systems/AnimationSystem.js:116-117,150,221-230`
596
+ - **Defect**: `updateAnimationState` unconditionally does `clips.on.added.add(initAnimationClip)` and
597
+ `clips.on.removed.add(...)` with fresh closures each time. It runs from `registerAnimation` (line 150),
598
+ which runs both from `link()` and from every `MeshEvents.DataSet` event. `unlink()` removes only the
599
+ `MeshEvents.DataSet` entity listener; the clip-signal handlers are never removed, and `Signal.add`
600
+ does not deduplicate.
601
+ - **Failure scenario**: `MeshSystem.process` sets a placeholder mesh (DataSet #1) then the real asset
602
+ (DataSet #2) for every async-loaded mesh with `__use_placeholder_mesh` on — together with `link()`
603
+ that is 3 registrations. Every later runtime mesh swap adds another. After N mesh sets, a single
604
+ `animation.clips.add(clip)` invokes `startClip` N times, and the handler list grows without bound for
605
+ the component's life; unlink/relink cycles accumulate across cycles too.
606
+ - **Fix**: keep references to the two handlers on the component, remove any previous pair at the top of
607
+ `updateAnimationState`, and remove them in `unlink()`.
608
+ - **Confidence**: Confirmed
609
+
610
+ ### ECS-35 `SerializationMetadata` binary adapter truncates a documented 32-bit flag field to 8 bits
611
+ - **Severity**: MINOR
612
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/components/SerializationMetadataSerializationAdapter.js:15,24`
613
+ - **Defect**: `flags` is documented as a "32 bit bitfield" (`SerializationMetadata.js:19-22`) and
614
+ `toJSON` writes the full number, but the binary adapter uses `writeUint8`/`readUint8`.
615
+ - **Failure scenario**: a consumer defines a ninth flag `Foo = 1 << 8`. `metadata.setFlag(Foo)` →
616
+ `flags === 256`. `toJSON()` preserves `256`; the binary path writes `256 & 0xFF === 0` and reads back
617
+ `flags === 0`, silently losing the flag on save/load.
618
+ - **Fix**: `writeUint32`/`readUint32`, bumping the adapter `version` and adding an upgrader.
619
+ - **Confidence**: Confirmed
620
+
621
+ ---
622
+
623
+ ## Storage / serialization
624
+
625
+ ### ECS-36 Schema back-reference is looked up by address in the schema-*object* map, then dereferenced
626
+ - **Severity**: MAJOR
627
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/storage/binary/object/BinaryObjectSerializationAdapter2.js:416`
628
+ - **Defect**: `#deserializeObject` resolves a `HEADER_TAG_REF_SCHEMA` record with
629
+ `this.#schema_table.getRecord(schema_address)`. `getRecord(schema)` looks up `#schema_map`, which is
630
+ keyed by the **schema object** (`#schema_map.set(record.schema, record)`); the address-keyed map is
631
+ `#address_map`, reachable only via `getSchemaAt(address)`. Passing a number can never hit, so
632
+ `record` is `undefined` and line 419 (`record.typeName`) throws. The whole `HEADER_TAG_REF_SCHEMA`
633
+ decode branch can only crash.
634
+ - **Failure scenario**: the `schema` parameter of `serialize(buffer, object, schema)` is a documented
635
+ optional argument. `const S = {v: undefined}; a2.serialize(buf, {v:1}, S); a2.serialize(buf, {v:2}, S);`
636
+ — the first object writes `HEADER_TAG_INLINE_SCHEMA` and interns `S`; the second takes the `else`
637
+ branch at line 375 and writes `HEADER_TAG_REF_SCHEMA` + address. On read, the first decodes fine, the
638
+ second throws `TypeError: Cannot read properties of undefined (reading 'typeName')`. Same for a single
639
+ object whose explicit schema reuses one sub-schema object for two fields.
640
+ - **Fix**: `record = this.#schema_table.getSchemaAt(schema_address);`. Also reset `#schema_table` when
641
+ switching an instance between writing and reading — write-side records are registered at write-buffer
642
+ positions and never cleared, so they can shadow read-side records at the same address.
643
+ - **Confidence**: Confirmed
644
+
645
+ ### ECS-37 Per-component transient flag is ignored whenever the dataset has no `SerializationMetadata` type
646
+ - **Severity**: MAJOR
647
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/storage/BinaryBufferSerializer.js:26-34`
648
+ - **Defect**: `isEntityTransient` returns `false` on `smComponentIndex === -1` **before** checking
649
+ `componentInstance[COMPONENT_SERIALIZATION_TRANSIENT_FIELD]`. The two mechanisms are independent —
650
+ the magic field is a per-instance opt-out that does not require a `SerializationMetadata` component
651
+ to exist — but it is gated behind one.
652
+ - **Failure scenario**: `EntityComponentDataset.computeComponentTypeIndex` returns `-1` for an
653
+ unregistered type, and a type is only registered once some entity actually receives one. In a dataset
654
+ where nothing ever adds a `SerializationMetadata`, `smComponentIndex === -1`. Mark any component
655
+ transient the way the game does (`guiElement[COMPONENT_SERIALIZATION_TRANSIENT_FIELD] = true` in
656
+ `setupStrategyHeadsUpDisplays.js:98`, `sockets[...] = true` in `CombatUnitEntityDecorator.js:660`,
657
+ `this.__transform2GridPosition[...] = true` in `CharacterMoveAsynchronousAction.js:236`) and it is
658
+ written to the save anyway, then resurrected on load as a duplicate of a runtime-only component.
659
+ - **Fix**: move the `COMPONENT_SERIALIZATION_TRANSIENT_FIELD` check above the `smComponentIndex === -1`
660
+ early return.
661
+ - **Confidence**: Confirmed
662
+
663
+ ### ECS-38 Object adapter resolves class identity through *inherited* `typeName`
664
+ - **Severity**: MAJOR
665
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/storage/binary/object/BinaryObjectSerializationAdapter.js:59-61`
666
+ - **Defect**: `const typeName = Klass.typeName;` is a normal static lookup, so an ES class that does not
667
+ declare its own `typeName` silently resolves to its ancestor's. The object-v2 writer explicitly
668
+ guards this with `Object.prototype.hasOwnProperty.call(ctor, "typeName")`
669
+ (`BinaryObjectSerializer.js:270`) and has a dedicated regression test, so the v1 adapter is the
670
+ un-fixed half.
671
+ - **Failure scenario**: `BehaviorComponentSerializationAdapter.serialize`
672
+ (`engine/intelligence/behavior/ecs/BehaviorComponentSerializationAdapter.js:38`) routes every behavior
673
+ through `this.objectAdapter.serialize(buffer, behavior)`. `Behavior.js:78` sets
674
+ `Behavior.typeName = "Behavior"`, while `SelectorBehavior`, `WeightedRandomBehavior`, `BranchBehavior`
675
+ and `ConditionalBehavior` declare none. Saving an entity whose behavior tree contains a
676
+ `SelectorBehavior` therefore looks up adapter `"Behavior"` — not registered — and the save aborts with
677
+ `No adapter found for class 'Behavior'`, naming a class that is not in the tree. If any ancestor *does*
678
+ have a registered adapter the failure is worse: the subclass is written with the ancestor's payload
679
+ and read back as the ancestor, dropping all subclass state.
680
+ - **Fix**: require an own `typeName` —
681
+ `Object.prototype.hasOwnProperty.call(Klass, "typeName") ? Klass.typeName : undefined` — and throw
682
+ naming the actual constructor when absent, mirroring `BinaryObjectSerializer.writeValue`.
683
+ - **Confidence**: Confirmed
684
+
685
+ ### ECS-39 The last component type in a stream is never finalized on load
686
+ - **Severity**: MINOR
687
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/storage/BinaryBufferDeSerializer.js:117-127`
688
+ - **Defect**: `cycleFunction` finalizes the *previous* type at the top of the next loop iteration
689
+ (`if (typesDecoded > 0) collectionDeSerializer.finalize();`), but the `typesLeft === 0` exit at
690
+ line 117 returns `TaskSignal.EndSuccess` first. So `BinaryCollectionDeSerializer.finalize()` — and
691
+ hence `adapter.finalize()` — never runs for the final type. The write path
692
+ (`BinaryBufferSerializer.js:194`) finalizes every type correctly.
693
+ - **Failure scenario**: load a buffer containing types `[Name, CombatUnit]`. `CombatUnit` elements are
694
+ read to exhaustion, `typesLeft` is `0`, the task ends, and
695
+ `CombatUnitSerializationAdapter.finalize()`
696
+ (`model/game/ecs/component/unit/serialization/CombatUnitSerializationAdapter.js:51`) is never invoked,
697
+ so its module-static `serialization_array` is left holding stale entries instead of being spliced
698
+ empty. The deserializer is also left in `State.Ready`, so a subsequent `initialize()` on the same
699
+ instance throws `IllegalStateException`. Which adapter is affected depends on which component type
700
+ happens to be last, so the leak is non-deterministic across saves.
701
+ - **Fix**: finalize the in-flight collection before returning `EndSuccess`, guarding against
702
+ double-finalize on a re-entered cycle.
703
+ - **Confidence**: Confirmed
704
+
705
+ ---
706
+
707
+ ## GUI / HUD / speaker
708
+
709
+ ### ECS-40 `LineSetDescription.collect` walks the root's children instead of the popped node's — infinite loop
710
+ - **Severity**: CRITICAL
711
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/speaker/lines/sets/LineSetDescription.js:56`
712
+ - **Defect**: Inside the traversal loop the popped node is `set`, but the child-set expansion reads
713
+ `const sets = this.sets;` — the *root's* children — on every iteration. The root's children are
714
+ therefore re-pushed forever and `pointer` never drains.
715
+ - **Failure scenario** (reproduced): a `voice-line-sets` entry with a non-empty `sets` array
716
+ (schema-supported: `LineSetDescriptionTable.linkOne` reads `sets` from JSON, and `validateOne` exists
717
+ purely to cycle-check that graph). Root `R{lines:[l-root], sets:[C]}`, `C{lines:[l-child]}` →
718
+ `collect` writes `l-root, l-child, l-child, l-child, …` without bound; `destination` grows until OOM.
719
+ `VoiceSystem.sayLineFromSet` calls `set.collect(temp_lines, 0)`, so the tab hangs on the first nested
720
+ bark set anyone authors.
721
+ - **Fix**: `const sets = set.sets;`
722
+ - **Confidence**: Confirmed
723
+
724
+ ### ECS-41 `GUIElementSystem.unlink` calls `Signal.remove` without the context it subscribed with
725
+ - **Severity**: MAJOR
726
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/gui/GUIElementSystem.js:261` (subscribe at `:248`)
727
+ - **Defect**: `link` does `component.visible.onChanged.add(handleComponentVisibilityChange, {el, system, entity})`;
728
+ `unlink` does `component.visible.onChanged.remove(handleComponentVisibilityChange)` with no `thisArg`.
729
+ `Signal.remove` matches on `handle === h && context === thisArg`, so it matches nothing and returns
730
+ `false`.
731
+ - **Failure scenario** (reproduced against the real `Signal`): `add(handler, ctx)` then `remove(handler)`
732
+ → returns `false`, `hasHandlers()` stays `true`, the next `send1` still runs the handler. Concretely,
733
+ `ecd.removeComponentFromEntity(entity, GUIElement)` (real caller:
734
+ `model/game/scenes/combat/UnitStateManager.js:437`) or `Entity.destroy()` → `Entity.build()` leaves
735
+ the old subscription in place, and the next `link` adds a second. With two handlers,
736
+ `component.visible.set(true)` runs `attachComponent` twice → `parent.addChild(component.view)` twice
737
+ → `View.addChild` (`view/View.js:365`) pushes the same child into `parent.children` twice (the
738
+ `isLinked` assert that would catch it is stripped in production). `removeChild` then drops only the
739
+ first entry, so `parent.children` keeps a stale entry whose `el` is no longer in the DOM; a later
740
+ `removeAllChildren` calls `this.el.removeChild(child.el)` on a non-child and throws `NotFoundError`.
741
+ - **Fix**: store the context object (as `ViewportPositionSystem` does in `this.data`) and pass it to
742
+ `remove`.
743
+ - **Confidence**: Confirmed
744
+
745
+ ### ECS-42 `ViewportPositionSystem.unlink` never unsubscribes from `el.view.size.onChanged`
746
+ - **Severity**: MAJOR
747
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/gui/position/ViewportPositionSystem.js:154` (unlink at `:167-183`)
748
+ - **Defect**: `link` makes five `onChanged.add(updatePosition, eventContext)` subscriptions —
749
+ `vp.position`, `vp.offset`, `vp.anchor`, `vp.enabled` and `el.view.size`. `unlink` removes only the
750
+ first four.
751
+ - **Failure scenario**: `GUIEngine.createModal` (`meep/src/engine/ui/GUIEngine.js:215`) does
752
+ `window.removeComponent(ViewportPosition)` immediately after `openWindow` built the entity,
753
+ deliberately freeing the modal from viewport positioning while the `GUIElement` and its
754
+ `ui-modal-window-container` view stay live on screen. `unlink` runs, but `updatePosition` stays bound
755
+ to that container's `size.onChanged` with a context holding the now-detached `vp`. Any later size
756
+ change re-invokes `positionComponent(el, vp)`, overwriting `view.position` from the stale
757
+ `vp.position`, overwriting `view.transformOrigin` with the stale `vp.anchor` (default `0,0`,
758
+ discarding the `0.5,0.5` set at `GUIEngine.js:213`), and calling `el.visible.set(...)` — if the stale
759
+ position is out of bounds and `stickToScreenEdge` is false (the default), `el.visible.set(false)`
760
+ makes `GUIElementSystem` detach the modal from the DOM and the open modal vanishes.
761
+ - **Fix**: add `el.view.size.onChanged.remove(updatePosition, eventContext);` inside the
762
+ `eventContext !== undefined` block in `unlink`.
763
+ - **Confidence**: Confirmed
764
+
765
+ ### ECS-43 `HeadsUpDisplay.toJSON` writes `transformPerspectiveRotation`, `fromJSON` reads `perspectiveRotation`
766
+ - **Severity**: MAJOR
767
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/gui/hud/HeadsUpDisplay.js:86` vs `:90`
768
+ - **Defect**: The JSON key emitted for the `PerspectiveRotation` flag does not match the key the reader
769
+ destructures, so the reader always falls back to its `false` default.
770
+ - **Failure scenario** (reproduced): a HUD with `PerspectiveRotation` set — exactly what
771
+ `makeEnemyGroupHud` in `model/game/scenes/strategy/gui/setupStrategyHeadsUpDisplays.js` configures.
772
+ `toJSON()` yields `{"worldOffset":…,"transformWorldOffset":true,"transformPerspectiveRotation":true}`;
773
+ `HeadsUpDisplay.fromJSON(that)` yields `flags = 1` instead of `3`,
774
+ `getFlag(PerspectiveRotation) === false`, and `a.equals(b) === false`. The screen-space billboard
775
+ rotation is silently dropped on any JSON round-trip. The binary adapter round-trips the flag
776
+ correctly, so the two persistence paths disagree.
777
+ - **Fix**: emit `perspectiveRotation` in `toJSON`, or rename the `fromJSON` destructure to
778
+ `transformPerspectiveRotation`. Pick one and keep both sides identical.
779
+ - **Confidence**: Confirmed
780
+
781
+ ### ECS-44 `GUIElementSystem.link` logs "does not have a view" and then dereferences the missing view
782
+ - **Severity**: MINOR
783
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/gui/GUIElementSystem.js:212`
784
+ - **Defect**: The un-managed-with-no-view branch does `console.error(...)` with no `return`. Execution
785
+ falls through to `if (component.visible.getValue()) this.attachComponent(component, entity)`
786
+ (`visible` defaults to `true`), and `attachComponent` reaches `parent.addChild(component.view)` with
787
+ `component.view === null`.
788
+ - **Failure scenario**: `const c = new GUIElement(); c.clearFlag(GUIElementFlag.Managed);` added to an
789
+ entity — precisely the state the error message describes. `View.addChild(null)` executes
790
+ `child.parent = this;` (its guarding asserts are stripped in production) →
791
+ `TypeError: Cannot set properties of null (setting 'parent')` propagates out of `link`, aborting the
792
+ component-added observer dispatch for that entity so later systems never link.
793
+ - **Fix**: `return;` after the `console.error` at `:212`.
794
+ - **Confidence**: Confirmed
795
+
796
+ ### ECS-45 `ButtonViewEntity.finalize` destroys the structural children created in its constructor
797
+ - **Severity**: MINOR
798
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/gui/view/ButtonViewEntity.js:104-107` (children created at `:17-23`)
799
+ - **Defect**: The constructor adds the `foreground` and `background` `EmptyView`s the button's CSS is
800
+ built on. `finalize()` calls `removeAllChildren()`, which removes them too — but `ViewEntity.finalize`
801
+ is documented as bringing the view "to initial state ready to be destroyed **or re-used**", and
802
+ `GUIElementSystem` implements exactly that re-use contract (`unlink` calls `view.finalize()` and
803
+ clears `Initialized`; `attachComponent` re-calls `view.initialize(...)` when it sees
804
+ `Managed && !Initialized`).
805
+ - **Failure scenario**: an entity carrying a `view.Button` `GUIElement` (registered at
806
+ `model/game/GameClassRegistry.js:89`) goes through link → unlink → link. On the second `initialize`
807
+ the label is re-added but `.foreground`/`.background` are gone, so the button renders with no
808
+ background fill and dead hover/press styling. `LocalizedLabelViewEntity` is correct precisely because
809
+ its constructor adds no children.
810
+ - **Fix**: create the foreground/background children in `initialize()`, or have `finalize()` remove only
811
+ the label child.
812
+ - **Confidence**: Confirmed
813
+
814
+ ### ECS-46 `LineSetDescriptionTable.validateOne` reports a false circular dependency for a shared child set
815
+ - **Severity**: MINOR
816
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/speaker/lines/sets/LineSetDescriptionTable.js:79`
817
+ - **Defect**: The check treats *any* second visit to a node as a cycle
818
+ (`if (visited.hasOwnProperty(set.id))`) rather than tracking the current traversal path, so a DAG with
819
+ a shared child is rejected.
820
+ - **Failure scenario**: `A{sets:[B,C]}`, `B{sets:[D]}`, `C{sets:[D]}` — no cycle. Traversal pops `A`,
821
+ pushes `B,C`; pops `C`, pushes `D`; pops `D`, marks visited; pops `B`, pushes `D`; pops `D` again →
822
+ `visited` hit → `errorConsumer("Circular dependency detected: D -> C -> A")` and `return false`. Valid
823
+ voice-line-set data fails validation at load, and the reported path names the wrong parent.
824
+ - **Fix**: track the recursion stack (mark on entry, unmark on exit) or colour nodes white/grey/black,
825
+ and only report a cycle on re-entering a grey node.
826
+ - **Confidence**: Confirmed
827
+
828
+ ### ECS-47 `VoiceSystem.sayLineFromSet` dereferences `undefined` when the set yields no lines
829
+ - **Severity**: MINOR
830
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/speaker/VoiceSystem.js:313-315`
831
+ - **Defect**: `weightedRandomFromArray` returns `undefined` when `length === 0`
832
+ (`core/collection/array/weightedRandomFromArray.js:26-28`), and the result is used unguarded as
833
+ `selected_line.id`. The neighbouring "set not found" case at `:303` warns and returns, so graceful
834
+ handling of bad data is the intended contract here.
835
+ - **Failure scenario**: a `voice-line-sets` entry with an empty or omitted `lines` array (`linkOne`
836
+ defaults `lines = []`) — a placeholder or not-yet-authored bark set.
837
+ `ecd.sendEvent(entity, VoiceEvents.SpeakSetLine, {id: thatSet})` → `collect` returns `0` →
838
+ `TypeError: Cannot read properties of undefined (reading 'id')` thrown out of the entity-event dispatch.
839
+ - **Fix**: guard on `selected_line === undefined` after the `weightedRandomFromArray` call.
840
+ - **Confidence**: Confirmed
841
+
842
+ ---
843
+
844
+ ## Attachment / parent / transform / IK
845
+
846
+ ### ECS-49 `EntityNode`'s `parent` setter strips `TransformAttachment` and never re-adds it
847
+ - **Severity**: MAJOR
848
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/parent/EntityNode.js:240-270`
849
+ - **Defect**: The setter removes both components up front
850
+ (`let parent_entity = this_entity.removeComponent(ParentEntity); let attachment = this_entity.removeComponent(TransformAttachment);`)
851
+ and, in the `node !== null` branch, explicitly re-adds `ParentEntity` via `this_entity.add(parent_entity)`
852
+ — but the attachment is only put back through `this.__safe_get_attachment()`, which runs **only when
853
+ `attachment === null`**. When the node already had a `TransformAttachment`, the removed instance is
854
+ mutated (`attachment.parent = parent_entity_id`) and dropped on the floor.
855
+ - **Failure scenario** (reproduced): three built `EntityNode`s A, B, C each with a `Transform`.
856
+ ```js
857
+ A.addChild(C); // C -> ParentEntity{0}, TransformAttachment{parent:0}
858
+ C.parent = B; // public setter, the documented re-parent entry point
859
+ ```
860
+ Observed: `ParentEntity.entity` correctly becomes `1`, but
861
+ `C.entity.getComponent(TransformAttachment)` is `null` and
862
+ `ds.getComponent(C.entity.id, TransformAttachment)` is `undefined`. `TransformAttachmentSystem` has
863
+ unlinked C, so C's world transform is no longer driven by any parent — the node visually detaches
864
+ while still being logically parented (and still gets destroyed when B is destroyed).
865
+ `addChild` escapes this only because it routes through `removeChild` → `node.parent = null` first,
866
+ which nulls both.
867
+ - **Fix**: after `attachment.parent = parent_entity_id`, put the component back —
868
+ `if (this_entity.getComponent(TransformAttachment) === null) this_entity.add(attachment);` — or
869
+ simply do not remove `TransformAttachment` at the top when `node !== null`.
870
+ - **Confidence**: Confirmed
871
+
872
+ ### ECS-50 `InverseKinematicsSystem` declares read-only access to `Mesh` because of an undefined enum member
873
+ - **Severity**: MINOR
874
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/ik/InverseKinematicsSystem.js:18`
875
+ - **Defect**: `ResourceAccessSpecification.from(Mesh, ResourceAccessKind.Read | ResourceAccessSpecification.Write)`
876
+ — `Write` is a member of `ResourceAccessKind`, not of `ResourceAccessSpecification`. The latter has no
877
+ such static, so the expression is `1 | undefined === 1`: the system declares `Read` only, while
878
+ `TwoBoneInverseKinematicsSolver.solve` actually mutates the mesh's bone quaternions
879
+ (`boneA.setRotationFromQuaternion(...)`, `TwoBoneInverseKinematicsSolver.js:173-174`).
880
+ - **Failure scenario**: `EntityManager.updateExecutionOrder`'s `scoreSystem` weights a `Write` component
881
+ at `attached_edge_count * 2` and a `Read` at `* 1`, and `computeSystemComponentDependencyGraph` only
882
+ emits edges out of a system that declares `Write`/`Create`. With the typo, `InverseKinematicsSystem`
883
+ contributes no dependency edges and is scored as a pure `Mesh` reader, so it can be ordered *after*
884
+ the systems that consume mesh/bone state in the same `simulate()` call — IK corrections (feet planted
885
+ on terrain) are applied one frame late, producing visible foot sliding / ground penetration on the
886
+ frame a character's pose changes.
887
+ - **Fix**: `ResourceAccessKind.Read | ResourceAccessKind.Write`.
888
+ - **Confidence**: Confirmed
889
+
890
+ ### ECS-51 `OneBoneSurfaceAlignmentSolver` recovers the alignment angle with `asin`, which cannot exceed 90°
891
+ - **Severity**: MINOR
892
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/ecs/ik/OneBoneSurfaceAlignmentSolver.js:170-175`
893
+ - **Defect**: `axis.crossVectors(v3, Vector3.forward); const betaSine = axis.length(); const beta = Math.asin(clamp(betaSine, -1, 1));`
894
+ — `|a × b| = sin θ` is ambiguous between `θ` and `π − θ`, and `asin` always returns the branch in
895
+ `[0, π/2]`. The angle between the contact normal and the bone axis is not recoverable this way once it
896
+ exceeds 90°.
897
+ - **Failure scenario**: bone axis `(0,0,1)` in bone space, contact normal in bone space
898
+ `(0, 0.9, −0.436)` — 116° apart. `|cross| = 0.9` → `beta = asin(0.9) = 64.2°`. The bone is rotated
899
+ 64° instead of 116°, so instead of squaring up to the surface it ends up 52° off, tilted into the
900
+ surface. Any surface whose normal is more than 90° from the bone's own axis (steep slopes,
901
+ overhangs, an inverted foot-bone rig) is aligned to the wrong angle rather than clamped by
902
+ `constraint.limit`.
903
+ - **Fix**: recover the angle unambiguously — `Math.atan2(axis.length(), v3.dot(Vector3.forward))` —
904
+ before normalising `axis`.
905
+ - **Confidence**: Likely (the trigonometric limitation is unambiguous; I did not exercise it against a
906
+ real rig, so how often a live skeleton crosses 90° is unverified)
907
+
908
+ ---
909
+
910
+ ## Status of previously-known items
911
+
912
+ | Item | Status |
913
+ | --- | --- |
914
+ | `Entity.promiseEvent` calling `removeEventListener` instead of `add` | **FIXED** (`Entity.js:326-327` now `addEventListener`) |
915
+ | `EntityComponentDataset.dropData` not clearing `__entityAnyEventListeners` | **FIXED** (`EntityComponentDataset.js:1882`) |
916
+ | `HeadsUpDisplaySystem` scaling world-offset Z by `scale.y` | **FIXED** (`HeadsUpDisplaySystem.js:88` uses `transform_scale.z`) |
917
+ | `obtainTileAtWorldPosition2D` dereferencing an out-of-bounds tile | **STILL PRESENT** (`TerrainTileManager.js:487-489`; `getTileByWorldPosition2D` can return `undefined`, then `tile.gridPosition`) |
918
+ | `FogOfWar.rebuildSampler` width/height swap | **FIXED** (`FogOfWar.js:707-713`, correct `Sampler2D(data, itemSize, width, height)` order) |
919
+ | `TerrainTileManager.stitchTile` floor-vs-ceil | **STILL PRESENT** (`:566` uses `floor`; `:353`, `:405`, `:408`, `:422` all use `ceil`) |
920
+ | Event-dispatch pointer restore missing `try/finally` | **STILL PRESENT** (`EntityComponentDataset.js:2138-2162` and `2180-2204`) |