@woosh/meep-engine 2.168.1 → 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.
- package/README.md +2 -2
- package/build/bundle-worker-image-decoder.js +1 -1
- package/package.json +1 -1
- package/src/REVIEW_2026_08_06/adv-core.md +511 -0
- package/src/REVIEW_2026_08_06/adv-gfx.md +480 -0
- package/src/REVIEW_2026_08_06/adv-view.md +446 -0
- package/src/REVIEW_2026_08_06/api-core.md +296 -0
- package/src/REVIEW_2026_08_06/api-engine.md +195 -0
- package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +507 -0
- package/src/REVIEW_2026_08_06/core-binary.md +348 -0
- package/src/REVIEW_2026_08_06/core-collection.md +376 -0
- package/src/REVIEW_2026_08_06/core-math.md +221 -0
- package/src/REVIEW_2026_08_06/core-process.md +266 -0
- package/src/REVIEW_2026_08_06/core-science.md +223 -0
- package/src/REVIEW_2026_08_06/engine-ai-nav.md +279 -0
- package/src/REVIEW_2026_08_06/engine-animation.md +167 -0
- package/src/REVIEW_2026_08_06/engine-asset.md +166 -0
- package/src/REVIEW_2026_08_06/engine-ecs.md +920 -0
- package/src/REVIEW_2026_08_06/engine-input.md +197 -0
- package/src/REVIEW_2026_08_06/engine-misc.md +248 -0
- package/src/REVIEW_2026_08_06/engine-terrain.md +52 -0
- package/src/REVIEW_2026_08_06/generation.md +176 -0
- package/src/REVIEW_2026_08_06/geom-2d.md +434 -0
- package/src/REVIEW_2026_08_06/geom-3d.md +117 -0
- package/src/REVIEW_2026_08_06/gfx-gi.md +143 -0
- package/src/REVIEW_2026_08_06/gfx-render.md +740 -0
- package/src/REVIEW_2026_08_06/gfx-texture.md +690 -0
- package/src/REVIEW_2026_08_06/prior-criticals.md +49 -0
- package/src/REVIEW_2026_08_06/prior-highs.md +138 -0
- package/src/REVIEW_2026_08_06/test-gaps.md +364 -0
- package/src/REVIEW_2026_08_06/view.md +667 -0
- package/src/REVIEW_2026_08_06.md +571 -0
- package/src/core/binary/BinaryBuffer.d.ts.map +1 -1
- package/src/core/binary/BinaryBuffer.js +12 -1
- package/src/core/binary/BitImage2.d.ts +5 -1
- package/src/core/binary/BitImage2.d.ts.map +1 -1
- package/src/core/binary/BitImage2.js +5 -1
- package/src/core/bvh8/build/NodeProxy.d.ts.map +1 -1
- package/src/core/bvh8/build/NodeProxy.js +314 -308
- package/src/core/collection/list/List.d.ts +15 -0
- package/src/core/collection/list/List.d.ts.map +1 -1
- package/src/core/collection/list/List.js +34 -0
- package/src/core/collection/list/SortedListProjection.d.ts.map +1 -1
- package/src/core/collection/list/SortedListProjection.js +4 -1
- package/src/core/collection/map/BiMap.d.ts +25 -2
- package/src/core/collection/map/BiMap.d.ts.map +1 -1
- package/src/core/collection/map/BiMap.js +57 -3
- package/src/core/debug/matchers/IsAnything.d.ts.map +1 -1
- package/src/core/debug/matchers/IsAnything.js +5 -1
- package/src/core/events/signal/Signal.d.ts.map +1 -1
- package/src/core/events/signal/Signal.js +3 -1
- package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +433 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts +31 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.js +84 -0
- package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts +24 -2
- package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts.map +1 -1
- package/src/core/geom/2d/polygon/polygon2_signed_area.js +52 -34
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts +28 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.js +86 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts +40 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.js +1280 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts +28 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.js +77 -0
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts +6 -3
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts.map +1 -1
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.js +26 -13
- package/src/core/geom/2d/triangle/tri2_signed_area.d.ts +17 -1
- package/src/core/geom/2d/triangle/tri2_signed_area.d.ts.map +1 -1
- package/src/core/geom/2d/triangle/tri2_signed_area.js +35 -23
- package/src/core/geom/2d/v2_morton_encode.d.ts +20 -0
- package/src/core/geom/2d/v2_morton_encode.d.ts.map +1 -0
- package/src/core/geom/2d/v2_morton_encode.js +23 -0
- package/src/core/geom/3d/atlas/segment/atlas_merge_charts.d.ts.map +1 -1
- package/src/core/geom/3d/atlas/segment/atlas_merge_charts.js +4 -1
- package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts +27 -0
- package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts.map +1 -0
- package/src/core/geom/3d/polygon/polygon3_compute_normal.js +60 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts +34 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts.map +1 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.js +94 -0
- package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +8 -2
- package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -1
- package/src/core/geom/3d/quaternion/quat3_multiply.js +8 -2
- package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/ConvexHullShape3D.js +32 -2
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts +23 -6
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.js +268 -124
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts +17 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.js +44 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts +20 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.js +40 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts +19 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.js +34 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts +22 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.js +37 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts +87 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.js +599 -0
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts +32 -0
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts.map +1 -1
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.js +66 -7
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts +22 -1
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts.map +1 -1
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.js +83 -38
- package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +554 -0
- package/src/core/geom/vec3/v3_cotangent.d.ts +6 -2
- package/src/core/geom/vec3/v3_cotangent.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_cotangent.js +6 -2
- package/src/core/geom/vec3/v3_cross.d.ts +6 -2
- package/src/core/geom/vec3/v3_cross.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_cross.js +6 -2
- package/src/core/geom/vec3/v3_multiply.d.ts +6 -2
- package/src/core/geom/vec3/v3_multiply.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_multiply.js +6 -2
- package/src/core/geom/vec3/v3_subtract.d.ts +6 -2
- package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_subtract.js +6 -2
- package/src/core/localization/Localization.js +1 -1
- package/src/core/math/random/randomSeed.d.ts +13 -0
- package/src/core/math/random/randomSeed.d.ts.map +1 -0
- package/src/core/math/random/randomSeed.js +16 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.d.ts +50 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.d.ts.map +1 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.js +107 -0
- package/src/core/math/spline/spline3_hermite_reverse.d.ts +34 -0
- package/src/core/math/spline/spline3_hermite_reverse.d.ts.map +1 -0
- package/src/core/math/spline/spline3_hermite_reverse.js +49 -0
- package/src/core/math/statistics/hammersley_sequence.d.ts +17 -4
- package/src/core/math/statistics/hammersley_sequence.d.ts.map +1 -1
- package/src/core/math/statistics/hammersley_sequence.js +25 -16
- package/src/core/process/Future.d.ts.map +1 -1
- package/src/core/process/Future.js +12 -0
- package/src/core/process/PromiseWatcher.d.ts.map +1 -1
- package/src/core/process/PromiseWatcher.js +9 -2
- package/src/core/process/executor/ConcurrentExecutor.d.ts.map +1 -1
- package/src/core/process/executor/ConcurrentExecutor.js +12 -2
- package/src/core/process/worker/WorkerProxy.d.ts.map +1 -1
- package/src/core/process/worker/WorkerProxy.js +5 -2
- package/src/core/process/worker/extractTransferables.d.ts.map +1 -1
- package/src/core/process/worker/extractTransferables.js +4 -1
- package/src/engine/Engine.d.ts +12 -12
- package/src/engine/Engine.d.ts.map +1 -1
- package/src/engine/Engine.js +149 -10
- package/src/engine/asset/loaders/ArrayBufferLoader.d.ts.map +1 -1
- package/src/engine/asset/loaders/ArrayBufferLoader.js +7 -0
- package/src/engine/ecs/speaker/lines/sets/LineSetDescription.d.ts.map +1 -1
- package/src/engine/ecs/speaker/lines/sets/LineSetDescription.js +3 -1
- package/src/engine/ecs/terrain/ecs/TerrainSystem.d.ts.map +1 -1
- package/src/engine/ecs/terrain/ecs/TerrainSystem.js +4 -1
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts +29 -0
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts.map +1 -1
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.js +84 -23
- package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +446 -0
- package/src/engine/graphics/GraphicsEngine.d.ts +56 -7
- package/src/engine/graphics/GraphicsEngine.d.ts.map +1 -1
- package/src/engine/graphics/GraphicsEngine.js +196 -24
- package/src/engine/graphics/context/WebGLContextFailureReason.d.ts +9 -0
- package/src/engine/graphics/context/WebGLContextFailureReason.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextFailureReason.js +17 -0
- package/src/engine/graphics/context/WebGLContextMonitor.d.ts +106 -0
- package/src/engine/graphics/context/WebGLContextMonitor.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextMonitor.js +341 -0
- package/src/engine/graphics/context/WebGLContextState.d.ts +14 -0
- package/src/engine/graphics/context/WebGLContextState.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextState.js +24 -0
- package/src/engine/graphics/context/testWebGLContextLoss.d.ts +2 -0
- package/src/engine/graphics/context/testWebGLContextLoss.d.ts.map +1 -0
- package/src/engine/graphics/context/testWebGLContextLoss.js +696 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.d.ts.map +1 -1
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.js +68 -32
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts +14 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts.map +1 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.js +41 -0
- package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +1 -1
- package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js +5 -0
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts +1 -1
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.js +2 -1
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts +1 -1
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.js +3 -5
- package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.d.ts.map +1 -1
- package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.js +3 -2
- package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.d.ts.map +1 -1
- package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.js +3 -1
- package/src/engine/graphics/geometry/computeMeshSurfaceArea.js +1 -1
- package/src/engine/graphics/load_and_set_cubemap_v0.d.ts +5 -0
- package/src/engine/graphics/load_and_set_cubemap_v0.d.ts.map +1 -1
- package/src/engine/graphics/load_and_set_cubemap_v0.js +26 -21
- package/src/engine/graphics/particles/particular/group/ParticleGroup.d.ts.map +1 -1
- package/src/engine/graphics/particles/particular/group/ParticleGroup.js +19 -8
- package/src/engine/graphics/particles/particular/group/optimizeCommandQueue.js +1 -1
- package/src/engine/graphics/texture/atlas/AtlasLookupTexture.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/AtlasLookupTexture.js +10 -3
- package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/TextureAtlas.js +4 -1
- package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.js +11 -0
- package/src/engine/graphics/texture/cubemap/load_environment_map.d.ts.map +1 -1
- package/src/engine/graphics/texture/cubemap/load_environment_map.js +5 -2
- package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.js +26 -18
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts +15 -2
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.js +29 -3
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts +11 -1
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.js +158 -171
- package/src/engine/graphics/texture/sampler/sampler2d_paint.js +1 -1
- package/src/engine/graphics/texture/sampler/util/bitSet2Sampler2D.js +2 -2
- package/src/engine/input/ecs/util/TopDownCameraControllerHelper.d.ts.map +1 -1
- package/src/engine/input/ecs/util/TopDownCameraControllerHelper.js +10 -3
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts +14 -0
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts.map +1 -0
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.js +38 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +433 -425
- package/src/engine/physics/constraint/solve_constraints.d.ts +4 -1
- package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
- package/src/engine/physics/constraint/solve_constraints.js +38 -13
- package/src/engine/physics/contact/ManifoldStore.d.ts +15 -3
- package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
- package/src/engine/physics/contact/ManifoldStore.js +15 -3
- package/src/engine/physics/ecs/Joint.d.ts +7 -4
- package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
- package/src/engine/physics/ecs/Joint.js +7 -4
- package/src/engine/physics/ecs/PhysicsSystem.d.ts +14 -0
- package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.js +16 -0
- package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
- package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +3 -3
- package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts +15 -7
- package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/convex_convex_manifold.js +33 -10
- package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +6 -2
- package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_concave.js +6 -2
- package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +6 -2
- package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_hit.js +6 -2
- package/src/engine/physics/queries/raycast.d.ts.map +1 -1
- package/src/engine/physics/queries/raycast.js +11 -4
- package/src/engine/save/storage/IndexedDBStorage.d.ts.map +1 -1
- package/src/engine/save/storage/IndexedDBStorage.js +21 -3
- package/src/engine/simulation/Ticker.d.ts +12 -0
- package/src/engine/simulation/Ticker.d.ts.map +1 -1
- package/src/engine/simulation/Ticker.js +18 -0
- package/src/engine/sound/simulation/AcousticSimulator.d.ts.map +1 -1
- package/src/engine/sound/simulation/AcousticSimulator.js +9 -1
- package/src/engine/sound/simulation/core/VolumeField.d.ts +6 -2
- package/src/engine/sound/simulation/core/VolumeField.d.ts.map +1 -1
- package/src/engine/sound/simulation/core/VolumeField.js +6 -2
- package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts +6 -2
- package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/AcousticProbeField.js +6 -2
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts +6 -2
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.js +6 -2
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts +3 -1
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.js +3 -1
- package/src/engine/ui/GUIEngine.d.ts.map +1 -1
- package/src/engine/ui/GUIEngine.js +8 -1
- package/src/generation/theme/TerrainTheme.d.ts.map +1 -1
- package/src/generation/theme/TerrainTheme.js +2 -1
- package/src/generation/theme/ThemeEngine.d.ts.map +1 -1
- package/src/generation/theme/ThemeEngine.js +6 -5
- package/src/view/ViewGroup.d.ts.map +1 -1
- package/src/view/ViewGroup.js +9 -7
- package/src/view/graphics/WebGLContextFailureView.d.ts +19 -0
- package/src/view/graphics/WebGLContextFailureView.d.ts.map +1 -0
- package/src/view/graphics/WebGLContextFailureView.js +76 -0
- package/src/view/minimap/Minimap.d.ts +8 -0
- package/src/view/minimap/Minimap.d.ts.map +1 -1
- package/src/view/minimap/Minimap.js +16 -3
- package/src/view/minimap/dom/MinimapMarkerView.d.ts.map +1 -1
- 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`.
|