NEON-28: drop freed occluder keys without material restore

pull/38/head
VinPropane 2026-04-10 23:09:12 -04:00
parent e225f1c550
commit b702b14be4
3 changed files with 19 additions and 11 deletions

View File

@ -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)

View File

@ -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

View File

@ -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 occluders 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 120Hz physics jitter (camera was above `Player` in scene order). |