diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 3355431..49c3d2c 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -238,20 +238,28 @@ static func occluder_override_key_is_valid(body: Variant) -> bool: func _restore_occluder_overrides_list(overrides: Array) -> void: for entry in overrides: - var mi: MeshInstance3D = entry["mesh"] - if is_instance_valid(mi): - mi.set_surface_override_material(entry["surface"], entry["original"]) + var mi = entry.get("mesh") + if not is_instance_valid(mi) or not mi is MeshInstance3D: + continue + var s: int = int(entry.get("surface", -1)) + if s < 0: + continue + var original: Variant = entry.get("original") + if original != null: + if typeof(original) != TYPE_OBJECT or not is_instance_valid(original): + continue + (mi as MeshInstance3D).set_surface_override_material(s, original) -## Drop invalid keys from [member _occluder_overrides] and restore any surviving meshes. +## Drop invalid keys from [member _occluder_overrides] without touching meshes. +## When the occluder [Node3D] is freed, its [MeshInstance3D] children and materials are gone or +## tearing down — restoring overrides here triggers "freed instance" errors. Erasing the entry is +## enough; live bodies stay on the valid path ([method _restore_occluder]). func _purge_invalid_occluder_override_keys() -> void: var keys: Array = _occluder_overrides.keys() for body in keys: if occluder_override_key_is_valid(body): continue - var overrides: Variant = _occluder_overrides.get(body) - if overrides != null: - _restore_occluder_overrides_list(overrides) _occluder_overrides.erase(body) diff --git a/docs/decomposition/modules/E1_M2_IsometricCameraController.md b/docs/decomposition/modules/E1_M2_IsometricCameraController.md index 74d3f8e..c956c7f 100644 --- a/docs/decomposition/modules/E1_M2_IsometricCameraController.md +++ b/docs/decomposition/modules/E1_M2_IsometricCameraController.md @@ -93,7 +93,7 @@ See Epic 1 **Slice 2 — Locked isometric camera**: follow-center, zoom bands, o - **Click-through input (NEON-30):** `scripts/ground_pick.gd` unconditionally skips bodies in the `"occluder"` group during click-to-move ground-pick raycasts. When a cast hits an occluder the ray origin advances `OCCLUDER_PICK_THROUGH` meters past the hit point and continues, independent of whether `OcclusionPolicy` is currently fading that body. This keeps the `"occluder"` group as the sole tagging convention shared between the camera occlusion system and ground-pick input — no dedicated collision layer is added for this case. -- **Integration hardening (NEON-28):** [Consumer contract](#consumer-contract-neon-28) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **purged** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`). **Follow / state / occlusion** run in **`_physics_process`** with **`process_physics_priority = 1`** so the rig updates **after** `Player` (default **0**) and tracks **`move_and_slide`** without display-vs-physics jitter. +- **Integration hardening (NEON-28):** [Consumer contract](#consumer-contract-neon-28) + `camera_state.gd` header; **E6.M2** adjacency notes ([E6_M2_ConsentAndRiskUxSignals.md](E6_M2_ConsentAndRiskUxSignals.md)); invalid / freed occluder bodies are **dropped** from `_occluder_overrides` before each occlusion ray pass and before full restore (`occluder_override_key_is_valid`, tests in `isometric_follow_camera_test.gd`) — **no** material restore on that path (freed subtree would error); live bodies still restore via `_restore_occluder` when they leave the ray. **Follow / state / occlusion** run in **`_physics_process`** with **`process_physics_priority = 1`** so the rig updates **after** `Player` (default **0**) and tracks **`move_and_slide`** without display-vs-physics jitter. ## Jira backlog diff --git a/docs/plans/NEON-28-implementation-plan.md b/docs/plans/NEON-28-implementation-plan.md index ebaaeba..51e9ea6 100644 --- a/docs/plans/NEON-28-implementation-plan.md +++ b/docs/plans/NEON-28-implementation-plan.md @@ -28,7 +28,7 @@ **Additional in-repo hardening (engineering, supports “stable contracts”)** -- **`IsometricFollowCamera` occluder lifecycle:** `_occluder_overrides` must not **leak or fault** when occluder bodies are **freed** or invalid; purge invalid keys and restore surviving `MeshInstance3D` overrides. +- **`IsometricFollowCamera` occluder lifecycle:** `_occluder_overrides` must not **leak or fault** when occluder bodies are **freed** or invalid; **erase** stale dict keys without calling `set_surface_override_material` on a freed subtree (that errors); live bodies still restore when they leave the ray. **Out of scope (from Jira)** @@ -63,7 +63,7 @@ Jira acceptance criteria (verbatim intent): - Reconcile code and docs. Add **policy flags** to `CameraState` only if the E6.M2 / E1.M2 contract text commits to them; otherwise explicitly document **“no policy flags in state yet; read rig exports.”** 3. **`isometric_follow_camera.gd` hardening** - - At start of `_update_occlusion` (and/or restore passes): drop dictionary keys whose body is **`not is_instance_valid`**; restore materials where `MeshInstance3D` is still valid; then erase entries. + - At start of `_update_occlusion` (and/or restore passes): **erase** dictionary keys whose body is **`not is_instance_valid`** (do **not** restore materials on that path — freed occluder’s meshes are gone). Harden `_restore_occluder_overrides_list` with `is_instance_valid` on mesh and non-null `original` for the live-body restore path. 4. **Optional telemetry** - Keep or add **TODO(E9.M1)** pointers in rig / policy scripts; no new telemetry pipeline unless out of scope exception is agreed. @@ -76,7 +76,7 @@ Jira acceptance criteria (verbatim intent): | Topic | Choice | Rationale | |-------|--------|-----------| | **`CameraState` policy flags** | **Doc-first** | Add fields only if Jira/E6.M2 contract text requires a snapshot; avoid speculative API. | -| **Invalid occluder keys** | **Purge + restore** | Prevents stuck materials and leaks when occluders despawn. | +| **Invalid occluder keys** | **Erase only** | Freed body ⇒ subtree is gone; restoring overrides hits freed `MeshInstance3D` / materials. Drop dict entry; live bodies restore when they leave the ray. | | **Epic Slice 2 wording** | **Align with rotation policy** | Satisfies Jira AC on “yaw not exposed / default in UX” vs misleading “no rotation.” | | **Rig tick (jitter follow-up)** | **`_physics_process` + `process_physics_priority = 1`** | `Player` default **0** runs before **1**; camera reads post–`move_and_slide`. Avoids `_process` vs 120 Hz physics jitter (camera was above `Player` in scene order). |