acdream/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs
Erik 3c36b4cc21 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>
2026-08-03 14:02:03 +02:00

421 lines
14 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class EntityEffectPoseRegistryTests
{
[Fact]
public void Publish_PreservesIndexedParts_AndUpdateRootDoesNotReplaceThem()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(7u, new Vector3(1, 2, 3));
poses.Publish(entity, new[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0, 0, 4),
});
entity.SetPosition(new Vector3(10, 20, 30));
Assert.True(poses.UpdateRoot(entity));
Assert.True(poses.TryGetRootPose(7u, out Matrix4x4 root));
Assert.Equal(new Vector3(10, 20, 30), root.Translation);
Assert.True(poses.TryGetPartPose(7u, 1, out Matrix4x4 part));
Assert.Equal(new Vector3(0, 0, 4), part.Translation);
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()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(8u, Vector3.Zero);
poses.Publish(entity, new[] { Matrix4x4.Identity });
entity.MeshRefs = new[]
{
new MeshRef(0x01000001u, Matrix4x4.CreateTranslation(1, 2, 3)),
new MeshRef(0x01000002u, Matrix4x4.CreateTranslation(4, 5, 6)),
};
poses.PublishMeshRefs(entity);
Assert.True(poses.TryGetPartPoses(8u, out var parts));
Assert.Equal(2, parts.Count);
Assert.Equal(new Vector3(4, 5, 6), parts[1].Translation);
}
[Fact]
public void Publish_MissingMiddlePartRetainsIndicesButCannotBeResolved()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(81u, Vector3.Zero);
poses.Publish(
entity,
[
Matrix4x4.CreateTranslation(1, 0, 0),
Matrix4x4.CreateTranslation(2, 0, 0),
Matrix4x4.CreateTranslation(3, 0, 0),
],
[true, false, true]);
Assert.False(poses.TryGetPartPose(81u, 1, out _));
Assert.True(poses.TryGetPartPose(81u, 2, out Matrix4x4 third));
Assert.Equal(3f, third.Translation.X);
}
[Fact]
public void PublishMeshRefs_PrefersStableIndexedEntityPosesOverCompactedDrawables()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(82u, Vector3.Zero);
entity.MeshRefs =
[
new MeshRef(1u, Matrix4x4.CreateTranslation(1, 0, 0)),
new MeshRef(3u, Matrix4x4.CreateTranslation(3, 0, 0)),
];
entity.SetIndexedPartPoses(
[
Matrix4x4.CreateTranslation(1, 0, 0),
Matrix4x4.CreateTranslation(2, 0, 0),
Matrix4x4.CreateTranslation(3, 0, 0),
],
[true, false, true]);
poses.PublishMeshRefs(entity);
Assert.False(poses.TryGetPartPose(82u, 1, out _));
Assert.True(poses.TryGetPartPose(82u, 2, out Matrix4x4 third));
Assert.Equal(3f, third.Translation.X);
}
[Fact]
public void AnimationHookQueue_DrainsAgainstPosePublishedAfterCapture()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(9u, Vector3.Zero);
poses.Publish(entity, Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
var sink = new RecordingSink();
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
entity.SetPosition(new Vector3(4, 5, 6));
poses.UpdateRoot(entity);
queue.Drain();
Assert.Single(sink.Calls);
Assert.Equal(new Vector3(4, 5, 6), sink.Calls[0].Position);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_DeleteAndLocalIdReuse_DropsOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity first = Entity(9u, Vector3.Zero);
poses.Publish(first, Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
var sink = new RecordingSink();
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
queue.Capture(9u, sequencer, new AnimationHook[] { new SoundHook() });
Assert.True(poses.Remove(9u));
WorldEntity replacement = Entity(9u, new Vector3(40f, 50f, 60f));
poses.Publish(replacement, Array.Empty<Matrix4x4>());
queue.Drain();
Assert.Empty(sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationDoneReuseDuringCapture_DropsOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
var sink = new RecordingSink();
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
};
queue.Capture(
9u,
sequencer,
new AnimationHook[]
{
new AnimationDoneHook(),
new SoundHook(),
});
queue.Drain();
Assert.Empty(sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void MultipleAnimationDoneHooks_StopAdvancingDisplacedSequencerAfterReuse()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var queue = new AnimationHookFrameQueue(
new AnimationHookRouter(),
poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
sequencer.Manager.AddToQueue(0x10000002u, ticks: 1u);
int completions = 0;
sequencer.MotionDoneTarget = (_, success) =>
{
Assert.True(success);
completions++;
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
};
queue.Capture(
9u,
sequencer,
new AnimationHook[]
{
new AnimationDoneHook(),
new AnimationDoneHook(),
});
Assert.Equal(1, completions);
Assert.Single(sequencer.Manager.PendingAnimations);
queue.Drain();
Assert.Equal(0, queue.Count);
}
[Fact]
public void HookSinkReuseDuringDrain_StopsRemainingOldIncarnationHooks()
{
var poses = new EntityEffectPoseRegistry();
poses.Publish(Entity(9u, Vector3.Zero), Array.Empty<Matrix4x4>());
var router = new AnimationHookRouter();
bool replaced = false;
var sink = new CallbackSink(() =>
{
if (replaced)
return;
replaced = true;
Assert.True(poses.Remove(9u));
poses.Publish(
Entity(9u, new Vector3(40f, 50f, 60f)),
Array.Empty<Matrix4x4>());
});
router.Register(sink);
var queue = new AnimationHookFrameQueue(router, poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
queue.Capture(
9u,
sequencer,
new AnimationHook[] { new SoundHook(), new SoundHook() });
queue.Drain();
Assert.Equal(1, sink.Calls);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
{
var poses = new EntityEffectPoseRegistry();
var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 1u);
queue.Capture(
11u,
sequencer,
new AnimationHook[] { new AnimationDoneHook() });
Assert.Empty(sequencer.Manager.PendingAnimations);
queue.Drain();
Assert.Empty(sequencer.Manager.PendingAnimations);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_DeferredDrainDoesNotOwnPartArrayUseTime()
{
var poses = new EntityEffectPoseRegistry();
var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 0u);
queue.Capture(12u, sequencer, Array.Empty<AnimationHook>());
queue.Drain();
Assert.Single(sequencer.Manager.PendingAnimations);
}
[Fact]
public void Publish_UsesOutdoorEffectCellWithoutChangingRenderParent()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(12u, Vector3.Zero);
entity.ParentCellId = null;
entity.EffectCellId = 0xA9B4000Bu;
poses.Publish(entity, Array.Empty<Matrix4x4>());
Assert.True(poses.TryGetCellId(entity.Id, out uint cellId));
Assert.Equal(0xA9B4000Bu, cellId);
}
[Fact]
public void ChangeNotifications_SuppressEquivalentStaticPublishes()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(13u, new Vector3(1, 2, 3));
var changed = new List<uint>();
poses.EffectPoseChanged += changed.Add;
Matrix4x4 part = Matrix4x4.CreateTranslation(0, 0, 1);
poses.Publish(entity, new[] { part });
poses.Publish(entity, new[] { part });
Assert.True(poses.UpdateRoot(entity));
Assert.Equal(new[] { 13u }, changed);
entity.SetPosition(new Vector3(4, 5, 6));
Assert.True(poses.UpdateRoot(entity));
Assert.Equal(new[] { 13u, 13u }, changed);
poses.Publish(entity, new[] { Matrix4x4.CreateTranslation(0, 0, 2) });
Assert.Equal(new[] { 13u, 13u, 13u }, changed);
Assert.True(poses.Remove(entity.Id));
Assert.Equal(new[] { 13u, 13u, 13u, 13u }, changed);
}
private static WorldEntity Entity(uint id, Vector3 position) => new()
{
Id = id,
ServerGuid = id,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = 0x01010001u,
};
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint Id, Vector3 Position)> Calls { get; } = new();
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) =>
Calls.Add((entityId, entityWorldPosition));
}
private sealed class CallbackSink(Action callback) : IAnimationHookSink
{
public int Calls { get; private set; }
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
Calls++;
callback();
}
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint animationId) => null;
}
}