fix(vfx): resolve an entity's cell through one owner so effects follow it

Fixes #282 (plan S2). Adds register row AP-133.

Retail gives a CPhysicsObj exactly ONE cell: ShouldDrawParticles @0x0050fe60
reads this->cell and calls IsInView on it, and set_cell_id @0x0050f4f0 /
change_cell @0x00513390 are the only things that move it. acdream splits that
into ParentCellId (render parent, deliberately null for outdoor dat stabs) and
EffectCellId (the authored landcell those parentless stabs still need) - an
adaptation, now recorded as AP-133.

WorldEntity.EffectCellId documents itself as the stab field, with live and
interior entities using ParentCellId. f24532ad began writing it for live
entities too. Because EntityEffectPoseRegistry resolved EffectCellId FIRST,
that write won - and the audit shows only 3 of 14 cell writers maintain it.
The other 11 do not, including the hottest paths: RemotePhysicsUpdater:239,294
and LiveEntityOrdinaryPhysicsUpdater:107 write ParentCellId every physics tick
from the snapshot, and LocalPlayerProjectionController:79 writes the local
player's cell every frame.

So a moving entity updated its cell constantly while EffectCellId stayed
frozen at whatever cell it materialized in. Its particles and lights kept
being tested against that stale cell and failed IsInView the moment it crossed
a boundary - effects vanishing on a monster that is plainly visible, or
drawing through a wall from a room the viewer cannot see.

The consumers had also drifted into disagreeing: EntityEffectPoseRegistry
preferred EffectCellId while WbDrawDispatcher.TryGetEntityCell and the remote
spawn seed preferred ParentCellId - two answers to "which cell is this in".

- WorldEntity.VisibilityCellId (ParentCellId ?? EffectCellId) is the single
  accessor; all five consumer sites resolve through it, so the precedence
  cannot drift apart again.
- LiveEntityRuntime's three live-entity EffectCellId writes are removed,
  restoring the field to its documented purpose. Its real writers -
  LandblockLoader:80,97 and LandblockBuildFactory:408 - are untouched, and the
  parentless-stab path is pinned by a new test.
- f24532ad's actual fix is preserved: RebucketLiveEntity still installs the
  committed cell, just on the one field live entities use.

LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell is
back to moving the entity by ParentCellId alone - its original pre-f24532ad
form - and passes. CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell
had its two EffectCellId assertions (added by f24532ad, encoding the defect)
replaced with the corrected contract: ParentCellId set, EffectCellId null,
VisibilityCellId resolving - a stronger assertion, not a relaxed one.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 14:02:03 +02:00
parent 97d11e6c7f
commit 3c36b4cc21
10 changed files with 134 additions and 29 deletions

View file

@ -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.
- **#282OPEN — live entities now write `EffectCellId`, contradicting its
- **#282DONE (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

View file

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