Skip to content

fix: apply TriggerArea collisionMask and mesh updates after creation - #9809

Draft
alejandro-jimenez-dcl wants to merge 1 commit into
mainfrom
bugsweep/trigger-area-layer-update
Draft

fix: apply TriggerArea collisionMask and mesh updates after creation#9809
alejandro-jimenez-dcl wants to merge 1 commit into
mainfrom
bugsweep/trigger-area-layer-update

Conversation

@alejandro-jimenez-dcl

Copy link
Copy Markdown
Contributor

Problem

A scene that mutates a live TriggerArea component - changing collisionMask or mesh
after creation - sees no effect: trigger events keep firing against the original mask/mesh
until the component is removed and re-added. The CL_MAIN_PLAYER → wider mask case is
triply stale (component mask, mono TargetTransform filter, gameObject.layer routing).
GitHub #6484 (stale-bot autoclosed; defect verified present at the pin).

Root cause

TriggerAreaHandlerSystem.SetupTriggerArea reads PBTriggerArea exactly once and bakes
mask/mesh/targetOnlyMainPlayer into the component; UpdateTriggerArea never consumed the
PB component's IsDirty - later PUTs from the scene were silently dropped. Both sibling
area systems (AvatarModifierAreaHandlerSystem, CameraModeAreaHandlerSystem) already
consume their dirty flags via the identical pattern; this system was the odd one out.

Fix

  • SDKEntityTriggerAreaComponent: new UpdateMaskAndMeshType(...) (mirrors
    UpdateAreaSize); TryAssignArea rebinds/clears the mono's TargetTransform on every
    call (also closes a latent pooled-mono stale-filter leak).
  • TriggerAreaHandlerSystem.UpdateTriggerArea: consumes pbTriggerArea.IsDirty exactly
    like the siblings; on an actual mask change, emits synthetic TaetExit/TaetEnter for
    current insiders whose match status flips (Unity does not synthesize enter/exit on live
    filter swaps), skipping colliders still in the pending-ENTER queue.
  • SDKEntityTriggerArea (mono): SetTargetTransform evicts non-target insiders from all
    tracking sets when a filter binds (the synthetic EXITs already on the wire make the sets
    truthful), and OnTriggerExit only queues exits for colliders it actually tracked - no
    phantom ENTERs and no unbalanced EXITs across mask cycles.

Residual documented edges only ever MISS an event (same-frame race class shared with the
siblings); no path fabricates one.

Test

New EditMode TriggerAreaLayerUpdateShould (5 tests, existing harness): dirty-PB
mask/mesh consumption, synthetic EXIT and ENTER emission, TargetTransform rebind on
CL_MAIN_PLAYER → CL_PLAYER, and the mask-cycle eviction regression from the adversarial
review. All 14 pre-existing TriggerAreaHandlerSystemShould tests stay green.

Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 5/5 as intended (no dirty
consumption, no synthetic events, filter stays bound at pin) / GREEN PASS 5/5, sibling
regression suite 14/14.

Fixes #6484
Related: #6483 (CL_POINTER support - out of scope, unchanged), #3728 (same
read-PB-once defect class in GLTFContainer, closed)

Includes inspection-warning cleanup in all touched files.

Fixes #6484

## Problem

A scene that mutates a live `TriggerArea` component — changing `collisionMask` or `mesh`
after creation — sees no effect: trigger events keep firing against the original mask/mesh
until the component is removed and re-added. The `CL_MAIN_PLAYER → wider mask` case is
triply stale (component mask, mono `TargetTransform` filter, `gameObject.layer` routing).
GitHub #6484 (stale-bot autoclosed; defect verified present at the pin).

## Root cause

`TriggerAreaHandlerSystem.SetupTriggerArea` reads `PBTriggerArea` exactly once and bakes
mask/mesh/targetOnlyMainPlayer into the component; `UpdateTriggerArea` never consumed the
PB component's `IsDirty` — later PUTs from the scene were silently dropped. Both sibling
area systems (`AvatarModifierAreaHandlerSystem`, `CameraModeAreaHandlerSystem`) already
consume their dirty flags via the identical pattern; this system was the odd one out.

## Fix

- `SDKEntityTriggerAreaComponent`: new `UpdateMaskAndMeshType(...)` (mirrors
  `UpdateAreaSize`); `TryAssignArea` rebinds/clears the mono's `TargetTransform` on every
  call (also closes a latent pooled-mono stale-filter leak).
- `TriggerAreaHandlerSystem.UpdateTriggerArea`: consumes `pbTriggerArea.IsDirty` exactly
  like the siblings; on an actual mask change, emits synthetic `TaetExit`/`TaetEnter` for
  current insiders whose match status flips (Unity does not synthesize enter/exit on live
  filter swaps), skipping colliders still in the pending-ENTER queue.
- `SDKEntityTriggerArea` (mono): `SetTargetTransform` evicts non-target insiders from all
  tracking sets when a filter binds (the synthetic EXITs already on the wire make the sets
  truthful), and `OnTriggerExit` only queues exits for colliders it actually tracked — no
  phantom ENTERs and no unbalanced EXITs across mask cycles.

Residual documented edges only ever MISS an event (same-frame race class shared with the
siblings); no path fabricates one.

## Test

New EditMode `TriggerAreaLayerUpdateShould` (5 tests, existing harness): dirty-PB
mask/mesh consumption, synthetic EXIT and ENTER emission, TargetTransform rebind on
`CL_MAIN_PLAYER → CL_PLAYER`, and the mask-cycle eviction regression from the adversarial
review. All 14 pre-existing `TriggerAreaHandlerSystemShould` tests stay green.

## Validation

Windows Unity 6000.4 EditMode lane at the pin: RED FAIL 5/5 as intended (no dirty
consumption, no synthetic events, filter stays bound at pin) / GREEN PASS 5/5, sibling
regression suite 14/14.

Fixes #6484
Related: #6483 (CL_POINTER support — out of scope, unchanged), #3728 (same
read-PB-once defect class in GLTFContainer, closed)

Includes inspection-warning cleanup in all touched files.
@alejandro-jimenez-dcl
alejandro-jimenez-dcl requested review from a team as code owners August 19, 2026 12:20
@alejandro-jimenez-dcl alejandro-jimenez-dcl self-assigned this Aug 19, 2026
@github-actions
github-actions Bot requested a review from DafGreco August 19, 2026 12:35
@decentraland-bot
decentraland-bot self-requested a review August 19, 2026 12:41
@alejandro-jimenez-dcl
alejandro-jimenez-dcl marked this pull request as draft August 19, 2026 12:49

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #9809: fix: apply TriggerArea collisionMask and mesh updates after creation


STEP 1 — Context & scope

Changed files (5):

  • TriggerAreaHandlerSystem.cs — dirty-flag consumption, synthetic event emission, ColliderMatchesMask predicate, (uint) cast fixes
  • SDKEntityTriggerAreaComponent.csUpdateMaskAndMeshType, IsEnterPending, TryAssignArea refactor to rebind SetTargetTransform on every call
  • SDKEntityTriggerArea.csSetTargetTransform with eviction, OnTriggerExit tracking guard, IsEnterPending, TargetTransform encapsulation
  • TriggerAreaLayerUpdateShould.cs — 5 new EditMode tests
  • TriggerAreaLayerUpdateShould.cs.meta — Unity meta

Surrounding context read: full TriggerAreaHandlerSystem.cs (base + head), full SDKEntityTriggerArea.cs (base + head), full SDKEntityTriggerAreaComponent.cs (base + head). Sibling systems confirmed: AvatarModifierAreaHandlerSystem and CameraModeAreaHandlerSystem both consume IsDirty on their PB components via the identical pattern this PR adopts.


STEP 2 — Root-cause check

Problem: UpdateTriggerArea never consumed PBTriggerArea.IsDirty, so scene-side PUTs that changed collisionMask or mesh after creation were silently dropped. Both sibling area systems already consume their dirty flags — this system was the odd one out.

Fix: Consumes IsDirty in UpdateTriggerArea, updates the component via UpdateMaskAndMeshType, and emits synthetic ENTER/EXIT events for insiders whose match status flips (Unity does not synthesize enter/exit on live filter swaps).

RESULT: PASS ✅ — Fixes the root cause, not a symptom. The dirty-flag consumption pattern is identical to the siblings.


STEP 3 — Design & integration

No new long-lived units introduced. All logic is placed within existing owners:

  • Dirty-flag consumption → TriggerAreaHandlerSystem.UpdateTriggerArea (the natural home, matching sibling systems)
  • UpdateMaskAndMeshTypeSDKEntityTriggerAreaComponent (mirrors existing UpdateAreaSize)
  • SetTargetTransform with eviction → SDKEntityTriggerArea mono (the existing owner of TargetTransform and the tracking sets)

Owner search: The lifecycle for TriggerArea components is owned by TriggerAreaHandlerSystem (setup via SetupTriggerArea, destruction via HandleEntityDestruction/HandleComponentRemoval, finalize via FinalizeComponents). The mono is managed by SDKEntityTriggerAreaComponent.TryAssignArea (creation) and TryRelease (pooling). The dirty-flag consumption fits within the existing UpdateTriggerArea query — no new system, no new lifecycle manager.

Teardown/consumption trace:

  • isNotTargetEntity (Predicate<Collider>?) — cached delegate on the MonoBehaviour instance, captured via ??=; lives with the mono, no separate teardown needed.
  • No new subscriptions, event hookups, +=, or connections introduced.

RESULT: PASS


STEP 4 — Member audit

New member Consumers Verdict
SDKEntityTriggerAreaComponent.UpdateMaskAndMeshType(ColliderLayer, SDKEntityTriggerAreaMeshType) 1 (UpdateTriggerArea) Named state-mutation method mirroring UpdateAreaSize — legitimate encapsulation, not a derived predicate. ✅
SDKEntityTriggerAreaComponent.IsEnterPending(Collider) 1 (ReEvaluateEntitiesInside) Thin forwarding accessor centralizing access to the mono's pending-enter set. ✅
SDKEntityTriggerArea.IsEnterPending(Collider) 1 (SDKEntityTriggerAreaComponent.IsEnterPending) Direct HashSet.Contains check. ✅
SDKEntityTriggerArea.SetTargetTransform(Transform?) 1 (TryAssignArea) Replaces the direct public field assignment with a method that also performs eviction — legitimate encapsulation lift. ✅
SDKEntityTriggerArea.TargetTransform (field → property) Multiple (existing OnTriggerEnter/OnTriggerExit, new SetTargetTransform) Good encapsulation — private set prevents external mutation. ✅

RESULT: PASS


STEP 5 — Line-level review

Pass A — Blocking-issue categories

Query attribute change ([All(typeof(PBTriggerArea), typeof(TriggerAreaComponent))][All(typeof(TriggerAreaComponent))] + in PBTriggerArea parameter): In Arch source-gen queries, component types in the method signature are automatically included in the query filter. Removing PBTriggerArea from [All] while adding it as a parameter is functionally equivalent and avoids redundancy. ✅

pbTriggerArea.IsDirty = false on an in parameter: PBTriggerArea is a Protobuf-generated class (reference type); in makes the reference read-only but the object mutable. The sibling systems use the identical pattern. ✅

ReEvaluateEntitiesInside iteration safety: Iterates CurrentEntitiesInside (the mono's HashSet<Collider>). The loop body calls only ColliderMatchesMask (pure predicate) and PropagateResultComponent (CRDT write) — neither modifies the iterated set. No concurrent modification. ✅

ColliderMatchesMask vs PropagateResultComponent consistency: ColliderMatchesMask mirrors the layer-mask gate of PropagateResultComponent without calling TryGetAvatarEntity. This is intentional — the worst case is an unnecessary PropagateResultComponent call that returns early (no event fabricated). Falls under the documented same-frame race class. ✅

(uint) casts in PropagateResultComponent: Explicit unsigned bitwise AND for ColliderLayer enum values, consistent with the new ColliderMatchesMask. Prevents potential signed-arithmetic surprises on high-bit flags. Correct cleanup. ✅

OnTriggerExit tracking guard (if (!currentEntitiesInside.Remove(other)) return;): Only queues an exit for colliders that were actually tracked. Prevents unbalanced EXITs from colliders admitted by physics while the target filter was bound (they bypassed OnTriggerEnter and were never in currentEntitiesInside). ✅

SetTargetTransform eviction: When binding a filter, RemoveWhere on all three sets evicts colliders that the filter would stop tracking. The isNotTargetEntity predicate reads TargetTransform dynamically (not at capture time), so it always uses the latest value despite being cached via ??=. Correct. ✅

TryAssignArea refactor (!hasMonoBehaviourmonoBehaviour is not { } area): Pattern-match captures the mono as area, eliminating repeated null-forgiving monoBehaviour!. accesses. SetTargetTransform moved outside the creation branch so it runs on every TryAssignArea call — this is the key fix that ensures mask changes rebind/clear the filter. ✅

targetOnlyMainPlayer mutability (readonly → mutable): Required for UpdateMaskAndMeshType to update the fast-path flag. The struct is always accessed via ref in ECS queries, so mutations are written back correctly. ✅

Struct mutation via ref: UpdateTriggerArea takes ref SDKEntityTriggerAreaComponent, so UpdateMaskAndMeshType mutations persist in ECS. IncrementalTick (post-increment property) correctly advances for each synthetic event on the ref. ✅

= null! on serialized properties (BoxCollider, SphereCollider): Standard Unity practice for [SerializeField] properties — tells the nullable analyzer these are assigned by Unity serialization. Follows CLAUDE.md convention. ✅

Removed using SceneRunner.Scene;: Correct — no longer referenced after the diff. ✅

Pass B — Design, encapsulation & resource smells

No issues found. The isNotTargetEntity predicate is a single-allocation cached delegate (no per-call closures). No magic numbers introduced. Naming is clear and matches existing conventions.

No P0, P1, or P2 issues found.


STEP 6 — Complexity assessment

COMPLEX — Modifies an ECS system query signature, adds component mutation via a struct method, alters physics callback tracking logic (OnTriggerExit), and introduces synthetic event emission that bridges the ECS and physics layers.


STEP 7 — QA assessment

QA_REQUIRED: YES — Runtime behavior change affecting trigger area event emission visible to scenes. Scenes that mutate a live TriggerArea component will now see updated collision behavior.


STEP 8 — Non-blocking warnings

None. Main.unity is not in the changed files.


Security review

No security issues found. The diff is purely internal ECS game-engine logic — no external input parsing, no network calls, no auth logic, no secrets, no new dependencies.


Test coverage

5 new EditMode tests in TriggerAreaLayerUpdateShould:

  1. UpdateMaskAndMeshTypeFromDirtyPBTriggerArea — dirty-flag consumption, mask/mesh update, component re-flagged dirty
  2. EmitSyntheticExitWhenMaskChangeExcludesInsider — narrowing mask emits synthetic EXIT
  3. EmitSyntheticEnterWhenMaskChangeIncludesInsider — widening mask emits synthetic ENTER
  4. RebindTargetTransformWhenMaskChangesFromMainPlayerOnly — CL_MAIN_PLAYER → CL_PLAYER clears the fast-path filter
  5. EvictStaleInsiderWhenMaskCyclesThroughMainPlayerOnly — full mask cycle validates eviction + no fabricated events

All 14 pre-existing TriggerAreaHandlerSystemShould tests reported green. Coverage is thorough for the fix scope.


Summary

This is a well-crafted bug fix that correctly addresses the root cause (missing IsDirty consumption), follows the established sibling-system pattern, handles edge cases carefully (pending enters, destroyed colliders, mask cycles, target-filter eviction), and ships with comprehensive test coverage. The synthetic ENTER/EXIT emission logic is sound — ColliderMatchesMask correctly mirrors PropagateResultComponent's gate, and SetTargetTransform eviction prevents unbalanced events across mask cycles. The OnTriggerExit tracking guard closes the phantom-exit vector. No blocking issues found.

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies ECS system query, component struct mutation, physics callback tracking, and introduces synthetic event emission bridging ECS and Unity physics layers.
QA_REQUIRED: YES


Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit ca789fc
Logs https://github.com/decentraland/unity-explorer/actions/runs/32252179269
Download Windows https://github.com/decentraland/unity-explorer/suites/87428446709/artifacts/
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/trigger-area-layer-update/pr-25323-ca789fc/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/87428446709/artifacts/9370175613
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/bugsweep/trigger-area-layer-update/pr-25323-ca789fc/Decentraland_macos.zip
Built on 2026-08-19T14:51:11Z

Lint

Warnings not reduced: 12724 => 13126 — remove at least 403 warnings to merge.

Warnings/errors in files changed by this PR (4)
Assets/DCL/SDKEntityTriggerArea/SDKEntityTriggerArea.cs:7  InconsistentNaming  Name 'SDKEntityTriggerArea' does not match rule 'members_should_be_pascal_case'. Suggested name is 'SdkEntityTriggerArea'.
Assets/DCL/SDKEntityTriggerArea/Components/SDKEntityTriggerAreaComponent.cs:17  InconsistentNaming  Name 'SDKEntityTriggerAreaComponent' does not match rule 'members_should_be_pascal_case'. Suggested name is 'SdkEntityTriggerAreaComponent'.
Assets/DCL/SDKEntityTriggerArea/Components/SDKEntityTriggerAreaComponent.cs:11  InconsistentNaming  Name 'SDKEntityTriggerAreaMeshType' does not match rule 'members_should_be_pascal_case'. Suggested name is 'SdkEntityTriggerAreaMeshType'.
Assets/DCL/SDKEntityTriggerArea/Components/SDKEntityTriggerAreaComponent.cs:20  InconsistentNaming  Name 'monoBehaviour' does not match rule 'members_should_be_pascal_case'. Suggested name is 'MonoBehaviour'.

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25059 0 13
PlayMode ✅ Passed 236 0 37

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9809, run #32266550901

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2313 (×3) 1659 (×3)
CPU average 38.6 ms (33.4–38.8) 53.6 ms (38.8–53.7) 14.9 ms 🔴 39% slower
CPU 1% worst 322.6 ms (57.0–343.5) 895.5 ms (606.3–919.7) 572.9 ms 🔴 178% slower
CPU 0.1% worst 344.1 ms (341.1–360.3) 902.4 ms (855.0–953.0) 558.3 ms 🔴 162% slower
GPU average 9.5 ms (9.2–9.6) 10.7 ms (9.7–10.8) 1.2 ms 🔴 13% slower
GPU 1% worst 35.6 ms (23.5–37.7) 95.0 ms (47.2–95.6) 59.4 ms 🔴 167% slower
GPU 0.1% worst 44.4 ms (39.8–45.0) 98.3 ms (94.9–99.0) 53.9 ms 🔴 122% slower
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4105 (×3) 4086 (×3)
CPU average 21.8 ms (21.8–22.9) 21.9 ms (20.9–22.6) 0.0 ms ⚪ within noise
CPU 1% worst 215.9 ms (215.7–217.7) 209.5 ms (81.0–227.0) -6.4 ms ⚪ within noise
CPU 0.1% worst 226.3 ms (222.9–228.8) 233.3 ms (233.3–237.6) 7.0 ms 🔴 3% slower
GPU average 2.5 ms (2.0–3.2) 5.2 ms (3.2–5.9) 2.8 ms 🔴 111% slower
GPU 1% worst 34.3 ms (34.2–36.2) 35.2 ms (35.2–36.3) 0.9 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (34.8–37.5) 37.4 ms (36.4–38.7) 1.1 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants