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]