@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,197 @@
1
+ # Correctness review — `engine/input/` + `engine/control/`
2
+
3
+ Scope: every non-spec source file under
4
+ `H:/git/moh/app/src/mir-engine/meep/src/engine/input/` and
5
+ `H:/git/moh/app/src/mir-engine/meep/src/engine/control/`.
6
+
7
+ Excluded per brief: `editor/`, `prototype*.js`, `*.spec.js` (read as evidence only), `.d.ts`.
8
+ Already-fixed / already-captured / known-good items are not re-reported.
9
+
10
+ Two findings (INPUT-3, INPUT-4) were **empirically confirmed** with a throw-away
11
+ full-pipeline harness (physics + sensors + controller + WallRun/WallJump, modelled on
12
+ `WallRunScenarios.spec.js`); the harness was deleted afterwards. Observed output is
13
+ quoted inline.
14
+
15
+ ---
16
+
17
+ ### INPUT-1 `TopDownCameraControllerHelper` throws on every call — geometry classes never imported
18
+ - **Severity**: CRITICAL
19
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/ecs/util/TopDownCameraControllerHelper.js:45`
20
+ - **Defect**: The module imports only `Group`, `Matrix4`, `Mesh`, `MeshLambertMaterial` from `three`, but the factory body constructs `new CylinderGeometry(...)` (line 45) and `new SphereGeometry(...)` (line 46). Both are unresolved free identifiers in an ES-module scope.
21
+ - **Failure scenario**: `TopDownCameraControllerHelper(topDownController, targetTransform, 1)` → first statement of the function body → `ReferenceError: CylinderGeometry is not defined`. There is no code path through the function that avoids it; the helper is 100% dead on arrival for any library consumer.
22
+ - **Fix**: add `CylinderGeometry`, `SphereGeometry` to the `three` import list (aliased in the local style, e.g. `CylinderGeometry as ThreeCylinderGeometry`) and use the aliases at lines 45–46.
23
+ - **Confidence**: Confirmed
24
+
25
+ ---
26
+
27
+ ### INPUT-2 Pointer "down" state can never be cleared — no `pointercancel`, no blur recovery, `stop()` leaves the drag live
28
+ - **Severity**: CRITICAL
29
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/PointerDevice.js:483` (`start`), `:499` (`stop`)
30
+ - **Defect**: `start()` subscribes only to `pointermove` / `pointerup` / `pointerdown` / `wheel` / `contextmenu`. `PointerEvents.Cancel` is defined in `events/PointerEvents.js:13` but never subscribed, and there is no `window` `blur` handler (the sibling `KeyboardDevice` has one and documents exactly this hazard). Additionally `stop()` removes the listeners but leaves `buttons[i].is_down === true` and leaves `observeDrag`'s `handleDrag` / `handleDragEnd` registered on `on.move` / `#globalUp`. The only thing that ever calls `InputDeviceSwitch.release()` for the pointer is `#eventHandlerGlobalPointerUp`, and the only thing that ends a drag is `#globalUp` firing.
31
+ - **Failure scenario** (touch, needs no API call): finger down on the canvas → `pointerdown` → `buttons[0].press()`, `observeDrag.handleDown` arms `noDrag` + `handleDragStart`. Finger moves → `pointermove` → `handleDragStart` fires `on.dragStart` and registers `handleDrag` on `on.move` and `handleDragEnd` on `#globalUp`. The browser now claims the gesture (pull-to-refresh / two-finger pan / palm rejection) and dispatches **`pointercancel`**; per spec no `pointerup` follows. Result: `pointer.mouseButtonLeft.is_down` stays `true` forever, `on.dragEnd` is never sent, and `handleDrag` stays subscribed — so the *next* pointer that merely hovers/moves over the element (second finger, stylus, mouse) makes `on.drag` fire with the stale `origin`, swinging the camera with no button held.
32
+ Second reachable path with no touch at all: `pointerDevice.domElement = otherEl` (public setter, `:431`) internally does `stop()` then `start()`; performed while the left button is held it produces the same stuck `is_down` + live drag.
33
+ - **Fix**: subscribe `#eventHandlerGlobalPointerUp` to `PointerEvents.Cancel` on `window` as well (release the button and dispatch `#globalUp` so `handleDragEnd` runs), add a `window` `blur` handler that does the same for every pressed button, and have `stop()` release all `buttons[i]` and dispatch `#globalUp` once so any in-flight drag is torn down before the listeners go away.
34
+ - **Confidence**: Confirmed
35
+
36
+ ---
37
+
38
+ ### INPUT-3 `InputControllerSystem` proxy mutates its binding array mid-dispatch — crashes or skips listeners
39
+ - **Severity**: CRITICAL
40
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/ecs/systems/InputControllerSystem.js:62` (`Proxy.handler`), `:98` (`registerBinding`), `:132` (`remove`)
41
+ - **Defect**: `handler()` caches `const l = bindings.length` and then indexes `bindings[i]` for `i < l`, while `add()` (→ `registerBinding`, which `push`es **and re-`sort`s**) and `remove()` (which `splice`s) both mutate that same array synchronously. The `running` flag and the `deferred` queue that were clearly meant to guard this are dead code — `running` is never set to `true`, so `add()` always takes the immediate path and `__processDeferred()` always drains an empty list.
42
+ - **Failure scenario**: entities A and B both have an `InputController` bound to `pointer/on/down`, so the proxy holds `bindings = [A, B]`. A's listener destroys B's entity (a perfectly ordinary "click closes the panel" handler) → `ecd.removeEntity(B)` → `InputControllerSystem.unlink(B)` → `removeBindings` → `proxy.remove(bindingB)` → `bindings.splice(1,1)` → `bindings = [A]`. Control returns to `handler()`, which continues with `l === 2`: `i = 1` → `bindings[1] === undefined` → `const listener = binding.listener` → **`TypeError: Cannot read properties of undefined (reading 'listener')`**. (Signal's `try/catch` swallows it into `console.error`, so the remaining bindings on that path are silently dropped for that event.)
43
+ Non-crashing variant, same root cause: A's listener builds a new entity whose `InputController` binds the same path with a higher priority; `registerBinding` re-sorts the live array under the running loop, so an as-yet-unvisited sibling binding is skipped for that event and the freshly-created binding is invoked for the very event that created it.
44
+ - **Fix**: iterate a snapshot (`const list = bindings.slice()`), or re-read `bindings.length` each iteration and iterate over a stable copy; alternatively finish the intended design — set `this.running = true` around the dispatch loop, route `add()`/`remove()` through `deferred` while running, and drain in `__processDeferred()`.
45
+ - **Confidence**: Confirmed
46
+
47
+ ---
48
+
49
+ ### INPUT-4 `runtime.prevJumpHeld` is frozen while an ability owns motion — wall-jump silently dies mid wall-run
50
+ - **Severity**: MAJOR
51
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/abilities/WallRun.js:114` (`tick`), `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js:1115`
52
+ - **Defect**: `runtime.prevJumpHeld = intent.jump` is written only in `_advanceJumpFsm`, which runs exclusively inside `_runBaseLocomotion` — i.e. only on ticks where **no** ability owns motion. `Slide.tick` (`Slide.js:126`) and `ScrambleUp.tick` (`ScrambleUp.js:158`) explicitly maintain it themselves; `WallRun.tick` does not. `WallJump.canActivate` (`WallJump.js:50`) rejects when `runtime.prevJumpHeld` is true, so a stale `true` makes the ability permanently unreachable.
53
+ - **Failure scenario**: sprint at a wall and jump into it **keeping the jump button held** (the natural input). Base locomotion latches `prevJumpHeld = true`; wall-run engages while it is still held, so base stops running and `prevJumpHeld` is frozen at `true`. The player then releases jump and presses it again to kick off the wall — `WallJump.canActivate` sees `prevJumpHeld === true`, returns `false`, and no wall-jump ever fires for the remainder of the wall-run. The canonical wall-run → wall-jump chain is lost; the player just rides the wall until the 2 s timer.
54
+ Harness output (full physics + sensors pipeline, jump held across wall-run entry at tick 24, released at 34, re-pressed at 37):
55
+ ```
56
+ { "wallRunStart": 24, "releasedAt": 34, "repressedAt": 37, "kicked": false,
57
+ "window": [ {"i":34,"act":"WallRun","prevJumpHeld":true,"jump":false,"vx":0},
58
+ ...
59
+ {"i":37,"act":"WallRun","prevJumpHeld":true,"jump":true,"vx":0},
60
+ {"i":40,"act":"WallRun","prevJumpHeld":true,"jump":true,"vx":0} ] }
61
+ ```
62
+ `vx` never leaves 0 — the body is never kicked off the wall. `WallRunScenarios.spec.js:193` passes only because its driver releases jump at tick 18, *before* wall-run engages at ~24.
63
+ - **Fix**: make the jump-edge bookkeeping unconditional — move `runtime.prevJumpHeld = intent.jump` out of `_advanceJumpFsm` and into `_tickEntity` (after the ability layer, alongside `_resolveCrouchHeld`, which already does exactly this for crouch), and drop the per-ability copies in `Slide`/`ScrambleUp`/`WallJump`/`LedgeGrab` that exist only to work around the gap.
64
+ - **Confidence**: Confirmed
65
+
66
+ ---
67
+
68
+ ### INPUT-5 Ability activation costs a whole fixed step of motion — nothing integrates on the activation tick
69
+ - **Severity**: MAJOR
70
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/abilities/AbilitySet.js:123` (step 3), `:146` (return)
71
+ - **Defect**: `AbilitySet.tick` returns `this.active === null`. On the tick where step 3 (or step 2) *activates* an ability it returns `false`, so `_tickEntity` skips `_runBaseLocomotion` (`FirstPersonPlayerControllerSystem.js:737`) — but the newly activated ability's own `tick()` is not called until the next fixed step (only `onActivate` ran). No layer moves the body: no gravity, no `_resolveMotion`, no ground categorisation, no `airborneTime`/`timeSinceGrounded` advance. The class docstring for step 1 explicitly claims "no wasted frame"; activation is exactly that wasted frame.
72
+ - **Failure scenario**: sprint into a wall-run at ~7 m/s. Harness output for the activation tick (deltas of `transform.position` and `velocityY` across one `fixedUpdate`):
73
+ ```
74
+ {"i":22,"act":null, "dx":-0.04276,"dy":0.08163,"dz":0.11748,"dvy":-0.97959}
75
+ {"i":23,"act":null, "dx":-0.04276,"dy":0.06531,"dz":0.11748,"dvy":-0.97959}
76
+ {"i":24,"act":"WallRun","dx":0, "dy":0, "dz":0, "dvy":0 } <-- activation tick
77
+ {"i":25,"act":"WallRun","dx":0, "dy":0.06327,"dz":0.11748,"dvy":-0.12245}
78
+ ```
79
+ The body is frozen for a full 16.7 ms — ~12 cm of lost travel and one skipped gravity step — on every slide entry, wall-run entry, scramble, ledge-grab and mantle. Visible as a one-frame hitch at each ability transition.
80
+ - **Fix**: after activating in step 2/step 3, tick the newly activated ability in the same call (mirroring step 1's fall-through), and use its return value to decide the final `this.active`/`return` — i.e. hoist step 1's "tick then maybe release" into a small loop so a freshly activated ability owns motion on the tick it takes control.
81
+ - **Confidence**: Confirmed
82
+
83
+ ---
84
+
85
+ ### INPUT-6 `KeyboardDevice.stop()` leaves held keys latched down
86
+ - **Severity**: MAJOR
87
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/KeyboardDevice.js:214`
88
+ - **Defect**: `stop()` unsubscribes all four listeners (element keydown/keyup, window keyup, window blur) but never releases the switches. The class docstring commits to the invariant for window blur ("any 'pressed' keys are forcibly released with the appropriate signal") — `#handleWindowBlurEvent` implements it, `stop()` does not, even though stopping guarantees the release can never be observed.
89
+ - **Failure scenario**: hold `W`; the host calls `keyboard.stop()` (pausing input for a cutscene / modal / settings screen); release `W`; call `keyboard.start()`. `keys.w.is_down` is still `true` and no `keys.w.up` was ever dispatched. Every polling consumer (`keyboard.keys.w.is_down`) reads "held", and every edge consumer stays latched — e.g. `KeyboardCameraController` (`ecs/controllers/KeyboardCameraController.js:57`) sets `controls.panUp = false` only on `keys/w/up`, so the camera pans forward indefinitely after input resumes. It only clears when the player happens to press and release `W` again.
90
+ - **Fix**: in `stop()`, run the same loop `#handleWindowBlurEvent` uses — `for (let keyName in KeyCodes) this.keys[keyName].release();` — so the switches and their `up` signals are consistent with "we can no longer observe releases".
91
+ - **Confidence**: Confirmed
92
+
93
+ ---
94
+
95
+ ### INPUT-7 `FirstPersonPlayerControllerConfig` drops `scrambleUp` on clone / copy / serialize, and `equals()` ignores it
96
+ - **Severity**: MAJOR
97
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/FirstPersonPlayerControllerConfig.js:589` (`toJSON`), `:618` (`fromJSON`)
98
+ - **Defect**: `scrambleUp` is declared as a config block (`:187`) and read by `ScrambleUp` (`abilities/ScrambleUp.js:67,86,126`), but it is absent from both `toJSON()`'s output object and `fromJSON()`'s assignment chain. `copy(other)` is implemented as `this.fromJSON(other.toJSON())` (`:658`), and `FirstPersonPlayerController.equals` (`FirstPersonPlayerController.js:201`) compares `JSON.stringify(config.toJSON())`.
99
+ - **Failure scenario**: a designer tunes `controller.config.scrambleUp.maxJumpForce = 3000` (a deliberately weak climb). The controller component is cloned or round-tripped — `FirstPersonPlayerController.clone()` → `copy()` → `fromJSON(toJSON())` — as happens on prefab instantiation or a save/load. The clone silently reverts to the default `7200 N`, so the same character now scrambles roughly `√(7200/3000) ≈ 1.55×` faster off the wall and climbs ~2.4× higher. Separately, two controllers differing *only* in `scrambleUp` compare `equals() === true`, so change detection / dedup treats the retuned config as unchanged.
100
+ - **Fix**: add `scrambleUp: { ...this.scrambleUp }` to `toJSON()` and `if (json.scrambleUp) Object.assign(this.scrambleUp, json.scrambleUp);` to `fromJSON()`.
101
+ - **Confidence**: Confirmed
102
+
103
+ ---
104
+
105
+ ### INPUT-8 Jump buffer is decremented before it is tested — effective buffer is `bufferTime − dt`, and `bufferTime ≤ dt` disables jumping entirely
106
+ - **Severity**: MAJOR
107
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js:1117`
108
+ - **Defect**:
109
+ ```js
110
+ if (jumpPressedEdge) { state.jumpBufferRemaining = cfg.jump.bufferTime; }
111
+ state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt); // decays on the press tick too
112
+ const canJumpNow = (...) && state.jumpBufferRemaining > 0 && ...;
113
+ ```
114
+ The freshly-armed buffer is aged by one full `dt` before it is ever tested, so the buffer is always one step shorter than configured and a buffer of one step or less is consumed before the gate reads it.
115
+ - **Failure scenario**: a designer sets `cfg.jump.bufferTime = 0` to turn jump buffering off (the natural way to express "no buffering — jump only on the exact press"). Player stands on flat ground and presses jump: `jumpBufferRemaining = 0` → `max(0, 0 − 0.0167) = 0` → `canJumpNow` is `false` on the press tick, and there is no later tick with a rising edge, so **the player can never jump**. Same total failure at a 30 Hz fixed step (`dt = 0.0333`) with any `bufferTime ≤ 0.0333`.
116
+ - **Fix**: age the buffer *before* re-arming it, i.e. `state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt); if (jumpPressedEdge) state.jumpBufferRemaining = cfg.jump.bufferTime;` — the press tick then always sees the full window.
117
+ - **Confidence**: Confirmed
118
+
119
+ ---
120
+
121
+ ### INPUT-9 Stride-timing mastery curves are missing the closing keyframe — the last quarter of the gait cycle returns the peak bonus
122
+ - **Severity**: MAJOR
123
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/mastery/StrideTimingJumpEvaluator.js:19`, `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/mastery/SlideInitiationTimingEvaluator.js:22`
124
+ - **Defect**: Both curves are keyed at `0.00, 0.25, 0.50, 0.75` only, and are evaluated with a cyclic input (`controller.state.stridePhase ∈ [0,1)`). `AnimationCurve.evaluate` clamps past the last key (`engine/animation/curve/AnimationCurve.js:280-283`: `if (i >= key_count - 1) return keys[key_count - 1].value;`), so the whole interval `[0.75, 1.0)` returns the *peak* value instead of ramping back down toward the footfall trough at phase 1.0 ≡ 0.0. `BreathRhythmEvaluator` (`mastery/BreathRhythmEvaluator.js:30`) shows the intended pattern — it closes the loop with `Keyframe.from(1.00, 1.0)`.
125
+ - **Failure scenario**: player is sprinting with `state.stridePhase = 0.95` (a fifth of a stride before the R-foot footfall, which the file's own comment marks as the `0.96` penalty region) and presses jump. `StrideTimingJumpEvaluator.evaluate` → `curve.evaluate(0.95)` → clamps to `keys[3].value = 1.12` → the jump impulse gets the full **+12 % midstance bonus** instead of the ≈ −4 % footfall penalty — a 17 % swing in the wrong direction, over 25 % of every gait cycle. Identical error for slide entry velocity (`1.06` instead of `≈0.97`).
126
+ - **Fix**: append `Keyframe.from(1.00, 0.96)` to `makeDefaultStrideTimingJumpCurve()` and `Keyframe.from(1.00, 0.97)` to `makeDefaultSlideInitiationTimingCurve()`, matching the phase-0 value so the curve is continuous across the wrap.
127
+ - **Confidence**: Confirmed
128
+
129
+ ---
130
+
131
+ ### INPUT-10 `observePinch` mixes client-space and element-space coordinates; `TouchDevice` cannot satisfy its device contract
132
+ - **Severity**: MAJOR
133
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/touch/observePinch.js:52`
134
+ - **Defect**: `computeTouchRadius` derives `center` from `getTouchCenter` (`touch/getTouchCenter.js:20` — raw `clientX`/`clientY`, no bounding-rect subtraction) but derives each per-touch position via `device.readPointerPositionFromEvent` (`PointerDevice.js:464` → `readPositionFromMouseEvent`, which **does** subtract `getBoundingClientRect().top/left`). Line 63 then computes `v2.sub(center)` across the two different frames. Separately, `TouchDevice` passes itself as `device` (`touch/TouchDevice.js:45`) but implements no `readPointerPositionFromEvent`, so the call is a `TypeError`.
135
+ - **Failure scenario** (the JSDoc-sanctioned use, `@param {PointerDevice} device`): canvas whose bounding rect starts at `(100, 50)`. Two fingers 100 px apart horizontally, centred at `clientX = 300`. `center` = `(300, y)` in client space; each touch's `v2` = `(250 − 100, …) = (150, …)` and `(350 − 100, …) = (250, …)` in element space. `v2.sub(center).abs()` yields `150` and `50`, averaging to a pinch radius of **100 px** where the true half-extent is **50 px** — off by the element's left offset, and it changes with where the canvas sits on the page. Every `pinch`/`pinchStart` payload is therefore wrong, and pinch-zoom scales by a page-layout-dependent factor.
136
+ With `device` being a `TouchDevice` (the only in-repo wiring), `handlePinchStart` throws `TypeError: device.readPointerPositionFromEvent is not a function`; Signal's `try/catch` logs it, leaves `pinchActive === true`, and `pinchStart`/`pinch` never fire while `pinchEnd` still fires on lift.
137
+ - **Fix**: compute the centre in the same frame as the samples — replace `getTouchCenter(touchList, center)` with an average of `device.readPointerPositionFromEvent(...)` over the touch list (or subtract the bounds once from the `getTouchCenter` result). Give `TouchDevice` a `readPointerPositionFromEvent(result, touch)` that delegates to `readPositionFromMouseEvent` with its own element.
138
+ - **Confidence**: Confirmed
139
+
140
+ ---
141
+
142
+ ### INPUT-11 Key-up `preventDefault` is gated on the wrong signal
143
+ - **Severity**: MINOR
144
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/KeyboardDevice.js:142`
145
+ - **Defect**: `#handlerKeyUp` decides whether to suppress the browser default with `if (button.down.hasHandlers())` — the *press* signal — instead of `button.up.hasHandlers()`. Copy-paste from `#handlerKeyDown:112`.
146
+ - **Failure scenario**: the bound element carries `tabindex` (required by the constructor) and the game subscribes only to `keys.space.up` (a "release to throw" binding) with no `keys.space.down` handler. Pressing and releasing Space: `keydown` is not prevented (no `down` handler — correct), and `keyup` is *also* not prevented because the check reads `down.hasHandlers() === false` — so the browser's default Space-on-keyup activation (page scroll / activation of the focused control) fires alongside the game action. The mirror case also misfires: a binding with only a `down` handler needlessly suppresses the `keyup` default.
147
+ - **Fix**: `if (button.up.hasHandlers()) { should_prevent_default = true; }`.
148
+ - **Confidence**: Confirmed
149
+
150
+ ---
151
+
152
+ ### INPUT-12 Window blur releases keys without dispatching the aggregate `on.up` signal
153
+ - **Severity**: MINOR
154
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/KeyboardDevice.js:187`
155
+ - **Defect**: `#handleWindowBlurEvent` calls `this.keys[keyName].release()` for every key but never calls `this.on.up.send1(...)`. `#handlerWindowKeyUp:178` does dispatch `on.up` alongside the same `release()`, so the two recovery paths disagree. `on.up` is a documented public signal and a legal binding path (`"keyboard/on/up"` resolves through `resolvePath` in both `InputSystem` and `InputControllerSystem`).
156
+ - **Failure scenario**: a binding is registered on the path `keyboard/on/up` (e.g. a generic "any key released" handler that stops a charged action). The player holds the key and Alt-Tabs away. The per-key switch releases (so `keys.x.is_down` is correct) but `on.up` never fires, so the aggregate-bound listener never sees the release and the charged action stays engaged after the player returns.
157
+ - **Fix**: dispatch a release notification from the blur handler for each key that was actually down — either synthesise the `on.up` send per released key, or restructure so both recovery paths funnel through one "force release key" helper that owns both the switch and `on.up`.
158
+ - **Confidence**: Confirmed
159
+
160
+ ---
161
+
162
+ ### INPUT-13 `eventToSourceIdentifier` throws where `TouchEvent` is not a global
163
+ - **Severity**: MINOR
164
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/events/eventToSourceIdentifier.js:17`
165
+ - **Defect**: `event instanceof TouchEvent` references the bare global. `TouchEvent` is undefined on desktop Safari and on Firefox builds with `dom.w3c_touch_events.enabled = 0`. The sibling `devices/isHTMLElementFocusable.js:7` guards precisely this case (`if (typeof klass !== "function") return false;`), so the codebase already knows the hazard.
166
+ - **Failure scenario**: on desktop Safari, calling `eventToSourceIdentifier(keyboardEvent)` (or any event that is not a `MouseEvent`/`PointerEvent`/`WheelEvent`) skips the first branch and evaluates `instanceof TouchEvent` → `ReferenceError: Can't find variable: TouchEvent`. Mouse-family events are unaffected because they short-circuit on the first branch, which is why this survives testing on Chrome.
167
+ - **Fix**: guard with `typeof TouchEvent === "function" && event instanceof TouchEvent`, or reuse the `isInstanceOf` helper pattern from `isHTMLElementFocusable.js`.
168
+ - **Confidence**: Confirmed
169
+
170
+ ---
171
+
172
+ ### INPUT-14 `pose.postureAmount` saturates to 1 during a crouch→stand transition, then snaps to 0
173
+ - **Severity**: MINOR
174
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/control/first-person/FirstPersonPlayerControllerSystem.js:1697`
175
+ - **Defect**: `postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3)`. For `Stand` (and `Hang`) `postureTargetH === cfg.body.height`, so the span collapses to the `1e-3` floor and `postureAmount = clamp((height - eyeHeight) / 1e-3, 0, 1)` is a step function on a 1 mm threshold rather than a blend weight.
176
+ - **Failure scenario**: the player releases crouch. `state.posture` becomes `Stand` on that tick, but `eyeHeightSpring` needs `crouch.transitionTime = 0.18 s` to climb from `0.8` to `1.8`. Throughout that ~11-tick window `(1.8 − eyeHeight)` is ≫ 1e-3, so `pose.postureAmount` publishes a hard **1.0** (documented as "fully in the posture's target shape") while `pose.posture` says `Stand`; the instant the spring lands within 1 mm it drops to **0.0**. A rig blending its Stand track by `postureAmount` gets full weight for the entire stand-up and a single-frame pop at the end.
177
+ - **Fix**: when the posture's target height equals the standing height (Stand, Hang), publish `postureAmount` from that posture's own semantics rather than dividing by a degenerate span — e.g. `postureAmount = 0` for `Stand`, and for `Hang` a dedicated channel (or reuse `crouchAmount`'s span) instead of the `1e-3` floor.
178
+ - **Confidence**: Confirmed
179
+
180
+ ---
181
+
182
+ ### INPUT-15 `TouchDevice` never refreshes its move anchor on touch end — first move after lifting a finger reports a bogus delta
183
+ - **Severity**: MINOR
184
+ - **File**: `H:/git/moh/app/src/mir-engine/meep/src/engine/input/devices/touch/TouchDevice.js:62`
185
+ - **Defect**: `#eventHandlerTouchStart` seeds `#anchor_touch_last` and `#eventHandlerTouchMove` advances it, but `#eventHandlerTouchEnd` recomputes `this.position` from the *remaining* `event.touches` without updating `#anchor_touch_last`. The next `touchmove` therefore differences the new (single-finger) centroid against the stale (multi-finger) centroid.
186
+ - **Failure scenario**: two-finger pan with fingers at `clientX = 100` and `clientX = 500` — centroid `300`, anchor `300`. Lift the right finger: `touchend` sets `position` to `100` but leaves the anchor at `300`. The very next `touchmove` emits `delta = (100 + ε) − 300 ≈ −200 px` in a single frame, so a pan/camera consumer jumps 200 px sideways when the user merely lifts one finger.
187
+ (Note: `TouchDevice`'s handlers are never attached to a DOM target — the separately-captured `TouchDevice.js:53` finding — so this is currently only reachable through the class as public library surface. It is a distinct defect that will survive fixing the attachment.)
188
+ - **Fix**: `this.#anchor_touch_last.copy(this.position);` at the end of `#eventHandlerTouchEnd` (and in a `touchcancel` handler), matching what `#eventHandlerTouchStart` already does.
189
+ - **Confidence**: Confirmed
190
+
191
+ ---
192
+
193
+ ## Notes requested by the brief
194
+
195
+ **`phaseCrossed` exactly-on-boundary landing** (`FirstPersonPlayerControllerSystem.js:2019`) — the no-wrap branch's `prev < boundary` still excludes a phase that is already sitting exactly on the boundary, so the condition is unchanged; in practice it is benign, because the only way the phase reaches a boundary is the crossing that already fired, and a phase landing exactly *on* a boundary from below is caught by `next >= boundary`. The one genuine miss (`next === prev` after an exact full-cycle advance in one step) needs `strideFreq·0.5·dt === 1.0` exactly and is unreachable at sane frequencies.
196
+
197
+ **`WallRunScenarios.spec.js`** — the suite is green and no longer documents any unimplemented behaviour: all eight tests assert real, currently-implemented outcomes (four "happy" paths including the wall-kick chain, four "unhappy" refusals). It does, however, only exercise the wall-kick with the jump button *released before* wall-run engages, which is exactly why INPUT-4 is invisible to it.
@@ -0,0 +1,248 @@
1
+ # Correctness review — engine misc (save, metrics, platform, google, ui, options, scene, plugin, knowledge, achievements, development, logging, notify, simulation, reference, engine root)
2
+
3
+ Reviewed 2026-08-06. Correctness only. All paths absolute under
4
+ `H:/git/moh/app/src/mir-engine/meep/src/engine/`.
5
+
6
+ ---
7
+
8
+ ### EMISC-1 IndexedDB store reports success before the transaction commits (silent save loss)
9
+ - **Severity**: MAJOR
10
+ - **File**: `save/storage/IndexedDBStorage.js:98-128` (also `remove` at :45-76)
11
+ - **Defect**: `store()` resolves the caller on the *request*'s `success` event. In IndexedDB a request succeeding does not mean the data is durable — the write is only committed when the enclosing transaction fires `complete`. A transaction that aborts after the request succeeded (quota exceeded at commit, browser eviction, page teardown) never notifies anyone, because no `abort`/`error` listener is attached to the transaction. The file's own header says `TODO add QuotaExceededError handling`.
12
+ - **Failure scenario**: `GameSaveStateManager.store()` → `storage.store('game-save-state-<id>-data', <8 MB ArrayBuffer>, resolve, reject)`. The `put` request fires `success`, `resolve()` runs, `GameStateLoader.save()` resolves and the UI prints "saved". The transaction then aborts with `QuotaExceededError` on commit. Nothing rejects, nothing logs. The slot id was already appended to the id list by `StorageBackedSet` (a separate transaction that fit), so the next `update()` lists a save whose data key does not exist; loading it resolves with `undefined` (see EMISC-11) and the run is unrecoverable.
13
+ - **Fix**: hold the transaction (`const tx = db.transaction(...)`), resolve on `tx.addEventListener('complete', ...)` and reject on `tx.addEventListener('abort'|'error', ...)`; keep the request-level `error` listener only to capture the reason.
14
+ - **Confidence**: Confirmed
15
+
16
+ ---
17
+
18
+ ### EMISC-2 `Engine.start()` throws when constructed with `enableAudio: false`
19
+ - **Severity**: MAJOR
20
+ - **File**: `Engine.js:584`
21
+ - **Defect**: The constructor only creates `this.sound` when `enableAudio !== false` (`Engine.js:290-302`), and `#initialize_audio()` explicitly guards `sound === undefined` (`Engine.js:421-424`). `start()` does not: `this.sound.start()` is evaluated unconditionally while building the `Promise.all` array, so it throws synchronously before `start()` returns a promise.
22
+ - **Failure scenario**: `const e = new Engine(new InMemoryEnginePlatform(), {enableAudio:false, enableGraphics:false}); await e.start();` → `TypeError: Cannot read properties of undefined (reading 'start')` thrown synchronously out of `start()`. This exact construction is used by in-repo specs (`Engine.spec.js:12`, `EngineConfiguration.spec.js:24`, `ecs/dynamic_actions/DynamicActorSystem.spec.js:12`) — none can call `start()` today.
23
+ - **Fix**: `const p_sound = this.sound === undefined ? Promise.resolve() : this.sound.start();` then chain `promiseEntityManager` off `p_sound`.
24
+ - **Confidence**: Confirmed
25
+
26
+ ---
27
+
28
+ ### EMISC-3 `Engine.render()` dereferences `graphics` before the guard that tests it
29
+ - **Severity**: MAJOR
30
+ - **File**: `Engine.js:542-549`
31
+ - **Defect**:
32
+ ```js
33
+ const graphics = this.graphics;
34
+ if (graphics.autoDraw) { graphics.needDraw = true; } // line 544 — unguarded
35
+ if (graphics && this.renderingEnabled && graphics.needDraw) { ... } // line 549 — guard, too late
36
+ ```
37
+ The later `graphics &&` proves the author expects `graphics` to be absent (it is, whenever `enableGraphics:false`).
38
+ - **Failure scenario**: `new Engine(platform, {enableGraphics:false, enableAudio:true})`, then `start()`. `start()` schedules `requestAnimationFrame(this.#animation_frame)` (`Engine.js:596`), which calls `this.render()` (`Engine.js:621`) → `TypeError: Cannot read properties of undefined (reading 'autoDraw')` on every animation frame, forever, and the frame-delay metric at `Engine.js:629` is never recorded. Two other sites in scope have the same unguarded dependency once graphics is disabled: `ui/GUIEngine.js:481` (`engine.graphics.domElement.classList` inside the cursor `process` callback, run during `startup`) and `ui/bindings/DomElementProcessorManager.js:300,309`.
39
+ - **Fix**: move the `autoDraw` read inside the existing `graphics !== undefined` guard (single `if (graphics !== undefined && this.renderingEnabled) { if (graphics.autoDraw) graphics.needDraw = true; ... }`), and guard the two GUI sites the same way.
40
+ - **Confidence**: Confirmed
41
+
42
+ ---
43
+
44
+ ### EMISC-4 `Engine.stop()` throws instead of shutting down, and never stops the render loop
45
+ - **Severity**: MAJOR
46
+ - **File**: `Engine.js:635-656` (interacts with `simulation/Ticker.js:250`)
47
+ - **Defect**: `stop()` calls `this.ticker.pause()`. `Ticker.pause()` throws `Error("Not currently running")` whenever `is_active` is false (`simulation/Ticker.js:261-263`), and `is_active` requires *both* handles to be set — but `Ticker.start()` discards the handle of its first `requestAnimationFrame` (`Ticker.js:250`), so `is_active` stays `false` from `start()` until the first rAF *and* the following `setTimeout(...,0)` have both run. Additionally `stop()` uses `pause()` rather than `ticker.stop()`, so the ticker's rAF/timeout loop keeps cycling after shutdown, and the engine's own `#animation_frame` rAF loop (`Engine.js:616-630`) is never cancelled at all — no handle is ever stored for it.
48
+ - **Failure scenario A**: `await engine.start(); await engine.stop();` (the options load at `Engine.js:603` resolves on a microtask with `InMemoryStorage`, well before the first rAF) → `stop()` throws at the first statement after the monitor stop; `entityManager.shutdown`, `plugins.shutdown`, `gui.shutdown` and `assetManager.shutdown` never run, leaving all listeners, timers and GPU resources alive.
49
+ - **Failure scenario B**: the same in a backgrounded tab, where rAF never fires — `is_active` is permanently false, so `engine.stop()` can never succeed.
50
+ - **Failure scenario C**: after a `stop()` that does get past the throw, `#animation_frame` keeps calling `this.render()` every frame forever, retaining the whole engine graph.
51
+ - **Fix**: call `this.ticker.stop()` (idempotent, no throw) in `Engine.stop()`; store the `#animation_frame` handle in a field and `cancelAnimationFrame` it in `stop()`; store the first rAF handle in `Ticker.start()` (see known-issue list).
52
+ - **Confidence**: Confirmed
53
+
54
+ ---
55
+
56
+ ### EMISC-5 `Engine.start()` reports success after a failed startup
57
+ - **Severity**: MAJOR
58
+ - **File**: `Engine.js:583-610`
59
+ - **Defect**: The `Promise.all([...])` rejection handler is `function (e) { logger.error(...) }` — it logs and returns `undefined`, which *fulfils* the promise returned by `start()`. Nothing downstream can tell startup apart from failure, and the fulfilment branch (ticker start, options load) is skipped.
60
+ - **Failure scenario**: `staticKnowledge.load()` fails to fetch `data/database/.../data.json` (404 after a deploy). `start()` resolves. `EngineHarness.initialize` then proceeds to `document.body.appendChild(...)` and resolves the harness promise with a "started" engine whose ticker was never started (`Engine.js:599` skipped), whose options were never loaded (`:603` skipped) and whose knowledge tables are empty — the game renders a black screen with no error surfaced to the caller.
61
+ - **Fix**: rethrow after logging (`(e) => { logger.error(...); throw e; }`) so `start()` rejects.
62
+ - **Confidence**: Confirmed
63
+
64
+ ---
65
+
66
+ ### EMISC-6 `Clock.pause()` accumulates time while the clock is already stopped
67
+ - **Severity**: MAJOR
68
+ - **File**: `Clock.js:87-91`
69
+ - **Defect**:
70
+ ```js
71
+ pause() {
72
+ this.__isRunning = false;
73
+ updateElapsedTime(this); // module-level fn: adds (now - __lastMeasurement) * speed unconditionally
74
+ }
75
+ ```
76
+ It calls the *module* helper, bypassing the `__isRunning` check in `Clock.updateElapsedTime()` (`:108-113`). Every `pause()` on an already-paused (or never-started) clock injects the interval since the last measurement into `elapsedTime`.
77
+ - **Failure scenario**: player alt-tabs; `Ticker.#suspend()` calls `clock.pause()` at t=100 s (`simulation/Ticker.js:120`). Ten minutes later, while still hidden, the app pauses explicitly (`ticker.pause()` → `clock.pause()`, `Ticker.js:269`). The second `pause()` adds 600 s to `elapsedTime` even though the clock was stopped the whole time. `DynamicActorSystem.getCurrentTime()` (`ecs/dynamic_actions/DynamicActorSystem.js:174`) and `VoiceSystem.getCurrentTime()` (`ecs/speaker/VoiceSystem.js:262`) read that value directly, and cooldowns are stored as absolute ready-times (`DynamicActorSystem.js:248,405`) — so on return every global cooldown is considered expired and the actor barks fire in a burst. Second instance of the same defect: `new Ticker()` calls `clock.pause()` in its constructor (`Ticker.js:72`) on a clock whose `__lastMeasurement` is still `0`, so a fresh `Engine`'s clock starts at "seconds since page load" instead of 0, contradicting `getElapsedTime()`'s documented contract.
78
+ - **Fix**: `pause() { this.updateElapsedTime(); this.__isRunning = false; }` — use the guarded instance method and clear the flag afterwards.
79
+ - **Confidence**: Confirmed
80
+
81
+ ---
82
+
83
+ ### EMISC-7 Plugins that skip `super.initialize()/super.startup()` never leave `ProcessState.New`, so startup re-runs
84
+ - **Severity**: MAJOR
85
+ - **File**: `achievements/AchievementManager.js:232-238` and `:421-430`; `save/GameStateLoader.js:15-17`
86
+ - **Defect**: `BaseProcess` is what advances the state machine (`core/process/BaseProcess.js:24-59`), and `PluginReferenceContext` navigates purely on that state (`plugin/PluginReferenceContext.js:20-95`). `AchievementManager.initialize()` overrides `EnginePlugin.initialize()` without calling `super.initialize(engine)`, and `AchievementManager.startup()`/`GameStateLoader.startup()` never call `super.startup()`. Their state therefore stays `New` (resp. `Initialized`) forever. `navigate_to_running` re-runs `initialize()` + `startup()` on *every* transition request instead of short-circuiting, and `navigate_to_finalized` returns immediately for state `New` — so `shutdown()` is never invoked. `ui/bindings/DomElementProcessorManager.js:267-280` does it correctly, which shows the intended contract.
87
+ - **Failure scenario**: any plugin that declares `AchievementManager` in `dependencies`, or a second `plugins.startup()` (engine restart), routes through `__transition_plugin_to_state` (`plugin/EnginePluginManager.js:75-101`), which sees `dependency_state !== Running` and calls `transition(Running)` again → `navigate_to_running` → `AchievementManager.startup()` a second time → `loadDefinitions()` pushes a full duplicate set into `this.entries` (`AchievementManager.js:249-255`). Result: every achievement appears twice in the list UI, `activate()` registers two handlers per id while `this.handlers[id]` keeps only the last one (the first can never be removed by `deactivateEntry`), and an already-unlocked achievement re-presents its popup + sound. Separately, `Engine.stop()` → `plugins.shutdown()` never calls `AchievementManager.shutdown()` at all, so blackboard references and trigger subscriptions survive engine teardown.
88
+ - **Fix**: call `super.initialize(engine)` at the end of `AchievementManager.initialize`, and `await super.startup()` / `super.startup()` in both `startup()` overrides.
89
+ - **Confidence**: Confirmed
90
+
91
+ ---
92
+
93
+ ### EMISC-8 `AchievementManager.startup()` does not await `initializeGateway()`
94
+ - **Severity**: MAJOR
95
+ - **File**: `achievements/AchievementManager.js:421-430` (call at :427)
96
+ - **Defect**: `this.initializeGateway();` is fired without `await`, then `this.isStarted.set(true)` runs immediately. `initializeGateway` awaits `gateway.getUnlocked()` (a storage round trip) before it sets `isGatewayInitialized`. Setting `isStarted` flips `isActive` (`:108-119`) → `activate()` → `activateEntry()` → `ReactiveExpression.process()` which invokes the handler synchronously with the current value (`core/model/reactive/model/ReactiveExpression.js:88-95`).
97
+ - **Failure scenario**: a save is loaded whose blackboard already satisfies an achievement condition, so the blackboard is attached before startup. `startup()` → `isStarted.set(true)` → `activate()` → handler fires `true` → `unlock(id)`. Inside `unlock`, `this.isGatewayInitialized.getValue()` is still `false` (`:157`), so `deactivateEntry()` and `present()` are both skipped: the achievement is written to the gateway and to the blackboard but the player never sees the notification, and the trigger stays subscribed so it can fire again. Additionally, a rejection from `getUnlocked()` becomes an unhandled promise rejection with no state change at all (`isGatewayInitialized` stays false permanently, so *no* achievement is ever presented for the rest of the session).
98
+ - **Fix**: `await this.initializeGateway();` before `this.isStarted.set(true)`.
99
+ - **Confidence**: Confirmed
100
+
101
+ ---
102
+
103
+ ### EMISC-9 `EngineHarness.initialize()` uses an async function as a Promise executor — failures vanish and the promise never settles
104
+ - **Severity**: MAJOR
105
+ - **File**: `EngineHarness.js:126-166`
106
+ - **Defect**: `new Promise(async function (resolve, reject) { ... })`. Any rejection inside the executor rejects the *executor's own* (discarded) promise; `resolve`/`reject` are never called, so the promise returned by `initialize()` — and cached in `this.p` — stays pending forever. There is no `try/catch` and `reject` is never used.
107
+ - **Failure scenario**: a plugin's `startup()` throws inside `await config.apply(engine)` (`:135`), or `await engine.start()` rejects (`:140`). `await EngineHarness.bootstrap({configuration})` never resolves and never rejects: the harness hangs with a blank page, no console error, and the memoised `this.p` guarantees every retry hangs too.
108
+ - **Fix**: make the body a plain async function and return its promise (`const promise = (async () => { ... return engine; })();`), or wrap the executor body in `try { ... } catch (e) { reject(e); }`.
109
+ - **Confidence**: Confirmed
110
+
111
+ ---
112
+
113
+ ### EMISC-10 `GameStateLoader.count()` has no rejection path — the promise never settles on failure
114
+ - **Severity**: MAJOR
115
+ - **File**: `save/GameStateLoader.js:119-136`
116
+ - **Defect**: `gameSaves.update().then(() => { ... resolve(matches.length); });` — no second `then` argument, no `.catch(reject)`, unlike `load()` (`:110`) and `exists()` (`:164`) which both wire `reject`.
117
+ - **Failure scenario**: `update()` rejects because the IndexedDB list read fails (private-browsing quota, corrupt store, DB open error propagated from `IndexedDBStorage.list`'s `.catch(reject)`). The `resolve` callback is never called and `reject` is never called, so a caller doing `new Promise((res, rej) => loader.count(name, res, rej))` awaits forever — the save-slot UI spins with no error, and the failure only shows up as an unhandled rejection in the console.
118
+ - **Fix**: append `.catch(reject)` (and pass a `reject` through, matching `exists()`).
119
+ - **Confidence**: Confirmed
120
+
121
+ ---
122
+
123
+ ### EMISC-11 `GameStateLoader.load()` resolves with `undefined` for a save that does not exist
124
+ - **Severity**: MAJOR
125
+ - **File**: `save/GameStateLoader.js:68-111` (legacy branch at :101-103)
126
+ - **Defect**: When no non-locked metadata matches, `load()` falls back to `legacyLoad` → `storage.loadBinary(name, resolve, ...)`. Both storage backends deliberately use `undefined` as the "missing key" sentinel (`save/storage/IndexedDBStorage.js:88-90` resolves `event.target.result`; `save/storage/InMemoryStorage.js:15` resolves `this.#data.get(key)`), and every other consumer checks for it (`save/StorageBackedList.js:59`, `achievements/gateway/StorageAchievementGateway.js:29`). `load()` does not: it forwards `undefined` to the success callback, so "no such save" is reported as "load succeeded, here is your data".
127
+ - **Failure scenario**: `model/game/Game.js:102-106` calls `gameStateLoader.load('auto-save', json => loadStateJSON(json))` with no reject handler. With no auto-save present, `loadStateJSON(undefined)` → `game.initializeFromState(undefined)` → TypeError deep inside state restoration instead of a clean "no save found". The same path is reachable from any UI that offers Load before a save exists.
128
+ - **Fix**: in the legacy branch, reject (or resolve a documented sentinel) when the loaded value is `undefined`, e.g. `lResolve` wrapped: `data => data === undefined ? lReject(new Error(...)) : lResolve(data)`.
129
+ - **Confidence**: Confirmed
130
+
131
+ ---
132
+
133
+ ### EMISC-12 `pickDefaultLocale()` can return a locale that is not among the supported options
134
+ - **Severity**: MAJOR
135
+ - **File**: `platform/WebEnginePlatform.js:35-98` (`:81-85`)
136
+ - **Defect**: When no supplied locale scores above 0, the function returns the hard-coded `'en-gb'` regardless of whether `'en-gb'` is in `localeOptions`. The documented contract (`platform/EnginePlatform.js:18-22`: `@param {string[]} options @returns {string}`) is that the result is one of `options`.
137
+ - **Failure scenario**: a title shipping `['ru', 'de']` runs on a browser with `navigator.languages === ['ja-JP']`. Every option scores 0, so `pickDefaultLocale` returns `'en-gb'`. `Localization.loadLocale('en-gb')` then requests a locale file that was never built; the load fails and every `getString(key)` renders the literal `@key` placeholder throughout the UI.
138
+ - **Failure scenario (secondary)**: `localeOptions === []` → `array_pick_best_element` returns `undefined` → `best.score` throws `TypeError`.
139
+ - **Fix**: fall back to `localeOptions[0]` (mirroring `InMemoryEnginePlatform.pickDefaultLocale`) rather than a hard-coded id.
140
+ - **Confidence**: Confirmed
141
+
142
+ ---
143
+
144
+ ### EMISC-13 The `meep` library imports source from the host application
145
+ - **Severity**: MAJOR
146
+ - **File**: `save/GameStateLoader.js:1`, `scene/SerializedScene.js:1`
147
+ - **Defect**:
148
+ - `import { GameSaveStateMetadata } from "../../../../view/game/save/GameSaveStateMetadata.js";` resolves to `app/src/mir-engine/view/game/save/GameSaveStateMetadata.js` — outside the `meep/` package. The symbol is referenced only from a JSDoc `@type`, so the runtime dependency is entirely gratuitous.
149
+ - `import { MirScene } from "../../../../model/game/scenes/MirScene.js";` resolves to `app/src/mir-engine/model/game/scenes/MirScene.js`; `SerializedScene extends MirScene`, i.e. an engine class is rooted in a game-specific class.
150
+ - **Failure scenario**: any consumer that installs `meep` on its own (the package is published as `@woosh/meep-engine`, and app code already imports it by that specifier — see `view/game/save/GameSaveStateManager.js:1-3`) fails at bundle time with `Module not found: ../../../../view/game/save/GameSaveStateMetadata.js`. Inside this repo the effect is that the entire `view/game/save` and `model/game/scenes` subtrees are pulled into any bundle that touches the save subsystem.
151
+ - **Fix**: delete the `GameSaveStateMetadata` import (JSDoc-only — reference the type by name or drop the annotation). For `SerializedScene`, base it on the engine's own `Scene` and let the game subclass it, or move `SerializedScene` out of `meep` into the app.
152
+ - **Confidence**: Confirmed
153
+
154
+ ---
155
+
156
+ ### EMISC-14 `getURLHash()`'s non-browser guard throws instead of guarding
157
+ - **Severity**: MINOR
158
+ - **File**: `platform/GetURLHash.js:8-10`
159
+ - **Defect**: `if (window === undefined) { return result; }` — in an environment where `window` is not *declared*, evaluating the identifier throws `ReferenceError` before the comparison happens; in a browser the comparison is never true. The guard can therefore never fire. The correct form is `typeof window === "undefined"` (compare with `browserInfo.js:12-16` and `simulation/Ticker.js:146`, which both use `globalThis.X === undefined` correctly).
160
+ - **Failure scenario**: `getURLHash()` (public export, also used by `EngineHarness.setLocale` at `EngineHarness.js:45`) called from Node/SSR/a worker → `ReferenceError: window is not defined`, where the author's intent was an empty `{}`.
161
+ - **Fix**: `const w = globalThis.window; if (w === undefined) { return result; }`.
162
+ - **Confidence**: Confirmed
163
+
164
+ ---
165
+
166
+ ### EMISC-15 `Scene.setup()` reads the global `name` instead of `this.name`
167
+ - **Severity**: MINOR
168
+ - **File**: `scene/Scene.js:84`
169
+ - **Defect**: `` promiseTask(..., `${name} scene setup`) `` — `name` is not in scope; it resolves to the global. There is no local, parameter or class field named `name` in that method.
170
+ - **Failure scenario**: in a browser, `window.name` is normally `""`, so a scene called "main" produces the task label `" scene setup"` instead of `"main scene setup"`, and that label is what the loading screen shows. In Node (specs, headless tooling) there is no global `name`, so `Scene.setup()` throws `ReferenceError: name is not defined` — module code is strict, so there is no implicit-global fallback.
171
+ - **Fix**: `` `${this.name} scene setup` ``.
172
+ - **Confidence**: Confirmed
173
+
174
+ ---
175
+
176
+ ### EMISC-16 `IndexedDBStorage.list()` uses the removed `IDBTransaction.READ_ONLY` constant
177
+ - **Severity**: MINOR
178
+ - **File**: `save/storage/IndexedDBStorage.js:133`
179
+ - **Defect**: `db.transaction(MAIN_STORE_NAME, IDBTransaction.READ_ONLY)` while every other method in the file correctly uses `IndexedDBTransactionMode.ReadOnly`. The legacy `IDBTransaction` constants were removed from the spec; in current browsers the expression evaluates to `undefined` (which WebIDL then defaults back to `"readonly"`, so it happens to work), and the statement additionally hard-depends on the `IDBTransaction` *global* existing.
180
+ - **Failure scenario**: running against an IndexedDB shim that provides `indexedDB` but does not install `IDBTransaction` as a global (a common way to polyfill for tests/Node) — `store`, `load` and `remove` all work, while `list()` alone throws `ReferenceError: IDBTransaction is not defined`, which surfaces as `Storage.contains()` failing (`save/Storage.js:105-110` is implemented on top of `list`).
181
+ - **Fix**: `IndexedDBTransactionMode.ReadOnly`.
182
+ - **Confidence**: Confirmed
183
+
184
+ ---
185
+
186
+ ### EMISC-17 One failed achievement write poisons every later `unlock()`
187
+ - **Severity**: MINOR
188
+ - **File**: `achievements/gateway/StorageAchievementGateway.js:39-74`
189
+ - **Defect**: The serialisation chain is built with `this.last.finally(cb)`. `Promise.prototype.finally` propagates the *upstream* settlement: once `this.last` is rejected, every subsequent `unlock()` returns a promise that rejects with the original, stale reason even when its own write succeeded — and `this.last` is then re-poisoned, permanently.
190
+ - **Failure scenario**: `unlock('first_blood')` fails (storage error). `this.last` is a rejected promise. `unlock('level_10')` later writes the id to storage successfully, but the promise it returns rejects with the `first_blood` error. `AchievementManager.unlock()` ignores the returned promise (`achievements/AchievementManager.js:149`), so this manifests as one unhandled rejection per achievement for the rest of the session, and any caller that does await it sees a false failure.
191
+ - **Fix**: chain with `this.last.then(cb, cb)` (as `StorageBackedSet.read` does at `save/StorageBackedList.js:71`) so each write's result is independent.
192
+ - **Confidence**: Confirmed
193
+
194
+ ---
195
+
196
+ ### EMISC-18 `browserInfo()` returns `version: undefined` on Opera, and does not cache the IE/Opera results
197
+ - **Severity**: MINOR
198
+ - **File**: `browserInfo.js:26-31` (cache at `:8`,`:45`)
199
+ - **Defect**: `ua.match(/\bOPR|Edge\/(\d+)/)` — the capture group belongs only to the `Edge/(\d+)` alternative, so for an Opera UA (`... OPR/98.0.4759.15`) the `\bOPR` branch matches and `tem[1]` is `undefined`. The function returns `{name:'Opera', version: undefined}`. The IE and Opera branches also `return` before `cached = result`, so the memoisation at `:8` never applies to them (every call re-runs the regexes).
200
+ - **Failure scenario**: on Opera, `browserInfo().version` is `undefined`; any gate of the form `browserInfo().version >= 90` evaluates `undefined >= 90` → `false`, so a version-gated code path is taken as if the browser were ancient. (Chromium Edge's UA token is `Edg/`, not `Edge/`, so Edge never matches this branch at all and is reported as Chrome.)
201
+ - **Fix**: `/\b(?:OPR|Edge)\/(\d+)/` and assign `cached` on every return path.
202
+ - **Confidence**: Confirmed
203
+
204
+ ---
205
+
206
+ ### EMISC-19 `count()` counts locked saves that `exists()` and `load()` deliberately ignore
207
+ - **Severity**: MINOR
208
+ - **File**: `save/GameStateLoader.js:131` vs `:84-95` and `:155`
209
+ - **Defect**: `load()` filters `m.locked` out and `exists()` filters `!m.locked`, but `count()` matches on name only.
210
+ - **Failure scenario**: the only save named `"quicksave"` is locked. `count('quicksave')` → `1`, so an overwrite-confirmation dialog is shown ("1 save exists, overwrite?"), while `exists('quicksave')` → falls through to `legacyExists` → `false` and `load('quicksave')` falls through to the legacy path and resolves `undefined` (EMISC-11). The three APIs disagree about the same slot.
211
+ - **Fix**: apply the same `!m.locked` predicate in `count()`.
212
+ - **Confidence**: Confirmed
213
+
214
+ ---
215
+
216
+ ### EMISC-20 `DomElementProcessorManager.unlink()` leaves every binding live; a restart double-binds
217
+ - **Severity**: MINOR
218
+ - **File**: `ui/bindings/DomElementProcessorManager.js:303-310` (vs `link` at `:286-301`)
219
+ - **Defect**: `link()` walks the tree and creates a `DomElementBinding` per match, storing them in `__element_to_bindings`. `unlink()` disconnects the observer and removes the pre-render handler but never calls `binding.unbind()` (which is what runs `processor.shutdown()`) and never clears `__element_to_bindings`.
220
+ - **Failure scenario**: `plugins.shutdown()` → `unlink()`, then a later `plugins.startup()` → `link()` → `__handleAttachedElement(root)` re-binds every still-attached element, appending a *second* binding to the existing array. `__handle_frame_entry` then runs both processors per element per frame; for `DomElementProcessorTilt3D` two handlers write `el.style.transform` in sequence each frame, and the first generation's processors can never be shut down because their bindings are unreachable through any element-removal path that would call `unbind()`.
221
+ - **Fix**: in `unlink()`, iterate `__element_to_bindings`, call `unbind()` on each binding, then `clear()` the map.
222
+ - **Confidence**: Confirmed
223
+
224
+ ---
225
+
226
+ ### EMISC-21 Viewport sizing always falls back to `window`; the sized-container path is unreachable
227
+ - **Severity**: MINOR
228
+ - **File**: `Engine.js:475-490`
229
+ - **Defect**:
230
+ ```js
231
+ while (parentElement !== null && parentElement.innerWidth === undefined) { parentElement = parentElement.parentElement; }
232
+ ```
233
+ `innerWidth` is a `Window` property; no `Element` ever has it, so the condition holds for every ancestor and the loop always terminates with `parentElement === null` (past `<html>`), after which the code substitutes `window`. The intended "traverse up until we find an element with defined dimensions" can never succeed.
234
+ - **Failure scenario**: the engine is mounted into a 640×480 host `<div>` (an embedded/editor layout) with `graphics_control_viewport_size` enabled. Instead of sizing to the container, `viewStack.size` is set to `window.innerWidth/innerHeight`, so the canvas and the whole GUI stack overflow the container by the full page size, and every `resize` re-applies it.
235
+ - **Fix**: measure the host element with `getBoundingClientRect()`/`clientWidth`/`clientHeight` (checking for a non-zero box) instead of probing for `innerWidth`.
236
+ - **Confidence**: Confirmed
237
+
238
+ ---
239
+
240
+ ## Status of previously-known issues
241
+
242
+ | Known issue | Status |
243
+ | --- | --- |
244
+ | `Ticker.pause()` not pausing the clock | FIXED — `simulation/Ticker.js:269` now calls `this.clock.pause()` (but see EMISC-6: `Clock.pause()` itself over-accumulates). |
245
+ | `Ticker.start()` discarding the rAF handle | STILL PRESENT — `simulation/Ticker.js:250`; consequences: `is_active` is false for the first frame after `start()` (breaks `pause()`/`resume()`/`Engine.stop()`, EMISC-4), and a `stop()` issued before that first frame cannot cancel it, so the loop resurrects itself and runs forever. |
246
+ | `SceneManager.clear()` leaving clock speed-modifiers applied | FIXED — `scene/SceneManager.js:153-165` routes through `deactivateScene()`. |
247
+ | `ElasticSearchLogger` posting a JSON array / `performance.now()` as `@timestamp` | PARTIALLY FIXED — NDJSON `_bulk` body is correct (`logging/elastic/ElasticSearchLogger.js:110-123`); the timestamp is STILL `performance.now()` (`:151`, emitted at `:117`), so every document lands at 1970-01-01 + a few seconds in Elasticsearch. |
248
+ | `GUIEngine.shutdown` leaks | STILL PRESENT — `ui/GUIEngine.js:501-513` does not stop `this.ticker` (started at `:470`), does not unbind `engine.gameView.size.process` (`:464`), does not unbind `this.cursor.process` (`:476`), does not detach `this.view` from `gameView`, and never calls `shutdown()` on any `SceneGUIContext` in `sceneContexts`. |
@@ -0,0 +1,52 @@
1
+ # Terrain second pass (spawned from the engine/ecs review)
2
+
3
+ ### TERR-1 `build_height_field_geometry` produces NaN vertices on 1-cell edge tiles
4
+ - **Severity**: MAJOR
5
+ - **File**: `engine/graphics/geometry/buffered/build_height_field_geometry.js:23-24,39-40`
6
+ - **Defect**: `gridX2 = size.x * resolution - 1`; `uMultiplier = (size.x / totalSize.x) / gridX2`. When a tile is one grid cell wide and `resolution === 1`, `gridX2 === 0` → multiplier `Infinity` → `u = 0 * Infinity + uConst = NaN`.
7
+ - **Failure scenario**: `Terrain.size = (41,41)`, `resolution = 1`, default `tileSize = (10,10)`. `TerrainTileManager.initializeTiles` gives the last column/row tiles `size.x = 41 - 40 = 1`. Every vertex of those 9 edge tiles gets `NaN` position and uv; `indices.length = gridX2 * gridY2 * 6 = 0` so they render nothing. `TerrainTile.computeBoundingBox()` then yields a NaN AABB which is written into the shared `TerrainTileManager.bvh`, propagating NaN up the parent nodes so `raycastFirstSync` (terrain picking, `ClingToTerrain`) starts missing on tiles far from the edge.
8
+ - **Fix**: guard the degenerate span (`gridX2 === 0 ? 0 : ...`) for both axes, or use `gridX1 = size.x * resolution + 1` so each axis always has >= 2 vertices.
9
+ - **Confidence**: Confirmed (arithmetic); the BVH-wide fallout is Likely.
10
+
11
+ ### TERR-2 `TerrainLayers.loadTextureData` writes a completed load into a stale layer index
12
+ - **Severity**: MAJOR
13
+ - **File**: `engine/ecs/terrain/ecs/layers/TerrainLayers.js:199-217`
14
+ - **Defect**: the completion handler captures only the numeric `layerIndex` and re-resolves via `this.get(index)`, never checking the layer is still the one whose load finished. `Terrain.addLayer` (`Terrain.js:684-691`) has exactly this guard, so the hazard is known.
15
+ - **Failure scenario**: terrain built with L0..L3 (batch A in flight). `ThemeEngine.applyTerrainThemes` calls `terrain.layers.clear()`, adds two new layers, `terrain.build()` (batch B). When batch A lands: indices 0-1 overwrite slots batch B already wrote (previous map's ground textures render); indices 2-3 resolve to `undefined` and `__obtain_layer_data_at_resolution` dereferences `layer.diffuse` → TypeError inside a fulfilled handler → unhandled rejection.
16
+ - **Fix**: capture the layer object and short-circuit, mirroring `Terrain.addLayer`.
17
+ - **Confidence**: Likely (needs batch A still in flight).
18
+
19
+ ### TERR-3 `TerrainOverlay.paintSampler` passes the overlay instead of its sampler — silent no-op
20
+ - **Severity**: MAJOR
21
+ - **File**: `engine/ecs/terrain/overlay/TerrainOverlay.js:321`
22
+ - **Defect**: `TerrainOverlay` has no `extends`, and its own surface is `readPoint`/`paintPoint`/`clearPoint` plus `size`/`sampler` — it defines no `width`, `height`, `read`, or `write`. `sampler2d_paint` requires all four on its destination.
23
+ - **Failure scenario**: `destination.width` is `undefined` → `_w = Math.min(..., undefined - destinationX)` → `NaN`; both loops test `x < NaN` → false → zero iterations. `paintSampler` and `paintImage` do nothing, then set `texture.needsUpdate = true` and return as if they succeeded. Silent no-op, not a crash.
24
+ - **Fix**: `sampler2d_paint(this.sampler, scaled_source, 0, 0, dx, dy, dWidth, dHeight);`
25
+ - **Confidence**: Confirmed.
26
+
27
+ ### TERR-4 `makeTerrainWorkerProxy` never clears `useSampleCallbacks`
28
+ - **Severity**: MINOR
29
+ - **File**: `engine/ecs/terrain/ecs/makeTerrainWorkerProxy.js:45-55`
30
+ - **Defect**: `setHeightSampler` iterates `globalScope.useSampleCallbacks` but never empties it, so it only grows.
31
+ - **Failure scenario**: `Terrain.unlink()` terminates the worker with `buildTile` requests still in `WorkerProxy.__pending`; on re-`link()`, `worker.start()` re-sends them before `setHeightSampler`, so they land in `useSampleCallbacks`. They then re-run on **every** subsequent `updateHeights()` (every terrain-paint stroke), each replay a full geometry build + 21 UV-tension passes + BVH build whose result is discarded, on the single build worker.
32
+ - **Fix**: snapshot and clear the array before dispatching.
33
+ - **Confidence**: Confirmed.
34
+
35
+ ### TERR-5 `SplatMapping.removeWeightLayer` can set array-texture depth to 0
36
+ - **Severity**: MINOR
37
+ - **File**: `engine/ecs/terrain/ecs/splat/SplatMapping.js:423-432`
38
+ - **Defect**: removing the only layer leaves `image.depth = 0` and sets `needsUpdate = true`. The same class refuses this in `resize` (`:503-508`) and `TerrainLayers.buildTexture` (`:407-412`), commenting that glTexStorage3D requires all dims > 0.
39
+ - **Failure scenario**: single-layer terrain (`EngineHarness.js:507` does `splat.resize(1,1,1)`); `removeWeightLayer(0)` → next frame uploads a 0-depth `DataTexture2DArray` → `GL_INVALID_VALUE`, `splatWeightMap` left invalid.
40
+ - **Fix**: apply the same guard as `resize`.
41
+ - **Confidence**: Confirmed (logic); GL outcome Likely.
42
+
43
+ ### TERR-6 `TerrainLayers.writeLayerDataIntoTexture` bounds guard off by one layer
44
+ - **Severity**: MINOR
45
+ - **File**: `engine/ecs/terrain/ecs/layers/TerrainLayers.js:367`
46
+ - **Defect**: checks the write's *start* address, not its end: should be `single_layer_byte_size * (index + 1)`.
47
+ - **Failure scenario**: 512x512, texture built for 2 layers, third layer written before `buildTexture()` — the intended diagnostic never fires and a bare `RangeError: offset is out of bounds` is thrown from `TypedArray.set` instead.
48
+ - **Fix**: `if (arrayData.length < single_layer_byte_size * (index + 1))`.
49
+ - **Confidence**: Confirmed.
50
+
51
+ ### Corroboration
52
+ Independently confirms **GFXT-1**: `sampler2d_paint.js:68` reads `const s_x = Math.round(x + sourceY);` — `sourceY` where `sourceX` is meant. Masked for `TerrainOverlay` (which passes `0, 0`) but wrong for any caller with `sourceX !== sourceY`.