diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2004ebbd..ab914277 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -30,7 +30,7 @@ Plan: [`2026-08-03-recent-regression-cleanup.md`](plans/2026-08-03-recent-regres All three were introduced by the 2026-08-02/03 stabilization batch, found while reconciling #281's 43 test failures. -- **#282 — OPEN — live entities now write `EffectCellId`, contradicting its +- **#282 — DONE (2026-08-03) — live entities now write `EffectCellId`, contradicting its documented contract, and 12 `ParentCellId` writers cannot keep it in sync.** `WorldEntity.EffectCellId` (src/AcDream.Core/World/WorldEntity.cs:95-102) documents itself as existing ONLY for outdoor dat stabs, which keep a null @@ -50,6 +50,25 @@ reconciling #281's 43 test failures. the null-render-parent stab case. Fix shape: one owner writes the entity's visibility cell and the effects path reads that owner. Caught in miniature by `LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell`. + **Landed 2026-08-03 (S2).** The audit found 14 cell writers: only 3 rebucket + (and so repaired `EffectCellId` by accident), while 11 do not — including the + hottest paths, `RemotePhysicsUpdater:239,294` and + `LiveEntityOrdinaryPhysicsUpdater:107` (every physics tick from the snapshot) + and `LocalPlayerProjectionController:79` (the local player every frame). So a + moving entity updated its cell constantly while `EffectCellId` stayed frozen. + The consumers also disagreed: `EntityEffectPoseRegistry` preferred + `EffectCellId`, while `WbDrawDispatcher.TryGetEntityCell` and the remote + spawn seed preferred `ParentCellId`. Fix: `WorldEntity.VisibilityCellId` + (`ParentCellId ?? EffectCellId`) is the single accessor every consumer + resolves through; `LiveEntityRuntime`'s three live-entity `EffectCellId` + writes are removed, restoring the field to its documented stab/building-shell + purpose (`LandblockLoader:80,97`, `LandblockBuildFactory:408`). Register row + AP-133 records the adaptation and the exact way it can regress. + `Refresh_FollowsCurrentTopLevelRootAndCell` is back to moving the entity by + `ParentCellId` alone — its original pre-`f24532ad` form — and passes. + Complete Release solution: 10,836 passed / 4 skipped / 0 failed. + **User visual check still outstanding:** a monster with an active spell + effect crossing a cell boundary, and a lit static object, indoors and out. - **#283 — OPEN — Runtime's world frame and App's render origin rebase at different moments during a teleport.** `670f307c` gave Runtime its own world frame (`RuntimePhysicsState.ObserveLocalWorldFrame`), which rebases the diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 82eb6ae9..8e020dab 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -278,6 +278,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | | AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) | | AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | +| AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index a2b4cafd..d1e85d1d 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -731,10 +731,9 @@ internal sealed class LiveEntityNetworkUpdateController update.Guid, entity, remote.Body.Position, - // Interior live entities carry ParentCellId; outdoor live - // entities carry the outdoor landcell in EffectCellId (see - // WorldEntity's cell-field docs). 0 → helper no-ops. - entity.ParentCellId ?? entity.EffectCellId ?? 0u); + // #282: one owner for "which cell is this in" (see + // WorldEntity.VisibilityCellId). 0 → helper no-ops. + entity.VisibilityCellId ?? 0u); } if (!IsCurrentOwner(remote)) return default; diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs index 0e9a6362..8348b566 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs @@ -54,7 +54,7 @@ public sealed class EntityEffectPoseRegistry : Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position), partLocal, - entity.EffectCellId ?? entity.ParentCellId ?? 0u, + entity.VisibilityCellId ?? 0u, availability); } @@ -79,11 +79,11 @@ public sealed class EntityEffectPoseRegistry : else { changed = record.RootWorld != rootWorld - || record.CellId != (entity.EffectCellId ?? entity.ParentCellId ?? 0u); + || record.CellId != (entity.VisibilityCellId ?? 0u); } record.RootWorld = rootWorld; - record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; + record.CellId = entity.VisibilityCellId ?? 0u; if (entity.IndexedPartTransforms.Count > 0) { changed |= CopyParts( @@ -160,7 +160,14 @@ public sealed class EntityEffectPoseRegistry : return false; Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); - uint cellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; + // #282: resolve through the one owner. Reading EffectCellId first was + // inverted relative to every other consumer + // (WbDrawDispatcher.TryGetEntityCell, + // LiveEntityNetworkUpdateController's spawn seed), and for a live + // entity it pinned effects to the materialization cell - only 3 of 14 + // cell writers maintain EffectCellId, while ParentCellId is updated + // every physics tick. + uint cellId = entity.VisibilityCellId ?? 0u; if (record.RootWorld == rootWorld && record.CellId == cellId) return true; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 7cacabdd..a540e0e3 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -435,14 +435,13 @@ public sealed partial class WbDrawDispatcher : IDisposable private static bool TryGetEntityCell(WorldEntity entity, out uint cell) { - if (entity.ParentCellId is uint parent) + // #282: one owner for "which cell is this in" - see + // WorldEntity.VisibilityCellId. This site already had the correct + // precedence; routing it through the accessor keeps it from drifting + // apart from the effect/particle path again. + if (entity.VisibilityCellId is uint resolved) { - cell = parent; - return true; - } - if (entity.EffectCellId is uint effect) - { - cell = effect; + cell = resolved; return true; } cell = 0; diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index e0d88d4d..a4c4d2d2 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -851,8 +851,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // CObjCell read by ShouldDrawParticles at the same edge; retaining // the prior sidecar cell makes newly-created spell particles fail // IsInView as soon as the player crosses an outdoor landcell. + // #282: a live entity's cell is ParentCellId alone. EffectCellId + // exists only for outdoor dat stabs/building shells, which keep a + // null render parent (LandblockLoader:80,97) - writing it here + // made it win WorldEntity.VisibilityCellId for live entities, + // which the 11 non-rebucketing per-tick writers cannot maintain. entity.ParentCellId = spatialCellOrLandblockId; - entity.EffectCellId = spatialCellOrLandblockId; } Exception? spatialNotificationFailure = null; uint priorRebucketingGuid = _rebucketingGuid; @@ -1097,8 +1101,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource entity.SetPosition(projection.WorldPosition); entity.Rotation = projection.Orientation; + // #282: live entities carry ParentCellId only; EffectCellId is the + // outdoor dat stab / building-shell field (LandblockLoader:80,97). entity.ParentCellId = token.ExactCellId; - entity.EffectCellId = token.ExactCellId; return RebucketLiveEntityPresentationOnly( record.ServerGuid, record, @@ -1234,8 +1239,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource entity.SetPosition(projection.WorldPosition); entity.Rotation = projection.Orientation; + // #282: live entities carry ParentCellId only; EffectCellId is the + // outdoor dat stab / building-shell field (LandblockLoader:80,97). entity.ParentCellId = token.ExactCellId; - entity.EffectCellId = token.ExactCellId; record.IsSpatiallyProjected = true; Exception? spatialNotificationFailure = null; diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index cdf1931a..3fa702e9 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -101,6 +101,26 @@ public sealed class WorldEntity /// public uint? EffectCellId { get; set; } + /// + /// #282: the ONE cell this entity occupies, as retail models it. A + /// CPhysicsObj has a single cell; ShouldDrawParticles + /// @0x0050fe60 reads that same field and calls IsInView on it, and + /// set_cell_id @0x0050f4f0 / change_cell @0x00513390 are the + /// only things that move it. Our split into + /// + is an + /// adaptation for outdoor dat stabs, which keep a null render parent yet + /// still need a landcell for particle gating. + /// + /// Resolve through here, never by reading one field or re-deriving the + /// precedence at a call site. Live entities carry + /// , kept current by every per-tick physics and + /// network writer; stabs and building shells carry only + /// . Reading FIRST is + /// what stranded live entities' particles and lights on their + /// materialization cell, because only 3 of 14 cell writers maintain it. + /// + public uint? VisibilityCellId => ParentCellId ?? EffectCellId; + /// /// True when this entity originates from LandBlockInfo.Buildings[] /// (the dat array that carries building shells: cottage walls, smithy walls, diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs index 65bad16c..4ac820bc 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs @@ -30,6 +30,55 @@ public sealed class EntityEffectPoseRegistryTests Assert.False(poses.TryGetPartPose(7u, -1, out _)); } + /// + /// #282: a live entity's cell is ParentCellId, and every per-tick + /// physics/network writer moves only that field + /// (RemotePhysicsUpdater, LiveEntityOrdinaryPhysicsUpdater, + /// LocalPlayerProjectionController, …). EffectCellId exists solely + /// for outdoor dat stabs and building shells, which keep a null render + /// parent (LandblockLoader:80,97). Resolving effects by EffectCellId + /// FIRST stranded a moving entity's particles and lights on whatever cell + /// it was materialized in, so they failed IsInView the moment it crossed + /// a boundary. Retail has one cell per CPhysicsObj - ShouldDrawParticles + /// @0x0050fe60 reads that same field. + /// + [Fact] + public void MovingLiveEntity_CarriesItsEffectsToTheNewCell() + { + var poses = new EntityEffectPoseRegistry(); + WorldEntity entity = Entity(11u, new Vector3(1, 2, 3)); + poses.Publish(entity, Array.Empty()); + Assert.True(poses.TryGetCellId(11u, out uint published)); + Assert.Equal(0x01010001u, published); + + // One physics tick later the entity is in the next cell. Only + // ParentCellId moves - that is all the production writers touch. + entity.SetPosition(new Vector3(30, 2, 3)); + entity.ParentCellId = 0x01010002u; + Assert.True(poses.UpdateRoot(entity)); + + Assert.True(poses.TryGetCellId(11u, out uint moved)); + Assert.Equal(0x01010002u, moved); + } + + /// + /// #282: the stab case EffectCellId was actually introduced for still + /// works - a null render parent falls through to the authored landcell. + /// + [Fact] + public void OutdoorStabWithNoRenderParent_UsesItsAuthoredLandcell() + { + var poses = new EntityEffectPoseRegistry(); + WorldEntity stab = Entity(12u, new Vector3(4, 5, 6)); + stab.ParentCellId = null; + stab.EffectCellId = 0x0101001Au; + + poses.Publish(stab, Array.Empty()); + + Assert.True(poses.TryGetCellId(12u, out uint cell)); + Assert.Equal(0x0101001Au, cell); + } + [Fact] public void PublishMeshRefs_ReplacesPartSnapshotImmediately() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs index dbbff468..9f3b2763 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/LiveEntityLightControllerTests.cs @@ -22,15 +22,12 @@ public sealed class LiveEntityLightControllerTests LightSource light = Assert.Single(fixture.Sink.GetOwnedLights(entity.Id)!); entity.SetPosition(new Vector3(20, 30, 40)); - // f24532ad: a canonical cell move now writes BOTH sidecar cells - // together (LiveEntityRuntime.RebucketLiveEntity:845-856), because - // retail's CPhysicsObj::set_cell changes the one CObjCell that - // ShouldDrawParticles reads. EntityEffectPoseRegistry.UpdateRoot:163 - // resolves EffectCellId ?? ParentCellId, so moving the entity by - // ParentCellId alone would leave effects and lights on the stale - // materialization cell. + // #282: a live entity's cell is ParentCellId. Moving it alone must be + // enough - that is what every per-tick physics and network writer + // does, and lights/effects resolve through + // WorldEntity.VisibilityCellId rather than preferring the stab-only + // EffectCellId. entity.ParentCellId = 0x01010002u; - entity.EffectCellId = 0x01010002u; Assert.True(fixture.Poses.UpdateRoot(entity)); fixture.Controller.Refresh(); diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 4aa33ce9..9252d083 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -2200,13 +2200,21 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.Equal(0x01010022u, record.FullCellId); Assert.Equal(0x0102FFFFu, record.CanonicalLandblockId); + // #282: a live entity's cell is ParentCellId, and VisibilityCellId is + // the one accessor every consumer resolves through. EffectCellId is + // the outdoor dat stab / building-shell field and must stay null here + // - f24532ad briefly wrote it for live entities, which made it win + // VisibilityCellId while the 11 non-rebucketing per-tick writers left + // it frozen at the materialization cell. Assert.Equal(0x01010022u, entity.ParentCellId); - Assert.Equal(0x01010022u, entity.EffectCellId); + Assert.Null(entity.EffectCellId); + Assert.Equal(0x01010022u, entity.VisibilityCellId); runtime.RebucketLiveEntity(guid, 0x01020033u); Assert.Equal(0x01020033u, record.FullCellId); Assert.Equal(0x01020033u, entity.ParentCellId); - Assert.Equal(0x01020033u, entity.EffectCellId); + Assert.Null(entity.EffectCellId); + Assert.Equal(0x01020033u, entity.VisibilityCellId); } [Fact]