Skip to content

fix: sdk reserved entities recycle version bump - #9847

Open
pravusjif wants to merge 3 commits into
devfrom
fix/sdk-reserved-entities-recycle-version-pump
Open

fix: sdk reserved entities recycle version bump#9847
pravusjif wants to merge 3 commits into
devfrom
fix/sdk-reserved-entities-recycle-version-pump

Conversation

@pravusjif

@pravusjif pravusjif commented Aug 24, 2026

Copy link
Copy Markdown
Member

Problem

ADR-245 reserves entity numbers 32–255 for remote players and requires a new generation
(version) every time a number is reassigned
. Unity Explorer never bumped it: the pool was a
bare bool[224], so every player on a given number got version 0.

Entity ids pack version in the upper 16 bits and number in the lower 16 (ADR-117), and a
scene's CRDT state stores deleted entities as number -> version, discarding any message whose
version is not greater than the stored one. So once a number was tombstoned in a scene, every
player who later inherited that slot stayed invisible to that scene for the rest of its lifetime —
no recovery short of a scene reload.

Hammurabi, Bevy and Godot all bump; Unity was the only host that didn't.

Fix

PlayerCRDTEntitiesHandlerSystem now tracks a generation per reserved number:

  • reservedEntityVersions[] runs alongside reservedEntities[], initialised to -1, so the
    first hand-out of a number is version 0 and every recycle advances it.
  • ReserveNextFreeEntity() became TryReserveNextFreeEntity(out CRDTEntity) and returns
    CRDTEntity.Create(number, version). The old -1 exhaustion sentinel is gone — once version
    bits live in Id, a sentinel int id is ambiguous.
  • FreeReservedEntity takes a CRDTEntity and indexes by EntityNumber; the raw Id no longer
    equals the number.
  • A number that has used all 65 536 versions is retired rather than re-issued with a repeated
    generation, and logs once. CRDTEntity.MAX_VERSION was added for that bound.

Nothing downstream needed changing: the versioned id flows unchanged into PlayerSceneCRDTEntity,
OutgoingCRDTMessagesProvider (keyed by the full CRDTEntity) and CRDTSerializer (writes all
4 bytes). The Id == PLAYER_ENTITY checks in the propagation systems stay correct — the local
player is never pooled, so its version is always 0.

QA TEST STEPS

This test requires opening more than 1 Explorer in parallel to test in a multiplayer test scene.

Use the build from this PR to enter the ZONE/SEPOLIA world sdk7testscenes.dcl.eth at position 93,-8:

DO NOT interact with the red sphere in the scene, it's used for other tests.

  1. Enter the scene with 1 explorer, "account A"
  2. Enter the scene in parallel with a 2nd explorer (will need the --multi-instance true param, logged with a 2nd account, "account B"
  3. After both players see each other, close the FIRST Explorer and confirm that "account B" stops seeing "account A" (wait some seconds until it disappears)
  4. Open a new instance of the Explorer in parallel (will need the --multi-instance true param, logged with a 3rd account, "account C")
  5. After "account B" sees the player "account C", check the scene UI and confirm:
    • You DON'T see the "id reused, no gen bump" warning
    • You DO see the same entity ID and VERSION in both the CLIENT and the SERVER (e.g. #32 v1)
    • The following screenshot shows the WRONG state (you have to confirm this is not the state you see):
Screenshot 2026-08-24 at 7 46 26 PM

@pravusjif pravusjif self-assigned this Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 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 f970edf
Logs https://github.com/decentraland/unity-explorer/actions/runs/33516242697
Download Windows https://github.com/decentraland/unity-explorer/suites/90819652329/artifacts/9814215369
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/sdk-reserved-entities-recycle-version-pump/pr-25781-f970edf/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/90819652329/artifacts/
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/sdk-reserved-entities-recycle-version-pump/pr-25781-f970edf/Decentraland_macos.zip
Built on 2026-09-01T18:18:43Z

Lint

Warnings not reduced: 12191 => 12201 — remove at least 11 warnings to merge.

Warnings/errors in files changed by this PR (34)
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:98  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:121  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:142  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:150  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:184  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:194  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:229  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:236  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:258  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:267  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:275  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:282  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:289  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:305  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:313  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:321  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:341  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:347  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:365  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:374  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:380  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:401  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:406  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:415  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:421  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:444  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:450  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:454  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:477  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:501  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:510  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:528  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:539  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant
Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs:562  RedundantSuppressNullableWarningExpression  The nullable warning suppression expression is redundant

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25529 0 13
PlayMode ✅ Passed 248 0 37

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
unity-explorer Ready Ready Preview Aug 24, 2026 6:31pm

Request Review

@pravusjif pravusjif moved this to QA in Creators Tools Aug 25, 2026
@pravusjif
pravusjif marked this pull request as ready for review August 25, 2026 22:33
@pravusjif
pravusjif requested review from a team as code owners August 25, 2026 22:33
@github-actions
github-actions Bot requested a review from DafGreco August 25, 2026 22:34
@pravusjif
pravusjif requested review from mikhail-dcl and removed request for DafGreco August 25, 2026 22:34
@decentraland-bot
decentraland-bot self-requested a review August 25, 2026 22:34
@pravusjif
pravusjif requested review from alejandro-jimenez-dcl and removed request for decentraland-bot August 25, 2026 22:34

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

PR Review — fix: sdk reserved entities recycle version bump


STEP 1 — Context & Scope

Files reviewed:

  • Explorer/Assets/DCL/Infrastructure/CRDT/CRDTEntity.cs — added MAX_VERSION constant
  • Explorer/Assets/DCL/Multiplayer/SDK/Systems/GlobalWorld/PlayerCRDTEntitiesHandlerSystem.cs — version tracking for reserved entity numbers
  • Explorer/Assets/DCL/Multiplayer/SDK/Tests/PlayerCRDTEntitiesHandlerSystemShould.cs — 4 new tests + updated assertions

Surrounding files read in full:

  • PlayerCRDTEntity component (Components/PlayerCRDTEntity.cs)
  • SpecialEntitiesID (CrdtEcsBridge/Components/SpecialEntitiesID.cs)
  • MultiplayerPlugin (global plugin registration)
  • Downstream consumers: PlayerSceneCRDTEntity, propagation systems, OutgoingCRDTMessagesProvider

Reference docs: CLAUDE.md, review-instructions.md


STEP 2 — Root-cause check: ✅ PASS

Problem: ADR-245 requires a new generation (version) every time a reserved entity number (32–255) is reassigned to a different remote player. Unity Explorer never bumped the version — the pool was a bare bool[224], so every player inheriting a slot got version 0. Scenes store deleted entities as number → version and discard messages whose version isn't strictly greater. A tombstoned slot made every subsequent player on that number invisible to that scene.

Fix: Adds per-slot version tracking. Each recycle bumps the generation. This addresses the root cause (missing version increment), not a symptom. The fix is correct and aligns with Hammurabi, Bevy, and Godot implementations.


STEP 3 — Design & integration: ✅ PASS

Owner search:

  • The reserved entity number pool is owned exclusively by PlayerCRDTEntitiesHandlerSystem (created in constructor via ClearReservedEntities(), managed through TryReserveNextFreeEntity / FreeReservedEntity).
  • No other system creates, destroys, or manages this pool.
  • MultiplayerPlugin.cs:167 — registered as a global plugin system, created once per session.

Assessment: The version tracking is added to the existing lifecycle owner. No new units are introduced — reservedEntityVersions[] is a parallel tracking structure alongside the existing reservedEntities[]. This is the natural and correct home for this state.

Pre-existing note: CLAUDE.md §1 states "Systems must not contain state — all state goes into ECS." The system already held persistent state (reservedEntities[], currentReservedEntitiesCount, reservedEntitiesExhaustionReported) before this PR. The entity number pool is a global resource shared across all entities (not per-entity state), which is a legitimate exception. This PR extends an existing pattern — it does not introduce the concern.

Teardown/consumption trace: No subscriptions, callbacks, or event hookups added. The int[] is a plain array requiring no disposal. ClearReservedEntities() resets both arrays. ✅


STEP 4 — Member audit: ✅ PASS

Member Visibility Consumers Assessment
CRDTEntity.MAX_VERSION public const FreeReservedEntity (line 216), test RetireReservedNumberWhenItRunsOutOfVersions (line 442) Correctly public — other code may need to reference the version bound. Naming follows project convention (SCREAMING_SNAKE_CASE, matching SpecialEntitiesID constants). ✅
TryReserveNextFreeEntity(out CRDTEntity) private AddPlayerCRDTEntity (line 73) Single caller. Replaces sentinel-returning ReserveNextFreeEntity(). The Try pattern with out is idiomatic C# and eliminates the ambiguity of the old -1 sentinel when version bits live in Id. ✅
FreeReservedEntity(CRDTEntity) private RemoveComponent (line 160) Single caller. Changed from int entityId to CRDTEntity — indexes by EntityNumber (line 208), which correctly extracts the number from the packed id via Id & 0xffff. ✅

STEP 5 — Line-level review

Pass A — Blocking issues: None found.

Bit-packing correctness verified:

  • CRDTEntity.Create(32, 0xffff)32 | (0xffff << 16)0xffff0020 (negative as signed int)
  • EntityNumber: 0xffff0020 & 0xffff = 0x0020 = 32 ✅
  • EntityVersion: (0xffff0020 >> 16) & 0xffff — C# arithmetic right shift sign-extends, giving 0xffffffff, then & 0xffff = 0xffff = 65535 ✅
  • crdtEntity.Id == SpecialEntitiesID.PLAYER_ENTITY checks in ResolvePlayerCRDTScene (line 117) and RemovePlayerFromScene (line 169) remain correct — local player (PLAYER_ENTITY=1) is never pooled, so its version is always 0 and its Id always equals 1 ✅

Version lifecycle verified:

  • Init: reservedEntityVersions[i] = -1 (in ClearReservedEntities, called from constructor)
  • First handout: ++(-1) = 0 → version 0 ✅
  • Recycle: ++(prev) → monotonically increasing ✅
  • Retirement: reservedEntityVersions[index] >= MAX_VERSION → slot stays true, count not decremented → TryReserveNextFreeEntity correctly skips retired slots ✅
  • No window exists between field initializer (new int[224], defaults to 0) and ClearReservedEntities() because the object isn't accessible before the constructor completes ✅

Idempotency verified:

  • Double-free guard (if (!reservedEntities[index]) return;, line 212) prevents count corruption ✅
  • Retired slot: reservedEntities[index] stays true, so a second free attempt would hit the retirement check and log again — but this is impossible in practice because a retired slot is never handed out again, so no entity will ever hold a CRDTEntity with that retired number+version ✅

currentReservedEntitiesCount accounting verified:

  • Increment: line 193 (on reservation) ✅
  • Decrement: line 226 (on non-retired free) ✅
  • No decrement on retirement: correct — retired slots are permanently occupied ✅
  • Full retirement scenario: if all 224 slots are retired, currentReservedEntitiesCount == 224 == reservedEntities.LengthTryReserveNextFreeEntity returns false at the short-circuit (line 185) ✅

No allocation in hot path: TryReserveNextFreeEntity and FreeReservedEntity do no allocations. The string interpolation in the retirement warning (line 218) only fires once per slot lifetime (on the slot's final free), not per-frame. ✅ (CLAUDE.md §4)

Pass B — Design smells: None found. No new magic values, no naming issues, no encapsulation violations.

Test quality:

  • 4 new tests cover: version propagation to scene entity, independent per-number versioning, slot retirement at MAX_VERSION, and correct fallback to the next slot after retirement.
  • Existing tests updated to assert full CRDTEntity equality (number + version) instead of raw Id — strengthens the assertions.
  • AAA pattern followed. NUnit + NSubstitute used correctly. ✅ (CLAUDE.md §10)
  • RetireReservedNumberWhenItRunsOutOfVersions loops 65,536 times to exhaust the version space — necessary to test the boundary condition and acceptable given the lightweight per-iteration cost (no I/O, minimal ECS operations on a single-entity world).

STEP 6 — Complexity

COMPLEX — modifies CRDT entity id assignment and version management in the multiplayer synchronization layer. Directly affects how remote players are identified across scene boundaries.


STEP 7 — QA assessment

QA_REQUIRED: YES — changes runtime multiplayer code that determines whether remote players are visible in scenes. The fix addresses a player-facing visibility bug. The PR includes detailed QA test steps.


STEP 8 — Non-blocking warnings

None. Main.unity is not modified.


Security Review

No security issues found.

  • No secrets, tokens, or credentials in the diff
  • No user input handling — the entity pool is managed internally by the system
  • No auth/authz changes
  • Entity numbers in log messages are not sensitive data
  • Version exhaustion attack (rapid connect/disconnect to retire all slots) would require ~14.7M cycles on a single slot — practically infeasible and limited in impact (pool shrinks by one slot per exhausted number out of 224 available)

Consumer Impact

No public API surface is broken. The new CRDTEntity.MAX_VERSION constant is additive. All changes to PlayerCRDTEntitiesHandlerSystem are to private methods. The PlayerCRDTEntity component's public interface is unchanged (its constructor already accepted CRDTEntity). The versioned id flows unchanged into downstream systems (PlayerSceneCRDTEntity, OutgoingCRDTMessagesProvider, CRDTSerializer). No consumer impact.


Summary

Clean, well-scoped fix that addresses the root cause of ADR-245 non-compliance. The version tracking is added to the correct lifecycle owner with no architectural concerns. Bit-packing, version lifecycle, pool accounting, and edge cases (retirement at MAX_VERSION) are all correct. Tests are thorough — 4 new tests cover the key scenarios including boundary conditions. No blocking issues.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies CRDT entity id versioning in the multiplayer synchronization layer (reserved entity pool management, version packing, scene-level entity identity)
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.

@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: fix: sdk reserved entities recycle version bump

STEP 2 — Root-cause check ✅

Problem: ADR-245 requires a new version (generation) every time a reserved CRDT entity number is reassigned to a different player. The pool was a bare bool[224] that never bumped the version, so every player inheriting a number got version 0. Since the CRDT protocol stores deleted entities as number → version and discards any message whose version is not greater than the stored one (CRDTProtocol.cs:68-73), a tombstoned number at version 0 made every subsequent player on that slot invisible to the scene.

This diff fixes the cause, not a symptom. The version tracking is added at the reservation layer (TryReserveNextFreeEntity) so the versioned entity flows naturally through PlayerSceneCRDTEntity, OutgoingCRDTMessagesProvider, and CRDTSerializer without any downstream changes needed.

STEP 3 — Design & integration ✅

Lifecycle owner search:

  1. The entity being managed is the reserved CRDT entity number pool (slots 32–255) for remote players.
  2. The existing owner is PlayerCRDTEntitiesHandlerSystem, which already maintains bool[] reservedEntities and currentReservedEntitiesCount as persistent cross-scene state. This system creates slots in TryReserveNextFreeEntity and releases them in FreeReservedEntity. Files examined:
    • PlayerCRDTEntitiesHandlerSystem.cs — sole owner of the reservation pool
    • PlayerCRDTEntity.cs — component carrying the CRDTEntity in the global world
    • PlayerSceneCRDTEntity.cs — component carrying the CRDTEntity in scene worlds
    • PlayerTransformPropagationSystem.cs, PlayerProfileDataPropagationSystem.cs — downstream consumers (read-only, use CRDTEntity.Id for local player check only)
  3. The version tracking array is added alongside the existing reservedEntities array in the same owner — there is no alternative owner. The pool is a cross-scene protocol resource; it cannot live in ECS (no single entity or scene owns it).

Design verdict: correct. The new int[] reservedEntityVersions extends the existing persistent pool state in its natural owner. No duplicate lifecycle, no frame-based reconciliation, no polling.

Teardown trace:

  • reservedEntities[i] set to true in TryReserveNextFreeEntity → set to false in FreeReservedEntity (or permanently retired at version exhaustion) ✅
  • ClearReservedEntities() resets both arrays — called in the constructor ✅
  • Retired slots (version ≥ MAX_VERSION) intentionally stay reserved to avoid repeating a generation, which is correct protocol behavior ✅

STEP 4 — Member audit ✅

CRDTEntity.MAX_VERSION (new public const): Used in FreeReservedEntity (line 216) and RetireReservedNumberWhenItRunsOutOfVersions test (line 442). Two consumers. As a const int, it is a value boundary — appropriate as a public constant on the type it describes.

TryReserveNextFreeEntity(out CRDTEntity) (changed from ReserveNextFreeEntity()): Private method, sole caller is AddPlayerCRDTEntity. The Try pattern correctly replaces the ambiguous -1 sentinel with a bool return + out parameter. Single-use is fine — it is the pool allocation primitive, not a derived predicate.

FreeReservedEntity(CRDTEntity) (parameter changed from int): Private method, sole caller is RemoveComponent. Takes CRDTEntity instead of raw int because the raw Id no longer equals the entity number when version > 0. The method extracts EntityNumber to index into the pool — correct.

STEP 5 — Line-level review ✅

Pass A — Blocking issues: None found.

  • Array bounds: FreeReservedEntity indexes by crdtEntity.EntityNumber - OTHER_PLAYER_ENTITIES_FROM. Since only entities produced by TryReserveNextFreeEntity reach this method, and that method constrains i to [0, reservedEntities.Length), the index is always in bounds. The range guard at lines 209 also handles unexpected values defensively.
  • Integer overflow: ++reservedEntityVersions[i] increments from -1 to 0 on first use, then monotonically. The retirement check (>= MAX_VERSION) fires when version reaches 0xffff, preventing CRDTEntity.Create from ever receiving version 0x10000 (which would overflow the 16-bit field in the packed Id).
  • crdtEntity.Id == PLAYER_ENTITY checks: All downstream systems (RemovePlayerFromScene:169, PlayerTransformPropagationSystem:41, WritePlayerTransformSystem:43/59, PropagateAvatarLocomotionOverridesSystem:55) compare the full Id to PLAYER_ENTITY (=1). The local player is never pooled (gets PLAYER_ENTITY directly at line 72), so its version is always 0 and Id remains 1. Remote players have numbers ≥ 32, so their Ids are always ≥ 32 even at version 0. These checks remain correct. ✅
  • Version bump timing: Version is incremented at reservation time (TryReserveNextFreeEntity:196), not at free time. This is correct — bumping on free would waste versions for slots that sit idle.
  • currentReservedEntitiesCount consistency: Retired slots stay true in reservedEntities and are counted as occupied, which correctly prevents reuse. currentReservedEntitiesCount is only decremented when a slot is actually freed (line 226). ✅
  • String interpolation in warnings: Only triggered on version exhaustion (line 218) and pool exhaustion (line 81) — not hot paths. No allocation concern. ✅
  • Allocations in Update(): No new allocations added to any query method. The version tracking uses pre-allocated arrays. ✅

Pass B — Design smells: None found. No new types, no new dependencies, no magic values. The pool logic is self-contained in the existing system.

Security review ✅

No security issues found. The changes are purely internal CRDT entity management. No user input processing, no network data handling, no authentication changes. The version tracking is a protocol-compliance fix that affects only how entity IDs are packed locally.

Test coverage ✅

Three new tests added:

  1. PropagateRecycledEntityVersionToTheSceneEntity — verifies the versioned entity flows through to PlayerSceneCRDTEntity in the scene world after a number is recycled.
  2. BumpEntityVersionsIndependentlyPerReservedNumber — verifies each reserved number tracks its version independently (number 32 at v1, number 33 still at v0).
  3. RetireReservedNumberWhenItRunsOutOfVersions — exhausts all 65,536 versions on a single number and verifies the slot is retired, forcing the next player onto the next available number.

Existing tests updated to expect CRDTEntity.Create(number, version) instead of raw int IDs. Comments updated to reflect the version bump semantics.

STEP 6 — Complexity

COMPLEX — touches CRDT entity management, multiplayer sync, and the reserved entity pool lifecycle.

STEP 7 — QA assessment

QA required — the change affects how remote players are exposed to scenes at runtime. The PR includes detailed QA test steps for verifying entity ID reuse with version bumps in a multiplayer scenario.

STEP 8 — Non-blocking warnings

None. Main scene is not modified.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies CRDT entity number pool lifecycle and multiplayer entity version tracking in PlayerCRDTEntitiesHandlerSystem
QA_REQUIRED: YES


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

@decentraland-bot

This comment has been minimized.

@pravusjif pravusjif added no-warning-ratchet Prevent CI linting checks force-build Used to trigger a build on draft PR labels Aug 26, 2026
@decentraland-bot

This comment has been minimized.

@DafGreco DafGreco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✔️ PR reviewed and approved by QA on both platforms following instructions playing both happy and un-happy path

Regressions for this ticket had been performed in order to verify that the normal flow is working as expected:

Evidence

Image
fixpravus.mp4

@pravusjif
pravusjif enabled auto-merge (squash) September 1, 2026 13:54
@pravusjif
pravusjif disabled auto-merge September 1, 2026 14:13
@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9847, run #33542931736

Overall: ✅ no significant changes

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

How to read this table
  • Each build is measured 3 times, interleaved with the other build (change, baseline, change, baseline, ...) in the same session, so both see the same world content and machine state. The values are the median, and (min–max) is the lowest and highest of those runs.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a difference that passed every check: the runs are fully separated (every run of one build faster than every run of the other), and the median difference is at least 3% and at least 0.5 ms.
  • ⚪ within noise — the builds' runs overlap, or the difference is tiny; it cannot be told apart from random variation. Treat it as no change.
  • — informational — the 0.1% worst metrics average only the few worst frames of a run, so a single OS hiccup swings them by a lot; they are shown for context and never earn a verdict.
  • ⚠️ no verdict — the two builds' sessions were not comparable (very different sample counts, or too few usable runs), so no conclusion is drawn from them.
  • Exceptions per run — the average number of exceptions in a run's log, not counting teardown ones logged while the app quits. Flagged only on a difference of at least 2 per run and 1.5× the other build; exception kinds the baseline never threw are called out under the table. The Exception breakdown groups all of 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.
  • The Overall line at the top only reacts to a metric that moved on two or more machines, or by 10% or more on one — a single modest 🟢/🔴 cell can still be a statistical fluke.

Intel Core i5

Metric Baseline Change Δ Result
Samples 4286 (×3) 4720 (×3)
CPU average 20.9 ms (18.3–24.9) 19.0 ms (16.4–19.8) -2.0 ms ⚪ within noise
CPU 1% worst 401.8 ms (326.7–566.8) 298.7 ms (258.4–370.8) -103.1 ms ⚪ within noise
CPU 0.1% worst 444.3 ms (419.3–738.4) 423.5 ms (308.2–461.1) -20.8 ms — informational
GPU average 13.7 ms (11.2–16.5) 12.2 ms (9.7–12.7) -1.5 ms ⚪ within noise
GPU 1% worst 276.2 ms (159.5–454.3) 175.0 ms (129.4–212.4) -101.2 ms ⚪ within noise
GPU 0.1% worst 443.9 ms (417.2–738.7) 422.1 ms (294.5–444.1) -21.8 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

Apple M1

Metric Baseline Change Δ Result
Samples 3906 (×3) 4144 (×3)
CPU average 22.9 ms (22.1–23.0) 21.6 ms (21.3–23.0) -1.3 ms ⚪ within noise
CPU 1% worst 231.0 ms (230.8–232.6) 227.6 ms (201.5–229.2) -3.5 ms ⚪ within noise
CPU 0.1% worst 239.2 ms (237.0–241.2) 238.1 ms (234.5–238.3) -1.0 ms — informational
GPU average 40.4 ms (38.7–40.8) 37.5 ms (37.5–41.8) -2.9 ms ⚪ within noise
GPU 1% worst 48.6 ms (47.3–49.1) 46.6 ms (45.9–49.7) -1.9 ms ⚪ within noise
GPU 0.1% worst 50.1 ms (49.1–50.7) 48.7 ms (47.1–51.9) -1.4 ms — informational
Exceptions per run 0 0 0 ⚪ no significant change

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

Labels

force-build Used to trigger a build on draft PR no-warning-ratchet Prevent CI linting checks

Projects

Status: QA

Development

Successfully merging this pull request may close these issues.

4 participants