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.f24532adbegan 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 byf24532ad, 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:
parent
97d11e6c7f
commit
3c36b4cc21
10 changed files with 134 additions and 29 deletions
|
|
@ -30,6 +30,55 @@ public sealed class EntityEffectPoseRegistryTests
|
|||
Assert.False(poses.TryGetPartPose(7u, -1, out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #282: a live entity's cell is <c>ParentCellId</c>, and every per-tick
|
||||
/// physics/network writer moves only that field
|
||||
/// (RemotePhysicsUpdater, LiveEntityOrdinaryPhysicsUpdater,
|
||||
/// LocalPlayerProjectionController, …). <c>EffectCellId</c> 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MovingLiveEntity_CarriesItsEffectsToTheNewCell()
|
||||
{
|
||||
var poses = new EntityEffectPoseRegistry();
|
||||
WorldEntity entity = Entity(11u, new Vector3(1, 2, 3));
|
||||
poses.Publish(entity, Array.Empty<Matrix4x4>());
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #282: the stab case EffectCellId was actually introduced for still
|
||||
/// works - a null render parent falls through to the authored landcell.
|
||||
/// </summary>
|
||||
[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<Matrix4x4>());
|
||||
|
||||
Assert.True(poses.TryGetCellId(12u, out uint cell));
|
||||
Assert.Equal(0x0101001Au, cell);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishMeshRefs_ReplacesPartSnapshotImmediately()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue